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
+500
View File
@@ -0,0 +1,500 @@
import CryptoKit
import Foundation
import Synchronization
// MARK: - EchoLedger
/// **What the app wrote, so a landing reload can tell its own echo from someone else's edit**
/// 02-architecture.md Components EchoLedger, built here in base ahead of Pro's committer
/// (ruled 2026-07-29).
///
/// ### The one thing it is not
///
/// It never feeds the render path. 02's layering rule is absolute "the snapshot is only ever
/// built from disk, never from memory of what the app meant to write" and provenance is the
/// separate, *downstream* concern this type is: the loader has already produced the snapshot before
/// a single receipt is consulted, and consulting them cannot change it. `EchoLedgerTests` pins that
/// with a poisoned ledger and an unchanged snapshot, because it is the invariant the whole one-way
/// flow rests on.
///
/// ### Receipts
///
/// "Every BoardWriter operation drops a receipt of its expected on-disk outcome before returning:
/// path content hash for writes , an absence marker for deletes, an oldnew pair for folder
/// moves; a newer app write to the same path supersedes the receipt." The drops live inside
/// `BoardWriter`, at the four primitives that actually touch disk (`atomicReplace`, the folder move,
/// the removal, the attachment copy), so no *call site* does bookkeeping and a new Writer operation
/// built from those primitives is covered the day it is written. `BoardStore.performWrite` is what
/// makes them reachable: it binds this store's ledger to `EchoLedger.current` for the bracket's
/// duration, so a Writer static stateless, `Sendable`, owned by nobody can drop a receipt into
/// the ledger of the board it is writing without being handed one.
///
/// **Bracketed operations are deliberately outside that seam.** `performWholesale` does not bind a
/// ledger: 02 says bracketed operations "don't consult it (they commit themselves and announce once
/// at completion)", so receipts from inside a bracket would be litter nothing would ever consume.
///
/// ### Classification
///
/// "Classification runs per observed changed file in a debounce window: current on-disk content
/// matches the receipt **app-mediated**, receipt consumed; no receipt, or mismatch **foreign**."
/// `verdicts(from:to:diff:includingTrash:)` is that rule applied to the unit a reload can actually
/// observe see its own note on why *files* are reached through *items*.
///
/// **Final content decides the races**, which is 02's settled answer to both of them: an agent
/// writing byte-identical bytes over a fresh app write matches the hash and classifies app-mediated
/// (with identical bytes the misattribution is unobservable in the tree, accepted), and a foreign
/// edit landing on an app-written path inside the same window misses the hash and classifies foreign
/// (last writer wins the file).
///
/// ### In-memory, per-store, dies with the session
///
/// A `let` on `BoardStore`, with no persistence and no recovery: "losing it costs attribution and
/// nothing else", and everything a lost ledger cannot vouch for classifies foreign *the app never
/// vouches for changes it didn't witness*. That is also why an unsatisfied receipt is **not**
/// consumed: keeping it makes the next observation of that item foreign too, which is the
/// conservative direction, and a receipt that is later satisfied again is exactly the
/// byte-identical race the design already accepts.
public final class EchoLedger: Sendable {
// MARK: - Vocabulary
/// What one observed change turned out to be.
public enum Provenance: Sendable, Equatable {
/// The app wrote this, and disk still agrees with what it wrote.
case appMediated
/// Nobody vouched for it no receipt, or a receipt disk no longer matches.
case foreign
}
/// One completed write's expected on-disk outcome, keyed by the path it describes.
///
/// Three cases and not four, because a folder move is **one** fact filed under two keys: the
/// pair is stored at both endpoints so whichever end a reload observes finds it, and consuming
/// either end retires both. Splitting it into a departure and an arrival would let half of a
/// move be consumed and the other half linger forever.
public enum Receipt: Sendable, Equatable {
/// A file write: these are the bytes that landed.
case content(hash: String)
/// A delete: nothing should be here.
case absence
/// A folder move: what used to be at `from` is at `to`.
case move(from: String, to: String)
}
/// What the landing reload saw at a path.
///
/// `.present` is the honest middle case rather than a missing one: the snapshot **names** a
/// card's attachments but never reads their bytes, so an attachment receipt can only ever be
/// checked for arrival and checking it that way costs no I/O on the reload seam, which is the
/// whole reason classification is a function of two snapshots and nothing else.
public enum Observation: Sendable, Equatable {
case content(hash: String)
case present
case absent
}
/// The ledger the Writer drops receipts into for the duration of one `performWrite` bracket.
///
/// A task-local rather than a parameter on forty Writer signatures, and rather than a property
/// on `BoardWriter` (which is a stateless `enum` of statics and must stay one 02 Components:
/// "No hidden state"). The binding is dynamically scoped to exactly the bracket, which is
/// exactly the lifetime "a receipt describes a *completed* write, and the ledger lives beside
/// the Writer, not in it" asks for. `nil` a Writer call outside any bracket, a test, a write
/// into some *other* board's tree (Duplicate, template instantiation) records nothing, which
/// is what those paths want: there is no session whose echoes they are.
@TaskLocal public static var current: EchoLedger?
private let receipts = Mutex<[String: Receipt]>([:])
public init() {}
// MARK: - Hashing
/// SHA-256, hex. The hash's only job is equality, and a cryptographic digest is the one kind
/// that can be compared without a second thought about adversarial or accidental collisions in
/// a file the user's agent is also writing.
public static func hash(of data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
/// The text form, which is the one every `index.md` write uses: `BoardWriter.atomicReplace`
/// emits `Data(text.utf8)`, so hashing the same text hashes the same bytes.
public static func hash(of text: String) -> String {
hash(of: Data(text.utf8))
}
/// A path in the one spelling the ledger keys on. `standardizedFileURL` and not
/// `resolvingSymlinksInPath`: the latter touches the filesystem, and both the Writer's URLs and
/// the loader's derive from the same `BoardStore.rootURL`, so there is nothing to resolve.
static func key(_ url: URL) -> String {
url.standardizedFileURL.path
}
// MARK: - Recording
/// A file write the receipt for the bytes that just landed.
public func recordWrite(at url: URL, text: String) {
recordWrite(atPath: Self.key(url), hash: Self.hash(of: text))
}
/// A file write whose bytes are already in hand.
public func recordWrite(at url: URL, data: Data) {
recordWrite(atPath: Self.key(url), hash: Self.hash(of: data))
}
/// The path-and-hash form **supersession** lives here, as a plain overwrite: "a newer app
/// write to the same path supersedes the receipt", because only the final content decides.
public func recordWrite(atPath path: String, hash: String) {
receipts.withLock { $0[path] = .content(hash: hash) }
}
/// An attachment import "attachment imports hash during the copy (the bytes stream through
/// the app anyway)".
///
/// **A judgment call, recorded**: they do not, quite. `BoardWriter.importAttachments` hands the
/// copy to `FileManager.copyItem`, which never brings the bytes into this process, so the hash
/// is taken from the landed file the instant the copy returns memory-mapped, so a large
/// attachment costs a digest pass rather than a resident copy. A file that cannot be read back
/// records **nothing**: a receipt whose hash is a guess is worse than no receipt, and no receipt
/// means the import classifies foreign, which is the safe direction.
public func recordImport(at url: URL) {
guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { return }
recordWrite(at: url, data: data)
}
/// A delete the absence marker.
///
/// Everything the ledger held *below* the path goes with it: a folder that is gone cannot have
/// an `index.md` whose hash still means anything, and leaving those receipts behind would make
/// the next thing to appear at that path inherit them.
public func recordDeletion(at url: URL) {
recordDeletion(atPath: Self.key(url))
}
public func recordDeletion(atPath path: String) {
receipts.withLock { store in
Self.forget(&store, under: path)
store[path] = .absence
}
}
/// A folder move the oldnew pair, filed at both ends (see `Receipt.move`).
///
/// Receipts *below* the old path are rebased onto the new one rather than dropped: the bytes did
/// not change, only where they are, and an `index.md` receipt from earlier in the same bracket
/// is still the truth about the file that just travelled.
public func recordMove(from source: URL, to destination: URL) {
recordMove(fromPath: Self.key(source), toPath: Self.key(destination))
}
public func recordMove(fromPath source: String, toPath destination: String) {
receipts.withLock { store in
for path in Array(store.keys) where path.hasPrefix(source + "/") {
store[destination + path.dropFirst(source.count)] = store.removeValue(forKey: path)
}
let pair = Receipt.move(from: source, to: destination)
store[source] = pair
store[destination] = pair
}
}
private static func forget(_ store: inout [String: Receipt], under path: String) {
let prefix = path + "/"
for key in Array(store.keys) where key.hasPrefix(prefix) {
store.removeValue(forKey: key)
}
}
// MARK: - Reading (tests, and the per-item footprint)
/// How many receipts are outstanding the supersession and consumption rules made assertable.
public var outstandingReceipts: Int {
receipts.withLock { $0.count }
}
public func receipt(at url: URL) -> Receipt? {
receipt(atPath: Self.key(url))
}
public func receipt(atPath path: String) -> Receipt? {
receipts.withLock { $0[path] }
}
/// Every path the ledger holds a receipt for strictly *below* `folder`.
///
/// Only a **card** may ask this. A lane's subtree is its cards' business and the board root's is
/// the whole board's, so a prefix sweep at either level would consume receipts belonging to
/// items that were never classified see `Footprint.observations`.
func receiptPaths(under folder: String) -> [String] {
let prefix = folder + "/"
return receipts.withLock { Array($0.keys.filter { $0.hasPrefix(prefix) }) }
}
// MARK: - Classification
/// The per-file rule, over one item's footprint: **every receipt the ledger holds for these
/// paths must still match what the reload observed there**, and there must be at least one.
///
/// - No receipt anywhere in the footprint `.foreign`. That is the launch-catch-up doctrine and
/// the reconciling reload's whole story: files changed during a blind window carry no
/// receipts, and the app never vouches for changes it didn't witness.
/// - All held receipts satisfied `.appMediated`, and they are **consumed** one write, one
/// echo, and a second reload observing the same item finds nothing vouching for it.
/// - Any held receipt unsatisfied `.foreign`, and nothing is consumed (see the type's note).
///
/// Paths in `observations` that the ledger knows nothing about are simply not consulted: a
/// pasted card's copied attachments have no receipts of their own, and demanding one for every
/// byte under an item would make every copy foreign.
public func classify(_ observations: [String: Observation]) -> Provenance {
let held: [(path: String, receipt: Receipt)] = receipts.withLock { store in
observations.keys.compactMap { path in store[path].map { (path, $0) } }
}
guard !held.isEmpty else { return .foreign }
for entry in held {
guard let observation = observations[entry.path],
entry.receipt.isSatisfied(at: entry.path, by: observation)
else { return .foreign }
}
receipts.withLock { store in
for entry in held {
store.removeValue(forKey: entry.path)
// A move is one fact under two keys; retiring one end retires the other.
if case let .move(from, to) = entry.receipt {
store.removeValue(forKey: from)
store.removeValue(forKey: to)
}
}
}
return .appMediated
}
}
// MARK: - Satisfaction
extension EchoLedger.Receipt {
/// Whether what the reload saw at `path` is what this receipt predicted.
///
/// `path` is a parameter because a move's answer depends on which end is being asked: the old
/// path must be empty and the new one must not.
func isSatisfied(at path: String, by observation: EchoLedger.Observation) -> Bool {
switch self {
case let .content(hash):
switch observation {
case let .content(actual):
// **Final content decides.** Byte-identical is app-mediated however it got there;
// one byte different is foreign however it started.
return actual == hash
case .present:
// Bytes the reload never read (an attachment): arrival is the whole of what this
// receipt can be checked against, and a foreign rewrite of an attachment's *bytes*
// cannot be what made the card's rendered content change anyway.
return true
case .absent:
return false
}
case .absence:
return observation == .absent
case let .move(from, to):
if path == to { return observation != .absent }
if path == from { return observation == .absent }
return false
}
}
}
// MARK: - The reload's verdicts
/// One landing reload's provenance answers, in the two shapes the announcer needs.
public struct EchoVerdicts: Sendable, Equatable {
/// The snapshot diff narrowed to the changes **the ledger does not vouch for** the digest's
/// only input (10-accessibility.md Live board announcements: "app-mediated echoes never
/// announce", on every reload origin).
public var foreign = BoardDiff()
/// Every identity whose observed change classified foreign, buckets or not. Wider than
/// `foreign`'s four buckets on purpose: an item whose `modified` stamp an agent bumped changes
/// no rendered field and lands in no bucket, but it is still a foreign change and still what
/// makes the digest's bare "Board changed" backstop honest.
public var foreignItems: Set<ItemID> = []
public init() {}
}
extension EchoLedger {
/// **Which of this reload's changes were the app's own** the classifier the announcer runs on
/// every origin.
///
/// ### Files are reached through items, and why that is the honest mapping
///
/// FSEvents is directory-granular and the watcher deliberately reports only *that* the tree
/// changed (02-architecture.md), so "the observed changed files" is not something a reload is
/// handed it is something the two snapshots identify. An item whose folder, whose `index.md`
/// bytes, or whose attachment listing differs between them is precisely a changed file (or set
/// of them) that this reload observed, and the item is the unit every consumer downstream the
/// digest, the vanishing-focus sentence, pro-m1's commit message actually speaks in.
///
/// The bytes come from the snapshot rather than from a second read of the disk:
/// `FrontmatterDocument` owns a byte-identical round trip, so `document.serialized()` **is** the
/// file the walk read, and hashing it compares the receipt against the content that produced the
/// snapshot now landing. That keeps the whole classification a pure function of two values no
/// I/O on the reload seam, and no window in which the file could change between the walk and the
/// verdict.
///
/// ### Implied events are the container's, here as in the digest
///
/// A card that arrived with a brand-new lane, or left with a deleted one, is not classified at
/// all: `BoardDiff` already counts that as the lane's single event, and classifying the cards
/// separately would let an app-mediated lane delete (whose receipts were swept with the folder)
/// produce a board full of foreign-looking children.
///
/// - Parameter includingTrash: the digest's universe (`BoardDiff.between(_:_:includingTrash:)`).
/// Trash items are not classified while the column is hidden, for the same reason they are not
/// diffed: they are not in the universe at all, so their churn cannot make the board speak.
public func verdicts(
from old: BoardModel,
to new: BoardModel,
diff: BoardDiff,
includingTrash: Bool
) -> EchoVerdicts {
let older = Self.footprints(of: old, includingTrash: includingTrash)
let newer = Self.footprints(of: new, includingTrash: includingTrash)
var verdicts = EchoVerdicts()
for (id, footprint) in newer {
if let was = older[id], was == footprint { continue }
if older[id] == nil, footprint.arrivalIsImplied(by: diff) { continue }
if classify(footprint.observations(present: true, in: self)) == .foreign {
verdicts.foreignItems.insert(id)
}
}
for (id, footprint) in older where newer[id] == nil {
if footprint.departureIsImplied(by: diff) { continue }
if classify(footprint.observations(present: false, in: self)) == .foreign {
verdicts.foreignItems.insert(id)
}
}
// The board root's own `index.md`, whose footprint is that one file: the lanes below it are
// items in their own right and the `.trash/` beside it is theirs too, so a prefix sweep here
// would consume the whole board's receipts on a board rename.
var boardRootIsForeign = false
if old.document != new.document {
boardRootIsForeign = classify([
Self.key(new.rootURL): .present,
Self.key(new.rootURL.appendingPathComponent(BoardLoader.indexFileName)):
.content(hash: Self.hash(of: new.document.serialized()))
]) == .foreign
}
verdicts.foreign.cards = Self.narrow(diff.cards, to: verdicts.foreignItems)
verdicts.foreign.lanes = Self.narrow(diff.lanes, to: verdicts.foreignItems)
// **The backstop, rebuilt rather than copied.** `BoardDiff.boardChanged` is "anything at all
// differs", which on a mixed reload would be true because of the app's own write. What it
// has to mean here is "some foreign change happened that no bucket names", and the honest
// reading of that is: any foreign-classified item at all. A reload the ledger vouches for
// end to end leaves it `false`, which is the silence the ruling is about.
verdicts.foreign.boardChanged = boardRootIsForeign || !verdicts.foreignItems.isEmpty
return verdicts
}
private static func narrow(_ changes: BoardDiff.Changes, to foreign: Set<ItemID>) -> BoardDiff.Changes {
var narrowed = BoardDiff.Changes()
narrowed.added = changes.added.intersection(foreign)
narrowed.edited = changes.edited.intersection(foreign)
narrowed.moved = changes.moved.intersection(foreign)
narrowed.deleted = changes.deleted.intersection(foreign)
return narrowed
}
// MARK: Footprints
/// One item's files, as the snapshot describes them.
///
/// `document` rather than its serialization because the *comparison* runs over every item on
/// every reload and `FrontmatterDocument` is already `Equatable`; the bytes are produced only
/// for the handful of items that turned out to have changed.
struct Footprint: Equatable {
var folder: String
var document: FrontmatterDocument
var attachments: [String]
/// The lane this card sits in, for the implied-events rule. `nil` for a lane and for a
/// trash card, neither of which has a container that can arrive or depart.
var homeLane: ItemID?
/// Whether the item's subtree is its own true for a card, whose folder holds only its
/// `index.md` and its `attachments/`, false for a lane.
var ownsItsSubtree: Bool
func arrivalIsImplied(by diff: BoardDiff) -> Bool {
guard let homeLane else { return false }
return diff.lanes.added.contains(homeLane)
}
func departureIsImplied(by diff: BoardDiff) -> Bool {
guard let homeLane else { return false }
return diff.lanes.deleted.contains(homeLane)
}
/// What the reload observed at every path this item can hold a receipt for.
///
/// - The folder itself: present, or gone.
/// - Its `index.md`: the hash of the bytes the walk read, or gone.
/// - A card's remaining receipts, resolved by the snapshot's attachment listing a name the
/// card still lists is `.present`, anything else (a removed attachment, a loose file that
/// was relocated out) is `.absent`.
func observations(present: Bool, in ledger: EchoLedger) -> [String: EchoLedger.Observation] {
var observations: [String: EchoLedger.Observation] = [
folder: present ? .present : .absent,
folder + "/" + BoardLoader.indexFileName:
present ? .content(hash: EchoLedger.hash(of: document.serialized())) : .absent
]
guard ownsItsSubtree else { return observations }
let attachmentPrefix = folder + "/" + BoardWriter.attachmentsFolderName + "/"
for path in ledger.receiptPaths(under: folder) where observations[path] == nil {
let name = path.hasPrefix(attachmentPrefix) ? String(path.dropFirst(attachmentPrefix.count)) : nil
observations[path] = present && name.map(attachments.contains) == true ? .present : .absent
}
return observations
}
}
/// Every item the digest's universe can see, keyed by identity the same walk
/// `BoardDiff.cardIndex` does, carrying files instead of rendered fields.
static func footprints(of snapshot: BoardModel, includingTrash: Bool) -> [ItemID: Footprint] {
var footprints: [ItemID: Footprint] = [:]
let root = snapshot.rootURL
for lane in snapshot.lanes {
let laneFolder = ItemPath.lane(lane.id).folder(under: root)
footprints[lane.id] = Footprint(
folder: key(laneFolder),
document: lane.document,
attachments: [],
homeLane: nil,
ownsItsSubtree: false
)
for card in lane.cards {
footprints[card.id] = Footprint(
folder: key(ItemPath.card(lane: lane.id, id: card.id).folder(under: root)),
document: card.document,
attachments: card.attachments,
homeLane: lane.id,
ownsItsSubtree: true
)
}
}
guard includingTrash else { return footprints }
for card in snapshot.trash {
footprints[card.id] = Footprint(
folder: key(ItemPath.trashCard(card.id).folder(under: root)),
document: card.document,
attachments: card.attachments,
homeLane: nil,
ownsItsSubtree: true
)
}
return footprints
}
}