- canon_log.py: stringify jsonschema error-path segments before sorting so a future schema mixing array indices and object keys can't TypeError (#1) - main.py: RequestValidationError handler reshapes pydantic body failures into the same {"detail": {"canon_log_errors": [...]}} envelope as schema failures, so the client parses ONE 422 shape (#2a) + tests - pytest.ini: filter the starlette/httpx TestClient deprecation → pristine output (#4) - docs/canon-log.md: living contract for the client (Plan B) — schemas, the §7 boundary, the unified error envelope, per-role injection (#3) Full suite 26 passing, 0 warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.3 KiB
Canon Log — living contract
The canon log is the compact structured state code maintains and injects into every AI call (charter §11). This is the contract the Godot client and the FastAPI proxy both bind to. The schemas are the single source of truth; this doc is the human-readable companion.
- Schemas (authoritative):
/docs/schemas/canon-log.schema.json,/docs/schemas/origin.schema.json - Design rationale:
superpowers/specs/2026-07-09-canon-log-schema-design.md
The three layers
| Layer | Owner | Where | Mutable? |
|---|---|---|---|
| World content | authored | /content/world (id-referenced) |
no — static per playthrough |
| Origin seed | authored | /content/origins |
no — an initial-state overlay |
| Canon log | code (client) | runtime + save | yes — evolves every turn |
The client constructs the canon log at new-game (origin + world + character creation) and maintains it turn to turn. The proxy never mutates it — it validates every posted log and rejects an invalid one before any prompt work.
Canon log shape
Full field rules live in canon-log.schema.json. Summary:
{
"schema_version": 1,
"player": { "name": "…", "class_id": "sellsword|assassin|priest", "luck_descriptor": "…" },
"location": { "id": "…", "name": "…" },
"party": [ { "id": "…", "name": "…", "disposition": -100..100 } ],
"recent_events": ["… (≤5, rolling)"],
"established_facts": ["… (durable, uncapped)"],
"active_quests": [ { "id": "…", "name": "…", "status": "active|complete|failed", "objective": "…" } ],
"humiliations": [ { "id": "…", "text": "…", "weight": 1..10, "turn": 0.. } ]
}
The §7 boundary (enforced structurally). The schema sets
additionalProperties: false on the root and on player, and player admits
only name, class_id, luck_descriptor. Numeric Luck, stats, HP/MP, and
inventory cannot be represented in the canon log — the AI never sees a number
it could use to calculate Luck. Only luck_descriptor (e.g. "Fortune spits on
you") crosses the boundary.
All string ids match ^[a-z0-9_]+$.
Origin seed shape
Full rules in origin.schema.json. An origin seeds the initial log:
{
"schema_version": 1,
"id": "…", "display_name": "…", "description": "…",
"start_location_id": "…",
"situation": ["…"], "opening_facts": ["…"],
"disposition_overrides": { "<npc_id>": -100..100 },
"inventory_grants": [ { "item_id": "…", "qty": 1.. } ],
"start_quest_id": "…|null",
"build_constraints": { "allowed_classes": ["sellsword",…], "luck_modifier": 0 }
}
Every id an origin references must resolve in world content — the proxy exposes
content.unresolved_refs(origin, world) for that check; humiliations are never
seeded (earned in play).
API contract
The client posts game state to a role endpoint; the body is:
{ "canon_log": { …a canon log… } }
Endpoints (charter §4): POST /dm/narrate, /dm/adjudicate, /dm/improvise,
/npc/speak, /party/banter. Each validates canon_log and, on success,
returns the role's result. (Prompt routing is stubbed for now — a valid log
returns {"detail": "not implemented"}.)
Error shape — ONE envelope for every 422
Whether the log fails the JSON Schema or the request body is malformed at the
pydantic layer (missing/mistyped canon_log), the proxy returns the same
shape, so the client parses one thing:
HTTP 422
{ "detail": { "canon_log_errors": ["<message>", "<message>", …] } }
Client rule: on 422, read detail.canon_log_errors (a list of strings). It is
never the raw pydantic error list. A degraded-DM fallback (charter §13) should
fire on any non-200 rather than surfacing these strings to the player — they are
for logs and development.
Per-role injection
The canon log is the shared base of every call; roles add their own extras (the log never grows per-role fields):
| Role | Log + … |
|---|---|
| Narrator | log only |
| NPC | log + persona, knowledge[], available_moves[] (§6) |
| Improviser | log + the whitelisted event-change set (§7) |
| Banter | log, with humiliations as primary source (§9) |
| Adjudicator | log + the current legal action set |