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
+28 -23
View File
@@ -34,9 +34,23 @@ import Foundation
/// 4. **A vanished focus** the specific sentence, which beats the generic one (below).
/// 5. **The board digest** the ordinary foreign-change case.
///
/// Rungs 4 and 5 are foreign-only; 1 through 3 are not, because a lock raised by the app's own
/// bracketed operation is exactly the case 10 names ("including the read-only lock after a failed
/// bracketed reload"), and silence there would be the app hiding its own failure.
/// ### Provenance is per file, and it arrives already decided
///
/// **The announcer consumes the ledger's per-file facts on every reload origin, reconciling
/// included** (10-accessibility.md, ruled 2026-07-29). So there is no origin on `ReloadFacts` and no
/// origin test in the ladder: what buys an app-mediated echo its silence is that its files carry
/// receipts the disk still matches (`EchoLedger`), and the reload seam hands this type a diff
/// already narrowed to the changes nobody vouched for. A reconciling sweep over a blind window
/// wake, activation, a missed-events flag reveals files with no receipts at all, so they classify
/// foreign and the sweep speaks: "the app never vouches for changes it didn't witness", applied to
/// speech. Rungs 4 and 5 are therefore *foreign-only by construction* rather than by a gate.
///
/// Rungs 1 through 3 never had one and still do not: **banner transitions are origin-independent**
/// (10, confirmed 2026-07-29) a lock raised by the app's own bracketed operation is exactly the
/// case 10 names ("including the read-only lock after a failed bracketed reload"), and silence there
/// would be the app hiding its own failure. Rung 2 is the one exemption from the ledger in either
/// direction: a bracket announces its result and never its churn, so 02-architecture.md keeps
/// bracketed operations out of the ledger entirely.
public enum BoardAnnouncer {
// MARK: - A vanishing focus
@@ -200,14 +214,6 @@ public enum BoardAnnouncer {
/// state one fact and default the rest.
public struct ReloadFacts: Sendable, Equatable {
/// The reload's provenance the classification 10's first rule is stated in terms of.
///
/// **The merge is lossy and that is accepted** (`WatchOrigin.merged`): a foreign edit landing
/// inside an app-mediated span arrives labeled `.appMediated` and stays silent. `Motion`
/// documents the same blur on the same seam and for the same reason the alternative is
/// splitting deliveries, which `FolderWatcher.schedule` rejects for the coalescing it costs.
public var origin: WatchOrigin
/// Whether this is the reload that closes a bracketed wholesale operation.
public var endsBracketedOperation = false
@@ -216,7 +222,10 @@ public enum BoardAnnouncer {
/// base-edition bracket today (see `BoardStore.performWholesale(announcing:_:)`).
public var completion: String?
/// The snapshot comparison, empty by default so a test about origins need not build one.
/// The snapshot comparison **already narrowed to the foreign-classified changes**
/// (`EchoLedger.verdicts(from:to:diff:includingTrash:)`), empty by default so a test about
/// the ladder need not build one. An app-mediated echo reaches here as an empty diff, which
/// is why the digest rung needs no gate of its own.
public var diff = BoardDiff()
/// What went out from under the cursor, if anything (`focusOutcome(old:new:selection:focused:)`).
@@ -233,9 +242,7 @@ public enum BoardAnnouncer {
public var breakageBefore: BoardLoadError?
public var breakageAfter: BoardLoadError?
public init(origin: WatchOrigin) {
self.origin = origin
}
public init() {}
}
/// The ladder see the type's doc comment for the five rungs and why they are in that order.
@@ -246,14 +253,12 @@ public enum BoardAnnouncer {
if facts.endsBracketedOperation { return facts.completion }
if let cleared = clearedCondition(facts) { return cleared }
// **App-mediated echoes never do.** The user's own action already had its feedback the
// gesture, the animation, the menu it came from and narrating it back is the app talking
// over the user. A reconciling sweep is silent for the neighbouring reason: it makes no claim
// that anything changed (a wake, an activation, a missed-events flag), and a board that
// announced a digest every time the app came forward would be announcing the *absence* of an
// event, the way `Motion` refuses to animate one.
guard facts.origin == .foreign else { return nil }
// **App-mediated echoes never do**, and there is deliberately no test for that here. The
// user's own action already had its feedback the gesture, the animation, the menu it came
// from and narrating it back is the app talking over the user; what enforces it is the
// ledger upstream, which hands this an empty diff and no vanished focus when every file the
// reload observed carries a receipt disk still matches. A reconciling sweep that reveals
// nothing arrives the same way and is silent for the same reason not because of its label.
if let vanished = facts.vanishedFocus {
return AccessibilityPhrases.vanishedFocus(vanished)
}
+92 -23
View File
@@ -309,6 +309,21 @@ public final class BoardStore {
/// this store's own state and are composed in at render time by `bannerRows`.
public let banners = BannerCenter()
/// **This board's write-provenance ledger** (02-architecture.md Components EchoLedger):
/// what the app wrote, so a landing reload can tell its own echo from someone else's edit.
///
/// **In-memory, per-store, dies with the session** a `let` beside `transient` and `banners`,
/// for the same reason all three are: closing the board is the reset, and "losing it costs
/// attribution and nothing else". Owned rather than injected because there is exactly one
/// answer to "which ledger is this board's", and a second one would be a second provenance.
///
/// Its consumers today are the announcer's digest and the vanishing-focus sentence, both
/// through `land`. Pro's auto-committer (06-history-undo.md) becomes the second one without
/// this line changing which is why the type lives in `LiveStore/` beside `BoardDiff` rather
/// than in `KanbanPro/`.
@ObservationIgnored
public let echoes = EchoLedger()
/// The rows the board window's strip renders, in precedence order.
///
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
@@ -644,7 +659,7 @@ public final class BoardStore {
// appears and when it clears", and appearing and clearing are transitions, not states. Read
// here rather than at each mutation below so there is one before-picture for the whole
// landing, whichever branch it takes.
var facts = BoardAnnouncer.ReloadFacts(origin: origin)
var facts = BoardAnnouncer.ReloadFacts()
facts.endsBracketedOperation = endsWholesaleOperation
facts.completion = completion
facts.lockBefore = readOnlyLock
@@ -652,37 +667,52 @@ public final class BoardStore {
switch outcome {
case let .success(result):
// **What changed, and what it cost the cursor** both computed against the *outgoing*
// snapshot, so they have to be taken before the assignment below replaces it. Both are
// pure functions of two value types; nothing here decides whether anyone is told.
// **What changed, who changed it, and what it cost the cursor** all three computed
// against the *outgoing* snapshot, so they have to be taken before the assignment below
// replaces it. All three are functions of two value types; nothing here reads disk and
// nothing here decides whether anyone is told.
//
// Asked only of a `.foreign` reload that is not closing a bracket, which is the only
// reload either answer is used by: 10-accessibility.md gives the app's own echoes
// silence, gives a bracket one sentence at completion rather than a description of its
// churn, and phrases the vanishing-focus case as "deleted *externally*" a sentence that
// would be a lie about an app-mediated delete, whose own command already chose a
// successor (04-interactions.md The map's rule) and must not have it overridden here.
// Skipping the comparison on the other origins keeps the ordinary echo's landing exactly
// as cheap as it was.
// **Asked on every origin, reconciling included** (10-accessibility.md Live board
// announcements, ruled 2026-07-29): what buys silence is the *ledger*, not the reload's
// label. An app-mediated echo is silent because its files carry receipts that still
// match; a reconciling sweep over a blind window is not, because the files it reveals
// carry none "the app never vouches for changes it didn't witness". The one exemption
// is the bracket, which 10 gives a single sentence at completion rather than a
// description of its churn, and which 02 keeps out of the ledger entirely.
let focus: BoardAnnouncer.FocusOutcome
if origin == .foreign, !endsWholesaleOperation {
if !endsWholesaleOperation {
// **The digest covers the trash only while the trash lane is shown**
// (10-accessibility.md Live board announcements, ruled 2026-07-29). This is the
// seam that reading takes: visibility is view state on the board's own transient
// container one per store, shared by every window onto this board
// (`TransientBoardState.isTrashVisible`) so the store asks it here and the
// summarizer stays a pure function of two snapshots plus one fact.
facts.diff = BoardDiff.between(
snapshot,
result.model,
includingTrash: transient.isTrashVisible
// (`TransientBoardState.isTrashVisible`) so the store asks it here and both the
// summarizer and the classifier stay pure functions of two snapshots plus one fact.
let shownTrash = transient.isTrashVisible
let diff = BoardDiff.between(snapshot, result.model, includingTrash: shownTrash)
let verdicts = echoes.verdicts(
from: snapshot,
to: result.model,
diff: diff,
includingTrash: shownTrash
)
focus = BoardAnnouncer.focusOutcome(
facts.diff = verdicts.foreign
// The vanishing-focus sentence takes the same gate, one rung up the ladder: it says
// "deleted *externally*", which would be a lie about an app-mediated delete whose
// own command already chose a successor (04-interactions.md The map's rule) and
// must not have it overridden here. So a vanishing the ledger vouches for is no
// vanishing at all as far as speech and focus are concerned.
let outcome = BoardAnnouncer.focusOutcome(
old: snapshot,
new: result.model,
selection: transient.selection,
focused: focusedItem
)
focus = Self.vanishingIsForeign(
focused: focusedItem,
old: snapshot,
new: result.model,
foreignItems: verdicts.foreignItems
) ? outcome : .survived
} else {
focus = .survived
}
@@ -810,6 +840,37 @@ public final class BoardStore {
}
}
/// **Whether the hole under the cursor was somebody else's doing** the gate that used to be
/// `origin == .foreign`, now asked of the ledger per file (10-accessibility.md, ruled
/// 2026-07-29).
///
/// It mirrors `BoardAnnouncer.focusOutcome`'s own choice of subject rather than second-guessing
/// it, because the two must agree about *what vanished*: when the lane went, the lane is the
/// subject and the lane's classification is the one that decides a card swept away with an
/// app-mediated lane delete carries no receipt of its own (the removal took the whole subtree's
/// receipts with it), and letting the child's verdict speak would announce the user's own
/// gesture back at them.
///
/// `false` for a focus that was never on the board's old side: there is nothing to classify, and
/// `focusOutcome` already answers `.survived` there.
nonisolated static func vanishingIsForeign(
focused: ItemID?,
old: BoardModel,
new: BoardModel,
foreignItems: Set<ItemID>
) -> Bool {
guard let focused else { return false }
if old.lanes.contains(where: { $0.id == focused }) {
return foreignItems.contains(focused)
}
guard let home = old.lanes.first(where: { lane in lane.cards.contains { $0.id == focused } })
else { return false }
if new.lanes.contains(where: { $0.id == home.id }) {
return foreignItems.contains(focused)
}
return foreignItems.contains(home.id)
}
/// **The cursor**, as 10-accessibility.md's announcements mean it: the navigation head when it
/// is still in the selection, else a sole selected item, else nothing.
///
@@ -924,7 +985,7 @@ public final class BoardStore {
/// exactly once, in one place, from the banner's own headline: a lock the user hears described
/// one way and reads another is two locks as far as they can tell.
private func announceLockChange(from before: ReadOnlyLockReason?) {
var facts = BoardAnnouncer.ReloadFacts(origin: .foreign)
var facts = BoardAnnouncer.ReloadFacts()
facts.lockBefore = before
facts.lockAfter = readOnlyLock
announce(BoardAnnouncer.speech(for: facts))
@@ -966,8 +1027,15 @@ public final class BoardStore {
// `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
// and an unbalanced bracket would leave the watcher suspended for the rest of the session.
defer { watcherBrackets?.end() }
// `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error` and
// the Writer's typed error is lost on the way to the banner.
// **The receipt seam** (02-architecture.md Components EchoLedger). Binding the ledger
// here rather than passing it down is what keeps `BoardWriter` the stateless enum of statics
// the same bullet requires: the Writer's disk primitives drop receipts into whichever
// board's ledger is bound, and the binding is exactly this bracket which is exactly the
// span in which a write is the app's. A Writer call outside one (another board's tree, a
// test) finds no ledger and records nothing.
return try EchoLedger.$current.withValue(echoes) {
// `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error`
// and the Writer's typed error is lost on the way to the banner.
do throws(BoardWriteError) {
return try operation()
} catch {
@@ -975,6 +1043,7 @@ public final class BoardStore {
throw error
}
}
}
/// Runs an operation that rewrites the tree **wholesale** pull-rebase, branch switch, undo
/// restore (06-history-undo.md, 07-sync-collab.md) under the bracket, and arms the rule that
+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
}
}
+36 -1
View File
@@ -117,6 +117,11 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not replace file: \(String(cString: strerror(status)))")
)
}
// **The receipt, dropped after the bytes land and before the call returns** (the
// EchoLedger's contract, 02-architecture.md Components). This one line covers every
// `index.md` in the app: `updateIndex` funnels here, and so do create, materialize,
// recreate, the task-marker flip, the body save and the raw-source Apply.
EchoLedger.current?.recordWrite(at: fileURL, text: text)
}
// MARK: - Create
@@ -539,6 +544,9 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not move folder: \(error.localizedDescription)")
)
}
// The oldnew pair. The `updateIndex` below then supersedes the arrived `index.md`'s own
// receipt, which is the truth about the file the reload will read.
EchoLedger.current?.recordMove(from: sourceFolder, to: arrivedRoot)
if isImport {
let children = childCandidates(of: arrivedRoot)
@@ -636,6 +644,9 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not rename folder: \(error.localizedDescription)")
)
}
// A remint is a folder move like any other as far as provenance goes the identity
// changed, so the arriving item is a different item, and the pair says where it came from.
EchoLedger.current?.recordMove(from: folder, to: destination)
}
/// Whether two URLs name the same place on disk symlinks resolved, `..`/`.` standardized
@@ -994,6 +1005,10 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)")
)
}
// A delete is a move into `.trash/` on disk (01-storage-format.md § Deletion), so the
// receipt is the move pair and it reads correctly from either end: the board side sees an
// absence where the card was, the shown-trash side sees an arrival where it went.
EchoLedger.current?.recordMove(from: cardFolder, to: arrived)
try updateIndex(inItemFolder: arrived, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(order))
@@ -1062,6 +1077,9 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
// The absence marker and it takes the lane's cards' receipts with it, which is what makes
// the digest's implied-events rule and the ledger agree that this was one event.
EchoLedger.current?.recordDeletion(at: laneFolder)
}
/// Permanently removes one card from the trash the trash's **Delete / Delete Immediately**
@@ -1094,6 +1112,7 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: cardFolder)
}
/// **Empty Trash** (, 03-board-ui.md § Trash): permanently removes every card in
@@ -1126,6 +1145,7 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: card)
purged.append(ItemID(rawValue: card.lastPathComponent))
}
return purged
@@ -1299,6 +1319,10 @@ public enum BoardWriter: Sendable {
let fileURL = folder.appendingPathComponent(name)
do {
try contents.write(to: fileURL)
// An undo restore recreates whole subtrees byte for byte; the bytes are already
// in hand, so every restored file gets its own receipt rather than only the
// `index.md` at the top.
EchoLedger.current?.recordWrite(at: fileURL, data: contents)
} catch {
throw BoardWriteError(
operation: operation,
@@ -1375,6 +1399,7 @@ public enum BoardWriter: Sendable {
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: itemFolder)
}
// MARK: - Undoing a create
@@ -1796,6 +1821,9 @@ public enum BoardWriter: Sendable {
let destinationURL = attachmentsFolder.appendingPathComponent(name)
do {
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
// "Attachment imports hash during the copy" see `EchoLedger.recordImport(at:)`
// for the one place that phrase and `FileManager.copyItem` do not quite meet.
EchoLedger.current?.recordImport(at: destinationURL)
} catch {
try? FileManager.default.removeItem(at: destinationURL)
throw BoardWriteError(
@@ -1879,8 +1907,12 @@ public enum BoardWriter: Sendable {
let operation = WriteOperation.relocateLooseFile(filename: name)
let sourceURL = cardFolder.appendingPathComponent(name)
let landed = freshAttachmentName(for: name, in: attachmentsFolder)
let landedURL = attachmentsFolder.appendingPathComponent(landed)
do {
try FileManager.default.moveItem(at: sourceURL, to: attachmentsFolder.appendingPathComponent(landed))
try FileManager.default.moveItem(at: sourceURL, to: landedURL)
// A move pair, not a write: the bytes were not touched, only their place and the
// pair is what tells the classifier the loose file's disappearance was the app's.
EchoLedger.current?.recordMove(from: sourceURL, to: landedURL)
} catch {
throw BoardWriteError(
operation: operation,
@@ -2078,6 +2110,9 @@ public enum BoardWriter: Sendable {
var trashedURL: NSURL?
do {
try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL)
// Where the file went is the *system* Trash, outside any board so from this board's
// point of view it is an absence, exactly as a purge is.
EchoLedger.current?.recordDeletion(at: fileURL)
} catch {
throw BoardWriteError(
operation: operation,
+4 -3
View File
@@ -190,9 +190,10 @@ enum AccessibilityPhrases {
/// what happened. It is omitted at zero an empty lane vanishing has no second clause to add,
/// and "with 0 cards" would be an odd way to say "and nothing else".
///
/// "Externally" and not "by an agent" or "on disk": the announcement fires only on a foreign
/// reload (`BoardAnnouncer.speech(for:)`), and which outside writer did it an editor, an
/// agent, `git` in a terminal is exactly what the store cannot know.
/// "Externally" and not "by an agent" or "on disk": the sentence is only ever composed for a
/// vanishing the `EchoLedger` did not vouch for (`BoardStore.vanishingIsForeign`), and which
/// outside writer did it an editor, an agent, `git` in a terminal is exactly what the store
/// cannot know.
static func vanishedFocus(_ vanished: BoardAnnouncer.VanishedFocus) -> String {
switch vanished {
case let .card(title):
+95 -41
View File
@@ -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)
}
+518
View File
@@ -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 oldnew 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 oldnew 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"])
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and a reserved `.trash/` container read by the very same card parse the lanes use — pinned by a golden fixture suite of 18 on-disk boards.
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes move a card's folder into the board's reserved `.trash/` at a caller-minted top rank, restore is the ordinary move back out, and purge is physical; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy.
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo, and a per-board **write-provenance ledger** — in-memory, dying with the session — records a content hash, an absence marker or an old→new pair for every file the app writes, so a landing reload can tell its own echo from an outside edit file by file (final content decides: byte-identical is the app's, one byte different is somebody else's); a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded, then per-card frame memory once you've placed one; follows its card across lanes; dismisses the moment its card leaves the board — into the trash, with its deleted lane, purged, or moved to another board). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). A card has one presentation: selection changes only its styling, never its geometry, so the masonry never reflows on a click — the paperclip chip is the face's whole attachment story, and viewing the files themselves is the card window's job.
@@ -48,7 +48,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is left alone, and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent, and a card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled.
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent. Which is which is decided **per file by the write-provenance ledger**, never by which kind of reload delivered it: a reconciling sweep on wake or reactivation announces whatever changed in the blind window (the app never vouches for changes it didn't witness), and a foreign edit that lands on a file the app had just written is still announced. A card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled.
## Development