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
+5 -1
View File
@@ -209,7 +209,7 @@ visible from within.
## Tests
```bash
cd backend && .venv/bin/python -m pytest tests/ -q # 638 tests
cd backend && .venv/bin/python -m pytest tests/ -q # 680 tests
cd frontend && npm run lint && npm run build
```
@@ -225,6 +225,10 @@ suite as complete evidence.
is lost from the union, or if a new HTTP client is added without the shared
verification context.
M4 added `test_save_points.py`, which fails if restoring a Save Point starts
deleting history, stops going through the active head, forks on its own, or lets
a Save Point on one campaign be restored through another.
M2 added two more. `test_endpoint_policy.py` fails if the set of reachable
addresses widens, or if either place the rule is applied stops applying it —
it resolves hostnames through a stub, so it tests the policy rather than
+15 -4
View File
@@ -67,10 +67,20 @@ that isn't the live one starts a new branch.
displaced future stays on the line it was written for, and ordinary Redo stops offering it.
Nothing a retry replaces is discarded either — the old attempt stays as another take of that
turn, one keystroke and one click from becoming a branch of its own.
- **Save Points.** Name a moment — "Before entering the abbey" — keep playing,
restart the app, and come back to it. Restoring one moves the story back to
that moment and deletes nothing: the turns you wrote after it stay, Redo still
walks forward into them, and writing something different from the Save Point
is what starts a new line while the old one is kept. A Save Point is a name for
a position and holds no copy of the story, so restoring it is the same
movement Undo makes (`backend/app/routers/adventures/checkpoints.py`,
`backend/app/head.py`). They last until you delete them, and deleting one
deletes no story.
- **Import and export.** AI Dungeon-compatible scenario format; JSON for everything else. An adventure exports as `ai-dnd-adventure-v2`, which carries the whole tree:
every branch, every take, the fork points, which branches the story has left behind, and the
position it is being read at — all of them chosen rather than computed, which is the rule for
what a bundle carries. A campaign exported after two Undos imports still undone, with its
every branch, every take, the fork points, which branches the story has left behind, the Save
Points and the position it is being read at — all of them chosen rather than computed, which is
the rule for what a bundle carries. A campaign opens where its head says, never at a Save Point
merely because it has one. A campaign exported after two Undos imports still undone, with its
retained future intact, instead of silently reopening at its newest turn. Files that predate
the head position, and files saved in the old single-line format, still import.
- **Single user, no accounts.** There is no sign-up, no login, no session and no API key
@@ -196,6 +206,7 @@ frontend/ React + Vite SPA ──HTTP/SSE──► backend/ FastAPI
├─ tlstrust.py one TLS context: the OS trust store unioned with certifi's
├─ tree.py forking, promotion, and where a node is placed
├─ head.py the active head: where the story is read, and what moving it costs
├─ checkpoints Save Points: durable names for positions, in routers/adventures/
├─ attempts.py the takes of one turn, grouped by parent
├─ context/ prompt assembly under a token budget + lineage/history windowing
├─ worldstate/ the stat engine: clamps, cooldowns, bands, milestones
@@ -210,7 +221,7 @@ development, Vite proxies `/api` to FastAPI.
## Tests
638 backend tests: unit tests plus full HTTP integration through the real turn engine, with
680 backend tests: unit tests plus full HTTP integration through the real turn engine, with
the model provider mocked. They run with no route to the Internet, which is a requirement
rather than a convenience — an offline claim proved on a machine that has been online once
proves nothing.
+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:
+34
View File
@@ -314,6 +314,40 @@ def move_to(db: Session, adventure: models.Adventure, depth: int) -> None:
attempts.restore_state(adventure, node_at(db, adventure, depth))
def move_to_node(db: Session, adventure: models.Adventure, node: models.Action) -> bool:
"""Moves the head onto `node`, changing line only if it is not on this one.
M4 restores a Save Point through this, and it adds no restoring of its own:
the depth half is `move_to` unchanged, so the state, the transcript, the
assembled context and memory eligibility all arrive exactly as they do for
Undo and Redo. Returns whether the line had to change as well as the depth,
which is the one thing about a restore a caller cannot work out afterwards.
The head is two values, and the two halves move for different reasons. A
Save Point almost always names a position on the story being read — its own
line, or the shared prefix that line inherits — and then only the depth
moves. Leaving the branch alone is what makes the restored position keep the
continuation it has: after a divergence, restoring to the shared prefix must
put the reader back on the *new* line, where Redo walks into the turns they
are still writing, not into the future they left. Reaching for the Save
Point's own branch there would quietly hand back the abandoned story.
The other case is real and has to work. A Save Point survives divergence
(`STORY-BRANCH-SEMANTICS.md` §19), so one can name a position on a line the
story has since left, and no amount of depth movement reaches a branch this
path does not contain. The line then moves as well — one assignment, the
same one `switch_branch` makes — and the depth still moves through
`move_to`. Nothing is created: a restore never forks, whichever case it
takes. The first write below the restored head does, through
`fork_if_behind_head`, like every other write.
"""
switched = not lineage.path_of(db, adventure).uncapped().contains(node)
if switched:
adventure.head_branch_id = node.branch_id
move_to(db, adventure, node.depth)
return switched
def fork_if_behind_head(db: Session, adventure: models.Adventure) -> bool:
"""Gives the story a new branch when a write would displace a retained future.
+11
View File
@@ -350,6 +350,17 @@ MIGRATIONS: list[tuple[int, str | dict[str, str]]] = [
# No backfill.
(78, "ALTER TABLE branches ADD COLUMN superseded_at TIMESTAMP"),
(79, "ALTER TABLE branches ADD COLUMN superseded_depth INTEGER"),
# M4: Save Points. `create_all` creates the `checkpoints` table itself, on
# existing databases as well as fresh ones, exactly as it did for
# `memories` at version 2 and `branches` at version 46. What it does not
# create is the index every list and every cascade reads, so that is what
# this version is.
#
# No backfill. A Save Point records a decision someone made, and nobody has
# made one yet: an M3 database has no position a user chose to name, and
# inventing one would be inventing the decision.
(80, "CREATE INDEX IF NOT EXISTS ix_checkpoints_adventure "
"ON checkpoints (adventure_id)"),
]
LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1)
+54
View File
@@ -241,6 +241,60 @@ class Branch(Base):
superseded_depth: Mapped[int | None] = mapped_column(Integer, nullable=True)
class Checkpoint(Base):
"""M4: a Save Point — a durable named pointer to a story position.
"Save Point" is what the user reads; `checkpoint` is what the code calls it
(`BROWSER-UX-SPEC.md` §23).
The row holds a name and a coordinate, and no story. `DATA-MODEL.md` §8
describes the pointer as naming a turn; the coordinate here is
`(branch_id, depth)`, which is what M3 made the head and what
`head.node_at` resolves. Restoring one is therefore head movement with a
bounds check rather than a restore system of its own — see ADR 012 and
`head.move_to_node`.
A coordinate rather than an action id, deliberately. One coordinate can
hold several attempts at a turn and exactly one of them is live, so a
retry replaces the row a Save Point would have pinned. "Turn 42 of this
line" survives a retry; "action 918" would point at a take the story no
longer tells.
`branch_id` is the branch the node itself sits on, not the branch that was
being read when the Save Point was made. Those differ whenever the head is
resting in a shared prefix, and the node's own branch is the one that still
names the position after the reader has moved elsewhere.
Deleting a branch deletes its Save Points, by the same cascade that takes
its memories: the story the pointer names is gone with it. Nothing else
removes one. They are not cleaned up for going stale, for being behind the
head, or for pointing into a future the story has left
(`STORY-BRANCH-SEMANTICS.md` §19).
"""
__tablename__ = "checkpoints"
id: Mapped[int] = mapped_column(primary_key=True)
adventure_id: Mapped[int] = mapped_column(
ForeignKey("adventures.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(120), default="")
# `DATA-MODEL.md` §8's optional notes, and `BROWSER-UX-SPEC.md` §24's
# optional second field. Empty is the ordinary case.
note: Mapped[str] = mapped_column(Text, default="")
branch_id: Mapped[int] = mapped_column(
ForeignKey("branches.id", ondelete="CASCADE")
)
depth: Mapped[int] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)
# Bumped by a rename, which is the only edit a Save Point allows. The
# coordinate is never rewritten: `STORY-BRANCH-SEMANTICS.md` §24 keeps a
# Save Point's meaning auditable by making "move it" delete-and-recreate.
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=utcnow, onupdate=utcnow
)
class Memory(Base):
"""Phase 6: an auto-summarized (or hand-written) fact about the adventure.
@@ -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)
+66
View File
@@ -25,6 +25,10 @@ ICON_MAX = 16 # One emoji or glyph. VARCHAR(16).
BRANCH_NAME_MAX = 80 # What a player called one line of the story. VARCHAR(80).
PERSONA_NAME_MAX = 80 # The protagonist's name. VARCHAR(80).
PERSONA_PRONOUNS_MAX = 40 # "they/them" and the like. VARCHAR(40).
# M4: what a player called a Save Point. VARCHAR(120). Wider than a branch name
# because these are sentences rather than labels — "Before entering the abbey"
# is the example the specification uses throughout.
CHECKPOINT_NAME_MAX = 120
Name = Annotated[str, Field(max_length=NAME_MAX)]
Tags = Annotated[str, Field(max_length=TAGS_MAX)]
@@ -35,6 +39,7 @@ Image = Annotated[str, Field(max_length=IMAGE_MAX)]
Icon = Annotated[str, Field(max_length=ICON_MAX)]
PersonaName = Annotated[str, Field(max_length=PERSONA_NAME_MAX)]
PersonaPronouns = Annotated[str, Field(max_length=PERSONA_PRONOUNS_MAX)]
CheckpointName = Annotated[str, Field(max_length=CHECKPOINT_NAME_MAX)]
class ORMModel(BaseModel):
@@ -267,6 +272,67 @@ class BranchRename(BaseModel):
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
# ---------- Save Points (M4) ----------
#
# "Save Point" is the user-facing term and `checkpoint` is the internal one
# (`BROWSER-UX-SPEC.md` §23). The wire format uses the internal name, as the
# rest of this module does.
class CheckpointOut(ORMModel):
"""One Save Point: a name and the position it names.
The position is reported three ways because the panel needs three different
things from it. `turn` is what a reader counts — the same `depth + 1` the
branch list shows. `depth` and `branch_id` are the coordinate itself.
`on_path` says whether the position lies on the story being read, which is
how the panel can tell a Save Point on this line from one naming a line the
story has left; restoring either works, but they are not the same offer.
`resolved` is false when the coordinate no longer names a live turn, which
an action deleted out of the middle of a story can do. Restore refuses such
a Save Point rather than moving the head somewhere approximate, so the list
says so before the button is pressed.
"""
id: int
adventure_id: int
name: str
note: str = ""
branch_id: int
depth: int
turn: int = 0
on_path: bool = True
resolved: bool = True
created_at: datetime
updated_at: datetime
class CheckpointCreate(BaseModel):
"""A Save Point at wherever the story is being read.
The position is not a field. A Save Point is made at the campaign's active
head, which the server already knows, and accepting a coordinate from the
client would be the second way to name a position — the thing this milestone
exists not to build.
"""
name: CheckpointName
note: Prose = ""
class CheckpointRename(BaseModel):
"""A new label, and nothing else.
There is deliberately no coordinate here. `STORY-BRANCH-SEMANTICS.md` §24
keeps a Save Point's meaning auditable by refusing to move one: rename it,
or delete it and make another where you are.
"""
name: CheckpointName | None = None
note: Prose | None = None
class ActionUpdate(BaseModel):
text: ActionText
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -119,7 +119,12 @@ def pre_tree():
# Dropping the tables is the only way to remove the columns.
# SQLite refuses to drop a column that a foreign key references,
# and that is exactly the case for `branch_id`.
for table in ("actions", "memories", "branches", "adventures"):
#
# `checkpoints` (M4) is dropped first and for a different reason: it
# references both `branches` and `adventures`, and SQLite refuses to
# drop a table another table still points at. Any future table that
# references these four has to be added to the front of this list.
for table in ("checkpoints", "actions", "memories", "branches", "adventures"):
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
for ddl in PRE_TREE_DDL:
conn.execute(text(ddl))
@@ -592,7 +597,7 @@ def pre_split():
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
for table in ("actions", "memories", "branches", "adventures"):
for table in ("checkpoints", "actions", "memories", "branches", "adventures"):
conn.execute(text(f"DROP TABLE IF EXISTS {table}"))
for ddl in PRE_TREE_DDL:
conn.execute(text(ddl))
+19
View File
@@ -106,6 +106,25 @@ export const api = {
}),
deleteBranch: (advId, branchId) =>
request(`/adventures/${advId}/branches/${branchId}`, { method: 'DELETE' }),
// Save Points (M4). A Save Point is a durable name for a story position; the
// server stores the coordinate and nothing else. Create takes no position —
// it is always made at the campaign's active head, which is where the reader
// is. Restore answers with the story as it now stands, like a branch switch,
// so the caller replaces its window rather than reloading everything.
listCheckpoints: (advId) => request(`/adventures/${advId}/checkpoints`),
createCheckpoint: (advId, name, note = '') =>
request(`/adventures/${advId}/checkpoints`, {
method: 'POST', body: JSON.stringify({ name, note }),
}),
renameCheckpoint: (advId, checkpointId, name) =>
request(`/adventures/${advId}/checkpoints/${checkpointId}`, {
method: 'PATCH', body: JSON.stringify({ name }),
}),
deleteCheckpoint: (advId, checkpointId) =>
request(`/adventures/${advId}/checkpoints/${checkpointId}`, { method: 'DELETE' }),
restoreCheckpoint: (advId, checkpointId) =>
request(`/adventures/${advId}/checkpoints/${checkpointId}/restore`, { method: 'POST' }),
// Play a turn again, differently (SP9). An AI turn regenerates; a player's
// own takes the text given. Streams, because it is a turn like any other.
//
+26 -2
View File
@@ -22,6 +22,7 @@ import { BranchPanel } from './panels/BranchPanel'
import { InsightsPanel } from './panels/InsightsPanel'
import { MemoryPanel } from './panels/MemoryPanel'
import { PlotPanel } from './panels/PlotPanel'
import { SavePointPanel } from './panels/SavePointPanel'
const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story']
@@ -68,7 +69,7 @@ export default function Play() {
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState(null)
const [editing, setEditing] = useState(null)
const [panel, setPanel] = useState(null) // null | 'plot' | 'insights'
const [panel, setPanel] = useState(null) // null | 'plot' | 'memory' | 'branches' | 'savepoints' | 'insights'
// Bumped when something outside the turn loop changes the drawers' state
// (currently "Update from scenario"), which no action count would reflect.
const [stateKey, setStateKey] = useState(0)
@@ -545,6 +546,8 @@ export default function Play() {
onClick={() => setPanel(panel === 'memory' ? null : 'memory')}>Memory</button>
<button className={panel === 'branches' ? 'active' : ''}
onClick={() => setPanel(panel === 'branches' ? null : 'branches')}>Branches</button>
<button className={panel === 'savepoints' ? 'active' : ''}
onClick={() => setPanel(panel === 'savepoints' ? null : 'savepoints')}>Save Points</button>
<button className={panel === 'insights' ? 'active' : ''}
onClick={() => { setInspectActionId(null); setPanel(panel === 'insights' ? null : 'insights') }}>
Insights
@@ -714,6 +717,13 @@ export default function Play() {
from here: that future is retained, but it is no longer the
continuation this story tells. */}
<button onClick={redo} disabled={busy || !history.redo} title="Ctrl+Shift+Z">↷ Redo</button>
{/* Save Point sits with the history controls because that is what
it is: a name for a position Undo and Redo move between. The
button opens the panel, where the name is typed — the moment it
saves is wherever the story is being read, so there is nothing
to choose first. */}
<button onClick={() => setPanel('savepoints')} disabled={busy}
title="Name this moment so you can come back to it">⚑ Save Point</button>
</div>
<div className="input-bar">
<div className="mode-select">
@@ -752,7 +762,10 @@ export default function Play() {
{panel && (
<div className="side-panel">
<div className="side-panel-header">
<h2>{{ plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches', insights: 'Insights' }[panel]}</h2>
<h2>{{
plot: 'Plot Components', memory: 'Memory Bank', branches: 'Branches',
savepoints: 'Save Points', insights: 'Insights',
}[panel]}</h2>
<button onClick={() => setPanel(null)}>✕</button>
</div>
{panel === 'plot' ? (
@@ -764,6 +777,17 @@ export default function Play() {
// but deleting a branch deletes the memories that hung off it,
// and that happens without a turn being played.
refreshKey={`${actions.length}:${stateKey}`} />
) : panel === 'savepoints' ? (
// Restoring one moves the story exactly as Undo and Redo do, so it
// adopts the returned window the same way a branch switch does —
// the state panels are showing another position's numbers until
// they re-read.
<SavePointPanel
advId={id}
refreshKey={`${actions.length}:${stateKey}`}
onRestored={adoptWindow}
onError={(message) => setToast({ text: message, isError: true })}
/>
) : panel === 'branches' ? (
<BranchPanel
advId={id}
@@ -0,0 +1,209 @@
// Save Points: naming a place in the story, and going back to one.
//
// "Save Point" is the word throughout, and the words branch, head, node and
// fork appear nowhere a player can read (`BROWSER-UX-SPEC.md` §23). The panel
// is deliberately small — a form, a list, and two confirmations. It is not a
// branch explorer, and the tree it sits over stays out of sight.
//
// Both confirmations exist to say what does *not* happen, because that is the
// part a player cannot see and would otherwise assume the worst about: restore
// keeps the later story, and deleting a Save Point deletes no story at all.
import { useEffect, useState } from 'react'
import { api } from '../../../api'
function SavePointPanel({ advId, refreshKey, onRestored, onError }) {
const [points, setPoints] = useState(null)
const [failed, setFailed] = useState(null)
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [saving, setSaving] = useState(false)
const [busyId, setBusyId] = useState(null)
const [renaming, setRenaming] = useState(null) // { id, text }
const [confirming, setConfirming] = useState(null) // { id, kind }
const [tick, setTick] = useState(0)
useEffect(() => {
let cancelled = false
setFailed(null)
api.listCheckpoints(advId)
.then((list) => { if (!cancelled) setPoints(list) })
.catch((err) => { if (!cancelled) setFailed(err.message) })
return () => { cancelled = true }
}, [advId, refreshKey, tick])
// Answers whether it worked, so a caller can keep its editor open on a
// refusal — a rename the server turned down must not take the typed name
// with it.
async function run(id, work) {
setBusyId(id)
try {
await work()
setTick((t) => t + 1)
return true
} catch (err) {
onError(err.message)
return false
} finally {
setBusyId(null)
}
}
async function create(e) {
e.preventDefault()
if (!name.trim() || saving) return
setSaving(true)
try {
await api.createCheckpoint(advId, name.trim(), note.trim())
setName('')
setNote('')
setTick((t) => t + 1)
} catch (err) {
onError(err.message)
} finally {
setSaving(false)
}
}
const restore = (p) => run(p.id, async () => {
onRestored(await api.restoreCheckpoint(advId, p.id))
setConfirming(null)
})
const rename = async (p) => {
if (await run(p.id, () => api.renameCheckpoint(advId, p.id, renaming.text))) {
setRenaming(null)
}
}
const remove = async (p) => {
if (await run(p.id, () => api.deleteCheckpoint(advId, p.id))) setConfirming(null)
}
return (
<div className="save-point-panel">
<form className="save-point-new" onSubmit={create}>
<label htmlFor="save-point-name">Save this moment</label>
<input
id="save-point-name"
autoFocus
maxLength={120}
placeholder="Before entering the abbey"
value={name}
disabled={saving}
onChange={(e) => setName(e.target.value)}
/>
<input
className="save-point-note"
maxLength={500}
placeholder="A note, if you want one (optional)"
value={note}
disabled={saving}
onChange={(e) => setNote(e.target.value)}
/>
<button type="submit" className="primary" disabled={saving || !name.trim()}>
Save Point
</button>
<p className="save-point-hint">
Saves the moment you are reading now. If you have stepped back, that
is the moment it saves.
</p>
</form>
{failed && <div className="panel-empty">Couldn’t read the Save Points — {failed}</div>}
{!failed && !points && <div className="panel-empty">Reading your Save Points…</div>}
{!failed && points?.length === 0 && (
<p className="save-point-intro">
No Save Points yet. Name a moment you might want to come back to, then
keep playing — going back to it later leaves everything you wrote
after it in place.
</p>
)}
<div className="save-point-list">
{(points || []).map((p) => {
const isRenaming = renaming?.id === p.id
const busy = busyId === p.id
const confirm = confirming?.id === p.id ? confirming.kind : null
return (
<div key={p.id} className={`save-point-row ${p.on_path ? '' : 'elsewhere'}`}>
<div className="save-point-head">
{isRenaming ? (
<input
className="save-point-rename"
autoFocus
maxLength={120}
value={renaming.text}
onChange={(e) => setRenaming({ ...renaming, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') rename(p)
if (e.key === 'Escape') setRenaming(null)
}}
/>
) : (
<span className="save-point-name">{p.name}</span>
)}
</div>
<div className="save-point-meta">
Moment {p.turn}
{/* Said plainly, without naming a branch: the story took a
different turning after this point, and going back to it
returns to the telling it was saved in. */}
{!p.on_path && p.resolved && ' · on a path you left'}
{!p.resolved && ' · this moment is no longer in the story'}
</div>
{p.note && <div className="save-point-note-text">{p.note}</div>}
{confirm === 'restore' ? (
<div className="save-point-confirm">
<span>
The story will return to this Save Point. Everything you
wrote after it is kept — it just stops being where you are.
</span>
<button type="button" className="primary" disabled={busy}
onClick={() => restore(p)}>Restore</button>
<button type="button" onClick={() => setConfirming(null)}>Cancel</button>
</div>
) : confirm === 'delete' ? (
<div className="save-point-confirm">
<span>
Delete this Save Point? Deleting it does not delete any of
the story — only the name you gave this moment.
</span>
<button type="button" className="danger" disabled={busy}
onClick={() => remove(p)}>Delete</button>
<button type="button" onClick={() => setConfirming(null)}>Keep</button>
</div>
) : (
<div className="save-point-tools">
<button type="button" disabled={busy || !p.resolved}
title={p.resolved ? undefined
: 'The moment this Save Point named is no longer in the story.'}
onClick={() => setConfirming({ id: p.id, kind: 'restore' })}>
Restore
</button>
{isRenaming ? (
<>
<button type="button" disabled={busy} onClick={() => rename(p)}>Save</button>
<button type="button" onClick={() => setRenaming(null)}>Cancel</button>
</>
) : (
<button type="button" disabled={busy}
onClick={() => setRenaming({ id: p.id, text: p.name })}>
Rename
</button>
)}
<button type="button" className="danger" disabled={busy}
onClick={() => setConfirming({ id: p.id, kind: 'delete' })}>
Delete
</button>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
export { SavePointPanel }
+96
View File
@@ -255,6 +255,102 @@
flex-basis: 100%;
}
/* Save Points (M4). Deliberately the same furniture as the branch panel: a Save
Point is a name for a position, and it should not look like a different
species of thing from the lines it names positions on. The confirmations
borrow the branch panel's shape but not its danger colouring — restoring
destroys nothing, and only deletion is red. */
.save-point-panel { display: flex; flex-direction: column; gap: 14px; }
.save-point-new { display: flex; flex-direction: column; gap: 7px; }
.save-point-new label {
font-size: 0.64rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-dim);
}
.save-point-new input {
padding: 5px 9px;
font-family: var(--font-story);
font-size: 0.95rem;
}
.save-point-new input.save-point-note {
font-size: 0.82rem;
}
.save-point-hint, .save-point-intro {
margin: 0;
color: var(--text-dim);
font-size: 0.78rem;
line-height: 1.55;
}
.save-point-list { display: flex; flex-direction: column; gap: 8px; }
.save-point-row {
display: flex;
flex-direction: column;
gap: 5px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-input);
}
/* A Save Point naming a moment on a telling the story has left. Dimmed rather
than hidden: it still restores, and hiding it would be the automatic cleanup
this milestone deliberately does not do. */
.save-point-row.elsewhere { opacity: 0.72; }
.save-point-head { display: flex; align-items: center; gap: 7px; }
.save-point-name {
font-family: var(--font-story);
font-size: 0.98rem;
color: var(--text);
}
.save-point-rename {
flex: 1;
min-width: 0;
padding: 3px 7px;
font-family: var(--font-story);
font-size: 0.95rem;
}
.save-point-meta {
font-size: 0.72rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.save-point-note-text {
font-size: 0.78rem;
color: var(--text-dim);
line-height: 1.5;
}
.save-point-tools, .save-point-confirm {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
.save-point-tools button, .save-point-confirm button {
padding: 3px 9px;
font-size: 0.72rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
}
.save-point-tools button:hover:not(:disabled),
.save-point-confirm button:hover:not(:disabled) {
color: var(--accent-bright);
border-color: var(--border-bright);
}
.save-point-tools button.danger:hover:not(:disabled),
.save-point-confirm button.danger:hover:not(:disabled) {
color: var(--danger);
border-color: var(--danger);
}
.save-point-tools button:disabled,
.save-point-confirm button:disabled { opacity: 0.4; cursor: default; }
.save-point-confirm span {
font-size: 0.74rem;
color: var(--text-dim);
line-height: 1.5;
flex-basis: 100%;
}
.action-edit { margin-bottom: 14px; }
/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
several paragraphs, and the old fixed 110px turned that into a keyhole.
+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)