M5: genre-neutral authoritative narrative state, with review corrections

Replaces AI-DnD's RPG relative-delta world state with the genre-neutral typed
narrative state of ADR 010: explicit, absolute, allowlisted events proposed by
the model, validated by the application, applied to one authoritative document,
and snapshotted per position so restore stays a row read.

This commit includes the corrective pass that followed the independent review
in planning/reports/M5-IMPLEMENTATION-REPORT.md. The invariant it exists to
hold is:

    visible active transcript position == stored head == authoritative state

Narrator editing (D10, STORY-BRANCH-SEMANTICS §§14-15)

  A narrator edit no longer rewrites a row. It returns to the state before the
  turn, takes the reader's exact text as the accepted narration, re-derives the
  state that text implies, and becomes a new active continuation — while the
  original narration keeps its words, its live flag and its whole future as
  retained history. At the tip the correction is another take; with story below
  it, it forks. No new history machinery: this is the existing fork/take/head
  path with the reader's text in place of a generated reply. The §14A refusal
  is therefore gone for narrator turns, and remains only for player input.

Pre-M5 positions

  Migration 88 backfills the empty narrative document onto every action written
  before M5, and a missing snapshot now restores the empty document instead of
  leaving the previous position's state standing. Restoring to an old Save
  Point no longer leaves a later position's entities and facts on screen.

Narrator context

  Replayed history carries prose only; the machine-readable block is no longer
  reconstructed into past turns, where it contradicted the authoritative state
  in the same prompt. A fact withdrawn by a manual correction is now named as
  no longer true, with the reader's reason, rather than silently dropped.

Also

  - state_changes joins the action-list bulk read, removing one query per row.
  - Extraction takes only the application's own protocol payload: an ordinary
    ```json or ```python block in a story survives, and a mangled proposal
    still does not reach the reader.

