Merge canon log contract & api validation (Plan A)

JSON Schema contract for canon log + origin seeds (single source of truth),
validator module, POC deserter origin + minimal world fixtures with
id-resolution, canon-log validation wired into all five role endpoints
(422 on invalid), and schemas bundled into the Docker image with a passing
runtime smoke test. Full suite 25 passing.
This commit is contained in:
2026-07-09 12:58:44 -05:00
25 changed files with 571 additions and 36 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.claude
client
content
docs/*
!docs/schemas
**/__pycache__
**/*.py[cod]
.venv
venv
**/.pytest_cache
api/tests
api/.env
api/.env.*

View File

@@ -1,16 +0,0 @@
# Keep the build context small and secrets out of the image.
.env
.env.*
__pycache__/
*.py[cod]
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
docs/
README.md
Dockerfile
.dockerignore

View File

@@ -1,8 +1,7 @@
# /api — FastAPI proxy (charter §4). Docker-based for parity across
# dev / homelab VPS / fly.io.
# /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
# Faster, quieter Python in containers.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
@@ -10,11 +9,12 @@ ENV PYTHONUNBUFFERED=1 \
WORKDIR /app
# Install deps first so the layer caches when only app code changes.
COPY requirements.txt .
COPY api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# App code.
COPY app ./app
# 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

49
api/app/canon_log.py Normal file
View File

@@ -0,0 +1,49 @@
"""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")

50
api/app/content.py Normal file
View File

@@ -0,0 +1,50 @@
"""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

View File

@@ -1,15 +1,31 @@
"""FastAPI proxy entrypoint — the guarding proxy of charter §4.
Skeleton only: a health check plus the five role endpoints as stubs so the
container runs and the client has something to point at. Real prompt routing,
model selection, logging, and auth land later — all server-side (§4, §5).
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 FastAPI
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."""
@@ -18,30 +34,30 @@ def health() -> dict:
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
# The client knows these paths and nothing about which model or prompt serves
# them. Stubs for now; each will load its prompt from api/prompts/ and route to
# the configured model.
# 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() -> dict:
def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/dm/adjudicate")
def adjudicate() -> dict:
def adjudicate(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/dm/improvise")
def improvise() -> dict:
def improvise(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/npc/speak")
def npc_speak() -> dict:
def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}
@app.post("/party/banter")
def banter() -> dict:
def banter(req: TurnRequest = Depends(valid_turn)) -> dict:
return {"detail": "not implemented"}

3
api/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests

2
api/requirements-dev.txt Normal file
View File

@@ -0,0 +1,2 @@
# Dev/test deps. Installed on top of requirements.txt.
pytest==8.3.2

View File

@@ -3,3 +3,4 @@ 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

0
api/tests/__init__.py Normal file
View File

29
api/tests/fixtures/canon_log_valid.json vendored Normal file
View File

@@ -0,0 +1,29 @@
{
"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 }
]
}

View File

@@ -0,0 +1,70 @@
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) == []
def test_root_level_stray_field_is_rejected():
# §2/§7: stats/HP/inventory must never enter the log — additionalProperties:false at root.
doc = _valid()
doc["hp"] = 10
assert validate_canon_log(doc) != []
def test_negative_turn_is_rejected():
doc = _valid()
doc["humiliations"][0]["turn"] = -1
assert validate_canon_log(doc) != []

View File

@@ -0,0 +1,47 @@
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) == []
def test_broken_disposition_npc_ref_is_detected():
world = load_world(CONTENT_ROOT)
origin = copy.deepcopy(load_origin(DESERTER))
origin["disposition_overrides"]["ghost_npc"] = 10
assert "npc:ghost_npc" in unresolved_refs(origin, world)

View File

@@ -0,0 +1,38 @@
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

View File

@@ -0,0 +1,54 @@
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) != []
def test_disposition_override_key_must_match_id_pattern():
doc = copy.deepcopy(VALID_ORIGIN)
doc["disposition_overrides"] = {"Brannoc Thane": 40}
assert validate_origin(doc) != []

View File

@@ -0,0 +1,25 @@
{
"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
}
}

View File

@@ -0,0 +1 @@
{ "id": "coin", "name": "coin", "slot": "currency" }

View File

@@ -0,0 +1 @@
{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" }

View File

@@ -0,0 +1,2 @@
{ "id": "greywater_docks", "name": "the Greywater docks",
"description": "A rot-black wharf where the river meets the sea trade." }

View File

@@ -0,0 +1,3 @@
{ "id": "brannoc_thane", "name": "Brannoc Thane", "role": "companion",
"persona": "Dry, warm, economical. Twenty years past his prime and at peace with it.",
"knowledge": [] }

View File

@@ -0,0 +1,3 @@
{ "id": "cadwyn_vell", "name": "Cadwyn Vell", "role": "companion",
"persona": "Florid when performing, clipped when scared. A fine musician and a finer liar.",
"knowledge": [] }

View File

@@ -0,0 +1,2 @@
{ "id": "find_the_ledger", "name": "The Missing Ledger",
"objective": "Find who took Fenn's ledger" }

View File

@@ -1,17 +1,22 @@
# 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: ./api
build:
context: .
dockerfile: api/Dockerfile
ports:
- "8000:8000"
env_file:
- ./api/.env
- path: ./api/.env
required: false
environment:
PORT: 8000
# Mount source + reload so edits are live without a rebuild.
# 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

View File

@@ -0,0 +1,83 @@
{
"$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, "pattern": "^[a-z0-9_]+$" },
"text": { "type": "string", "minLength": 1 },
"weight": { "type": "integer", "minimum": 1, "maximum": 10 },
"turn": { "type": "integer", "minimum": 0 }
}
}
}
}
}

View File

@@ -0,0 +1,53 @@
{
"$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",
"propertyNames": { "pattern": "^[a-z0-9_]+$" },
"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" }
}
}
}
}