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 old→new 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? /// One receipt plus its attributes. Split from `Receipt` so the public vocabulary stays the /// three outcomes it always was and an attribute can be added without every reader learning a /// new shape. private struct Entry: Sendable, Equatable { var receipt: Receipt /// **Whether the write that dropped this receipt was a heal** (06-history-undo.md ▸ Commit /// messages, ruled 2026-07-29: "the Writer's heal operations drop heal-marked receipts in /// the EchoLedger — attribution machinery like the author split, never message tagging"). /// /// **Inert in base beyond the ledger itself.** Nothing here reads it and nothing renders it; /// it is the flag pro-m1's committer reads to split a heal's paths into their own commit, /// and it is stored rather than derived because by commit time the only thing that still /// knows a path was healed is the receipt. var isHeal: Bool = false } private let receipts = Mutex<[String: Entry]>([:]) 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) { // The heal mark is **not** carried over: supersession replaces the whole receipt, and a // later ordinary write to the same path is exactly the case where the path stops being the // heal's alone. `markHeal(at:)` is called after the write it describes, never before. receipts.withLock { $0[path] = Entry(receipt: .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] = Entry(receipt: .absence) } } /// A folder move — the old→new 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 = Entry(receipt: .move(from: source, to: destination)) store[source] = pair store[destination] = pair } } /// **Marks the receipt at `path` as a heal** — the Writer's heal operations call this on the /// paths they touched, after the bytes land (06-history-undo.md ▸ Commit messages, ruled /// 2026-07-29). /// /// A no-op where there is no receipt (a Writer call outside any bracket, a test): a mark with no /// receipt to attach to would describe nothing. It never *creates* a receipt for the same reason /// — the receipt is the record of a completed write, and this only ever adds an attribute to /// one that already exists. /// /// Both ends of a move pair are one fact filed under two keys, so marking either marks both. public func markHeal(at url: URL) { markHeal(atPath: Self.key(url)) } public func markHeal(atPath path: String) { receipts.withLock { store in guard var entry = store[path] else { return } entry.isHeal = true store[path] = entry if case let .move(from, to) = entry.receipt { let other = path == from ? to : from if var twin = store[other] { twin.isHeal = true store[other] = twin } } } } private static func forget(_ store: inout [String: Entry], 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]?.receipt } } /// Whether the receipt at this path is heal-marked — `false` for a path with no receipt at all, /// which is the same shrug every other read here gives an unknown path. public func isHeal(at url: URL) -> Bool { isHeal(atPath: Self.key(url)) } public func isHeal(atPath path: String) -> Bool { receipts.withLock { $0[path]?.isHeal ?? false } } /// 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.receipt) } } } 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 = [] 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) -> 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 } }