# Native Undo

The undo/redo substrate for **every board** (amended 2026-08-08 — strategy/01-git-excision.md: app-managed git is excised, and with it the git provider this doc used to hand off to; the earlier text here described the mode split and the add-git mid-session swap, both gone with the stack). The provider seam survives the excision deliberately — `HistoryProviding` is nil-safe and default-native (01-git-excision.md ▸ Reversibility) — so a future provider (the deferred foreign-change journal, an ops-backed history, or even git again) re-binds without re-plumbing; until one exists, this stack is the only substrate any board binds, repo-nested and user-repo boards included (this stack never touches git — memory-only, journal-free, session-scoped — so leave-strictly-alone holds by construction). The design problem is not NSUndoManager itself — it is native undo over **files-are-truth**: the disk can change underneath the stack, because the app is not the only writer.

## Rules

- **Two levels: one stack per board, one per open card window** (re-ruled 2026-07-31 — the session-coarsening model, superseding the pure one-stack rule): the **board stack** is owned by the board session and shared by board surfaces; a **card window owns its own stack** for the session it represents — every gesture issued in that window (comment post/delete/edit, body Edit sessions, style/details changes, attachment ops where undoable) registers there at fine grain, and the window's command surface answers with it (app-owned rows and toolbar targets — the command-surface rule below, re-ruled 2026-08-08; the delegate-vended `window.undoManager` premise this sentence used to state fell to the SwiftUI latch recorded there). Disk stays live throughout — files-first untouched; this is history granularity only. **Window close coarsens**: the session's net effect registers on the board stack as **one coarse step named "Changes to '⟨card⟩'"** (ruled 2026-07-31 — the board row reads "Undo Changes to 'Fix login'": plural and scope-flavored, distinct from every fine verb, honest about folding many kinds; the fine body-edit wording never leaks onto the board menu), values-based, whose undo restores the card subtree to its session-start state — deleted comments included — and whose redo reapplies the net effect; a session with no net change registers nothing. The coarse step is transactional at apply time: staleness validation runs per component (the field-level predicate below), and any stale component skips the whole step — never a partial session revert. **Session steps anchor by card identity, never by path** (ruled 2026-07-31): the coarse step — and the window's fine steps it folds — stores the card's UUID plus expected values, and apply-time validation resolves the card's *current* folder exactly the way the window itself always resolves its card (the per-snapshot UUID walk; `writeCardBody` already resolves trash locations on purpose). A tracked relocation — a lane move mid-session or after close, a trash move — therefore never stales the step; only genuine content changes do, which is what the validation exists to catch. A card that resolves nowhere (purged, or moved out of the board) is the honest skip. 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo above either stack. **Two stacks over one open card are the blessed shape** (2026-07-31): a board-issued gesture on a card whose window is open registers on the board stack while the window's own gestures register on the window stack — no ordering relation between the two, interleaving decided by ⌘Z focus (06 ▸ Undo routing); routing board gestures into the open window's stack was considered and rejected, since board ⌘Z must never see card-session steps.
- **Registration at the Writer boundary.** Every app-mediated mutation already passes through the Writer as a `WriteOperation` (02-architecture.md) — that closed enum is the exact inventory of undoable operations. Each Writer call site registers the inverse operation, computed from the pre-write snapshot the store already holds: move → move back (original lane, original `order`); reorder → restore original `order`; rename → restore title; restyle → restore prior style; resize → restore prior width; Edit-session body save → restore prior body bytes; card or lane delete (⌫) → move back out of `.trash/` (lanes rejoined the trash 2026-07-29 — the recreate-from-capture inverse retires with the last destructive delete); restore-by-move → move back in; create → remove the created folder.
- **What is not undoable** (settled): **Permanently delete** (the trash's Delete, Empty Trash) — `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule (03-board-ui.md) already fires on all base boards, since none have git history: the confirm *is* the safety. **The duplicate-id remint** (01-storage-format.md — a silent scheduled heal since 2026-07-29, formerly the user-gated Repair) — heals aren't user gestures, so nothing enters the stack, and undoing one would recreate the duplicate id it exists to remove. Permanently delete matches its existing "destructive, confirmed, final" posture; the remint sits outside undo as all heals do. **Raw Source Apply** (blessed 2026-07-31): the hatch writes byte-for-byte outside every contract — no `modified` stamp, no attribution clear, and no history step at either level; an Apply-only session folds to no coarse step, and an Apply mixed into a session is invisible to the fold. The hatch's story is "you edited the file," and files-are-truth covers it (05-card-window.md's carve-outs are the same statement from the stamping side).
- **Coalescing follows commit granularity** (settled; window scoping added 2026-07-31): one gesture, one undo step — a multi-card move is one step with a plural title; an Edit session is one step, registered at the Edit→Preview flip (the effective Save — 05-card-window.md) **on the card window's stack**, like every window gesture; the window close registers the one coarse session step on the board stack (Rules above); a styling batch is one step (03's one-gesture-one-commit rule, substrate swapped). The 06 vocabulary supplies menu titles ("Undo Move 3 Cards"), via NSUndoManager's dynamic retitling — the same naming machinery both editions use.
- **Session-only persistence** (settled): the stack lives with the board session and dies at close/quit — standard macOS behavior. (The excised git substrate's survive-relaunch property went with it; persistence is a future provider's to offer through the same seam.)
- **Foreign writes never join the stack** (settled): NSUndoManager can only undo what the app mediated. An agent's or hand edit is not a step — the honest capability gap, whose designated successor is the deferred foreign-change journal (strategy/01-git-excision.md ▸ Successors). Foreign changes also do not clear the stack wholesale; collisions are handled lazily, per step, by validation:
- **Staleness validation before every apply** (settled): an inverse operation re-checks its target against the disk — a fresh read of the target at ⌘Z time (blessed 2026-07-29: not the store snapshot, which is by construction one reload behind the app's own writes; a rapid ⌘Z run validated against the snapshot would compare pre-state and false-skip every step). **The predicate is field-level** (settled — ruled 2026-07-27): each step registers both sides of its write anyway (the before-value is the inverse; the after-value is what its write set), so validation compares the targeted field's current value against the expected after-value — nearly free, and truer to never-surprise-the-file than an existence-only check (an inverse rename must not clobber a foreign rename on a still-existing card; body steps compare bytes). Target folder gone, or the field no longer holding the step's after-value → the step is **skipped, not applied**: popped from the stack with an info-tone banner ("Undo skipped — 'Fix login' changed outside Lanework"), and ⌘Z falls through to the next step. Never apply a stale inverse on top of someone else's newer write. **Delete steps validate their undo by existence only** (blessed 2026-07-31): a delete's forward write sets nothing but the `modified` stamp (the arrival rank mint retired 2026-07-31), and a clock reading is not a choosable after-value — pinning it would false-skip the restore whenever an agent touched the trashed card; the undo therefore expects only that the trashed folder still exists, while the redo stays field-level via the restore's `order` write. **Invalidation is lazy** (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning — the EchoLedger's foreign diffs do not eagerly drop colliding steps. The stack always looks full; with the field-level predicate a skip fires only on a genuine per-field collision, and a skipped step's banner explains itself, where eager pruning would shrink the stack invisibly mid-session.
- **Locks disable the stack** (settled): every read-only lock (vanished root, failed reload after wholesale ops, unwritable location — 02-architecture.md) disables Undo/Redo with the other mutating commands; the stack itself survives the lock and resumes when it clears. Steps landed before a lock validate like any other at apply time.
- **The command surface is app-owned** (ruled 2026-08-08 — superseding the delegate-vended-manager mechanism): Edit ▸ Undo/Redo are the app's own rows (`CommandGroup(replacing: .undoRedo)`) and the toolbar pair carries explicit targets, both reading the focused session's `BoardUndoManager` through SwiftUI focus (`FocusedValues` — the ⌘I board-info precedent; board windows publish the session's manager, card windows their own): titles from `undoMenuItemTitle`/`redoMenuItemTitle` (the platform still composes and localizes the verb over the bare 06-vocabulary phrase), enablement from `canUndo`/`canRedo` (locks and empty stacks, unchanged). The original mechanism — the system's nil-target `undo:`/`redo:` resolving through `windowWillReturnUndoManager` — is unreachable on a SwiftUI window: NSWindow reads and **permanently latches** its own empty undo manager during window creation, before any app code can install the delegate, so the hook is never consulted (diagnosed 2026-08-07 by live probe, standalone-repro-verified; the green command-surface tests install the delegate before the first read and so never reproduce it). Alternatives rejected: registering steps into the latched manager re-opens the substrate problem the two-arrays decision answered (a `.failed` crossing must leave the step in place; NSUndoManager pops a group before running it and offers no way back); intercepting nil-target `undo:` from the responder chain is preempted by NSWindow handling the action itself, ahead of its delegate. The rows enact ▸ Undo routing themselves — the same predicate, relocated from the window delegate to the command layer: while a text surface holds focus, ⌘Z goes to the first responder's own `undoManager` (an editor's delegate-vended manager, or a field editor's window-latched one, which is exactly where its typing undo registered), and otherwise to the focused board or card stack. The `windowWillReturnUndoManager` hook stays wired — it is the correct answer wherever AppKit itself asks a delegate for a manager — but nothing of the command surface depends on it any more.

## Undo routing

(Migrated here 2026-08-08 from 06-history-undo.md at that doc's retirement — the rule was always tier- and substrate-independent, and this is its live home now; four docs cite it by this heading.)

**Routing is by focus** — the platform's first-responder rule. While a text-editing surface is focused (card title field, body Edit mode, raw source, board inline rename), ⌘Z/⇧⌘Z are that editor's own **text undo** — standard, transient, session-scoped: leaving the editor (mode flip, focus loss, close) ends the session, and from then on that content's undo story is board history. Text undo works on **every** board, and so does board-level undo: every board binds a provider (the native stack today; the seam admits future providers), so "no undo" is not a state any board is in. **Control-class text fields route the same way** (settled): the search field (04-interactions.md ▸ Search), the popover's rename field, and the popover's other configuration fields own ⌘Z/⇧⌘Z as field-local text undo while focused — "board menu commands stay enabled" never hands Edit ▸ Undo to board history while a text-bearing control has focus; a reflexive undo over a typo must never become a board-level restore. With focus outside every text-bearing surface — editor or control — Edit ▸ Undo/Redo are, **in a card window, that window's own session stack** (the two-level model in Rules above — fine-grained window gestures; the coarse close unit is one board step), and on board surfaces board history — the board's bound provider (disabling is locks and empty stacks). **No fall-through**: exhausting a focused editor's — or the window's — stack beeps; it never reaches board history. **The routing decision is enacted at the command layer** (re-ruled 2026-08-08, mechanism only — the rule itself is unchanged): the app-owned Undo/Redo rows apply this section's predicate themselves (Rules ▸ the command-surface rule), because the window-delegate hook the platform route rode is never consulted on a SwiftUI window.

## Interaction with the trash

⌫'s undo is the move back — a delete is a move into `.trash/` (cards resettled 2026-07-28; lanes rejoined 2026-07-29), so its undo is the ordinary inverse move, returning a card to its source lane and rank, a lane to its strip position (subtree intact — it never left the folder); a restore-by-move undoes the same way in reverse. The stack and the trash never conflict — they are the same folder moves addressed by recency instead of by selection. The old lane-delete recreate-from-capture inverse is **retired** — no destructive delete remains outside a trash, so nothing needs byte capture. A **permanent delete registers no step** — the trash's Delete and Empty Trash are not undoable (Rules above), lanes and their freight included; the confirm is the safety.

**Comments keep the no-capture rule true — on the window stack** (re-ruled 2026-07-31, superseding the board-stack routing): a comment delete is a move into the card's `comments/.trash/` (01-storage-format.md ▸ Enhanced schema — the materialized-trash pattern one level down), its inverse the ordinary move back, and the step lives on the **card window's own stack** (Rules above) — the board stack never carries a granular comment step, so the old stale-after-close skip scenario cannot arise. **The purge of `comments/.trash/` defers with the coarse step** (re-ruled 2026-07-31, superseding purge-at-close): 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 **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** (ruled 2026-07-31, decoupling skip from purge): 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; crash residue still sweeps at the next card-window open — **and residue is defined by the purge-deferral condition itself** (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. Unowned content sweeps as before, armed-then-cleared like every heal memo. **Every purge of `comments/.trash/` is per-entry behind the ownership gate** (ruled 2026-08-06 — the container-whole retirement purge retires): a step's retirement and a no-step close remove only entries no live step still backs — the same `backedContent` inventory the sweep consults, making it one condition, *three* consumers. The container-whole purge assumed one owning step per card's comment trash, and two sessions over the same card broke it: the second step's retirement — or a mere reopen-and-close that registered nothing — emptied the first step's backing out from under it, silently killing an undo the stack still promised. Under the gate a purge cannot stale a live step by construction; an entry that outlives its owner is collected by whichever consumer runs next (the next retirement, close, open-time sweep, or session end — convergence, not a leak). One carve-out: **an open card window is itself an owner of its card's comment trash** — a retirement firing while the card's window is open defers its purge to that window's close, because entries deleted in the live session are backed by the window's fine steps, which the board-stack inventory cannot see; the close then settles by the same gate (its coarse step becomes the owner, or the no-step close purges the unowned). Purge timing follows the undo substrate's need — a future provider with durable history may ride its own flush instead, through the same seam.

## Out of scope

- **Attachment operations, v1** (deferred — ratified 2026-07-27): attach → remove is a clean inverse, but remove-attachment → re-add requires the removed file to survive somewhere (a staging area with a lifecycle — App Support, bounded, its own cleanup rules; possibly shared with 04 ▸ Clipboard's staging). The deferral is the ruling: attachment add/remove registers **no undo step** in v1 (the operations remain, as today, confirmed-or-benign); the staging design pass reopens post-2.0.
- **EchoLedger-synthesized foreign undo** (deferred, wishlist — WISHLIST.md item 6): the ledger already classifies foreign diffs for announcements; it could synthesize inverse operations and push foreign steps onto the stack, narrowing the foreign-change gap (now the deferred journal's charter — strategy/01-git-excision.md ▸ Successors). Real design needed (ordering vs app steps, attribution, user expectations) — not assumed by this doc.

## Open questions

None currently — the staleness predicate (field-level) and invalidation timing (lazy) were ruled 2026-07-27 and are settled in Rules above.
