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
+289 -44
View File
@@ -414,6 +414,17 @@ struct AutoCommitAttributionTests {
#expect(committer.commitCount == 2, "the heal's paths commit separately — the split's third class")
#expect(isClean(at: fixture.root))
// **And it is authored by the third pinned synthetic** (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; the committer stays the user."
let log = try history(at: fixture.root)
let user = GitCommitOperation.userIdentity(at: fixture.root)
#expect(log[0].authorEmail == user.email, "the user's own write stays the user's")
#expect(log[1].authorName == CommitAttribution.integrityAuthorName)
#expect(log[1].authorEmail == CommitAttribution.integrityAuthorEmail)
#expect(log[1].committerName == user.name, "the committer is always the user")
}
@Test("The app's own delete is the user's, not an agent's")
@@ -553,6 +564,70 @@ struct AutoCommitStageAroundTests {
#expect(committer.commitCount == 0)
#expect(committer.lastFailure == nil, "an empty window is a no-op, never a failure")
}
/// **A close flush queues behind an in-flight flush rather than skipping it** (06 Rules
/// Auto-commit: "nothing settled is ever left unsaved or uncommitted by closing").
///
/// The interleaving is the close sequence's own, forced rather than waited for. `endCardSession`
/// releases the stage-around **and arms a fresh debounce**, and `CloseFlushCoordinator` then
/// spends its card-drain deadline before reaching `committerFlush` two intervals that are both
/// two seconds, so in practice the debounce fired into the drain's last moments about half the
/// time. What made that a defect rather than a coin toss is what the debounced flush had already
/// planned: a commit whose exclusion list still held the session's folder. Skipping behind it left
/// the session uncommitted *permanently* teardown stops the committer, and there is no later
/// flush anywhere.
///
/// So the flush in flight here is deliberately one that planned **with** the exclusion, and the
/// release happens while it is still running. Before the fix this test's `flushNow()` returned
/// having done nothing and the card's body stayed dirty forever.
@Test("A flush asked for while one is in flight waits for it, and commits what it was asked to")
func anExplicitFlushIsNeverDroppedBehindAnInFlightOne() async throws {
let (fixture, git, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
// The one point inside a flush that is both off the main actor and injectable: composing.
// It holds the flush open long enough for the close to arrive underneath it.
committer.composer = SlowComposer(delay: 0.4)
let token = UUID()
committer.beginCardSession(token) { fixture.url("\(Ident.lane1)/\(Ident.card1)") }
// The session's uncommitted work, held by the stage-around
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
plain(order: "1024", title: "First", body: "typed and never committed"))
// and a board change beside it, so the debounced flush has something to compose slowly about
// rather than answering `nothingToCommit` before it ever reaches the composer.
try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing"))
// Arm the debounce and let it fire: from here until the composer returns, a flush is in
// flight, and it planned its commit while the session folder was still excluded.
committer.noteReloadLanded(sawForeignChange: true)
try await waitUntil { committer.isCommitInFlight }
// The close sequence, arriving underneath it: the session ends, its folder is released, and
// the coordinator asks for the flush that must not be lost.
committer.endCardSession(token)
await committer.flushNow()
#expect(isClean(at: fixture.root),
"the close flush waited its turn and committed the session it was asked to")
#expect(GitRepository.trackedPaths(at: fixture.root)
.contains("\(Ident.lane1)/\(Ident.card1)/\(BoardLoader.indexFileName)"))
}
}
/// A composer that takes its time, so a test can hold a flush open and drive the close sequence into
/// the gap. Everything else about it is the real one this suite asserts *when* a commit exists, and
/// a fake message would make the commits it reads back unrecognisable.
private struct SlowComposer: CommitMessageComposing {
let delay: TimeInterval
func message(for request: CommitMessageRequest) -> String {
// Blocking, deliberately: this runs on the flush's own detached task, and what the test needs
// held open is that task rather than the actor the close sequence is running on.
Thread.sleep(forTimeInterval: delay)
return CommitMessageEngine.message(for: request)
}
}
// MARK: - Contention, holds, and failure
@@ -823,29 +898,155 @@ struct AutoCommitCompositionTests {
}
}
// MARK: - The Edit-session boundary
// MARK: - The composition root
/// **The wired-at-`beginSession` seams, pinned where they are wired** (02-architecture.md Layering;
/// 12-editions.md The provider seam).
///
/// Every suite above composes its own committer by hand, which is what makes them readable and is
/// exactly why they cannot see the defect this suite exists for: `AppModel.beginSession` once composed
/// the committer *without* the store's `EchoLedger` (`HistoryStore.compose`'s default is a fresh one,
/// for the store-less callers), so every unit layer passed while every production commit misattributed
/// the app's own writes arriving unvouched-for and authored `Lanework External`. It was fixed in
/// `a381fac` by passing `store.echoes`, and nothing but a test that opens a board *through the model*
/// could have caught it or can keep it caught.
///
/// So the assertions here are about the **composition** and never about the units: not "the ledger
/// classifies" (`AutoCommitAttributionTests`) and not "a bracket announces at completion"
/// (`BoardAnnouncerTests`), but that a board opened the way a window opens one has those two wires in
/// it.
@MainActor
@Suite("Auto-commit ▸ the Edit-session boundary")
@Suite("Auto-commit ▸ the composition root")
struct AutoCommitCompositionRootTests {
/// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
/// Support home `AppModelTests`' own fixture, for its reason.
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("AutoCommitCompositionTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let model = AppModel(
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
model.currentTier = { .pro }
return (model, { try? FileManager.default.removeItem(at: folder) })
}
/// Opens a board the way `BoardWindowHost` does record, acquire, flag, begin so what is under
/// test is the real `beginSession` and not a hand-assembled session.
private func openBoard(_ model: AppModel, at url: URL) throws -> AppModel.BoardSession {
let ref = BoardWindowRef(url: url)
let recordID = model.boardRegistry.recordOpen(of: url)
let store = try model.storeRegistry.acquire(url)
model.boardRegistry.setOpenNow(id: recordID)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
return try #require(model.session(for: ref))
}
/// **(a) The committer is composed with the session store's own ledger** the same instance the
/// store's writes drop receipts into (`BoardStore.echoes`).
///
/// Asserted through the one thing the ledger decides: **authorship**. An ordinary app-mediated
/// write through the store, committed by the session's own committer, is authored by this
/// machine's user. Composed with any *other* ledger it would be authored `Lanework External`
/// which is not a hypothetical shape, it is what `AutoCommitAttributionTests`'
/// `foreignIsLaneworkExternal` pins for a write nobody vouched for, and what this board's every
/// commit did before `a381fac`.
@Test("beginSession composes the committer with the store's own EchoLedger")
func theCommitterIsComposedWithTheStoresLedger() async throws {
let (fixture, _, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let session = try openBoard(model, at: fixture.root)
let committer = try #require(session.git?.committer)
// Only the explicit flush commits, and it does not sit out a watcher that a temp directory may
// or may not deliver events for: this test is about *who* the commit is by.
committer.stop()
committer.debounceInterval = .seconds(30)
committer.coveringSnapshotDeadline = .milliseconds(50)
committer.coveringSnapshotPollInterval = .milliseconds(5)
// An ordinary write through the store the Writer boundary, receipt and all. Nothing here
// touches the ledger by hand, which is the whole point: the receipt has to travel from the
// store's own ledger to the committer's, and there is only one way for that to be true.
let outcome = session.store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "By the app.\n")
#expect(outcome == .written)
await committer.flushNow()
let head = try #require(try history(at: fixture.root).first)
#expect(head.authorEmail == GitCommitOperation.userIdentity(at: fixture.root).email)
#expect(
head.authorEmail != CommitAttribution.externalAuthorEmail,
"a committer composed over any other ledger would blame the outside world for this write"
)
}
/// **(b) The announcer outlet is bound** the undo restore's bracket runs through the store's
/// `performWholesale(announcing:)`, so its subject reaches `BoardStore.announce`.
///
/// `GitHistoryProvider.runBracketed` is optional and "`nil` runs the work bare, which is what a
/// repository-level test wants" so an unwired seam is silent rather than broken, and every
/// repository-level suite in this file would keep passing over one. What a session owes it is the
/// store's bracket: the watcher suspension, the reload floor that locks the board if the closing
/// reload fails, and 10-accessibility.md's one sentence at completion.
@Test("beginSession binds the restore's bracket to the board's announcer outlet")
func theRestoreBracketReachesTheAnnouncer() async throws {
let (fixture, _, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let session = try openBoard(model, at: fixture.root)
session.git?.committer?.stop()
let store = session.store
let provider = try #require(session.history as? GitHistoryProvider, "a git board binds the git provider")
var spoken: [String] = []
store.announce = { if let phrase = $0 { spoken.append(phrase) } }
let bracket = try #require(provider.runBracketed, "the restore has a bracket to run inside")
await bracket("Undid 'Add card'") {
try? fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Restored"))
}
// The bracket's closing reload the one it armed, whichever way the operation went.
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
#expect(spoken == ["Undid 'Add card'"], "the trail's sentence and the spoken one are the same one")
#expect(store.readOnlyLock == nil, "the closing reload succeeded, so nothing is locked")
}
}
// MARK: - The Edit-session flag
/// What the card body still owes the commit model after the stage-around widened to the whole window
/// (06 Rules Auto-commit, 2026-07-31): the **flag**, not an announcement.
///
/// `CardBodyEditSession.editSessionDidChange` was the boundary's announcement, and it went with the
/// widening the exclusion now opens with the window and releases when the window's session ends,
/// so nothing in production ever wired it (`CardWindowHost.configureSession`). What survives is
/// `isEditing`, which the close path reads as part of "does this window hold unsaved content".
@MainActor
@Suite("Auto-commit ▸ the Edit-session flag")
struct EditSessionBoundaryTests {
@Test("Entering and leaving Edit announces the session exactly once each way")
func theBoundaryIsAnnouncedOnce() {
@Test("Entering and leaving Edit moves the flag, and a re-assertion of the mode does not")
func theBoundaryMovesTheFlagOnce() {
let session = CardBodyEditSession()
let presentation = CardBodyPresentation()
presentation.beginEdits = { session.beginEditSession() }
presentation.flushEdits = { session.endEditSession() }
var events: [Bool] = []
session.editSessionDidChange = { events.append($0) }
presentation.setMode(.edit)
presentation.setMode(.edit) // a re-published focus value, a menu validation pass
session.beginEditSession() // idempotent
presentation.setMode(.preview)
presentation.setMode(.preview)
#expect(session.isEditing)
#expect(events == [true, false])
presentation.setMode(.preview)
presentation.setMode(.preview)
#expect(!session.isEditing)
}
@@ -859,40 +1060,6 @@ struct EditSessionBoundaryTests {
#expect(presentation.openIfNeeded(body: "") == .edit)
#expect(session.isEditing)
}
@Test("A window closing from Preview announces nothing")
func closingFromPreviewIsSilent() {
let session = CardBodyEditSession()
var events: [Bool] = []
session.editSessionDidChange = { events.append($0) }
// `CardWindowSession.endSession()` calls this on every close, in Edit or not.
session.endEditSession()
#expect(events.isEmpty)
}
@Test("The session's last keystrokes are on disk before the committer is nudged")
func theFlushPrecedesTheNudge() {
let session = CardBodyEditSession()
var landed: [String] = []
var textAtNudge: String?
session.save = { text in
landed.append(text)
return .written
}
session.editSessionDidChange = { isEditing in
if !isEditing { textAtNudge = landed.last }
}
session.beginEditSession()
session.adopt(diskBody: "before")
session.edited("after")
session.endEditSession()
// A nudge that arrived before the flush would arm a commit carrying the file as it stood one
// keystroke ago.
#expect(textAtNudge == "after")
}
}
// MARK: - Semantic messages, through the whole engine
@@ -1038,6 +1205,84 @@ struct AutoCommitMessageTests {
#expect(try headSubject(at: fixture.root) == "Update 'notes.txt'")
}
// MARK: The covering snapshot
/// One card-window session's worth of state, as the close flush meets it: a change on disk that
/// the app vouched for, and a `store.snapshot` that has not caught up yet.
///
/// The board's two store reads are faked rather than driven through a real `BoardStore`, and
/// deliberately: what is being pinned is *the order the flush reads them in*, which a real
/// watcher would settle by racing rather than by rule. `landsAfterReads` is the reload landing
/// the generation asked for the nth time is the walk that finally covers the write.
private func flushRacingItsReload(
awaitsCoverage: Bool,
landsAfterReads: Int = 3
) async throws -> String? {
let (fixture, git, ledger) = try await makeGitBoard()
defer { fixture.tearDown() }
let committer = try quickCommitter(git)
// Only the explicit flush runs: a debounce firing mid-wait would be a second flush answering
// the question this test is asking of the first.
committer.debounceInterval = .seconds(30)
committer.coveringSnapshotPollInterval = .milliseconds(1)
committer.coveringSnapshotDeadline = .milliseconds(500)
// The board as the app last read it one card, which is what HEAD's tree also says.
var current = try fixture.snapshot()
committer.currentSnapshot = { current }
// The session's write lands on disk, vouched for, with no reload behind it yet.
let text = plain(order: "2048", title: "Second")
try fixture.item("\(Ident.lane1)/\(Ident.card2)", text)
ledger.recordWrite(
at: fixture.url("\(Ident.lane1)/\(Ident.card2)").appendingPathComponent(BoardLoader.indexFileName),
text: text
)
committer.noteWriteBracketClosed()
if awaitsCoverage {
var generation = 0
var reads = 0
committer.awaitReloadQuiescence = {}
committer.snapshotGeneration = {
reads += 1
if reads == landsAfterReads {
current = (try? fixture.snapshot()) ?? current
generation += 1
committer.noteReloadLanded(sawForeignChange: false)
}
return generation
}
}
await committer.flushNow()
return try headSubject(at: fixture.root)
}
/// **"The flush awaits the snapshot that covers it"** (06 Rules Auto-commit, ruled
/// 2026-07-31): "the commit's subject can never be outrun by its own reload".
@Test("A close flush racing a stale snapshot composes from the covering one")
func theFlushAwaitsItsCoveringSnapshot() async throws {
#expect(try await flushRacingItsReload(awaitsCoverage: true) == "Add card 'Second'")
}
/// The same race with the store's two reads unwired the storeless configuration, and what the
/// close flush did before the ruling. The commit still lands (the condition is the *tree*), but
/// its subject describes a board that has not heard about the card it is committing.
@Test("Without the await the subject is the one the stale snapshot could compose — the defect, pinned")
func aStaleSnapshotComposesTheShrug() async throws {
#expect(try await flushRacingItsReload(awaitsCoverage: false) == CommitMessageEngine.unnamedSubject)
}
/// The bound is a bound: a board whose watcher stream never came up has no reload to wait for, and
/// the close path may not hang on one. The commit lands from the snapshot in hand.
@Test("A covering reload that never lands ends the wait rather than the app")
func theWaitIsBounded() async throws {
// The generation never moves, so the wait runs to its (millisecond) deadline and composes.
#expect(try await flushRacingItsReload(awaitsCoverage: true, landsAfterReads: .max)
== CommitMessageEngine.unnamedSubject)
}
}
// MARK: - Attribution, as a pure function