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
This commit is contained in:
co-authored by
Claude Opus 5
parent
62a997f364
commit
b7005e6fdd
@@ -0,0 +1,194 @@
|
||||
"""M5: the typed event vocabulary, and the allowlist that bounds it.
|
||||
|
||||
ADR 010 replaced AI-DnD's relative-delta protocol because the ambiguity was
|
||||
architectural: a number in a delta field is syntactically legal whether the
|
||||
model meant "add 50" or "set to 50", and no validator can tell which. Every
|
||||
event here therefore states its operation in its `type`, and every value it
|
||||
carries is **absolute**. There is no event whose meaning depends on a prompt
|
||||
instruction having been followed.
|
||||
|
||||
## The allowlist is a security boundary, not a convenience
|
||||
|
||||
Model output is untrusted input (`SECURITY-THREAT-MODEL.md`), and this table is
|
||||
the entire set of things a model may cause to happen. H05's
|
||||
`{"event_type": "execute_shell", ...}` is refused here — not because "shell" is
|
||||
recognised and blocked, but because it is not in `SPECS`, and nothing outside
|
||||
`SPECS` is dispatched. There is no fallback branch, no generic handler and no
|
||||
name-to-callable lookup that a payload could steer.
|
||||
|
||||
Adding an event means adding a spec here and a case in `apply.py`. Nothing else
|
||||
in the application can widen the vocabulary, which is what keeps
|
||||
"state extraction" from drifting into "tool execution".
|
||||
|
||||
## Shape of a spec
|
||||
|
||||
required fields that must be present and non-empty
|
||||
optional fields that may be present
|
||||
refs fields naming an entity that must already exist
|
||||
creates the field naming an entity this event may bring into being
|
||||
|
||||
`refs` is what `validate.py` uses for referential integrity, and `creates` is
|
||||
the deliberate exception: exactly one event type may introduce an entity, so a
|
||||
typo in any other event surfaces as an unknown reference rather than silently
|
||||
creating a second, empty Mara.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Field types the schema layer enforces. Kept deliberately small: a narrative
|
||||
# state event carries names, labels and plain values, and nothing here needs a
|
||||
# nested structure a model could hide something inside.
|
||||
TEXT = "text"
|
||||
KEY = "key" # an entity/thread identifier: a slug the campaign chose
|
||||
VALUE = "value" # a JSON scalar — str, int, float, bool or None
|
||||
LABELS = "labels" # a list of short strings
|
||||
|
||||
#: The whole vocabulary. Nothing outside this mapping is dispatched, ever.
|
||||
SPECS: dict[str, dict] = {
|
||||
"create_entity": {
|
||||
"required": {"entity": KEY, "name": TEXT},
|
||||
"optional": {"entity_type": TEXT, "description": TEXT, "aliases": LABELS},
|
||||
"refs": (),
|
||||
"creates": "entity",
|
||||
"summary": "brings a person, place, thing or group into the story",
|
||||
},
|
||||
"set_entity_status": {
|
||||
"required": {"entity": KEY, "status": TEXT},
|
||||
"optional": {},
|
||||
"refs": ("entity",),
|
||||
"creates": None,
|
||||
"summary": "sets whether an entity is active, gone, destroyed …",
|
||||
},
|
||||
"set_entity_attribute": {
|
||||
# The one numeric-capable event, and it is an assignment. ADR 010's
|
||||
# `set_value`: the operation is in the name, so a value of 50 can only
|
||||
# mean fifty. An `increment_value` could be added later without
|
||||
# ambiguity, because it would be a different `type`.
|
||||
"required": {"entity": KEY, "attribute": TEXT, "value": VALUE},
|
||||
"optional": {},
|
||||
"refs": ("entity",),
|
||||
"creates": None,
|
||||
"summary": "sets a named value on an entity, absolutely",
|
||||
},
|
||||
"set_entity_conditions": {
|
||||
# Absolute too: the full set replaces the old one. "Add a condition"
|
||||
# would need the current set to be known by the model, which is exactly
|
||||
# the assumption that made deltas unreliable.
|
||||
"required": {"entity": KEY, "conditions": LABELS},
|
||||
"optional": {},
|
||||
"refs": ("entity",),
|
||||
"creates": None,
|
||||
"summary": "replaces the conditions an entity is under",
|
||||
},
|
||||
"set_current_location": {
|
||||
"required": {"entity": KEY, "location": KEY},
|
||||
"optional": {},
|
||||
"refs": ("entity", "location"),
|
||||
"creates": None,
|
||||
"summary": "moves an entity to a location",
|
||||
},
|
||||
"set_possession": {
|
||||
"required": {"item": KEY, "owner": KEY},
|
||||
"optional": {},
|
||||
"refs": ("item", "owner"),
|
||||
"creates": None,
|
||||
"summary": "gives an item to an owner",
|
||||
},
|
||||
"clear_possession": {
|
||||
"required": {"item": KEY},
|
||||
"optional": {},
|
||||
"refs": ("item",),
|
||||
"creates": None,
|
||||
"summary": "leaves an item held by nobody",
|
||||
},
|
||||
"add_fact": {
|
||||
"required": {"predicate": TEXT},
|
||||
"optional": {
|
||||
"subject": KEY, "object": KEY, "value": VALUE, "fact_id": TEXT,
|
||||
},
|
||||
# Only the subject is checked as an entity. The *object* of a fact is
|
||||
# routinely not one — "Mara knows where the key was found" has another
|
||||
# fact as its object, and C03 needs exactly that — so it is checked
|
||||
# against entities *and* known facts in `validate._check`. Requiring an
|
||||
# entity here would make the knowledge distinction C03 asks for
|
||||
# unrepresentable.
|
||||
"refs": ("subject",),
|
||||
"creates": None,
|
||||
"summary": "asserts something about the world",
|
||||
},
|
||||
"invalidate_fact": {
|
||||
"required": {"fact_id": TEXT},
|
||||
"optional": {"reason": TEXT},
|
||||
"refs": (),
|
||||
"creates": None,
|
||||
"summary": "withdraws a fact without deleting the record of it",
|
||||
},
|
||||
"add_relationship": {
|
||||
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
|
||||
"optional": {"description": TEXT},
|
||||
"refs": ("source", "target"),
|
||||
"creates": None,
|
||||
"summary": "ties two entities together",
|
||||
},
|
||||
"end_relationship": {
|
||||
"required": {"source": KEY, "target": KEY, "relationship": TEXT},
|
||||
"optional": {},
|
||||
"refs": ("source", "target"),
|
||||
"creates": None,
|
||||
"summary": "ends a tie without erasing that it existed",
|
||||
},
|
||||
"open_story_thread": {
|
||||
"required": {"thread": KEY, "title": TEXT},
|
||||
"optional": {"description": TEXT},
|
||||
"refs": (),
|
||||
"creates": None,
|
||||
"summary": "records narrative business left open",
|
||||
},
|
||||
"resolve_story_thread": {
|
||||
"required": {"thread": KEY},
|
||||
"optional": {"resolution": TEXT},
|
||||
"refs": (),
|
||||
"creates": None,
|
||||
"summary": "closes narrative business",
|
||||
},
|
||||
"set_scene": {
|
||||
"required": {},
|
||||
"optional": {"summary": TEXT, "location": KEY, "present": LABELS},
|
||||
"refs": ("location",),
|
||||
"creates": None,
|
||||
"summary": "records the immediate situation",
|
||||
},
|
||||
}
|
||||
|
||||
#: The allowlist itself, as a set, for the one question that matters most.
|
||||
ALLOWED = frozenset(SPECS)
|
||||
|
||||
|
||||
def is_allowed(event_type) -> bool:
|
||||
"""Whether `event_type` names an event this application will ever apply.
|
||||
|
||||
A string is required: a dict, a list or None is not a type, and coercing one
|
||||
with `str()` would turn a malformed payload into a lookup that might
|
||||
accidentally succeed.
|
||||
"""
|
||||
return isinstance(event_type, str) and event_type in ALLOWED
|
||||
|
||||
|
||||
def spec(event_type: str) -> dict | None:
|
||||
return SPECS.get(event_type)
|
||||
|
||||
|
||||
def vocabulary_for_prompt() -> str:
|
||||
"""The event list as the narrator prompt describes it.
|
||||
|
||||
Generated from `SPECS` rather than written out beside it, so the model can
|
||||
never be told about an event the application does not implement — the drift
|
||||
that would produce proposals rejected for reasons nobody could see.
|
||||
"""
|
||||
lines = []
|
||||
for name, definition in SPECS.items():
|
||||
fields = list(definition["required"]) + [
|
||||
f"{field}?" for field in definition["optional"]
|
||||
]
|
||||
lines.append(f' {name}({", ".join(fields)}) — {definition["summary"]}')
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user