Files
code_of_conquest_dnd/docs/canon-log.md
Phillip Tarrant a9357fb787 fix(creation-model): migrate stray class_id/allowed_classes copies
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
2026-07-12 20:58:49 -05:00

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.

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