import Foundation import Testing @testable import Kanban /// The write-provenance ledger — 02-architecture.md ▸ Components ▸ EchoLedger: /// /// > 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. 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. … Feeds attribution and /// > announcements only — never the render path. /// /// Three layers, three suites: the receipts as values, the Writer actually dropping them, and the /// reload seam turning them into silence or speech. // MARK: - Fixtures private let lane1 = Ident.lane1 private let lane2 = Ident.lane2 private let card1 = Ident.card1 private let card2 = Ident.card2 private let lane1ID = ItemID(rawValue: lane1) private let card1ID = ItemID(rawValue: card1) private let card2ID = ItemID(rawValue: card2) /// Two lanes — Todo (2 cards), Doing (empty). private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.board() try fixture.lane(lane1, order: "1024", title: "Todo") try fixture.lane(lane2, order: "2048", title: "Doing") try fixture.card(card1, in: lane1, order: "1024", title: "Fix login") try fixture.card(card2, in: lane1, order: "2048", title: "Second") return fixture } // MARK: - The receipts themselves /// Pure, over synthetic paths: none of these rules needs a filesystem, and stating them without one /// is what makes them readable as the design's own sentences. @Suite("EchoLedger — receipts") struct EchoLedgerReceiptTests { private let index = "/b/lane/card/index.md" private let folder = "/b/lane/card" @Test("A write's receipt is its content hash, at its path") func writeDropsAContentHash() { let ledger = EchoLedger() ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "one")) #expect(ledger.receipt(atPath: index) == .content(hash: EchoLedger.hash(of: "one"))) } /// "A newer app write to the same path supersedes the receipt" — only the final content decides, /// so the intermediate is not kept and cannot be redeemed. @Test("A newer write to the same path supersedes the earlier receipt") func supersession() { let ledger = EchoLedger() ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "one")) ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "two")) #expect(ledger.outstandingReceipts == 1, "one path, one receipt — never a journal") #expect(ledger.classify([index: .content(hash: EchoLedger.hash(of: "one"))]) == .foreign) ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "two")) #expect(ledger.classify([index: .content(hash: EchoLedger.hash(of: "two"))]) == .appMediated) } @Test("A delete drops an absence marker, satisfied by nothing being there") func deleteDropsAnAbsenceMarker() { let ledger = EchoLedger() ledger.recordDeletion(atPath: folder) #expect(ledger.receipt(atPath: folder) == .absence) #expect(ledger.classify([folder: .present]) == .foreign, "something is there — nobody vouched for that") #expect(ledger.classify([folder: .absent]) == .appMediated) } /// A folder that is gone cannot have an `index.md` whose hash still means anything, and leaving /// those receipts behind would let the next thing to appear at that path inherit them. @Test("A delete takes the receipts below it with it") func deleteSweepsItsSubtree() { let ledger = EchoLedger() ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "one")) ledger.recordDeletion(atPath: folder) #expect(ledger.receipt(atPath: index) == nil) #expect(ledger.outstandingReceipts == 1) } /// "An old→new pair for folder moves" — one fact, filed at both ends so whichever end a reload /// observes finds it. @Test("A move files the same pair at both ends") func moveDropsAPair() { let ledger = EchoLedger() ledger.recordMove(fromPath: "/b/lane/card", toPath: "/b/.trash/card") let pair = EchoLedger.Receipt.move(from: "/b/lane/card", to: "/b/.trash/card") #expect(ledger.receipt(atPath: "/b/lane/card") == pair) #expect(ledger.receipt(atPath: "/b/.trash/card") == pair) } @Test("The pair reads correctly from either end — gone here, arrived there") func moveIsSatisfiedFromEitherEnd() { let ledger = EchoLedger() ledger.recordMove(fromPath: "/b/lane/card", toPath: "/b/.trash/card") #expect(ledger.classify(["/b/lane/card": .absent]) == .appMediated, "the board side sees a departure") let other = EchoLedger() other.recordMove(fromPath: "/b/lane/card", toPath: "/b/.trash/card") #expect(other.classify(["/b/.trash/card": .present]) == .appMediated, "the shown-trash side sees an arrival") } @Test("Reading either end of a pair retires both — half a move is never left behind") func consumingOneEndRetiresBoth() { let ledger = EchoLedger() ledger.recordMove(fromPath: "/b/lane/card", toPath: "/b/.trash/card") #expect(ledger.classify(["/b/lane/card": .absent]) == .appMediated) #expect(ledger.outstandingReceipts == 0) } /// The bytes did not change, only where they are — so an `index.md` receipt from earlier in the /// same bracket is still the truth about the file that just travelled. @Test("A move rebases the receipts below it rather than dropping them") func moveRebasesItsSubtree() { let ledger = EchoLedger() ledger.recordWrite(atPath: "/b/lane/card/index.md", hash: EchoLedger.hash(of: "one")) ledger.recordMove(fromPath: "/b/lane/card", toPath: "/b/.trash/card") #expect(ledger.receipt(atPath: "/b/lane/card/index.md") == nil) #expect(ledger.receipt(atPath: "/b/.trash/card/index.md") == .content(hash: EchoLedger.hash(of: "one"))) } // MARK: Classification /// The launch-catch-up doctrine, and the reconciling reload's whole story: "the app never /// vouches for changes it didn't witness". @Test("No receipt is foreign — an empty ledger vouches for nothing") func noReceiptIsForeign() { #expect(EchoLedger().classify([index: .content(hash: EchoLedger.hash(of: "anything"))]) == .foreign) } @Test("A match consumes the receipt — one write, one echo") func matchConsumes() { let ledger = EchoLedger() ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "one")) #expect(ledger.classify([index: .content(hash: EchoLedger.hash(of: "one"))]) == .appMediated) #expect(ledger.outstandingReceipts == 0) #expect( ledger.classify([index: .content(hash: EchoLedger.hash(of: "one"))]) == .foreign, "a second reload observing the same file finds nothing vouching for it" ) } /// The conservative direction: keeping an unsatisfied receipt makes the next observation of the /// item foreign too, and a receipt later satisfied again is exactly the byte-identical race the /// design already accepts. @Test("A mismatch keeps the receipt rather than spending it") func mismatchKeepsTheReceipt() { let ledger = EchoLedger() ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "one")) #expect(ledger.classify([index: .content(hash: EchoLedger.hash(of: "other"))]) == .foreign) #expect(ledger.outstandingReceipts == 1) } /// **Every** held receipt has to still match. An item whose `index.md` the app wrote and whose /// attachment somebody else removed is not the app's echo. @Test("One unsatisfied receipt in a footprint makes the whole item foreign") func oneMismatchDecides() { let ledger = EchoLedger() ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "one")) ledger.recordWrite(atPath: folder + "/attachments/a.png", hash: EchoLedger.hash(of: "bytes")) #expect( ledger.classify([ index: .content(hash: EchoLedger.hash(of: "one")), folder + "/attachments/a.png": .absent ]) == .foreign ) } /// The snapshot names a card's attachments but never reads their bytes, so an attachment receipt /// can only ever be checked for arrival — which is what keeps classification free of I/O. @Test("A content receipt for bytes the reload never read is satisfied by arrival") func unreadBytesAreSatisfiedByPresence() { let ledger = EchoLedger() ledger.recordWrite(atPath: folder + "/attachments/a.png", hash: EchoLedger.hash(of: "bytes")) #expect(ledger.classify([folder + "/attachments/a.png": .present]) == .appMediated) } @Test("A path the ledger knows nothing about is not consulted — a copy's carried files are not foreign") func unknownPathsAreNotDemanded() { let ledger = EchoLedger() ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "one")) #expect( ledger.classify([ index: .content(hash: EchoLedger.hash(of: "one")), folder + "/attachments/carried.png": .present ]) == .appMediated ) } // MARK: The two races /// "An agent writing byte-identical bytes over a fresh app write matches and classifies /// app-mediated — with identical bytes the misattribution is unobservable in the tree, accepted." @Test("A byte-identical foreign overwrite of a fresh app write classifies app-mediated") func byteIdenticalOverwriteIsAppMediated() { let ledger = EchoLedger() let landed = "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n\n" ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: landed)) // Somebody else rewrote the file — with exactly the same bytes. #expect(ledger.classify([index: .content(hash: EchoLedger.hash(of: landed))]) == .appMediated) } /// "A foreign edit landing on an app-written path inside the same window misses the hash and the /// file classifies foreign — last writer wins the file, the app's subsumed intermediate never /// separately recorded." @Test("A foreign edit over a fresh app write classifies foreign — final content decides") func foreignEditOverAFreshWriteIsForeign() { let ledger = EchoLedger() ledger.recordWrite(atPath: index, hash: EchoLedger.hash(of: "---\nschema: 1\ntitle: Todo\n---\n\n")) let theirs = "---\nschema: 1\ntitle: Their title\n---\n\n" #expect(ledger.classify([index: .content(hash: EchoLedger.hash(of: theirs))]) == .foreign) } } // MARK: - The Writer's drops /// The receipts are dropped **inside `BoardWriter`**, at the primitives that touch disk, into /// whichever ledger `EchoLedger.current` is bound to — so these run the real Writer over a real temp /// board with a ledger bound by hand, exactly as `BoardStore.performWrite` binds one. @Suite("EchoLedger — what the Writer drops") struct EchoLedgerWriterTests { @Test("Every index.md write drops a receipt for the bytes that landed") func indexWritesDropReceipts() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let ledger = EchoLedger() let folder = fixture.url(lane1) try EchoLedger.$current.withValue(ledger) { try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.width, to: .int(3)) } } let landed = try fixture.indexText(lane1) #expect( ledger.receipt(at: folder.appendingPathComponent("index.md")) == .content(hash: EchoLedger.hash(of: landed)) ) } @Test("A delete-to-trash drops the old→new pair") func deleteToTrashDropsAPair() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let ledger = EchoLedger() let from = fixture.url("\(lane1)/\(card1)") try EchoLedger.$current.withValue(ledger) { _ = try BoardWriter.deleteCardToTrash(at: from, inBoard: fixture.root, order: 1024) } let to = fixture.url(".trash/\(card1)") #expect(ledger.receipt(at: from) == .move(from: EchoLedger.key(from), to: EchoLedger.key(to))) } @Test("A purge drops an absence marker") func purgeDropsAnAbsenceMarker() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let ledger = EchoLedger() let folder = fixture.url("\(lane1)/\(card1)") try EchoLedger.$current.withValue(ledger) { try BoardWriter.purgeItem(at: folder) } #expect(ledger.receipt(at: folder) == .absence) } /// "Attachment imports hash during the copy (the bytes stream through the app anyway)" — see /// `EchoLedger.recordImport(at:)` for where that phrase and `FileManager.copyItem` do not quite /// meet, and why the hash is taken from the landed file instead. @Test("An attachment import hashes what it copied") func attachmentImportHashes() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let ledger = EchoLedger() let payload = Data("not a real png, but real bytes".utf8) let source = try fixture.file("sources/photo.png", payload) let cardFolder = fixture.url("\(lane1)/\(card1)") try EchoLedger.$current.withValue(ledger) { _ = try BoardWriter.importAttachments([source], intoCard: cardFolder) } let landed = cardFolder.appendingPathComponent("attachments/photo.png") #expect(ledger.receipt(at: landed) == .content(hash: EchoLedger.hash(of: payload))) } /// The seam is the bracket, not the process: a Writer call outside one — another board's tree, /// a template instantiation, a test — records nothing, because there is no session whose echo /// it would be. @Test("A Writer call outside a bracket records nothing") func noLedgerNoReceipts() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try BoardWriter.updateIndex(inItemFolder: fixture.url(lane1), operation: .style(title: nil)) { document in document.set(FrontmatterKeys.width, to: .int(3)) } #expect(EchoLedger.current == nil) } } // MARK: - At the reload seam /// The whole round trip: a write goes out through `BoardStore.performWrite`, comes back through a /// reload, and the ledger is what decides whether the board says anything about it. @MainActor @Suite("EchoLedger — at the reload seam") struct EchoLedgerStoreTests { @MainActor private final class SpokenLog { var lines: [String] = [] func record(_ phrase: String?) { if let phrase { lines.append(phrase) } } } private func listen(to store: BoardStore) -> SpokenLog { let log = SpokenLog() store.announce = { log.record($0) } return log } private func reload(_ store: BoardStore, _ origin: WatchOrigin = .foreign) async { store.handleWatcherEvent(.treeChanged(origin)) await store.awaitQuiescence() } // MARK: The one-way flow's invariant /// **Feeds attribution and announcements only — never the render path** (02-architecture.md ▸ /// Layering, and the whole reason files-are-truth survives an app that also writes them). /// /// The ledger is poisoned with receipts that describe a board that does not exist, and the /// reload lands anyway, byte for byte what the loader read — because the loader had already /// finished before a single receipt was consulted, and consulting them cannot reach back. @Test("A poisoned ledger cannot move the snapshot by one byte") func theLedgerNeverFeedsTheRenderPath() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) for relative in ["", lane1, lane2, "\(lane1)/\(card1)", "\(lane1)/\(card2)"] { let folder = fixture.url(relative) store.echoes.recordWrite(at: folder.appendingPathComponent("index.md"), text: "a board that isn't there") store.echoes.recordDeletion(at: folder.appendingPathComponent("attachments")) } try fixture.card(card1, in: lane1, order: "1024", title: "Changed by somebody") await reload(store) #expect(store.snapshot == (try fixture.snapshot()), "the snapshot is the loader's, and only the loader's") #expect(store.snapshot.lanes.first?.cards.first?.title.value == "Changed by somebody") } // MARK: App-mediated writes are silent, file by file @Test("A card created through the store is not news") func appCreateIsSilent() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) store.transient.beginPlaceholder(inLane: lane1ID) store.transient.updateDraft("Minted by the app") _ = store.commitPlaceholder() await reload(store, .appMediated) #expect(store.snapshot.lanes.first?.cards.count == 3, "the card really arrived") #expect(log.lines.isEmpty) } @Test("A card moved between lanes through the store is not news") func appMoveIsSilent() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) store.moveCards([card1ID], toLane: ItemID(rawValue: lane2), at: 0) await reload(store, .appMediated) #expect(store.snapshot.lanes.last?.cards.map(\.id) == [card1ID], "the move really landed") #expect(log.lines.isEmpty) } /// The attachment path, whose receipt is checked for arrival rather than for bytes: the card's /// rendered content changed (its attachment listing did), and the ledger still vouches for it. @Test("An attachment imported through the store is not news") func appAttachmentImportIsSilent() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let source = try fixture.file("sources/photo.png", Data("bytes".utf8)) let log = listen(to: store) store.importAttachments([source], toCard: card1ID) await reload(store, .appMediated) #expect(store.snapshot.lanes.first?.cards.first?.attachments == ["photo.png"], "the import really landed") #expect(log.lines.isEmpty) } @Test("A board renamed through the store is not news") func appBoardRenameIsSilent() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) store.renameBoard("Renamed by the app") await reload(store, .appMediated) #expect(store.snapshot.title.value == "Renamed by the app") #expect(log.lines.isEmpty) } // MARK: Foreign writes announce, whatever the reload calls itself @Test("The same rename made by somebody else is news on any origin") func foreignBoardRenameSpeaks() async throws { for origin in [WatchOrigin.foreign, .appMediated, .reconciling] { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) try fixture.board(title: "Renamed by an agent") await reload(store, origin) #expect(log.lines == ["Board changed"], "origin \(origin.rawValue)") } } /// The mixed reload the ruling's "per-file facts" phrase exists for: one write of ours, one of /// theirs, landing in the same debounce window. Only theirs is counted. @Test("A mixed window announces only the changes nobody vouched for") func aMixedWindowCountsOnlyTheForeignHalf() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) store.setLaneWidth(lane1ID, units: 3) try fixture.card(card2, in: lane1, order: "2048", title: "Theirs") await reload(store, .appMediated) #expect(log.lines == ["Board changed: 1 card edited"], "one card, not one card and one lane") } // MARK: The two races, end to end /// The receipt is checked against the bytes the walk actually read, so an agent that rewrote the /// file with the app's own bytes is indistinguishable from the app — "with identical bytes the /// misattribution is unobservable in the tree, accepted". @Test("A byte-identical foreign overwrite of a fresh app write stays silent") func byteIdenticalOverwriteStaysSilent() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) store.setLaneWidth(lane1ID, units: 3) // Somebody else rewrites the file — with exactly the bytes the app just put there. try fixture.item(lane1, try fixture.indexText(lane1)) await reload(store, .appMediated) #expect(store.snapshot.lanes.first?.width.value == 3, "the change is real and observable") #expect(log.lines.isEmpty) } /// The other side of the same window: a foreign edit that lands on the app-written path misses /// the hash, so the file classifies foreign — last writer wins the file. @Test("A foreign edit over a fresh app write announces") func foreignEditOverAFreshWriteSpeaks() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let log = listen(to: store) store.setLaneWidth(lane1ID, units: 3) try fixture.lane(lane1, order: "1024", title: "Renamed by an agent", width: 5) await reload(store, .appMediated) #expect(store.snapshot.lanes.first?.title.value == "Renamed by an agent", "last writer won the file") #expect(log.lines == ["Board changed: 1 lane edited"]) } }