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
This commit is contained in:
JesseMarkowitz
2026-09-03 18:48:54 -04:00
co-authored by Claude Opus 5
parent 3c8e91f644
commit e08d49c3eb
20 changed files with 2272 additions and 26 deletions
+67
View File
@@ -413,6 +413,49 @@ than act silently, because retained history must not be made to disagree with
itself in a way the user cannot see. See `STORY-BRANCH-SEMANTICS.md` §10 and
§14A.
### 8.8 Save Points, as implemented in M4
M4 added durable named Save Points and built nothing in §8 that was not already
there. This records what the milestone establishes as fact.
**A Save Point is a name and a coordinate.** The stored row holds the name, an
optional note, and `(branch, depth)` — the same pair §8.7 calls the head. It
holds no transcript, no state, no summary, no memory, and no branch contents.
`DATA-MODEL.md` §8 describes the pointer as naming a turn; the coordinate is
that turn's address, and `DATA-MODEL.md` §8's implementation note records why
this project uses the address rather than a row id: one coordinate can hold
several attempts at a turn, and a retry replaces the live one. "Turn 42 of this
line" survives a retry; a row id would pin a take the story no longer tells.
**Restore is head movement, and nothing else.** It resolves the coordinate,
refuses it if it no longer names a live turn, and then moves the head — the
depth through §8.7's single move operation, unchanged. The transcript, the
assembled context, the state and memory eligibility all arrive together because
they already read through the one capped lineage. There is no second restore
path, no state reconstruction, no memory pruning and no separate redo stack:
D13 is satisfied by the mechanism rather than by code written to satisfy it.
**A Save Point may name a position on a line the story has left.** Save Points
survive divergence, so this is reachable in ordinary use, and the depth half of
the head cannot reach a branch the current path does not contain. Restore
therefore moves the branch half as well when, and only when, the coordinate is
not on the path being read — the same single assignment a branch switch makes.
The distinction matters in the other direction too: a Save Point in a shared
prefix must *not* drag the reader onto the ancestor, because which continuation
follows that turn is exactly what the reader has already chosen.
**Restore never forks.** Moving the head is not a decision to abandon anything.
The first write below the restored head forks, through §8.7's existing check,
and the displaced future stays retained — so Redo still walks the original
continuation until the user writes something different, and stops offering it
once they have.
**Nothing removes a Save Point but the user.** There is no cleanup pass, and none
is wanted: a Save Point pointing behind the head, or into a line the story left,
is doing its job. The one exception is referential and not a policy — deleting a
branch takes its Save Points with it, by the same cascade that takes its
memories, because the story they named went with it.
## 9. Export / Import and Head Position
AI-DnD's current export carries branch information but reconstructs the imported head at the branch tip.
@@ -460,6 +503,30 @@ Every row of an abandoned line is exported either way, so without that metadata
restored campaign could not distinguish abandoned history from active history —
which is precisely what a later cleanup or recovery feature has to select on.
### 9.2 Save Points in the bundle, as implemented in M4
Save Points are exported and imported with the campaign, which is `I04`. They
fall on the "chosen" side of §9.1's rule without argument: a position someone
named is not recoverable from the rows, since nothing about a turn records that
a player once bookmarked it.
No format version bump. A bundle written before M4 has no `checkpoints` key and
imports with none, which is what such a campaign had — the same unambiguous
absence §9.1 relies on for the head depth, and the same treatment the persona
block and the branch disposition received.
The head and the Save Points are independent, deliberately. An import opens the
campaign where `headDepth` says, never at a Save Point merely because the file
carries one: the bundle records where the story was being read and, separately,
which positions were named, and choosing between them is the user's to make
after the file is open.
A Save Point whose coordinate names no turn in the file is dropped rather than
refusing the import — the opposite of the head depth's treatment, and for a
stated reason. A misplaced head affects every read in the file; a bookmark
pointing outside the story affects only itself, and rejecting a whole campaign
to protect one bookmark would lose the story to save the pointer.
## 10. Authoritative Narrative State
### 10.1 Do not retain the RPG state protocol as the product model