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

33 KiB
Raw Permalink Blame History

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.

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):
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:

[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:

{
  "$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:

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:

"""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
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:

{
  "$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:

{
  "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:

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
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:

{ "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:

{ "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:

{ "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:

{ "id": "find_the_ledger", "name": "The Missing Ledger",
  "objective": "Find who took Fenn's ledger" }

Create content/world/items/worn_shortsword.json:

{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" }

Create content/world/items/coin.json:

{ "id": "coin", "name": "coin", "slot": "currency" }
  • Step 2: Create the POC origin fixture

Create content/origins/deserter.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:

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:

"""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
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:

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:

"""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
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:

# /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:

# 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:

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
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.