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
+102 -15
View File
@@ -812,6 +812,15 @@ public final class AppModel {
git.activateAutoCommit { [weak store] committer in
guard let store else { return }
committer.currentSnapshot = { [weak store] in store?.snapshot }
// **The flush awaits the snapshot that covers it** (06 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". Both halves of that
// await are reads of the store the composer is already diffing, which is why they are
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
committer.snapshotGeneration = { [weak store] in store?.snapshotGeneration }
// 02-architecture.md Write-failure surfacing, through the strip the board window
// already renders: a genuine commit failure means "your edits are saved, history has
// stopped advancing", which is exactly what the standing suspension row says. Lock
@@ -839,6 +848,14 @@ public final class AppModel {
git.didAddGit = { [weak self] in
self?.bindHistoryProvider(for: ref)
}
// **The form-anchored posture's fallback half** (06 Interaction with external writers,
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead
// when that form has been dismissed before the answer arrived "inline is the primary
// surface, never a silence trap". The banner enumeration is the same one branch switch and
// undo restore post into, one row per failure.
git.reportFailure = { [weak store] failure in
store?.banners.postGitFailure(.addGit, reason: failure.message)
}
}
// **The binding 13-native-undo.md Rules' "registration at the Writer boundary" needs**: the
// store is that boundary every app-mediated mutation goes out through one of its write
@@ -900,17 +917,16 @@ public final class AppModel {
self?.resumeCardSessionStaging(for: ref)
}
// **A restore that failed cleanly** (06 Interaction with external writers: "surfaces as a
// one-shot banner failure naming the operation and the error, the tree left as it was").
// one-shot banner failure naming the operation and the error, the tree left as it was")
// now literally that, at the failure rank in the error tone (02 The banner surface, settled
// 2026-07-31: the one-shot class's second, message-carrying shape). The loss-row compromise
// this line used to carry is retired: a Z that didn't happen is an action that didn't
// happen, not content that didn't arrive.
//
// Posted as a **loss row**, and the compromise is recorded rather than hidden: the true
// failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed
// `WriteOperation` vocabulary and a git operation is deliberately not one of those
// (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest
// class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that
// can name both halves 06 asks for. A message-carrying failure class is the right fix and is
// a banner-surface change, not this card's.
provider.reportFailure = { [weak store] failure in
store?.banners.postLoss(failure.description)
// The closure passes the *direction* and libgit2's own message and stops there "Undo
// failed " is BannerCenter's sentence, from the closed `GitOperation` vocabulary.
provider.reportFailure = { [weak store] direction, failure in
store?.banners.postGitFailure(.restore(direction), reason: failure.message)
}
provider.runBracketed = { [weak store] subject, work in
guard let store else { return await work() }
@@ -978,11 +994,16 @@ public final class AppModel {
store?.banners.updateOperation(id, label: label)
}
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
// The loss row, on `GitHistoryProvider.reportFailure`'s recorded compromise see it for why
// a git failure cannot be a `OneShotBanner` today.
// The failure rank's git shape, as on the restore path above: a switch that didn't happen is
// an action that didn't happen ("Couldn't switch branches ", BannerCenter's words from
// the operation alone).
switcher.reportFailure = { [weak store] failure in
store?.banners.postLoss(failure.description)
store?.banners.postGitFailure(.branchSwitch, reason: failure.message)
}
// **The recovery notice stays a loss row**, and the ruling is explicit about why (02 The
// banner surface): "recovery notices report a success, not a failure, and stay warning-tone".
// "A branch switch was interrupted the previous state is restored" is the app tidying up
// after itself, with nothing for the user to do the loss class's own register.
switcher.reportRecovery = { [weak store] message in
store?.banners.postLoss(message)
}
@@ -1009,7 +1030,16 @@ public final class AppModel {
let outcome = await gate.settleAll()
// The switch's flush runs next and must find a tree it can settle whole see
// `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough.
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
//
// **And the fine undo stacks go with it** (06 Branch switching, ruled 2026-07-31): the
// same `.proceed`, the same seam, for the same reason one rung up what a window is
// holding describes the branch being left. "Cancel keeps the current branch and the
// sessions" is this `if`, unchanged: a cancelled or failed settle clears nothing, exactly
// as it releases nothing.
if outcome == .proceed {
self.releaseCardSessionStaging(for: ref)
self.discardCardWindowUndoStacks(for: ref)
}
return outcome
}
@@ -1019,6 +1049,23 @@ public final class AppModel {
Task { await switcher.recoverInterruptedOperation() }
}
/// **The three buttons, as a seam** `SessionSettleStep.ask(message:)` in production.
///
/// `SessionSettleGate` already keeps the presentation behind a closure for its own reason
/// ("presenting three buttons is AppKit's job and cannot be asserted without a display the
/// presentation is a seam and the decision is testable"), and every gate this model builds pointed
/// that closure straight at the alert so the *composition* around the gate, which is what
/// `releaseCardSessionStaging(for:)` and `discardCardWindowUndoStacks(for:)` hang off, could only
/// be exercised by a board with nothing to settle. Lifting the ask one level up is what lets a test
/// answer Save All, Discard and Cancel over real card windows without a modal on screen.
///
/// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam
/// rather than API: production never assigns it.
@ObservationIgnored
var settleAsk: @MainActor (String) async -> SessionSettleChoice = {
await SessionSettleStep.ask(message: $0)
}
/// **The save-or-discard step for one board**, built from its open card windows
/// (06-history-undo.md Rules Undo restore vs open Edit sessions; Branch switching).
///
@@ -1055,7 +1102,10 @@ public final class AppModel {
)
}
},
ask: { await SessionSettleStep.ask(message: message) },
ask: { [weak self] in
guard let self else { return await SessionSettleStep.ask(message: message) }
return await self.settleAsk(message)
},
focus: { [weak self] id in
guard let self, let session = self.sessions[ref] else { return }
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
@@ -1212,6 +1262,43 @@ public final class AppModel {
}
}
/// **The branch switch's settle empties every open card window's fine undo stack**
/// (06-history-undo.md Branch switching, ruled 2026-07-31).
///
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
/// > the branch being left Save All and Discard alike end with every window's stack empty, the
/// > board-stack discard-and-reseed precedent one level down; the windows stay open, following
/// > their cards onto the new branch with fresh stacks."
///
/// ### Why here, beside the staging release
///
/// Because it is the same fact about the same moment. `releaseCardSessionStaging(for:)` lets go of
/// what a window is holding *on disk*; this lets go of what it is holding *in memory*, and both
/// are true of a session whose branch is about to be replaced under it. Running them from one
/// `.proceed` is also what makes "Cancel clears nothing" a property of one `if` rather than a rule
/// two call sites have to keep in step (`wireBranchSwitching`).
///
/// **The board stack is not touched**, and it is not an omission: the switch discards and reseeds
/// it from the new HEAD's first-parent ancestry a few steps later, inside the bracket
/// (`GitBranchSwitcher.reseedUndo` `GitHistoryProvider.reseed`). Doing it here would be the same
/// discard, one level up, at the wrong moment before the checkout that decides what to reseed
/// *from*.
///
/// **The restore path deliberately does not call this.** An undo restore materializes a diff and
/// leaves the branch where it is, so a window's steps still describe the branch they were made on;
/// what protects them there is 13-native-undo.md's field-level staleness predicate, which is a
/// per-step question rather than a wholesale one.
///
/// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close
/// flush's* seam end the session, say whether it holds unsaved content, offer the settle's two
/// writes and a fine undo stack is none of those things. The one type that has one is the card
/// window's own session, which is what every registration passes.
func discardCardWindowUndoStacks(for ref: BoardWindowRef) {
for cardRef in sessions[ref]?.cardRefs ?? [] {
(cardSessions[cardRef] as? CardWindowSession)?.undo.discardSteps()
}
}
/// **The next session begins** the other half of `releaseCardSessionStaging(for:)`, run when the
/// operation behind the settle has finished with the tree.
///
+9
View File
@@ -428,6 +428,15 @@ enum TemplateEngine {
// The lane list is the loader's own level detection `directoryCandidates` (hidden
// entries and symlinks already out) narrowed by the identity predicate so a stray
// folder at board level is never descended into here either.
// **The seed, before the normalization it governs** (01-storage-format.md § Fractal
// layout Rules and 06-history-undo.md Repository hygiene, ruled 2026-07-31: every
// board carries a `.gitignore`, and an instantiated board is a board created today). A
// template that carries one of its own copies through and is honored the seed writes
// only into a free name which is 09-templates.md's "strays copy through" doing the
// right thing by itself; a template written before the ruling gains the seed here rather
// than a reload later, so the new board's very first walk has its noise gate.
try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: root)
let lanes = ((try? BoardLoader.directoryCandidates(in: root)) ?? [])
.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
for lane in lanes {