Realign code with the 2026-07-31 findings-resolution rulings

The full bullet list from Implementation card bf080d9a — both ruling
batches, including the three appended mid-session by 16ef377:

- Restore subjects compose the inverse, never nest: crossing "Undo: S"
  emits "Redo: S" and vice versa; parity, not stack depth, reads a
  legacy double prefix (GitHistoryProvider.restoreSubject).
- Git-operation failures join the one-shot failure banner tier:
  BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error
  tone at failure rank merged with write one-shots by recency; the
  postLoss compromise is retired at both AppModel wirings.
- order/schema optional below the board root: append-at-end reading
  (ordered siblings first, folder-name tie-break among the order-less),
  schema reads 1, both coerce-tier logged; the root keeps its
  requirements. Ranks.resolvedOrders materializes finite ranks so
  models and placement math stay untouched; first Writer rewrite
  stamps a real rank on touch, placement against an order-less sibling
  stamps that sibling inline in the same bracket. Agent guide v10
  teaches optional keys and zero-read filing. Hostile-YAML order
  shapes become coercion tests; Fixtures/Valid/optional-keys.kanban
  replaces the four retired Malformed boards.
- .gitignore is the relocation-heal noise gate: GitignoreRules pure
  matcher (standard semantics, board-root file only), loader consults
  it once per walk so matched loose files keep the stray posture;
  seeded (.DS_Store + .*.lanework-*) at board creation and template
  instantiation, healed in when missing at open — repo-nested
  included; empty file honored, existing files never edited; the
  committer's obedience via libgit2 status is pinned by test.
- Comments crash-residue sweep gates on step ownership: HistoryStep
  derives backing from its own undo expectations, backedContent unions
  both stacks, the sweep purges per-entry only what no live step owns.
- Skip-purge decoupled (16ef377): a stale-skipped coarse step strands
  whole in NativeHistoryProvider.strandedSteps — still backing, retired
  only at session end; clean exits purge as before.
- Coarse close step named "Changes to '<card>'"; the fine body-edit
  wording never leaks onto the board menu.
- Branch-switch settle clears every open card window's fine stack on
  Save All and Discard alike; the empty fold registers no coarse step.
- Close flush awaits its covering snapshot (quiesce + one generation
  bump, 1s bound), and an explicit flush now queues behind an
  in-flight one instead of skipping — the audit-caught interleaving
  could lose a close flush permanently when the debounce fired inside
  the close sequence; regression tests force both races.
- Commit comment bullets sort chronologically by created, not UUID.
- The production-unwired CardBodyEditSession.editSessionDidChange seam
  is deleted with its seam-only tests.
- Composition-root pins: beginSession composes the committer with the
  store's own EchoLedger and binds the announcer (the miswire class).
