Make the card window the commit unit on Pro boards

Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.

Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.

2444 tests in 422 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 20:23:45 -04:00
parent 9119aa1e9a
commit a381fac742
11 changed files with 1709 additions and 105 deletions
+113 -31
View File
@@ -772,7 +772,14 @@ public final class AppModel {
// **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.
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier)
//
// **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, tier: tier, 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 Pro's git boards
@@ -875,7 +882,12 @@ public final class AppModel {
}
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
provider.resumeCommitting = { [weak git] in git?.committer?.start() }
// 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").
//
@@ -903,7 +915,11 @@ public final class AppModel {
// `GitRestoreOperation.plan`.
provider?.noteDiscarded(cardFolderName: folder)
}
return await gate.settle(touching: paths)
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()
@@ -929,7 +945,12 @@ public final class AppModel {
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() }
switcher.resumeCommitting = { [weak git] in git?.committer?.start() }
// 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".
@@ -974,7 +995,11 @@ public final class AppModel {
switcher?.noteDiscarded(cardFolderName: folder)
}
// Every open session, not the ones a diff reaches see `SessionSettleGate.settleAll`.
return await gate.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.
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
return outcome
}
// **The own-leftovers check, at open** (06 Rules Abnormal repo states). Beside the
@@ -1097,47 +1122,94 @@ 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
// A window that left without its session ending a crash-shaped teardown, or a dismissal
// that raced the flush must not leave its card folder excluded from staging forever.
setEditSession(false, for: ref)
// **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 Edit session by. Beside `cardSessions` for its
/// 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 editSessionTokens: [CardWindowRef: UUID] = [:]
private var cardSessionTokens: [CardWindowRef: UUID] = [:]
/// **A card window's Edit session opened or closed** (06-history-undo.md Rules Auto-commit:
/// the committer "stages around open Edit sessions").
/// **A card window's session opened or closed** (06-history-undo.md Rules Auto-commit: the
/// committer "stages around the whole open card folder").
///
/// This is the honest seam between the two halves of the rule: `CardBodyEditSession` knows a
/// session is open, 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 the free
/// tier, a Pro board with no repository records nothing, which is the same `nil` every other
/// git seam takes.
/// 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 the free tier, a Pro board with
/// no repository records nothing, which is the same `nil` every other git seam takes.
///
/// 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.
func setEditSession(_ isOpen: Bool, for ref: CardWindowRef) {
///
/// 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 }
if isOpen {
let token = editSessionTokens[ref] ?? UUID()
editSessionTokens[ref] = token
let cardID = ref.cardIdentity
committer.beginEditSession(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)
}
} else if let token = editSessionTokens.removeValue(forKey: ref) {
committer.endEditSession(token)
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.
///
/// ### 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 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)
}
}
@@ -1353,6 +1425,15 @@ 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)
@@ -1372,8 +1453,9 @@ public final class AppModel {
// 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
// Edit session, so nothing is staged around and each session's body lands in exactly one
// commit. `nil` on every board with no committer, which is the whole free tier.
// 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 the
// whole free tier.
committerFlush: { [weak self] in
await self?.sessions[ref]?.git?.committer?.flushNow()
},
+17 -12
View File
@@ -634,14 +634,12 @@ struct CardWindowHost: View {
bodyPresentation.beginEdits = { [session] in
session.body.beginEditSession()
}
// **The stage-around registry's one wire** (06-history-undo.md Rules Auto-commit). The
// buffer announces its session boundary, the app model knows which board this card belongs
// to, and the committer knows what staging is; this line is the join, and it is the only
// place all three are in scope. A free-tier board or any board with no repository has no
// committer, so `setEditSession` records nothing and the buffer never learns the difference.
session.body.editSessionDidChange = { [appModel, ref] isEditing in
appModel.setEditSession(isEditing, for: ref)
}
// **No stage-around wire here any more** (06-history-undo.md Rules Auto-commit, widened
// 2026-07-31 recorded because its absence is the change): the EditPreview flip used to open
// and close the committer's exclusion, and the unit is now the *window*, so the exclusion is
// opened by `AppModel.registerCardWindow` and released by `unregisterCardWindow` after the
// session's own last writes. A flip that still moved it would un-hold the folder in the middle
// of a session whose comment posts and draft saves are supposed to be inside one commit.
Self.configureRawSource(
rawSource,
body: session.body,
@@ -949,15 +947,22 @@ struct CardWindowHost: View {
/// Leaves the session and lets the store go.
///
/// The release rides **behind** the session's end rather than beside it: a session that has
/// something to commit (m6) needs the store it is committing through, and a refcount that hit
/// zero first would have stopped the watcher underneath it. In m4 the hook is a no-op and the
/// ordering costs one run-loop turn the point is that the shape is already right.
/// something to commit needs the store it is committing through, and a refcount that hit zero
/// first would have stopped the watcher underneath it.
///
/// **Unregistering rides behind it too** (06-history-undo.md Rules Auto-commit: "window close
/// flushes the session as one commit"), which is new in this milestone and is the whole ordering
/// the one-commit rule rests on: unregistering is what releases the committer's stage-around, and
/// releasing it before `endSession()` had written the body's last keystrokes, posted the draft and
/// purged `comments/.trash/` would leave a debounce free to fire over a half-finished session
/// two commits where the design promises one. The board's own close flush drives the same two
/// steps in the same order through `CloseFlushCoordinator`, one window at a time.
private func finish() {
guard case let .open(store) = phase else { return }
phase = .closing
appModel.unregisterCardWindow(ref)
Task { @MainActor in
await session.endSession()
appModel.unregisterCardWindow(ref)
appModel.storeRegistry.release(store)
}
}