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
+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}