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:
co-authored by
Claude Opus 5
parent
3c8e91f644
commit
e08d49c3eb
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user