# Phase 0B — Spike: Non-Destructive Undo/Redo in AI-DnD **Date:** 2026-09-01 **Base:** AI-DnD `d72f7c1b`, disposable copy at `phase0b/spike/` **Verdict:** **Passed on every criterion.** The retrofit is small, centralized, and costs 5 of 632 tests — all of which assert the deleted-row behavior that was deliberately replaced. ## The question Can undo move a head cursor backward without deleting rows, does writing below a moved-back head fork a branch, and does Redo work — without breaking branch-scoped memory isolation? ## What the change turned out to be Three files, **+130 / -31 lines**, and roughly a fifth of the additions are comments and the new endpoint's docstring. | File | Change | |---|---| | `app/context/lineage.py` | +26 / -3 | | `app/routers/adventures/takes.py` | +74 / -27 | | `app/routers/adventures/turns.py` | +30 / -1 | ### 1. Read path — cap the lineage at the head The stored lineage records the newest branch entry **uncapped** (`max_depth = None`, meaning "through to the tip"), and older entries capped at their fork depths. `Path.clause` and `Path.contains` now read an uncapped entry as capped at `self.tip`, which is `adventure.head_depth`: ```python def _cap(self, max_depth: int | None) -> int | None: return self.tip if max_depth is None else max_depth ``` This is the whole read-side change. It works because **every read already funnels through `lineage.path_of()`**, so one substitution moves the entire application — transcript paging, context assembly, `attempts.preceding`, and memory retrieval — onto a head that can sit behind the deepest node. A `Path.uncapped()` helper was added for the two callers that must deliberately look past the head (redo, and the fork check). `prefix_covering` already computed `top = self.tip if max_depth is None else max_depth`, so the windowing arithmetic was consistent with this reading before the change; nothing there needed touching. ### 2. Undo — move the head instead of deleting `undo_turn` keeps its turn lock, its "nothing to undo" guard and its fork-boundary guard, keeps `attempts.preceding` + `attempts.restore_state` for the state rollback, and replaces the two `delete_turn` calls plus `tree.refresh_head` with one assignment: ```python adventure.head_depth = (first_removed.depth or 0) - 1 ``` No `db.delete`. No `memorybank.forget_node`. ### 3. Write path — fork when writing below the head `turns.fork_if_behind_head` runs in `create_action` beside the existing `_move_to_after`. If any live node on the path sits deeper than the head, it calls the **already-existing** `tree.branch_at(db, adventure, adventure.head_depth)`, which creates an empty branch leaving the path at that depth and does not touch the branch being left. When the head is at the tip it does nothing, so a story that is never undone forks exactly as often as before. ### 4. Redo — walk the head forward New `POST /adventures/{id}/redo`. Reads the path *uncapped*, takes the next node deeper than the head, and advances over the whole turn (player action + AI reply) rather than half of it. 400 when there is nothing to redo. ## Results ### Undo deletes nothing; redo round-trips exactly Six actions across three turns, live Ollama: ``` AFTER 3 TURNS head=(branch 1, depth 5) rows 1..6 all live visible: [1 story, 2 ai, 3 do, 4 ai, 5 do, 6 ai] UNDO x1 rows 6 -> 6 DELETED=0 head=(1, 3) visible: [1 story, 2 ai, 3 do, 4 ai] UNDO x2 more head=(1, -1) visible: [] rows still 6 REDO x3 head=(1, 5) rows 6 visible: [1 story, 2 ai, 3 do, 4 ai, 5 do, 6 ai] ``` Elsewhere in the run, **11 consecutive undos** were performed on a 23-action story with 0 rows deleted, which clears the specification's "at least five undo operations, unlimited preferred" comfortably. ### Writing below a moved-back head forks, and the abandoned line survives ``` UNDO once, then write a different continuation: 1 story br1 d0 live 5 do br1 d4 live <- the abandoned tail, 2 ai br1 d1 live 6 ai br1 d5 live still live, still on br1 3 do br1 d2 live 4 ai br1 d3 live 7 do br2 d4 live <- the new line 8 ai br2 d5 live branches: {id 1, parent null, fork_depth null, own_actions 6, is_head False} {id 2, parent 1, fork_depth 3, own_actions 2, is_head True} visible on the new line: [1, 2, 3, 4, 7, 8] switch to branch 1 : [1, 2, 3, 4, 5, 6] ``` This is `DECISIONS/005-branch-preserving-history.md` behavior: the abandoned future is retained as an alternate branch rather than erased, and it is reachable through the existing branch switcher with no new UI concept. ### Memory isolation still holds — the important regression check A 23-action story with a real memory embedded via `nomic-embed-text` at depth 5. Probed with terms unique to the turns an undo hides, plus terms from the turns it keeps: ``` AT TIP (control) head_depth=22 memories_used=1 (similarity 0.689) hidden-turn terms leaking: ['six bullets','warehouse nine','iron stairs', 'ledger','docks','office desk','inside my coat'] (correct — nothing is hidden at the tip) visible-turn terms present: ['rainy city','desk drawer'] 11 undos, 0 rows deleted (actions still 23) AFTER UNDO head_depth=3 memories_used=0 hidden-turn terms leaking: NONE visible-turn terms present: ['rainy city','desk drawer'] REDO back to tip head_depth=22 memories_used=1 (similarity 0.689) ``` The memory at depth 5 stops being retrieved when the head moves behind it and becomes eligible again on redo — **without being deleted**. That is the property the destructive undo bought by calling `forget_node`, recovered for free, because `Memory` rows carry `branch_id` and `depth` and retrieval already goes through the same capped clause. > One false alarm worth recording: an early probe reported `revolver` leaking. It > was legitimate visible history — actions 22 and 23 sit at depths 2 and 3, at or > below the head. A second false alarm came from probing the context *after* the > script had already redone to the tip. Both were resolved by re-probing inside a > single script with an explicit control, which is why the table above reports the > control run alongside the result. ### Test suite: 627 passed, 5 failed ``` 5 failed, 627 passed in 184s (0.8% of the suite) tests/test_attempt_siblings.py::test_undo_takes_every_attempt_with_it tests/test_branch_forking.py::test_undo_stops_at_the_fork tests/test_state_revert.py::test_undo_reverts_state_to_before_the_turn tests/test_state_revert.py::test_undo_of_bare_continue_uses_the_node_in_front tests/test_state_revert.py::test_undo_prunes_memory_covering_removed_actions ``` Every one asserts that rows or memories were **deleted**: - `assert [a.type for a in adv.actions] == ["start"]` (three of them) - `assert len(_rows(...)) == rows_before - 1` - `assert texts == {"k"}` — the pruned-memory set Crucially, the *state* assertions inside those same tests still pass. In both `test_state_revert` cases, `assert adv.script_state == {"gold": 0}` succeeds and only the row-count line fails — state rollback is intact. And `test_undo_stops_at_the_fork` still enforces its real subject: the guard refusing to undo into a parent branch is untouched and still returns 400. Also worth noting for the open question raised in the recommendation: the four memory suites — `test_memory_settling`, `test_memory_nodes`, `test_memory_retrieval`, `test_memory_rewrite` — and `test_history_window` all **passed unchanged**. The cursor arithmetic did not need re-deriving. ## Rough edges found, not fixed 1. **Undo can empty the story.** The original guard refuses when the newest node is `type == "start"`, but an adventure opened with a user-written `story` action has no `start` node, so undo walks to `head_depth = -1` and the transcript renders empty. Redo recovers it, but the floor should be the opening node rather than `-1`. 2. **Retry and `add_take` were left on the old path.** The spike only routed the ordinary write through `fork_if_behind_head`. Retrying while the head is behind the tip is not yet defined and needs a decision — most likely the same fork. 3. **No pruning story yet.** Abandoned turns now accumulate. That matches the specification ("retained but marked disposable; cleanup later"), but nothing marks them disposable and no cleanup exists. 4. **Frontend untouched.** There is no Redo button and the branch panel does not distinguish a line abandoned by undo from one forked deliberately. ## Conclusion The retrofit lands where the recommendation predicted: one chokepoint (`lineage.Path.clause`), one stored head that already existed (`adventures.head_depth`), and one branch primitive that already existed (`tree.branch_at`). The unknown was whether the depth cap would disturb the memory and summary cursors, and it does not. The fork decision is sound. The remaining work on this axis is the four rough edges above plus named checkpoints, not a redesign.