Files
interactive-story/planning/DECISIONS/013-authoritative-narrative-state-document.md
JesseMarkowitzandClaude Opus 5 d63804f22e v1.1: harden context window and narrator protocol boundary
WP-A1 and WP-A2, implemented in sequence, plus the corrective work the owner
asked for at review. Reported in
planning/reports/v1.1/V1.1-WP-A1-A2-REPORT.md (corrective addendum §R).
Planning package v4.2.

WP-A1: context-window safety reserve
- The prompt leaves max(256, ceil(5% of the effective window)) tokens free
  beside the reply. That is 256 at 4,096 and 820 at 16,384. The value is fixed,
  not a setting, and not calibrated per model.
- M6's 64-token margin is gone. Separators and the chat hint are priced
  exactly; tokenizer drift is the reserve's job.
- Protected context that cannot fit raises ContextOverflow before the model
  is called.
- Streams set stream_options.include_usage. Measured on Ollama 0.33, a stream
  sent no usage without it.
- Each sent turn records fits, exceeded, truncation_suspected or unknown.
  The status is returned on the done event, logged when bad, and shown in the
  context inspector. The turn is always kept.
- Accounting is per-attempt data (attempts.ATTEMPT_KEYS).
- Corrective: a cold model is loaded before its turn is built. When the
  window is unverified but the server answered, contextwindow.ensure_window
  makes one bounded POST /api/generate naming only the model. It sends no
  prompt, generates nothing and writes nothing. It then probes again, and the
  turn is built to that answer. If the load fails, or the window is still
  unknown, the turn falls back to the old behaviour.
- Real host, 4,096 window:
  - v1 cold turn: sent 13,875, the server read 2,050.
  - Same turn after the correction: the window was verified, 3,082 sent,
    3,097 read, fits, 499 tokens left beside the reply.
  - Verified turns elsewhere left 275-2,297 tokens against v1's 23-42.

WP-A2: protocol echo and genre-neutral state prompting
- The vocabulary is shown as the JSON object the model sends, not as
  name(field, ...). This costs 121 tokens.
- The example uses character-1, item-1 and location-1.
- The extractor removes shapes anchored to application-owned text:
  - a vocabulary call line;
  - an echoed length hint;
  - the renderer's scene line left last;
  - an empty fence opener.
- Corrective R5: the echoed continue hint is recognised by its own sentence
  ("Output only story text"). A Hard-limit-opened bracket is removed only
  directly above an echo already cut from the same reply.
- Replay of all 518 real v1 replies: 9 changed, 0 flagged, and no story prose
  removed. That is unchanged by R5.
- Replay of 64 v1.1 replies: 3 changed, 0 flagged. The depth-16 instruction
  tail is removed.
- Identity diagnostic after the correction:
  - 0 identity signals;
  - 0 prompt example identifiers proposed;
  - 0/10 stored turns with protocol or instruction shapes.
- 50-turn run: 51 accepted, 0 of 54 stored turns carry protocol.
- SPECS, render.py and validate.py are identical to v1.0.0.

Compatibility: a real v1.0.0 database reads identically on v1.0.0 and v1.1,
field for field, with schema and user_version 94 unchanged. Undo, redo, Save
Point restore, export and import all work on it. There is no schema,
migration or bundle-format change.

Verification: the backend suite passes 1,534 with 17 skipped and 0 failed.
The frontend passes 165/165, and lint and the build are clean. The offline
container and the browser regression were re-run on this tree (see §R.3).

One test was re-calibrated, not weakened: test_history_block_trim's prefix
test had assumed which turn holds the floor at a 2,048 budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvegagkhuCZoFPdv4M1egY
2026-09-14 16:35:05 -04:00

135 lines
6.0 KiB
Markdown

