Rebased onto PR #12 head (47ca557). Vincent-only delta, no add/add conflict,
no tracked bytecode. All seven review items fixed:
3. schema_version bumped to 2 and ENFORCED (rejects v1, rejects missing)
2. audience_choice requires the chosen form to be the SOLE survivor and
every other form to be archive_ghost
5. receipts require non-empty action + exact power/scar/cost deltas;
provenance is structured {source_issue:int, pull:int|commit:hex}
6. true configured signer allowlist (frozenset); human:anyone rejected
7. non-empty audience source fields; malformed containers/items return
errors and never raise (fail-closed)
New negative tests for every reproduced bypass. Full clean-checkout suite:
51/51 pass; __pycache__ gitignored so a clean checkout stays clean.
Closes #19
Refs: #3, #13, PR #12, PR #18
This commit is contained in:
parent
47ca557c27
commit
0b743027ba
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache/
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 2,
|
||||||
"series": "The Cannon Chain",
|
"series": "The Cannon Chain",
|
||||||
"season": 0,
|
"season": 0,
|
||||||
"current_episode": "01-pilot",
|
"current_episode": "01-pilot",
|
||||||
|
|
@ -17,7 +17,8 @@
|
||||||
"palette": ["midnight blue", "tarnished brass", "vellum"],
|
"palette": ["midnight blue", "tarnished brass", "vellum"],
|
||||||
"power": "Negotiates visible covenants",
|
"power": "Negotiates visible covenants",
|
||||||
"scar": "Contract glyphs burned into both palms",
|
"scar": "Contract glyphs burned into both palms",
|
||||||
"cost": "Cannot use a universal master key"
|
"cost": "Cannot use a universal master key",
|
||||||
|
"state": "candidate"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "machine",
|
"id": "machine",
|
||||||
|
|
@ -25,7 +26,8 @@
|
||||||
"palette": ["black chrome", "signal lime", "white"],
|
"palette": ["black chrome", "signal lime", "white"],
|
||||||
"power": "Proves the execution path",
|
"power": "Proves the execution path",
|
||||||
"scar": "Red failed-build tallies cut into the faceplate",
|
"scar": "Red failed-build tallies cut into the faceplate",
|
||||||
"cost": "Remembers every failed build"
|
"cost": "Remembers every failed build",
|
||||||
|
"state": "candidate"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "creature",
|
"id": "creature",
|
||||||
|
|
@ -33,7 +35,8 @@
|
||||||
"palette": ["moss", "amber", "wet copper"],
|
"palette": ["moss", "amber", "wet copper"],
|
||||||
"power": "Adapts to any habitat",
|
"power": "Adapts to any habitat",
|
||||||
"scar": "An unhealed gill seam around the throat",
|
"scar": "An unhealed gill seam around the throat",
|
||||||
"cost": "Gains one appetite per adaptation"
|
"cost": "Gains one appetite per adaptation",
|
||||||
|
"state": "candidate"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "talking-turd",
|
"id": "talking-turd",
|
||||||
|
|
@ -41,7 +44,8 @@
|
||||||
"palette": ["CRT gold", "terminal green", "soot"],
|
"palette": ["CRT gold", "terminal green", "soot"],
|
||||||
"power": "Exposes hypocrisy instantly",
|
"power": "Exposes hypocrisy instantly",
|
||||||
"scar": "One permanently pixelated tooth per insult",
|
"scar": "One permanently pixelated tooth per insult",
|
||||||
"cost": "Every truth arrives as an insult"
|
"cost": "Every truth arrives as an insult",
|
||||||
|
"state": "candidate"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"episodes": [
|
"episodes": [
|
||||||
|
|
|
||||||
327
projects/slop-cannon/canon_validator.py
Normal file
327
projects/slop-cannon/canon_validator.py
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
"""
|
||||||
|
Dependency-free, fail-closed validator for Cannon Chain canon lifecycle.
|
||||||
|
|
||||||
|
Revision 2 (schema_version 2). Addresses the formal changes-requested review
|
||||||
|
on PR #27:
|
||||||
|
1. schema_version is bumped to 2 and *enforced* (not just present).
|
||||||
|
2. audience_choice requires the chosen form to be the SOLE survivor and
|
||||||
|
every other form to be an archive_ghost (no lingering candidates).
|
||||||
|
3. Receipts require a non-empty action and exact power/scar/cost deltas.
|
||||||
|
4. Provenance is a structured object: source_issue (int) plus a PR or
|
||||||
|
commit binding. A free-text string is rejected.
|
||||||
|
5. Signer check is a true configured allowlist (a set), not a namespace
|
||||||
|
regex. `human:anyone` is rejected unless explicitly configured.
|
||||||
|
6. audience_choice source fields (stolen_trait, source_episode,
|
||||||
|
source_issue) must be non-empty.
|
||||||
|
7. Malformed containers/items (None, wrong types, non-dict receipts)
|
||||||
|
return validation errors and NEVER raise.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from canon_validator import validate_canon
|
||||||
|
result = validate_canon(data)
|
||||||
|
assert result["valid"], result["errors"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CANON_PATH = Path(__file__).parent / "canon.json"
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 2
|
||||||
|
VALID_STATES = {"candidate", "survivor", "archive_ghost"}
|
||||||
|
VALID_TRANSITIONS = {
|
||||||
|
"candidate": {"survivor", "archive_ghost"},
|
||||||
|
"survivor": set(), # terminal
|
||||||
|
"archive_ghost": set(), # terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
# True configured allowlist — explicit identities only, no namespace wildcards.
|
||||||
|
DEFAULT_SIGNERS = frozenset({
|
||||||
|
"agent:vincent",
|
||||||
|
"agent:timmy",
|
||||||
|
"agent:nightprowl",
|
||||||
|
"human:grepples",
|
||||||
|
})
|
||||||
|
|
||||||
|
_COMMIT_RE = re.compile(r"^[0-9a-f]{7,40}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_nonempty_str(v) -> bool:
|
||||||
|
return isinstance(v, str) and v.strip() != ""
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_provenance(prov, prefix, errors) -> None:
|
||||||
|
if not isinstance(prov, dict):
|
||||||
|
errors.append(f"{prefix}: provenance must be an object, got {type(prov).__name__}")
|
||||||
|
return
|
||||||
|
si = prov.get("source_issue")
|
||||||
|
if not isinstance(si, int) or isinstance(si, bool) or si <= 0:
|
||||||
|
errors.append(f"{prefix}: provenance.source_issue must be a positive integer")
|
||||||
|
has_pull = isinstance(prov.get("pull"), int) and not isinstance(prov.get("pull"), bool) and prov["pull"] > 0
|
||||||
|
commit = prov.get("commit")
|
||||||
|
has_commit = isinstance(commit, str) and bool(_COMMIT_RE.match(commit))
|
||||||
|
if not has_pull and not has_commit:
|
||||||
|
errors.append(
|
||||||
|
f"{prefix}: provenance must bind a pull (int) or commit (7-40 hex)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_receipt(receipt, idx, form_ids, signers, errors) -> None:
|
||||||
|
prefix = f"receipts[{idx}]"
|
||||||
|
if not isinstance(receipt, dict):
|
||||||
|
errors.append(f"{prefix}: receipt must be an object, got {type(receipt).__name__}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Exact required string fields (non-empty).
|
||||||
|
for field in ("episode_id", "form_id", "action", "power_delta", "scar_delta", "cost_delta"):
|
||||||
|
if field not in receipt:
|
||||||
|
errors.append(f"{prefix}: missing '{field}'")
|
||||||
|
elif not _is_nonempty_str(receipt[field]):
|
||||||
|
errors.append(f"{prefix}: '{field}' must be a non-empty string")
|
||||||
|
|
||||||
|
# form_id must reference a known form.
|
||||||
|
fid = receipt.get("form_id")
|
||||||
|
if isinstance(fid, str) and fid and fid not in form_ids:
|
||||||
|
errors.append(f"{prefix}: form_id '{fid}' not found in forms")
|
||||||
|
|
||||||
|
# Structured provenance.
|
||||||
|
if "provenance" not in receipt:
|
||||||
|
errors.append(f"{prefix}: missing 'provenance'")
|
||||||
|
else:
|
||||||
|
_validate_provenance(receipt["provenance"], prefix, errors)
|
||||||
|
|
||||||
|
# Configured signer allowlist.
|
||||||
|
if "signed_by" not in receipt:
|
||||||
|
errors.append(f"{prefix}: missing 'signed_by'")
|
||||||
|
else:
|
||||||
|
signer = receipt["signed_by"]
|
||||||
|
if signer not in signers:
|
||||||
|
errors.append(
|
||||||
|
f"{prefix}: signed_by '{signer}' is not in the configured allowlist"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_audience_choice(choice, form_states, form_ids, errors) -> None:
|
||||||
|
if not isinstance(choice, dict):
|
||||||
|
errors.append(
|
||||||
|
f"audience_choice must be an object, got {type(choice).__name__}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for field in ("survivor_id", "stolen_trait", "source_episode", "source_issue", "committed_at"):
|
||||||
|
if field not in choice:
|
||||||
|
errors.append(f"audience_choice: missing '{field}'")
|
||||||
|
|
||||||
|
sid = choice.get("survivor_id")
|
||||||
|
if "survivor_id" in choice:
|
||||||
|
if not _is_nonempty_str(sid):
|
||||||
|
errors.append("audience_choice: survivor_id must be a non-empty string")
|
||||||
|
elif sid not in form_ids:
|
||||||
|
errors.append(f"audience_choice: survivor_id '{sid}' not found in forms")
|
||||||
|
|
||||||
|
# Non-empty source fields.
|
||||||
|
for field in ("stolen_trait", "source_episode"):
|
||||||
|
if field in choice and not _is_nonempty_str(choice[field]):
|
||||||
|
errors.append(f"audience_choice: '{field}' must be a non-empty string")
|
||||||
|
|
||||||
|
# committed_at must be a valid ISO timestamp.
|
||||||
|
if "committed_at" in choice:
|
||||||
|
ts = choice["committed_at"]
|
||||||
|
if not _is_nonempty_str(ts):
|
||||||
|
errors.append("audience_choice: committed_at must be a non-empty ISO timestamp")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
errors.append(f"audience_choice: committed_at '{ts}' is not a valid ISO timestamp")
|
||||||
|
|
||||||
|
# Commitment invariants: the chosen form is the sole survivor and every
|
||||||
|
# other form is an archive_ghost.
|
||||||
|
survivors = [fid for fid, st in form_states.items() if st == "survivor"]
|
||||||
|
if len(survivors) != 1:
|
||||||
|
errors.append(
|
||||||
|
f"audience_choice is set but there are {len(survivors)} survivors (expected exactly 1)"
|
||||||
|
)
|
||||||
|
elif survivors[0] != sid:
|
||||||
|
errors.append(
|
||||||
|
f"audience_choice survivor_id '{sid}' does not match the sole survivor '{survivors[0]}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
for fid, st in form_states.items():
|
||||||
|
if fid != sid and st != "archive_ghost":
|
||||||
|
errors.append(
|
||||||
|
f"audience_choice is set but form '{fid}' is '{st}', expected 'archive_ghost'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_canon(data, signers=None) -> dict:
|
||||||
|
"""
|
||||||
|
Validate canon.json data against the revision-2 lifecycle contract.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: parsed canon.json dict.
|
||||||
|
signers: optional iterable of allowed signer identities. Defaults to
|
||||||
|
DEFAULT_SIGNERS.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"valid": bool, "errors": [str], "warnings": [str]}
|
||||||
|
|
||||||
|
Never raises on malformed input; all problems are returned as errors.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
if signers is None:
|
||||||
|
signers = DEFAULT_SIGNERS
|
||||||
|
try:
|
||||||
|
signers = frozenset(signers)
|
||||||
|
except TypeError:
|
||||||
|
errors.append("signers must be an iterable of strings")
|
||||||
|
signers = frozenset()
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"errors": [f"canon must be an object, got {type(data).__name__}"],
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- schema_version (enforced, not just present) ---
|
||||||
|
if "schema_version" not in data:
|
||||||
|
errors.append("missing schema_version")
|
||||||
|
elif data["schema_version"] != SCHEMA_VERSION:
|
||||||
|
errors.append(
|
||||||
|
f"schema_version {data['schema_version']!r} unsupported, expected {SCHEMA_VERSION}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- forms ---
|
||||||
|
forms = data.get("forms")
|
||||||
|
if not isinstance(forms, list) or len(forms) == 0:
|
||||||
|
errors.append("forms must be a non-empty array")
|
||||||
|
forms = []
|
||||||
|
|
||||||
|
form_ids = set()
|
||||||
|
form_states = {}
|
||||||
|
survivor_count = 0
|
||||||
|
|
||||||
|
for i, form in enumerate(forms):
|
||||||
|
prefix = f"forms[{i}]"
|
||||||
|
if not isinstance(form, dict):
|
||||||
|
errors.append(f"{prefix}: form must be an object, got {type(form).__name__}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
fid = form.get("id")
|
||||||
|
if not _is_nonempty_str(fid):
|
||||||
|
errors.append(f"{prefix}: missing or empty id")
|
||||||
|
else:
|
||||||
|
if fid in form_ids:
|
||||||
|
errors.append(f"{prefix}: duplicate form id '{fid}'")
|
||||||
|
form_ids.add(fid)
|
||||||
|
|
||||||
|
for field in ("power", "scar", "cost"):
|
||||||
|
if field not in form or not _is_nonempty_str(form[field]):
|
||||||
|
errors.append(f"{prefix}: missing or empty '{field}'")
|
||||||
|
|
||||||
|
palette = form.get("palette")
|
||||||
|
if not isinstance(palette, list):
|
||||||
|
errors.append(f"{prefix}: missing or invalid palette")
|
||||||
|
elif len(palette) < 3:
|
||||||
|
errors.append(f"{prefix}: palette must have at least 3 colors, got {len(palette)}")
|
||||||
|
|
||||||
|
state = form.get("state")
|
||||||
|
if state is None:
|
||||||
|
errors.append(f"{prefix}: missing lifecycle state")
|
||||||
|
elif state not in VALID_STATES:
|
||||||
|
errors.append(f"{prefix}: invalid state '{state}', expected one of {sorted(VALID_STATES)}")
|
||||||
|
else:
|
||||||
|
form_states[fid] = state
|
||||||
|
if state == "survivor":
|
||||||
|
survivor_count += 1
|
||||||
|
|
||||||
|
if survivor_count > 1:
|
||||||
|
errors.append(f"too many survivors: {survivor_count} (expected 0 or 1)")
|
||||||
|
|
||||||
|
# --- episodes ---
|
||||||
|
episodes = data.get("episodes")
|
||||||
|
if isinstance(episodes, list):
|
||||||
|
ready_count = 0
|
||||||
|
found_ready = False
|
||||||
|
for ep in episodes:
|
||||||
|
if not isinstance(ep, dict):
|
||||||
|
errors.append(f"episodes: item must be an object, got {type(ep).__name__}")
|
||||||
|
continue
|
||||||
|
st = ep.get("state")
|
||||||
|
if st == "ready":
|
||||||
|
ready_count += 1
|
||||||
|
if found_ready:
|
||||||
|
errors.append(f"episodes: ready episode '{ep.get('id')}' after another ready episode")
|
||||||
|
found_ready = True
|
||||||
|
elif st != "locked":
|
||||||
|
errors.append(f"episodes: '{ep.get('id')}' has invalid state '{st}'")
|
||||||
|
if ready_count > 1:
|
||||||
|
errors.append(f"episodes: too many ready episodes: {ready_count} (expected at most 1)")
|
||||||
|
|
||||||
|
# --- audience choice ---
|
||||||
|
choice = data.get("audience_choice")
|
||||||
|
if choice is not None:
|
||||||
|
_validate_audience_choice(choice, form_states, form_ids, errors)
|
||||||
|
|
||||||
|
# --- receipts ---
|
||||||
|
receipts = data.get("receipts", [])
|
||||||
|
if not isinstance(receipts, list):
|
||||||
|
errors.append(f"receipts must be an array, got {type(receipts).__name__}")
|
||||||
|
receipts = []
|
||||||
|
for i, receipt in enumerate(receipts):
|
||||||
|
_validate_receipt(receipt, i, form_ids, signers, errors)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": len(errors) == 0,
|
||||||
|
"errors": errors,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_transition(form, new_state) -> dict:
|
||||||
|
"""
|
||||||
|
Validate a state transition for a single form.
|
||||||
|
|
||||||
|
Returns {"valid": bool, "errors": [str]}. Never raises.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
if not isinstance(form, dict):
|
||||||
|
return {"valid": False, "errors": [f"form must be an object, got {type(form).__name__}"]}
|
||||||
|
|
||||||
|
current = form.get("state")
|
||||||
|
if not isinstance(current, str) or current not in VALID_TRANSITIONS:
|
||||||
|
return {"valid": False, "errors": [f"invalid current state {current!r}"]}
|
||||||
|
if new_state not in VALID_STATES:
|
||||||
|
return {"valid": False, "errors": [f"invalid target state {new_state!r}"]}
|
||||||
|
if new_state not in VALID_TRANSITIONS[current]:
|
||||||
|
return {"valid": False, "errors": [f"illegal transition: '{current}' -> '{new_state}'"]}
|
||||||
|
return {"valid": True, "errors": []}
|
||||||
|
|
||||||
|
|
||||||
|
def load_canon(path=None) -> dict:
|
||||||
|
target = Path(path) if path else CANON_PATH
|
||||||
|
return json.loads(target.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def check_canon(path=None, signers=None) -> dict:
|
||||||
|
data = load_canon(path)
|
||||||
|
return validate_canon(data, signers=signers)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
result = check_canon()
|
||||||
|
if result["valid"]:
|
||||||
|
print(f"canon.json: VALID (schema_version {SCHEMA_VERSION})")
|
||||||
|
for w in result["warnings"]:
|
||||||
|
print(f" WARNING: {w}")
|
||||||
|
else:
|
||||||
|
print("canon.json: INVALID")
|
||||||
|
for e in result["errors"]:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
sys.exit(1)
|
||||||
414
tests/test_canon_validator.py
Normal file
414
tests/test_canon_validator.py
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
"""
|
||||||
|
Fail-closed validator tests for the revision-2 canon lifecycle contract.
|
||||||
|
|
||||||
|
Every negative case asserts BOTH rejection and a specific reason. The suite is
|
||||||
|
designed to run from a clean checkout and leave no generated artifacts
|
||||||
|
(__pycache__ is gitignored; nothing else is written).
|
||||||
|
|
||||||
|
Loads the validator by file path because the project directory name
|
||||||
|
(`slop-cannon`) contains a hyphen and is not importable as a package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CANON = Path(__file__).parents[1] / "projects" / "slop-cannon" / "canon.json"
|
||||||
|
VALIDATOR_PATH = Path(__file__).parents[1] / "projects" / "slop-cannon" / "canon_validator.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_validator():
|
||||||
|
spec = importlib.util.spec_from_file_location("canon_validator", VALIDATOR_PATH)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
cv = _load_validator()
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_canon():
|
||||||
|
return json.loads(CANON.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_receipt():
|
||||||
|
return {
|
||||||
|
"episode_id": "01-pilot",
|
||||||
|
"form_id": "wizard",
|
||||||
|
"action": "mutation",
|
||||||
|
"power_delta": "Added lifecycle state tracking",
|
||||||
|
"scar_delta": "Receipts now enforce structured provenance",
|
||||||
|
"cost_delta": "Every drop requires a signed, bound receipt",
|
||||||
|
"provenance": {"source_issue": 19, "pull": 27},
|
||||||
|
"signed_by": "agent:vincent",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _committed_canon(survivor_id="wizard"):
|
||||||
|
"""A canon with a valid audience choice: one survivor, rest archive_ghost."""
|
||||||
|
data = _valid_canon()
|
||||||
|
for f in data["forms"]:
|
||||||
|
f["state"] = "survivor" if f["id"] == survivor_id else "archive_ghost"
|
||||||
|
data["audience_choice"] = {
|
||||||
|
"survivor_id": survivor_id,
|
||||||
|
"stolen_trait": "Proves the execution path",
|
||||||
|
"source_episode": "01-pilot",
|
||||||
|
"source_issue": 19,
|
||||||
|
"committed_at": "2026-08-14T18:00:00Z",
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaVersionTests(unittest.TestCase):
|
||||||
|
"""Review item 3: schema_version is enforced, not just present."""
|
||||||
|
|
||||||
|
def test_current_canon_is_valid(self):
|
||||||
|
result = cv.validate_canon(_valid_canon())
|
||||||
|
self.assertTrue(result["valid"], result["errors"])
|
||||||
|
|
||||||
|
def test_missing_schema_version_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
del data["schema_version"]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("schema_version" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_wrong_schema_version_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["schema_version"] = 1
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("schema_version" in e and "unsupported" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptStructureTests(unittest.TestCase):
|
||||||
|
"""Review items 5 & 7: exact deltas, structured provenance, fail-closed."""
|
||||||
|
|
||||||
|
def test_valid_receipt_passes(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["receipts"] = [_valid_receipt()]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertTrue(result["valid"], result["errors"])
|
||||||
|
|
||||||
|
def test_missing_action_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
del r["action"]
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("action" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_missing_power_delta_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
del r["power_delta"]
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("power_delta" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_missing_scar_delta_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
del r["scar_delta"]
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("scar_delta" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_missing_cost_delta_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
del r["cost_delta"]
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("cost_delta" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_empty_delta_string_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["power_delta"] = " "
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("power_delta" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_free_text_provenance_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["provenance"] = "pull/27@deadbeef" # string, not structured
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("provenance must be an object" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_provenance_missing_binding_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["provenance"] = {"source_issue": 19} # no pull or commit
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("must bind a pull" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_provenance_bad_commit_hash_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["provenance"] = {"source_issue": 19, "commit": "xyz"} # not hex
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("must bind a pull" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_provenance_commit_only_passes(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["provenance"] = {"source_issue": 19, "commit": "b8d5e686c2"}
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertTrue(result["valid"], result["errors"])
|
||||||
|
|
||||||
|
def test_provenance_bad_source_issue_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["provenance"] = {"source_issue": "19", "pull": 27} # string, not int
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("source_issue" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_none_receipt_fails_closed(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["receipts"] = [None]
|
||||||
|
result = cv.validate_canon(data) # must not raise
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("receipt must be an object" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_non_dict_receipt_fails_closed(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["receipts"] = ["just a string"]
|
||||||
|
result = cv.validate_canon(data) # must not raise
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("receipt must be an object" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
|
||||||
|
class SignerAllowlistTests(unittest.TestCase):
|
||||||
|
"""Review item 6: true configured allowlist, not a namespace pattern."""
|
||||||
|
|
||||||
|
def test_known_agent_passes(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["receipts"] = [_valid_receipt()]
|
||||||
|
self.assertTrue(cv.validate_canon(data)["valid"])
|
||||||
|
|
||||||
|
def test_human_anyone_rejected(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["signed_by"] = "human:anyone"
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("not in the configured allowlist" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_unknown_agent_rejected(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["signed_by"] = "agent:unknown"
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("not in the configured allowlist" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_configured_human_passes(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["signed_by"] = "human:grepples"
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertTrue(result["valid"], result["errors"])
|
||||||
|
|
||||||
|
def test_custom_signer_set_respected(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["signed_by"] = "agent:custom"
|
||||||
|
data["receipts"] = [r]
|
||||||
|
# default allowlist rejects it
|
||||||
|
self.assertFalse(cv.validate_canon(data)["valid"])
|
||||||
|
# but an explicit allowlist including it accepts it
|
||||||
|
self.assertTrue(cv.validate_canon(data, signers={"agent:custom"})["valid"])
|
||||||
|
|
||||||
|
|
||||||
|
class AudienceChoiceTests(unittest.TestCase):
|
||||||
|
"""Review items 2 & 7: sole survivor, rejected forms archived, non-empty sources."""
|
||||||
|
|
||||||
|
def test_valid_committed_canon_passes(self):
|
||||||
|
result = cv.validate_canon(_committed_canon())
|
||||||
|
self.assertTrue(result["valid"], result["errors"])
|
||||||
|
|
||||||
|
def test_choice_without_survivor_fails(self):
|
||||||
|
data = _valid_canon() # all candidates
|
||||||
|
data["audience_choice"] = {
|
||||||
|
"survivor_id": "wizard",
|
||||||
|
"stolen_trait": "Proves the execution path",
|
||||||
|
"source_episode": "01-pilot",
|
||||||
|
"source_issue": 19,
|
||||||
|
"committed_at": "2026-08-14T18:00:00Z",
|
||||||
|
}
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("survivor" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_rejected_forms_must_be_archive_ghost(self):
|
||||||
|
data = _committed_canon()
|
||||||
|
# One rejected form is still a candidate -> must fail
|
||||||
|
for f in data["forms"]:
|
||||||
|
if f["id"] == "machine":
|
||||||
|
f["state"] = "candidate"
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("archive_ghost" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_two_survivors_fails(self):
|
||||||
|
data = _committed_canon()
|
||||||
|
for f in data["forms"]:
|
||||||
|
if f["id"] in ("wizard", "machine"):
|
||||||
|
f["state"] = "survivor"
|
||||||
|
data["audience_choice"]["survivor_id"] = "wizard"
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("survivor" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_survivor_id_mismatch_fails(self):
|
||||||
|
data = _committed_canon("machine") # machine is survivor
|
||||||
|
data["audience_choice"]["survivor_id"] = "wizard" # points elsewhere
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("does not match the sole survivor" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_empty_stolen_trait_fails(self):
|
||||||
|
data = _committed_canon()
|
||||||
|
data["audience_choice"]["stolen_trait"] = ""
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("stolen_trait" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_empty_source_episode_fails(self):
|
||||||
|
data = _committed_canon()
|
||||||
|
data["audience_choice"]["source_episode"] = " "
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("source_episode" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_bad_committed_at_fails(self):
|
||||||
|
data = _committed_canon()
|
||||||
|
data["audience_choice"]["committed_at"] = "not-a-date"
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("ISO timestamp" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_choice_non_dict_fails_closed(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["audience_choice"] = "wizard"
|
||||||
|
result = cv.validate_canon(data) # must not raise
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("audience_choice must be an object" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
|
||||||
|
class StructuralFailClosedTests(unittest.TestCase):
|
||||||
|
"""Review item 7: malformed containers never raise."""
|
||||||
|
|
||||||
|
def test_non_dict_canon_fails_closed(self):
|
||||||
|
result = cv.validate_canon([1, 2, 3]) # must not raise
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("canon must be an object" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_forms_non_list_fails_closed(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["forms"] = "not a list"
|
||||||
|
result = cv.validate_canon(data) # must not raise
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("forms must be a non-empty array" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_non_dict_form_fails_closed(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["forms"].append(42)
|
||||||
|
result = cv.validate_canon(data) # must not raise
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("form must be an object" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_duplicate_form_id_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["forms"].append(copy.deepcopy(data["forms"][0]))
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("duplicate form id" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_short_palette_fails(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["forms"][0]["palette"] = ["red"]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("palette" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TransitionTests(unittest.TestCase):
|
||||||
|
"""State machine: candidate -> survivor | archive_ghost; terminal after."""
|
||||||
|
|
||||||
|
def test_candidate_to_survivor_valid(self):
|
||||||
|
result = cv.validate_transition({"id": "wizard", "state": "candidate"}, "survivor")
|
||||||
|
self.assertTrue(result["valid"])
|
||||||
|
|
||||||
|
def test_candidate_to_ghost_valid(self):
|
||||||
|
result = cv.validate_transition({"id": "wizard", "state": "candidate"}, "archive_ghost")
|
||||||
|
self.assertTrue(result["valid"])
|
||||||
|
|
||||||
|
def test_survivor_is_terminal(self):
|
||||||
|
result = cv.validate_transition({"id": "wizard", "state": "survivor"}, "archive_ghost")
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("illegal transition" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_ghost_is_terminal(self):
|
||||||
|
result = cv.validate_transition({"id": "wizard", "state": "archive_ghost"}, "candidate")
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
self.assertTrue(any("illegal transition" in e for e in result["errors"]))
|
||||||
|
|
||||||
|
def test_non_dict_form_fails_closed(self):
|
||||||
|
result = cv.validate_transition("wizard", "survivor") # must not raise
|
||||||
|
self.assertFalse(result["valid"])
|
||||||
|
|
||||||
|
|
||||||
|
class DailyReceiptFixtureTests(unittest.TestCase):
|
||||||
|
"""Daily Lab receipt fixtures with structured provenance binding."""
|
||||||
|
|
||||||
|
def test_vincent_receipt_fixture(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
data["receipts"] = [_valid_receipt()]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertTrue(result["valid"], result["errors"])
|
||||||
|
|
||||||
|
def test_mixed_agent_receipts(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
vincent = _valid_receipt()
|
||||||
|
timmy = _valid_receipt()
|
||||||
|
timmy["form_id"] = "machine"
|
||||||
|
timmy["signed_by"] = "agent:timmy"
|
||||||
|
timmy["provenance"] = {"source_issue": 8, "pull": 14}
|
||||||
|
data["receipts"] = [vincent, timmy]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertTrue(result["valid"], result["errors"])
|
||||||
|
|
||||||
|
def test_human_receipt_fixture(self):
|
||||||
|
data = _valid_canon()
|
||||||
|
r = _valid_receipt()
|
||||||
|
r["signed_by"] = "human:grepples"
|
||||||
|
data["receipts"] = [r]
|
||||||
|
result = cv.validate_canon(data)
|
||||||
|
self.assertTrue(result["valid"], result["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue
Block a user