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
+317
View File
@@ -0,0 +1,317 @@
"""M5: deciding which proposed events the application will accept.
A proposal is untrusted model output. This module is the gate between it and the
authoritative state, and it is layered so that a rejection can say *which* rule
refused and a test can aim at one layer at a time:
1. envelope is this a proposal at all — a dict with a list of events?
2. allowlist is each event type one this application implements? (H05)
3. schema are the required fields present, and the right shape?
4. referential do the entities and threads it names exist?
5. semantic does it contradict campaign canon, or itself?
Layer 2 is the security boundary and runs before any field is read, so a payload
carrying `command` or `path` alongside an unknown type is discarded without those
fields ever being looked at.
## What rejection means
Nothing is partially applied. `review` returns accepted and rejected events
separately and the caller decides; `apply.py` is only ever handed the accepted
list. A proposal with one bad event out of four therefore lands three, which is
`partially_accepted` — the alternative, discarding all four because the model
misspelled one entity, loses story the user watched happen.
What is *never* allowed is a rejected event mutating anything, or a rejection
being silent: every refusal carries a reason, is counted, and is stored on the
proposal record for §8's audit.
## What this module does not do
It does not decide whether the model was *right*. A typed event can be
well-formed, reference real entities, contradict nothing, and still describe
something the narration did not say. That is C06's territory and no validator
can settle it — ADR 010 says so plainly. What validation buys is that a wrong
proposal is wrong in a way a person can see in the audit trail, rather than one
that silently means something other than it appears to.
"""
from __future__ import annotations
from . import events, model
# A rejected event carries one of these, so tests and the debug view can assert
# on the reason rather than on prose.
UNKNOWN_TYPE = "unknown_event_type"
NOT_AN_OBJECT = "not_an_object"
MISSING_FIELD = "missing_field"
BAD_FIELD_TYPE = "bad_field_type"
UNKNOWN_REFERENCE = "unknown_reference"
CANON_CONFLICT = "canon_conflict"
SELF_CONTRADICTION = "self_contradiction"
DUPLICATE_ENTITY = "duplicate_entity"
# How many events one proposal may carry. A narration describes a turn, not a
# migration; a hundred events is a runaway model or a payload trying to be
# something else, and either way the cap bounds the work before it is done.
MAX_EVENTS = 40
# How long a text field may be. Long enough for a description, short enough that
# a proposal cannot smuggle a document into the state.
MAX_TEXT = 2_000
MAX_LABELS = 40
class Rejection:
"""One event that will not be applied, and why."""
__slots__ = ("event", "reason", "detail")
def __init__(self, event, reason: str, detail: str = ""):
self.event = event
self.reason = reason
self.detail = detail
def as_dict(self) -> dict:
return {"event": self.event, "reason": self.reason, "detail": self.detail}
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Rejection {self.reason}: {self.detail}>"
class Review:
"""The verdict on one proposal."""
__slots__ = ("accepted", "rejected")
def __init__(self, accepted: list[dict], rejected: list[Rejection]):
self.accepted = accepted
self.rejected = rejected
@property
def status(self) -> str:
"""`DATA-MODEL.md` §19's validation_status."""
if self.rejected and self.accepted:
return "partially_accepted"
if self.rejected:
return "rejected"
return "accepted"
def as_dict(self) -> dict:
return {
"status": self.status,
"accepted": self.accepted,
"rejected": [r.as_dict() for r in self.rejected],
}
def review(payload, state: dict, canon: dict | None = None) -> Review:
"""Returns which of `payload`'s events may be applied to `state`.
`state` is the document the events would apply to, needed because
referential checks ask what already exists. `canon` carries the campaign's
own rules, which outrank anything a narration proposes (C01).
The state is **not** mutated. Events are checked against a running view that
accounts for entities earlier events in the same proposal create, so a
proposal may introduce Mara and then move her, but nothing is written until
the caller applies the accepted list.
"""
accepted: list[dict] = []
rejected: list[Rejection] = []
proposed = _events_of(payload)
if proposed is None:
return Review([], [Rejection(payload, NOT_AN_OBJECT,
"the proposal is not an object with an event list")])
# Entities this proposal has introduced, so a later event in the same
# proposal may refer to them. Kept separately from `state` so that a
# rejected create cannot make a later reference resolve.
introduced: set[str] = set()
for raw in proposed[:MAX_EVENTS]:
problem = _check(raw, state, introduced, canon)
if problem is not None:
rejected.append(problem)
continue
accepted.append(raw)
spec = events.spec(raw["type"])
if spec and spec["creates"]:
introduced.add(str(raw[spec["creates"]]))
for extra in proposed[MAX_EVENTS:]:
rejected.append(Rejection(extra, BAD_FIELD_TYPE,
f"more than {MAX_EVENTS} events in one proposal"))
return Review(accepted, rejected)
def _events_of(payload) -> list | None:
"""The event list, from either shape a proposal may legitimately take."""
if isinstance(payload, list):
return [e for e in payload]
if not isinstance(payload, dict):
return None
found = payload.get("events")
if found is None:
return []
if not isinstance(found, list):
return None
return found
def _check(raw, state: dict, introduced: set[str], canon: dict | None) -> Rejection | None:
"""Returns why `raw` is unacceptable, or None if it may be applied."""
# ---- layer 1: is it an event-shaped object at all ----
if not isinstance(raw, dict):
return Rejection(raw, NOT_AN_OBJECT, "event is not an object")
# ---- layer 2: the allowlist, before any field is read ----
#
# H05 lands here. `execute_shell` is refused because it is not in the
# vocabulary, and its `command` field is never looked at — there is no
# branch in this application that could reach it.
event_type = raw.get("type", raw.get("event_type"))
if not events.is_allowed(event_type):
return Rejection(raw, UNKNOWN_TYPE, f"{event_type!r} is not a state event")
raw["type"] = event_type
spec = events.spec(event_type)
# ---- layer 3: schema ----
for field, kind in spec["required"].items():
if field not in raw:
return Rejection(raw, MISSING_FIELD, f"{event_type} needs {field!r}")
bad = _bad_shape(raw[field], kind, field)
if bad:
return Rejection(raw, BAD_FIELD_TYPE, bad)
for field, kind in spec["optional"].items():
if field in raw and raw[field] is not None:
bad = _bad_shape(raw[field], kind, field)
if bad:
return Rejection(raw, BAD_FIELD_TYPE, bad)
# ---- layer 4: referential integrity ----
known = set(state.get("entities") or {}) | introduced
for field in spec["refs"]:
named = raw.get(field)
if named is None or field not in raw:
continue # optional reference, absent
if not isinstance(named, str) or named not in known:
return Rejection(raw, UNKNOWN_REFERENCE,
f"{event_type} names {field}={named!r}, which does not exist")
if event_type == "add_fact" and raw.get("object") is not None:
# An object may name an entity or another fact. Checking both keeps the
# reference meaningful — a typo is still caught — without forcing every
# thing a fact can be about to be promoted to an entity first.
known_facts = {f.get("id") for f in (state.get("facts") or [])}
target = raw["object"]
if not isinstance(target, str) or (target not in known and target not in known_facts):
return Rejection(raw, UNKNOWN_REFERENCE,
f"add_fact names object={target!r}, which does not exist")
if event_type == "invalidate_fact":
if not any(f.get("id") == raw["fact_id"] for f in (state.get("facts") or [])):
return Rejection(raw, UNKNOWN_REFERENCE,
f"no fact {raw['fact_id']!r} to invalidate")
if event_type == "resolve_story_thread":
if raw["thread"] not in (state.get("threads") or {}):
return Rejection(raw, UNKNOWN_REFERENCE,
f"no story thread {raw['thread']!r} to resolve")
if spec["creates"]:
key = raw[spec["creates"]]
if key in known:
return Rejection(raw, DUPLICATE_ENTITY,
f"{key!r} already exists; use set_* to change it")
# ---- layer 5: semantics ----
return _semantic(raw, state, canon)
def _bad_shape(value, kind: str, field: str) -> str | None:
"""Returns why `value` is the wrong shape for `kind`, or None."""
if kind in (events.TEXT, events.KEY):
if not isinstance(value, str) or not value.strip():
return f"{field!r} must be a non-empty string"
if len(value) > MAX_TEXT:
return f"{field!r} is longer than {MAX_TEXT} characters"
return None
if kind == events.VALUE:
# A scalar. Explicitly not a dict or a list: a nested payload is how a
# value field becomes somewhere to hide a second protocol.
if not isinstance(value, (str, int, float, bool)) and value is not None:
return f"{field!r} must be a plain value, not a structure"
if isinstance(value, str) and len(value) > MAX_TEXT:
return f"{field!r} is longer than {MAX_TEXT} characters"
return None
if kind == events.LABELS:
if not isinstance(value, list):
return f"{field!r} must be a list"
if len(value) > MAX_LABELS:
return f"{field!r} has more than {MAX_LABELS} entries"
for item in value:
if not isinstance(item, str) or not item.strip():
return f"{field!r} must contain only non-empty strings"
if len(item) > MAX_TEXT:
return f"{field!r} contains an over-long entry"
return None
return f"{field!r} has an unknown field kind" # pragma: no cover
def _semantic(raw: dict, state: dict, canon: dict | None) -> Rejection | None:
"""Deterministic checks the application can actually make.
Deliberately modest. ADR 010 is explicit that typed events do not make a
model correct, and pretending arbitrary fiction can be validated would be
worse than admitting it cannot: it would produce confident rejections of
perfectly good story. So this refuses only what the application *knows* is
wrong — a self-contradiction, or a collision with a rule the campaign wrote
down.
"""
event_type = raw["type"]
# An entity cannot hold itself, and cannot be in itself.
if event_type == "set_possession" and raw["item"] == raw["owner"]:
return Rejection(raw, SELF_CONTRADICTION, "an item cannot possess itself")
if event_type == "set_current_location" and raw["entity"] == raw["location"]:
return Rejection(raw, SELF_CONTRADICTION, "an entity cannot be inside itself")
if event_type in ("add_relationship", "end_relationship") and raw["source"] == raw["target"]:
return Rejection(raw, SELF_CONTRADICTION,
"a relationship needs two different entities")
# C01: campaign canon outranks narration. The rule is generic — a campaign
# declares transitions it forbids, and any event proposing one is refused.
# Nothing here knows what any of those transitions mean; the campaign
# says which it forbids, in data.
conflict = _canon_conflict(raw, state, canon)
if conflict is not None:
return Rejection(raw, CANON_CONFLICT, conflict)
return None
def _canon_conflict(raw: dict, state: dict, canon: dict | None) -> str | None:
"""Whether campaign canon forbids what this event proposes.
Canon is configuration, not code (J03). A campaign writes:
{"forbidden_status_changes": [{"from": "dead", "to": "active"}]}
and a narration that tries to bring a dead character back is refused —
without this module, or any other, containing the word for what that is. A
science-fiction campaign forbidding a different transition uses the same
field and the same code path.
"""
if not isinstance(canon, dict):
return None
if raw["type"] != "set_entity_status":
return None
forbidden = canon.get("forbidden_status_changes")
if not isinstance(forbidden, list):
return None
current = (model.entity(state, raw["entity"]) or {}).get("status")
for rule in forbidden:
if not isinstance(rule, dict):
continue
if rule.get("from") == current and rule.get("to") == raw["status"]:
return (
f"campaign canon does not allow {raw['entity']!r} to go from "
f"{current!r} to {raw['status']!r}"
)
return None