diff --git a/api/app/canon_log.py b/api/app/canon_log.py index bf7e30c..d10de58 100644 --- a/api/app/canon_log.py +++ b/api/app/canon_log.py @@ -38,7 +38,9 @@ def _validator(schema_name: str) -> Draft202012Validator: def validation_errors(doc: dict, schema_name: str) -> list[str]: validator = _validator(schema_name) - return [e.message for e in sorted(validator.iter_errors(doc), key=lambda e: list(e.path))] + # Stringify path segments before sorting: a path can mix str property names + # and int array indices, which are not orderable against each other. + return [e.message for e in sorted(validator.iter_errors(doc), key=lambda e: [str(p) for p in e.path])] def validate_origin(doc: dict) -> list[str]: diff --git a/api/app/main.py b/api/app/main.py index 6ae1412..d04fa7b 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -7,6 +7,8 @@ Prompt routing, model selection, and logging land later. """ from fastapi import Depends, FastAPI, HTTPException +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from pydantic import BaseModel from .canon_log import validate_canon_log @@ -14,6 +16,16 @@ from .canon_log import validate_canon_log app = FastAPI(title="coc-rpg proxy", version="0.0.1") +@app.exception_handler(RequestValidationError) +async def unify_validation_errors(request, exc: RequestValidationError) -> JSONResponse: + """Reshape pydantic body-validation failures into the same envelope as a + canon-log schema failure, so the client parses ONE 422 shape (charter §11): + {"detail": {"canon_log_errors": [...]}}. + """ + errors = [f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in exc.errors()] + return JSONResponse(status_code=422, content={"detail": {"canon_log_errors": errors}}) + + class TurnRequest(BaseModel): canon_log: dict diff --git a/api/pytest.ini b/api/pytest.ini index c7b23ec..c038626 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -1,3 +1,5 @@ [pytest] pythonpath = . testpaths = tests +filterwarnings = + ignore:Using `httpx` with `starlette.testclient` is deprecated diff --git a/api/tests/test_endpoints.py b/api/tests/test_endpoints.py index ee536f2..b7db5ee 100644 --- a/api/tests/test_endpoints.py +++ b/api/tests/test_endpoints.py @@ -36,3 +36,12 @@ def test_every_role_endpoint_rejects_an_invalid_canon_log(): def test_missing_canon_log_is_a_422(): r = client.post("/dm/narrate", json={}) assert r.status_code == 422 + assert "canon_log_errors" in r.json()["detail"] + + +def test_malformed_body_uses_the_unified_error_shape(): + # A pydantic body failure returns the SAME envelope as a schema failure, + # so the client only ever parses one 422 shape. + r = client.post("/dm/narrate", json={"canon_log": "not-a-dict"}) + assert r.status_code == 422 + assert "canon_log_errors" in r.json()["detail"] diff --git a/docs/canon-log.md b/docs/canon-log.md new file mode 100644 index 0000000..b090df6 --- /dev/null +++ b/docs/canon-log.md @@ -0,0 +1,110 @@ +# 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`](schemas/canon-log.schema.json), [`/docs/schemas/origin.schema.json`](schemas/origin.schema.json) +- Design rationale: [`superpowers/specs/2026-07-09-canon-log-schema-design.md`](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: + +```json +{ + "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: + +```json +{ + "schema_version": 1, + "id": "…", "display_name": "…", "description": "…", + "start_location_id": "…", + "situation": ["…"], "opening_facts": ["…"], + "disposition_overrides": { "": -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: + +```json +{ "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: + +```json +HTTP 422 +{ "detail": { "canon_log_errors": ["", "", …] } } +``` + +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 |