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
174 lines
7.0 KiB
Python
174 lines
7.0 KiB
Python
"""M5: reading the authoritative narrative state, and correcting it by hand.
|
|
|
|
Three endpoints, and the split between them is the point:
|
|
|
|
GET /state what the campaign currently believes
|
|
POST /state/corrections the user overruling it (C04)
|
|
GET /state/events how it came to believe that (§8's audit)
|
|
|
|
The browser reads the first and writes the second. It never writes state
|
|
directly — `BUILD-MILESTONES.md` M5 is explicit that the browser is a
|
|
presentation layer and must not become the owner of state — so a correction goes
|
|
through the same validator, the same applier and the same event log as a
|
|
narration does. The only difference is the `source` recorded on it, and that
|
|
difference is the whole of C04's audit requirement.
|
|
|
|
The state returned here is always the state at the **active head**, because that
|
|
is what `adventure.narrative_state` holds: head movement restores it from the
|
|
destination node's snapshot, so an undone story is described by what was true
|
|
then rather than by what the campaign later became.
|
|
"""
|
|
|
|
from fastapi import Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ... import head, models, narrative, schemas
|
|
from ...database import get_db
|
|
|
|
from . import turns
|
|
from .deps import current_adventure, router
|
|
|
|
|
|
@router.get("/{adventure_id}/state", response_model=schemas.NarrativeStateOut)
|
|
def read_state(
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""The authoritative state at the position the story is being read at.
|
|
|
|
Grouped for display, with only the categories that actually hold something —
|
|
a heading with no rows under it tells a reader nothing, and the panel should
|
|
not have to decide what to hide.
|
|
"""
|
|
state = narrative.store.current(adventure)
|
|
view = narrative.render.for_inspector(state)
|
|
return schemas.NarrativeStateOut(
|
|
groups=[schemas.StateGroup(**group) for group in view["groups"]],
|
|
empty=view["empty"],
|
|
# The raw document, for the correction form to name a key with and for a
|
|
# test to assert on without parsing prose.
|
|
document=state,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{adventure_id}/state/corrections",
|
|
response_model=schemas.NarrativeStateOut,
|
|
status_code=201,
|
|
)
|
|
def correct_state(
|
|
adventure_id: int,
|
|
payload: schemas.StateCorrection,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Applies the user's own state events, as an explicit correction.
|
|
|
|
C04. The user says "Mara never learned where the silver key was found", and
|
|
that becomes authoritative for everything that follows — while the transcript
|
|
stays exactly as it was written. Correcting the world is not editing the
|
|
story, and conflating them would rewrite prose the user did not ask to
|
|
change.
|
|
|
|
The events go through the **same validator** as a narration's. A user is
|
|
trusted more than a model, but not with references that do not resolve or
|
|
with an event type the application does not implement: a typo should be a
|
|
clear refusal, not a corrupt document. What being trusted buys is authority —
|
|
the resulting facts carry `manual_correction`, which outranks
|
|
`accepted_story` when the two disagree, and which the prompt renders so the
|
|
model is told the reader overruled it.
|
|
|
|
Held under the turn lock, for the reason creating a Save Point is: this reads
|
|
the head and writes a snapshot onto the node the head rests on, and a turn in
|
|
flight is about to move both.
|
|
"""
|
|
if not payload.events:
|
|
raise HTTPException(400, "A correction needs at least one change.")
|
|
|
|
turns.acquire_turn_lock(adventure_id)
|
|
try:
|
|
state = narrative.store.current(adventure)
|
|
review = narrative.validate.review(
|
|
{"events": [event.model_dump(exclude_none=True) for event in payload.events]},
|
|
state,
|
|
narrative.store.canon_of(adventure),
|
|
)
|
|
if not review.accepted:
|
|
raise HTTPException(400, _refusal_message(review))
|
|
|
|
node = head.node_at(db, adventure, adventure.head_depth)
|
|
new_state, _proposal = narrative.store.record(
|
|
db, adventure,
|
|
review=review,
|
|
raw_block=payload.note or "",
|
|
parsed={"events": [e.model_dump(exclude_none=True) for e in payload.events]},
|
|
action=node,
|
|
branch_id=node.branch_id if node is not None else adventure.head_branch_id,
|
|
depth=node.depth if node is not None else adventure.head_depth,
|
|
source="manual_correction",
|
|
)
|
|
narrative.store.set_current(adventure, new_state)
|
|
# The correction belongs to the position it was made at, so a later Undo
|
|
# past it drops it and a Redo back brings it again — the same rule every
|
|
# other state change follows. Without re-snapshotting the node, the
|
|
# correction would survive a head movement that stepped over it.
|
|
if node is not None:
|
|
node.narrative_state_after = new_state
|
|
adventure.updated_at = models.utcnow()
|
|
db.commit()
|
|
db.refresh(adventure)
|
|
finally:
|
|
turns._active_turns.discard(adventure_id)
|
|
|
|
view = narrative.render.for_inspector(narrative.store.current(adventure))
|
|
return schemas.NarrativeStateOut(
|
|
groups=[schemas.StateGroup(**group) for group in view["groups"]],
|
|
empty=view["empty"],
|
|
document=narrative.store.current(adventure),
|
|
)
|
|
|
|
|
|
def _refusal_message(review) -> str:
|
|
"""Why a correction was refused, in the words the user needs.
|
|
|
|
The first rejection's detail, because a correction is usually one or two
|
|
events and a wall of them helps nobody.
|
|
"""
|
|
if review.rejected:
|
|
first = review.rejected[0]
|
|
return f"That correction can't be applied — {first.detail or first.reason}."
|
|
return "That correction can't be applied."
|
|
|
|
|
|
@router.get("/{adventure_id}/state/events", response_model=list[schemas.StateEventOut])
|
|
def read_state_events(
|
|
limit: int = 100,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""The accepted state changes, newest first: §8's audit trail.
|
|
|
|
What changed, which turn caused it, whether the model or the user asserted
|
|
it, and what the value was before. Bounded by default — this is an audit
|
|
view, and an unbounded read of a long campaign's every event is the query
|
|
shape this project keeps a regression test about.
|
|
"""
|
|
limit = max(1, min(limit, 500))
|
|
rows = narrative.store.history(db, adventure, limit=limit)
|
|
return [
|
|
schemas.StateEventOut(
|
|
id=row.id,
|
|
action_id=row.action_id,
|
|
branch_id=row.branch_id,
|
|
depth=row.depth,
|
|
turn=(row.depth + 1) if row.depth is not None else None,
|
|
sequence=row.sequence,
|
|
event_type=row.event_type,
|
|
payload=row.payload or {},
|
|
before=row.before,
|
|
source=row.source,
|
|
created_at=row.created_at,
|
|
)
|
|
for row in rows
|
|
]
|