Files
JesseMarkowitzandClaude Opus 5 62a997f364 M4: close out Save Points, with browser verification
Closes M4. The review's three findings are fixed, the durability rule the
specification always implied is now enforced, and M3's and M4's browser
behaviour has been verified in a real browser for the first time.

B-1 -- the Save Point list was an N+1 that loaded whole Action rows,
narration included, to answer "does a row exist here". It is now one bulk
two-column coordinate query plus one lineage: 53 SELECTs for 25 Save Points
became 5, and the count no longer grows with the list. The clause is an OR
of exact (branch, depth) pairs rather than two IN lists, because the cross
product would report a Save Point resolved on the strength of another one's
depth existing on this one's branch. A test builds exactly that trap.

B-2 -- reclassified during closeout from "missing warning" to a behaviour
defect, and fixed as one. STORY-BRANCH-SEMANTICS §19 says a named checkpoint
remains until explicitly deleted, and §28 already required future cleanup to
retain checkpoint-referenced paths; a cascade that silently removed Save
Points with a branch violated both, and a warning would only have documented
the violation. A branch a Save Point names can no longer be deleted. The
request is refused with the offending Save Points named, the user deletes
them explicitly -- which deletes no story -- and the branch then goes. The
scope is the subtree, because deleting a branch takes its descendants. Both
delete controls disable and explain. Recorded as a new §19.1; models.py,
TECHNICAL-DESIGN §8.8 and DATA-MODEL §8 had all recorded the cascade as the
rule and now record the refusal.

An earlier pass in this same closeout had kept the cascade and added a
warning. That was the wrong fix and its tests were replaced rather than left
standing, since they pinned the defect.

B-3 -- the D11/L03 automation never left one process, so it could not
distinguish durable state from a live Python object. It now spawns real
server processes, kills the first, and reads the campaign back with the
second.

C-5 -- creating a Save Point takes the campaign's turn lock. "Save where I
am" has to name one committed position, and the head is what a turn in
flight is about to move. Rename and Delete deliberately do not take it.

The architecture is untouched: a Save Point is still name + note +
(branch, depth), and restore is still coordinate -> head.move_to_node ->
head.move_to -> attempts.restore_state. No second restore path, no state
copied into a checkpoint, no fork on restore.

Browser verification -- the first in this project, and it covers both
milestones. Firefox 154.0.1 through geckodriver over the W3C WebDriver
protocol, driving the rendered DOM: 47/47 checks, twice, on independent
databases, no console errors. M3's Undo/Redo enable states, transcript
movement, Retry and the take pager, divergence retiring Redo; M4's whole
Save Point lifecycle, both confirmations, and the new branch-delete refusal
including its recovery. No dependency was added: the WebDriver client is
stdlib HTTP.

No application defect was found by the browser. Four failures occurred, all
in the harness -- a wrong SPA route, a wait comparing transcript length when
the empty-story placeholder is longer than the first turn, a fixture
deleting the branch it was reading, and a reload assertion that sampled
once instead of waiting. The last was checked against the app before being
called a harness bug.

Tests: 698 backend pass (was 680), 60 M4, 94 M3 history, 66 export/
migrations, 93 security/local-only. Frontend lint and build clean, Docker
build clean, loopback binding unchanged. No assertion weakened, no skip
added.

Planning: STORY-BRANCH-SEMANTICS §19.1 is the only behavioural change and it
strengthens §19. V1-ACCEPTANCE-TESTS records D11-D14, I04, L03 and the
E-series, keeping automated, live-runtime and browser evidence distinct, and
weakens no pass condition. DATA-MODEL records the coordinate with the retry
measurement that settles it. BROWSER-UX-SPEC rules for Moment over Turn.
BUILD-MILESTONES marks M4 COMPLETE, closes M3's browser condition, and lists
what M5 inherits. VERSION adds v2.6.

No new ADR: ADR 005 already decides that history is preserved rather than
overwritten, and §19.1 is that decision applied to checkpoint-referenced
history.

M4 is closed. M5 may now be briefed; it has not been started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWU4gTfLYY6Qq9U7aa9Qw2
2026-09-04 06:34:56 -04:00

