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
+28 -23
View File
@@ -34,9 +34,23 @@ import Foundation
/// 4. **A vanished focus** the specific sentence, which beats the generic one (below).
/// 5. **The board digest** the ordinary foreign-change case.
///
/// Rungs 4 and 5 are foreign-only; 1 through 3 are not, because a lock raised by the app's own
/// bracketed operation is exactly the case 10 names ("including the read-only lock after a failed
/// bracketed reload"), and silence there would be the app hiding its own failure.
/// ### Provenance is per file, and it arrives already decided
///
/// **The announcer consumes the ledger's per-file facts on every reload origin, reconciling
/// included** (10-accessibility.md, ruled 2026-07-29). So there is no origin on `ReloadFacts` and no
/// origin test in the ladder: what buys an app-mediated echo its silence is that its files carry
/// receipts the disk still matches (`EchoLedger`), and the reload seam hands this type a diff
/// already narrowed to the changes nobody vouched for. A reconciling sweep over a blind window
/// wake, activation, a missed-events flag reveals files with no receipts at all, so they classify
/// foreign and the sweep speaks: "the app never vouches for changes it didn't witness", applied to
/// speech. Rungs 4 and 5 are therefore *foreign-only by construction* rather than by a gate.
///
/// Rungs 1 through 3 never had one and still do not: **banner transitions are origin-independent**
/// (10, confirmed 2026-07-29) a lock raised by the app's own bracketed operation is exactly the
/// case 10 names ("including the read-only lock after a failed bracketed reload"), and silence there
/// would be the app hiding its own failure. Rung 2 is the one exemption from the ledger in either
/// direction: a bracket announces its result and never its churn, so 02-architecture.md keeps
/// bracketed operations out of the ledger entirely.
public enum BoardAnnouncer {
// MARK: - A vanishing focus
@@ -200,14 +214,6 @@ public enum BoardAnnouncer {
/// state one fact and default the rest.
public struct ReloadFacts: Sendable, Equatable {
/// The reload's provenance the classification 10's first rule is stated in terms of.
///
/// **The merge is lossy and that is accepted** (`WatchOrigin.merged`): a foreign edit landing
/// inside an app-mediated span arrives labeled `.appMediated` and stays silent. `Motion`
/// documents the same blur on the same seam and for the same reason the alternative is
/// splitting deliveries, which `FolderWatcher.schedule` rejects for the coalescing it costs.
public var origin: WatchOrigin
/// Whether this is the reload that closes a bracketed wholesale operation.
public var endsBracketedOperation = false
@@ -216,7 +222,10 @@ public enum BoardAnnouncer {
/// base-edition bracket today (see `BoardStore.performWholesale(announcing:_:)`).
public var completion: String?
/// The snapshot comparison, empty by default so a test about origins need not build one.
/// The snapshot comparison **already narrowed to the foreign-classified changes**
/// (`EchoLedger.verdicts(from:to:diff:includingTrash:)`), empty by default so a test about
/// the ladder need not build one. An app-mediated echo reaches here as an empty diff, which
/// is why the digest rung needs no gate of its own.
public var diff = BoardDiff()
/// What went out from under the cursor, if anything (`focusOutcome(old:new:selection:focused:)`).
@@ -233,9 +242,7 @@ public enum BoardAnnouncer {
public var breakageBefore: BoardLoadError?
public var breakageAfter: BoardLoadError?
public init(origin: WatchOrigin) {
self.origin = origin
}
public init() {}
}
/// The ladder see the type's doc comment for the five rungs and why they are in that order.
@@ -246,14 +253,12 @@ public enum BoardAnnouncer {
if facts.endsBracketedOperation { return facts.completion }
if let cleared = clearedCondition(facts) { return cleared }
// **App-mediated echoes never do.** The user's own action already had its feedback the
// gesture, the animation, the menu it came from and narrating it back is the app talking
// over the user. A reconciling sweep is silent for the neighbouring reason: it makes no claim
// that anything changed (a wake, an activation, a missed-events flag), and a board that
// announced a digest every time the app came forward would be announcing the *absence* of an
// event, the way `Motion` refuses to animate one.
guard facts.origin == .foreign else { return nil }
// **App-mediated echoes never do**, and there is deliberately no test for that here. The
// user's own action already had its feedback the gesture, the animation, the menu it came
// from and narrating it back is the app talking over the user; what enforces it is the
// ledger upstream, which hands this an empty diff and no vanished focus when every file the
// reload observed carries a receipt disk still matches. A reconciling sweep that reveals
// nothing arrives the same way and is silent for the same reason not because of its label.
if let vanished = facts.vanishedFocus {
return AccessibilityPhrases.vanishedFocus(vanished)
}