Build the EchoLedger - per-file write provenance for announcements

User-ruled 2026-07-29: the ledger builds now in base, pre-release
(DESIGN/02 - Components - EchoLedger; DESIGN/10 - Live board
announcements). Receipts drop inside BoardWriter's four disk primitives
(atomic replace, folder move, removal, attachment copy) into a @TaskLocal
ledger that BoardStore.performWrite binds for the bracket's duration -
no call-site bookkeeping, and performWholesale deliberately binds
nothing per 02's bracket exemption. Classification is a pure function of
two snapshots: an item whose folder, index.md bytes, or attachment
listing differs is an observed change; disk matching the receipt is
app-mediated (receipt consumed), no receipt or mismatch is foreign.
Byte-identical foreign overwrites classify app-mediated (unobservable,
accepted); a foreign edit over a fresh app write classifies foreign.

The announcer now consumes per-file facts on every reload origin - the
WatchOrigin gate is gone (ReloadFacts.origin removed outright; nothing
read it after the gate fell). Reconciling sweeps announce their
receipt-less findings as foreign, closing both interim holes
(debounce-window absorption, reconcile silence). The vanishing-focus
rung gates on the ledger too: "deleted externally" would be a lie about
an app-mediated delete, and the subject's own verdict decides.

Divergence flagged: attachment imports hash the landed file right after
FileManager.copyItem rather than during the copy (the bytes do not
stream through the app); an unreadable read-back records nothing, the
direction that biases toward foreign.