352 lines
14 KiB
Python

"""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 dataclasses import dataclass
from fastapi import Depends, HTTPException
from sqlalchemy import and_, or_
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()
)
@dataclass(frozen=True)
class _Coordinate:
"""The shape `lineage.Path` reads, without loading a story row.
`Path.contains` asks three things of a node: its branch, its depth, and
whether it is live. A coordinate already known to resolve has all three, so
the membership question can be put to the coordinate itself. That keeps the
single implementation of "is this on the path being read" in `lineage`,
where M3 put it, while costing no query and no prose.
"""
branch_id: int
depth: int
live: bool = True
def _live_coordinates(
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
) -> set[tuple[int, int]]:
"""Returns which of these Save Points' coordinates still name a live turn.
One query for the whole list, selecting two integer columns.
This replaces a resolution per Save Point (M4 review §R B-1), which cost one
query each and loaded whole `Action` entities — narration included — to
answer a question that is only ever "does a row exist here". `paging.py`
states the rule this now follows: a bulk read names the columns it needs, so
a new column costs nothing until someone adds it to the list.
The clause is an OR of exact `(branch, depth)` pairs rather than
`branch IN (…) AND depth IN (…)`, which would match the cross product and
report a Save Point as resolved because *some other* Save Point's depth
exists on *this* one's branch.
"""
coordinates = {(c.branch_id, c.depth) for c in checkpoints}
if not coordinates:
return set()
rows = (
db.query(models.Action.branch_id, models.Action.depth)
.filter(
models.Action.adventure_id == adventure.id,
models.Action.live.is_(True),
or_(*[
and_(models.Action.branch_id == branch, models.Action.depth == depth)
for branch, depth in coordinates
]),
)
.all()
)
return {(branch, depth) for branch, depth in rows}
def _render_all(
db: Session, adventure: models.Adventure, checkpoints: list[models.Checkpoint]
) -> list[schemas.CheckpointOut]:
"""Reads Save Points out with the three facts the panel needs about them.
Bounded work whatever the length of the list: one query for the coordinates
and one lineage for the campaign, both computed before the loop. Rendering
one Save Point and rendering fifty differ in Python, not in round trips.
"""
live = _live_coordinates(db, adventure, checkpoints)
# The path is a property of the campaign, not of any Save Point, so it is
# read once. Reading it per row was the other half of the N+1.
path = lineage.path_of(db, adventure).uncapped()
out = []
for checkpoint in checkpoints:
coordinate = (checkpoint.branch_id, checkpoint.depth)
resolved = coordinate in live
rendered = schemas.CheckpointOut.model_validate(checkpoint)
# The same `depth + 1` the branch list counts with, so a moment number
# means the same thing in both places.
rendered.turn = checkpoint.depth + 1
rendered.resolved = resolved
rendered.on_path = resolved and path.contains(
_Coordinate(checkpoint.branch_id, checkpoint.depth)
)
out.append(rendered)
return out
def _rendered(
db: Session, adventure: models.Adventure, checkpoint: models.Checkpoint
) -> schemas.CheckpointOut:
"""Reads one Save Point out, through the same path the list uses."""
return _render_all(db, adventure, [checkpoint])[0]
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 _render_all(db, adventure, rows)
@router.post(
"/{adventure_id}/checkpoints",
response_model=schemas.CheckpointOut,
status_code=201,
)
def create_checkpoint(
adventure_id: int,
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.
**Held under the campaign's turn lock** (M4 closeout, review §S C-5). "Save
where I am" has to name one committed position, and the head is exactly what
a turn in flight is about to move. Without the lock this endpoint could read
`head_depth` while a turn was mid-commit and store a coordinate for a
position the story had already left — a Save Point silently naming the wrong
moment, which no later operation could detect. It is the same lock Undo,
Redo and Restore take, for the same reason, and not a new mechanism.
Rename and Delete deliberately do **not** take it: neither reads nor moves a
story position, so there is nothing for a turn in flight to race them over.
"""
name = _clean_name(payload.name)
turns.acquire_turn_lock(adventure_id)
try:
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)
finally:
turns._active_turns.discard(adventure_id)
@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)