Files
JesseMarkowitzandClaude Opus 5 b7005e6fdd 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
2026-09-05 07:01:50 -04:00

273 lines
10 KiB
Python

"""M5: turning accepted events into a new state document.
Pure and total. Every function here takes a document and returns a new one; none
touches the database, and none can fail on an event `validate.review` accepted —
validation is the only place an event is refused, so this module never has to
decide anything twice.
The dispatch is an explicit `if/elif` chain over `events.SPECS`, not a lookup
table keyed on the payload. The difference matters: a table maps a string a model
supplied to a callable, and the security of that arrangement rests entirely on
the allowlist being correct. A chain of literal comparisons cannot be steered by
a payload at all, whatever the allowlist does.
"""
from __future__ import annotations
import copy
from . import model
def apply_events(
state: dict,
accepted: list[dict],
*,
branch_id: int | None = None,
depth: int | None = None,
source: str = "accepted_story",
) -> dict:
"""Returns `state` with every event in `accepted` applied, in order.
The input document is never mutated: head movement stores snapshots by
reference in places, and a mutation here would edit the past.
`branch_id`/`depth` stamp facts and relationships with where they were
established, which is what makes the audit trail answer "which turn caused
this" without a join. `source` records whether the campaign, the story or
the user established it — C04's provenance, carried on the value itself.
"""
document = model.normalize(state)
for event in accepted:
_apply_one(document, event, branch_id, depth, source)
return document
def _apply_one(state: dict, event: dict, branch_id, depth, source: str) -> None:
kind = event["type"]
if kind == "create_entity":
state["entities"][event["entity"]] = model.new_entity(
type=event.get("entity_type") or "other",
name=event["name"],
description=event.get("description") or "",
aliases=event.get("aliases") or [],
)
elif kind == "set_entity_status":
_entity(state, event["entity"])["status"] = event["status"]
elif kind == "set_entity_attribute":
# Absolute assignment. The whole reason ADR 010 exists.
_entity(state, event["entity"])["attributes"][event["attribute"]] = event["value"]
elif kind == "set_entity_conditions":
_entity(state, event["entity"])["conditions"] = list(event["conditions"])
elif kind == "set_current_location":
_entity(state, event["entity"])["location"] = event["location"]
elif kind == "set_possession":
state["possessions"][event["item"]] = event["owner"]
elif kind == "clear_possession":
state["possessions"].pop(event["item"], None)
elif kind == "add_fact":
state["facts"].append({
"id": event.get("fact_id") or _fact_id(state),
"subject": event.get("subject"),
"predicate": event["predicate"],
"object": event.get("object"),
"value": event.get("value"),
"authority": _authority(source),
"source": source,
"status": "active",
"branch_id": branch_id,
"depth": depth,
})
elif kind == "invalidate_fact":
for fact in state["facts"]:
if fact.get("id") == event["fact_id"]:
# Withdrawn, not removed: C04 needs the record of what the
# campaign used to believe, and a deleted row audits nothing.
fact["status"] = "invalidated"
fact["invalidated_by"] = source
fact["invalidated_at"] = {"branch_id": branch_id, "depth": depth}
if event.get("reason"):
fact["invalidated_reason"] = event["reason"]
elif kind == "add_relationship":
state["relationships"].append({
"id": _relationship_id(state),
"source": event["source"],
"target": event["target"],
"type": event["relationship"],
"description": event.get("description") or "",
"status": "active",
"established_by": source,
"branch_id": branch_id,
"depth": depth,
})
elif kind == "end_relationship":
for relationship in state["relationships"]:
if (
relationship.get("source") == event["source"]
and relationship.get("target") == event["target"]
and relationship.get("type") == event["relationship"]
and relationship.get("status") == "active"
):
relationship["status"] = "ended"
relationship["ended_at"] = {"branch_id": branch_id, "depth": depth}
elif kind == "open_story_thread":
state["threads"][event["thread"]] = {
"title": event["title"],
"description": event.get("description") or "",
"status": "open",
"opened_at": {"branch_id": branch_id, "depth": depth},
}
elif kind == "resolve_story_thread":
thread = state["threads"].get(event["thread"])
if isinstance(thread, dict):
thread["status"] = "resolved"
thread["resolution"] = event.get("resolution") or ""
thread["resolved_at"] = {"branch_id": branch_id, "depth": depth}
elif kind == "set_scene":
scene = dict(state.get("scene") or {})
if "summary" in event:
scene["summary"] = event["summary"]
if "location" in event:
scene["location"] = event["location"]
if "present" in event:
scene["present"] = list(event["present"] or [])
scene["at"] = {"branch_id": branch_id, "depth": depth}
state["scene"] = scene
# No `else`. Every allowed type is handled above, and an unhandled one
# cannot arrive: `validate.review` refuses anything outside the allowlist,
# and the allowlist is this list. A silent fall-through would be the one way
# an event could appear accepted and do nothing.
def _entity(state: dict, key: str) -> dict:
"""The entity record for `key`, created bare if a snapshot lost it.
Validation guarantees the entity exists, so this is a repair path for a
hand-edited or partially imported document rather than a normal branch. A
bare record is better than a KeyError: the story is still readable, and the
inspector shows an entity with nothing known about it, which is true.
"""
entities = state["entities"]
found = entities.get(key)
if not isinstance(found, dict):
found = model.new_entity(name=key)
entities[key] = found
found.setdefault("attributes", {})
found.setdefault("conditions", [])
return found
def _authority(source: str) -> str:
"""Which authority band a source's assertions carry.
A user's correction outranks the story (C04); the story outranks a guess.
`DATA-MODEL.md` §14 orders the bands, and this is the mapping into them.
"""
if source == "manual_correction":
return "manual_correction"
if source == "campaign_canon":
return "campaign_canon"
return "accepted_story"
def _fact_id(state: dict) -> str:
return f"f{len(state['facts']) + 1}"
def _relationship_id(state: dict) -> str:
return f"r{len(state['relationships']) + 1}"
def diff(before: dict, after: dict) -> list[str]:
"""A short human-readable list of what changed between two documents.
Shown under a turn the way the world-state chip used to be, and recorded on
the node for the bulk read. Text rather than structure, because its only
consumer is a person reading "Aldric now holds the silver key".
"""
before = model.normalize(before)
after = model.normalize(after)
lines: list[str] = []
for key, entity in after["entities"].items():
was = before["entities"].get(key)
name = model.entity_name(after, key)
if was is None:
lines.append(f"{name} enters the story")
continue
if was.get("status") != entity.get("status"):
lines.append(f"{name} is now {entity.get('status')}")
if was.get("location") != entity.get("location") and entity.get("location"):
lines.append(f"{name} is at {model.entity_name(after, entity['location'])}")
if sorted(was.get("conditions") or []) != sorted(entity.get("conditions") or []):
now = ", ".join(entity.get("conditions") or []) or "nothing"
lines.append(f"{name}: {now}")
for attribute, value in (entity.get("attributes") or {}).items():
if (was.get("attributes") or {}).get(attribute) != value:
lines.append(f"{name} {attribute} = {value}")
for item, owner in after["possessions"].items():
if before["possessions"].get(item) != owner:
lines.append(
f"{model.entity_name(after, item)} → {model.entity_name(after, owner)}"
)
for item in before["possessions"]:
if item not in after["possessions"]:
lines.append(f"{model.entity_name(after, item)} is held by nobody")
known = {f.get("id") for f in before["facts"]}
for fact in after["facts"]:
if fact.get("id") not in known:
lines.append(f"fact: {_fact_text(after, fact)}")
was_active = {f["id"] for f in model.active_facts(before)}
for fact in before["facts"]:
if fact.get("id") in was_active and fact.get("id") not in {
f["id"] for f in model.active_facts(after)
}:
lines.append(f"withdrawn: {_fact_text(after, fact)}")
known = {r.get("id") for r in before["relationships"]}
for relationship in after["relationships"]:
if relationship.get("id") not in known:
lines.append(
f"{model.entity_name(after, relationship['source'])} "
f"{relationship['type']} "
f"{model.entity_name(after, relationship['target'])}"
)
for key, thread in after["threads"].items():
was = before["threads"].get(key)
if was is None:
lines.append(f"opened: {thread.get('title', key)}")
elif was.get("status") != thread.get("status"):
lines.append(f"{thread.get('status')}: {thread.get('title', key)}")
return lines
def _fact_text(state: dict, fact: dict) -> str:
parts = []
if fact.get("subject"):
parts.append(model.entity_name(state, fact["subject"]))
parts.append(str(fact.get("predicate", "")))
if fact.get("object"):
parts.append(model.entity_name(state, fact["object"]))
if fact.get("value") is not None:
parts.append(str(fact["value"]))
return " ".join(p for p in parts if p)