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
+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)
}
}