feat(api): origin seed JSON Schema + validator

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 12:26:39 -05:00
parent 38c659cbfe
commit 710745e548
7 changed files with 155 additions and 0 deletions

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

@@ -0,0 +1,49 @@
"""Load the JSON Schema contracts and validate canon logs / origin seeds.
Schemas are the single source of truth in /docs/schemas. This module is the
api's half of the contract (charter §11): every canon log the client posts is
validated here before it could ever reach a prompt.
"""
import json
import os
from functools import lru_cache
from pathlib import Path
from jsonschema import Draft202012Validator
def _schema_dir() -> Path:
"""Locate the schema directory.
Honours CANON_SCHEMA_DIR, else walks up from this file looking for
docs/schemas (local checkout) or schemas (bundled into the Docker image).
"""
env = os.environ.get("CANON_SCHEMA_DIR")
if env:
return Path(env)
here = Path(__file__).resolve()
for parent in here.parents:
for candidate in (parent / "docs" / "schemas", parent / "schemas"):
if candidate.is_dir():
return candidate
raise RuntimeError("schema directory not found")
@lru_cache(maxsize=None)
def _validator(schema_name: str) -> Draft202012Validator:
with open(_schema_dir() / schema_name) as f:
return Draft202012Validator(json.load(f))
def validation_errors(doc: dict, schema_name: str) -> list[str]:
validator = _validator(schema_name)
return [e.message for e in sorted(validator.iter_errors(doc), key=lambda e: list(e.path))]
def validate_origin(doc: dict) -> list[str]:
return validation_errors(doc, "origin.schema.json")
def validate_canon_log(doc: dict) -> list[str]:
return validation_errors(doc, "canon-log.schema.json")

3
api/pytest.ini Normal file
View File

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

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

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

View File

@@ -3,3 +3,4 @@ fastapi==0.139.0
uvicorn[standard]==0.51.0
httpx==0.28.1 # calling Ollama / Replicate
pydantic==2.13.4 # request/response contracts
jsonschema==4.23.0 # canon log / origin contract validation

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

View File

@@ -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) != []