Planning: ADR 013 records the authoritative document shape; §§14-15/14A, D10,
C04 and BUILD-MILESTONES are updated to describe what exists. Debt is recorded
against M8 (scenario editor UX) and M9 (export of the audit trail).

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-05 07:01:50 -04:00
co-authored by Claude Opus 5
parent 62a997f364
commit b7005e6fdd
57 changed files with 7257 additions and 474 deletions
+53 -31
View File
@@ -36,7 +36,7 @@ from app.main import app
from app import auth, tree
from app.routers import adventures
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies
from fakes import GOLD_PER_TURN, GOLD_SCHEMA, ScriptedProvider, gold_replies, tally_of, tally_reply
@pytest.fixture()
@@ -52,7 +52,6 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Tavern", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -131,7 +130,7 @@ def _gold(adv_id) -> int:
db = SessionLocal()
try:
adv = db.get(models.Adventure, adv_id)
return (adv.world_state or {}).get("player", {}).get("gold", 0)
return tally_of(adv.narrative_state)
finally:
db.close()
@@ -250,7 +249,7 @@ def test_d05_a_new_turn_below_the_head_retires_redo_and_keeps_the_future(client)
_undo(client)
assert _adventure(client)["can_redo"] is True
ScriptedProvider.replies = ["A different road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A different road.", 1)]
_play(client, "go the other way")
# Ordinary Redo cannot walk into the old future any more...
@@ -294,8 +293,8 @@ def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
current is the one belonging to the position the story is read at, so a
number only the abandoned future ever reached cannot survive a divergence."""
ScriptedProvider.replies = [
"You find a purse.\n```state\n{\"player.gold\": 10}\n```",
"You find the hoard.\n```state\n{\"player.gold\": 500}\n```",
tally_reply("You find a purse.", 10),
tally_reply("You find the hoard.", 510),
]
_play(client, "search")
_play(client, "keep searching")
@@ -304,7 +303,7 @@ def test_e01_e04_a_fact_from_the_abandoned_future_is_not_current(client):
_undo(client)
assert _gold(client.adv_id) == 10
ScriptedProvider.replies = ["You leave empty-handed.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("You leave empty-handed.", 11)]
_play(client, "go home")
assert _gold(client.adv_id) == 11, "the hoard belonged to a story this one is not"
@@ -425,6 +424,7 @@ def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client
_play(client, "knock")
first = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
ScriptedProvider.replies = [tally_reply("Another knock.", GOLD_PER_TURN)]
r = client.post(f"/api/adventures/{client.adv_id}/retry")
assert r.status_code == 200, r.text
@@ -432,7 +432,8 @@ def test_d06_d08_retry_keeps_the_earlier_take_and_reuses_the_parent_state(client
assert len(ai_rows) == 2, "the earlier take is retained"
assert first.id in {a.id for a in ai_rows}
# Both takes sit at the same coordinate, which is what makes them takes
# rather than turns, and the state is one turn's worth either way.
# rather than turns, and the state is one turn's worth either way — the
# retry replaced the take rather than stacking on top of it.
assert {a.depth for a in ai_rows} == {first.depth}
assert _gold(client.adv_id) == GOLD_PER_TURN
@@ -485,15 +486,15 @@ def test_d09_d10_replaying_a_turn_forks_and_keeps_the_old_line(client):
prose that creates no continuation, and M3 does not change it.
"""
ScriptedProvider.replies = [
"You accuse her.\n```state\n{\"player.gold\": 10}\n```",
"She draws a knife.\n```state\n{\"player.gold\": 20}\n```",
tally_reply("You accuse her.", 10),
tally_reply("She draws a knife.", 30),
]
_play(client, "I accuse Mara of stealing the key.")
_play(client, "wait")
accusation = [a for a in _rows(client.adv_id) if a.type == "do"][0]
old_future = {a.id for a in _rows(client.adv_id)}
ScriptedProvider.replies = ["She shakes her head.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("She shakes her head.", 1)]
r = client.post(
f"/api/adventures/{client.adv_id}/actions/{accusation.id}/takes",
json={"text": "I quietly ask Mara whether she has seen the key."},
@@ -791,39 +792,55 @@ def test_editing_a_turn_on_the_visible_story_is_still_allowed(client):
assert "Mara wears a green cloak." in _texts(client)
def test_editing_a_turn_with_an_undone_future_is_refused(client):
"""The first unsafe case: the story past the head is not on screen, so an
edit here would silently change the words it was written from."""
def test_editing_a_narrator_turn_with_an_undone_future_keeps_it(client):
"""M5 corrective pass: what §14A refused, §§14-15 now handle.
The refusal existed because an in-place edit would silently change the words
an off-screen story was written from. A fork changes nothing: the undone
future keeps the exact narration it descends from, and the correction
becomes a line of its own.
"""
_turns(client, 3)
_undo(client)
at_head = [a for a in _rows(client.adv_id) if a.type == "ai" and a.live]
target = sorted(at_head, key=lambda a: a.depth)[-2]
before = target.text
before, target_id = target.text, target.id
undone = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (target.depth or 0)]
assert undone, "the fixture needs a future to leave behind"
r = _edit(client, target.id, "Something else entirely.")
r = _edit(client, target_id, "Something else entirely.")
assert r.status_code == 400
assert "not on screen" in r.json()["detail"]
# Refused, not partially applied.
assert _rows(client.adv_id)[0].adventure_id == client.adv_id
assert [a.text for a in _rows(client.adv_id) if a.id == target.id] == [before]
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows(client.adv_id)}
# §15.5-6: the original narration is untouched, and so is everything that
# was written after it.
assert rows[target_id].text == before
assert all(old_id in rows for old_id in undone)
# §15.4: the correction is what the story now tells.
assert "Something else entirely." in _texts(client)
assert before not in _texts(client)
def test_editing_a_turn_a_divergence_left_behind_is_refused(client):
"""The second unsafe case, and the one a head check alone would miss: after
a divergence the head is back at a tip, but a displaced line still runs on
past the shared turn."""
def test_editing_a_narrator_turn_a_divergence_left_behind_keeps_that_line(client):
"""The case a head check alone would miss: after a divergence the head is
back at a tip, but a displaced line still runs on past the shared turn. It
keeps its words too."""
_turns(client, 3)
shared = [a for a in _rows(client.adv_id) if a.type == "ai"][0]
shared_id, before = shared.id, shared.text
_undo(client)
_undo(client)
_play(client, "a different road")
assert _adventure(client)["can_redo"] is False, "the head is at a tip again"
displaced = [a.id for a in _rows(client.adv_id) if (a.depth or 0) > (shared.depth or 0)]
r = _edit(client, shared.id, "Rewritten under both lines.")
r = _edit(client, shared_id, "Rewritten under both lines.")
assert r.status_code == 400
assert "left behind by a new continuation" in r.json()["detail"]
assert r.status_code == 200, r.text
rows = {a.id: a for a in _rows(client.adv_id)}
assert rows[shared_id].text == before
assert all(old_id in rows for old_id in displaced), "the displaced line survives"
assert "Rewritten under both lines." in _texts(client)
def test_a_turn_the_displaced_line_does_not_descend_from_is_still_editable(client):
@@ -857,14 +874,19 @@ def test_a_take_that_is_not_live_stays_editable(client):
def test_the_guard_lifts_when_the_story_is_brought_back(client):
"""Refusal is a redirection, not a dead end: the error names Redo, so Redo
has to make the edit possible again."""
has to make the edit possible again.
The guard now covers a player's own input only. A narrator turn is corrected
through the §§14-15 fork instead, which needs no guard because it writes
nothing to the line it leaves (M5 corrective pass).
"""
_turns(client, 3)
_undo(client)
live = sorted(
[a for a in _rows(client.adv_id) if a.type == "ai" and a.live],
[a for a in _rows(client.adv_id) if a.type == "do" and a.live],
key=lambda a: a.depth,
)
target = live[-2]
target = live[-1]
assert _edit(client, target.id, "x").status_code == 400
_redo(client)