Files
interactive-story/planning/archive/phase0/PHASE-0B-UNDO-SPIKE.md
JesseMarkowitzandClaude Opus 5 d27ee34901 Docs: consolidate active planning and archive historical material
The planning package had grown to where a new agent could not tell what was
authoritative. Phase 0 execution prompts sat beside the specification; four
completed milestone reports sat beside the current one; and upstream AI-DnD's
own `plan/` build log and `docs/` project site still described a hosted,
scripted, multi-user product with accounts — every screenshot in it showed a
Scripts tab and a Sign up button, none of which has existed since M2.

`planning/archive/` now holds the history and says so in its own README:
`phase0/` for the research that chose AI-DnD, `milestone-reports/` for M1 and
M2, `decisions/` for ADR 008, the Phase-0-before-build gate Phase 0 satisfied.
`planning/reports/` holds only the current milestone's report, because that is
the one M4 planning has to read; it moves to the archive when M4's replaces it.

Deleted rather than archived: the Phase 0B execution prompts and the
handoff/status/summary documents, the Phase 0A discovery and triage reports,
upstream's `plan/` and `docs/` trees, and `frontend/README.md`, which was Vite's
template boilerplate. All of it is in Git history, and the two recommendation
reports carry every conclusion the deleted research reached.

Archived documents are kept verbatim. Paths written inside them point at where
those files were when the document was written, which is the point: an evidence
record that has been quietly edited is no longer evidence.

Active documentation is corrected where it pointed at the removed trees or
described removed capability as present. `DEVELOPMENT.md`'s "things M1 did not
touch" list had gone stale at M2 and claimed QuickJS scripting was still tested;
its test count was 604 against an actual 638. `README.md` loses the upstream CI
badge, which reported upstream's pipeline rather than this fork's, and a
reference to `backend/app/worldstate/engine.py`, a file that does not exist.
`planning/README.md` is rewritten as the documentation index.

New: `planning/PROJECT-SOURCES.md` and `planning/project-sources.txt`, the
manifest of what belongs in the ChatGPT project's Sources.

Source comments referring to the deleted trees are reworded; no behaviour
changes. 638 backend tests pass, frontend lints and builds, and a reference scan
over all 48 tracked Markdown files reports no unresolved path in active
documentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCbwH7yLGKsj1rhXXzKSCu
2026-09-03 14:33:07 -04:00

9.0 KiB

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:

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:

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.