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
+97 -28
View File
@@ -309,6 +309,21 @@ public final class BoardStore {
/// this store's own state and are composed in at render time by `bannerRows`.
public let banners = BannerCenter()
/// **This board's write-provenance ledger** (02-architecture.md Components EchoLedger):
/// what the app wrote, so a landing reload can tell its own echo from someone else's edit.
///
/// **In-memory, per-store, dies with the session** a `let` beside `transient` and `banners`,
/// for the same reason all three are: closing the board is the reset, and "losing it costs
/// attribution and nothing else". Owned rather than injected because there is exactly one
/// answer to "which ledger is this board's", and a second one would be a second provenance.
///
/// Its consumers today are the announcer's digest and the vanishing-focus sentence, both
/// through `land`. Pro's auto-committer (06-history-undo.md) becomes the second one without
/// this line changing which is why the type lives in `LiveStore/` beside `BoardDiff` rather
/// than in `KanbanPro/`.
@ObservationIgnored
public let echoes = EchoLedger()
/// The rows the board window's strip renders, in precedence order.
///
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
@@ -644,7 +659,7 @@ public final class BoardStore {
// appears and when it clears", and appearing and clearing are transitions, not states. Read
// here rather than at each mutation below so there is one before-picture for the whole
// landing, whichever branch it takes.
var facts = BoardAnnouncer.ReloadFacts(origin: origin)
var facts = BoardAnnouncer.ReloadFacts()
facts.endsBracketedOperation = endsWholesaleOperation
facts.completion = completion
facts.lockBefore = readOnlyLock
@@ -652,37 +667,52 @@ public final class BoardStore {
switch outcome {
case let .success(result):
// **What changed, and what it cost the cursor** both computed against the *outgoing*
// snapshot, so they have to be taken before the assignment below replaces it. Both are
// pure functions of two value types; nothing here decides whether anyone is told.
// **What changed, who changed it, and what it cost the cursor** all three computed
// against the *outgoing* snapshot, so they have to be taken before the assignment below
// replaces it. All three are functions of two value types; nothing here reads disk and
// nothing here decides whether anyone is told.
//
// Asked only of a `.foreign` reload that is not closing a bracket, which is the only
// reload either answer is used by: 10-accessibility.md gives the app's own echoes
// silence, gives a bracket one sentence at completion rather than a description of its
// churn, and phrases the vanishing-focus case as "deleted *externally*" a sentence that
// would be a lie about an app-mediated delete, whose own command already chose a
// successor (04-interactions.md The map's rule) and must not have it overridden here.
// Skipping the comparison on the other origins keeps the ordinary echo's landing exactly
// as cheap as it was.
// **Asked on every origin, reconciling included** (10-accessibility.md Live board
// announcements, ruled 2026-07-29): what buys silence is the *ledger*, not the reload's
// label. An app-mediated echo is silent because its files carry receipts that still
// match; a reconciling sweep over a blind window is not, because the files it reveals
// carry none "the app never vouches for changes it didn't witness". The one exemption
// is the bracket, which 10 gives a single sentence at completion rather than a
// description of its churn, and which 02 keeps out of the ledger entirely.
let focus: BoardAnnouncer.FocusOutcome
if origin == .foreign, !endsWholesaleOperation {
if !endsWholesaleOperation {
// **The digest covers the trash only while the trash lane is shown**
// (10-accessibility.md Live board announcements, ruled 2026-07-29). This is the
// seam that reading takes: visibility is view state on the board's own transient
// container one per store, shared by every window onto this board
// (`TransientBoardState.isTrashVisible`) so the store asks it here and the
// summarizer stays a pure function of two snapshots plus one fact.
facts.diff = BoardDiff.between(
snapshot,
result.model,
includingTrash: transient.isTrashVisible
// (`TransientBoardState.isTrashVisible`) so the store asks it here and both the
// summarizer and the classifier stay pure functions of two snapshots plus one fact.
let shownTrash = transient.isTrashVisible
let diff = BoardDiff.between(snapshot, result.model, includingTrash: shownTrash)
let verdicts = echoes.verdicts(
from: snapshot,
to: result.model,
diff: diff,
includingTrash: shownTrash
)
focus = BoardAnnouncer.focusOutcome(
facts.diff = verdicts.foreign
// The vanishing-focus sentence takes the same gate, one rung up the ladder: it says
// "deleted *externally*", which would be a lie about an app-mediated delete whose
// own command already chose a successor (04-interactions.md The map's rule) and
// must not have it overridden here. So a vanishing the ledger vouches for is no
// vanishing at all as far as speech and focus are concerned.
let outcome = BoardAnnouncer.focusOutcome(
old: snapshot,
new: result.model,
selection: transient.selection,
focused: focusedItem
)
focus = Self.vanishingIsForeign(
focused: focusedItem,
old: snapshot,
new: result.model,
foreignItems: verdicts.foreignItems
) ? outcome : .survived
} else {
focus = .survived
}
@@ -810,6 +840,37 @@ public final class BoardStore {
}
}
/// **Whether the hole under the cursor was somebody else's doing** the gate that used to be
/// `origin == .foreign`, now asked of the ledger per file (10-accessibility.md, ruled
/// 2026-07-29).
///
/// It mirrors `BoardAnnouncer.focusOutcome`'s own choice of subject rather than second-guessing
/// it, because the two must agree about *what vanished*: when the lane went, the lane is the
/// subject and the lane's classification is the one that decides a card swept away with an
/// app-mediated lane delete carries no receipt of its own (the removal took the whole subtree's
/// receipts with it), and letting the child's verdict speak would announce the user's own
/// gesture back at them.
///
/// `false` for a focus that was never on the board's old side: there is nothing to classify, and
/// `focusOutcome` already answers `.survived` there.
nonisolated static func vanishingIsForeign(
focused: ItemID?,
old: BoardModel,
new: BoardModel,
foreignItems: Set<ItemID>
) -> Bool {
guard let focused else { return false }
if old.lanes.contains(where: { $0.id == focused }) {
return foreignItems.contains(focused)
}
guard let home = old.lanes.first(where: { lane in lane.cards.contains { $0.id == focused } })
else { return false }
if new.lanes.contains(where: { $0.id == home.id }) {
return foreignItems.contains(focused)
}
return foreignItems.contains(home.id)
}
/// **The cursor**, as 10-accessibility.md's announcements mean it: the navigation head when it
/// is still in the selection, else a sole selected item, else nothing.
///
@@ -924,7 +985,7 @@ public final class BoardStore {
/// exactly once, in one place, from the banner's own headline: a lock the user hears described
/// one way and reads another is two locks as far as they can tell.
private func announceLockChange(from before: ReadOnlyLockReason?) {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.lockBefore = before
facts.lockAfter = readOnlyLock
announce(BoardAnnouncer.speech(for: facts))
@@ -966,13 +1027,21 @@ public final class BoardStore {
// `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
// and an unbalanced bracket would leave the watcher suspended for the rest of the session.
defer { watcherBrackets?.end() }
// `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error` and
// the Writer's typed error is lost on the way to the banner.
do throws(BoardWriteError) {
return try operation()
} catch {
banners.post(error)
throw error
// **The receipt seam** (02-architecture.md Components EchoLedger). Binding the ledger
// here rather than passing it down is what keeps `BoardWriter` the stateless enum of statics
// the same bullet requires: the Writer's disk primitives drop receipts into whichever
// board's ledger is bound, and the binding is exactly this bracket which is exactly the
// span in which a write is the app's. A Writer call outside one (another board's tree, a
// test) finds no ledger and records nothing.
return try EchoLedger.$current.withValue(echoes) {
// `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error`
// and the Writer's typed error is lost on the way to the banner.
do throws(BoardWriteError) {
return try operation()
} catch {
banners.post(error)
throw error
}
}
}