Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-09-canon-log-contract-and-api-validation.md
Phillip Tarrant 87c5748295 docs: implementation plan for canon log contract + api validation
Plan A of two: the JSON Schema contract, authored POC fixtures (deserter
origin + minimal world), and the api's runtime validation of every canon log
(422 on invalid, enforcing the §7 luck boundary). Five TDD tasks, all runnable
with pytest + Docker in-repo. Plan B (GDScript client construction/maintenance)
deferred until we're in Godot.

Plan: docs/superpowers/plans/2026-07-09-canon-log-contract-and-api-validation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:18:41 -05:00

1021 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Canon Log Contract & API Validation — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the canon log + origin JSON Schema contract, minimal authored fixtures, and the api's runtime validation of every incoming canon log — the enforceable half of the [canon log design](../specs/2026-07-09-canon-log-schema-design.md).
**Architecture:** Two JSON Schemas in `/docs/schemas` are the single source of truth. The api (Python/FastAPI) loads them and validates any canon log posted to a role endpoint, rejecting invalid ones with HTTP 422 before they could ever reach a prompt. Authored fixtures (one origin + minimal world content) exercise a language-neutral content-integrity check: every id an origin references must resolve in world content. The GDScript client that *constructs* and *maintains* the log is a separate plan (Plan B).
**Tech Stack:** Python 3.12, FastAPI, `jsonschema` (Draft 2020-12), pytest. JSON everywhere. Docker for parity.
## Global Constraints
- **Python 3.12**, FastAPI. Runtime deps pinned in `api/requirements.txt`; dev deps in `api/requirements-dev.txt`.
- **Schemas are the single source of truth**, at `/docs/schemas/*.json`, validated by both sides (this plan wires the api side).
- **Numeric Luck, stats, HP/MP, inventory contents never appear in the canon log** (charter §7/§2). The canon-log schema enforces this via `additionalProperties: false` and a `player` object that admits only `name`, `class_id`, `luck_descriptor`.
- **`recent_events` is capped at 5 items** (charter §11). Enforced with `maxItems: 5`.
- **POC classes:** `sellsword` | `assassin` | `priest` only (charter §8).
- **All string ids match `^[a-z0-9_]+$`.**
- **Execution branch:** run this plan on a `feature/canon-log-contract` branch off `dev` (per the git workflow in CLAUDE.md §18); the design/plan docs themselves live on `docs/canon-log-schema`. Per-task commits below land on the feature branch.
- **Test setup (do once before Task 1):**
```bash
python3 -m venv .venv
.venv/bin/pip install -r api/requirements.txt -r api/requirements-dev.txt
```
All test commands below assume the venv is active (`source .venv/bin/activate`) and are run **from the `api/` directory** (`cd api`), where `pytest.ini` puts `app` on the path.
---
### Task 1: Dev tooling + origin schema + origin validator
**Files:**
- Modify: `api/requirements.txt` (add `jsonschema`)
- Create: `api/requirements-dev.txt`
- Create: `api/pytest.ini`
- Create: `docs/schemas/origin.schema.json`
- Create: `api/app/canon_log.py`
- Test: `api/tests/__init__.py`, `api/tests/test_origin_schema.py`
**Interfaces:**
- Produces: `app.canon_log.validate_origin(doc: dict) -> list[str]` — returns a list of human-readable error messages; empty list means valid. Also `app.canon_log.validate_canon_log(doc: dict) -> list[str]` (used by Task 2/4) and internal `app.canon_log.validation_errors(doc: dict, schema_name: str) -> list[str]`.
- [ ] **Step 1: Add the runtime dependency**
Modify `api/requirements.txt` to add one line (keep existing pins):
```
# FastAPI proxy runtime deps. Pinned from first resolve; bump deliberately.
fastapi==0.139.0
uvicorn[standard]==0.51.0
httpx==0.28.1 # calling Ollama / Replicate
pydantic==2.13.4 # request/response contracts
jsonschema==4.23.0 # canon log / origin contract validation
```
- [ ] **Step 2: Create the dev dependency file**
Create `api/requirements-dev.txt`:
```
# Dev/test deps. Installed on top of requirements.txt.
pytest==8.3.2
```
- [ ] **Step 3: Create the pytest config**
Create `api/pytest.ini` so `app` is importable and tests are discovered:
```ini
[pytest]
pythonpath = .
testpaths = tests
```
- [ ] **Step 4: Install (if not already done in Test setup)**
Run: `.venv/bin/pip install -r api/requirements.txt -r api/requirements-dev.txt`
Expected: installs `jsonschema` and `pytest` with no errors.
- [ ] **Step 5: Create the origin JSON Schema**
Create `docs/schemas/origin.schema.json`:
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://coc-rpg/schemas/origin.schema.json",
"title": "Origin Seed",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version", "id", "display_name", "description",
"start_location_id", "situation", "opening_facts",
"disposition_overrides", "inventory_grants", "start_quest_id",
"build_constraints"
],
"properties": {
"schema_version": { "type": "integer", "const": 1 },
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
"display_name": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"start_location_id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
"situation": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"opening_facts": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"disposition_overrides": {
"type": "object",
"additionalProperties": { "type": "integer", "minimum": -100, "maximum": 100 }
},
"inventory_grants": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["item_id", "qty"],
"properties": {
"item_id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
"qty": { "type": "integer", "minimum": 1 }
}
}
},
"start_quest_id": { "type": ["string", "null"], "pattern": "^[a-z0-9_]+$" },
"build_constraints": {
"type": "object",
"additionalProperties": false,
"required": ["allowed_classes", "luck_modifier"],
"properties": {
"allowed_classes": {
"type": "array",
"minItems": 1,
"items": { "enum": ["sellsword", "assassin", "priest"] }
},
"luck_modifier": { "type": "integer" }
}
}
}
}
```
- [ ] **Step 6: Create the empty test package marker**
Create `api/tests/__init__.py` (empty file).
- [ ] **Step 7: Write the failing test**
Create `api/tests/test_origin_schema.py`:
```python
import copy
from app.canon_log import validate_origin
VALID_ORIGIN = {
"schema_version": 1,
"id": "deserter",
"display_name": "The Deserter",
"description": "You walked away from a company that doesn't allow walking away.",
"start_location_id": "greywater_docks",
"situation": ["Arrived at Greywater by barge before dawn, hood up"],
"opening_facts": ["the player deserted the Iron Kettle mercenary company"],
"disposition_overrides": {"brannoc_thane": 40, "cadwyn_vell": 15},
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
"start_quest_id": "find_the_ledger",
"build_constraints": {
"allowed_classes": ["sellsword", "assassin", "priest"],
"luck_modifier": 0,
},
}
def test_valid_origin_passes():
assert validate_origin(VALID_ORIGIN) == []
def test_null_start_quest_is_allowed():
doc = copy.deepcopy(VALID_ORIGIN)
doc["start_quest_id"] = None
assert validate_origin(doc) == []
def test_unknown_class_is_rejected():
doc = copy.deepcopy(VALID_ORIGIN)
doc["build_constraints"]["allowed_classes"] = ["bard"]
assert validate_origin(doc) != []
def test_missing_required_field_is_rejected():
doc = copy.deepcopy(VALID_ORIGIN)
del doc["start_location_id"]
assert validate_origin(doc) != []
def test_extra_field_is_rejected():
doc = copy.deepcopy(VALID_ORIGIN)
doc["surprise"] = True
assert validate_origin(doc) != []
```
- [ ] **Step 8: Run test to verify it fails**
Run: `cd api && python -m pytest tests/test_origin_schema.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.canon_log'`.
- [ ] **Step 9: Write the validator module**
Create `api/app/canon_log.py`:
```python
"""Load the JSON Schema contracts and validate canon logs / origin seeds.
Schemas are the single source of truth in /docs/schemas. This module is the
api's half of the contract (charter §11): every canon log the client posts is
validated here before it could ever reach a prompt.
"""
import json
import os
from functools import lru_cache
from pathlib import Path
from jsonschema import Draft202012Validator
def _schema_dir() -> Path:
"""Locate the schema directory.
Honours CANON_SCHEMA_DIR, else walks up from this file looking for
docs/schemas (local checkout) or schemas (bundled into the Docker image).
"""
env = os.environ.get("CANON_SCHEMA_DIR")
if env:
return Path(env)
here = Path(__file__).resolve()
for parent in here.parents:
for candidate in (parent / "docs" / "schemas", parent / "schemas"):
if candidate.is_dir():
return candidate
raise RuntimeError("schema directory not found")
@lru_cache(maxsize=None)
def _validator(schema_name: str) -> Draft202012Validator:
with open(_schema_dir() / schema_name) as f:
return Draft202012Validator(json.load(f))
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))]
def validate_origin(doc: dict) -> list[str]:
return validation_errors(doc, "origin.schema.json")
def validate_canon_log(doc: dict) -> list[str]:
return validation_errors(doc, "canon-log.schema.json")
```
- [ ] **Step 10: Run test to verify it passes**
Run: `cd api && python -m pytest tests/test_origin_schema.py -v`
Expected: PASS — 5 passed.
- [ ] **Step 11: Commit**
```bash
git add api/requirements.txt api/requirements-dev.txt api/pytest.ini \
docs/schemas/origin.schema.json api/app/canon_log.py \
api/tests/__init__.py api/tests/test_origin_schema.py
git commit -m "feat(api): origin seed JSON Schema + validator"
```
---
### Task 2: Canon log schema + validator (enforces the §7 Luck boundary)
**Files:**
- Create: `docs/schemas/canon-log.schema.json`
- Create: `api/tests/fixtures/canon_log_valid.json`
- Test: `api/tests/test_canon_log_schema.py`
**Interfaces:**
- Consumes: `app.canon_log.validate_canon_log` (from Task 1).
- Produces: `api/tests/fixtures/canon_log_valid.json` — a canonical valid canon log reused by Task 4's endpoint tests.
- [ ] **Step 1: Create the canon log JSON Schema**
Create `docs/schemas/canon-log.schema.json`:
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://coc-rpg/schemas/canon-log.schema.json",
"title": "Canon Log",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version", "player", "location", "party",
"recent_events", "established_facts", "active_quests", "humiliations"
],
"properties": {
"schema_version": { "type": "integer", "const": 1 },
"player": {
"type": "object",
"additionalProperties": false,
"required": ["name", "class_id", "luck_descriptor"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"class_id": { "enum": ["sellsword", "assassin", "priest"] },
"luck_descriptor": { "type": "string", "minLength": 1 }
}
},
"location": {
"type": "object",
"additionalProperties": false,
"required": ["id", "name"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
"name": { "type": "string", "minLength": 1 }
}
},
"party": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "disposition"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
"name": { "type": "string", "minLength": 1 },
"disposition": { "type": "integer", "minimum": -100, "maximum": 100 }
}
}
},
"recent_events": {
"type": "array",
"maxItems": 5,
"items": { "type": "string", "minLength": 1 }
},
"established_facts": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"active_quests": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "status", "objective"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9_]+$" },
"name": { "type": "string", "minLength": 1 },
"status": { "enum": ["active", "complete", "failed"] },
"objective": { "type": "string", "minLength": 1 }
}
}
},
"humiliations": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "text", "weight", "turn"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"text": { "type": "string", "minLength": 1 },
"weight": { "type": "integer", "minimum": 1, "maximum": 10 },
"turn": { "type": "integer", "minimum": 0 }
}
}
}
}
}
```
- [ ] **Step 2: Create the valid fixture**
Create `api/tests/fixtures/canon_log_valid.json`:
```json
{
"schema_version": 1,
"player": {
"name": "Aldric",
"class_id": "sellsword",
"luck_descriptor": "Fortune spits on you"
},
"location": { "id": "greywater_docks", "name": "the Greywater docks" },
"party": [
{ "id": "brannoc_thane", "name": "Brannoc Thane", "disposition": 40 },
{ "id": "cadwyn_vell", "name": "Cadwyn Vell", "disposition": 15 }
],
"recent_events": [
"Arrived at Greywater by barge before dawn",
"Brannoc recognised the harbourmaster and went quiet"
],
"established_facts": [
"the eastern bridge out of Greywater is washed out",
"the harbourmaster is named Oda Fenn"
],
"active_quests": [
{ "id": "find_the_ledger", "name": "The Missing Ledger",
"status": "active", "objective": "Find who took Fenn's ledger" }
],
"humiliations": [
{ "id": "h_0001", "text": "vomited on a shrine step in front of a priest",
"weight": 7, "turn": 3 }
]
}
```
- [ ] **Step 3: Write the failing test**
Create `api/tests/test_canon_log_schema.py`:
```python
import copy
import json
from pathlib import Path
from app.canon_log import validate_canon_log
FIXTURE = Path(__file__).parent / "fixtures" / "canon_log_valid.json"
def _valid():
with open(FIXTURE) as f:
return json.load(f)
def test_valid_canon_log_passes():
assert validate_canon_log(_valid()) == []
def test_numeric_luck_is_rejected():
# Charter §7: the AI must never be able to calculate Luck. A stray numeric
# luck field must fail validation, not slip through.
doc = _valid()
doc["player"]["luck"] = 5
assert validate_canon_log(doc) != []
def test_recent_events_over_five_is_rejected():
doc = _valid()
doc["recent_events"] = [f"event {i}" for i in range(6)]
assert validate_canon_log(doc) != []
def test_disposition_out_of_range_is_rejected():
doc = _valid()
doc["party"][0]["disposition"] = 200
assert validate_canon_log(doc) != []
def test_unknown_quest_status_is_rejected():
doc = _valid()
doc["active_quests"][0]["status"] = "abandoned"
assert validate_canon_log(doc) != []
def test_humiliation_weight_bounds_enforced():
doc = _valid()
doc["humiliations"][0]["weight"] = 11
assert validate_canon_log(doc) != []
def test_empty_optional_arrays_are_allowed():
doc = _valid()
doc["party"] = []
doc["recent_events"] = []
doc["established_facts"] = []
doc["active_quests"] = []
doc["humiliations"] = []
assert validate_canon_log(doc) == []
```
- [ ] **Step 4: Run test to verify it fails**
Run: `cd api && python -m pytest tests/test_canon_log_schema.py -v`
Expected: FAIL — `FileNotFoundError` for `canon-log.schema.json` on the first test (schema not yet found) OR assertion failures. (If Step 1 already created the schema, the numeric-luck / bounds tests still prove behavior; all must pass only after Step 1 is in place. If any test unexpectedly passes before the schema exists, stop — the schema resolver is pointing at the wrong directory.)
- [ ] **Step 5: (No new code)** The schema from Step 1 is the implementation. Confirm `docs/schemas/canon-log.schema.json` exists.
- [ ] **Step 6: Run test to verify it passes**
Run: `cd api && python -m pytest tests/test_canon_log_schema.py -v`
Expected: PASS — 7 passed.
- [ ] **Step 7: Commit**
```bash
git add docs/schemas/canon-log.schema.json \
api/tests/fixtures/canon_log_valid.json \
api/tests/test_canon_log_schema.py
git commit -m "feat(api): canon log JSON Schema + validator, enforce §7 luck boundary"
```
---
### Task 3: Authored fixtures + content-integrity resolution
**Files:**
- Create: `content/world/locations/greywater_docks.json`
- Create: `content/world/npcs/brannoc_thane.json`, `content/world/npcs/cadwyn_vell.json`
- Create: `content/world/quests/find_the_ledger.json`
- Create: `content/world/items/worn_shortsword.json`, `content/world/items/coin.json`
- Create: `content/origins/deserter.json`
- Create: `api/app/content.py`
- Test: `api/tests/test_content_resolution.py`
**Interfaces:**
- Consumes: `app.canon_log.validate_origin` (Task 1).
- Produces: `app.content.load_world(content_root: Path) -> dict[str, set[str]]` (keys: `locations`, `npcs`, `quests`, `items`); `app.content.load_origin(path: Path) -> dict`; `app.content.unresolved_refs(origin: dict, world: dict) -> list[str]` (empty means every referenced id resolves).
- [ ] **Step 1: Create the world content fixtures**
Create `content/world/locations/greywater_docks.json`:
```json
{ "id": "greywater_docks", "name": "the Greywater docks",
"description": "A rot-black wharf where the river meets the sea trade." }
```
Create `content/world/npcs/brannoc_thane.json`:
```json
{ "id": "brannoc_thane", "name": "Brannoc Thane", "role": "companion",
"persona": "Dry, warm, economical. Twenty years past his prime and at peace with it.",
"knowledge": [] }
```
Create `content/world/npcs/cadwyn_vell.json`:
```json
{ "id": "cadwyn_vell", "name": "Cadwyn Vell", "role": "companion",
"persona": "Florid when performing, clipped when scared. A fine musician and a finer liar.",
"knowledge": [] }
```
Create `content/world/quests/find_the_ledger.json`:
```json
{ "id": "find_the_ledger", "name": "The Missing Ledger",
"objective": "Find who took Fenn's ledger" }
```
Create `content/world/items/worn_shortsword.json`:
```json
{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" }
```
Create `content/world/items/coin.json`:
```json
{ "id": "coin", "name": "coin", "slot": "currency" }
```
- [ ] **Step 2: Create the POC origin fixture**
Create `content/origins/deserter.json`:
```json
{
"schema_version": 1,
"id": "deserter",
"display_name": "The Deserter",
"description": "You walked away from a company that doesn't allow walking away. Greywater was just far enough. You hoped.",
"start_location_id": "greywater_docks",
"situation": [
"Arrived at Greywater by barge before dawn, hood up",
"Down to your last coin and owed a favour you can't repay"
],
"opening_facts": [
"the player deserted the Iron Kettle mercenary company",
"a bounty notice for the player circulates in the northern towns"
],
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
"inventory_grants": [
{ "item_id": "worn_shortsword", "qty": 1 },
{ "item_id": "coin", "qty": 3 }
],
"start_quest_id": "find_the_ledger",
"build_constraints": {
"allowed_classes": ["sellsword", "assassin", "priest"],
"luck_modifier": 0
}
}
```
- [ ] **Step 3: Write the failing test**
Create `api/tests/test_content_resolution.py`:
```python
import copy
import json
from pathlib import Path
from app.canon_log import validate_origin
from app.content import load_world, load_origin, unresolved_refs
REPO_ROOT = Path(__file__).resolve().parents[2]
CONTENT_ROOT = REPO_ROOT / "content"
DESERTER = CONTENT_ROOT / "origins" / "deserter.json"
def test_deserter_origin_matches_schema():
assert validate_origin(load_origin(DESERTER)) == []
def test_deserter_ids_all_resolve():
world = load_world(CONTENT_ROOT)
assert unresolved_refs(load_origin(DESERTER), world) == []
def test_broken_location_ref_is_detected():
world = load_world(CONTENT_ROOT)
origin = copy.deepcopy(load_origin(DESERTER))
origin["start_location_id"] = "nowhere"
assert "location:nowhere" in unresolved_refs(origin, world)
def test_broken_item_ref_is_detected():
world = load_world(CONTENT_ROOT)
origin = copy.deepcopy(load_origin(DESERTER))
origin["inventory_grants"].append({"item_id": "ghost_blade", "qty": 1})
assert "item:ghost_blade" in unresolved_refs(origin, world)
def test_null_quest_ref_resolves():
world = load_world(CONTENT_ROOT)
origin = copy.deepcopy(load_origin(DESERTER))
origin["start_quest_id"] = None
assert unresolved_refs(origin, world) == []
```
- [ ] **Step 4: Run test to verify it fails**
Run: `cd api && python -m pytest tests/test_content_resolution.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.content'`.
- [ ] **Step 5: Write the content module**
Create `api/app/content.py`:
```python
"""Load authored world content and cross-check origin references.
Language-neutral content integrity: every id an origin references (start
location, start quest, granted items, seeded npc dispositions) must resolve in
world content. New-game construction (client-side, Plan B) relies on this
holding; catching it here fails a broken origin at authoring time, not three
scenes into play.
"""
import json
from pathlib import Path
def _load_ids(dir_path: Path) -> set[str]:
ids: set[str] = set()
for f in dir_path.glob("*.json"):
with open(f) as fh:
ids.add(json.load(fh)["id"])
return ids
def load_world(content_root: Path) -> dict[str, set[str]]:
world = content_root / "world"
return {
"locations": _load_ids(world / "locations"),
"npcs": _load_ids(world / "npcs"),
"quests": _load_ids(world / "quests"),
"items": _load_ids(world / "items"),
}
def load_origin(path: Path) -> dict:
with open(path) as f:
return json.load(f)
def unresolved_refs(origin: dict, world: dict[str, set[str]]) -> list[str]:
missing: list[str] = []
if origin["start_location_id"] not in world["locations"]:
missing.append(f"location:{origin['start_location_id']}")
quest = origin.get("start_quest_id")
if quest is not None and quest not in world["quests"]:
missing.append(f"quest:{quest}")
for grant in origin["inventory_grants"]:
if grant["item_id"] not in world["items"]:
missing.append(f"item:{grant['item_id']}")
for npc_id in origin["disposition_overrides"]:
if npc_id not in world["npcs"]:
missing.append(f"npc:{npc_id}")
return missing
```
- [ ] **Step 6: Run test to verify it passes**
Run: `cd api && python -m pytest tests/test_content_resolution.py -v`
Expected: PASS — 5 passed.
- [ ] **Step 7: Commit**
```bash
git add content/world content/origins/deserter.json \
api/app/content.py api/tests/test_content_resolution.py
git commit -m "feat(content): POC deserter origin + world fixtures with id-resolution check"
```
---
### Task 4: Enforce the contract at the api boundary
**Files:**
- Modify: `api/app/main.py`
- Test: `api/tests/test_endpoints.py`
**Interfaces:**
- Consumes: `app.canon_log.validate_canon_log` (Task 1/2), `api/tests/fixtures/canon_log_valid.json` (Task 2).
- Produces: a shared FastAPI dependency `app.main.valid_turn` and request model `app.main.TurnRequest` (`{ "canon_log": dict }`). All five role endpoints require it; an invalid canon log returns HTTP 422 with `{"detail": {"canon_log_errors": [...]}}`.
- [ ] **Step 1: Write the failing test**
Create `api/tests/test_endpoints.py`:
```python
import json
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
VALID_LOG = json.load(open(Path(__file__).parent / "fixtures" / "canon_log_valid.json"))
ROLE_ENDPOINTS = [
"/dm/narrate", "/dm/adjudicate", "/dm/improvise",
"/npc/speak", "/party/banter",
]
def test_health_ok():
assert client.get("/health").json() == {"status": "ok"}
def test_every_role_endpoint_accepts_a_valid_canon_log():
for path in ROLE_ENDPOINTS:
r = client.post(path, json={"canon_log": VALID_LOG})
assert r.status_code == 200, f"{path} rejected a valid log: {r.text}"
def test_every_role_endpoint_rejects_an_invalid_canon_log():
bad = json.loads(json.dumps(VALID_LOG))
bad["player"]["luck"] = 5 # §7 leak
for path in ROLE_ENDPOINTS:
r = client.post(path, json={"canon_log": bad})
assert r.status_code == 422, f"{path} accepted an invalid log"
assert "canon_log_errors" in r.json()["detail"]
def test_missing_canon_log_is_a_422():
r = client.post("/dm/narrate", json={})
assert r.status_code == 422
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd api && python -m pytest tests/test_endpoints.py -v`
Expected: FAIL — endpoints currently take no body, so posting `{"canon_log": ...}` returns 200 with `{"detail": "not implemented"}` and the *reject* test fails (invalid log still returns 200).
- [ ] **Step 3: Wire validation into the endpoints**
Replace the contents of `api/app/main.py` with:
```python
"""FastAPI proxy entrypoint — the guarding proxy of charter §4.
Skeleton: a health check plus the five role endpoints. Each role endpoint now
validates the posted canon log against the contract (charter §11) before doing
anything else — an invalid log is rejected with 422 and never reaches a prompt.
Prompt routing, model selection, and logging land later.
"""
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
from .canon_log import validate_canon_log
app = FastAPI(title="coc-rpg proxy", version="0.0.1")
class TurnRequest(BaseModel):
canon_log: dict
def valid_turn(req: TurnRequest) -> TurnRequest:
"""Shared dependency: reject any request whose canon log breaks the contract."""
errors = validate_canon_log(req.canon_log)
if errors:
raise HTTPException(status_code=422, detail={"canon_log_errors": errors})
return req
@app.get("/health")
def health() -> dict:
"""Liveness probe for compose / fly.io."""
return {"status": "ok"}
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
# The client knows these paths and nothing about which model or prompt serves
# them. Bodies are validated against the canon log contract; the AI half is a
# stub until prompt routing lands.
@app.post("/dm/narrate")
def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/dm/adjudicate")
def adjudicate(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/dm/improvise")
def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/npc/speak")
def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/party/banter")
def banter(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd api && python -m pytest tests/test_endpoints.py -v`
Expected: PASS — 4 passed.
- [ ] **Step 5: Run the whole suite**
Run: `cd api && python -m pytest -v`
Expected: PASS — all tests from Tasks 14 green.
- [ ] **Step 6: Commit**
```bash
git add api/app/main.py api/tests/test_endpoints.py
git commit -m "feat(api): validate canon log at every role endpoint (422 on invalid)"
```
---
### Task 5: Bundle schemas into the Docker image + smoke test
**Files:**
- Modify: `api/Dockerfile`
- Modify: `docker-compose.yml`
- Create: `.dockerignore` (repo root)
**Interfaces:**
- Consumes: everything above. No new code interfaces.
- Produces: a runnable image whose build context is the repo root, so `/docs/schemas` is bundled at `/app/schemas` and the resolver in `canon_log.py` finds it at runtime.
- [ ] **Step 1: Rewrite the Dockerfile for a repo-root build context**
Replace `api/Dockerfile` with:
```dockerfile
# /api — FastAPI proxy (charter §4). Build context is the repo root so the
# canon log schemas (/docs/schemas) are bundled into the image.
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
# Install deps first so the layer caches when only app code changes.
COPY api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# App code + the schema contract (single source of truth in docs/schemas).
COPY api/app ./app
COPY docs/schemas ./schemas
# Run as non-root.
RUN useradd --create-home --uid 1000 appuser
USER appuser
EXPOSE 8000
# fly.io / compose set PORT; default to 8000 (charter localhost:8000).
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
```
- [ ] **Step 2: Point compose at the repo-root context**
Replace `docker-compose.yml` with:
```yaml
# Local dev — run the proxy in Docker with live reload.
# docker compose up --build
# Build context is the repo root so docs/schemas is available to the image.
# Ollama runs on the homelab (charter §4), not here — point OLLAMA_BASE_URL at it.
services:
api:
build:
context: .
dockerfile: api/Dockerfile
ports:
- "8000:8000"
env_file:
- ./api/.env
environment:
PORT: 8000
# Mount source + schemas so edits are live without a rebuild.
volumes:
- ./api/app:/app/app:ro
- ./docs/schemas:/app/schemas:ro
command: >
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
- [ ] **Step 3: Add a repo-root .dockerignore**
Create `.dockerignore` (the build context is now the repo root — keep the client, content, and the rest of docs out of the image, but let `docs/schemas` through):
```
.git
.claude
client
content
docs/*
!docs/schemas
**/__pycache__
**/*.py[cod]
.venv
venv
**/.pytest_cache
api/tests
api/.env
api/.env.*
```
- [ ] **Step 4: Build the image**
Run: `docker build -f api/Dockerfile -t coc-rpg-proxy:test .`
Expected: build succeeds; `COPY docs/schemas ./schemas` present in the output.
- [ ] **Step 5: Smoke test the running container**
Run:
```bash
docker run -d --name coc-smoke -p 8000:8000 coc-rpg-proxy:test
sleep 1
# health
curl -s http://localhost:8000/health
# valid canon log -> 200
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8000/dm/narrate \
-H 'content-type: application/json' \
--data-binary @api/tests/fixtures/canon_log_valid.json \
|| true
# NOTE: the fixture is the bare log; wrap it for the endpoint:
curl -s -o /dev/null -w 'valid=%{http_code}\n' -X POST http://localhost:8000/dm/narrate \
-H 'content-type: application/json' \
-d "{\"canon_log\": $(cat api/tests/fixtures/canon_log_valid.json)}"
# invalid canon log (numeric luck) -> 422
curl -s -o /dev/null -w 'invalid=%{http_code}\n' -X POST http://localhost:8000/dm/narrate \
-H 'content-type: application/json' \
-d '{"canon_log": {"schema_version": 1, "player": {"name": "x", "class_id": "sellsword", "luck_descriptor": "y", "luck": 5}, "location": {"id": "greywater_docks", "name": "z"}, "party": [], "recent_events": [], "established_facts": [], "active_quests": [], "humiliations": []}}'
docker rm -f coc-smoke
docker rmi coc-rpg-proxy:test
```
Expected: `{"status":"ok"}`, then `valid=200`, then `invalid=422`. This proves the schema is bundled and enforced at runtime, not just in tests.
- [ ] **Step 6: Commit**
```bash
git add api/Dockerfile docker-compose.yml .dockerignore
git commit -m "chore(api): bundle canon log schemas into image; repo-root build context"
```
---
## Self-Review
**Spec coverage:**
- Three-layer model — world content, origin seed, canon log: fixtures (Task 3) + schemas (Tasks 12). ✔
- In/out boundary, numeric Luck never in log (§7): `additionalProperties:false` + explicit numeric-luck rejection test (Task 2). ✔
- Canon log schema, all fields + `recent_events` cap 5, uncapped facts, append-only humiliations shape: Task 2. ✔ (Append-only *behavior* is client-side, Plan B; the schema only constrains shape.)
- Origin seed schema, six seed axes: Task 1. ✔
- New-game construction: **client-side (Plan B)** — out of this plan's scope by design; Task 3 covers the language-neutral precondition (id resolution). ✔ (noted, not a gap)
- Maintenance mutations: **client-side (Plan B)** — out of scope. ✔ (noted)
- Per-role injection: Task 4 wires all five endpoints to require a valid log; role-specific extras are later work. ✔
- Storage/format, JSON, one schema both sides, bundled for runtime: Task 5. ✔
**Placeholder scan:** No TBD/TODO; every code and test step shows complete content. ✔
**Type consistency:** `validate_canon_log` / `validate_origin` / `validation_errors` signatures match across Tasks 1, 2, 4. `load_world` / `load_origin` / `unresolved_refs` signatures match across Task 3. `TurnRequest` / `valid_turn` names consistent in Task 4. ✔
**Deviations from spec (flag for reviewer):**
- Spec suggested `origin.schema.json` would be validated "later"; this plan validates it now (Task 1) — strictly additive.
- No `/docs/canon-log.md` living-contract prose doc is created here; the design spec + the schemas themselves serve as the contract for the POC. Add the prose doc in a later docs pass if desired.
**Out of scope (Plan B):** GDScript canon log model, new-game construction routine, turn-to-turn maintenance hooks, GUT tests.