Realign code with the 2026-07-31 findings-resolution rulings

The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 07:43:45 -04:00
parent 16ef3779e8
commit 274ccd9ff5
75 changed files with 5619 additions and 791 deletions
+228 -9
View File
@@ -134,6 +134,45 @@ public final class GitAutoCommitter {
@ObservationIgnored
public var currentSnapshot: (@MainActor () -> BoardModel?)?
/// **The reload pipeline settling** `BoardStore.awaitQuiescence()`, and `nil` on a storeless
/// committer.
///
/// Read only by `awaitCoveringSnapshot()`, whose whole correctness rests on it: it is what makes
/// the *next* walk a walk that started after this flush's changes were on disk.
@ObservationIgnored
public var awaitReloadQuiescence: (@MainActor () async -> Void)?
/// **Which generation the board `currentSnapshot` answers with is at**
/// `BoardStore.snapshotGeneration`, incremented by every landed reload.
///
/// `nil` the closure absent, or answering `nil` because the store has gone means there is no
/// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless
/// committer.
@ObservationIgnored
public var snapshotGeneration: (@MainActor () -> Int?)?
/// **How long an explicit flush waits for its covering reload** before composing from the snapshot
/// it already has.
///
/// A bound rather than an open-ended wait, and recorded as a judgment call: 06 rules that the
/// flush awaits its covering snapshot and does not say what happens if that reload never lands. It
/// normally lands within the watcher's ~200 ms debounce, and it is *scheduled unconditionally* by
/// the write bracket that closed (`FolderWatcher.endBracket`, "the mandatory single post-bracket
/// reload even if not one filesystem event was seen"), so the wait is short and certain in every
/// ordinary case. What it must not be is unbounded: this flush runs on the close and quit paths,
/// and a board whose watcher stream failed to start (`BoardStoreRegistry.acquire` logs and carries
/// on) would otherwise make the app unquittable. So the wait ends, generously, and the commit is
/// composed from the snapshot in hand one stale subject in a degraded configuration, against a
/// hang.
@ObservationIgnored
public var coveringSnapshotDeadline: Duration = .seconds(1)
/// How often the wait re-reads the generation. Polled rather than signalled for
/// `CloseFlushCoordinator.drainCardWindows`' reason: the point of this wait is that it *ends*, and
/// a continuation resumed by a reload that never lands has no way to.
@ObservationIgnored
public var coveringSnapshotPollInterval: Duration = .milliseconds(10)
/// **A genuine commit failure** disk full, repo corruption (06: "files stay safe on disk but
/// history stops advancing; surfaced per 02-architecture.md Write-failure surfacing, retried
/// on the next debounce"). Wired to `BannerCenter.suspendHistory(reason:)`.
@@ -208,6 +247,23 @@ public final class GitAutoCommitter {
@ObservationIgnored
private var holdsForeignChanges = false
/// **Whether an app write has closed with no reload landed since** the covering await's entry
/// gate (`awaitCoveringSnapshot()`).
///
/// Set at every write-bracket close and cleared by every landing, so it answers exactly "is
/// `currentSnapshot` known to be behind the tree". Without it an explicit flush on a quiet board
/// would wait out the whole deadline for a reload nothing has any reason to schedule.
///
/// **The one corner it does not cover, recorded rather than discovered**: a reload that was
/// already *in flight* when the write bracket closed walked the pre-write tree, and its landing
/// clears this flag all the same the store's landing signal carries no such distinction
/// (`HistoryCommitSeam.reloadDidLand`). A flush inside that gap composes from a snapshot one walk
/// behind, which is the pre-ruling behaviour for a window narrower than it used to be: the write
/// bracket's own mandatory post-bracket reload is already scheduled and lands ~200 ms later, and
/// closing the gap properly needs a fact only `BoardStore` has (whether a walk was running).
@ObservationIgnored
private var holdsUncoveredWrites = false
/// Open **card-window sessions**, each answering with the folder to stage around *right now*.
///
/// A closure per session rather than a stored URL, because a card can move lane, or into the
@@ -222,6 +278,10 @@ public final class GitAutoCommitter {
@ObservationIgnored
private var isFlushing = false
/// Explicit flushes suspended behind the one in flight, resumed together by `endFlushing()`.
@ObservationIgnored
private var flushWaiters: [CheckedContinuation<Void, Never>] = []
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
init(boardRoot: URL, ledger: EchoLedger) {
@@ -259,6 +319,9 @@ public final class GitAutoCommitter {
/// this is the only moment at which the committer can still see them (`HarvestedReceipt`).
public func noteWriteBracketClosed() {
harvest()
// The snapshot the composer diffs is now known to be behind the tree until a reload lands
// see `holdsUncoveredWrites` and `awaitCoveringSnapshot()`.
holdsUncoveredWrites = true
arm()
}
@@ -269,6 +332,7 @@ public final class GitAutoCommitter {
/// *file* at flush time, because this is one bit about a whole reload.
public func noteReloadLanded(sawForeignChange: Bool) {
if sawForeignChange { holdsForeignChanges = true }
holdsUncoveredWrites = false
arm()
}
@@ -307,7 +371,7 @@ public final class GitAutoCommitter {
public func noteWillWrite() {
guard holdsForeignChanges, !isFlushing, let input = makeInput() else { return }
isFlushing = true
defer { isFlushing = false }
defer { endFlushing() }
pending?.cancel()
pending = nil
// One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means
@@ -398,9 +462,110 @@ public final class GitAutoCommitter {
/// store tears down". `CloseFlushCoordinator.committerFlush` is this, and by the time it runs the
/// sessions have ended, so nothing is staged around any more.
public func flushNow() async {
await awaitCoveringSnapshot()
// **Queued behind an in-flight flush, never skipped** see `awaitFlushInFlight()`.
await awaitFlushInFlight()
await flush()
}
/// **Suspends until no flush is running** what makes `flushNow()` a promise rather than an
/// attempt (06-history-undo.md Rules Auto-commit: "nothing settled is ever left unsaved or
/// uncommitted by closing").
///
/// ### The bug this exists for
///
/// `flush()` skips when one is already running, which is exactly right for the **debounce** a
/// timer firing into a commit already in progress has nothing to add, and coalescing is the
/// cadence rule. It was catastrophically wrong for the **explicit** flush, which is the close
/// flush, the quit flush, the branch switch's pre-checkout flush and File Duplicate's: those
/// callers are not asking for a commit *soon*, they are asking to be told when the pipeline is
/// empty, and a `return` gave them that answer while it was still full.
///
/// It was reachable, and by a *narrow* margin in one direction and a wide one in the other. The
/// close sequence releases each session's stage-around and then nudges the committer
/// (`endCardSession`), which arms a fresh debounce; `CloseFlushCoordinator` then spends up to its
/// card-drain deadline before reaching `committerFlush`. With the two intervals both at two
/// seconds the debounce fired *into* the drain's last moments about half the time and the flush
/// it started had, in the worst case, planned its commit while the session's folder was still
/// staged around. So the in-flight flush committed nothing of the session, the close flush skipped
/// behind it, and teardown stopped the committer: the window's whole session was left uncommitted,
/// permanently, with no later flush anywhere that could have picked it up. Even in the benign
/// interleaving `closeBoard` returned and at quit, `applicationShouldTerminate` replied while
/// the commit was still detached work in flight.
///
/// ### The shape
///
/// A queue of waiters rather than a lock, `BoardStore.awaitQuiescence()`'s own shape and for its
/// reason: this type is `@MainActor`, so there is no data race to exclude only a *suspension* to
/// wait out and the thing a caller wants is "tell me when it is over", which is what a resumed
/// continuation is. The loop re-checks rather than trusting one resumption, so a flush that armed
/// another on its way out cannot slip between the resume and the caller's own attempt.
private func awaitFlushInFlight() async {
while isFlushing {
await withCheckedContinuation { flushWaiters.append($0) }
}
}
/// Ends one flush and releases whoever was queued behind it. The single exit for both flushing
/// paths the debounced one and the synchronous flush-before-overwrite so a waiter can never be
/// left suspended by a path that forgot it.
private func endFlushing() {
isFlushing = false
let waiters = flushWaiters
flushWaiters.removeAll()
for waiter in waiters { waiter.resume() }
}
/// **The flush awaits the snapshot that covers it** (06-history-undo.md Rules Auto-commit,
/// ruled 2026-07-31).
///
/// > "The composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot
/// > generation covering its changed paths before the committer runs the commit's subject can
/// > never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the
/// > practical cushion, never the guarantee."
///
/// ### What "covering its changed paths" means to this store
///
/// A reload is a **whole tree walk** the store has no changed-path channel at all
/// (02-architecture.md; `BoardStore.refreshCommentIndex`'s own note) so a walk that *started*
/// after this flush's writes were on disk covers every path they touched, by construction. There
/// is nothing narrower to ask for and nothing narrower to wait on, and that is what makes the
/// generation counter a sufficient answer rather than an approximation of one.
///
/// Two steps, in this order, are what turn it into a guarantee:
///
/// 1. **Quiesce.** A walk already in flight may have started *before* the writes, so its landing
/// proves nothing. `BoardStore.awaitQuiescence()` returns when none is running and none is
/// owed, which is the moment after which every walk is a walk that started later.
/// 2. **Wait for one generation.** The write bracket that produced these changes already
/// scheduled the reload that will supply it unconditionally, whether or not FSEvents said
/// anything (`FolderWatcher.endBracket`) so this is a bounded wait on work already in the
/// pipeline, not a hope.
///
/// ### Why only the explicit flush
///
/// This is `flushNow()`'s alone: the close and quit paths, the branch switch's pre-checkout flush,
/// File Duplicate's pending-work step, and the undo restore's. Those are the flushes that run
/// *because* something just finished, which is exactly when the snapshot can still be one walk
/// behind. The debounced flush is re-armed by both the write and the reload and fires two seconds
/// after the later of them 06's own "practical cushion", doing the job it is enough for and
/// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite.
private func awaitCoveringSnapshot() async {
guard holdsUncoveredWrites, let read = snapshotGeneration else { return }
await awaitReloadQuiescence?()
// Re-read the gate: the quiescence may itself have been the covering landing.
guard holdsUncoveredWrites, let base = read() else { return }
let started = ContinuousClock.now
while let current = read(), current == base {
guard ContinuousClock.now - started < coveringSnapshotDeadline else {
Self.logger.notice("the covering reload did not land in time; composing from the snapshot in hand")
return
}
try? await Task.sleep(for: coveringSnapshotPollInterval)
}
}
/// Arms (or re-arms) the debounce. Every signal funnels through here, so "debounced past drag and
/// typing churn" is one timer rather than a rule each call site remembers.
private func arm(after interval: Duration? = nil) {
@@ -414,10 +579,13 @@ public final class GitAutoCommitter {
}
}
/// One flush. **Skipping when one is already running is the debounce's rule and only the
/// debounce's** an explicit `flushNow()` has already waited its turn (`awaitFlushInFlight()`)
/// before it gets here, so this guard can only ever coalesce a timer.
private func flush() async {
guard !isFlushing else { return }
isFlushing = true
defer { isFlushing = false }
defer { endFlushing() }
pending?.cancel()
pending = nil
@@ -512,6 +680,7 @@ public final class GitAutoCommitter {
var previous: BoardModel?
var current: BoardModel?
var agentGuideText: String?
var commentTimestamps: [String: Date] = [:]
}
/// Reads the two snapshots and the guide's bytes the only impure step in the message path, kept
@@ -537,6 +706,12 @@ public final class GitAutoCommitter {
// card's title. So a comment-only window loads the current board and skips the materialization.
let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) }
let namesACard = changed.contains { CommentPath.classify($0.path) != nil }
// **The chronology the bullets sort by** (06 Rules Auto-commit, blessed 2026-07-31) the
// one field of a comment the composer needs and the board snapshot cannot carry. Read beside
// the guide's bytes, for the guide's reason, and only for a window that names a comment at all.
if namesACard {
composition.commentTimestamps = commentTimestamps(for: changed, boardRoot: input.boardRoot)
}
guard touchesModel || namesACard else { return composition }
// **The store's snapshot when there is one, disk when there is not.** A storeless committer is
@@ -550,6 +725,42 @@ public final class GitAutoCommitter {
return composition
}
/// **When each comment this window touched was created**, keyed by its folder the chronology
/// `CommitMessageEngine` sorts a commit's comment bullets by (06 Rules Auto-commit, blessed
/// 2026-07-31: "by the comments' own `created`, folder name on ties").
///
/// One `index.md` per touched comment folder, read off the **working tree** which is the state
/// this commit is about to stage, and the only place a comment's own fields exist at all. A folder
/// this window *removed* (the close purge) has nothing left to read, and a comment whose
/// frontmatter does not parse or carries no `created` answers nothing either: all three are
/// absent from the map and sort after their dated siblings, which is `CommentThread.sorted`'s own
/// fallback for the same field. Nothing here is a defect and nothing is reported a commit
/// message is the wrong place to discover one (`CommentThread.searchableBodies`' rule, kept).
///
/// Internal rather than private so the composer's own suite can resolve the chronology exactly the
/// way a flush does, instead of hand-assembling a map the flush could never produce
/// (`WriterFixture.snapshot()`'s reason, restated one field down).
nonisolated static func commentTimestamps(
for changed: [GitChangedPath],
boardRoot: URL
) -> [String: Date] {
var timestamps: [String: Date] = [:]
var seen: Set<String> = []
for path in changed {
guard let folder = CommitMessageEngine.commentFolder(of: path.path), seen.insert(folder).inserted
else { continue }
let index = boardRoot
.appendingPathComponent(folder)
.appendingPathComponent(IntegrityRules.indexFileName)
guard let data = try? Data(contentsOf: index),
let document = try? BoardLoader.parseDocument(data, path: folder),
let created = document.created.value
else { continue }
timestamps[folder] = created
}
return timestamps
}
/// The three-way split turned into commits or, on an unborn HEAD, the one commit 06 fixes.
private nonisolated static func plan(
_ changed: [GitChangedPath],
@@ -571,7 +782,8 @@ public final class GitAutoCommitter {
isRootCommit: isRootCommit,
snapshot: composition.current,
previousSnapshot: composition.previous,
agentGuideText: composition.agentGuideText
agentGuideText: composition.agentGuideText,
commentTimestamps: composition.commentTimestamps
)
}
@@ -600,16 +812,23 @@ public final class GitAutoCommitter {
authorship = .foreign(
CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot)
)
// **A heal is authored by the user**, recorded as a judgment call: DESIGN fixes that a
// heal's paths commit *separately* and says nothing about who they are by. The healer is
// the app acting on the user's behalf its writes are app-mediated, receipt and all so
// authoring them as the user is the honest reading, and authoring them as `Lanework
// External` would blame the outside world for the app's own repair.
// **A heal is authored `Lanework Integrity <integrity@lanework.invalid>`** (06 Commit
// messages Healing mutations commit separately, ruled 2026-07-31): "a heal is a third
// origin not the user's gesture, not a foreign writer and the separation exists for
// audit, so the trail filters by author like every origin". This authored heals as the
// *user* until that ruling, which left the separate commit filterable only by message
// shape and the shape vocabulary deliberately never says "healed".
case .heal: authorship = .heal
case .user: authorship = .user
}
// The committer stays the user throughout 06's recorded-by convention, which is why
// only the author varies here.
let author: GitIdentity
if case let .foreign(identity) = authorship { author = identity } else { author = user }
switch authorship {
case let .foreign(identity): author = identity
case .heal: author = CommitAttribution.integrityIdentity
case .user: author = user
}
let kind: PlannedCommitKind
switch group.kind {
case .foreign: kind = .foreign