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