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

300 lines
14 KiB
Python

"""Reading, editing, and deleting individual actions.
`list_actions` pages through the current branch. The edit and delete endpoints
change one node, and deleting one removes the whole attempt group at its
coordinate through `nodes.delete_turn`.
"""
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import attempts, head, memorybank, models, narrative, schemas, tree
from ...context import cursors, lineage
from ...database import get_db
from . import turns
from .deps import CurrentUser, current_adventure, router
from .nodes import db_tip, delete_turn
from .paging import ACTION_PAGE, action_window, annotate_takes
@router.get("/{adventure_id}/actions", response_model=schemas.ActionPage)
def list_actions(
before_id: int | None = None,
limit: int = ACTION_PAGE,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
"""Returns a page of the story, working backwards from the newest action.
`before_id` is the oldest action the caller already holds, so scrolling up
asks for what comes before it. Omit `before_id` for the newest window. See
`action_window` for why this anchors on a row rather than an offset.
"""
limit = max(1, min(limit, ACTION_PAGE * 4))
actions, total, has_more = action_window(
db, adventure, before_id=before_id, limit=limit
)
return schemas.ActionPage(
actions=[
schemas.ActionOut.model_validate(a)
for a in annotate_takes(db, adventure.id, actions)
],
total=total,
has_more=has_more,
# Every page carries them, not just the newest window: the client reads
# the flags off whichever page arrived last, and scrolling up must not
# be able to grey out a Redo that is still available (M3).
can_undo=head.can_undo(db, adventure),
can_redo=head.can_redo(db, adventure),
)
@router.patch("/{adventure_id}/actions/{action_id}", response_model=schemas.ActionOut)
def update_action(
adventure_id: int,
action_id: int,
payload: schemas.ActionUpdate,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# A narrator turn the story is currently telling is corrected through the
# §§14-15 path, which forks. A take the story is *not* telling is a
# different thing: it has no continuation of its own — keeping one is what
# forking is for — so correcting its words cannot contradict anything, and
# it stays the plain in-place edit it has always been.
if action.type == "ai" and lineage.path_of(db, adventure).contains(action):
return _edit_narration(db, adventure, action, payload.text)
# A player's own words. Editing one rewrites this row and re-evaluates
# nothing after it, which is what makes it a correction rather than a new
# continuation. That is safe while everything descending from the row is on
# screen, and unsafe the moment something descends from it that is not — an
# undone future, or a line a divergence left behind. The reader cannot see
# that story, so they cannot see what their correction has just contradicted
# (M3, `STORY-BRANCH-SEMANTICS.md` §13).
if head.displaced_history_under(db, adventure, action):
raise HTTPException(
400,
"This turn has a later story that is not on screen — undone, or "
"left behind by a new continuation. Editing it here would change "
"the words that story was written from. Redo to bring it back "
"first, or play the turn again to start a new line from here.",
)
turns.acquire_turn_lock(adventure_id)
try:
action.text = payload.text
db.commit()
finally:
turns._active_turns.discard(adventure_id)
db.refresh(action)
return action
def _edit_narration(
db: Session, adventure: models.Adventure, action: models.Action, text: str
) -> models.Action:
"""Corrects narrator prose by hand, per `STORY-BRANCH-SEMANTICS.md` §§14-15.
A narrator edit is not a rewrite of a row. It is a continuation written from
the same place the original was written from, using the reader's words
instead of the model's. §15 lists what that has to mean, and each clause
maps to a step below:
1. return to the state immediately before the edited narration — the
preceding node's snapshot, one row read;
2. treat the edited text as the accepted narrator output — it is stored
verbatim, with only the protocol block stripped, and no model is called;
3. re-evaluate the state that output implies — the normal M5 extraction and
validation path, run against that starting state;
4. create a new active continuation — a new node, and the head on it;
5. retain the original narration and its future as disposable history —
nothing on the old line is written to at all.
The M5 review found the previous implementation failing 3-5 together: it
edited the row in place and rewound the campaign's live state to that
position while the head stayed at the tip, so the reader saw a full
transcript over a state document describing an earlier moment, and the
snapshots below the edit still described prose that no longer existed
(Finding 1). Forking is what fixes it, and no new machinery is needed to
fork — this function is the ⑂ path from `takes.py` with the reader's text in
place of a generated one.
Two shapes, chosen by whether anything was written after the turn:
at the tip the attempts of the turn are still leaves, so the
correction joins them as a sibling take and the
original is retained beside it in the pager;
anything below the story after the turn was written as a
continuation of the words that are there now, so it
keeps them: the correction leaves the path just
before the turn and the old line keeps its node, its
future, and its live flag.
The §14A refusal is gone from this path, and this is what replaces it. It
refused an in-place edit under an off-screen future because the edit would
silently change the words that story was written from. Nothing is changed
now — the off-screen future keeps the exact narration it descends from — so
the case that had to be refused is simply handled.
"""
if action.depth is None:
raise HTTPException(400, "That turn is not on the story you are reading.")
turns.acquire_turn_lock(adventure.id)
try:
# §15.2. The reader's words are the narration; a block they pasted in is
# protocol and is stripped before storage, exactly as a model's is.
prose, parsed, raw_block = narrative.extract.split(text)
# §15.1. Not the campaign's current state — the state this turn was
# played from. One row read, not a replay (ADR 012).
before = attempts.preceding(db, adventure, action)
starting_state = (
narrative.model.normalize(before.narrative_state_after)
if before is not None and isinstance(before.narrative_state_after, dict)
else narrative.model.empty()
)
corrected = models.Action(
adventure_id=adventure.id,
type="ai",
text=prose,
# No model was called, so there is no prompt to show for this node.
# In the sibling case the turn's assembled prompt moves to whichever
# attempt is live, which is what the Insights viewer reads; in the
# forked case the original keeps it, because the original is still
# the live node of its own line.
context_snapshot=None,
)
tip = db_tip(db, adventure)
# A turn the head rests on is not a leaf while a retained future
# descends from it, and `db_tip` reads the capped path and cannot see
# that future. Ask the head module as well (M3).
at_the_tip = (
tip is not None
and tip.id == action.id
and not head.behind_tip(db, adventure)
)
if at_the_tip:
# §15.4-5 as a take. The original stays at this coordinate as a
# prior attempt, reachable through the pager, and the correction
# becomes the one the story tells.
attempts.hand_over_the_prompt(action, corrected)
attempts.add_attempt(db, adventure, action, corrected)
db.add(corrected)
# The words at this coordinate changed, so anything derived from
# them no longer describes the story.
memorybank.forget_node(db, adventure, action)
cursors.rewind_all(adventure, action.branch_id, action.depth - 1)
db.flush()
else:
# §15.4-5 as a branch. Nothing on the departed line is written to:
# the original node keeps its text, its live flag and every turn
# that was played after it.
departed = lineage.branch_of(db, adventure)
tree.branch_at(db, adventure, action.depth - 1)
if departed is not None:
head.mark_superseded(departed, action.depth - 1)
tree.place_action(db, adventure, corrected)
db.add(corrected)
db.flush()
# §15.3. The same validation path a generated turn takes, so a hand
# -typed event is no more trusted than a model's: the allowlist, the
# schema, the references and the canon all still apply.
review = narrative.validate.review(
parsed if parsed is not None else {"events": []},
starting_state,
narrative.store.canon_of(adventure),
)
# `record` writes the events and the provenance. Its returned document
# applies them to the campaign's *current* state, which is not what an
# edit derives from, so the document this node leaves behind is computed
# from the turn's own starting point below.
narrative.store.record(
db, adventure,
review=review,
raw_block=raw_block,
parsed=parsed,
action=corrected,
branch_id=corrected.branch_id,
depth=corrected.depth,
source="narrator_edit",
)
new_state = narrative.apply.apply_events(
starting_state, review.accepted,
branch_id=corrected.branch_id, depth=corrected.depth,
source="narrator_edit",
)
corrected.state_changes = {
"accepted": review.accepted,
"rejected": [r.as_dict() for r in review.rejected],
"summary": narrative.apply.diff(starting_state, new_state),
}
# The head is on the corrected node, so the campaign's live state is
# what that node leaves behind, and the node's own snapshot is the same
# document. That equality is the invariant the review found broken:
# visible position == head == authoritative state.
narrative.store.set_current(adventure, new_state)
attempts.snapshot_outcome(adventure, corrected)
adventure.updated_at = models.utcnow()
db.commit()
finally:
turns._active_turns.discard(adventure.id)
db.refresh(corrected)
return corrected
@router.delete("/{adventure_id}/actions/{action_id}", status_code=204)
def delete_action(
adventure_id: int,
action_id: int,
db: Session = Depends(get_db),
adventure: models.Adventure = Depends(current_adventure),
):
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# The lock is held for the same reason undo holds it: this endpoint puts
# the shared state back, and a turn that is still generating is about to
# write it.
turns.acquire_turn_lock(adventure_id)
try:
# This works like undo. The turn is deleted with all of its attempts,
# and whatever it produced is withdrawn. The marks are depths, and a
# depth does not move when an action before it is deleted.
was_at = adventure.head_depth
delete_turn(db, adventure, action)
db.flush()
db.expire(adventure, ["actions"])
# Deleting the newest action moves the tip. Deleting an action in the
# middle leaves a gap in the depths, which is intended. See
# `_backfill_tree`.
tree.refresh_head(db, adventure)
# `refresh_head` recomputes the tip, which since M3 is not the head. A
# story sitting behind its retained tip must not be dragged forward to
# the tip by an unrelated delete — that would silently Redo it. Keep the
# head where the reader left it, unless the delete took the ground out
# from under it, in which case the new tip is as far as it can stay.
if was_at < adventure.head_depth:
adventure.head_depth = was_at
# The script state and the world state belong to the adventure, not to
# the node, so deleting the node does not take back what it did to
# them. Put them back to what the story now ends with, which is the
# same restore a branch switch does.
#
# The world state carries the cooldown clock in `_meta.last_changed`,
# and that clock is a depth. Leaving it set marked the deleted turn's
# changes as having happened at the depth the next turn is played at,
# so the referee refused them as changed too recently — on a turn the
# story no longer contains. Deleting a middle action restores the tip's
# own outcome, which is the state the adventure is already in.
attempts.restore_state(adventure, db_tip(db, adventure))
db.commit()
finally:
turns._active_turns.discard(adventure_id)