Commit Graph
3 Commits
Author SHA1 Message Date
JesseMarkowitzandClaude Opus 5 e08d49c3eb M4: add durable named Save Points
A Save Point is a name for a story position, and restoring one is head
movement. That is the whole architecture, and it is what ADR 012 and
BUILD-MILESTONES' note on M4 asked for: M3 made the head a stored
(branch, depth) and made arriving at one a row lookup plus a state restore,
so a Save Point needs no restore machinery of its own.

What the user gets:

- Name the moment they are reading, keep playing, restart the app, and come
  back to it. Restoring moves the story back and deletes nothing: the later
  turns stay, Redo still walks forward into them, and writing something
  different is what starts a new line while the old one is kept.
- Rename, delete, and a list, in a Save Points panel beside the branch panel,
  with a Save Point button next to Undo and Redo. Both confirmations say what
  is *not* destroyed, because that is the part the screen cannot show.
- Save Points survive export and import.

What was deliberately not built:

- No second restore path. `head.move_to_node` is the only new movement: its
  depth half is M3's `head.move_to` unchanged, and its branch half is the
  single assignment `switch_branch` already makes. No head field is written
  in the checkpoint router, nothing reconstructs state, nothing prunes a
  memory, nothing copies or deletes a turn, and restore never forks — the
  first write below the restored head does, through `fork_if_behind_head`.
- No automatic cleanup. A Save Point behind the head, or naming a line the
  story left, is doing its job (STORY-BRANCH-SEMANTICS §19). The one removal
  is a cascade: deleting a branch takes its Save Points, as it takes its
  memories, because the story they named went with it.
- No new ADR. ADR 012 already decides the architecture, and a table is not a
  decision.

The one call the planning package did not already make: restore moves the
branch half of the head only when the coordinate is off the path being read.
Doing it unconditionally would quietly hand back an abandoned continuation
whenever a Save Point in a shared prefix was restored; never doing it would
make a Save Point on a departed line unrestorable, which contradicts §19.
TECHNICAL-DESIGN §8.8 records it.

Schema: a `checkpoints` table holding a name, an optional note and a
(branch, depth) coordinate — no copy of any story. `create_all` builds it as
it did `memories` and `branches`; migration 80 adds the index. No backfill,
because nobody had named a position before M4.

The coordinate is deliberately not an action id: one coordinate holds every
attempt at a turn and exactly one is live, so a coordinate follows a retry
where a row id would pin a take the story no longer tells.

Tests: 680 pass (638 before). 42 new in tests/test_save_points.py covering
D11-D14, I04, L03, E-series lineage and memory isolation after restore and
divergence, the edge cases, and an M3-database migration. One pre-existing
fixture in test_tree_migration.py needed `checkpoints` added to its drop
list — SQLite refuses to drop a table another table references.

Not verified: the browser. No session has had a usable one, so the Save
Point panel's DOM behaviour is unobserved — as M3's Redo control still is.
The twenty-step sequence was driven over HTTP against a live server with a
real process restart instead, and all seventeen checks pass. M4 is
implemented, not accepted: no review has been written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-03 18:48:54 -04:00
JesseMarkowitzandClaude Opus 5 7f082b61d8 M3: complete non-destructive history and active-head export
The head-cursor model landed in 903fa7a and stopped there: the backend
moved the head instead of deleting turns, but a bundle still reopened at
its newest row, the browser had no way forward, and five inherited tests
still asserted the contract Undo had just stopped honouring. This is the
rest of the milestone, plus the one unsafe operation the review found.

Export now writes headDepth, and it belongs on the other side of the rule
app/bundle.py states about itself. The head depth used to be derived —
the tip of the head branch, a fact about the nodes that arrived with it —
and that was true while Undo deleted, because the newest row was the only
place a story could be read. It is a decision now: the same tree exports
identically whether the user undid three turns or none, so the file has
to say. An import that ignored it would silently Redo the story to its
newest retained turn, which is the Phase 0B export finding this milestone
exists to close. A file with no headDepth is opened at the tip, which is
not a fallback but the position such a file recorded; a file naming a
depth its own rows do not reach is refused in plan(), before a row is
written, for the reason that module gives about half-written trees.

Which branches the story has left goes into the file for the same reason.
Every row of an abandoned line arrives on an import either way, so the
disposition is the only thing telling it apart from an active one, and a
restored backup that had lost it would have nothing for the later cleanup
and recovery screens to select on. Both keys or neither: a time with no
depth cannot say what was displaced.

The browser gets a Redo button beside Undo, on Ctrl+Shift+Z, and both are
enabled from can_undo/can_redo rather than from the transcript. Neither
is derivable on the client — Undo stops at the campaign opening, which
may be off the top of the loaded window, and Redo depends on the retained
future, which the client is never sent — so the flags now ride on every
window the server hands back, including a scrolled-up page and the
response to an import. Moving the head also refreshes the state panels,
which undo never did: it has rolled the world state back since long
before M3 and the drawer kept showing the old numbers.

An in-place edit is now refused when story descends from the turn and is
not on screen. Editing rewrites one row and re-evaluates nothing, which
is what makes it a correction rather than a continuation, and that is
harmless while everything below the turn is visible — the reader can see
what their change has to agree with. It stops being harmless when the
continuation is undone, or was left behind by a divergence, because the
edit then silently changes the words an invisible stretch of story was
written from. That was the one way M3's retained history could be made to
contradict itself. Refusing is deliberately the whole of the fix: making
such an edit fork is STORY-BRANCH-SEMANTICS.md §14-15, and §15 wants the
state the edited prose implies re-evaluated, which is M5's extraction
pass. The requirement is not weakened, only deferred, and §14A now says
so.

