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
+90 -585
View File
@@ -344,11 +344,12 @@ public final class AppModel {
/// **Read once per session, at composition, and never again** (12 The entitlement: "a lapse
/// never interrupts an open session"). `beginSession` is the only caller.
///
/// **Dormant since PIVOT 2026-08-07** (12-editions.md git left the paywall): the answer is
/// still read and still recorded on the session (`BoardSession.tier`), and **nothing git-shaped
/// consults it any more** `HistoryStore.compose` and `makeHistoryProvider` both lost the axis.
/// The seam is kept unchanged, mechanics and all, because the entitlement is correct for
/// whatever the next base/Pro split turns out to gate; what it does not do is decide undo.
/// **Dormant since PIVOT 2026-08-07** (12-editions.md git left the paywall, and the 2026-08-08
/// ruling then excised git entirely): the answer is still read and still recorded on the session
/// (`BoardSession.tier`), and **nothing consults it any more** `makeHistoryProvider` lost the
/// axis, and the stack it used to gate has gone. The seam is kept unchanged, mechanics and all,
/// because the entitlement is correct for whatever the next base/Pro split turns out to gate;
/// what it does not do is decide undo.
@ObservationIgnored
public var currentTier: @MainActor () -> Tier = { .free }
@@ -357,68 +358,39 @@ public final class AppModel {
/// **The composition root for `HistoryProviding`** (12-editions.md The provider seam): what a
/// board session's undo stack is built by, called once per board as its session begins.
///
/// **The provider follows the board and, since PIVOT 2026-08-07, the board alone**
/// (re-ruled 2026-07-31 12 The provider seam; 13-native-undo.md's header; 06 Rules; then
/// 12 PIVOT 2026-08-07, git leaves the paywall). A board's substrate is decided by what the
/// board *is*. The 2026-07-31 re-ruling took the tier out of every row but one it still decided
/// whether git was on the table at all and the pivot took it out of that one too: "every tier
/// composes the git stack on git-mode boards exactly as Pro did". The rule the re-ruling replaced
/// bound the native stack free-tier-wide and nothing at all on Pro's gitless boards, which made
/// subscribing *remove* undo from a mode-none board an upgrade that takes a feature away.
/// **Every board gets the native stack, and the seam has one answer**
/// (13-native-undo.md's header; `strategy/01-git-excision.md`, ruled 2026-08-08 the app-managed
/// git substrate is excised, so `Kanban/History/` is the only one there is). Nothing about a board
/// decides this any more: not its tier (12 PIVOT 2026-08-07 took the last row the tier decided),
/// not whether it sits inside somebody's repository, not what is on disk beside it. A board
/// nobody has done anything special to and a board living in a user's git repo bind the same
/// stack, which was already true before this ruling and is now true by construction.
///
/// It takes the store because that is what a provider is a history *of*: the git provider needs
/// the board root it is a repository at, and the native one's steps are computed from the same
/// store's snapshots. A property rather than an initializer argument so a test
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
/// nothing renders from it.
/// ### Why it is still a seam
///
/// ### The two answers, and the two `nil`s that are no longer among them
/// Deliberately kept, and the excision plan says so in as many words ( Reversibility: "the seams
/// the git stack plugged into are all nil-safe/default-native and are **kept**, so a future
/// provider journal, ops service, or even git again re-binds without re-plumbing"). Two things
/// it buys today: a test binds a fake substrate or a substrate-less board without a second
/// `AppModel` initializer, and the day a second provider exists it arrives as a different default
/// here rather than as a branch threaded through the session.
///
/// - **Mode `git`** the git provider: undo as forward restore commits over HEAD's first-parent
/// ancestry (06). **In every tier** since the pivot; this row used to read "Pro only no other
/// tier composes a git state", because `HistoryStore.compose` answered `nil` off Pro without so
/// much as a `stat`.
/// - **Mode `none`, `repoNested`, and `unverifiable` alike** the **native stack**. "Boards
/// without app-managed git repo-nested included bind 13-native-undo.md's native stack in
/// **every** tier" (03-board-ui.md Toolbar Catalog, re-ruled 2026-07-31 twice; 12 The
/// provider seam; 13's header). `unverifiable` joins the same branch structurally a denial
/// can never be told apart from a repository actually being there, so it takes `repoNested`'s
/// posture, undo included (06 Rules Detection). This is where a board nobody opted git in
/// for lands, permanently: opt-in per board is exactly what the pivot left standing (13's
/// header, read through 12 PIVOT 2026-08-07).
/// - **No `HistoryStore` at all** the argument stays optional for the seam's own reason (a test
/// binds a git-stateless board through it), and no session composes one: `compose` returns a
/// store for every board there is. It answers the native stack, which is what it always
/// answered for the free tier that used to arrive this way.
/// It takes the store because that is what a provider is a history *of*: the native stack's steps
/// are computed from that store's snapshots, and a provider that needed the board root would find
/// it there too. `@ObservationIgnored` because nothing renders from it.
///
/// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone
/// stance "concerns *git*, and this stack never touches git memory-only, journal-free,
/// session-scoped so what repo-nested denies is app-managed history, never Z" (13's header).
/// It also made the Pro upgrade story exceptional, which was the other half of the same defect:
/// the free tier could not tell such a board from a plain one and bound the native stack anyway,
/// so subscribing *removed* undo from exactly the boards it left alone. Edit Undo/Redo and the
/// toolbar pair now disable only under a lock and on an empty stack.
///
/// This closure therefore never answers `nil`, and the seam stays optional for the seam's own
/// reason: a test binds a substrate-less board through it (`BoardUndoManager.history`).
///
/// The `HistoryStore` argument is what makes the git/gitless split decidable here, and it is why
/// `beginSession` composes the git state *before* the provider: which substrate a board gets is a
/// question about its repository, and a root that had to ask the disk itself would be a second
/// detection.
/// **The production closure never answers `nil`**, and the optionality is the seam's rather than a
/// board's: `nil` means "no undo at all", which no board the app composes is
/// (`BoardUndoManager.history` answers the empty way over an absent substrate, which is what makes
/// a test able to bind one).
///
/// ### Consumers
///
/// `beginSession` calls it once per board, and `bindHistoryProvider(for:)` calls it again on the
/// one event that changes a board's answer under an open session add-git's commanded flip,
/// which swaps `none`'s native stack for `git`'s trail.
/// `beginSession`, once per board. Nothing re-binds a live session's substrate: the one event that
/// used to add-git's commanded mid-session mode flip went with the git stack.
@ObservationIgnored
public var makeHistoryProvider: (BoardStore, HistoryStore?) -> (any HistoryProviding)? = { store, git in
guard let git else { return NativeHistoryProvider() }
switch git.mode {
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
case .none, .repoNested, .unverifiable: return NativeHistoryProvider()
}
public var makeHistoryProvider: (BoardStore) -> (any HistoryProviding)? = { _ in
NativeHistoryProvider()
}
// MARK: Sessions
@@ -440,23 +412,21 @@ public final class AppModel {
/// window). It lives here for the store's reason exactly: the session is what every window
/// over this board shares, and "undo is board-local".
///
/// Which implementation it is, is the board's answer and nobody else's
/// (12-editions.md The provider seam, read through PIVOT 2026-08-07) see
/// `AppModel.makeHistoryProvider`.
/// Which implementation it is comes from one place see `AppModel.makeHistoryProvider`,
/// which since the 2026-08-08 git excision has exactly one answer for every board.
///
/// **`nil` is a board with no undo at all, and no board the app composes is one any more**
/// (re-ruled 2026-07-31 see `AppModel.makeHistoryProvider`): boards without app-managed git,
/// repo-nested included, bind the native stack in every tier, and git boards bind the git
/// provider. What keeps the optionality is the seam rather than a board: a test binds a
/// substrate-less session through `makeHistoryProvider`, and a store with no session at all
/// registers nothing (`BoardStore.registerStep`). The command surface disables through
/// `undoManager`, which answers the empty way over an absent substrate.
/// **`nil` is a board with no undo at all, and no board the app composes is one**. What keeps
/// the optionality is the seam rather than a board: a test binds a substrate-less session
/// through `makeHistoryProvider`, and a store with no session at all registers nothing
/// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which
/// answers the empty way over an absent substrate.
///
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
/// sanctioned mid-session mode flip, *swaps* the substrate here on the board it flips
/// native out, git in, the in-session steps discarded with it
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it `tier` has no setter,
/// and since PIVOT 2026-08-07 it has no say in this either.
/// A `var` rather than a `let` beside `tier`, and now for no event at all: the one sanctioned
/// mid-session substrate swap was add-git's commanded mode flip, which went with the git
/// stack. It stays a `var` because a second provider is a live possibility
/// (`strategy/01-git-excision.md` Reversibility) and because nothing is bought by tightening
/// it; a tier lapse could never touch it `tier` has no setter, and since PIVOT 2026-08-07 it
/// has no say in this either.
public var history: (any HistoryProviding)?
/// **The tier this board composed under** (12-editions.md The entitlement) recorded,
@@ -467,46 +437,14 @@ public final class AppModel {
/// nothing anywhere that re-evaluates a live session's tier, so a subscription ending
/// mid-session is a fact about the *next* open and about nothing already on screen.
///
/// **Nothing git-shaped reads it.** Git left the paywall (12 PIVOT 2026-08-07), so the
/// substrate, the committer, the branch controls and the History surfaces are all decided by
/// the board's mode alone. It stays recorded because the entitlement's machinery stays built
/// and correct for whatever the re-decided base/Pro split gates, and because the fact a board
/// opened under is a composition-time answer the way the provider binding is: whatever asks
/// next must get what this board opened with, never what the entitlement says at render time.
/// **Nothing reads it.** Git left the paywall (12 PIVOT 2026-08-07) and then left the app
/// (`strategy/01-git-excision.md`, ruled 2026-08-08), so no surface anywhere is decided by
/// this. It stays recorded because the entitlement's machinery stays built and correct for
/// whatever the re-decided base/Pro split gates, and because the fact a board opened under is
/// a composition-time answer the way the provider binding is: whatever asks next must get what
/// this board opened with, never what the entitlement says at render time.
public let tier: Tier
/// **This board's git state** (06-history-undo.md Rules; 02-architecture.md Components
/// HistoryStore) the detected mode, the repository behind it in git mode, and the
/// add-git action the popover offers on a board that has none.
///
/// **Never `nil` on a session the app composes**, since PIVOT 2026-08-07 (12-editions.md):
/// `HistoryStore.compose` runs detection for every board in every tier and returns a store.
/// It used to answer `nil` under the free tier the inert-`.git` posture made structural
/// rather than remembered and that posture is retired. The optionality survives for the
/// seam's sake alone: a test binds a git-stateless session through `makeHistoryProvider`.
///
/// A `let` beside `tier`, for `tier`'s reason: which board this is a git story *of* is
/// settled at composition and cannot change under an open session. What can change is the
/// mode *inside* it, by add-git alone the one commanded mid-session flip 06 allows.
public let git: HistoryStore?
/// The mode this board is being edited in, `none` when there is no git state at all which
/// no session the app composes is any more (see `git` above), and which a board nobody has
/// added git to detects anyway.
///
/// **Its first consumer is the provider seam** `makeHistoryProvider` reads exactly this to
/// know whether the board has a repository to be an undo stack for, and it is the *mode* and
/// nothing else that decides (re-ruled 2026-07-31; the tier's last say went with PIVOT
/// 2026-08-07): `git` binds the git provider, `none` and `repoNested` alike the native stack.
/// The popover's git section is the other reader and the one place the two gitless modes
/// still differ, since add-git is offered on one and explained away on the other.
///
/// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing
/// type's isolation, and everything that asks a session what mode it is in is main-actor
/// work anyway (a menu, a popover, a provider being composed).
@MainActor
public var gitMode: BoardGitMode { git?.mode ?? .none }
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
@@ -903,115 +841,15 @@ public final class AppModel {
// gains no network dependency." Synchronous, over facts already in memory. It is the *only*
// time this board asks: the answer becomes `BoardSession.tier` and nothing re-derives it.
//
// **Recorded, and consulted by nothing below** (12 PIVOT 2026-08-07 git left the
// paywall). This line used to sit on the same line as the git state it gated; the gate is
// gone, and what is left is a dormant fact kept for the base/Pro split still to be ruled.
// **Recorded, and consulted by nothing below** (12 PIVOT 2026-08-07 git left the paywall;
// `strategy/01-git-excision.md`, ruled 2026-08-08 git left the app). This line used to sit
// on the same line as the git state it gated; both the gate and the state are gone, and what
// is left is a dormant fact kept for the base/Pro split still to be ruled.
let tier = currentTier()
// **Mode detection** (06-history-undo.md Rules Detection: "checked at every board
// open") unconditionally, in every tier. One `stat` per open, freshly, so a board that
// gained or lost a `.git` since its last open opens in the mode it now has. Until the
// 2026-08-07 pivot this call took the tier and answered `nil` under `.free`, which bought
// the free tier a *zero*-stat open (the inert-`.git` posture); that promise is retired with
// the posture, and every open now pays what Pro's opens always paid.
//
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
//
// **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends
// on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the
// disk itself would be a second detection able to disagree with this one.
//
// **The ledger is the store's own** (06 Interaction with external writers: "the Writer/echo
// machinery the EchoLedger lets the auto-committer classify every observed change, per
// file, as app-mediated or foreign"). `compose` defaults to a fresh one for the store-less
// callers (the add-git surface, unit tests), and a session that took that default would hand
// the committer a ledger nothing ever writes to: every commit this app made would classify
// foreign and be authored `Lanework External`. The default is a fallback, never this path's.
let git = HistoryStore.compose(boardRoot: store.rootURL, ledger: store.echoes)
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior". On a git-mode board
// it is instead the repository's own trail, which survives everything (06 Rules Undo
// survives relaunch) the seam's whole point.
let history = makeHistoryProvider(store, git)
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md Fractal
// layout Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
// load wants its own see `BoardStore.makeIdentityHistoryRanker` and because add-git
// flips the mode mid-session, which this closure picks up for free by asking the git state
// at the moment of each load rather than at composition.
store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker }
// **The auto-commit engine, wired into the session it commits for** (06-history-undo.md
// Rules Auto-commit). Called on every session and not only on git-mode ones, because
// add-git can flip a board mid-session and the committer it builds then must land in
// exactly this shape `activateAutoCommit` remembers the wiring for that.
git.activateAutoCommit { [weak store] committer in
guard let store else { return }
committer.currentSnapshot = { [weak store] in store?.snapshot }
// **The flush awaits the snapshot that covers it** (06 Rules Auto-commit, ruled
// 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush
// awaits a snapshot generation covering its changed paths before the committer runs
// the commit's subject can never be outrun by its own reload". Both halves of that
// await are reads of the store the composer is already diffing, which is why they are
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
//
// The generation the gate counts is `landedReloads` completed *walks* rather than
// applied snapshots because a value-equal reload skips the assignment and its
// counter since 2026-07-31, and a walk covers a flush's paths whether or not it found
// anything to change (`BoardStore.landedReloads`).
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
committer.landedReloads = { [weak store] in store?.landedReloads }
// 02-architecture.md Write-failure surfacing, through the strip the board window
// already renders: a genuine commit failure means "your edits are saved, history has
// stopped advancing", which is exactly what the standing suspension row says. Lock
// contention and a held repository never reach here neither is a failure.
committer.reportFailure = { [weak store] failure in
store?.banners.suspendHistory(reason: failure.message)
}
committer.reportRecovery = { [weak store] in
store?.banners.clearHistorySuspension()
}
// **The corrupt-`.git` loud failure's standing row** (06-history-undo.md Rules,
// ruled 2026-07-31): a repository the app cannot open pauses the whole git surface
// and says so on the strip, "announced per 10-accessibility.md" and the same seam
// heals it, since the paused engine's own 15 s re-read is what notices a repository
// repaired in a terminal. Distinct from the suspension above: that row is history
// failing to advance and retrying, this one is there being nothing to advance into.
committer.reportRepositoryUnreadable = { [weak store] unreadable in
store?.noteRepositoryUnreadable(unreadable)
}
store.commitSeam = .binding(to: committer)
// **The undo stack's ear on the committer** every commit this engine lands, and
// which of it was heal work (06 Rules The stack is HEAD's first-parent ancestry,
// live; Heal commits are transparent to undo). Bound here rather than in
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
// `activateAutoCommit` remembers on its behalf.
committer.reportLanded = { [weak self, ref] window in
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
provider.noteLanded(window)
}
}
// **Add-git swaps the undo substrate too** (06 Rules Detection the one commanded
// mid-session mode flip; 13-native-undo.md's header "discards the in-session native
// stack and seeds the git trail from the root commit"). See `bindHistoryProvider(for:)`.
git.didAddGit = { [weak self] in
self?.bindHistoryProvider(for: ref)
}
// **The form-anchored posture's fallback half** (06 Interaction with external writers,
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead
// when that form has been dismissed before the answer arrived "inline is the primary
// surface, never a silence trap". The banner enumeration is the same one branch switch and
// undo restore post into, one row per failure.
git.reportFailure = { [weak store] failure in
store?.banners.postGitFailure(.addGit, reason: failure.message)
}
// **The detection-time answer, published once** (06 Rules: "a standing breakage-class
// banner **at detection**"). The probe ran inside `compose` above before this session
// existed, and therefore before the seam that carries its transitions was wired so a
// board that opened into an unreadable repository raises its row here rather than
// waiting for the first debounce to rediscover what composition already knows.
store.noteRepositoryUnreadable(git.isRepositoryUnreadable)
// the board session and dies at close/quit ... standard macOS behavior".
let history = makeHistoryProvider(store)
// **The binding 13-native-undo.md Rules' "registration at the Writer boundary" needs**: the
// store is that boundary every app-mediated mutation goes out through one of its write
// methods so it is the store that computes each inverse and registers it. What it cannot
@@ -1024,7 +862,6 @@ public final class AppModel {
recordID: recordID,
history: history,
tier: tier,
git: git,
// The lock's enablement half (13-native-undo.md Rules): Undo and Redo disable with the
// other mutating commands while the board refuses writes, and the stack survives to
// resume when it clears. Weak, so the adapter is never the reason a closed board's store
@@ -1036,183 +873,19 @@ public final class AppModel {
cardRefs: [],
access: access
)
wireGitUndo(history, store: store, git: git, ref: ref)
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
refreshRecents()
}
// MARK: - The git provider's wiring
/// Fills a `GitHistoryProvider`'s seams with the session it is the history of and does nothing
/// at all for any other substrate.
///
/// Everything the git provider needs is a fact about *this* board that neither a repository nor a
/// protocol could supply: which committer's debounce to settle first, whether the git surface is
/// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree
/// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason the provider
/// stays a thing that knows about commits, and the model stays the only object that knows what a
/// window is.
private func wireGitUndo(
_ history: (any HistoryProviding)?,
store: BoardStore,
git: HistoryStore?,
ref: BoardWindowRef
) {
guard let provider = history as? GitHistoryProvider, let git else { return }
provider.flushPendingCommit = { [weak git] in
await git?.committer?.flushNow()
}
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
// The stage-around the settle released comes back with the committer: a card window still open
// after the restore is still a session (`resumeCardSessionStaging(for:)`).
provider.resumeCommitting = { [weak self, weak git] in
git?.committer?.start()
self?.resumeCardSessionStaging(for: ref)
}
// **A restore that failed cleanly** (06 Interaction with external writers: "surfaces as a
// one-shot banner failure naming the operation and the error, the tree left as it was")
// now literally that, at the failure rank in the error tone (02 The banner surface, settled
// 2026-07-31: the one-shot class's second, message-carrying shape). The loss-row compromise
// this line used to carry is retired: a Z that didn't happen is an action that didn't
// happen, not content that didn't arrive.
//
// The closure passes the *direction* and libgit2's own message and stops there "Undo
// failed " is BannerCenter's sentence, from the closed `GitOperation` vocabulary.
provider.reportFailure = { [weak store] direction, failure in
store?.banners.postGitFailure(.restore(direction), reason: failure.message)
}
provider.runBracketed = { [weak store] subject, work in
guard let store else { return await work() }
// The completion phrase 10-accessibility.md gives a bracketed operation is the restore's
// own subject the sentence the trail now carries, spoken once when the reload lands.
try? await store.performWholesale(announcing: subject) { await work() }
}
provider.settleSessions = { [weak self, weak provider] paths in
guard let self, let provider else { return .proceed }
let gate = self.settleGate(for: ref) { [weak provider] folder in
// The card's uncommitted on-disk saves are reverted by the restore itself, which
// compares this folder against the working tree rather than against HEAD see
// `GitRestoreOperation.plan`.
provider?.noteDiscarded(cardFolderName: folder)
}
let outcome = await gate.settle(touching: paths)
// **Only on `.proceed`** a cancelled or failed settle leaves the board exactly as it
// was, sessions and their staging included.
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
return outcome
}
provider.seed()
wireBranchSwitching(git: git, store: store, provider: provider, ref: ref)
}
/// **The branch controls' seams** (06-history-undo.md Branch switching) the five things the
/// switch's sequence needs that a repository cannot supply, plus the per-board stamp that makes an
/// interrupted switch recognizable as this app's.
///
/// Wired beside the undo provider's rather than in a place of its own, because the two are the
/// same board's git session seen from two sides and because both must be re-wired on exactly the
/// same event, add-git's commanded mid-session flip (`bindHistoryProvider(for:)`).
private func wireBranchSwitching(
git: HistoryStore,
store: BoardStore,
provider: GitHistoryProvider,
ref: BoardWindowRef
) {
guard let switcher = git.switcher else { return }
let recordID = sessions[ref]?.recordID
switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() }
switcher.isHeld = { [weak git] in git?.committer?.pause != nil }
switcher.suspendCommitting = { [weak git] in git?.committer?.stop() }
// As on the restore path: what the settle released is a session that has not ended, and the
// window is still open on the other side of the checkout.
switcher.resumeCommitting = { [weak self, weak git] in
git?.committer?.start()
self?.resumeCardSessionStaging(for: ref)
}
// **The undo/redo reseed** the provider's own API, which is the relaunch reseed by
// construction: "discarded and reseeded from the new HEAD's first-parent ancestry redo
// starts empty".
switcher.reseedUndo = { [weak provider] in await provider?.reseed() }
switcher.didSwitch = { [weak git] in await git?.refreshBranch() }
switcher.runBracketed = { [weak store] announcement, work in
guard let store else { return await work() }
try? await store.performWholesale(announcing: announcement) { await work() }
}
switcher.beginProgress = { [weak store] label in
store?.banners.beginOperation(label: label) ?? UUID()
}
switcher.updateProgress = { [weak store] id, label in
store?.banners.updateOperation(id, label: label)
}
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
// The failure rank's git shape, as on the restore path above: a switch that didn't happen is
// an action that didn't happen ("Couldn't switch branches ", BannerCenter's words from
// the operation alone).
switcher.reportFailure = { [weak store] failure in
store?.banners.postGitFailure(.branchSwitch, reason: failure.message)
}
// **The recovery notice stays a loss row**, and the ruling is explicit about why (02 The
// banner surface): "recovery notices report a success, not a failure, and stay warning-tone".
// "A branch switch was interrupted the previous state is restored" is the app tidying up
// after itself, with nothing for the user to do the loss class's own register.
switcher.reportRecovery = { [weak store] message in
store?.banners.postLoss(message)
}
// **The per-board registry is the stamp's home** (`GitOperationStamp`). A session with no
// record a store-level test simply carries no stamp, and recovery then has nothing to
// recognize, which is the honest answer for a board the app has no state for.
switcher.readStamp = { [weak self] in
guard let self, let recordID else { return nil }
return self.boardRegistry.gitOperationStamp(id: recordID)
}
switcher.writeStamp = { [weak self] stamp in
guard let self, let recordID else { return }
self.boardRegistry.setGitOperationStamp(id: recordID, stamp)
}
switcher.settleSessions = { [weak self, weak switcher] in
guard let self, let switcher else { return .proceed }
let gate = self.settleGate(
for: ref,
message: SessionSettleStep.branchSwitchMessage
) { [weak switcher] folder in
switcher?.noteDiscarded(cardFolderName: folder)
}
// Every open session, not the ones a diff reaches see `SessionSettleGate.settleAll`.
let outcome = await gate.settleAll()
// The switch's flush runs next and must find a tree it can settle whole see
// `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough.
//
// **And the fine undo stacks go with it** (06 Branch switching, ruled 2026-07-31): the
// same `.proceed`, the same seam, for the same reason one rung up what a window is
// holding describes the branch being left. "Cancel keeps the current branch and the
// sessions" is this `if`, unchanged: a cancelled or failed settle clears nothing, exactly
// as it releases nothing.
if outcome == .proceed {
self.releaseCardSessionStaging(for: ref)
self.discardCardWindowUndoStacks(for: ref)
}
return outcome
}
// **The own-leftovers check, at open** (06 Rules Abnormal repo states). Beside the
// committer's start, which is where a pause first becomes knowable, and before anything the
// user does can land on top of a half-finished checkout.
Task { await switcher.recoverInterruptedOperation() }
}
/// **The three buttons, as a seam** `SessionSettleStep.ask(message:)` in production.
///
/// `SessionSettleGate` already keeps the presentation behind a closure for its own reason
/// ("presenting three buttons is AppKit's job and cannot be asserted without a display the
/// presentation is a seam and the decision is testable"), and every gate this model builds pointed
/// that closure straight at the alert so the *composition* around the gate, which is what
/// `releaseCardSessionStaging(for:)` and `discardCardWindowUndoStacks(for:)` hang off, could only
/// be exercised by a board with nothing to settle. Lifting the ask one level up is what lets a test
/// answer Save All, Discard and Cancel over real card windows without a modal on screen.
/// `discardCardWindowUndoStacks(for:)` hangs off, could only be exercised by a board with nothing
/// to settle. Lifting the ask one level up is what lets a test answer Save All, Discard and Cancel
/// over real card windows without a modal on screen.
///
/// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam
/// rather than API: production never assigns it.
@@ -1222,18 +895,20 @@ public final class AppModel {
}
/// **The save-or-discard step for one board**, built from its open card windows
/// (06-history-undo.md Rules Undo restore vs open Edit sessions; Branch switching).
/// (`SessionSettleGate`).
///
/// Built per ask rather than stored, because its whole content is "which card windows are open
/// right now" a set that changes under any operation slow enough to need the step at all.
///
/// **No production caller today** the git restore and branch switch were the two, and both went
/// with the git stack (`strategy/01-git-excision.md`). The composition is kept beside the gate it
/// composes, for the gate's own reason: it is what the next wholesale operation binds to.
///
/// - Parameters:
/// - message: what the step says it is about. The two callers describe different consequences
/// a restore changes the cards being edited, a switch replaces them and 06 gives the step to
/// both without giving either the other's wording.
/// - message: what the step says it is about. Different operations describe different
/// consequences, and a step that described the wrong one would be a worse modal than none.
/// - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the
/// gate can put that folder's uncommitted saves back to HEAD its own way (the restore folds it
/// into its plan; the switch reverts before it flushes).
/// gate can reconcile that folder's already-written bytes its own way.
func settleGate(
for ref: BoardWindowRef,
message: String = SessionSettleStep.message,
@@ -1272,62 +947,6 @@ public final class AppModel {
)
}
/// **Swaps the board's undo substrate onto an already-open session** add-git's one caller.
///
/// 06 Rules Detection sanctions exactly one mid-session mode flip, the app's own add-git:
/// "clicking it flips the open board into git mode immediately the popover flows straight into
/// the git controls, the first auto-commit follows". 13-native-undo.md's header spells out what
/// that does to undo: "**Add-git swaps the substrate mid-session** the commanded flip discards
/// the in-session native stack and seeds the git trail from the root commit, the branch-switch
/// discard-and-reseed precedent applied".
///
/// ### The discard is the whole of it there is no migration
///
/// The board opened mode-none under any tier now carries a live `NativeHistoryProvider` with real
/// steps on it (`makeHistoryProvider`), and those steps **die with the substrate**: they are
/// in-memory inverse operations against a board that has just acquired a commit trail, and
/// replaying one after the swap would walk the board back across a change the root commit already
/// records as the baseline. The branch-switch precedent says the same thing about the same
/// question "the undo/redo stack does not survive a switch. It is discarded and reseeded from
/// the new HEAD's first-parent ancestry redo starts empty" (06 Branch switching) so the old
/// stack is cleared rather than merely dropped, and the git provider's `seed()` (in `wireGitUndo`)
/// walks a trail whose only commit is the root, which is the stack's floor and not a step: Z is
/// correctly empty the instant the flip lands.
///
/// **Nothing is announced.** 06 gives the flip the popover's own flow ("straight into the git
/// controls") and 13 gives the discard no surface at all, exactly as the branch switch's discard
/// has none; what the user sees is the Edit rows and the toolbar pair revalidating through
/// `BoardUndoManager` on AppKit's own cadence, which is the same machinery every other enablement
/// change on this board rides. Nothing here posts a banner, and nothing should.
///
/// ### Why live rather than at the next open
///
/// A judgment call, recorded when the free-tier matrix still left this board with no provider at
/// all: the mode flip already carries the *committer* through (`HistoryStore.activateAutoCommit`
/// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with
/// the provider it composed" was a rule about a **tier** lapsing, which never could change a
/// running session and since PIVOT 2026-08-07 no longer changes the *next* open either, since
/// a git board composes the git provider in every tier; and a board that visibly starts
/// accumulating commits while Z answers from a
/// stack the repository knows nothing about would read as a defect rather than as a policy.
///
/// Called exactly once per board, structurally: `HistoryStore.addGit` refuses any mode but
/// `none`, and flips to `.git` before it fires `didAddGit`.
func bindHistoryProvider(for ref: BoardWindowRef) {
guard var session = sessions[ref], let git = session.git, git.mode == .git else { return }
guard let history = makeHistoryProvider(session.store, git) else { return }
guard history !== session.history else { return }
// Before the reassignment, while `session.history` is still the substrate being replaced: the
// in-flight native steps go with it, and any closure that outlives this line finds an empty
// stack rather than inverses against a pre-repository board.
session.history?.clear()
session.history = history
sessions[ref] = session
session.store.history = history
session.undoManager.history = history
wireGitUndo(history, store: session.store, git: git, ref: ref)
}
/// Registers a card window with its board's session, so the close flush can find it.
///
/// A card window whose board has no session is a card window with no board the ownership rule
@@ -1340,112 +959,32 @@ public final class AppModel {
}
sessions[ref.board]?.cardRefs.insert(ref)
cardSessions[ref] = session
// **The window *is* the commit unit** (06 Rules Auto-commit, widened 2026-07-31), so the
// stage-around opens here with the window rather than at the body's first EditPreview
// flip. From this line to `unregisterCardWindow` nothing this card's folder receives can land
// in an interim commit.
setCardSession(true, for: ref)
}
func unregisterCardWindow(_ ref: CardWindowRef) {
sessions[ref.board]?.cardRefs.remove(ref)
cardSessions[ref] = nil
// **The close flush's release** and, for a window that left without its session ending (a
// crash-shaped teardown, a dismissal that raced the flush), the backstop that must not leave a
// card folder excluded from staging forever. Both are the same line because both mean the same
// thing: this window is no longer holding its folder back.
setCardSession(false, for: ref)
}
/// Tokens the committer knows each card window's session by. Beside `cardSessions` for its
/// reason: this is the seam table's third column, written only here.
@ObservationIgnored
private var cardSessionTokens: [CardWindowRef: UUID] = [:]
/// **A card window's session opened or closed** (06-history-undo.md Rules Auto-commit: the
/// committer "stages around the whole open card folder").
/// **A wholesale operation's settle empties every open card window's fine undo stack.**
///
/// This is the honest seam between the two halves of the rule: the host knows a window exists, the
/// committer knows what staging is, and only the app model knows which board a card window belongs
/// to and how to reach its committer. A board with no committer any board nobody has added git
/// to, which since PIVOT 2026-08-07 is the only such board there is (12-editions.md) records
/// nothing, which is the same `nil` every other git seam takes.
/// The rule the branch switch established, kept as a rule about *any* operation that replaces the
/// tree under an open window: pre-operation steps describe a state that has gone, so Save All and
/// Discard alike end with every window's stack empty the board-stack discard-and-reseed
/// precedent one level down; the windows stay open with fresh stacks. Cancel clears nothing, which
/// is why this hangs off a `.proceed` at the call site rather than off the ask.
///
/// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be
/// moved into the trash, in the middle of a session, and what must be staged around is wherever
/// it is at the moment of the commit. `BoardStore.cardBodyTarget` is the resolution that spans
/// both containers, which is exactly why the body save uses it too.
/// **The board stack is not touched here**, and it is not an omission: an operation that replaces
/// the tree reseeds the board's stack itself, after the write that decides what to reseed *from*.
/// Doing it here would be the same discard at the wrong moment.
///
/// Idempotent both ways: re-opening reuses the token (a settle that released it, then a resume),
/// and closing an already-closed session reaches a committer that has nothing to remove.
func setCardSession(_ isOpen: Bool, for ref: CardWindowRef) {
guard isOpen else {
// **The token goes whether or not there is anyone left to tell.** A card window's own
// teardown can land after its board's, and a token kept past the board it names would
// outlive everything that could ever release it.
guard let token = cardSessionTokens.removeValue(forKey: ref) else { return }
sessions[ref.board]?.git?.committer?.endCardSession(token)
return
}
guard let session = sessions[ref.board], let committer = session.git?.committer else { return }
let token = cardSessionTokens[ref] ?? UUID()
cardSessionTokens[ref] = token
let cardID = ref.cardIdentity
committer.beginCardSession(token) { [weak store = session.store] in
guard let store,
let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil }
return path.folder(under: store.rootURL)
}
}
/// **The stage-around releases at the settle step** (06 Branch switching; Rules Undo restore
/// vs open Edit sessions) every open card window on this board, unconditionally.
/// **A diff-shaped operation deliberately does not call this.** One that materializes only a diff
/// leaves a window's steps describing a state that is still mostly there; what protects them is
/// 13-native-undo.md's field-level staleness predicate, a per-step question rather than a
/// wholesale one.
///
/// ### Why unconditionally, rather than through the modal
///
/// The save-or-discard step asks about *buffers* "unsaved keystrokes, or on-disk ~700 ms saves
/// the session hasn't committed" and a window that is merely open, with a comment posted an hour
/// ago and a clean editor, answers `needsSettling` with `false`. Under the widened stage-around
/// that window is still holding its whole folder out of every commit, so leaving it held would
/// walk a checkout onto a dirty tree and break the one guarantee the settle exists to buy: "with
/// sessions settled the restore runs on a settled tree it cannot fail dirty".
///
/// Releasing is therefore structural and silent, and the modal keeps its own narrower predicate:
/// Save All's flush then carries the session's commit, and Discard's reverted bytes are
/// reconciled by the operation itself (`GitRestoreOperation.plan`, `GitBranchSwitcher`), which is
/// why this runs *after* the gate has answered rather than before it.
func releaseCardSessionStaging(for ref: BoardWindowRef) {
for cardRef in sessions[ref]?.cardRefs ?? [] {
setCardSession(false, for: cardRef)
}
}
/// **The branch switch's settle empties every open card window's fine undo stack**
/// (06-history-undo.md Branch switching, ruled 2026-07-31).
///
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
/// > the branch being left Save All and Discard alike end with every window's stack empty, the
/// > board-stack discard-and-reseed precedent one level down; the windows stay open, following
/// > their cards onto the new branch with fresh stacks."
///
/// ### Why here, beside the staging release
///
/// Because it is the same fact about the same moment. `releaseCardSessionStaging(for:)` lets go of
/// what a window is holding *on disk*; this lets go of what it is holding *in memory*, and both
/// are true of a session whose branch is about to be replaced under it. Running them from one
/// `.proceed` is also what makes "Cancel clears nothing" a property of one `if` rather than a rule
/// two call sites have to keep in step (`wireBranchSwitching`).
///
/// **The board stack is not touched**, and it is not an omission: the switch discards and reseeds
/// it from the new HEAD's first-parent ancestry a few steps later, inside the bracket
/// (`GitBranchSwitcher.reseedUndo` `GitHistoryProvider.reseed`). Doing it here would be the same
/// discard, one level up, at the wrong moment before the checkout that decides what to reseed
/// *from*.
///
/// **The restore path deliberately does not call this.** An undo restore materializes a diff and
/// leaves the branch where it is, so a window's steps still describe the branch they were made on;
/// what protects them there is 13-native-undo.md's field-level staleness predicate, which is a
/// per-step question rather than a wholesale one.
/// **No production caller today** it went with the branch switch (`strategy/01-git-excision.md`)
/// and it is kept beside `settleGate(for:message:didDiscard:)` for that gate's reason.
///
/// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close
/// flush's* seam end the session, say whether it holds unsaved content, offer the settle's two
@@ -1457,18 +996,6 @@ public final class AppModel {
}
}
/// **The next session begins** the other half of `releaseCardSessionStaging(for:)`, run when the
/// operation behind the settle has finished with the tree.
///
/// A window that is still open after a restore or a branch switch is still a session, and its
/// folder must go back to being staged around. Idempotent, so the paths that resume without ever
/// having released (a cancelled switch, the open-time leftover check) cost a dictionary lookup.
func resumeCardSessionStaging(for ref: BoardWindowRef) {
for cardRef in sessions[ref]?.cardRefs ?? [] {
setCardSession(true, for: cardRef)
}
}
// MARK: - Launch failures
/// Records a board that could not be opened. Deliberately additive and never cleared on success:
@@ -1629,11 +1156,10 @@ public final class AppModel {
///
/// ### Declining costs nothing, today least of all
///
/// A board that says Not Now keeps the substrate it composed with, and since the provider follows
/// the board (`makeHistoryProvider`), a gitless board's undo is the *same* native stack either
/// way declining costs undo nothing at all, and costs a git board only the trail it would have
/// gained at its next open. The offer exists because "subscribe takes effect at each board's next
/// open" (12 The entitlement) needs one, not because anything breaks without it.
/// A board that says Not Now keeps the substrate it composed with, and since every board composes
/// the same one (`makeHistoryProvider`), that costs undo nothing at all. The offer exists because
/// "subscribe takes effect at each board's next open" (12 The entitlement) needs one, not
/// because anything breaks without it.
public func reopenOpenBoards() async {
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
let refs = sessions.keys.sorted { $0.path < $1.path }
@@ -1681,15 +1207,6 @@ public final class AppModel {
},
endCardSession: { [weak self] cardRef in
await self?.cardSessions[cardRef]?.endSession()
// **The release, here rather than only at the host's unregister** (06 Rules
// Auto-commit). The unregister does release it that is what closes a window on its
// own but it arrives from the *window's* teardown, which this sequence waits for
// only up to `cardDrainDeadline` and then proceeds anyway. A quit whose last window
// was slow to disappear would then flush with the folder still staged around and leave
// a settled session uncommitted, which is precisely what "nothing settled is ever left
// ... uncommitted by closing" forbids. Ending the session is this step's own act, so
// releasing what the session held is too. Idempotent with the unregister.
self?.setCardSession(false, for: cardRef)
},
dismissCardWindow: { [weak self] cardRef in
self?.windowDismisser?(value: cardRef)
@@ -1705,17 +1222,10 @@ public final class AppModel {
// window of its own the raw-source buffer is the candidate so that the order
// relative to `committerFlush` is already decided when one arrives.
//
// **`committerFlush` is filled now** (06-history-undo.md Rules Auto-commit: "Board
// window close and app quit flush the pipeline any pending editor save, then the
// pending auto-commit before teardown; nothing settled is ever left unsaved or
// uncommitted by closing"). By the time it runs, step 1 has ended every card window's
// session *and released its stage-around*, so each session body, comments, purge and
// all lands in exactly one commit. `nil` on every board with no committer, which is
// every board nobody has added git to (12 PIVOT 2026-08-07 the tier no longer
// decides; opt-in per board still does).
committerFlush: { [weak self] in
await self?.sessions[ref]?.git?.committer?.flushNow()
},
// **`committerFlush` stays nil too**, and now for want of a committer rather than by
// policy: the auto-committer it drove went with the git stack
// (`strategy/01-git-excision.md`). The slot is 02's ordering statement pending editor
// saves, then whatever records history and it keeps that place for the successor.
recordClose: { [weak self] in
guard let self, let session = sessions[ref] else { return }
let counts = Self.liveCounts(of: session.store.snapshot)
@@ -1734,11 +1244,6 @@ public final class AppModel {
},
tearDown: { [weak self] in
guard let self, let session = sessions.removeValue(forKey: ref) else { return }
// The committer dies with the session it commits for, `history.clear()`'s reason
// exactly: its debounce holds a closure over the store this line is about to release,
// and a timer that outlived its board would fire against a repository nobody is
// looking at. Its pending work has already been flushed by `committerFlush` above.
session.git?.stopAutoCommit()
// Session-only persistence, the other half of `beginSession` (13-native-undo.md
// Rules): "the stack ... dies at close/quit", so reopening the board starts empty.
// Cleared rather than merely dropped because the steps hold closures over the store