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
+45
View File
@@ -142,6 +142,51 @@ def test_the_state_snapshots_are_not_fetched_in_bulk(client, sql_log):
assert offenders == [], f"{column} was fetched in bulk"
def test_the_narrative_snapshot_is_not_fetched_in_bulk(client, sql_log):
"""M5's rollback snapshot follows the same rule as the two before it: only
the one node being restored to ever needs it."""
client.get(f"/api/adventures/{client.adv_id}")
offenders = [s for s in action_selects(sql_log) if "narrative_state_after" in s]
assert offenders == [], "narrative_state_after was fetched in bulk"
def test_the_action_list_does_not_cost_a_query_per_action(client, sql_log):
"""M5 review, Finding 2: the N+1 the bulk column set was there to prevent.
`ActionOut.state_summary` reads `state_changes` for every row on the page.
The column was added to the model without being added to
`ACTION_LIST_COLUMNS`, so each row lazy-loaded it on serialization: 51
actions cost 53 extra queries, and the cost grew with the story.
Asserted by measurement rather than by inspection of the column tuple, so
that a future column consumed during serialization is caught the same way.
"""
with SessionLocal() as db:
adventure = db.get(models.Adventure, client.adv_id)
for i in range(40):
db.add(models.Action(
adventure_id=adventure.id,
type="ai" if i % 2 else "do", text=f"Extra {i}.",
state_changes={"accepted": [], "rejected": [],
"summary": [f"fact: extra {i}"]},
))
db.commit()
sql_log.clear()
r = client.get(f"/api/adventures/{client.adv_id}")
assert r.status_code == 200, r.text
rows = len(r.json()["actions"])
assert rows >= 50, "the fixture needs enough rows for the growth to show"
assert len(action_selects(sql_log)) < rows, (
f"{len(action_selects(sql_log))} SELECTs against actions for {rows} rows — "
"the list is paying one query per action"
)
# And the summaries still arrive.
summaries = [a["state_summary"] for a in r.json()["actions"] if a["state_summary"]]
assert summaries, "state_summary came back empty, so the column is not being read"
def test_world_changes_still_works_without_the_snapshot(client):
"""The chips under an AI message must survive the snapshot being deferred."""
r = client.get(f"/api/adventures/{client.adv_id}")