# ADR 013 — The Authoritative Narrative State Document
**Status:** Accepted; implemented in M5
**Date:** 2026-09-04
## Context
[ADR 010](010-explicit-typed-narrative-state-events.md) settled the **protocol**:
the model proposes change as explicit, typed, absolute events drawn from a fixed
allowlist, never as relative deltas. It did not settle what those events write
into.
That shape turned out to be load-bearing. The prompt renders it, the browser's
state panel groups it, export carries it, migration has to produce it for
positions that predate it, and every Undo, Redo, take switch and Save Point
restore reads a copy of it. The M5 review recommended recording it as a decision
rather than leaving it an implementation detail of `narrative/model.py`. This ADR
records what was built; it does not extend it.
## Decision
### The document
One JSON document is the campaign's authoritative account of its own story. It
is genre-neutral: nothing in it names a stat, a level, a currency or a class.
```text
entities who and what exists — people, places, things, groups.
Each carries a name, a type, aliases, a description, a status,
free-form attributes, conditions, and a current location.
facts what has been established, as subject/predicate/object.
Each carries an id, a status, and where it came from.
relationships how entities stand to one another, directionally.
possessions which entity holds which item.
threads open story threads, with a title and a status.
scene where the story is now, and a short summary of the moment.
```
Two fields appear throughout and are the reason the document can be trusted:
- **authority** — who established this: the story, or the reader's own
correction. A reader's correction outranks the narration, and the prompt says
so in words.
- **provenance** — the branch and depth the change was made at, so a fact can be
traced to the moment it entered the story.
Nothing is deleted. A fact a correction takes back is marked `invalidated`, with
the reason and the position, because a record that vanished would audit nothing.
### The pipeline
```text
model output
-> extraction the protocol block is separated from the prose
-> validation envelope, allowlist, schema, references, canon
-> validated events recorded in `state_events`, with their provenance
-> the document applied to produce the new authoritative state
-> per-position snapshot stored on the node the turn produced
```
Each stage has one job, and the order is deliberate: the allowlist is checked
before any field is read, so an unknown event type is rejected before its
contents are touched.
**Implementation note (post-M11).** "The protocol block is separated from the
prose" covers more than one fenced block. A small local model also pastes the
rendered state into its prose, writes its proposal unfenced or quoted, and runs
out of tokens partway through it. All of that is removed before the prose is
stored, because stored prose is replayed as history. `TECHNICAL-DESIGN.md` §15.4
has the rules.
**Implementation note (v1.1 WP-A2).** A small model also copies the protocol's
*instructions*: the vocabulary written as calls, the length hint, and the scene
line. The fix is on both sides:
- **Prompt:** the vocabulary is shown in the wire format, and the fixed example
uses genre-neutral placeholders.
- **Extractor:** it recognises those echoes only by strings and names the
application owns.
No event type, field, validation rule or proposal record changed.
`TECHNICAL-DESIGN.md` §15.4 lists the four rules.
### Where it lives
- `adventures.narrative_state` — the current authoritative document. This is
what the narrator is told and what the reader is shown.
- `actions.narrative_state_after` — the whole document as it stood after that
position played, on **every** node.
- `state_events` / `state_proposals` — the audit: what was proposed, what was
accepted, what was refused and why.
### What each is for
**Event history is audit and provenance, not a source of truth.** Restoring a
position never replays it. This was proven by renaming the table away
mid-campaign: Undo and Redo continued to work.
**Snapshots are what make restore bounded.** Arriving at a position is a single
row read whose cost does not grow with the length of the story (ADR 012 §10.4).
A position without a snapshot is a position the head cannot be restored to, so
every node has one — including nodes written before M5, which migration 88
backfills with the empty document. A missing snapshot restores the empty
document rather than leaving the previous position's state standing.
**The document is the authority; the model only proposes.** Every event, whether
it came from the model or from a reader's correction, passes the same
validation. The model cannot write a field directly, cannot invent an event
type, and cannot refer to an entity that does not exist in this campaign.
## The invariant
```text
visible active transcript position == stored head == authoritative state
```
Everything above exists to hold this. The M5 review found two ways it had been
broken — a narrator edit that rewound live state while the head stayed at the
tip, and a restore to a migrated position that left a later position's state
standing — and both were fixed by making the rule absolute rather than by adding
a special case.
## Consequences
- The document is larger than the RPG dict it replaced, so both it and the
per-node snapshots are stored compressed.
- Genre lives in the campaign's canon and in the words the story uses, never in
the schema. A science-fiction campaign and a fantasy one produce the same
shapes.
- A reader's correction is durable and outranks narration, and the prompt states
both what holds and what has been withdrawn.
- Export carries the document, the canon and every snapshot. It does **not**
yet carry `state_events` / `state_proposals`, so an imported campaign keeps a
correction's effect but not its audit trail. Deferred to M9.