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
This commit is contained in:
JesseMarkowitz
2026-09-04 06:34:56 -04:00
co-authored by Claude Opus 5
parent 279a871a77
commit 62a997f364
23 changed files with 1818 additions and 131 deletions
+7 -1
View File
@@ -358,10 +358,16 @@ Each entry:
```text
Before entering the abbey
Turn 42
Moment 42
[Restore] [Rename] [Delete]
```
**Moment, not Turn.** M4 closeout ruled in favour of the implemented product:
the branch panel and the tree overlay already count in moments (`forked at
moment 9`, `ends at moment 40`), so a Save Point list saying "Turn 42" would
make one screen use two words for one thing. This is a vocabulary alignment and
changes no behaviour; the number is unchanged.
## 26. Restore Confirmation
Restoring is non-destructive.
+87 -24
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 — implemented 2026-09-03, awaiting review
**Status:** In implementation. M1-M4 complete and accepted (M1 and M2: 2026-09-02; M3 and M4: 2026-09-03); M5 — Genre-Neutral Authoritative Narrative State — next to brief
**Base:** AI-DnD `d72f7c1bda0f34fccd84afb7a25c34eb01c901de`
## 1. Purpose
@@ -274,14 +274,18 @@ review. The architecture is recorded in **ADR 012**.
a turn says while story descends from it off screen — both ratified in
`STORY-BRANCH-SEMANTICS.md` (§5, §10, §14A).
**Outstanding closeout condition:** the required **browser smoke test has not
been performed** — no session in which M3 was implemented or reviewed had a
browser available. The equivalent sequence was driven end-to-end against the
running application with real inference and a process restart, and every
server-side behaviour it covers passes; the DOM-level behaviour of the Redo
button, its disabled states and its keyboard shortcut remain unverified by
observation. This does not block M4, which touches none of that wiring, but it
remains an open M3 item until a human runs it.
**Closeout condition — CLOSED at M4 closeout (2026-09-03).** M3 was accepted with
one condition outstanding: the required **browser smoke test had not been
performed**, because no session in which M3 was implemented or reviewed had a
browser available, leaving the DOM-level behaviour of the Redo button and its
disabled states unverified by observation.
That condition is now discharged. A real Firefox 154.0.1, driven through
geckodriver, exercised M3's controls in the rendered application: Undo enabled
and Redo disabled at the tip, two Undos moving the transcript back, Redo becoming
enabled and returning the original tip exactly, Retry and the take pager, and a
divergent write retiring Redo with no stale old-future text on screen. It passed.
Evidence: `planning/reports/M4-IMPLEMENTATION-REPORT.md` §W.7.
**Debt carried forward, none of it blocking M4:** full narrator-edit state
re-evaluation is deferred to M5 (`STORY-BRANCH-SEMANTICS.md` §14A records the
@@ -351,12 +355,11 @@ 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
## Status: COMPLETE
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.
Accepted 2026-09-03. Evidence: `planning/reports/M4-IMPLEMENTATION-REPORT.md`,
including its §W closeout addendum. The Definition of Done is met, and — for the
first time in this project — **verified in a real browser**.
**What M4 delivered:**
@@ -395,17 +398,77 @@ it.
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.
**Facts M5 inherits, and must not redesign:**
**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).
- a Save Point is a **durable story coordinate** — `(branch, depth)` — carrying
no copy of transcript, state, prompt, memory or summary;
- **restore is M3 head movement**, through the same `head.move_to` Undo and Redo
use; there is no second restore path and M5 must not add one;
- **state recovery stays snapshot/cached-position based**, never a replay of the
campaign (`TECHNICAL-DESIGN.md` §10.4). This is now load-bearing for Save
Points as well as Undo/Redo;
- **the first divergent write** after a restore creates the continuation;
restore itself never forks;
- **history a Save Point names cannot disappear** through an unrelated deletion
(§19.1);
- **M3/M4 history and Save Point semantics are infrastructure now.** M5 replaces
the state *model*; it does not revisit how the story is positioned.
**Acceptance evidence:** D11-D14, I04 and L03 all pass. D11 and L03 are
discharged by automation that crosses a **genuine OS process boundary** — one
server process writes the campaign, is killed, and a second process reads it back
— rather than by recreating a client in one process.
**The browser condition is closed, for M4 and retrospectively for M3.** A real
Firefox 154.0.1, driven through geckodriver over the W3C WebDriver protocol,
exercised the rendered DOM end to end: **44/44 checks passed**, covering M3's
Undo/Redo enable states and transcript movement, Retry and the take pager, M3
divergence and the disappearance of Redo, and every M4 Save Point operation
including both confirmations and the branch-delete warning. No console errors.
This closes the outstanding M3 condition recorded above and the equivalent M4
one.
**The three review findings were fixed during closeout:**
- **B-1** — the Save Point list was an N+1 that loaded whole `Action` rows,
narration included. It is now one bulk two-column coordinate query plus one
lineage: **53 SELECTs for 25 Save Points became 5**, and the query count no
longer moves with the length of the list.
- **B-2** — deleting a branch silently deleted the Save Points naming it. Fixed
as a **behaviour** defect, not a wording one: a branch a Save Point names can
no longer be deleted at all. The request is refused with the offending Save
Points named, the user deletes them explicitly (which deletes no story), and
the branch then goes. Both delete controls, in the branch list and in the tree
overlay, disable and explain rather than warning about a loss that no longer
happens. `STORY-BRANCH-SEMANTICS.md` **§19.1** records the rule; §28 already
required a future cleanup feature to retain checkpoint-referenced paths, and
this is that requirement applied to the deletion path that exists today.
- **B-3** — the D11/L03 automation now spawns real server processes.
**Also fixed:** creating a Save Point takes the campaign's turn lock (review §S
C-5), so "save where I am" cannot read a head that a turn in flight is about to
move. Rename and Delete deliberately do not take it — neither reads nor moves a
story position.
**Debt M4 carries forward:** the Save Point panel has no frontend test, because
the project still has no frontend test runner at all (M8) — the browser smoke
test above is a closeout procedure, not a suite. `POST /adventures/import` still
returns every branch's rows rather than a head-capped window (inherited, M3).
## Note to M5 — the instrumentation is now larger than M3 estimated
M4 added **60 tests that use inherited RPG world-state values as deterministic
instrumentation**, on top of the ~20 M3 flagged. M5 replaces that state model,
and must **move the instrumentation while preserving the behavioural
assertions**: what those tests measure is where the story is being read and what
state belongs to that position, which is exactly as true after M5 as before it.
Deleting them would delete the evidence for D11-D14, I04, L03 and the E-series.
The existing constraint stands and is now load-bearing for Save Points as well as
Undo/Redo: **historical state must remain efficiently snapshot/cache
recoverable**, so that moving to a position never becomes proportional to
campaign length (`TECHNICAL-DESIGN.md` §10.4). Restore, Undo and Redo all pay
whatever that costs.
---
+31 -5
View File
@@ -230,6 +230,30 @@ 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.
The retry case is what settles the coordinate-versus-turn-id question, and M4
closeout measured it rather than arguing it. A Save Point named a turn whose
live row was id 17; retrying that turn made id 17 dead and id 18 live at the
same coordinate; the Save Point resolved to id 18 and restored correctly. A row
id would have pinned a take the story no longer tells. **A Save Point names a
story position, not a particular take of it.**
Three further properties of the implemented model, recorded so they are decided
rather than incidental:
- **Names are not unique**, and nothing requires them to be. No product
requirement asks for uniqueness, and two names for one moment is a reasonable
thing for a player to want.
- **Several Save Points may name the same position.** Same reason.
- **Ordinary list presentation is newest-created first.** Story order is not
something the list can honestly claim: depths on lines that have parted
company are not comparable, so ordering by depth would draw a sequence that no
reading of the story passes through. When each was made is a fact about all of
them.
None of this is genre-specific. A coordinate is a position in a story; what the
state at that position *contains* is M5's question, and changing it does not
change what a Save Point is.
Two consequences worth recording here:
- **Restore reuses the campaign's one head-movement mechanism.** It resolves the
@@ -238,11 +262,13 @@ Two consequences worth recording here:
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.
- **A checkpoint is durable against everything but its own explicit deletion.**
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* does not
remove one either: the deletion is refused while a checkpoint names any
position in the subtree, and the user deletes the checkpoint first
(§19.1). Deleting the whole campaign removes them, which is what deleting a
campaign means.
## 9. Narrative Entity
+6 -6
View File
@@ -85,13 +85,13 @@ One file, and it changes as development progresses:
planning/reports/M4-IMPLEMENTATION-REPORT.md
```
M3 is the most recently completed milestone, and M4 is the next to be briefed.
This report is M3's review *and* its primary evidence record — no separate M3
baseline report was produced — so it is the only place some of what M3 left
behind is written down, including the browser smoke test M3 still owes.
M4 is the most recently completed milestone, and M5 is the next to be briefed.
This report is M4's review *and* its closeout record: its §W holds the corrective
work, the process-boundary automation, and the real-browser verification that
closed the condition M3 and M4 both carried.
**Replace it, do not accumulate.** When M4's report lands, remove this one from
the project Sources and upload M4's instead. The repository does the same thing:
**Replace it, do not accumulate.** When M5's report lands, remove this one from
the project Sources and upload M5's instead. The repository does the same thing:
`planning/reports/` holds the current milestone's report and
`planning/archive/milestone-reports/` holds the rest.
+17 -16
View File
@@ -3,10 +3,10 @@
**This file is the index. Start here.**
**Current state:** Phase 0 complete; AI-DnD forked as the production base;
milestones **M1, M2 and M3 implemented and accepted** (M3: 2026-09-03).
**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.
milestones **M1, M2, M3 and M4 implemented and accepted** (M3 and M4:
2026-09-03).
**Next:** **M5 — Genre-Neutral Authoritative Narrative State.** Its brief has not
been written yet, and writing it is the current action.
**Package version:** see `VERSION.md`, which records what each revision changed
and why.
@@ -234,9 +234,9 @@ Milestone M3 COMPLETE (2026-09-03)
active-head export and ADR 012
|
v
Milestone M4 IMPLEMENTED 2026-09-03 —
named Save Points awaiting review; no report yet
|
Milestone M4 COMPLETE (2026-09-03)
named Save Points reports/M4-IMPLEMENTATION-REPORT.md
| browser verification: PASS (M3 + M4)
v
M5-M11, one at a time see BUILD-MILESTONES.md
```
@@ -245,16 +245,17 @@ M5-M11, one at a time see BUILD-MILESTONES.md
**One milestone at a time. Do not begin a milestone before its brief exists.**
**M4 is implemented and unreviewed.** Its implementation report is the current
action; M5 does not begin before that report is written and accepted.
**No M5 brief has been prepared.** Writing one is the current action, informed by
the M4 report's §U readiness assessment and by the note `BUILD-MILESTONES.md`
attaches to M5 — in particular that M4 added 55 tests using the inherited
world-state values as deterministic instrumentation, which M5 must **move rather
than delete**.
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 `archive/milestone-reports/M3-IMPLEMENTATION-REPORT.md` §M and §W.4,
`reports/M4-IMPLEMENTATION-REPORT.md` §M, 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.
**No conditions remain open on M1-M4.** The browser smoke condition that M3 and
M4 both carried was satisfied at M4 closeout: a real Firefox exercised the
rendered DOM for both milestones' controls, 44/44 checks passing. The M3 report's
§M.2 and the M4 report's §M record the condition as it stood; the M4 report's §W
records it closed.
## What each milestone closeout corrected
+33
View File
@@ -467,6 +467,39 @@ They should not be removed by:
- branch divergence,
- ordinary history cleanup.
### 19.1 A checkpoint protects the history it names
Added at M4 closeout, from implementation evidence.
§19's durability rule is only meaningful if something enforces it against the
operations that delete history. Deleting a branch deletes that branch and
everything forked from it, so a checkpoint naming a position anywhere in that
subtree would go with it — and go silently, because the story is what the user
asked to delete and the named moments are not mentioned in the request.
Therefore:
- **A branch cannot be deleted while a checkpoint names a position on it, or on
any branch forked from it.** The deletion is refused, not amended: nothing is
half-deleted, and no checkpoint is quietly relocated or dropped.
- **The refusal names the checkpoints standing in the way**, so the user knows
what to act on rather than being told only that something is in the way.
- **The user resolves it by deleting the checkpoint explicitly**, which is
§19's "until explicitly deleted" being honoured rather than worked around.
Deleting a checkpoint still deletes no story (§25), so the recovery costs the
user nothing they wanted to keep.
- **The scope is the subtree**, not the named branch alone.
This is the same rule §28 already anticipates for a future cleanup feature —
*retain paths referenced by checkpoints* — applied to the one deletion path that
exists today. A cleanup or discarded-history feature added later must honour it
too, and must not acquire a way to delete a checkpoint as a side effect of
deleting history.
Campaign deletion is not an exception to this and needs no rule: deleting a
campaign deletes everything in it, checkpoints included, and that is what the
user asked for.
## 20. Checkpoint Restore
Restoring a checkpoint:
+11 -3
View File
@@ -452,9 +452,17 @@ 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.
is doing its job.
That rule is enforced against the one operation that could break it. Deleting a
branch deletes everything forked from it, so a Save Point naming a position in
that subtree would go too — silently, since the story is what the user asked to
delete. **The deletion is therefore refused while any Save Point names that
subtree**, and the refusal names them. The user deletes the Save Point
explicitly, which deletes no story, and then the branch. `STORY-BRANCH-SEMANTICS.md`
§19.1 states the rule; §28 already required a future cleanup feature to retain
paths a checkpoint references, and this is that requirement applied to the
deletion path that exists today.
## 9. Export / Import and Head Position
+81 -3
View File
@@ -1,8 +1,28 @@
# Adventure Storyteller — V1 Acceptance Tests
**Status:** v1.2 planning/release contract — updated after Phase 0B, after M2 for
the security contract (H10 strengthened, H12 added), and after M3 for history
ownership and results (D03, D10, I07, L01)
**Status:** v1.3 planning/release contract — updated after Phase 0B, after M2 for
the security contract (H10 strengthened, H12 added), after M3 for history
ownership and results (D03, D10, I07, L01), and after M4 for Save Point results
(D11-D14, I04, L03, E-series) and the browser condition below
> **Browser-level verification (M4 closeout, 2026-09-03).** The browser smoke
> condition that M3 and M4 both carried is **satisfied**. A real Firefox 154.0.1,
> driven through geckodriver over the W3C WebDriver protocol, exercised the
> rendered DOM: M3's Undo/Redo enable states, transcript movement, Retry and the
> take pager, divergence and the loss of Redo; and M4's full Save Point
> lifecycle including both confirmations and the branch-delete warning. 44/44
> checks passed with no console errors, on two independent runs. No pass
> condition anywhere in this document was changed to achieve it. See
> `reports/M4-IMPLEMENTATION-REPORT.md` §W.
>
> **Three kinds of evidence are recorded separately below, and are not
> interchangeable.** *Automated* means a test in the repository's suite, which
> runs on every future change. *Live runtime* means a real server exercised over
> HTTP — stronger than a unit test about process boundaries, weaker than a
> browser about anything a user sees. *Browser* means the rendered DOM driven by
> a real browser, which is the only evidence that a control is visible, enabled
> and wired. Where a result cites more than one, the strongest is named last.
**Purpose:** Define black-box acceptance tests for finalist evaluation during Phase 0B and for the eventual v1 release.
## 1. Test Philosophy
@@ -737,6 +757,14 @@ Before entering the abbey
### Pass
Checkpoint persists across application restart.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated:* `backend/tests/test_process_restart.py` starts the application as a
subprocess, writes the campaign, **terminates the process**, and starts a second
process against the same database — the Save Point, its name and its
`(branch, depth)` coordinate all survive.
*Browser:* the Save Point is still listed after a full page reload
(`reports/M4-IMPLEMENTATION-REPORT.md` §W.7 section E).
---
## D12 — Restore Checkpoint
@@ -751,6 +779,11 @@ Checkpoint persists across application restart.
### Pass
Transcript/state return to checkpoint position.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated:* `test_d12_restore_returns_the_transcript_and_the_state`.
*Browser:* the visible transcript and the state both move back, and the view
refreshes without a manual reload (§W.7 section F).
---
## D13 — Restore Does Not Delete Later History
@@ -760,6 +793,13 @@ Transcript/state return to checkpoint position.
### Pass
Later story is retained as abandoned/disposable history.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated:* measured on **row identity**, not on counts — the set of action row
ids before a restore equals the set after it. Ordinary Redo still walks the retained
continuation until a divergent write, and after that write the displaced rows are
still present while Redo reports nothing ahead. Four `test_d13_*` tests, and
confirmed in the browser with a database check behind it.
---
## D14 — Delete Checkpoint
@@ -773,10 +813,31 @@ Delete named checkpoint.
- checkpoint pointer disappears,
- referenced story turn/history remains intact.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated:* the pointer row goes; the referenced turn, the later history and the
active head are all unchanged (`test_d14_delete_removes_the_pointer_and_no_story`).
*Browser:* the confirmation states that deleting the Save Point does not delete
the story, and the story remains afterwards (§W.7 section I).
A Save Point is also the **only** thing that can remove itself: deleting a branch
whose history a Save Point names is refused rather than cascading
(`STORY-BRANCH-SEMANTICS.md` §19.1), verified automatically and in the browser.
---
# E. Branch and Lineage Safety
> **M4 result (2026-09-03).** E01 and E04 were re-exercised through a Save Point
> restore rather than only through Undo, and pass: a memory derived past a
> restored head stops being retrievable and becomes eligible again on Redo,
> without being deleted or re-embedded; after restore-plus-divergence the old
> future's memory stays ineligible even as the new line grows past its depth; and
> the transcript after a restore holds only the active lineage while the
> displaced rows remain in the tree. **E03** (summary lineage over a long story)
> remains **NOT PERFORMED** — it needs a long-run campaign and is owned by
> M6/M11, unchanged from M3.
## E01 — Abandoned Future Cannot Affect Active State
**Priority:** REQUIRED FOR V1
@@ -1353,6 +1414,15 @@ Export preserves retained alternate/disposable history needed for recovery, unle
### Pass
Named checkpoints survive export/import.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated and live runtime.* A real round trip with three Save Points across two
branches: names, notes and
coordinates survive, branch references are remapped to the imported rows
(1→3, 2→4), and each restores to a distinct position and state in the new
campaign. Importing Save Points does **not** move the active head — the head
still comes from the bundle's `headDepth`. Bundles written before M4 carry no
`checkpoints` key, import cleanly, and create none.
---
## I05 — Knowledge Provenance Export
@@ -1581,6 +1651,14 @@ State at each position matches original accepted state.
### Pass
Correct historical state is reconstructed.
### Result — PASS (M4 closeout, 2026-09-04)
*Automated, across a genuine OS process boundary:* the state at the Save Point
was recorded before the first process exited, and a second process restored
exactly that value after the campaign had been advanced past it
(`backend/tests/test_process_restart.py`). This replaced a same-process
`TestClient` restart, which could not distinguish durable state from a live
object.
---
## L04 — Derived Data Can Be Rebuilt
+87 -2
View File
@@ -1,8 +1,93 @@
# Planning Package Version
- **Package:** Adventure Storyteller Planning Package v2.5
- **Package:** Adventure Storyteller Planning Package v2.6
- **Revision date:** 2026-09-03
- **Status:** Phase 0 complete; architecture selected; **Milestones M1, M2 and M3 implemented and accepted**; **M4 implemented 2026-09-03 and awaiting review.**
- **Status:** Phase 0 complete; architecture selected; **Milestones M1-M4 implemented and accepted**; M5 is next to brief.
## v2.6 — M4 Closeout (2026-09-03)
M4 is **accepted**. Its review returned *PASS WITH CORRECTIVE WORK REQUIRED*; the
corrective work is done, and the browser condition that M3 and M4 both carried is
closed.
**The three review findings, fixed:**
- **B-1 — the Save Point list was an N+1.** It resolved each Save Point with its
own query and 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 computation: **53 SELECTs for 25 Save Points became 5**, and the count
no longer grows with the list. Guarded by three tests, including one proving
the coordinate is matched as a *pair* — an `IN`-list version would report a
Save Point resolved because another branch has a live row at the same depth.
- **B-2 — deleting a branch silently deleted its Save Points.** Fixed as a
**behaviour** defect rather than a missing warning, because
`STORY-BRANCH-SEMANTICS.md` §19 says a named checkpoint remains until
explicitly deleted and §28 already required future cleanup to retain
checkpoint-referenced paths. **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. Both delete controls disable and explain. Recorded as a new
**§19.1**.
- **B-3 — the D11/L03 automation never left one process.** A new module spawns
real server processes, kills the first, and reads the campaign back with the
second.
**Also fixed (review §S C-5):** creating a Save Point now takes the campaign's
turn lock, so "save where I am" cannot read a head a turn in flight is about to
move. Rename and Delete deliberately do not take it, and a test pins that
decision.
**Real-browser verification — the first in this project.** A Firefox 154.0.1
driven through geckodriver over the W3C WebDriver protocol exercised the rendered
DOM for **both** milestones: **44/44 checks passed**, no console errors. It
covered M3's Undo/Redo enable states, transcript movement, Retry and the take
pager, and divergence retiring Redo; and M4's whole Save Point lifecycle
including both confirmations and the new branch-delete warning. **The outstanding
M3 browser condition is therefore closed as well.** No dependency was added to
the repository: the WebDriver client for the run was written against stdlib HTTP.
**Also corrected, found while fixing B-2:** `models.py`, `TECHNICAL-DESIGN.md`
§8.8 and `DATA-MODEL.md` §8 all described the cascade as the durability rule.
They now describe the refusal, and record that `checkpoints.branch_id`'s cascade
survives as referential integrity that the application no longer reaches.
**Documents corrected by this closeout:**
- `V1-ACCEPTANCE-TESTS.md` records results for **D11-D14, I04, L03** and the
E-series, and states that the browser-level condition is satisfied. **No pass
condition was weakened** — and D11/L03 now note that the automation crosses a
genuine OS process boundary, which is the standard later milestones should
meet.
- `DATA-MODEL.md` §8 records the coordinate as implemented, with the retry
measurement that settles coordinate-versus-turn-id, and three decisions that
were previously implicit: names are not unique, several Save Points may name
one position, and the list is newest-created first.
- `STORY-BRANCH-SEMANTICS.md` gains **§19.1** — a checkpoint protects the
history it names. This is the only behavioural specification change in the
closeout, and it strengthens §19 rather than weakening anything.
- `BROWSER-UX-SPEC.md` §25 rules for the implemented vocabulary: **Moment N**,
not *Turn N*, because the branch panel and tree overlay already count in
moments. Vocabulary only; no behaviour changes.
- `BUILD-MILESTONES.md` marks **M4 COMPLETE**, records the fixes and the browser
result, and warns M5 that the instrumentation to move is now **55 tests**.
- `README.md` records M1-M4 accepted and M5 as next to brief.
- `PROJECT-SOURCES.md` and `project-sources.txt` point at the current report;
`project-sources.txt` still named the archived M3 report and was corrected.
**Report rotation** happened in the reporting pass that preceded this closeout:
`M3-IMPLEMENTATION-REPORT.md` moved to `archive/milestone-reports/` as a pure
rename, and `reports/` now holds M4's report, whose **§W** is this closeout's
evidence record.
**No new ADR.** ADR 012 already decides the architecture, and the corrective work
forced no new architectural decision. `SPECIFICATION.md`,
`STORY-BRANCH-SEMANTICS.md`, `SECURITY-THREAT-MODEL.md`, `CONTEXT-AND-MEMORY.md`,
`IMPORTED-KNOWLEDGE-DESIGN.md` and ADRs 003, 005 and 012 are unchanged.
**M5 readiness:** ready. Save Points store no state and no checkpoint code reads
any, so M5 can change what a snapshot contains without touching what a Save Point
is — provided it keeps state recoverable at a position without replay
(`TECHNICAL-DESIGN.md` §10.4).
## v2.5 — M4 Implementation (2026-09-03)
+1 -1
View File
@@ -26,4 +26,4 @@ planning/DECISIONS/009-ai-dnd-production-base.md
planning/DECISIONS/010-explicit-typed-narrative-state-events.md
planning/DECISIONS/011-local-inference-endpoint-policy.md
planning/DECISIONS/012-active-head-non-destructive-history.md
planning/reports/M3-IMPLEMENTATION-REPORT.md
planning/reports/M4-IMPLEMENTATION-REPORT.md
@@ -1040,4 +1040,279 @@ second consecutive milestone. What to do with that is the reviewer's call.
---
# W. M4 Closeout Addendum (2026-09-03 / 2026-09-04)
**This addendum is current where it differs from the review above**, and §W.3 is
current where it differs from an earlier draft of this addendum: B-2 was first
addressed as a warning over a surviving cascade, then reclassified as a behaviour
defect and fixed by refusing the deletion. Only the final behaviour is described
below; the review's §R still records the finding as it was raised. Sections
A-V record what was true at commit `e08d49c`, before corrective work; nothing in
them has been rewritten. Where they say a finding is open or a test is
unperformed, this section says what happened next.
## W.1 Starting state
| Fact | Value |
| --- | --- |
| Closeout started from | `279a871` — *Planning: add the M4 implementation review report and rotate M3's* |
| Closeout dates | corrective work and browser verification 2026-09-03; B-2 reclassified and re-fixed 2026-09-04 |
| Reviewed implementation | `e08d49c` — signed, unchanged by this pass |
| Working tree at start | clean; the report and rotation were already committed |
| Upstream ancestry | intact |
| LICENSE | unchanged |
## W.2 B-1 — the Save Point list N+1 is gone
`GET /checkpoints` resolved each Save Point with its own query and loaded whole
`Action` entities to do it. It now takes one bulk query over two integer columns
plus one lineage computation, both before the loop.
Measured on the same 25-Save-Point fixture the review used:
| | before | after |
| --- | --- | --- |
| SQL statements per list | **53** | **5** |
| SELECTs against `actions` | 25 | **1** |
| columns in that SELECT | 11, including `text` | **2** (`branch_id`, `depth`) |
| narration fetched | yes | **no** |
| queries per Save Point | 2.1 | 0.2 |
Three tests guard it. One asserts on *growth* rather than a fixed budget — five
times the Save Points must not mean more queries — because a fixed number is
something to edit rather than a rule to keep. One asserts the emitted SQL never
names `actions.text`, `actions.reasoning` or `actions.world_delta`. The third is
the one worth keeping: it builds a **cross-product trap**, a dead coordinate on
one branch while another branch has a live row at the same depth, and proves the
dead one still reports `resolved: false`. An `IN`-list implementation would fail
it; the OR-of-pairs passes.
`_render_all` is now the single rendering path — create and rename call it
through `_rendered` with a list of one — so the list and the single-item
responses cannot drift.
## W.3 B-2 — a branch a Save Point names cannot be deleted
The review recorded this as a missing warning. On closeout it was reclassified as
a **behaviour defect**, because the authority is unambiguous:
`STORY-BRANCH-SEMANTICS.md` §19 says a named checkpoint remains until explicitly
deleted, and §28 already requires a future cleanup feature to *retain paths
referenced by checkpoints*. A cascade that removed Save Points along with a
branch violates both, and a warning would only have documented the violation.
**The deletion is now refused.** `DELETE /branches/{id}` returns **409** when any
Save Point names a position on that branch or on anything forked from it — the
subtree, because deleting a branch takes its descendants, and a check scoped to
the named branch alone would let a Save Point on a child vanish silently.
The refusal names what stands in the way rather than only counting it:
```text
This branch, or a branch forked from it, is where a Save Point “On the new
line” is saved. Delete that Save Point first if you no longer need it, then
delete the branch. Deleting a Save Point does not delete any story.
```
Long lists truncate ("and 2 more") so the message stays a sentence someone reads.
The recovery is the point, and it is cheap: deleting a Save Point deletes no
story (§25), so the user removes the pointer and the branch then goes. Verified
end to end in the browser (§W.7 section J).
Both delete controls — the branch list and the tree overlay — now **disable**
Delete while Save Points protect the subtree and say why, and the branch row
reports `· 2 Save Points kept here`. The confirmation, now only reachable when
nothing is at risk, says plainly that the story on other paths and every Save
Point are unaffected. Two views changed, because the same deletion is reachable
from both and a rule holding in one of them would not be a rule.
`checkpoints.branch_id` keeps `ON DELETE CASCADE` as referential integrity — a
Save Point must never point at a branch that is gone — but the guard means it
does not fire through the application. Campaign deletion still cascades, which is
what deleting a campaign means.
Six tests cover the five required scenarios plus the truncation: deletion refused
and nothing lost; the Save Point intact after the refusal; explicit deletion then
allowing the branch delete; a branch no Save Point names still deleting; a Save
Point on a *descendant* also protecting; and an unrelated Save Point not blocking
anything. A source-level test asserts both views disable and explain.
**Documents this corrected beyond the planned set:** `models.py`,
`TECHNICAL-DESIGN.md` §8.8 and `DATA-MODEL.md` §8 had all recorded the cascade as
the durability rule. They now record the refusal.
## W.4 B-3 — the restart test now crosses a process boundary
`backend/tests/test_process_restart.py` (3 tests, ~9 s) starts the real
application with `subprocess.Popen`, plays a story over HTTP, **kills the
process**, starts a second process against the same database file, and only then
asks its questions. Readiness is probed, never slept on; children are terminated
in a `finally` whether or not the test passes.
It covers D11 (the Save Point, its name and its coordinate survive), L03 (the
state at the position returns), that restore deletes no rows, that Redo is
available before divergence, that the retained continuation is still walkable,
and that a divergent write after a restart still forks on the write rather than
the restore.
The old same-process helper was **not** kept as a pretend equivalent.
## W.5 C-5 — creating a Save Point is serialized
Create now takes the campaign's existing turn lock, the same one Undo, Redo and
Restore take. No new lock was introduced. "Save where I am" has to name one
committed position, and the head is exactly what a turn in flight is about to
move.
Rename and Delete deliberately **do not** take it: neither reads nor moves a
story position, and refusing a label edit during generation would be a worse
product for no safety gained. A test pins that decision so it reads as a choice
rather than an oversight, and another proves a refused create releases the lock.
## W.6 Test and build results
All against the closeout tree.
| Suite | Result |
| --- | --- |
| **Full backend** | **698 passed**, 0 failed, 0 skipped (was 680) |
| **M4 targeted** (`test_save_points.py` + `test_process_restart.py`) | **60 passed** (57 + 3) |
| **M3 invariants** (8 modules) | **130 passed** |
| **Security / local-only** (incl. `test_egress.py`) | **93 passed** |
| **Frontend lint** | exit 0; 7 warnings, unchanged from baseline |
| **Frontend build** | exit 0 |
| **Docker build** | exit 0 |
698 − 680 = 18 new tests: 3 process-restart, 4 for B-1, 7 for B-2 (the refusal
rule and its recovery), 3 for C-5, and one holding the branch-list count to the
same subtree the server refuses on. Two tests written earlier in this closeout
were **replaced**, not kept alongside: they asserted the cascade behaviour that
§W.3 removed, and leaving them would have pinned the defect.
## W.7 Browser verification — PERFORMED, and it passes
**This is the first real-browser verification in the project**, and it discharges
the condition M3 and M4 both carried.
| | |
| --- | --- |
| Browser | **Mozilla Firefox 154.0.1**, headless |
| Driver | geckodriver 0.37.1, W3C WebDriver over HTTP |
| Client | written for the run against Python's stdlib — **no dependency added to the repository** |
| App | the real production-shaped server, SPA served same-origin, deterministic scripted model |
| Result | **47/47 checks passed**, no console errors; run twice on independent fresh databases |
§M reported no usable browser, and that was true of the paths tried there: the
`firefox` snap wrapper fails with mount-namespace errors and hangs on a headless
screenshot. The binary **inside** the snap
(`/snap/firefox/current/usr/lib/firefox/firefox`) runs correctly under
geckodriver, which §M did not try. §M's conclusion is superseded; its account of
what was attempted stands.
What the browser actually did, by section of the closeout brief:
| Section | Verified in the DOM |
| --- | --- |
| **A — M3 history** | transcript renders; Undo enabled and Redo disabled at the tip; two Undos move the transcript back twice; Redo becomes enabled; two Redos return the original tip exactly |
| **B — retry / takes** | Retry produces an alternate take; the take pager appears; stepping between takes changes the visible narration; no branch vocabulary needed |
| **C — M3 divergence** | a new continuation from a moved-back head appears; Redo becomes unavailable; no stale old-future text in the active transcript; position and continuation survive a reload — with a database check confirming the displaced rows are still on disk |
| **D — creation** | Save Point created and named through the form; appears in the list; panel says **Save Point** with no branch/head/node/fork wording; position reads **Moment N** |
| **E — persistence** | still present after a full page reload |
| **F — restore** | the confirmation visibly explains later history is kept; transcript and state move back; the Save Point stays listed; Redo becomes available; zero rows deleted (database check); the view refreshes without a manual reload |
| **G — restore + redo** | Redo returns the original continuation; the Save Point survives |
| **H — restore + divergence** | the different continuation appears; Redo into the displaced future is gone; no old-future narration in the active view; the displaced rows retained on disk |
| **I — rename / delete** | rename changes the name and not the position; it still restores to the same moment; the delete warning says the story is not deleted; the row disappears with no stale list; the story remains |
| **J — branch deletion** | the branch row reports the Save Points kept on it; Delete is **disabled** and explains what to do; the server independently refuses with **409** naming the Save Point; nothing is deleted by the refusal; and the documented recovery works — deleting the Save Point frees the branch |
| **K — control state** | enable/disable states match the position; no material console errors |
**No defect was found in the application by the browser run.** Four failures
occurred and all four were in the harness: a wrong SPA route (`/adventures/:id`
rather than `/play/:id`); a wait predicate that compared transcript *length* when
the empty-story placeholder is longer than the first turn; a fixture that tried
to delete the branch it was reading, which is refused by design; and a reload
assertion that sampled the transcript once instead of waiting for it to render.
The last was checked against the application before being called a harness bug —
after a reload the text is present at the first sample, so the race was the
test's.
## W.8 Planning documents updated
`V1-ACCEPTANCE-TESTS.md` (D11-D14, I04, L03, E-series results and the browser
condition; **no pass condition weakened**), `STORY-BRANCH-SEMANTICS.md` **§19.1**
(new — a checkpoint protects the history it names; the closeout's only
behavioural specification change, and it strengthens §19),
`DATA-MODEL.md` §8, `TECHNICAL-DESIGN.md` §8.8, `BROWSER-UX-SPEC.md` §25 (Moment,
not Turn), `BUILD-MILESTONES.md` (M4 COMPLETE, the facts M5 inherits, the M5
instrumentation note), `planning/README.md`, `VERSION.md` (v2.6),
`PROJECT-SOURCES.md` and `project-sources.txt`, which still named the archived M3
report.
Unchanged, as §T recommended: `SPECIFICATION.md`, `STORY-BRANCH-SEMANTICS.md`,
`SECURITY-THREAT-MODEL.md`, `CONTEXT-AND-MEMORY.md`,
`IMPORTED-KNOWLEDGE-DESIGN.md`, ADR 003, ADR 005, ADR 012. **No ADR 013**: the
corrective work forced no architectural decision. §19.1 is a durability rule
inside an existing specification, not a new architecture — ADR 005 already says
history is preserved rather than overwritten, and refusing to delete a
checkpoint's history is that decision applied, not a departure from it.
## W.9 The architecture is unchanged
Worth stating plainly, because closeout touched the checkpoint module. A Save
Point is still `name + optional note + (branch, depth)`. Restore is still
`coordinate → head.move_to_node → head.move_to → attempts.restore_state`. The
corrective work touched **how the list is read** and **when create is allowed to
read the head** — never what a Save Point is or how restoring one moves the
story. Nothing was copied into a checkpoint, no checkpoint-specific Redo stack
exists, restore still does not fork, and the first divergent write is still what
creates the continuation.
## W.10 Remaining debt
Unchanged from §S and none of it M4's: no frontend test runner (M8) — the browser
run above is a closeout procedure, not a suite; `POST /adventures/import` returns
every branch's rows rather than a head-capped window (inherited, M3); the RPG
world-state instrumentation, now **60 tests**, moves at M5.
## W.11 Result
D11, D12, D13, D14, I04 and L03 all **PASS**. The E-series lineage results
through a Save Point restore all **PASS** (E03 remains NOT PERFORMED, owned by
M6/M11). The Definition of Done is met, and now met in a browser:
> The user can create a named Save Point, continue, restart, restore it, and
> continue differently without losing later history.
```text
M4 CLOSED — READY FOR M5
```
M5 is next to brief. It has not been started.
## W.12 What M5 inherits
Recorded here because it is the only thing this report owes the next milestone.
These are constraints, not suggestions, and none of them is M5's to revisit.
- **The inherited RPG world-state system is instrumentation, not the target
architecture.** 60 tests in this milestone use gold arithmetic to make "the
state at this position" a number a test can assert. M5 replaces the machinery
underneath and must **move the instrumentation while keeping the assertions** —
what they measure is where the story is being read and what state belongs to
that position, which is exactly as true after M5.
- **M5 implements ADR 010's genre-neutral typed narrative-state model.** That is
the target; the stat/band/cooldown protocol is what it replaces.
- **Per-position snapshots, or equivalent fast recovery, must survive the
replacement** (`TECHNICAL-DESIGN.md` §10.4). Undo, Redo and Save Point restore
all resolve a coordinate and read the state recorded there. If M5 makes state
reconstruction proportional to campaign length, it degrades all three at once.
- **M3/M4 history and Save Point semantics are infrastructure now.** The stored
head, the single movement mechanism, the capped lineage, fork-on-first-write,
and the Save Point coordinate are settled. M5 changes what state *contains*,
not how the story is positioned, and should not add a second restore path.
- **A Save Point's history is protected** (`STORY-BRANCH-SEMANTICS.md` §19.1).
Any state-cleanup or migration work M5 introduces must not acquire a way to
delete a checkpoint, or the history one names, as a side effect.
---
*End of report.*