- Deliberate 06 conformance pass over every 2026-07-31-tagged
  sentence: fixed Change-custom-key subjects (the retired named
  generic was the only producer), the unbuilt Replace attachment
  vocabulary, heal commits now authored Lanework Integrity, the config
  reader scopes identity to plain [user] sections, add-git re-runs
  detection at create (a stale mode-none could initialize inside the
  user's repo), and add-git failures answer at the form or the banner.
  Structural residue filed on the Redesign board.

2554 tests / 439 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 07:43:45 -04:00
parent 16ef3779e8
commit 274ccd9ff5
75 changed files with 5619 additions and 791 deletions
+24 -9
View File
@@ -107,6 +107,11 @@ extension BoardStore {
let named = subject ?? name
let step = HistoryStep(
name: name,
// **What this step holds in a trash, read off what it already declares** the sweep's
// gate (`HistoryStep.backing`, ruled 2026-07-31). Derived here rather than passed by each
// call site because the expectations *are* the claim: a step whose undo needs a folder
// present under `comments/.trash/` is a step whose undo is the move back out of it.
backing: HistoryStep.backing(declaredBy: undoExpects),
retirement: retirement,
undo: { [weak self] direction in
BoardStore.cross(self, direction, named, undoExpects, undo)
@@ -130,9 +135,9 @@ extension BoardStore {
// MARK: The window close's coarse step
/// **Registers one card-window session as one board step** 13-native-undo.md Rules' window
/// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card
/// title', values-based, whose undo restores the card subtree to its session-start state
/// deleted comments included and whose redo reapplies the net effect").
/// close ("the session's net effect registers on the board stack as one coarse step named
/// 'Changes to card', values-based, whose undo restores the card subtree to its session-start
/// state deleted comments included and whose redo reapplies the net effect").
///
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
/// whether there is a board to register it on is this method's:
@@ -152,10 +157,11 @@ extension BoardStore {
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
///
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 Interaction with the trash).
/// - Returns: whether the purge now has an owner a live step holding it until the step leaves
/// the board stack, or a substrate that declined to keep the step and therefore ran it already
/// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still
/// owes the purge.
/// - Returns: whether the purge now has an owner a live step holding it until the substrate
/// lets the step go (which a stale skip deliberately does not do; 13's skip-purge decoupling,
/// ruled 2026-07-31), or a substrate that declined to keep the step and therefore ran it
/// already (`GitHistoryProvider.register`). `false` means nothing was registered and the caller
/// still owes the purge.
@discardableResult
func registerCardSession(
_ window: CardWindowUndo,
@@ -164,9 +170,18 @@ extension BoardStore {
) -> Bool {
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return false }
guard let net = window.netEffect() else { return false }
// **The title the step is registered with, not the one the session opened on** (the phrase is
// 13's "Changes to 'card'", ruled 2026-07-31). Read once and spent twice, which is the
// reason: the menu row and the skip banner must quote *one* name for one step, and a step
// whose row said "Changes to 'Fix login'" while its banner said "'Fix log-in' changed outside
// Lanework" would be two names for one card. Registration time is also the honest instant
// the row appears in the Edit menu the moment this returns, beside a board already showing
// this title, and a session that renamed the card is a session whose rename is *in* the net
// effect the row is offering to walk back.
let title = Self.cardTitle(at: target, in: snapshot)
registerStep(
HistoryPhrase.cardSession,
subject: Self.cardTitle(at: target, in: snapshot),
HistoryPhrase.cardSession(title),
subject: title,
retiring: retiring,
undoExpects: net.undoExpects,
redoExpects: net.redoExpects,
+5 -2
View File
@@ -167,8 +167,11 @@ public final class BoardUndoManager: UndoManager {
/// `NSSearchField` vends no manager of its own to the window, so `undo:` falls through to the
/// window's, and a reflexive Z over a typo in the search field or the board-rename field would
/// cross a *board* step. 06 rules that out by name: "Control-class text fields route the same way
/// (settled): the search field ... and the popover's text fields ... own Z/Z as field-local text
/// undo while focused a reflexive undo over a typo must never become a tree checkout." So the
/// (settled): the search field ..., the popover's rename field, and the settings sheet's text fields
/// (commit identity, credentials, remote URL) own Z/Z as field-local text undo while focused a
/// reflexive undo over a typo must never become a tree checkout." (That list is 06's own since the
/// 2026-07-31 popover/sheet split; the rule is per *responder class*, so it covers each of them
/// wherever it is hosted, and the sheet's arrival adds nothing here.) So the
/// window-level answer is the board's stack **only when no text-editing surface holds the
/// keyboard**, and a per-window text manager otherwise which is also exactly what AppKit would
/// have created for such a window on its own, so nothing about typing in a field changes.
+38 -1
View File
@@ -101,6 +101,39 @@ public final class CardWindowUndo {
writes[id] = write
}
/// **Empties this window's fine stack, registering nothing** the branch switch's settle step
/// (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."
///
/// ### Closing the stack is not closing the window
///
/// This is the whole reason it is a method rather than a call to `stack.clear()` at the call site.
/// A window *close* owes the board one coarse step folded from this stack
/// (`BoardStore.registerCardSession`, `netEffect()`); a settle clear owes it nothing, because the
/// window is not closing it stays open on the other side of the checkout and its next gesture
/// starts a fresh stack. So nothing here registers, nothing folds, and the fold that runs at the
/// window's eventual close sees only what the *new* branch's session did: `netEffect()` reads
/// `stack.pendingSteps`, which this empties, and `registerCardSession` answers `false` over an
/// empty fold exactly as it does for a session with no net change.
///
/// The raw writes go with the steps. They are keyed by step id and nothing on either stack names
/// them any more, so keeping them would be a table that only grows and a redone step's half of
/// the fold, which is the one reason the table outlives the undo stack, has nothing left to be
/// half of once both stacks are empty.
///
/// **Nothing retires here that would not have retired anyway.** `NativeHistoryProvider.clear()`
/// runs each dropped step's retirement, and a window's fine steps carry none: the deferred
/// `comments/.trash/` purge is the *coarse* step's (13-native-undo.md Interaction with the
/// trash), registered on the board's stack at close and never on this one.
func discardSteps() {
stack.clear()
writes.removeAll()
}
// MARK: - The fold
/// **The session's net effect, or `nil` when there is none** what the window's close registers
@@ -111,7 +144,11 @@ public final class CardWindowUndo {
///
/// - **undo** every live step's undo, newest first. Replaying the session backwards lands on the
/// state it started from, deleted comments included: their backing is still in
/// `comments/.trash/` because this step's own existence is what defers the purge.
/// `comments/.trash/` because this step's own existence is what defers the purge and, since
/// the sweep gate (13 Interaction with the trash, ruled 2026-07-31), what keeps the *next*
/// window open from sweeping it as residue. That claim needs nothing of its own here: the fold
/// below carries each delete's `.present(.trashedComment())` into the coarse step's
/// expectations, and `HistoryStep.backing(declaredBy:)` reads it straight off them.
/// - **redo** every live step's redo, oldest first. The session, replayed.
/// - **the undo's expectations** the state the session's writes left, folded **last-write-wins**
/// per field: what must still be true for the whole step to be safe to cross.
+34 -8
View File
@@ -25,6 +25,10 @@ import Foundation
/// destination clause a commit subject carries has no place in a title that has to stay short enough
/// for a menu row.
///
/// **One deliberate exception, and it is the only one**: `cardSession(_:)` names its card
/// ("Changes to 'Fix login'"), because the coarse close step is the one phrase whose *scope* is what
/// distinguishes it see that member.
///
/// Pure, and its own type rather than a `String` built at each call site, because a phrase composed
/// in eleven places is a vocabulary that drifts in eleven places.
public enum HistoryPhrase {
@@ -105,15 +109,37 @@ public enum HistoryPhrase {
// MARK: The card-window session
/// **The coarse close step's phrase** one card window's whole session, as the board's stack sees
/// it: 13-native-undo.md Rules names it "Edit card 'Fix login'", so the menu title is the same
/// verb and noun every other card edit uses, dropping the item clause a menu row has no space for.
/// it: **"Changes to 'card'"** (13-native-undo.md Rules, ruled 2026-07-31).
///
/// Deliberately *not* a new verb. The session is an edit to a card the fine-grained things
/// inside it (a comment posted, a colour chosen, a paragraph rewritten) are the window stack's
/// story, and a board-level row that tried to enumerate them would be the "Mixed update" problem
/// in a menu (06-history-undo.md Commit messages). It reads identically to a body-edit step
/// because on the board's stack it *is* the card's edit.
public static let cardSession = name(.edit, kind: .card)
/// > "the session's net effect registers on the board stack as **one coarse step named
/// > "Changes to 'card'"** the board row reads "Undo Changes to 'Fix login'": plural and
/// > scope-flavoured, distinct from every fine verb, honest about folding many kinds; the fine
/// > body-edit wording never leaks onto the board menu."
///
/// ### Why this one phrase carries its item
///
/// Every other phrase here drops the item clause a commit subject carries, because a verb plus a
/// noun already says what the row is (the type's note). This step has no such verb: what it folds
/// is a comment posted, a colour chosen, a paragraph rewritten, all at once, and a row that named
/// any one of them would be lying about the other two while enumerating them would be the
/// "Mixed update" problem in a menu (06-history-undo.md Commit messages). So the *scope* is the
/// phrase, and a scope is only legible when it names what it is the scope **of**.
///
/// ### It is not `Edit Card`, and that is the point
///
/// This member read `name(.edit, kind: .card)` until the ruling, and the fine body-edit step still
/// does (`registerBodyEdit`) two different steps on two different stacks, one row apart in the
/// Edit menu, saying the same six characters. The board menu now says "Changes to 'Fix login'" and
/// the window menu says "Edit Card": the coarse row names the session, the fine row names the
/// gesture, and neither can be mistaken for the other.
///
/// - Parameter title: the card's title **as the step is registered** `nil` renders the same
/// placeholder the card's own window title bar renders ("Untitled" is a rendering, never a
/// value 03-board-ui.md § Card face), so a menu row and the window it came from name the card
/// the same way.
public static func cardSession(_ title: String?) -> String {
"Changes to '\(title ?? "Untitled")'"
}
// MARK: Composition
+120 -15
View File
@@ -111,10 +111,18 @@ public struct HistoryStep {
/// It exists for one consumer, and the design names it precisely: the window-close coarse step
/// defers a card's `comments/.trash/` purge, because "the coarse close step's undo restores
/// deleted comments, so their backing lives as long as the step does the purge runs when the
/// coarse step leaves the board stack (undone-and-superseded, dropped off the end, or gone
/// stale) or the board session ends" (13-native-undo.md Interaction with the trash, re-ruled
/// 2026-07-31). A step is the only object that knows all three of those moments, and it knows
/// none of them itself so the *provider* reports them, through this.
/// coarse step leaves the board stack **cleanly** undone-and-superseded, or dropped off the end
/// or when the board session ends" (13-native-undo.md Interaction with the trash, re-ruled
/// 2026-07-31). A step is the only object that knows those moments, and it knows none of them
/// itself so the *provider* reports them, through this.
///
/// **A stale skip is deliberately not one of them** (the skip-purge decoupling, ruled
/// 2026-07-31): "a stale-skipped step's backing instead survives to board-session end ... the
/// skip banner says nothing was applied, and an irreversible purge riding that gesture would be
/// surprise loss". Leaving the stack and leaving history for good stopped being the same event
/// there, and this latch marks the second which is why a substrate that keeps a skipped step's
/// hold alive holds the *step* (`NativeHistoryProvider.strandedSteps`) rather than running this
/// early and re-arming the sweep from somewhere else.
///
/// **A reference type inside a value type, deliberately.** `reversed` copies the step every time
/// it crosses, and the two copies must not each run the work: sharing one latch is what makes
@@ -150,6 +158,26 @@ public struct HistoryStep {
/// The menu phrase, unprefixed see the type's note.
public let name: String
/// **What this step holds as undo backing** deleted content that exists on disk only because
/// *this step's undo would move it back out*, and which is therefore not residue for as long as
/// the step is crossable (13-native-undo.md Interaction with the trash, ruled 2026-07-31).
///
/// > "`comments/.trash/` content referenced by a live coarse step on the board stack is a step's
/// > **backing, not residue** the open-time sweep consults the stack and skips owned content,
/// > re-arming when the owning step leaves the stack (which is exactly when the deferred purge
/// > wanted to run; one condition, two consumers). Reopening a window can therefore never destroy
/// > its prior session's undo backing."
///
/// The pair with `retirement` is the whole mechanism: this says *what* is being held, that says
/// *when the hold ends*, and the two are read by the two consumers the ruling names the
/// crash-residue sweep (`BoardStore.sweepCommentTrashResidue(inCard:)`, through
/// `HistoryProviding.backedContent`) and the deferred purge itself.
///
/// **Declared by the step's own expectations rather than by hand** see `backing(declaredBy:)`.
/// Empty for every board gesture, which is all but a comment delete and the coarse step folding
/// one.
public let backing: Set<HistoryAnchor>
/// What this step owes when it leaves history see `Retirement`. `nil` for every step that owes
/// nothing, which is all but the card-window close step.
public let retirement: Retirement?
@@ -165,26 +193,58 @@ public struct HistoryStep {
public init(
id: UUID = UUID(),
name: String,
backing: Set<HistoryAnchor> = [],
retirement: Retirement? = nil,
undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome,
redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome
) {
self.id = id
self.name = name
self.backing = backing
self.retirement = retirement
self.undo = undo
self.redo = redo
}
/// **The backing a step's own registration already names**: every target its **undo** expects to
/// find *in a trash*.
///
/// Derived rather than passed, so no call site can forget it and no call site can say something
/// its expectations contradict. The reading is exact: a step whose undo requires a folder to be
/// present under `comments/.trash/` is a step whose undo *is* the move back out of it which is
/// the definition of backing, spelled in the currency the step already carries.
///
/// **`.trashedComment` is the whole vocabulary**, deliberately. The board's own `.trash/` holds
/// the same relationship (a delete step's undo restores from it) and is deliberately absent: the
/// board trash is a UI surface the user empties on purpose, with a confirm, and nothing sweeps it
/// behind their back so there is no consumer for the answer. `comments/.trash/` is the one
/// trash the app purges on its own schedule (01-storage-format.md § Enhanced schema), which is
/// exactly why it is the one that needs asking.
public static func backing(declaredBy undoExpects: [HistoryExpectation]) -> Set<HistoryAnchor> {
var backing: Set<HistoryAnchor> = []
for expectation in undoExpects where expectation.presence == .present {
guard case .trashedComment = expectation.anchor else { continue }
backing.insert(expectation.anchor)
}
return backing
}
/// The same step read backwards what a provider puts on the opposite stack once this one has
/// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move
/// Card": the phrase names the *gesture*, not the direction.
///
/// **The identity and the retirement travel with it**, both for the same reason: a step that has
/// crossed is the same step, so the fold that keyed state on it must still find that state, and
/// the purge it defers must still be owed exactly once.
/// **The identity, the backing and the retirement travel with it**, all three for one reason: a
/// step that has crossed is the same step, so the fold that keyed state on it must still find that
/// state, the content it holds must still be held, and the purge it defers must still be owed
/// exactly once.
///
/// The backing claim is constant across the crossing rather than swapped with the closures, and
/// that is the honest reading: an undone coarse step has *already* moved its comments back out of
/// `comments/.trash/`, so there is nothing there to sweep and its redo will put them back,
/// after which its undo needs them again. A claim that lapsed while the step sat on the redo stack
/// would be a claim that lapsed exactly when the step was still crossable.
public var reversed: HistoryStep {
HistoryStep(id: id, name: name, retirement: retirement, undo: redo, redo: undo)
HistoryStep(id: id, name: name, backing: backing, retirement: retirement, undo: redo, redo: undo)
}
}
@@ -220,14 +280,31 @@ public struct HistoryStep {
/// - **No routing.** Which surface Z reaches is focus's answer, not the substrate's
/// (06 Undo routing, tier-independent) `BoardUndoRouting`.
///
/// ### One obligation every implementation shares: retire what you drop
/// ### One obligation every implementation shares: retire what you let go of
///
/// A step may owe work for as long as it is crossable and no longer (`HistoryStep.Retirement` the
/// deferred `comments/.trash/` purge). Only the substrate knows when a step stops being crossable, so
/// **every implementation must call `retirement?.run()` on every step it lets go**: the redo stack it
/// clears on a `register`, a step it drops as stale, everything in `clear()`, and for a substrate
/// that keeps no steps at all the step handed to `register` itself. Nothing else in the app can
/// observe that moment, and a step dropped in silence would defer its work forever.
/// A step may owe work for as long as the substrate holds it and no longer (`HistoryStep.Retirement`
/// the deferred `comments/.trash/` purge). Only the substrate knows when it has stopped holding
/// one, so **every implementation must call `retirement?.run()` on every step it lets go**: the redo
/// stack it clears on a `register`, everything in `clear()`, and for a substrate that keeps no
/// steps at all the step handed to `register` itself. Nothing else in the app can observe that
/// moment, and a step dropped in silence would defer its work forever.
///
/// **Held is not the same as crossable**, since the skip-purge decoupling (13 Interaction with the
/// trash, ruled 2026-07-31): a step a stale skip popped is crossable by nothing, and its hold
/// nonetheless stands to the session's end, because "the skip banner says nothing was applied, and an
/// irreversible purge riding that gesture would be surprise loss". A substrate that pops stale steps
/// therefore has a place to put them (`NativeHistoryProvider.strandedSteps`); one that never keeps a
/// step is untouched by the distinction, which is why the git provider needed no change.
///
/// ### And its mirror: say what you are still holding
///
/// The same fact, asked the other way round `backedContent`. Retirement is the *moment* a hold
/// ends; that is the *inventory* of holds standing right now, which is what a sweep needs before it
/// removes anything (13 Interaction with the trash, ruled 2026-07-31: "one condition, two
/// consumers"). The two must be answered off the same steps or the pair stops being one condition
/// which is the whole reason a stranded step is kept whole rather than filleted into a retirement
/// here and a set of anchors there. It defaults to nothing, so a substrate that keeps no steps needs
/// no line of code.
@MainActor
public protocol HistoryProviding: AnyObject {
@@ -262,4 +339,32 @@ public protocol HistoryProviding: AnyObject {
/// Drops every step in both directions session-only persistence (13 Rules), run at the board
/// session's teardown. Also what a substrate that must re-seed (a branch switch, 06) calls first.
func clear()
/// **The undo backing every step this substrate still holds** the union of `HistoryStep.backing`
/// over everything it has not yet let go of.
///
/// Read by the crash-residue sweep before it removes anything
/// (`BoardStore.sweepCommentTrashResidue(inCard:)`): "content referenced by a live coarse step on
/// the board stack is a step's backing, not residue" (13 Interaction with the trash, ruled
/// 2026-07-31). Both directions count, because both are live: a step sitting on the redo stack has
/// not retired, and 13's own release condition "undone-and-superseded" is the moment it does.
/// So does a step a stale skip stranded, which is crossable in *neither* direction and holding all
/// the same: the skip-purge decoupling put its backing's release at the session's end, and a sweep
/// that could not see it would take the release back (`NativeHistoryProvider.strandedSteps`).
///
/// A *point-in-time* answer, computed on demand rather than cached: the stacks are the truth, and
/// a second copy of this could only ever be a stale one.
var backedContent: Set<HistoryAnchor> { get }
}
extension HistoryProviding {
/// **A substrate that keeps no steps holds no backing** the git provider's honest answer
/// (`GitHistoryProvider.register` retires every step on arrival, which is what makes "purge rides
/// the close flush" true on Pro), and a test fake's.
///
/// A default on the requirement rather than a free function, so the dispatch is the substrate's:
/// an implementation that *does* keep steps overrides it and every caller through `any
/// HistoryProviding` sees the override.
public var backedContent: Set<HistoryAnchor> { [] }
}
+90 -9
View File
@@ -39,8 +39,16 @@ import Foundation
/// A step that applies is pushed onto the opposite stack **reversed** its two halves swapped
/// (`HistoryStep.reversed`) which gives the whole classic dance (undo redo undo ) with one
/// rule. Both stacks therefore hold steps oriented so that *crossing them means calling `undo`*, and
/// a skipped step leaves nothing behind at all: it is popped and never re-pushed, which is 13's
/// a skipped step leaves nothing crossable behind: it is popped and never re-pushed, which is 13's
/// "popped from the stack ... and Z falls through to the next step".
///
/// ### A third collection, which is not a stack
///
/// What a stale skip leaves behind is not *nothing*, though, and that is the one place this type
/// keeps state a stack cannot express: a skipped step that was holding undo backing goes on holding
/// it until the session ends (`strandedSteps`; 13 Interaction with the trash, ruled 2026-07-31
/// the skip-purge decoupling). Nothing can cross a stranded step and nothing can see one in the Edit
/// menu; it is a hold outliving the history it belonged to.
@MainActor
public final class NativeHistoryProvider: HistoryProviding {
@@ -48,6 +56,26 @@ public final class NativeHistoryProvider: HistoryProviding {
private var undoSteps: [HistoryStep] = []
private var redoSteps: [HistoryStep] = []
/// **Steps a stale skip popped that were still holding something** off both stacks, uncrossable
/// forever, and *not yet retired* (13-native-undo.md Interaction with the trash, ruled
/// 2026-07-31):
///
/// > "the purge runs when the coarse step leaves the board stack **cleanly** undone-and-superseded,
/// > or dropped off the end or when the board session ends; **a stale-skipped step's backing
/// > instead survives to board-session end** ... the skip banner says nothing was applied, and an
/// > irreversible purge riding that gesture would be surprise loss the skip is exactly when the
/// > user may want to inspect what the collision left."
///
/// **The whole step is kept, not a copy of what it was holding**, and that is the ruling's "one
/// condition, two consumers" taken literally: the sweep's ownership question (`backedContent`) and
/// the deferred purge's moment (`HistoryStep.Retirement`) are two readings of one object's hold,
/// so they must not become two facts able to disagree. A skip that ran the retirement and re-armed
/// the sweep from a side-table would be saying *released* to one consumer and *still held* to the
/// other, for the same instant. Deferring the latch itself says it once.
///
/// Only steps that owe something are stranded see `strand(_:)`.
private var strandedSteps: [HistoryStep] = []
public init() {}
// MARK: - HistoryProviding
@@ -73,16 +101,44 @@ public final class NativeHistoryProvider: HistoryProviding {
/// membership question, and the two arrays already answer it.
public var pendingSteps: [HistoryStep] { undoSteps }
/// **What every step this substrate still holds is holding** the sweep's gate
/// (`HistoryProviding.backedContent`; 13-native-undo.md Interaction with the trash, ruled
/// 2026-07-31).
///
/// All *three* collections, unlike `pendingSteps` above, and the difference is the question: that
/// one asks what Z would cross *now*, this one asks what has not been released. A step on the
/// redo stack has not retired "undone-and-superseded" is a release condition precisely because
/// being undone is not one so its backing is still owed; and a stranded step is the case where
/// the two questions come furthest apart, holding backing it will never again cross for
/// (`strandedSteps`, the skip-purge decoupling ruled 2026-07-31). The readings differing is what
/// makes them two members.
///
/// Leaving the stranded out would defeat the ruling by the back door: the step is off the stack,
/// so the next window open's sweep would call its backing residue and purge exactly the content
/// the skip was meant to leave standing.
public var backedContent: Set<HistoryAnchor> {
var backing: Set<HistoryAnchor> = []
for step in undoSteps { backing.formUnion(step.backing) }
for step in redoSteps { backing.formUnion(step.backing) }
for step in strandedSteps { backing.formUnion(step.backing) }
return backing
}
/// Records one undoable step and clears the redo stack the classic rule, and the one every
/// substrate shares.
///
/// The cleared steps are **retired** on the way out (`HistoryStep.Retirement`): this is the
/// "undone-and-superseded" half of the deferred purge's release condition, and it is the only
/// moment the app can see it.
/// "undone-and-superseded" half of the deferred purge's release condition a *clean* exit in the
/// ruling's own word and it is the only moment the app can see it.
///
/// `strandedSteps` is deliberately untouched: superseding is something that happens to a step on
/// the redo stack, and a stranded step is on no stack to be superseded from. Its one exit is the
/// session's end.
public func register(_ step: HistoryStep) {
undoSteps.append(step)
retire(redoSteps)
let dropped = redoSteps
redoSteps.removeAll()
retire(dropped)
}
public func undo() { cross(.undo) }
@@ -92,17 +148,38 @@ public final class NativeHistoryProvider: HistoryProviding {
/// Session teardown, the add-git substrate swap, a branch reseed every step goes, so every step
/// retires: "the purge runs when the coarse step leaves the board stack ... or the board session
/// ends" (13 Interaction with the trash).
///
/// **This is where the stranded finally pay**, and the only place they can: "a stale-skipped
/// step's backing instead survives to board-session end" (ruled 2026-07-31) names exactly this
/// moment, and `AppModel`'s teardown reaches it with the store still alive the `clear()` is a
/// line above the store's release, for the reason the deferred purge needs it to be.
public func clear() {
retire(undoSteps)
retire(redoSteps)
let dropped = undoSteps + redoSteps + strandedSteps
undoSteps.removeAll()
redoSteps.removeAll()
strandedSteps.removeAll()
retire(dropped)
}
/// **Off the stack first, then retired** the order every caller above keeps, and the one
/// `backedContent` depends on: a retirement is where the deferred purge runs, and a step that
/// retired while still listed would be answering that it still holds the very content it is
/// releasing.
private func retire(_ steps: [HistoryStep]) {
for step in steps { step.retirement?.run() }
}
/// Keeps a skipped step's *hold* alive without keeping the step crossable see `strandedSteps`.
///
/// **A step holding nothing is dropped outright**, which is every board gesture: with no backing
/// to spare and no retirement to defer there is no consumer for the entry, and stranding it would
/// be bookkeeping that only grows. The predicate is the pair the ruling's two consumers read, so
/// it can never spare a step neither of them would ask about.
private func strand(_ step: HistoryStep) {
guard step.retirement != nil || !step.backing.isEmpty else { return }
strandedSteps.append(step)
}
// MARK: - The crossing
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** 13's
@@ -119,9 +196,13 @@ public final class NativeHistoryProvider: HistoryProviding {
push(step.reversed, onto: direction.opposite)
return
case .skipped:
// Dropped for good the third of the deferred purge's release conditions ("gone
// stale"), and the reason a skip is reported here rather than merely counted.
step.retirement?.run()
// **Off the stack, but not released** the skip-purge decoupling (13 Interaction
// with the trash, ruled 2026-07-31). A stale skip is the one exit that is not clean:
// "the skip banner says nothing was applied, and an irreversible purge riding that
// gesture would be surprise loss the skip is exactly when the user may want to
// inspect what the collision left". So the step stops being crossable here and its
// hold outlives it, to the session's end (`strandedSteps`).
strand(step)
continue
case .failed:
push(step, onto: direction)