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>
1021 lines
33 KiB
Markdown
1021 lines
33 KiB
Markdown
# 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 1–4 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 1–2). ✔
|
||
- 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.
|