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:
@@ -320,44 +320,39 @@ struct BoardAnnouncerSpeechTests {
|
||||
BoardLoadError(path: "Todo/index.md", reason: .missingOrder)
|
||||
}
|
||||
|
||||
// MARK: Origins
|
||||
// MARK: Provenance
|
||||
|
||||
@Test("A foreign reload speaks its digest — the design's own example sentence")
|
||||
@Test("A reload carrying foreign changes speaks its digest — the design's own example sentence")
|
||||
func foreignReloadSpeaks() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "Board changed: 2 cards edited, 1 card added")
|
||||
}
|
||||
|
||||
@Test("App-mediated echoes never announce — the user's own action is not news")
|
||||
func appMediatedIsSilent() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
/// **The 2026-07-29 ruling, made structural.** Rungs 4 and 5 used to be gated on
|
||||
/// `origin == .foreign`; the gate is gone and so is the field it read, so nothing about the
|
||||
/// *kind* of reload can reach this type. Silence is the `EchoLedger`'s doing upstream — an echo
|
||||
/// it vouched for end to end arrives here with an empty diff — and a reconciling sweep over a
|
||||
/// blind window arrives with a full one, because receipt-less files classify foreign.
|
||||
/// `BoardAnnouncerStoreTests` is where both halves are exercised against a real ledger.
|
||||
@Test("Silence comes from the ledger's narrowing, never from the reload's kind")
|
||||
func silenceIsTheLedgersDoing() {
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil, "an echo the ledger vouched for arrives empty")
|
||||
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
}
|
||||
|
||||
@Test("A reconciling sweep is silent — it claims nothing changed")
|
||||
func reconcilingIsSilent() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .reconciling)
|
||||
facts.diff = loudDiff()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
}
|
||||
|
||||
@Test("A foreign reload that changed nothing says nothing")
|
||||
func quietForeignReload() {
|
||||
let facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == nil)
|
||||
#expect(
|
||||
BoardAnnouncer.speech(for: facts) == "Board changed: 2 cards edited, 1 card added",
|
||||
"the very same reload, with something foreign left in it, speaks"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Specific beats generic
|
||||
|
||||
@Test("The vanishing-focus sentence displaces the digest — one reload, one sentence")
|
||||
func vanishingFocusBeatsTheDigest() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.diff = loudDiff()
|
||||
facts.vanishedFocus = .card(title: "Fix login")
|
||||
|
||||
@@ -368,7 +363,7 @@ struct BoardAnnouncerSpeechTests {
|
||||
|
||||
@Test("A bracketed operation announces its completion, never its churn")
|
||||
func bracketAnnouncesCompletion() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.endsBracketedOperation = true
|
||||
facts.completion = "Pulled 3 commits"
|
||||
facts.diff = loudDiff()
|
||||
@@ -379,7 +374,7 @@ struct BoardAnnouncerSpeechTests {
|
||||
/// Every base-edition bracket today. The seam exists; pro-m1 supplies the phrases.
|
||||
@Test("A bracket with no phrase to say stays silent rather than falling back to the digest")
|
||||
func bracketWithoutAPhrase() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.endsBracketedOperation = true
|
||||
facts.diff = loudDiff()
|
||||
facts.vanishedFocus = .card(title: "Fix login")
|
||||
@@ -391,7 +386,7 @@ struct BoardAnnouncerSpeechTests {
|
||||
|
||||
@Test("A raised read-only lock outranks everything else the reload could say")
|
||||
func raisedLockWins() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.endsBracketedOperation = true
|
||||
facts.completion = "Pulled 3 commits"
|
||||
facts.lockAfter = .bracketedReloadFailed
|
||||
@@ -404,7 +399,7 @@ struct BoardAnnouncerSpeechTests {
|
||||
|
||||
@Test("The banner's announcement is the banner's own label — one condition, one sentence")
|
||||
func announcementMatchesTheRowLabel() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.lockAfter = .vanishedRoot
|
||||
|
||||
#expect(
|
||||
@@ -415,7 +410,7 @@ struct BoardAnnouncerSpeechTests {
|
||||
|
||||
@Test("A lock that was already standing is not repeated on every reload")
|
||||
func standingLockIsNotRepeated() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.lockBefore = .vanishedRoot
|
||||
facts.lockAfter = .vanishedRoot
|
||||
facts.diff = { var diff = BoardDiff(); diff.boardChanged = true; return diff }()
|
||||
@@ -425,7 +420,7 @@ struct BoardAnnouncerSpeechTests {
|
||||
|
||||
@Test("A lock whose cause changed is news again")
|
||||
func changedLockCauseSpeaksAgain() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.lockBefore = .unwritableLocation
|
||||
facts.lockAfter = .vanishedRoot
|
||||
|
||||
@@ -437,7 +432,7 @@ struct BoardAnnouncerSpeechTests {
|
||||
|
||||
@Test("Reload breakage announces on arrival")
|
||||
func raisedBreakage() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.breakageAfter = breakage()
|
||||
|
||||
#expect(
|
||||
@@ -448,7 +443,7 @@ struct BoardAnnouncerSpeechTests {
|
||||
|
||||
@Test("A cleared lock is announced — the banner speaks when it clears, not only when it appears")
|
||||
func clearedLock() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.lockBefore = .bracketedReloadFailed
|
||||
facts.diff = loudDiff()
|
||||
|
||||
@@ -457,7 +452,7 @@ struct BoardAnnouncerSpeechTests {
|
||||
|
||||
@Test("Cleared breakage is announced, below the lock when both heal at once")
|
||||
func clearedBreakage() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.breakageBefore = breakage()
|
||||
|
||||
#expect(BoardAnnouncer.speech(for: facts) == "The board is loading again")
|
||||
@@ -470,7 +465,7 @@ struct BoardAnnouncerSpeechTests {
|
||||
/// one sentence the reload has.
|
||||
@Test("A completion phrase outranks the clearance it implies")
|
||||
func completionOutranksClearance() {
|
||||
var facts = BoardAnnouncer.ReloadFacts(origin: .appMediated)
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.endsBracketedOperation = true
|
||||
facts.completion = "Switched to branch 'redesign'"
|
||||
facts.lockBefore = .bracketedReloadFailed
|
||||
@@ -541,19 +536,40 @@ struct BoardAnnouncerStoreTests {
|
||||
}
|
||||
|
||||
/// The app's own delete already chose its successor (04-interactions.md ▸ The map); a recovery
|
||||
/// firing on the echo would override it. 02-architecture.md's silent-vanish rule is what an
|
||||
/// app-mediated reload still gets.
|
||||
@Test("An app-mediated echo leaves the emptied selection exactly as the set rule left it")
|
||||
/// firing on the echo would override it. **What buys that silence is the receipt, not the
|
||||
/// origin** (ruled 2026-07-29): the delete goes through the store, so the card's folder carries
|
||||
/// a move pair the reload matches, and the vanishing classifies app-mediated.
|
||||
@Test("The app's own delete keeps the successor its command chose — the reload never re-aims it")
|
||||
func appMediatedDoesNotRecover() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID], in: .board)
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(lane1)/\(card1)"))
|
||||
store.delete([card1ID])
|
||||
let afterTheGesture = store.transient.selection.ids
|
||||
#expect(afterTheGesture == [card2ID], "⌫ takes the successor sibling")
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(store.transient.selection.isEmpty)
|
||||
#expect(store.transient.selection.ids == afterTheGesture)
|
||||
#expect(store.transient.selection.ids != [lane1ID], "the lane recovery is the foreign case's move, not this one's")
|
||||
}
|
||||
|
||||
/// The same gesture made by somebody else: no receipt, so the vanishing is foreign, the sentence
|
||||
/// is spoken and focus recovers — the pair that shows the ledger is what tells them apart.
|
||||
@Test("The identical delete made externally does recover, and says so")
|
||||
func foreignDeleteRecoversAndSpeaks() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.select([card1ID], in: .board)
|
||||
let log = listen(to: store)
|
||||
|
||||
try fixture.move("\(lane1)/\(card1)", toTrash: card1)
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(log.lines == ["Card 'Fix login' was deleted externally"])
|
||||
#expect(store.transient.selection.ids == [lane1ID])
|
||||
}
|
||||
|
||||
@Test("A reload that leaves the selection standing never re-aims it")
|
||||
@@ -640,16 +656,54 @@ struct BoardAnnouncerStoreTests {
|
||||
#expect(log.lines == ["Board changed: 2 cards edited"])
|
||||
}
|
||||
|
||||
@Test("An app-mediated echo says nothing at all")
|
||||
/// **"App-mediated echoes never announce", now grounded in the ledger rather than in the
|
||||
/// reload's label**: the write goes through `performWrite`, the receipt matches the bytes the
|
||||
/// walk read back, and the lane's edit is narrowed out of the digest before the announcer sees
|
||||
/// it. The width assertion is there so the silence is never silence about a no-op.
|
||||
@Test("An app-mediated echo says nothing at all — every file it touched carries a matching receipt")
|
||||
func appMediatedEchoIsSilent() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Renamed")
|
||||
store.setLaneWidth(lane1ID, units: 3)
|
||||
await reload(store, .appMediated)
|
||||
|
||||
#expect(store.snapshot.lanes.first?.width.value == 3, "the write really landed")
|
||||
#expect(log.lines.isEmpty)
|
||||
}
|
||||
|
||||
/// **The ruling itself** (10-accessibility.md ▸ Live board announcements, 2026-07-29): "a
|
||||
/// reconciling reload that reveals external changes is never silent". Files changed during a
|
||||
/// blind window carry no receipts, so they classify foreign and announce — the launch-catch-up
|
||||
/// doctrine applied to speech. The old behaviour this replaces was a blanket
|
||||
/// `origin == .foreign` gate that swallowed exactly this case.
|
||||
@Test("A reconciling sweep announces what the blind window hid")
|
||||
func reconcilingSweepAnnouncesItsFindings() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = listen(to: store)
|
||||
|
||||
try fixture.card(card1, in: lane1, order: "1024", title: "Edited while we slept")
|
||||
await reload(store, .reconciling)
|
||||
|
||||
#expect(log.lines == ["Board changed: 1 card edited"])
|
||||
}
|
||||
|
||||
/// The other half of the same ruling: a reconciling sweep that finds the tree exactly as the app
|
||||
/// left it is still quiet. Reconciliation is not itself news — the *findings* are.
|
||||
@Test("A reconciling sweep over the app's own work is still silent")
|
||||
func reconcilingSweepOverAnEchoIsSilent() 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)
|
||||
await reload(store, .reconciling)
|
||||
|
||||
#expect(log.lines.isEmpty)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user