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