Files
code_of_conquest_dnd/docs/canon-log.md
Phillip Tarrant 7f1d9fa39a chore(api): canon log follow-ups — hardening, unified 422, contract doc
- 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>
2026-07-09 13:03:59 -05:00

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.

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