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