2026-07-30 findings-resolution ruling (Redesign card d40bfac1): Finder's ⌥⌘⌫ answered disk-space pressure boards don't have, and it was the one gesture reaching unrecoverable straight from the board. The delete vocabulary is now purely staged: board → trash, trash → permanent (confirmed on no-git boards), Empty Trash for bulk. Docs drop the File menu row, both ⌥-alternate context rows, and the VO custom action; code removal rides the session's realign card. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
7.8 KiB
Native Undo (base edition)
The undo/redo substrate for base Lanework (12-editions.md), filling the one gap mode:none admits (06-history-undo.md, 07-sync-collab.md): boards without git had no undo. Pro's substrate remains git (06); this doc never applies there. 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
- 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.
window.undoManagerfor board surfaces returns the session's manager; 06 ▸ Undo routing applies unchanged — text-editing surfaces get their session-scoped text undo, everywhere else ⌘Z/⇧⌘Z hit the board stack. Undo is board-local, exactly as git undo was. - 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): 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); 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. 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 (post-2.0) keep the no-capture rule true (ruled 2026-07-29): 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), so its inverse is the ordinary move back; the comment operations (post, edit, delete) join the WriteOperation inventory and the move-based inverse family when the feature ships (post-undo naturally rides the same rail — exact inventory settled at the build pass). The window-close purge of comments/.trash/ registers nothing (the permanent-delete posture), and any comment steps still on the board stack afterwards simply go stale and skip by the ordinary staleness validation — effectively window-lifetime undo for comments with zero new machinery, the one-stack-per-board rule and lazy invalidation untouched.
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.