The canon-log schema and NewGame code already migrated to race_id/ calling_id + allowed_callings, but two copies of the roster outside that schema were missed, and no parity test guards them: - docs/canon-log.md (the cross-boundary contract doc) still documented the OLD player shape and allowed_classes. Anyone building a player block from the doc would get a 422. - .claude/skills/world-building's schema reference still emitted allowed_classes with two dead calling ids (assassin, priest). Author a new origin with that skill and it fails origin.schema.json (additionalProperties: false, allowed_callings required) AND, if that ever loosened, silently allows zero callings at runtime. Also: - add schema tests rejecting a dead class_id field and an unknown calling_id (priest) - guard NewGame._validate's container types (spend/skills) so a malformed JSON round-trip (null spend, string skills) produces a front-loaded error list instead of a GDScript runtime crash - extend the no-mechanics-in-content test to db.races, not just db.callings - rewrite the roadmap's M4 bullet: the two contract migrations landed; only the creation screen and title->creation->shell flow remain client: 250/250. api: 74 passed, 2 skipped. content_build --check: clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
4.7 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": "…", "race_id": "human|elf|dwarf|beastfolk", "calling_id": "sellsword|reaver|cutpurse|trapper|hedge_mage|bonesetter|bloodsworn", "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, race_id, calling_id, luck_descriptor. race_id and
calling_id are there so the Narrator and NPCs can describe the player (a
"beastfolk cutpurse" reads different scene text than a "dwarf bonesetter") —
they are identity for prose, not mechanics. Numeric Luck, stats, HP/MP, and
inventory cannot be represented in the canon log — the mechanical sheet stays
in GameState and never reaches the log, and 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_callings": ["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 |