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 by16ef377: - 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user