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

196 lines
7.0 KiB
Python

"""M5: writing accepted state, atomically with the turn that caused it.
This is the only module in the package that touches the database, and the only
place authoritative narrative state is written.
## The atomicity rule (L01)
Everything a turn establishes goes in one transaction: the narration, the head
movement, the accepted events, the resulting snapshot, and the provenance. This
function *adds* to the caller's session and never commits — the turn engine's
single `db.commit()` remains the one commit point, so a failure anywhere before
it rolls the whole turn back rather than leaving narration accepted with half its
state written.
That ordering is deliberate and load-bearing. `L01` forbids a head position that
implies an accepted reply whose state commit did not complete, and the cheapest
way to guarantee that is to never have two commits to get out of step.
## What is not here
No reconstruction. Nothing in this module reads `state_events` to rebuild a
document — the snapshot on the node is the restore path
(`TECHNICAL-DESIGN.md` §10.4). The events are the audit trail, and an audit
trail that the system depends on for correctness stops being an audit trail and
becomes a replay engine.
"""
from __future__ import annotations
import copy
from sqlalchemy.orm import Session
from .. import models
from . import apply as apply_module
from . import model
def current(adventure: models.Adventure) -> dict:
"""The campaign's authoritative state right now, as a document.
Normalised on the way out, so every caller gets the same shape whatever a
hand-edited row or an older snapshot contains.
"""
return model.normalize(adventure.narrative_state)
def set_current(adventure: models.Adventure, state: dict) -> None:
adventure.narrative_state = model.normalize(state)
def canon_of(adventure: models.Adventure) -> dict:
"""The campaign's own rules, which outrank anything a narration proposes.
Configuration rather than code (C01, J03): the campaign says what it forbids,
and `validate` enforces it without knowing what the rule means.
"""
canon = adventure.campaign_canon
return canon if isinstance(canon, dict) else {}
def record(
db: Session,
adventure: models.Adventure,
*,
review,
raw_block: str = "",
parsed=None,
action: models.Action | None = None,
branch_id: int | None = None,
depth: int | None = None,
model_name: str = "",
source: str = "accepted_story",
) -> tuple[dict, models.StateProposal]:
"""Applies a reviewed proposal and records everything about it.
Returns `(new_state, proposal_row)`. The caller is responsible for putting
the new state where it belongs — on the campaign, and on the node's snapshot
— because only the caller knows whether this is a turn, a retry or a
correction.
Nothing is committed here. See the module docstring.
"""
before = current(adventure)
after = apply_module.apply_events(
before, review.accepted, branch_id=branch_id, depth=depth, source=source
)
proposal = models.StateProposal(
adventure_id=adventure.id,
action_id=action.id if action is not None else None,
branch_id=branch_id,
depth=depth,
model_name=model_name or "",
source=source,
status=review.status,
raw_output=raw_block or "",
detail={
"parsed": parsed,
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
},
)
db.add(proposal)
# The proposal needs an id before its events can point at it, and the
# session does not autoflush. This is a flush, not a commit: still one
# transaction, still all-or-nothing.
db.flush()
for sequence, event in enumerate(review.accepted):
db.add(models.StateEvent(
adventure_id=adventure.id,
proposal_id=proposal.id,
action_id=action.id if action is not None else None,
branch_id=branch_id,
depth=depth,
sequence=sequence,
event_type=event.get("type", ""),
payload=copy.deepcopy(event),
before=_before_value(before, event),
source=source,
))
return after, proposal
def _before_value(state: dict, event: dict) -> dict | None:
"""What the value this event changes was, immediately beforehand.
Recorded per event so §8's "what was the previous value" is answerable
without replaying anything. Only the slice the event touches: a whole
document per event would duplicate the snapshot for no extra answer.
"""
kind = event.get("type")
if kind in ("set_entity_status", "set_entity_attribute",
"set_entity_conditions", "set_current_location"):
entity = model.entity(state, event.get("entity", ""))
if entity is None:
return None
if kind == "set_entity_status":
return {"status": entity.get("status")}
if kind == "set_entity_attribute":
attribute = event.get("attribute")
return {"attribute": attribute,
"value": (entity.get("attributes") or {}).get(attribute)}
if kind == "set_entity_conditions":
return {"conditions": list(entity.get("conditions") or [])}
return {"location": entity.get("location")}
if kind in ("set_possession", "clear_possession"):
return {"owner": model.owner_of(state, event.get("item", ""))}
if kind == "invalidate_fact":
for fact in state.get("facts") or []:
if fact.get("id") == event.get("fact_id"):
return {"status": fact.get("status"), "predicate": fact.get("predicate")}
return None
if kind == "resolve_story_thread":
thread = (state.get("threads") or {}).get(event.get("thread", ""))
return {"status": thread.get("status")} if isinstance(thread, dict) else None
if kind == "end_relationship":
return {"status": "active"}
return None
# ------------------------------------------------------------------ reading
def events_for(
db: Session, adventure: models.Adventure, action_id: int
) -> list[models.StateEvent]:
"""The accepted events one node's narration produced, in order."""
return (
db.query(models.StateEvent)
.filter(
models.StateEvent.adventure_id == adventure.id,
models.StateEvent.action_id == action_id,
)
.order_by(models.StateEvent.sequence, models.StateEvent.id)
.all()
)
def history(
db: Session, adventure: models.Adventure, limit: int = 200
) -> list[models.StateEvent]:
"""The campaign's accepted state events, newest first.
Bounded by default: this is an audit view, and an unbounded read of a long
campaign's every event is the kind of query this project keeps a regression
test about.
"""
return (
db.query(models.StateEvent)
.filter(models.StateEvent.adventure_id == adventure.id)
.order_by(models.StateEvent.id.desc())
.limit(limit)
.all()
)