From 710745e54872ee5a862ee3604cd686559d5356c4 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:26:39 -0500 Subject: [PATCH 1/7] feat(api): origin seed JSON Schema + validator Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app/canon_log.py | 49 +++++++++++++++++++++++++++++++ api/pytest.ini | 3 ++ api/requirements-dev.txt | 2 ++ api/requirements.txt | 1 + api/tests/__init__.py | 0 api/tests/test_origin_schema.py | 48 ++++++++++++++++++++++++++++++ docs/schemas/origin.schema.json | 52 +++++++++++++++++++++++++++++++++ 7 files changed, 155 insertions(+) create mode 100644 api/app/canon_log.py create mode 100644 api/pytest.ini create mode 100644 api/requirements-dev.txt create mode 100644 api/tests/__init__.py create mode 100644 api/tests/test_origin_schema.py create mode 100644 docs/schemas/origin.schema.json diff --git a/api/app/canon_log.py b/api/app/canon_log.py new file mode 100644 index 0000000..bf7e30c --- /dev/null +++ b/api/app/canon_log.py @@ -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") diff --git a/api/pytest.ini b/api/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/api/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt new file mode 100644 index 0000000..f68e0b0 --- /dev/null +++ b/api/requirements-dev.txt @@ -0,0 +1,2 @@ +# Dev/test deps. Installed on top of requirements.txt. +pytest==8.3.2 diff --git a/api/requirements.txt b/api/requirements.txt index 30ba429..45f128a 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -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 diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/test_origin_schema.py b/api/tests/test_origin_schema.py new file mode 100644 index 0000000..bdfe8ca --- /dev/null +++ b/api/tests/test_origin_schema.py @@ -0,0 +1,48 @@ +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) != [] diff --git a/docs/schemas/origin.schema.json b/docs/schemas/origin.schema.json new file mode 100644 index 0000000..c4f7734 --- /dev/null +++ b/docs/schemas/origin.schema.json @@ -0,0 +1,52 @@ +{ + "$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" } + } + } + } +} From 843ab11a5c7cee8aaeb411582df6b047b8f84c96 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:31:10 -0500 Subject: [PATCH 2/7] =?UTF-8?q?feat(api):=20canon=20log=20JSON=20Schema=20?= =?UTF-8?q?+=20validator,=20enforce=20=C2=A77=20luck=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- api/tests/fixtures/canon_log_valid.json | 29 +++++++++ api/tests/test_canon_log_schema.py | 58 +++++++++++++++++ docs/schemas/canon-log.schema.json | 83 +++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 api/tests/fixtures/canon_log_valid.json create mode 100644 api/tests/test_canon_log_schema.py create mode 100644 docs/schemas/canon-log.schema.json diff --git a/api/tests/fixtures/canon_log_valid.json b/api/tests/fixtures/canon_log_valid.json new file mode 100644 index 0000000..b0587c7 --- /dev/null +++ b/api/tests/fixtures/canon_log_valid.json @@ -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 } + ] +} diff --git a/api/tests/test_canon_log_schema.py b/api/tests/test_canon_log_schema.py new file mode 100644 index 0000000..326d289 --- /dev/null +++ b/api/tests/test_canon_log_schema.py @@ -0,0 +1,58 @@ +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) == [] diff --git a/docs/schemas/canon-log.schema.json b/docs/schemas/canon-log.schema.json new file mode 100644 index 0000000..795a2e9 --- /dev/null +++ b/docs/schemas/canon-log.schema.json @@ -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 }, + "text": { "type": "string", "minLength": 1 }, + "weight": { "type": "integer", "minimum": 1, "maximum": 10 }, + "turn": { "type": "integer", "minimum": 0 } + } + } + } + } +} From e80a4071f6630820fbf7f8e440e78418419c41e9 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:34:31 -0500 Subject: [PATCH 3/7] fix(api): pattern-constrain humiliations id; broaden canon log boundary tests --- api/tests/test_canon_log_schema.py | 14 +++++++++++++- docs/schemas/canon-log.schema.json | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/api/tests/test_canon_log_schema.py b/api/tests/test_canon_log_schema.py index 326d289..c5b3908 100644 --- a/api/tests/test_canon_log_schema.py +++ b/api/tests/test_canon_log_schema.py @@ -1,4 +1,3 @@ -import copy import json from pathlib import Path @@ -56,3 +55,16 @@ def test_empty_optional_arrays_are_allowed(): 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) != [] diff --git a/docs/schemas/canon-log.schema.json b/docs/schemas/canon-log.schema.json index 795a2e9..9853159 100644 --- a/docs/schemas/canon-log.schema.json +++ b/docs/schemas/canon-log.schema.json @@ -72,7 +72,7 @@ "additionalProperties": false, "required": ["id", "text", "weight", "turn"], "properties": { - "id": { "type": "string", "minLength": 1 }, + "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 } From 4aa65c9d4c189270ba50a729deba5d80e1e8a173 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:37:29 -0500 Subject: [PATCH 4/7] feat(content): POC deserter origin + world fixtures with id-resolution check --- api/app/content.py | 50 ++++++++++++++++++++ api/tests/test_content_resolution.py | 40 ++++++++++++++++ content/origins/deserter.json | 25 ++++++++++ content/world/items/coin.json | 1 + content/world/items/worn_shortsword.json | 1 + content/world/locations/greywater_docks.json | 2 + content/world/npcs/brannoc_thane.json | 3 ++ content/world/npcs/cadwyn_vell.json | 3 ++ content/world/quests/find_the_ledger.json | 2 + 9 files changed, 127 insertions(+) create mode 100644 api/app/content.py create mode 100644 api/tests/test_content_resolution.py create mode 100644 content/origins/deserter.json create mode 100644 content/world/items/coin.json create mode 100644 content/world/items/worn_shortsword.json create mode 100644 content/world/locations/greywater_docks.json create mode 100644 content/world/npcs/brannoc_thane.json create mode 100644 content/world/npcs/cadwyn_vell.json create mode 100644 content/world/quests/find_the_ledger.json diff --git a/api/app/content.py b/api/app/content.py new file mode 100644 index 0000000..f95efd0 --- /dev/null +++ b/api/app/content.py @@ -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 diff --git a/api/tests/test_content_resolution.py b/api/tests/test_content_resolution.py new file mode 100644 index 0000000..133499c --- /dev/null +++ b/api/tests/test_content_resolution.py @@ -0,0 +1,40 @@ +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) == [] diff --git a/content/origins/deserter.json b/content/origins/deserter.json new file mode 100644 index 0000000..d7e0b47 --- /dev/null +++ b/content/origins/deserter.json @@ -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 + } +} diff --git a/content/world/items/coin.json b/content/world/items/coin.json new file mode 100644 index 0000000..eeb0ea3 --- /dev/null +++ b/content/world/items/coin.json @@ -0,0 +1 @@ +{ "id": "coin", "name": "coin", "slot": "currency" } diff --git a/content/world/items/worn_shortsword.json b/content/world/items/worn_shortsword.json new file mode 100644 index 0000000..6642e3a --- /dev/null +++ b/content/world/items/worn_shortsword.json @@ -0,0 +1 @@ +{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" } diff --git a/content/world/locations/greywater_docks.json b/content/world/locations/greywater_docks.json new file mode 100644 index 0000000..85c58ec --- /dev/null +++ b/content/world/locations/greywater_docks.json @@ -0,0 +1,2 @@ +{ "id": "greywater_docks", "name": "the Greywater docks", + "description": "A rot-black wharf where the river meets the sea trade." } diff --git a/content/world/npcs/brannoc_thane.json b/content/world/npcs/brannoc_thane.json new file mode 100644 index 0000000..2682e74 --- /dev/null +++ b/content/world/npcs/brannoc_thane.json @@ -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": [] } diff --git a/content/world/npcs/cadwyn_vell.json b/content/world/npcs/cadwyn_vell.json new file mode 100644 index 0000000..81583ec --- /dev/null +++ b/content/world/npcs/cadwyn_vell.json @@ -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": [] } diff --git a/content/world/quests/find_the_ledger.json b/content/world/quests/find_the_ledger.json new file mode 100644 index 0000000..d540c6b --- /dev/null +++ b/content/world/quests/find_the_ledger.json @@ -0,0 +1,2 @@ +{ "id": "find_the_ledger", "name": "The Missing Ledger", + "objective": "Find who took Fenn's ledger" } From 6d5510773a57db1e856f4a27bfbf70246a7e8ff1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:42:03 -0500 Subject: [PATCH 5/7] feat(api): validate canon log at every role endpoint (422 on invalid) --- api/app/main.py | 38 ++++++++++++++++++++++++++----------- api/tests/test_endpoints.py | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 api/tests/test_endpoints.py diff --git a/api/app/main.py b/api/app/main.py index 2223c21..6ae1412 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -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"} diff --git a/api/tests/test_endpoints.py b/api/tests/test_endpoints.py new file mode 100644 index 0000000..ee536f2 --- /dev/null +++ b/api/tests/test_endpoints.py @@ -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 From 4ab0e564ef5e63ec60f9473bfbd1ebdd990a9341 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:45:49 -0500 Subject: [PATCH 6/7] chore(api): bundle canon log schemas into image; repo-root build context Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 14 ++++++++++++++ api/Dockerfile | 12 ++++++------ docker-compose.yml | 8 ++++++-- 3 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..81987b7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.claude +client +content +docs/* +!docs/schemas +**/__pycache__ +**/*.py[cod] +.venv +venv +**/.pytest_cache +api/tests +api/.env +api/.env.* diff --git a/api/Dockerfile b/api/Dockerfile index 2eb6545..f9c575b 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 81ec3b8..cfba5e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,21 @@ # 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 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 From e39d1a446e750dabcb1ba46ccd2225c9bb8f4b2f Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 9 Jul 2026 12:54:01 -0500 Subject: [PATCH 7/7] fix(api): constrain disposition_overrides keys; close docker paper cuts Co-Authored-By: Claude Opus 4.8 (1M context) --- api/.dockerignore | 16 ---------------- api/tests/test_content_resolution.py | 7 +++++++ api/tests/test_origin_schema.py | 6 ++++++ docker-compose.yml | 3 ++- docs/schemas/origin.schema.json | 1 + 5 files changed, 16 insertions(+), 17 deletions(-) delete mode 100644 api/.dockerignore diff --git a/api/.dockerignore b/api/.dockerignore deleted file mode 100644 index 6dc1728..0000000 --- a/api/.dockerignore +++ /dev/null @@ -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 diff --git a/api/tests/test_content_resolution.py b/api/tests/test_content_resolution.py index 133499c..2f03655 100644 --- a/api/tests/test_content_resolution.py +++ b/api/tests/test_content_resolution.py @@ -38,3 +38,10 @@ def test_null_quest_ref_resolves(): 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) diff --git a/api/tests/test_origin_schema.py b/api/tests/test_origin_schema.py index bdfe8ca..036b58e 100644 --- a/api/tests/test_origin_schema.py +++ b/api/tests/test_origin_schema.py @@ -46,3 +46,9 @@ 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) != [] diff --git a/docker-compose.yml b/docker-compose.yml index cfba5e4..56d63d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,8 @@ services: ports: - "8000:8000" env_file: - - ./api/.env + - path: ./api/.env + required: false environment: PORT: 8000 # Mount source + schemas so edits are live without a rebuild. diff --git a/docs/schemas/origin.schema.json b/docs/schemas/origin.schema.json index c4f7734..0485da9 100644 --- a/docs/schemas/origin.schema.json +++ b/docs/schemas/origin.schema.json @@ -20,6 +20,7 @@ "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": {