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
+124
View File
@@ -134,6 +134,16 @@ def export(db: Session, adventure: models.Adventure) -> dict:
"memoryCursor": _exported_anchor(adventure, cursors.MEMORY, local),
"summaryCursor": _exported_anchor(adventure, cursors.SUMMARY, local),
"memories": [_exported_memory(m, local) for m in adventure.memories],
# M4. A Save Point is a decision — someone chose this position and gave
# it a name — so it goes in the file by the rule at the top of this
# module. Nothing here is derived: the coordinate is the one stored, not
# one recomputed from the rows, because the whole point of the pointer
# is that no amount of reading the turns can tell you which one somebody
# named. A bundle written before M4 has no key here and imports with no
# Save Points, which is what such a campaign had.
"checkpoints": [
_exported_checkpoint(c, local) for c in _checkpoints_of(db, adventure)
],
"storyCards": [
{"type": c.type, "name": c.name, "keys": c.keys,
"entry": c.entry, "notes": c.notes}
@@ -215,6 +225,35 @@ def _exported_memory(memory: models.Memory, local: dict[int, int]) -> dict:
}
def _checkpoints_of(db: Session, adventure: models.Adventure) -> list[models.Checkpoint]:
"""Returns the campaign's Save Points in creation order.
Read with a query rather than through a relationship, for the reason
`models.Branch` declares none: a relationship on `Adventure` would be loaded
by anything that touches an adventure, and the export is the only thing in
the application that wants every Save Point at once.
"""
return (
db.query(models.Checkpoint)
.filter(models.Checkpoint.adventure_id == adventure.id)
.order_by(models.Checkpoint.id)
.all()
)
def _exported_checkpoint(checkpoint: models.Checkpoint, local: dict[int, int]) -> dict:
return {
"name": checkpoint.name,
"note": checkpoint.note,
# The branch as a position in this file's list, like every other branch
# reference in the bundle. The depth is a coordinate along it and needs
# no translation.
"branch": _local(checkpoint.branch_id, local),
"depth": checkpoint.depth,
"createdAt": checkpoint.created_at.isoformat() if checkpoint.created_at else None,
}
def _imported_persona(persona) -> dict:
"""Reads a bundle's `persona` block into `Adventure` keyword arguments.
@@ -280,6 +319,13 @@ def plan(bundle: dict, version: str) -> dict:
"branches": branches,
"nodes": nodes,
"memories": _planned_memories(bundle, len(branches)),
# M4. Empty for a version 1 bundle and for any version 2 bundle written
# before Save Points existed, which is the same answer: no one had named
# a position in those campaigns.
"checkpoints": (
_planned_checkpoints(bundle, len(branches), nodes)
if version == FORMAT else []
),
"head": head,
# None means the file does not say, which is every version 1 bundle and
# every version 2 bundle written before M3. `_point_the_head` derives it
@@ -525,6 +571,57 @@ def _planned_memories(bundle: dict, branches: int) -> list[dict]:
return out
def _planned_checkpoints(
bundle: dict, branches: int, nodes: list[dict]
) -> list[dict]:
"""Returns the file's Save Points, checked against the tree it also carries.
A Save Point whose coordinate names no turn in the file is dropped rather
than imported, and dropped rather than raising. The two halves of that are
each deliberate:
* Dropped, because an imported pointer to a position the imported story does
not contain is a Save Point that can only ever refuse to restore. It would
be a row that exists to disappoint.
* Not a 400, unlike the head depth. The head is a position the campaign is
read at, so a file that misplaces it opens the story in the wrong place
and every read is affected. A Save Point is a bookmark, and a bad one
spoils nothing else in the file — refusing to import a whole campaign
because one bookmark is wrong would lose the story to save the bookmark.
A name that is blank once trimmed is dropped for the same reason the create
endpoint refuses one: an unnamed Save Point is not identifiable in a list.
"""
# Every coordinate the file writes, not only the ones it marks live.
# `_write_nodes` makes exactly one attempt at each coordinate live whatever
# the file says, so a coordinate that exists is a coordinate that will
# resolve — and reading the flags here would drop a Save Point over a
# question the writer has already settled.
written = {(n["branch"], n["depth"]) for n in nodes}
raw = bundle.get("checkpoints")
out: list[dict] = []
for entry in raw if isinstance(raw, list) else []:
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()[:schemas.CHECKPOINT_NAME_MAX]
if not name:
continue
depth = entry.get("depth")
if not _is_int(depth):
continue
branch = _as_index(entry.get("branch"), branches, default=None)
if branch is None or (branch, depth) not in written:
continue
out.append({
"name": name,
"note": str(entry.get("note") or ""),
"branch": branch,
"depth": depth,
"createdAt": _as_time(entry.get("createdAt")),
})
return out
def _planned_anchors(bundle: dict, branches: int) -> dict:
anchors = {}
for name in ("memory", "summary"):
@@ -550,6 +647,7 @@ def write(db: Session, adventure: models.Adventure, story: dict) -> None:
_write_nodes(db, adventure, story["nodes"], ids)
_write_memories(db, adventure, story["memories"], ids)
_point_the_head(adventure, story, ids)
_write_checkpoints(db, adventure, story["checkpoints"], ids)
_write_anchors(adventure, story, ids)
@@ -665,6 +763,32 @@ def _write_memories(
db.add(memory)
def _write_checkpoints(
db: Session, adventure: models.Adventure, specs: list[dict], ids: list[int]
) -> None:
"""Writes the Save Points, and moves nothing.
Note what this function does not touch. The head is pointed by
`_point_the_head` from the file's own `headBranch`/`headDepth`, and importing
a Save Point must not disturb it — a campaign exported at turn 30 with a
Save Point at turn 12 opens at turn 30. The bundle records where the story
was being read and, separately, which positions someone named; restoring one
of them is a thing the user does afterwards, not a thing an import does for
them.
"""
for spec in specs:
checkpoint = models.Checkpoint(
adventure_id=adventure.id,
name=spec["name"],
note=spec["note"],
branch_id=ids[spec["branch"]],
depth=spec["depth"],
)
if spec["createdAt"] is not None:
checkpoint.created_at = spec["createdAt"]
db.add(checkpoint)
def _point_the_head(
adventure: models.Adventure, story: dict, ids: list[int]
) -> None: