Compare commits

...

2 Commits

Author SHA1 Message Date
272995fb6c Merge canon log follow-ups
Sort-key hardening, unified 422 error envelope, starlette warning filter, and
the docs/canon-log.md living contract for the Plan B client.
2026-07-09 13:09:32 -05:00
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
5 changed files with 136 additions and 1 deletions

View File

@@ -38,7 +38,9 @@ def _validator(schema_name: str) -> Draft202012Validator:
def validation_errors(doc: dict, schema_name: str) -> list[str]: def validation_errors(doc: dict, schema_name: str) -> list[str]:
validator = _validator(schema_name) 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]: def validate_origin(doc: dict) -> list[str]:

View File

@@ -7,6 +7,8 @@ Prompt routing, model selection, and logging land later.
""" """
from fastapi import Depends, FastAPI, HTTPException from fastapi import Depends, FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from .canon_log import validate_canon_log 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 = 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): class TurnRequest(BaseModel):
canon_log: dict canon_log: dict

View File

@@ -1,3 +1,5 @@
[pytest] [pytest]
pythonpath = . pythonpath = .
testpaths = tests testpaths = tests
filterwarnings =
ignore:Using `httpx` with `starlette.testclient` is deprecated

View File

@@ -36,3 +36,12 @@ def test_every_role_endpoint_rejects_an_invalid_canon_log():
def test_missing_canon_log_is_a_422(): def test_missing_canon_log_is_a_422():
r = client.post("/dm/narrate", json={}) r = client.post("/dm/narrate", json={})
assert r.status_code == 422 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"]

110
docs/canon-log.md Normal file
View File

@@ -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": { "<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:
```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": ["<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 |