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
@@ -13,6 +13,7 @@ Read the modules in this order to follow a turn from end to end:
turns playing a turn, and the lock that allows only one at a time
takes retries and the attempts that collect at one coordinate
branches where a story splits
checkpoints Save Points: durable names for positions the head can return to
What this package re-exports, and what it deliberately does not:
@@ -31,6 +32,7 @@ from . import ( # noqa: F401
turns,
takes,
branches,
checkpoints,
bundle_io,
refresh,
insights,
@@ -0,0 +1,258 @@
"""M4: Save Points — create, list, rename, delete, and restore.
A Save Point is a durable named pointer to a story position and nothing else.
It stores a coordinate, never a copy of any story, and restoring one moves the
active head to that coordinate. That is the whole design, and it is what
`BUILD-MILESTONES.md`'s note on M4 and ADR 012 ask 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 is deliberately absent from this module, because a second copy of any of it
would be the failure M4 is warned about:
* no head fields are assigned here — `head.move_to_node` moves the head, and
`head.move_to` under it restores the state, exactly as Undo and Redo do;
* nothing reconstructs state, prunes a memory, copies a turn, or deletes one;
* nothing forks. Restore is not a decision to abandon anything, so it creates no
branch. The first write below the restored head forks, through the same
`fork_if_behind_head` every other write goes through, and the displaced future
stays retained (`STORY-BRANCH-SEMANTICS.md` §20).
The user-facing word is "Save Point" and the internal one is `checkpoint`
(`BROWSER-UX-SPEC.md` §23). Error strings here are read by a player, so they say
Save Point.
"""
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import head, models, schemas
from ...context import lineage
from ...database import get_db
from . import turns
from .deps import current_adventure, router
from .paging import current_window
def _node_at(
db: Session, adventure: models.Adventure, branch_id: int, depth: int
) -> models.Action | None:
"""Returns the live turn a Save Point's coordinate names, or None.
The lookup is by coordinate and is not scoped to any path. That is the
point of it: a Save Point outlives the reader moving away, so the question
it has to answer is "is this position still in this campaign's retained
history", not "is it on the story being read now". Whether it is on the
current path is a separate question, and `head.move_to_node` is what acts on
the answer.
`live` is what makes the coordinate follow a retry. One coordinate can hold
several attempts at a turn, and a Save Point names the turn rather than the
attempt, so it lands on whichever take the story currently tells.
"""
return (
db.query(models.Action)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.branch_id == branch_id,
models.Action.depth == depth,
models.Action.live.is_(True),
)
.order_by(models.Action.id)
.first()
)
def _rendered(
db: Session, adventure: models.Adventure, checkpoint: models.Checkpoint
) -> schemas.CheckpointOut:
"""Reads one Save Point out with the three facts the panel needs about it."""
node = _node_at(db, adventure, checkpoint.branch_id, checkpoint.depth)
out = schemas.CheckpointOut.model_validate(checkpoint)
# The same `depth + 1` the branch list counts with, so "turn 42" means the
# same thing in both places.
out.turn = checkpoint.depth + 1
out.resolved = node is not None
out.on_path = node is not None and lineage.path_of(db, adventure).uncapped().contains(node)
return out
def _get_or_404(
db: Session, adventure: models.Adventure, checkpoint_id: int
) -> models.Checkpoint:
"""Resolves a Save Point id, refusing one that belongs to another campaign.
The ownership check is the reason this is a function rather than a `db.get`
at each call site. A Save Point names a position in one campaign's history,
and a coordinate from another campaign would name a different story's turn —
or, worse, resolve against this one by arithmetic coincidence. So the id is
matched against this adventure, and a Save Point belonging to another is a
404 rather than a restore of the wrong story.
"""
checkpoint = db.get(models.Checkpoint, checkpoint_id)
if checkpoint is None or checkpoint.adventure_id != adventure.id:
raise HTTPException(404, "Save Point not found")
return checkpoint
def _clean_name(raw: str) -> str:
"""Returns the trimmed name, refusing one that is blank once trimmed."""
name = (raw or "").strip()
if not name:
raise HTTPException(400, "A Save Point needs a name.")
return name
@router.get("/{adventure_id}/checkpoints", response_model=list[schemas.CheckpointOut])
def list_checkpoints(
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the campaign's Save Points, newest first.
Newest first rather than in story order, because story order is not
something this list can honestly claim. Depths are positions along a path,
and two Save Points on lines that parted company are not comparable by depth
at all — ordering by it would draw a sequence that no reading of the story
passes through. When they were made is a fact about all of them.
"""
rows = (
db.query(models.Checkpoint)
.filter(models.Checkpoint.adventure_id == adventure.id)
.order_by(models.Checkpoint.created_at.desc(), models.Checkpoint.id.desc())
.all()
)
return [_rendered(db, adventure, row) for row in rows]
@router.post(
"/{adventure_id}/checkpoints",
response_model=schemas.CheckpointOut,
status_code=201,
)
def create_checkpoint(
payload: schemas.CheckpointCreate,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Names the position the story is currently being read at.
The active head, not the retained tip. Creating a Save Point after two Undos
saves the undone position, because that is where the reader is and the
position they are looking at is the one they mean. The distinction only
exists at all because M3 stopped Undo from deleting.
The node at the head is resolved before the row is written, and its own
branch is what gets stored — which is not always the branch being read. A
head resting in a shared prefix sits on an ancestor's node, and the
ancestor is the branch that still names that position after the reader has
forked away from it.
"""
name = _clean_name(payload.name)
node = head.node_at(db, adventure, adventure.head_depth)
if node is None:
raise HTTPException(400, "There is no turn here to save yet.")
checkpoint = models.Checkpoint(
adventure_id=adventure.id,
name=name,
note=payload.note or "",
branch_id=node.branch_id,
depth=node.depth,
)
db.add(checkpoint)
db.commit()
db.refresh(checkpoint)
return _rendered(db, adventure, checkpoint)
@router.patch(
"/{adventure_id}/checkpoints/{checkpoint_id}",
response_model=schemas.CheckpointOut,
)
def rename_checkpoint(
checkpoint_id: int,
payload: schemas.CheckpointRename,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Changes a Save Point's label. Nothing else about it moves.
Not the coordinate, not the head, not a row of story. A Save Point that has
been renamed restores to exactly the position it did before, which is
`STORY-BRANCH-SEMANTICS.md` §23.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
if payload.name is not None:
checkpoint.name = _clean_name(payload.name)
if payload.note is not None:
checkpoint.note = payload.note
db.commit()
db.refresh(checkpoint)
return _rendered(db, adventure, checkpoint)
@router.delete("/{adventure_id}/checkpoints/{checkpoint_id}", status_code=204)
def delete_checkpoint(
checkpoint_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Removes the named pointer, and only the pointer.
The turn it named stays, its branch stays, the future past it stays, and the
head does not move. This endpoint deletes one row of the `checkpoints`
table. `STORY-BRANCH-SEMANTICS.md` §25.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
db.delete(checkpoint)
db.commit()
return None
@router.post(
"/{adventure_id}/checkpoints/{checkpoint_id}/restore",
response_model=schemas.ActionPage,
)
def restore_checkpoint(
adventure_id: int,
checkpoint_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns the story to a Save Point, deleting nothing.
Four steps, and the last one is not this module's code: resolve the
coordinate, refuse it if it no longer names a live turn, hand it to
`head.move_to_node`, and answer with the window the head now caps. The
transcript, the world state, the assembled context and which memories can be
retrieved all move together, because all four already read through the one
path object the head caps — the same reason Undo needed no memory pruning.
The turns past the restored position are retained, exactly as they are after
an Undo, and ordinary Redo can still walk forward into them until the user
writes something different. Restore does not fork; the first write below the
head does.
A coordinate that no longer resolves is refused rather than approximated.
Moving the head to the nearest surviving turn would be the one outcome worse
than doing nothing: a Save Point that silently means somewhere else.
"""
checkpoint = _get_or_404(db, adventure, checkpoint_id)
turns.acquire_turn_lock(adventure_id)
try:
node = _node_at(db, adventure, checkpoint.branch_id, checkpoint.depth)
if node is None:
raise HTTPException(
409,
"That Save Point's position is no longer part of this story.",
)
head.move_to_node(db, adventure, node)
adventure.updated_at = models.utcnow()
db.commit()
db.refresh(adventure)
# A window, not the whole story, for the reason Undo gives: the client
# replaces its transcript with this, and the transcript is a window.
return current_window(db, adventure)
finally:
turns._active_turns.discard(adventure_id)