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:
@@ -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 old→new 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,
|
||||
|
||||
Reference in New Issue
Block a user