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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,13 +1027,21 @@ 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.
|
||||
do throws(BoardWriteError) {
|
||||
return try operation()
|
||||
} catch {
|
||||
banners.post(error)
|
||||
throw error
|
||||
// **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 {
|
||||
banners.post(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 old→new pair for folder
|
||||
/// moves; a newer app write to the same path supersedes the receipt." The drops live inside
|
||||
/// `BoardWriter`, at the four primitives that actually touch disk (`atomicReplace`, the folder move,
|
||||
/// the removal, the attachment copy), so no *call site* does bookkeeping and a new Writer operation
|
||||
/// built from those primitives is covered the day it is written. `BoardStore.performWrite` is what
|
||||
/// makes them reachable: it binds this store's ledger to `EchoLedger.current` for the bracket's
|
||||
/// duration, so a Writer static — stateless, `Sendable`, owned by nobody — can drop a receipt into
|
||||
/// the ledger of the board it is writing without being handed one.
|
||||
///
|
||||
/// **Bracketed operations are deliberately outside that seam.** `performWholesale` does not bind a
|
||||
/// ledger: 02 says bracketed operations "don't consult it (they commit themselves and announce once
|
||||
/// at completion)", so receipts from inside a bracket would be litter nothing would ever consume.
|
||||
///
|
||||
/// ### Classification
|
||||
///
|
||||
/// "Classification runs per observed changed file in a debounce window: current on-disk content
|
||||
/// matches the receipt → **app-mediated**, receipt consumed; no receipt, or mismatch → **foreign**."
|
||||
/// `verdicts(from:to:diff:includingTrash:)` is that rule applied to the unit a reload can actually
|
||||
/// observe — see its own note on why *files* are reached through *items*.
|
||||
///
|
||||
/// **Final content decides the races**, which is 02's settled answer to both of them: an agent
|
||||
/// writing byte-identical bytes over a fresh app write matches the hash and classifies app-mediated
|
||||
/// (with identical bytes the misattribution is unobservable in the tree, accepted), and a foreign
|
||||
/// edit landing on an app-written path inside the same window misses the hash and classifies foreign
|
||||
/// (last writer wins the file).
|
||||
///
|
||||
/// ### In-memory, per-store, dies with the session
|
||||
///
|
||||
/// A `let` on `BoardStore`, with no persistence and no recovery: "losing it costs attribution and
|
||||
/// nothing else", and everything a lost ledger cannot vouch for classifies foreign — *the app never
|
||||
/// vouches for changes it didn't witness*. That is also why an unsatisfied receipt is **not**
|
||||
/// consumed: keeping it makes the next observation of that item foreign too, which is the
|
||||
/// conservative direction, and a receipt that is later satisfied again is exactly the
|
||||
/// byte-identical race the design already accepts.
|
||||
public final class EchoLedger: Sendable {
|
||||
|
||||
// MARK: - Vocabulary
|
||||
|
||||
/// What one observed change turned out to be.
|
||||
public enum Provenance: Sendable, Equatable {
|
||||
/// The app wrote this, and disk still agrees with what it wrote.
|
||||
case appMediated
|
||||
/// Nobody vouched for it — no receipt, or a receipt disk no longer matches.
|
||||
case foreign
|
||||
}
|
||||
|
||||
/// One completed write's expected on-disk outcome, keyed by the path it describes.
|
||||
///
|
||||
/// Three cases and not four, because a folder move is **one** fact filed under two keys: the
|
||||
/// pair is stored at both endpoints so whichever end a reload observes finds it, and consuming
|
||||
/// either end retires both. Splitting it into a departure and an arrival would let half of a
|
||||
/// move be consumed and the other half linger forever.
|
||||
public enum Receipt: Sendable, Equatable {
|
||||
/// A file write: these are the bytes that landed.
|
||||
case content(hash: String)
|
||||
/// A delete: nothing should be here.
|
||||
case absence
|
||||
/// A folder move: what used to be at `from` is at `to`.
|
||||
case move(from: String, to: String)
|
||||
}
|
||||
|
||||
/// What the landing reload saw at a path.
|
||||
///
|
||||
/// `.present` is the honest middle case rather than a missing one: the snapshot **names** a
|
||||
/// card's attachments but never reads their bytes, so an attachment receipt can only ever be
|
||||
/// checked for arrival — and checking it that way costs no I/O on the reload seam, which is the
|
||||
/// whole reason classification is a function of two snapshots and nothing else.
|
||||
public enum Observation: Sendable, Equatable {
|
||||
case content(hash: String)
|
||||
case present
|
||||
case absent
|
||||
}
|
||||
|
||||
/// The ledger the Writer drops receipts into for the duration of one `performWrite` bracket.
|
||||
///
|
||||
/// A task-local rather than a parameter on forty Writer signatures, and rather than a property
|
||||
/// on `BoardWriter` (which is a stateless `enum` of statics and must stay one — 02 ▸ Components:
|
||||
/// "No hidden state"). The binding is dynamically scoped to exactly the bracket, which is
|
||||
/// exactly the lifetime "a receipt describes a *completed* write, and the ledger lives beside
|
||||
/// the Writer, not in it" asks for. `nil` — a Writer call outside any bracket, a test, a write
|
||||
/// into some *other* board's tree (Duplicate, template instantiation) — records nothing, which
|
||||
/// is what those paths want: there is no session whose echoes they are.
|
||||
@TaskLocal public static var current: EchoLedger?
|
||||
|
||||
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 old→new pair, filed at both ends (see `Receipt.move`).
|
||||
///
|
||||
/// Receipts *below* the old path are rebased onto the new one rather than dropped: the bytes did
|
||||
/// not change, only where they are, and an `index.md` receipt from earlier in the same bracket
|
||||
/// is still the truth about the file that just travelled.
|
||||
public func recordMove(from source: URL, to destination: URL) {
|
||||
recordMove(fromPath: Self.key(source), toPath: Self.key(destination))
|
||||
}
|
||||
|
||||
public func recordMove(fromPath source: String, toPath destination: String) {
|
||||
receipts.withLock { store in
|
||||
for path in Array(store.keys) where path.hasPrefix(source + "/") {
|
||||
store[destination + path.dropFirst(source.count)] = store.removeValue(forKey: path)
|
||||
}
|
||||
let pair = 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user