Bind the undo provider to the board, not the tier

The 2026-07-31 re-ruling: gitless boards bind the native stack in every
tier — a Pro upgrade no longer removes undo from mode-none boards — and
Pro git boards bind the git provider; repo-nested stays the no-undo
case under Pro, while the free tier (which never runs detection) binds
native there too, per 12's inert posture. Add-git now swaps a live
native substrate mid-session: the in-flight stack is cleared with the
discarded provider, the git trail seeds from the root commit, and the
same BoardUndoManager instance keeps nil-target menu validation fresh.

2405 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 18:53:38 -04:00
parent 3bd6187b94
commit 2e4dde5655
9 changed files with 286 additions and 104 deletions
+21 -19
View File
@@ -13,13 +13,13 @@ import AppKit
/// exactly how the system's Edit Undo row and the toolbar's nil-target pair (`BoardToolbar`) light
/// up, disable and retitle with no code of the app's own.
///
/// The seam, though, must not be an `NSUndoManager`: the free tier's stack is one, Pro's is git
/// (12-editions.md The provider seam), and a protocol that vended one could only ever have had a
/// single implementation. So the substrate stays behind `HistoryProviding` and *this* object is the
/// translation one per board session, over whichever provider that session was composed with. Pro
/// inherits the whole command surface (enablement, dynamic titles, Z, the toolbar pair) by binding
/// its provider and changing nothing here, which is what "a user subscribing (or lapsing) relearns
/// nothing" (12) has to mean in code.
/// The seam, though, must not be an `NSUndoManager`: a gitless board's stack is one, a Pro git
/// board's is git (12-editions.md The provider seam), and a protocol that vended one could only
/// ever have had a single implementation. So the substrate stays behind `HistoryProviding` and
/// *this* object is the translation one per board session, over whichever provider that session
/// was composed with. A git board inherits the whole command surface (enablement, dynamic titles,
/// Z, the toolbar pair) by binding its provider and changing nothing here, which is what "a user
/// subscribing (or lapsing) relearns nothing" (12) has to mean in code.
///
/// ### It deliberately keeps its inherited stack empty
///
@@ -51,22 +51,24 @@ public final class BoardUndoManager: UndoManager {
///
/// ### `nil` is a board with **no undo provider**, and it is a real state
///
/// Under Pro, a board in mode `none` or `repoNested` gets no provider at all "the pair disabled
/// on boards with no undo provider in the composed tier under Pro, no-git and repo-nested
/// boards, matching their menu items" (03-board-ui.md Toolbar Catalog; 06-history-undo.md
/// Rules). Every question below answers the empty way, so the Edit menu's rows, the toolbar
/// pair, and Z itself go quiet together, through the same validation path a lock uses. Modelling
/// it as an absent substrate rather than as a substrate that always says no is the honest shape:
/// there is nothing there, and nothing can accidentally accumulate in it.
/// A **repo-nested** board under Pro gets no provider at all, and since the re-ruling of
/// 2026-07-31 it is the only board that does: "the pair disabled only on repo-nested boards,
/// under locks, and on empty stacks the provider follows the board, so gitless boards bind
/// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md Toolbar Catalog;
/// 06-history-undo.md Rules). Every question below answers the empty way, so the Edit menu's
/// rows, the toolbar pair, and Z itself go quiet together, through the same validation path a
/// lock uses. Modelling it as an absent substrate rather than as a substrate that always says no
/// is the honest shape: there is nothing there, and nothing can accidentally accumulate in it.
///
/// ### Settable, for exactly one event
///
/// **Add-git** (06 Rules Detection) is the design's one sanctioned mid-session mode flip:
/// "clicking it flips the open board into git mode immediately the popover flows straight into
/// the git controls, the first auto-commit follows". A board that gains a repository mid-session
/// gains a commit trail, and a trail with a dead Z over it would read as a bug. The composition
/// root binds the git provider here on that flip, rather than rebuilding this object, so AppKit
/// keeps the identical manager it has already been handed by `windowWillReturnUndoManager`.
/// "clicking it flips the open board into git mode immediately". 13's header says what that means
/// here the flip **swaps the substrate**: the mode-none board's native stack is discarded and
/// the git trail seeded from the root commit, the branch-switch discard-and-reseed precedent. The
/// composition root writes the new provider into this property rather than rebuilding this
/// object, so AppKit keeps the identical manager it has already been handed by
/// `windowWillReturnUndoManager` and simply revalidates over a different stack.
///
/// (This is *not* a tier flip. 12-editions.md's "an open board finishes with the provider it
/// composed" is about a subscription lapsing, which cannot change a running session's tier at
+8 -7
View File
@@ -80,9 +80,9 @@ public enum HistoryStepOutcome: Equatable, Sendable {
/// holds: move move back ...; rename restore title" and both halves of that write are already
/// in the caller's hands: the before-value *is* the inverse, and the after-value is what the write
/// set (which is also what the staleness predicate compares). A step is therefore that pair, in the
/// design's own terms, and deliberately says nothing about how a stack stores it: the free tier's
/// stack is `NSUndoManager`-backed and Pro's is git (12-editions.md The provider seam), and neither
/// substrate appears here.
/// design's own terms, and deliberately says nothing about how a stack stores it: a gitless board's
/// stack is `NSUndoManager`-backed and a Pro git board's is git (12-editions.md The provider
/// seam), and neither substrate appears here.
///
/// ### `name` is the 06 vocabulary, unprefixed
///
@@ -143,10 +143,11 @@ public struct HistoryStep {
/// "One stack per board, owned by the board session. Not per-window: every window over a board
/// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md
/// Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds
/// which implementation it gets: the free tier binds `NativeHistoryProvider` (two step stacks over
/// the inverses registered at the Writer boundary), Pro binds the git provider in pro-m1 (undo as
/// forward restore commits over HEAD's first-parent ancestry 06-history-undo.md), Teams inherits
/// Pro's.
/// which implementation it gets **following the board, not the tier alone** (re-ruled 2026-07-31):
/// a gitless board binds `NativeHistoryProvider` (two step stacks over the inverses registered at
/// the Writer boundary) in every tier, a Pro git board binds the git provider (undo as forward
/// restore commits over HEAD's first-parent ancestry 06-history-undo.md), a repo-nested board
/// binds none at all, and Teams inherits Pro's.
///
/// ### What this protocol deliberately does not say
///