30 ledger tests added, announcer suite reworked to the ruling. 1638
green on both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 12:14:47 -04:00
parent 28ca2c3f50
commit 5880838e66
8 changed files with 1280 additions and 98 deletions
+95 -41
View File
@@ -320,44 +320,39 @@ struct BoardAnnouncerSpeechTests {
BoardLoadError(path: "Todo/index.md", reason: .missingOrder)
}
// MARK: Origins
// MARK: Provenance
@Test("A foreign reload speaks its digest — the design's own example sentence")
@Test("A reload carrying foreign changes speaks its digest — the design's own example sentence")
func foreignReloadSpeaks() {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.diff = loudDiff()
#expect(BoardAnnouncer.speech(for: facts) == "Board changed: 2 cards edited, 1 card added")
}
@Test("App-mediated echoes never announce — the user's own action is not news")
func appMediatedIsSilent() {
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
/// **The 2026-07-29 ruling, made structural.** Rungs 4 and 5 used to be gated on
/// `origin == .foreign`; the gate is gone and so is the field it read, so nothing about the
/// *kind* of reload can reach this type. Silence is the `EchoLedger`'s doing upstream an echo
/// it vouched for end to end arrives here with an empty diff and a reconciling sweep over a
/// blind window arrives with a full one, because receipt-less files classify foreign.
/// `BoardAnnouncerStoreTests` is where both halves are exercised against a real ledger.
@Test("Silence comes from the ledger's narrowing, never from the reload's kind")
func silenceIsTheLedgersDoing() {
var facts = BoardAnnouncer.ReloadFacts()
#expect(BoardAnnouncer.speech(for: facts) == nil, "an echo the ledger vouched for arrives empty")
facts.diff = loudDiff()
#expect(BoardAnnouncer.speech(for: facts) == nil)
}
@Test("A reconciling sweep is silent — it claims nothing changed")
func reconcilingIsSilent() {
var facts = BoardAnnouncer.ReloadFacts(origin: .reconciling)
facts.diff = loudDiff()
#expect(BoardAnnouncer.speech(for: facts) == nil)
}
@Test("A foreign reload that changed nothing says nothing")
func quietForeignReload() {
let facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
#expect(BoardAnnouncer.speech(for: facts) == nil)
#expect(
BoardAnnouncer.speech(for: facts) == "Board changed: 2 cards edited, 1 card added",
"the very same reload, with something foreign left in it, speaks"
)
}
// MARK: Specific beats generic
@Test("The vanishing-focus sentence displaces the digest — one reload, one sentence")
func vanishingFocusBeatsTheDigest() {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.diff = loudDiff()
facts.vanishedFocus = .card(title: "Fix login")
@@ -368,7 +363,7 @@ struct BoardAnnouncerSpeechTests {
@Test("A bracketed operation announces its completion, never its churn")
func bracketAnnouncesCompletion() {
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
var facts = BoardAnnouncer.ReloadFacts()
facts.endsBracketedOperation = true
facts.completion = "Pulled 3 commits"
facts.diff = loudDiff()
@@ -379,7 +374,7 @@ struct BoardAnnouncerSpeechTests {
/// Every base-edition bracket today. The seam exists; pro-m1 supplies the phrases.
@Test("A bracket with no phrase to say stays silent rather than falling back to the digest")
func bracketWithoutAPhrase() {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.endsBracketedOperation = true
facts.diff = loudDiff()
facts.vanishedFocus = .card(title: "Fix login")
@@ -391,7 +386,7 @@ struct BoardAnnouncerSpeechTests {
@Test("A raised read-only lock outranks everything else the reload could say")
func raisedLockWins() {
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
var facts = BoardAnnouncer.ReloadFacts()
facts.endsBracketedOperation = true
facts.completion = "Pulled 3 commits"
facts.lockAfter = .bracketedReloadFailed
@@ -404,7 +399,7 @@ struct BoardAnnouncerSpeechTests {
@Test("The banner's announcement is the banner's own label — one condition, one sentence")
func announcementMatchesTheRowLabel() {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.lockAfter = .vanishedRoot
#expect(
@@ -415,7 +410,7 @@ struct BoardAnnouncerSpeechTests {
@Test("A lock that was already standing is not repeated on every reload")
func standingLockIsNotRepeated() {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.lockBefore = .vanishedRoot
facts.lockAfter = .vanishedRoot
facts.diff = { var diff = BoardDiff(); diff.boardChanged = true; return diff }()
@@ -425,7 +420,7 @@ struct BoardAnnouncerSpeechTests {
@Test("A lock whose cause changed is news again")
func changedLockCauseSpeaksAgain() {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.lockBefore = .unwritableLocation
facts.lockAfter = .vanishedRoot
@@ -437,7 +432,7 @@ struct BoardAnnouncerSpeechTests {
@Test("Reload breakage announces on arrival")
func raisedBreakage() {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.breakageAfter = breakage()
#expect(
@@ -448,7 +443,7 @@ struct BoardAnnouncerSpeechTests {
@Test("A cleared lock is announced — the banner speaks when it clears, not only when it appears")
func clearedLock() {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.lockBefore = .bracketedReloadFailed
facts.diff = loudDiff()
@@ -457,7 +452,7 @@ struct BoardAnnouncerSpeechTests {
@Test("Cleared breakage is announced, below the lock when both heal at once")
func clearedBreakage() {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.breakageBefore = breakage()
#expect(BoardAnnouncer.speech(for: facts) == "The board is loading again")
@@ -470,7 +465,7 @@ struct BoardAnnouncerSpeechTests {
/// one sentence the reload has.
@Test("A completion phrase outranks the clearance it implies")
func completionOutranksClearance() {
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
var facts = BoardAnnouncer.ReloadFacts()
facts.endsBracketedOperation = true
facts.completion = "Switched to branch 'redesign'"
facts.lockBefore = .bracketedReloadFailed
@@ -541,19 +536,40 @@ struct BoardAnnouncerStoreTests {
}
/// The app's own delete already chose its successor (04-interactions.md The map); a recovery
/// firing on the echo would override it. 02-architecture.md's silent-vanish rule is what an
/// app-mediated reload still gets.
@Test("An app-mediated echo leaves the emptied selection exactly as the set rule left it")
/// firing on the echo would override it. **What buys that silence is the receipt, not the
/// origin** (ruled 2026-07-29): the delete goes through the store, so the card's folder carries
/// a move pair the reload matches, and the vanishing classifies app-mediated.
@Test("The app's own delete keeps the successor its command chose — the reload never re-aims it")
func appMediatedDoesNotRecover() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1ID], in: .board)
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
store.delete([card1ID])
let afterTheGesture = store.transient.selection.ids
#expect(afterTheGesture == [card2ID], "⌫ takes the successor sibling")
await reload(store, .appMediated)
#expect(store.transient.selection.isEmpty)
#expect(store.transient.selection.ids == afterTheGesture)
#expect(store.transient.selection.ids != [lane1ID], "the lane recovery is the foreign case's move, not this one's")
}
/// The same gesture made by somebody else: no receipt, so the vanishing is foreign, the sentence
/// is spoken and focus recovers the pair that shows the ledger is what tells them apart.
@Test("The identical delete made externally does recover, and says so")
func foreignDeleteRecoversAndSpeaks() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.select([card1ID], in: .board)
let log = listen(to: store)
try fixture.move("\(lane1)/\(card1)", toTrash: card1)
await reload(store, .appMediated)
#expect(log.lines == ["Card 'Fix login' was deleted externally"])
#expect(store.transient.selection.ids == [lane1ID])
}
@Test("A reload that leaves the selection standing never re-aims it")
@@ -640,16 +656,54 @@ struct BoardAnnouncerStoreTests {
#expect(log.lines == ["Board changed: 2 cards edited"])
}
@Test("An app-mediated echo says nothing at all")
/// **"App-mediated echoes never announce", now grounded in the ledger rather than in the
/// reload's label**: the write goes through `performWrite`, the receipt matches the bytes the
/// walk read back, and the lane's edit is narrowed out of the digest before the announcer sees
/// it. The width assertion is there so the silence is never silence about a no-op.
@Test("An app-mediated echo says nothing at all — every file it touched carries a matching receipt")
func appMediatedEchoIsSilent() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let log = listen(to: store)
try fixture.card(card1, in: lane1, order: "1024", title: "Renamed")
store.setLaneWidth(lane1ID, units: 3)
await reload(store, .appMediated)
#expect(store.snapshot.lanes.first?.width.value == 3, "the write really landed")
#expect(log.lines.isEmpty)
}
/// **The ruling itself** (10-accessibility.md Live board announcements, 2026-07-29): "a
/// reconciling reload that reveals external changes is never silent". Files changed during a
/// blind window carry no receipts, so they classify foreign and announce the launch-catch-up
/// doctrine applied to speech. The old behaviour this replaces was a blanket
/// `origin == .foreign` gate that swallowed exactly this case.
@Test("A reconciling sweep announces what the blind window hid")
func reconcilingSweepAnnouncesItsFindings() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let log = listen(to: store)
try fixture.card(card1, in: lane1, order: "1024", title: "Edited while we slept")
await reload(store, .reconciling)
#expect(log.lines == ["Board changed: 1 card edited"])
}
/// The other half of the same ruling: a reconciling sweep that finds the tree exactly as the app
/// left it is still quiet. Reconciliation is not itself news the *findings* are.
@Test("A reconciling sweep over the app's own work is still silent")
func reconcilingSweepOverAnEchoIsSilent() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let log = listen(to: store)
store.setLaneWidth(lane1ID, units: 3)
await reload(store, .reconciling)
#expect(log.lines.isEmpty)
}