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
+36 -1
View File
@@ -117,6 +117,11 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not replace file: \(String(cString: strerror(status)))")
)
}
// **The receipt, dropped after the bytes land and before the call returns** (the
// EchoLedger's contract, 02-architecture.md Components). This one line covers every
// `index.md` in the app: `updateIndex` funnels here, and so do create, materialize,
// recreate, the task-marker flip, the body save and the raw-source Apply.
EchoLedger.current?.recordWrite(at: fileURL, text: text)
}
// MARK: - Create
@@ -539,6 +544,9 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not move folder: \(error.localizedDescription)")
)
}
// The oldnew pair. The `updateIndex` below then supersedes the arrived `index.md`'s own
// receipt, which is the truth about the file the reload will read.
EchoLedger.current?.recordMove(from: sourceFolder, to: arrivedRoot)
if isImport {
let children = childCandidates(of: arrivedRoot)
@@ -636,6 +644,9 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not rename folder: \(error.localizedDescription)")
)
}
// A remint is a folder move like any other as far as provenance goes the identity
// changed, so the arriving item is a different item, and the pair says where it came from.
EchoLedger.current?.recordMove(from: folder, to: destination)
}
/// Whether two URLs name the same place on disk symlinks resolved, `..`/`.` standardized
@@ -994,6 +1005,10 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)")
)
}
// A delete is a move into `.trash/` on disk (01-storage-format.md § Deletion), so the
// receipt is the move pair and it reads correctly from either end: the board side sees an
// absence where the card was, the shown-trash side sees an arrival where it went.
EchoLedger.current?.recordMove(from: cardFolder, to: arrived)
try updateIndex(inItemFolder: arrived, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(order))
@@ -1062,6 +1077,9 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
// The absence marker and it takes the lane's cards' receipts with it, which is what makes
// the digest's implied-events rule and the ledger agree that this was one event.
EchoLedger.current?.recordDeletion(at: laneFolder)
}
/// Permanently removes one card from the trash the trash's **Delete / Delete Immediately**
@@ -1094,6 +1112,7 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: cardFolder)
}
/// **Empty Trash** (, 03-board-ui.md § Trash): permanently removes every card in
@@ -1126,6 +1145,7 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: card)
purged.append(ItemID(rawValue: card.lastPathComponent))
}
return purged
@@ -1299,6 +1319,10 @@ public enum BoardWriter: Sendable {
let fileURL = folder.appendingPathComponent(name)
do {
try contents.write(to: fileURL)
// An undo restore recreates whole subtrees byte for byte; the bytes are already
// in hand, so every restored file gets its own receipt rather than only the
// `index.md` at the top.
EchoLedger.current?.recordWrite(at: fileURL, data: contents)
} catch {
throw BoardWriteError(
operation: operation,
@@ -1375,6 +1399,7 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: itemFolder)
}
// MARK: - Undoing a create
@@ -1796,6 +1821,9 @@ public enum BoardWriter: Sendable {
let destinationURL = attachmentsFolder.appendingPathComponent(name)
do {
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
// "Attachment imports hash during the copy" see `EchoLedger.recordImport(at:)`
// for the one place that phrase and `FileManager.copyItem` do not quite meet.
EchoLedger.current?.recordImport(at: destinationURL)
} catch {
try? FileManager.default.removeItem(at: destinationURL)
throw BoardWriteError(
@@ -1879,8 +1907,12 @@ public enum BoardWriter: Sendable {
let operation = WriteOperation.relocateLooseFile(filename: name)
let sourceURL = cardFolder.appendingPathComponent(name)
let landed = freshAttachmentName(for: name, in: attachmentsFolder)
let landedURL = attachmentsFolder.appendingPathComponent(landed)
do {
try FileManager.default.moveItem(at: sourceURL, to: attachmentsFolder.appendingPathComponent(landed))
try FileManager.default.moveItem(at: sourceURL, to: landedURL)
// A move pair, not a write: the bytes were not touched, only their place and the
// pair is what tells the classifier the loose file's disappearance was the app's.
EchoLedger.current?.recordMove(from: sourceURL, to: landedURL)
} catch {
throw BoardWriteError(
operation: operation,
@@ -2078,6 +2110,9 @@ public enum BoardWriter: Sendable {
var trashedURL: NSURL?
do {
try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL)
// Where the file went is the *system* Trash, outside any board so from this board's
// point of view it is an absence, exactly as a purge is.
EchoLedger.current?.recordDeletion(at: fileURL)
} catch {
throw BoardWriteError(
operation: operation,