10 KiB
Native Undo
The undo/redo substrate for every board without app-managed git, in every tier (re-ruled 2026-07-31 — the provider follows the board, not the tier alone; formerly free-tier-only, which made a Pro upgrade remove undo from mode-none boards): the free tier binds it everywhere (any .git inert — 12-editions.md), and Pro binds it on mode-none and repo-nested boards alike (the repo-nested no-undo case retired 2026-07-31: leave-strictly-alone concerns git, and this stack never touches git — memory-only, journal-free, session-scoped — so what repo-nested denies is app-managed history, never ⌘Z; the upgrade story is thereby exceptionless), switching to the git provider (06-history-undo.md) where the board's own git exists. Add-git swaps the substrate mid-session — the commanded flip discards the in-session native stack and seeds the git trail from the root commit, the branch-switch discard-and-reseed precedent applied; a subscription lapse still never interrupts (12). 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
window.undoManageranswers with it (standard per-window AppKit scoping). 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 ("Edit card 'Fix login'"), 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. 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo above either stack. - 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, originalorder); reorder → restore originalorder; 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) —
purgeIsUnrecoverablestays 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. - 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. Git undo's survive-relaunch property is a Pro difference, stated honestly (12's matrix).
- 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 vs Pro (12's matrix). 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
modifiedstamp (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'sorderwrite. 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.
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 (undone-and-superseded, dropped off the end, or gone stale) or the board session ends; crash residue still sweeps at the next card-window open (the armed-then-cleared memo, unchanged). On Pro the substrate is history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge rides the close flush there as before — purge timing follows the undo substrate's need.
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 gap to Pro. 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.