The predicate asks one question rather than two. A descendant is
invisible either because it is past the head on this lineage or because
it is past a fork on a branch the story left, and both are "a live node,
deeper than this one, descending from it, off the path being read". A
first attempt scoped the search to branches other than the active one and
failed the divergence case, correctly: the departed branch is usually an
ancestor of the branch now being read. Only the deepest live node on each
descending branch is examined, because visibility is monotone in depth.

Five inherited tests are rewritten rather than deleted, because what they
were protecting is still worth protecting and only the mechanism changed.
The undo-state pair keeps its state assertions and swaps "the rows are
gone" for "the rows are all here and the story is read from earlier". The
memory test stops asserting that undo prunes memories and starts
asserting the property that replaced it: a memory past the head is
unreachable, still on disk, and retrievable again after Redo, with no
re-embedding. The attempt-group test still proves the group moves as one,
out of the story rather than out of the database. And the fork test
reverses: Undo used to refuse at a fork point because it deleted rows the
parent branch was also reading, and with nothing deleted there is nothing
to protect the parent from, so it now walks into the story the branch
inherits and stops at the campaign opening instead.

tests/test_head_cursor.py is the milestone's acceptance contract, named
by the items it discharges: D01-D10, E01-E04, I01-I03, I07, L01-L02, and
the invariant they all rest on — Undo deletes zero accepted turns,
asserted on row ids over the whole retained tree. E02 has both controls,
because a negative control alone would pass if memory retrieval were
simply broken. L01 records what it does not claim: the head does move by
one on a failed turn, onto the player's retained input, which is A05
rather than a gap. Two of the edit-guard tests exist to prove the guard
stays out of the way — a correction at the tip and a correction mid-story
with everything visible must both still work.

638 backend tests pass. Frontend lint is unchanged at seven pre-existing
warnings, none in the files touched; the bundle builds at 395.85 kB; the
production image builds.

Verified at runtime against the trusted-LAN Ollama over HTTPS: three
turns, two Undos, a Redo, a Retry, an Undo, a divergent continuation,
Redo correctly refused with 400, Undo back to the opening, export, a
process restart that reopened the campaign still undone, and an import
that opened at the same position with its retained future intact. The
browser click-through of that sequence has not been run — no session in
this milestone had a browser to drive — so the Redo control itself is
verified by its endpoint and its lint and build, not by a click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
2026-09-03 13:54:43 -04:00
JesseMarkowitzandClaude Opus 5 903fa7a74f M3: move the story's head instead of deleting its turns
Undo deleted. It removed the trailing AI action and the player action in
front of it, pruned the memories covering them, and let the tip fall back
to whatever survived. That made it the one operation in the application
that destroyed accepted story, and it was why there was no Redo: the
turns to move forward into no longer existed. Phase 0B demonstrated the
head-cursor alternative in a disposable spike; this is that concept as
production code.

backend/app/head.py is the whole of it. Three questions that used to be
one — where the story is being read, how far it is retained, and where it
opens — are now three functions, and every caller that moves the head or
asks about it goes through this module. The spike put the fork check in
the write path and left Retry and Add-take on the old one; sharing the
rules is what stops that divergence coming back.

lineage.Path now caps every entry at the head, so hiding the retained
future costs nothing at the call sites: the transcript, the context
builder, attempts.preceding and memory retrieval already funnelled
through path_of and narrow together. Path.uncapped() is the deliberate
exception, and only Redo and the fork check may use it. The memory bank
needs no pruning for the same reason — a memory carries the coordinate of
the node its block ends on, so one derived past the head falls outside
the capped clause and becomes retrievable again on Redo without having
been deleted and re-embedded.

Undo alone does not fork. Moving the head is not a decision to abandon
anything, since the user may be reading or about to Redo; the first write
below the head is where the story states which continuation it means. A
head already at the tip forks nothing, so a story that is never undone
forks exactly as often as it did before and the branch table does not
fill up with one branch per turn. Redo follows the lineage rather than
choosing among branches, which is what invalidates it after a divergence
with no flag to set or clear.

Migrations 78 and 79 give a branch superseded_at and superseded_depth.
Nothing reads them to decide behaviour — Redo is decided by the lineage,
so a stale or hand-edited value here cannot make the story wrong. They
exist so the cleanup and discarded-history features left to a later
version have something to select on, and so a divergence is observable in
a test.

Deleting an action no longer drags a moved-back head forward to the
recomputed tip, which would have silently redone the story. can_undo and
can_redo ride on AdventureOut and ActionPage because the client can work
out neither for itself: the campaign opening may be off the top of the
loaded window, and the retained future is never sent to it.

This is a checkpoint, not the finished milestone. 601 backend tests pass.
Five still assert the destructive contract — they count rows after an
undo and expect the story to be shorter — and need rewriting against the
new one; the world-state assertions inside them already pass. Export and
import do not yet carry the head coordinate, so a bundle still reopens at
the deepest node and can silently redo an undone story, which is the
Phase 0B finding this milestone exists to close. The browser has no Redo
control yet. None of the M3 acceptance coverage (D01-D10, E01-E04,
I01-I03, I07, L01-L02) is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
2026-09-03 11:45:01 -04:00