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
+57 -1
View File
@@ -1,6 +1,6 @@
# Adventure Storyteller — Production Build Milestones
**Status:** In implementation. M1, M2 and M3 complete and accepted (M1 and M2: 2026-09-02; M3: 2026-09-03); M4 — Named Save Points / Checkpoints — next
**Status:** In implementation. M1, M2 and M3 complete and accepted (M1 and M2: 2026-09-02; M3: 2026-09-03); M4 — Named Save Points / Checkpoints — implemented 2026-09-03, awaiting review
**Base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de`
## 1. Purpose
@@ -351,6 +351,62 @@ M3's cost was reconciling them; a parallel checkpoint mover would recreate that
divergence in a place where the two paths would silently disagree about what
"restore" means. See ADR 012.
## Status: IMPLEMENTED — awaiting review
Implementation landed 2026-09-03. **Not accepted**: the milestone report has not
been written and no reviewer has read the change. The Definition of Done above is
met by the code and the tests below; whether it is met by the *product* is what
the review is for.
**What M4 delivered:**
- **`checkpoints`**, a table holding a name, an optional note, and a
`(branch, depth)` coordinate — and no copy of any story. `create_all` builds
it, as it did `memories` and `branches`; migration 80 adds the index. No
backfill: nobody had named a position before M4, and inventing one would be
inventing the decision.
- **Create / list / rename / delete / restore** under
`/api/adventures/{id}/checkpoints`, campaign-scoped, with a Save Point from
another campaign a 404 rather than a restore of the wrong story.
- **Create at the active head, not the retained tip**, so a Save Point made after
two Undos names the undone position.
- **Restore that delegates**, and is the whole of the milestone's architecture:
resolve the coordinate, refuse it if it names no live turn, then
`head.move_to_node` — one function whose depth half is M3's `head.move_to`
unchanged, and whose branch half is the single assignment `switch_branch`
makes. Restore forks nothing.
- **A browser Save Point panel** — create form, list, Restore, Rename, Delete,
with both confirmations saying what is *not* destroyed — plus a Save Point
button beside Undo and Redo, where it belongs. No branch explorer, no
discarded-history browser, no merge UI.
- **Export/import of Save Points** with no format version bump, and a pre-M4
bundle importing with none.
**The one architectural decision M4 had to make**, which ADR 012 does not settle:
a Save Point can name a position on a line the story has since left, so restore
moves the branch half of the head as well — but *only* when the coordinate is not
on the path being read. Doing it unconditionally would quietly hand back an
abandoned continuation whenever a Save Point in a shared prefix was restored. No
new ADR: this is ADR 012's mechanism applied to both halves of a coordinate ADR
012 already defines, not a new architecture. `TECHNICAL-DESIGN.md` §8.8 records
it.
**Tests:** 42 in `backend/tests/test_save_points.py`, covering D11-D14, I04, L03,
E-series lineage and memory isolation after restore and divergence, the edge
cases in the brief, and the M3-database migration.
**Outstanding condition, carried from M3 and not resolved here:** the **browser
smoke test has still not been performed**, for M3 or for M4. No session has had a
usable browser. The M4 sequence was driven end-to-end over HTTP against a live
server with a real process restart, and every server-side behaviour it covers
passes; the DOM-level behaviour of the Save Point panel, its buttons and its
confirmations remains unverified by observation.
**Debt M4 carries forward:** none newly discovered in the head model. The Save
Point panel has no frontend test, because the project still has no frontend test
runner at all (M8). `POST /adventures/import` still returns every branch's rows
rather than a head-capped window (inherited, M3).
---
# M5 — Genre-Neutral Authoritative Narrative State
+39 -1
View File
@@ -210,6 +210,40 @@ checkpoint:
A checkpoint is a named pointer to a recoverable story position. It should normally remain tied to the turn where it was created. Restoring it moves the campaign active head; it does not delete later retained history. A new branch is created on the first divergent write after restore, not merely because the checkpoint was opened.
As implemented in M4, the pointer is a **coordinate rather than a turn id**:
`(branch_id, depth)`, which is the same pair §4 records as the campaign's active
head and which `head.node_at` resolves. This is the equivalence §4 already draws
between a turn reference and a branch-plus-depth, applied to the same position
from the other end, and it is not a shortcut — it is the more correct pointer of
the two for this data model:
- **A coordinate follows a retry; a row id does not.** One coordinate holds
every attempt at a turn and exactly one of them is live (§7). A Save Point
names the turn, so it must land on whichever take the story currently tells.
Pinning the row would leave the pointer on a superseded attempt the reader
cannot see.
- **The user-facing term is Save Point**; `checkpoint` remains the internal name
(`BROWSER-UX-SPEC.md` §23).
The row carries the name, an optional note, the coordinate, and its timestamps.
It carries **no** copy of the transcript, the state, the prompt, a memory, a
summary, or a branch's contents. Everything a restore produces comes from the
retained history the coordinate points into.
Two consequences worth recording here:
- **Restore reuses the campaign's one head-movement mechanism.** It resolves the
coordinate and moves the head; nothing is reconstructed and nothing is
deleted. The branch half of the head moves only when the coordinate is not on
the path being read, which is what makes a Save Point on a departed line
restorable at all — and what keeps a Save Point in a shared prefix from
dragging the reader off the line they chose. See `TECHNICAL-DESIGN.md` §8.8.
- **A checkpoint is durable against everything but its own deletion and its
branch's.** No pass removes one for going stale, sitting behind the head, or
naming a line the story left (`STORY-BRANCH-SEMANTICS.md` §19). Deleting a
branch removes its checkpoints by cascade, as it removes its memories, because
the story they named is gone.
## 9. Narrative Entity
An entity is a persistent thing or concept in the fictional world.
@@ -684,7 +718,11 @@ The physical container format remains an implementation choice, but the export m
As implemented in M3, the export carries the active branch, the active head
position on it, and each branch's disposition, alongside the whole retained turn
graph. The governing rule for this package is that an export carries what was
graph. **M4 added the checkpoints**, by the same rule: a position someone chose
to name cannot be recomputed from the turns, because nothing about a turn records
that it was bookmarked. The head and the checkpoints stay independent on import —
a campaign opens where its head says, never at a checkpoint merely because one is
in the file. The governing rule for this package is that an export carries what was
*chosen* and recomputes what is *derived* — and the active head moved from the
second category to the first, because once Undo stops deleting, two campaigns
with identical turns can be being read at different positions and no import can
+16 -13
View File
@@ -4,8 +4,9 @@
**Current state:** Phase 0 complete; AI-DnD forked as the production base;
milestones **M1, M2 and M3 implemented and accepted** (M3: 2026-09-03).
**Next:** **M4 — named Save Points.** Its brief has not been written yet, and
writing it is the current action.
**M4 — named Save Points — is implemented (2026-09-03) and awaiting review.**
Its implementation report has not been written, and writing it is the current
action. Do not begin M5.
**Package version:** see `VERSION.md`, which records what each revision changed
and why.
@@ -132,7 +133,9 @@ that is the one the next milestone's planning has to consult:
Completed earlier milestones are in `archive/milestone-reports/`. When M4's
report lands, M3's moves there too: a milestone report is useful during the
immediate next milestone and historical afterwards.
immediate next milestone and historical afterwards. **M3's report has not moved
yet**, because M4's does not exist — the rotation belongs to M4's closeout, not
to its implementation.
## The decision this package rests on
@@ -234,8 +237,8 @@ Milestone M3 COMPLETE (2026-09-03)
active-head export and ADR 012
|
v
Milestone M4 NEXT — brief not yet prepared
named Save Points
Milestone M4 IMPLEMENTED 2026-09-03 —
named Save Points awaiting review; no report yet
|
v
M5-M11, one at a time see BUILD-MILESTONES.md
@@ -245,15 +248,15 @@ M5-M11, one at a time see BUILD-MILESTONES.md
**One milestone at a time. Do not begin a milestone before its brief exists.**
**No M4 brief has been prepared.** Writing one is the current action, informed
by the post-M3 corrections below, by the note `BUILD-MILESTONES.md` attaches to
M4, and by **ADR 012**, which records the head-movement mechanism M4 must reuse
rather than reimplement.
**M4 is implemented and unreviewed.** Its implementation report is the current
action; M5 does not begin before that report is written and accepted.
One M3 condition remains open and does not block M4: the required **browser
smoke test has not been performed**, because no session in which M3 was
implemented or reviewed had a browser available. See
`reports/M3-IMPLEMENTATION-REPORT.md` §M and §W.4.
Two conditions remain open. The **browser smoke test has still not been
performed** — now for M3 and for M4 — because no session so far has had a usable
browser. See `reports/M3-IMPLEMENTATION-REPORT.md` §M and §W.4, and the M4 status
block in `BUILD-MILESTONES.md`. And **no M4 review exists**: the status block was
written by the implementation and records what it built, which is not the same as
a reviewer having read it.
## What each milestone closeout corrected
+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
+36 -2
View File
@@ -1,8 +1,42 @@
# Planning Package Version
- **Package:** Adventure Storyteller Planning Package v2.4
- **Package:** Adventure Storyteller Planning Package v2.5
- **Revision date:** 2026-09-03
- **Status:** Phase 0 complete; architecture selected; **Milestones M1, M2 and M3 implemented and accepted**; M4 is next to brief.
- **Status:** Phase 0 complete; architecture selected; **Milestones M1, M2 and M3 implemented and accepted**; **M4 implemented 2026-09-03 and awaiting review.**
## v2.5 — M4 Implementation (2026-09-03)
M4 added durable named Save Points. This revision records only what the
implementation established as fact; **no product requirement changed**, and the
milestone is **not** marked accepted — its review has not been written.
- `TECHNICAL-DESIGN.md` gains **§8.8** and **§9.2**: the Save Point as a name
plus a coordinate holding no story, restore as head movement with a bounds
check, the rule that the branch half of the head moves only when the
coordinate is off the path being read, restore never forking, and the bundle
carrying Save Points independently of the head.
- `DATA-MODEL.md` **§8** records the pointer as implemented — `(branch, depth)`
rather than a turn id, with the reason: 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 superseded take. **§29** records the checkpoints in the export.
- `BUILD-MILESTONES.md` **M4** gains a status block: what shipped, the one
architectural decision the milestone had to make and why it needed no new ADR,
the test count, and the outstanding browser condition.
- `README.md` describes Save Points as a user-facing capability.
**No ADR was created.** ADR 012 already decides the architecture M4 needed —
restore reuses active-head movement — and a table is not a decision. The one
question ADR 012 does not answer, whether restore moves the branch half of the
head, is that same mechanism applied to a coordinate ADR 012 already defines;
`TECHNICAL-DESIGN.md` §8.8 records the answer rather than a new ADR asserting it.
`SPECIFICATION.md`, `SECURITY-THREAT-MODEL.md`, `STORY-BRANCH-SEMANTICS.md` and
`V1-ACCEPTANCE-TESTS.md` are unchanged. M4 altered no product requirement, added
no outbound path, and implemented the checkpoint semantics
`STORY-BRANCH-SEMANTICS.md` §18-25 already specified rather than amending them.
**The browser smoke test remains unperformed, now for both M3 and M4.** No
session has had a usable browser. See `BUILD-MILESTONES.md` M3 and M4.
## v2.4 — Documentation Consolidation (2026-09-03)