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
+78 -1
View File
@@ -204,8 +204,13 @@ class ActionOut(ORMModel):
text: str
reasoning: str | None = None
# Phase 12: the compact RPG state changes for this turn, read from the
# model property.
# model property. Legacy as of M5 and empty on new turns; kept so a pre-M5
# campaign's chips still render.
world_changes: list[dict] = []
# M5: what this turn changed, as short lines for the chip under an AI
# message. Read from `Action.state_summary`, which reads the small
# bulk-loaded column rather than the deferred snapshot.
state_summary: list[str] = []
# SP9: the pager, such as `2/4`. It reports how many attempts this turn has
# and which one is on screen. It is keyed on the parent, so it counts the
# attempts of this turn rather than every node that shares a depth, and it
@@ -276,6 +281,78 @@ class BranchRename(BaseModel):
name: Annotated[str, Field(max_length=BRANCH_NAME_MAX)] | None = None
# ---------- Narrative state (M5) ----------
class StateGroup(BaseModel):
"""One labelled section of the state inspector.
Rows carry the key as well as the label, because a manual correction has to
name an entity and the user should not have to guess the identifier.
"""
title: str
rows: list[dict] = []
class NarrativeStateOut(BaseModel):
"""The authoritative state at the active head.
`groups` is the display form and `document` is the state itself. Both are
returned because they answer different questions: the panel renders the
first, and a correction form — or a test — needs the second to name a key.
"""
groups: list[StateGroup] = []
empty: bool = True
document: dict = {}
class StateEventIn(BaseModel):
"""One typed event, as a client proposes it.
Deliberately loose about which fields are present: the event vocabulary is
defined in `narrative/events.py` and enforced by `narrative/validate.py`,
and duplicating those rules here would create a second, drifting copy of the
allowlist. What this model does is bound the shapes — a type that is a
string, values that are scalars, labels that are short strings — so a
payload cannot smuggle a structure past Pydantic and reach the validator as
something other than an event.
"""
model_config = ConfigDict(extra="allow")
type: Annotated[str, Field(max_length=60)]
class StateCorrection(BaseModel):
"""A manual correction: the user overruling what the story established.
`note` records why, in the user's words, and is kept on the proposal record
so the audit says more than "the user changed this".
"""
events: Annotated[list[StateEventIn], Field(min_length=1, max_length=20)]
note: Prose = ""
class StateEventOut(ORMModel):
"""One accepted change, for the audit view."""
id: int
action_id: int | None = None
branch_id: int | None = None
depth: int | None = None
# The reader-facing position, matching the Save Point panel's vocabulary.
turn: int | None = None
sequence: int = 0
event_type: str
payload: dict = {}
before: dict | None = None
source: str = "accepted_story"
created_at: datetime
# ---------- Save Points (M4) ----------
#
# "Save Point" is the user-facing term and `checkpoint` is the internal one