"""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)