The seams unbind — the provider is always native, and the git stack compiles dead

Step 4 of strategy/01-git-excision.md, the entangled one: AppModel's makeHistoryProvider collapses to the native provider (the seam stays injectable per the reversibility posture), the session's git state and its wiring go (wireGitUndo, wireBranchSwitching, the card-session staging threading), BoardStore sheds commitSeam and the identity-history ranker (the loader's nil-safe rung now tops out at birth date — today's no-git behavior), SessionSettleGate keeps the gate and inherits the path utility it borrowed, BoardRegistry drops the persisted operation stamp (decode-safe), and the git banner family leaves BannerCenter with its announcer and accessibility phrases. One missed harvest tie severed (the narrator's root subject is its own now). Nothing outside Kanban/Git/ references the stack — proven by sweep. 2,855 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 11:25:41 -04:00
parent f6a24132b6
commit cdba512512
24 changed files with 281 additions and 2567 deletions
+46 -311
View File
@@ -36,79 +36,6 @@ public struct OneShotBanner: Identifiable, Sendable, Equatable {
}
}
/// **The one-shot failure class's second shape** (02-architecture.md § The banner surface, settled
/// 2026-07-31): "the `BoardWriteError`-shaped write failure, and a message-carrying **git-operation
/// failure** the operation named in the user's words plus the underlying error, phrasing still
/// BannerCenter's because failures rank by what they are, not by which error vocabulary threw
/// them".
///
/// It is a row of its own rather than a `BoardWriteError` with a git-shaped case because "git
/// operations stay off the closed `WriteOperation` vocabulary only the banner tier learns the
/// second shape": a `WriteOperation` is something the Writer does to a file the model knows, and a
/// checkout is not one. What the two shapes *do* share is everything the strip cares about the
/// error tone, the failure rank, and the one-shot's dismissable, untimed lifecycle which is why
/// they sit in one precedence class rather than in two (`BannerCenter.rows(...)`).
///
/// It replaces the shipped compromise, and the compromise is worth naming because its reasoning is
/// the mirror of this one's: a failed restore used to post a `LossBanner` the nearest class with a
/// free-form message which read as warning-tone content-didn't-arrive when what actually happened
/// was an action that didn't happen at all. "A failed undo restore, branch switch, or (pro-m2)
/// pull/push presents in the error tone at the failure rank, never as a warning-tone loss row."
public struct GitFailureBanner: Identifiable, Sendable, Equatable {
public let id: UUID
/// Which operation didn't happen a closed vocabulary, so the sentence is composed here rather
/// than carried in from the git layer (`GitOperation`).
public let operation: GitOperation
/// The underlying error, verbatim libgit2's own message, the free-form tail the phrasing rules
/// allow ("could not write to '': Permission denied"). Never the banner's verb.
public let reason: String
/// When it failed the sort key for "newest first within a class", shared with the write
/// failures it ranks beside.
public let occurredAt: Date
public init(id: UUID = UUID(), operation: GitOperation, reason: String, occurredAt: Date = Date()) {
self.id = id
self.operation = operation
self.reason = reason
self.occurredAt = occurredAt
}
}
/// **The git operations the banner can name** the closed vocabulary behind `GitFailureBanner`,
/// and the git half of the rule the `WriteOperation` enum already carries for writes: "the banner
/// owns all user-facing phrasing and localization from that vocabulary", and an operation added
/// without a sentence to say about it is a compile-time hole rather than a silent default
/// (`BannerCenter.actionPhrase(for:)` switches over it exhaustively).
///
/// Deliberately small. 02 names the members: "a failed undo restore, branch switch, or (pro-m2)
/// pull/push" the first three are here because they have producers today
/// (`GitHistoryProvider`, `GitBranchSwitcher`), and pull/push join by adding a case and a sentence
/// when the remote half ships.
///
/// No associated values: a branch switch's target is in the in-progress row the failure replaces
/// ("Switching to 'main'"), and a failure that named it again would be the only member of this
/// vocabulary carrying a payload a case can grow one the day a sentence genuinely needs it.
public enum GitOperation: Sendable, Equatable, CaseIterable {
case undo
case redo
case branchSwitch
/// **Add-git, when the form that asked has been dismissed** (06-history-undo.md Interaction with
/// external writers, ruled 2026-07-31): "if the sheet has been dismissed before the answer
/// arrives, the failure falls back to the one-shot banner above inline is the primary surface,
/// never a silence trap." Inline is `HistoryStore.lastFailure`; this is the fallback.
case addGit
/// The pair Z and Z map to so a caller that knows a `HistoryDirection` (the provider's
/// crossing) never has to spell the mapping itself, and never gets it backwards.
public static func restore(_ direction: HistoryDirection) -> GitOperation {
switch direction {
case .undo: .undo
case .redo: .redo
}
}
}
/// A loss row: content that didn't arrive though nothing failed (02-architecture.md § The banner
/// surface, "Loss rows are the warning-tone class for non-failure losses", settled 2026-07-28)
/// folders skipped from a Finder drop, the app's own relocation and repair notices, their future kin.
@@ -218,19 +145,18 @@ public struct InfoSignpost: Identifiable, Sendable, Equatable {
}
/// Work in flight, shown as an info row with a spinner (02-architecture.md § The banner surface,
/// "In-progress operations are info rows"): bracketed git operations ("Pulling", "Switching to
/// 'main'") and long non-git work (big-board Duplicate, template instantiation, large attachment
/// imports).
/// "In-progress operations are info rows"): long copy-shaped work big-board Duplicate, template
/// instantiation, large attachment imports and whatever wholesale operation joins them.
///
/// `label` is the caller's, deliberately: unlike a failure whose vocabulary is the closed
/// `WriteOperation` enum precisely so the banner can own every word an in-progress row names an
/// operation the banner layer has no enum for, and inventing one would mean a new git verb could
/// not describe itself without touching this file. The tradeoff is stated rather than hidden.
/// operation the banner layer has no enum for, and inventing one would mean a new verb could not
/// describe itself without touching this file. The tradeoff is stated rather than hidden.
///
/// **Cancel appears on safe copies only** (settled): copy-shaped work attachment imports,
/// Duplicate, template instantiation carries `cancel`, meaning "remove the partial copy, nothing
/// lost". Git brackets pass `nil`: they are seconds long, and aborting a rebase mid-flight is a
/// repair job, not a cancel.
/// lost". An operation that cannot be abandoned halfway passes `nil`: unwinding it is a repair job,
/// not a cancel.
public struct InProgressOperation: Identifiable, Sendable {
public let id: UUID
public let label: String
@@ -252,19 +178,16 @@ public struct InProgressOperation: Identifiable, Sendable {
/// One row in a window's banner strip.
///
/// The eight cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// The six cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// divide into three lifecycles that the view renders differently and that the ordering rule
/// treats as classes:
///
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `repositoryUnreadable`,
/// `historySuspended`. They describe
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `historySuspended`. They describe
/// ongoing state and carry no dismiss control "an error never evaporates unread" has a twin,
/// "a condition is never dismissed while it is still true". Each leaves when the thing it
/// describes stops being true.
/// - **One-shots dismiss**: `oneShot`, `gitFailure`, `loss`, and `signpost`. Each reports something
/// that already happened, so only the user can clear it. `gitFailure` is the same failure class as
/// `oneShot` in a second shape (settled 2026-07-31) same tone, same rank, same lifecycle, a
/// message where the other carries a `BoardWriteError`. `loss` shares the lifecycle deliberately
/// - **One-shots dismiss**: `oneShot`, `loss`, and `signpost`. Each reports something
/// that already happened, so only the user can clear it. `loss` shares the lifecycle deliberately
/// (settled 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the
/// harm, so it never auto-expires" is the same reasoning that keeps a one-shot from evaporating
/// unread, aimed at a row that isn't an error at all.
@@ -284,35 +207,18 @@ public enum BannerRow: Identifiable, Sendable {
/// the **whole** aggregate one row either way, but its headline names the first defect and
/// counts the rest rather than pretending the walk found only one.
case reloadBreakage(BoardLoadFailure)
/// **This board has a `.git` the app cannot open** (06-history-undo.md Rules, "A `.git` that
/// isn't a valid repository still reads as git mode and fails loudly", ruled 2026-07-31).
/// Condition, error tone, standing at the breakage rank the ruling's own class ("a standing
/// breakage-class banner at detection").
///
/// **No payload**, which is the ruling read literally: the sentence is fixed
/// (`BannerCenter.repositoryUnreadableMessage`, 06's words verbatim), and there is nothing to
/// diagnose past "libgit2 will not open it" a `reason` tail would be an invitation to leak
/// developer prose into a line the design already wrote. `HistorySuspension` carries one because
/// *its* tail genuinely varies ("disk full"); this row's cannot.
///
/// It heals rather than being dismissed, like every condition: "the banner clears when a later
/// open or reload finds the repo readable."
case repositoryUnreadable
/// A write that did not happen. Dismissable, error tone.
case oneShot(OneShotBanner)
/// A git operation that did not happen an undo restore, a branch switch, and (pro-m2) a pull
/// or a push. Dismissable, error tone, and it ranks *with* `oneShot`: the one-shot failure
/// class's second shape (settled 2026-07-31, see `GitFailureBanner`).
case gitFailure(GitFailureBanner)
/// Content that didn't arrive though nothing failed folders skipped from a Finder drop, the
/// app's own relocation and repair notices. Dismissable, warning tone: below the true failures above it,
/// above the ambient notices below it (settled 2026-07-28, see `LossBanner`).
case loss(LossBanner)
/// History has stopped advancing. Condition, warning tone the files are safe, only the undo
/// trail is degraded, which is a warning rather than an error. (m7's committer drives it.)
/// trail is degraded, which is a warning rather than an error. No producer today; the row waits
/// for whatever substrate can stall.
case historySuspended(HistorySuspension)
/// Work in flight. Info tone, spinner, **pinned above everything** and exempt from the collapse.
/// (m5's copy-shaped work and m7's git brackets drive it.)
/// (m5's copy-shaped work drives it.)
case inProgress(InProgressOperation)
/// A calm notice the passive half of the info tone, ranking last of all. (m6's card window
/// drives it, as 07-sync-collab.md's remote-change signpost.)
@@ -322,9 +228,7 @@ public enum BannerRow: Identifiable, Sendable {
switch self {
case .readOnlyLock: "read-only-lock"
case .reloadBreakage: "reload-breakage"
case .repositoryUnreadable: "repository-unreadable"
case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
case let .gitFailure(banner): "git-failure:\(banner.id.uuidString)"
case let .loss(loss): "loss:\(loss.id.uuidString)"
case .historySuspended: "history-suspension"
case let .inProgress(operation): "operation:\(operation.id.uuidString)"
@@ -334,15 +238,9 @@ public enum BannerRow: Identifiable, Sendable {
public var tone: BannerTone {
switch self {
// A git operation that failed is an action that didn't happen, so it takes the failure
// tone with the write failures it ranks beside "never as a warning-tone loss row"
// (02-architecture.md § The banner surface, settled 2026-07-31).
// The unreadable repository takes the **error** tone rather than the history suspension's
// warning, and the two sit either side of a real line: a suspension is history failing to
// advance and retrying every debounce, while this is a repository the app cannot open at
// all nothing it does will fix it, and 06 asks for a "breakage-class" row that "fails
// loudly". The reload breakage is the precedent the ruling names, and it is an error.
case .readOnlyLock, .reloadBreakage, .repositoryUnreadable, .oneShot, .gitFailure: .error
case .readOnlyLock, .reloadBreakage, .oneShot: .error
// A suspension is history failing to advance and retrying, which is degraded rather than
// broken the files are safe either way, so it takes the warning tone the loss row does.
case .historySuspended, .loss: .warning
case .inProgress, .signpost: .info
}
@@ -361,9 +259,7 @@ public enum BannerRow: Identifiable, Sendable {
switch self {
case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
case let .reloadBreakage(error): BannerCenter.headline(for: error)
case .repositoryUnreadable: BannerCenter.repositoryUnreadableMessage
case let .oneShot(banner): BannerCenter.headline(for: banner.error)
case let .gitFailure(banner): BannerCenter.headline(for: banner)
case let .loss(loss): loss.message
case let .historySuspended(suspension): BannerCenter.headline(for: suspension)
case let .inProgress(operation): operation.label
@@ -377,10 +273,9 @@ public enum BannerRow: Identifiable, Sendable {
public var dismissID: UUID? {
switch self {
case let .oneShot(banner): banner.id
case let .gitFailure(banner): banner.id
case let .loss(loss): loss.id
case let .signpost(signpost): signpost.id
case .readOnlyLock, .reloadBreakage, .repositoryUnreadable, .historySuspended, .inProgress: nil
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
}
}
@@ -464,12 +359,11 @@ public enum BannerRowControl: Identifiable, Sendable {
///
/// ### What lives here and what does not
///
/// A center holds the state nothing else does: dismissable one-shot failures in both shapes (write
/// and git-operation), loss rows, the history suspension, in-progress operations, and passive
/// signposts. It deliberately does **not** hold the read-only lock or the reload breakage those
/// are `BoardStore`'s truths, and copying them here would create a second place for them to be
/// stale. `BoardStore.bannerRows` composes both halves through
/// `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:gitFailures:)`, which is a
/// A center holds the state nothing else does: dismissable one-shot write failures, loss rows, the
/// history suspension, in-progress operations, and passive signposts. It deliberately does **not**
/// hold the read-only lock or the reload breakage those are `BoardStore`'s truths, and copying them
/// here would create a second place for them to be stale. `BoardStore.bannerRows` composes both
/// halves through `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:)`, which is a
/// *pure function* precisely so the precedence rule can be tested without a store, a window, or a
/// filesystem.
///
@@ -480,10 +374,7 @@ public enum BannerRowControl: Identifiable, Sendable {
/// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no
/// `default`**, so a Writer operation added without a sentence to say about it is a compile-time
/// hole rather than a silent fallback. The same rule covers locks, breakage, and the history
/// suspension: their user-facing lines are here, not on the error types. **And the git operations
/// too** (settled 2026-07-31): `GitOperation` is that vocabulary's git-side twin "the operation
/// named in the user's words plus the underlying error, phrasing still BannerCenter's" which is
/// why `GitOperationFailure.operation`, a developer-facing string, never reaches a row.
/// suspension: their user-facing lines are here, not on the error types.
///
/// ### One center per window, not per board
///
@@ -502,37 +393,14 @@ public final class BannerCenter {
/// turn still order deterministically.
public private(set) var oneShots: [OneShotBanner] = []
/// The failure class's git-shaped half, newest first like `oneShots` a failed undo restore,
/// a failed branch switch, and (pro-m2) a failed pull or push (settled 2026-07-31, see
/// `GitFailureBanner`).
///
/// **A second array rather than a second case inside `OneShotBanner`**: the two shapes have no
/// payload in common one carries the closed `WriteOperation` vocabulary and a path, the other
/// a git operation and libgit2's message and every reader of a write failure would have to
/// start asking whether it was one. They meet where the design says they meet, in the strip's
/// order: `rows(...)` merges them into one precedence class, newest first across both.
public private(set) var gitFailures: [GitFailureBanner] = []
/// Newest first, like `oneShots` content that didn't arrive though nothing failed,
/// dismissable and untimed for the same reason a one-shot failure is (settled 2026-07-28, see
/// `LossBanner`).
public private(set) var losses: [LossBanner] = []
/// The standing "history isn't advancing" condition, or `nil` when commits are landing.
/// The standing "history isn't advancing" condition, or `nil` when history is keeping up.
public private(set) var historySuspension: HistorySuspension?
/// **The standing "this board's git repository can't be read" condition** (06-history-undo.md
/// Rules, ruled 2026-07-31) `false` on every board whose repository opens, and on every board
/// that has none.
///
/// It lives here rather than on `BoardStore` where the lock and the breakage live because it
/// is not the store's truth: the fact belongs to the board's git state
/// (`HistoryStore.isRepositoryUnreadable`, itself the committer's pause), and the store learns it
/// through the same kind of seam the history suspension already arrives by
/// (`BoardStore.noteRepositoryUnreadable(_:)`). One condition, one owner, no second copy to go
/// stale.
public private(set) var isRepositoryUnreadable = false
/// Work in flight, newest first for the same reason `oneShots` is.
public private(set) var operations: [InProgressOperation] = []
@@ -552,19 +420,6 @@ public final class BannerCenter {
oneShots.insert(OneShotBanner(error: error), at: 0)
}
/// **Records a git operation that didn't happen** the failure class's second shape (settled
/// 2026-07-31, see `GitFailureBanner`): an undo or redo restore that failed cleanly
/// (06-history-undo.md Interaction with external writers), a branch switch that could not run
/// (06 Branch switching), and the remote pair when pro-m2 wires them.
///
/// `reason` is the underlying error and nothing else libgit2's own message, or the app's own
/// diagnosis of a repository it could not read. **Every user-facing word is composed here**
/// (`headline(for:)`), which is what keeps a caller from inventing a verb: the git layer's
/// `GitOperationFailure.operation` is a developer-facing string and never reaches the strip.
public func postGitFailure(_ operation: GitOperation, reason: String) {
gitFailures.insert(GitFailureBanner(operation: operation, reason: reason), at: 0)
}
/// Posts a loss row content that didn't arrive though nothing failed (settled 2026-07-28, see
/// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
///
@@ -804,32 +659,29 @@ public final class BannerCenter {
nonisolated static let mixedTrashDragMessage =
"Cards and lanes leave the trash separately \u{2014} restore one kind at a time"
/// Removes a dismissable row: a one-shot failure in either shape, a loss row, or a signpost.
/// Removes a dismissable row: a one-shot failure, a loss row, or a signpost.
/// **An id that names an in-progress operation is ignored** rather than ending it, because
/// "dismiss" and "cancel" are different promises and a row that offers one must never quietly do
/// the other.
public func dismiss(_ id: UUID) {
oneShots.removeAll { $0.id == id }
gitFailures.removeAll { $0.id == id }
losses.removeAll { $0.id == id }
signposts.removeAll { $0.id == id }
}
/// Removes every dismissable row both failure shapes, losses, and signposts alike. The strip's
/// Removes every dismissable row failures, losses, and signposts alike. The strip's
/// own "clear all" affordance later; today it is what a window uses when it re-homes its rows
/// elsewhere (m6).
public func dismissAllDismissableRows() {
oneShots.removeAll()
gitFailures.removeAll()
losses.removeAll()
signposts.removeAll()
}
// MARK: History suspension
/// Raises (or refreshes) the "changes aren't being recorded to history" condition m7's
/// committer calls this when a commit fails past `index.lock` contention (06-history-undo.md
/// covers the lock itself, which is deliberately *not* a banner).
/// Raises (or refreshes) the "changes aren't being recorded to history" condition for whatever
/// substrate can stall while the files themselves are landing fine. No producer today.
///
/// A second call while already suspended keeps the original `since` and takes the newer
/// `reason`: the condition never stopped being true, so restarting its clock would misreport
@@ -838,39 +690,18 @@ public final class BannerCenter {
historySuspension = HistorySuspension(reason: reason, since: historySuspension?.since ?? Date())
}
/// Clears it, on the first successful commit. Idempotent clearing a condition that is not
/// standing is not an error, it is the ordinary shape of "commit succeeded".
/// Clears it, the moment history catches up. Idempotent clearing a condition that is not
/// standing is not an error, it is the ordinary shape of "it worked".
public func clearHistorySuspension() {
historySuspension = nil
}
// MARK: The unreadable repository
/// **Raises the standing "this board's git repository can't be read" condition**
/// (06-history-undo.md Rules, ruled 2026-07-31) the detection-time probe's answer, and any
/// later read that reaches the same conclusion.
///
/// Idempotent, and it deliberately records nothing about *when*: unlike the history suspension
/// whose `since` exists so a later "suspended for 4 minutes" reading could be built there is
/// nothing about this condition's duration a user could act on. The repository is unreadable or
/// it is not.
public func raiseRepositoryUnreadable() {
isRepositoryUnreadable = true
}
/// Clears it "the banner clears when a later open or reload finds the repo readable" (06).
/// Idempotent, `clearHistorySuspension()`'s rule: clearing a condition that is not standing is
/// the ordinary shape of a repository that was fine all along.
public func clearRepositoryUnreadable() {
isRepositoryUnreadable = false
}
// MARK: In-progress operations
/// Starts an info row with a spinner and hands back its id.
///
/// - Parameter cancel: non-`nil` only for copy-shaped work, where cancelling means "remove the
/// partial copy, nothing lost" (02, settled). Git brackets pass `nil`.
/// partial copy, nothing lost" (02, settled). Everything else passes `nil`.
@discardableResult
public func beginOperation(label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) -> UUID {
let operation = InProgressOperation(label: label, cancel: cancel)
@@ -880,9 +711,9 @@ public final class BannerCenter {
/// **Relabels a running operation** the same row, still spinning, now saying something else.
///
/// It exists for one sentence in 06-history-undo.md Interaction with external writers:
/// "contention outlasting the brief retry surfaces as a *waiting* state in the operation's
/// in-progress banner row ('waiting for another writer's git lock'), retrying on its cadence".
/// It exists for the waiting state a long operation can fall into contention outlasting a brief
/// retry surfaces in the operation's *own* in-progress row ("waiting for another writer"),
/// retrying on its cadence.
/// The waiting state is explicitly *the operation's own row*, not a second row and not a
/// replacement the operation has not restarted, it is explaining itself so the id is stable
/// and the view neither churns nor re-animates.
@@ -937,25 +768,14 @@ public final class BannerCenter {
/// a condition and a one-shot are not comparable by recency in any way a user would read as
/// order the condition's `since` is when it *started* being true, not when it happened
/// so they are ordered by kind, and recency orders only the one-shots among themselves.
/// - **The failure rank holds both shapes, interleaved by recency** (settled 2026-07-31):
/// "failures rank by what they are, not by which error vocabulary threw them", so a failed
/// undo posted a second ago sits above a failed move from a minute ago and below one from a
/// second before it. Ties two rows sharing a `Date` to the microsecond put the write
/// failure first; the two shapes are posted from different call sites, so a tie is an
/// accident of the clock rather than an order anyone can read.
/// - **The failure rank is one class, ordered by recency** (settled 2026-07-31): "failures rank
/// by what they are, not by which error vocabulary threw them". The rank held two row shapes
/// while the git stack was wired, interleaved by `occurredAt`; the merge went with the second
/// shape and the rank is ready to take another the day one arrives.
///
/// - **The unreadable repository stands in the breakage class, just under the reload breakage**
/// (06-history-undo.md Rules, ruled 2026-07-31: "a standing breakage-class banner"). Under,
/// and not over, because the two describe different things going wrong and one of them is
/// about the user's content: a reload breakage means the board on screen is not the board on
/// disk, while an unreadable repository leaves every file exactly as it is and pauses only the
/// history over them. Both outrank every one-shot, which is what "breakage-class" buys.
///
/// `signposts`, `gitFailures` and `repositoryUnreadable` carry defaults: the first because its
/// producer is m6's card window, the other two because a center that hosts no git surface (a card
/// window's own) can never hold either. Every other class is spelled out at every call site
/// `losses` included, since a Finder drop that skipped folders already posts one
/// (`postSkippedFolders`).
/// `signposts` carries a default because its producer is m6's card window. Every other class is
/// spelled out at every call site `losses` included, since a Finder drop that skipped folders
/// already posts one (`postSkippedFolders`).
public nonisolated static func rows(
lock: ReadOnlyLockReason?,
breakage: BoardLoadFailure?,
@@ -963,9 +783,7 @@ public final class BannerCenter {
losses: [LossBanner],
suspension: HistorySuspension?,
operations: [InProgressOperation],
signposts: [InfoSignpost] = [],
gitFailures: [GitFailureBanner] = [],
repositoryUnreadable: Bool = false
signposts: [InfoSignpost] = []
) -> [BannerRow] {
var rows: [BannerRow] = []
@@ -977,15 +795,9 @@ public final class BannerCenter {
if let breakage {
rows.append(.reloadBreakage(breakage))
}
if repositoryUnreadable {
rows.append(.repositoryUnreadable)
}
let ordered = newestFirst(oneShots, by: \.occurredAt)
rows.append(contentsOf: failureRank(
writes: ordered.filter { !$0.isAttachmentImport },
git: newestFirst(gitFailures, by: \.occurredAt)
))
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
rows.append(contentsOf: newestFirst(losses, by: \.occurredAt).map(BannerRow.loss))
@@ -998,40 +810,13 @@ public final class BannerCenter {
return rows
}
/// **The failure rank, both shapes** (settled 2026-07-31): the non-attachment write failures and
/// the git-operation failures, merged into the one precedence class they share "failures rank
/// by what they are, not by which error vocabulary threw them".
///
/// A merge rather than a re-sort, because both inputs arrive newest-first already; ties keep the
/// write failure first, which is arbitrary and says so (`rows(...)`).
private nonisolated static func failureRank(
writes: [OneShotBanner],
git: [GitFailureBanner]
) -> [BannerRow] {
var merged: [BannerRow] = []
var writes = writes[...]
var git = git[...]
while let write = writes.first, let failure = git.first {
if failure.occurredAt > write.occurredAt {
merged.append(.gitFailure(failure))
git = git.dropFirst()
} else {
merged.append(.oneShot(write))
writes = writes.dropFirst()
}
}
merged.append(contentsOf: writes.map(BannerRow.oneShot))
merged.append(contentsOf: git.map(BannerRow.gitFailure))
return merged
}
/// Newest first, and **stable**: `sorted(by:)` is not, and two rows posted in the same run loop
/// turn can share a `Date` to the microsecond. Ties fall back to the input order, which every
/// `post` maintains newest-first on insertion so a tie renders in the order it was posted
/// rather than in whatever order the sort happened to leave.
///
/// One function over a date key rather than one per class: the three dismissable classes order
/// by exactly the same rule, and three copies of it were three places for it to drift.
/// One function over a date key rather than one per class: the dismissable classes order
/// by exactly the same rule, and a copy per class was a place for it to drift.
private nonisolated static func newestFirst<Row>(_ rows: [Row], by occurredAt: KeyPath<Row, Date>) -> [Row] {
rows
.enumerated()
@@ -1056,39 +841,6 @@ public final class BannerCenter {
return cause.isEmpty ? action : "\(action)\(cause)"
}
/// The user-facing line for a git operation that didn't happen: what the app could not do, then
/// why the write failure's shape exactly (settled 2026-07-31), because the two are one class.
///
/// The cause is the underlying error verbatim, on `causePhrase(for:)`'s reasoning read one layer
/// down: libgit2's messages are specific in a way no re-phrasing of ours would be, and the
/// alternative to showing one is a shrug. An empty or whitespace-only reason leaves the action
/// clause alone rather than trailing a dash into nothing.
public nonisolated static func headline(for failure: GitFailureBanner) -> String {
let action = actionPhrase(for: failure.operation)
let cause = trimmed(failure.reason)
return cause.isEmpty ? action : "\(action)\(cause)"
}
/// **Exhaustive by construction no `default`**, the `WriteOperation` rule applied to the git
/// vocabulary: an operation added to `GitOperation` without a sentence here fails to compile.
///
/// The undo pair is named by **the command the user pressed** the skipped-step row's rule
/// (`skippedStepMessage`), and for its reason: Z is the gesture, and "Couldn't restore an
/// earlier state" would describe machinery the user has no model of. The branch switch is named
/// by the control they used (the popover's branch picker) and stays plural "branches" rather
/// than naming the target, which the in-progress row this failure replaces already said
/// ("Switching to 'main'") and which `GitOperation` deliberately doesn't carry.
private nonisolated static func actionPhrase(for operation: GitOperation) -> String {
switch operation {
case .undo: "Undo failed"
case .redo: "Redo failed"
case .branchSwitch: "Couldn't switch branches"
// Named by the control the user pressed, like the pair above "Add Git" is the button, and
// the row is only ever seen by someone who pressed it and looked away.
case .addGit: "Couldn't add git to this board"
}
}
/// **Exhaustive by construction no `default`.** A `WriteOperation` case added without a
/// sentence here fails to compile, which is the settled contract ("a new Writer operation
/// without a banner rendering is a compile-time hole, not a silent default").
@@ -1521,25 +1273,8 @@ public final class BannerCenter {
return "\(verb) skipped — '\(subject)' changed outside Lanework"
}
/// **The unreadable repository's line 06-history-undo.md Rules' own sentence, verbatim**
/// (ruled 2026-07-31): "a standing breakage-class banner at detection ('This board's git
/// repository can't be read history is paused; Lanework leaves the repository untouched')".
///
/// A `static let` rather than a `headline(for:)` overload because the row carries nothing to
/// compose from: three of its four sibling conditions take a payload and phrase around it, and
/// this one is one fixed sentence. It stays here rather than on the git layer for the standing
/// reason "the banner owns all user-facing phrasing" which is also why the git layer's own
/// clause for the same state (`GitRepositoryPause.unreadable.explanation`, a fragment for
/// failure tails) never reaches the strip.
///
/// The three clauses are the ruling's and each is load-bearing: what is wrong, what it costs
/// (history is paused not the board, which loads and edits normally), and the promise that
/// makes waiting safe (the app will not try to repair a repository it cannot read).
public nonisolated static let repositoryUnreadableMessage =
"This board's git repository can't be read \u{2014} history is paused; Lanework leaves the repository untouched"
/// The suspended-history line. It names the *consequence* the user cares about undo and the
/// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries
/// flush-before-overwrite guarantee are degraded rather than the mechanics, and carries
/// the diagnosis as its tail.
public nonisolated static func headline(for suspension: HistorySuspension) -> String {
let reason = trimmed(suspension.reason)
-26
View File
@@ -244,21 +244,6 @@ public enum BoardAnnouncer {
public var breakageBefore: BoardLoadFailure?
public var breakageAfter: BoardLoadFailure?
/// **The unreadable-repository condition before and after** (06-history-undo.md Rules,
/// ruled 2026-07-31: the standing breakage-class banner, "announced per
/// 10-accessibility.md"). Booleans rather than a payload for the row's own reason the
/// sentence is fixed and a pair rather than a single flag for the lock's: what is
/// announced is the *transition*, in either direction.
///
/// No reload ever sets these. The condition is detected at board open and healed by the
/// paused engine's own re-read, neither of which is a reload so its producer is
/// `BoardStore.noteRepositoryUnreadable(_:)`, exactly as the writability probe's lock is
/// `announceLockChange(from:)`'s. They live on this value anyway because the ladder is where
/// "one sentence, chosen by precedence" is decided, and a second announcer would be a second
/// voice.
public var repositoryUnreadableBefore = false
public var repositoryUnreadableAfter = false
public init() {}
}
@@ -319,14 +304,6 @@ public enum BoardAnnouncer {
if let breakage = facts.breakageAfter, breakage != facts.breakageBefore {
return AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage))
}
// Last of the raised conditions, matching the strip's own precedence: the two above it
// describe the board's files, this one describes the history over them.
if facts.repositoryUnreadableAfter, !facts.repositoryUnreadableBefore {
return AccessibilityPhrases.bannerLabel(
tone: .error,
headline: BannerCenter.repositoryUnreadableMessage
)
}
return nil
}
@@ -339,9 +316,6 @@ public enum BoardAnnouncer {
if facts.breakageBefore != nil, facts.breakageAfter == nil {
return AccessibilityPhrases.reloadBreakageCleared
}
if facts.repositoryUnreadableBefore, !facts.repositoryUnreadableAfter {
return AccessibilityPhrases.repositoryUnreadableCleared
}
return nil
}
}
+1 -36
View File
@@ -158,16 +158,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
/// `UserDefaults`.
public var remoteLocationWarned: Bool
/// **A bracketed git operation this app started and has not finished** (06-history-undo.md Rules
/// Abnormal repo states: "every bracketed operation stamps its intent app-side (per-board
/// registry) before touching the repo").
///
/// `nil` for every board that is not mid-operation, which is every board almost all of the time:
/// the stamp is written immediately before the repository is touched and cleared as soon as the
/// operation is over, so finding one at open means the app died in between. See
/// `GitOperationStamp` for why it lives here rather than in the board folder or under `.git`.
public var gitOperationStamp: GitOperationStamp?
public init(
id: UUID = UUID(),
bookmark: Data,
@@ -182,8 +172,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
pushOnCommit: Bool = false,
remoteLocationWarned: Bool = false,
icon: String? = nil,
iconColor: String? = nil,
gitOperationStamp: GitOperationStamp? = nil
iconColor: String? = nil
) {
self.id = id
self.bookmark = bookmark
@@ -199,7 +188,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
self.remoteLocationWarned = remoteLocationWarned
self.icon = icon
self.iconColor = iconColor
self.gitOperationStamp = gitOperationStamp
}
// MARK: Codable
@@ -227,7 +215,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
case iconColor
case pushOnCommit
case remoteLocationWarned
case gitOperationStamp
}
public init(from decoder: any Decoder) throws {
@@ -246,11 +233,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
iconColor = try container.decodeIfPresent(String.self, forKey: .iconColor)
pushOnCommit = try container.decodeIfPresent(Bool.self, forKey: .pushOnCommit) ?? false
remoteLocationWarned = try container.decodeIfPresent(Bool.self, forKey: .remoteLocationWarned) ?? false
// Tolerant twice over: absent on every record written before this key existed, and absent
// again rather than fatal if a future build's stamp `Kind` is one this build cannot name.
// A stamp that cannot be read is a stamp that cannot recover anything, which degrades to the
// pause-and-defer stance rather than to a quarantined registry.
gitOperationStamp = try? container.decodeIfPresent(GitOperationStamp.self, forKey: .gitOperationStamp)
}
public func encode(to encoder: any Encoder) throws {
@@ -269,7 +251,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
try container.encodeIfPresent(iconColor, forKey: .iconColor)
try container.encode(pushOnCommit, forKey: .pushOnCommit)
try container.encode(remoteLocationWarned, forKey: .remoteLocationWarned)
try container.encodeIfPresent(gitOperationStamp, forKey: .gitOperationStamp)
// An unknown key is dropped, exactly as the synthesized conformance dropped it: the file's
// forward tolerance is a decoding property, and nothing here preserves what it cannot read.
}
@@ -599,22 +580,6 @@ public final class BoardRegistry {
update(id) { $0.remoteLocationWarned = true }
}
/// **Records or clears the bracketed git operation this app is about to run**
/// (`GitOperationStamp`).
///
/// It saves the file synchronously like every other setter here, and that is load-bearing rather
/// than incidental: the whole value of the stamp is that it is on disk *before* the repository is
/// touched, so a crash a millisecond later is still recognizable as this app's. `save()` writes
/// atomically, so the file a next launch reads is either the old one or this one.
public func setGitOperationStamp(id: UUID, _ stamp: GitOperationStamp?) {
update(id) { $0.gitOperationStamp = stamp }
}
/// The stamp this board is carrying, if any read once per session, at open.
public func gitOperationStamp(id: UUID) -> GitOperationStamp? {
record(id: id)?.gitOperationStamp
}
// MARK: - Reading
/// Every known board, most recently opened first, each classified by whether its bookmark
+11 -120
View File
@@ -288,10 +288,9 @@ public final class BoardStore: HealHost {
/// still refresh the card window's thread and board search's comment index. Keying either on
/// `snapshotGeneration` would make the value-equal skip a freshness bug rather than an
/// optimization.
/// - **The auto-committer's covering gate** (`GitAutoCommitter.awaitCoveringSnapshot`): what
/// covers a flush is a *walk* that started after its writes hit disk, and a completed walk
/// covers them whether or not the tree turned out to differ. A gate waiting on the applied
/// counter would spin out its whole deadline on any value-equal landing.
/// - **Any covering gate** a consumer waiting for a walk that started after its writes hit
/// disk. A completed walk covers them whether or not the tree turned out to differ, so a gate
/// waiting on the applied counter would spin out its whole deadline on any value-equal landing.
///
/// A failed reload bumps neither counter: it produced no snapshot, so it covers nothing and
/// refreshes nothing the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
@@ -471,41 +470,10 @@ public final class BoardStore: HealHost {
losses: banners.losses,
suspension: banners.historySuspension,
operations: banners.operations,
signposts: banners.signposts,
gitFailures: banners.gitFailures,
repositoryUnreadable: banners.isRepositoryUnreadable
signposts: banners.signposts
)
}
/// **The unreadable repository, raised or healed** (06-history-undo.md Rules, "A `.git` that
/// isn't a valid repository still reads as git mode and fails loudly", ruled 2026-07-31) the
/// session's one call for a condition that is the *git state's* truth rather than this store's
/// (`HistoryStore.isRepositoryUnreadable`, wired in `AppModel.beginSession`).
///
/// It does two things because the ruling asks for two: the row stands on the strip, and it is
/// **announced** "announced per 10-accessibility.md", which makes a standing banner "an
/// accessibility element announced when it appears and when it clears". This condition never
/// arrives on a reload (it is detected at open and healed by the paused engine's own re-read), so
/// it takes `announceLockChange(from:)`'s path exactly: through `BoardAnnouncer`'s ladder rather
/// than posting directly, so the sentence a user hears and the sentence they read off the row are
/// one string.
///
/// Idempotent, and silent when nothing changed: the 15 s re-read that keeps confirming an
/// unreadable repository must not say so every 15 s.
public func noteRepositoryUnreadable(_ unreadable: Bool) {
guard banners.isRepositoryUnreadable != unreadable else { return }
if unreadable {
Self.logger.error("this board's git repository could not be opened — history is paused")
banners.raiseRepositoryUnreadable()
} else {
banners.clearRepositoryUnreadable()
}
var facts = BoardAnnouncer.ReloadFacts()
facts.repositoryUnreadableBefore = !unreadable
facts.repositoryUnreadableAfter = unreadable
announce(BoardAnnouncer.speech(for: facts))
}
// MARK: Wiring
/// The watcher's bracket calls, injected rather than owned: the registry holds the watcher and
@@ -569,18 +537,6 @@ public final class BoardStore: HealHost {
@ObservationIgnored
public weak var history: (any HistoryProviding)?
/// **Where Pro's auto-committer meets the write and reload paths** (06-history-undo.md Rules
/// Auto-commit), or `nil` on every board there is no committer for which is every free-tier
/// board and every Pro board without a repository at its root.
///
/// Injected like `watcherBrackets` and `history`, and for their reason: the committer belongs to
/// the *session* (`HistoryStore.committer`), and a store that reached for one would be a second
/// answer to which committer a board has. `nil` keeps every method below behaving exactly as it
/// did before this milestone which is what makes the free tier's inert posture structural
/// rather than conditional.
@ObservationIgnored
public var commitSeam: HistoryCommitSeam?
// MARK: The open card windows
/// **Which of this board's cards have a card window open right now** one fact, kept for one
@@ -761,28 +717,6 @@ public final class BoardStore: HealHost {
@ObservationIgnored
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
/// **Where git path history reaches the loader** (01-storage-format.md Fractal layout
/// Rules, the duplicate-id winner rule; `BoardLoader.IdentityHistoryRanker`) `nil` on every
/// board the app manages no git for, which is every free-tier board and every Pro board without
/// a repo at its root.
///
/// A **provider** rather than a ranker, for two reasons that point the same way. Each load wants
/// its own ranker, so that a load never answers from a history that has moved since the last one
/// (the ranker caches internally, once, per load). And add-git flips a board into git mode
/// mid-session, which a closure asked at load time absorbs by construction while a value handed
/// over at composition never could.
///
/// `@MainActor` because it is called here, on the main actor, at the head of each reload; what
/// it returns is `Sendable` and does its git work off-main, inside the walk that consults it.
///
/// **The board's first load predates this** `init` runs inside `BoardStoreRegistry.acquire`,
/// before a session exists to compose the git state that supplies it so an opening board's
/// duplicate-id ladder falls through to birth date, and every reload after it consults history.
/// Deliberate, and the narrow cost of composing the git state where the design puts it
/// (`AppModel.beginSession`) rather than where the first walk happens to run.
@ObservationIgnored
var makeIdentityHistoryRanker: (@MainActor () -> BoardLoader.IdentityHistoryRanker?)?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
// MARK: - Init
@@ -904,10 +838,6 @@ public final class BoardStore: HealHost {
let generation = reloadGeneration
let root = rootURL
let barrier = loadBarrier
// Asked once per load, on the main actor, and answered off it: what comes back is a lazy
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
// break a tie for. `nil` everywhere the app manages no git.
let historyRanker = makeIdentityHistoryRanker?()
// **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's
// decision stands for the session, so a reload sees the board the user chose to open.
let skipping = skippedPaths
@@ -928,7 +858,6 @@ public final class BoardStore: HealHost {
outcome = .success(try BoardLoader.load(
boardRoot: root,
skipping: skipping,
historyRanker: historyRanker,
memo: memo,
counter: counter
))
@@ -982,13 +911,6 @@ public final class BoardStore: HealHost {
facts.lockBefore = readOnlyLock
facts.breakageBefore = reloadFailure
// **Whether this reload revealed anything the app does not vouch for** the one bit the
// auto-committer's flush-before-overwrite gate turns on (06 Rules Flush-before-overwrite).
// A failed reload counts as foreign, conservatively: a file the loader could not read is one
// the app certainly did not write, and the safe direction is to let the next app write commit
// what is there before overwriting it.
var sawForeignChange = false
switch outcome {
case let .success(result):
// **What changed, who changed it, and what it cost the cursor** all three computed
@@ -1020,7 +942,6 @@ public final class BoardStore: HealHost {
includingTrash: shownTrash
)
facts.diff = verdicts.foreign
sawForeignChange = verdicts.foreign.boardChanged || !verdicts.foreignItems.isEmpty
// 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
@@ -1170,7 +1091,6 @@ public final class BoardStore: HealHost {
if endsWholesaleOperation, readOnlyLock == nil {
readOnlyLock = .bracketedReloadFailed
}
sawForeignChange = true
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
}
@@ -1181,18 +1101,6 @@ public final class BoardStore: HealHost {
facts.lockAfter = readOnlyLock
facts.breakageAfter = reloadFailure
announce(BoardAnnouncer.speech(for: facts))
// **The auto-commit debounce, armed by every landing** (06 Rules Auto-commit;
// Interaction with external writers: "Agent and hand edits arrive through the watcher like
// any change and get auto-committed on the same debounce").
//
// Here rather than at the watcher, deliberately: a reload landing means the tree walk is
// over, so the committer never races the loader for the same files. **Unconditional on what
// changed**, equally deliberately a reload lands whether or not the snapshot moved, and the
// committer's condition is the *tree*, not the snapshot diff, so a window that touched only
// strays or only `CLAUDE.md` still commits (06 Commit messages Non-snapshot files commit
// too). A landing that finds nothing to commit is the silent no-op, not a wasted trip.
commitSeam?.reloadDidLand(sawForeignChange)
}
/// Installs the recovery `BoardAnnouncer` chose for a focus that vanished under a foreign
@@ -1448,24 +1356,11 @@ public final class BoardStore: HealHost {
if let readOnlyLock {
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
}
// **Flush-before-overwrite** (06-history-undo.md Rules), before the bracket rather than
// inside it: what the committer may need to do here is *commit*, and a commit taken with the
// watcher suspended would be a commit whose own reload never arrives. It is a no-op unless
// the window holds a change the app does not vouch for see `GitAutoCommitter.noteWillWrite`
// for the gate, and for the two costs it is recorded as carrying.
commitSeam?.willWrite()
watcherBrackets?.begin()
// `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.
//
// **The receipt harvest rides the same defer**, and after `end()` deliberately: the committer
// copies the ledger's receipts here because the landing reload *consumes* them, and this is
// the last moment they still describe a completed write nothing has classified yet
// (`EchoLedger.outstandingEntries`). A partway failure harvests too bytes that reached disk
// are bytes the next commit will carry, whoever they belong to.
defer {
watcherBrackets?.end()
commitSeam?.writeBracketDidClose()
}
// **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
@@ -1535,25 +1430,21 @@ public final class BoardStore: HealHost {
try operation()
} catch let error as BoardWriteError {
// Same honesty rule as `performWrite`, applied to the one error type the banner has
// phrasing for. A wholesale operation is usually git's (m7), whose own failure
// vocabulary is not `BoardWriteError` and whose surfacing the suspended-history
// condition, the in-progress row swapping for an error is the committer's to drive;
// but a `BoardWriteError` escaping here is an ordinary failed write and may no more
// bypass the strip than one from `performWrite`.
// phrasing for. A wholesale operation may carry a failure vocabulary of its own, whose
// surfacing is that operation's to drive; but a `BoardWriteError` escaping here is an
// ordinary failed write and may no more bypass the strip than one from `performWrite`.
banners.post(error)
throw error
}
}
/// The same bracket over work that **awaits** the undo restore (06-history-undo.md) and, next,
/// the branch switch.
/// The same bracket over work that **awaits**.
///
/// A sibling rather than a replacement, and the reason is a hard fact about the two callers: the
/// synchronous version above exists because `performWrite`-shaped work is synchronous, while a
/// git operation is a detached libgit2 task the main actor must not block on
/// (`GitRepository`'s isolation rule). Both keep the bracket, the reload floor and the completion
/// phrase in one place; the distinct argument label is what keeps overload resolution from having
/// to guess which one a trailing closure meant.
/// wholesale operation can be a detached task the main actor must not block on. Both keep the
/// bracket, the reload floor and the completion phrase in one place; the distinct argument label
/// is what keeps overload resolution from having to guess which one a trailing closure meant.
///
/// The refusal, the ordering and the arming are the synchronous version's, unchanged see its
/// doc comment for all three.
+16 -14
View File
@@ -318,22 +318,24 @@ public final class EchoLedger: Sendable {
/// **Every receipt the ledger holds right now, with its heal mark read, never consumed.**
///
/// Pro's auto-committer's one call (`GitAutoCommitter.harvest`), and it has to be a copy rather
/// than a read at commit time for an ordering reason worth stating here: receipts are *consumed*
/// by the landing reload that classifies them ("one write, one echo"), and the committer asks its
/// question a debounce later by which time the receipt for the user's own card edit is long
/// gone, and reading the live ledger would attribute the user's own work to `Lanework External`.
/// So the committer copies at the close of each write bracket, when a receipt describes a
/// completed write and nothing has yet had a chance to retire it, and re-applies the satisfaction
/// rule against disk itself (`CommitAttribution`).
/// **The attribution surface** the seam a provenance consumer harvests through, and it has to be
/// a copy rather than a read at attribution time for an ordering reason worth stating here:
/// receipts are *consumed* by the landing reload that classifies them ("one write, one echo"), and
/// a consumer asks its question a debounce later by which time the receipt for the user's own
/// card edit is long gone, and reading the live ledger would attribute the user's own work to an
/// external writer. So a consumer copies at the close of each write bracket, when a receipt
/// describes a completed write and nothing has yet had a chance to retire it, and re-applies the
/// satisfaction rule against disk itself.
///
/// Nothing is retired here, which is what makes this safe to call on every bracket: the
/// announcer's consumption still decides what speaks, and the committer's copy still decides what
/// each commit is authored by.
// The harvest speaks `HarvestedReceipt` committer-side vocabulary from
// Git/CommitAttribution.swift, which the phone target doesn't compile (the mobile MVP has
// no git). The recording side above is what BoardWriter needs on every platform; this copy
// has exactly one caller and it is Mac-only.
/// announcer's consumption still decides what speaks, and a harvested copy still decides what each
/// batch is attributed to.
///
/// **Kept with no live consumer** (`strategy/01-git-excision.md` What is kept): the harvest is
/// the foundation the foreign-change journal will be built on, so the surface stays even though
/// the git consumer that drove it has gone.
// Mac-only because `HarvestedReceipt` is, and the phone target doesn't compile it. The recording
// side above is what BoardWriter needs on every platform; this copy is Mac's alone.
#if os(macOS)
func outstandingEntries() -> [String: HarvestedReceipt] {
receipts.withLock { store in