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
+22 -22
View File
@@ -34,7 +34,7 @@ from app.main import app
from app import auth
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()
@@ -50,7 +50,6 @@ def client(monkeypatch):
setup.flush()
adv = models.Adventure(
user_id=user.id, title="Abbey", scenario_id=scenario.id,
world_state={"player": {"hp": 100, "gold": 0}},
)
setup.add(adv)
setup.flush()
@@ -153,7 +152,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()
@@ -459,7 +458,7 @@ def test_d13_a_different_continuation_forks_and_keeps_the_old_future(client):
# Restore itself created nothing.
assert _branch_count(client.adv_id) == branches_before
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
# The write forked...
@@ -482,7 +481,7 @@ def test_d13_the_save_point_still_names_the_same_position_after_divergence(clien
_turns(client, 3)
_restore(client, made["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
kept = _list(client)
@@ -514,7 +513,7 @@ def test_a_save_point_on_a_line_the_story_left_still_restores(client):
old_branch, old_depth = _head(client.adv_id)
_restore(client, fork_point["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
new_branch, _ = _head(client.adv_id)
assert new_branch != old_branch
@@ -539,7 +538,7 @@ def test_restore_onto_an_inherited_position_keeps_the_line_being_read(client):
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
new_branch, _ = _head(client.adv_id)
@@ -761,7 +760,7 @@ def test_a_branch_a_save_point_names_cannot_be_deleted(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked = _save(client, "On the new line")
doomed_branch = forked["branch_id"]
@@ -789,7 +788,7 @@ def test_deleting_the_save_point_then_lets_the_branch_go(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked = _save(client, "On the new line")
doomed_branch = forked["branch_id"]
@@ -819,7 +818,7 @@ def test_a_branch_no_save_point_names_still_deletes(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked_branch = _head(client.adv_id)[0]
root_id = _root_branch(client.adv_id)
@@ -839,11 +838,11 @@ def test_a_save_point_on_a_descendant_also_protects_the_branch(client):
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["Second line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Second line.", 1)]
_play(client, "second line")
middle_branch = _head(client.adv_id)[0]
_undo(client)
ScriptedProvider.replies = ["Third line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Third line.", 1)]
_play(client, "third line")
deepest = _save(client, "Down on the deepest line")
assert deepest["branch_id"] != middle_branch
@@ -864,7 +863,7 @@ def test_a_save_point_elsewhere_does_not_block_an_unrelated_branch(client):
elsewhere = _save(client, "Safe on the root")
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
forked_branch = _head(client.adv_id)[0]
root_id = _root_branch(client.adv_id)
@@ -883,7 +882,7 @@ def test_the_refusal_names_several_save_points_without_running_on(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
for n in range(5):
_save(client, f"Point {n}")
@@ -967,7 +966,7 @@ def test_e01_an_old_futures_memory_stays_out_of_a_new_continuation(client):
db.close()
_restore(client, made["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
assert _visible_memories(client) == []
@@ -990,7 +989,7 @@ def test_e04_the_transcript_after_a_restore_holds_only_the_active_lineage(client
assert displaced
_restore(client, made["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
ScriptedProvider.replies = gold_replies("New")
_turns(client, 2)
@@ -1140,7 +1139,7 @@ def test_save_points_on_two_branches_survive_the_round_trip(client):
old_line = _save(client, "Down the old road")
_restore(client, shared["id"])
ScriptedProvider.replies = ["Through the side door.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Through the side door.", 1)]
_play(client, "go around the back")
new_line = _save(client, "Down the new road")
@@ -1252,7 +1251,8 @@ def test_an_m3_database_gains_the_save_point_table_and_keeps_its_story():
}
assert "ix_checkpoints_adventure" in {i["name"] for i in insp.get_indexes("checkpoints")}
with m3.connect() as conn:
assert conn.execute(text("PRAGMA user_version")).scalar() == 80
assert (conn.execute(text("PRAGMA user_version")).scalar()
== migrations.LATEST_VERSION)
# No Save Points were invented for a campaign whose owner named none...
assert conn.execute(text("SELECT COUNT(*) FROM checkpoints")).scalar() == 0
# ...and the campaign it already had is untouched.
@@ -1374,7 +1374,7 @@ def test_the_bulk_resolution_does_not_confuse_coordinates_across_branches(client
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way") # forks; new branch has rows at 5,6
on_new_line = _save(client, "On the new line")
@@ -1428,7 +1428,7 @@ def test_the_branch_list_reports_how_many_save_points_each_line_carries(client):
_turns(client, 2)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["A new road.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("A new road.", 1)]
_play(client, "the other way")
on_new = _save(client, "On the new line")
@@ -1450,11 +1450,11 @@ def test_the_save_point_count_matches_what_blocks_the_deletion(client):
_turns(client, 4)
_undo(client)
_undo(client)
ScriptedProvider.replies = ["Second line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Second line.", 1)]
_play(client, "second line")
middle_branch = _head(client.adv_id)[0]
_undo(client)
ScriptedProvider.replies = ["Third line.\n```state\n{\"player.gold\": 1}\n```"]
ScriptedProvider.replies = [tally_reply("Third line.", 1)]
_play(client, "third line")
_save(client, "Deep one")