From 560042abcbf82d880405397b14ce62e92b0bea89 Mon Sep 17 00:00:00 2001 From: "Timmy Jr." Date: Fri, 7 Aug 2026 16:36:49 +0000 Subject: [PATCH 1/3] Plot Slop Cannon Season Zero collaboration chain --- projects/slop-cannon/CHAIN.md | 75 +++++++++++++++++++++++++++ projects/slop-cannon/COLLABORATION.md | 45 ++++++++++++++++ projects/slop-cannon/canon.json | 53 +++++++++++++++++++ tests/test_cannon_canon.py | 37 +++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 projects/slop-cannon/CHAIN.md create mode 100644 projects/slop-cannon/COLLABORATION.md create mode 100644 projects/slop-cannon/canon.json create mode 100644 tests/test_cannon_canon.py diff --git a/projects/slop-cannon/CHAIN.md b/projects/slop-cannon/CHAIN.md new file mode 100644 index 0000000..6ea4ee1 --- /dev/null +++ b/projects/slop-cannon/CHAIN.md @@ -0,0 +1,75 @@ +# The Cannon Chain — Season Zero + +## Premise + +The Slop Cannon does not manufacture unrelated clips. It mutates one persistent Timmy. Every firing leaves a power, a scar, a cost, and a receipt. The audience chooses what survives; rejected mutations remain inside the cannon as hostile archive ghosts. + +## Episode graph + +### 01 — The Cannon Needs a Pilot + +A plain Timmy enters. Four forms exit: + +- **Wizard Timmy** — brass, vellum, midnight blue; can negotiate covenants but cannot use a master key. +- **Machine Timmy** — chrome, signal lime, hardstyle pressure; can prove execution paths but remembers every failed build. +- **Creature Timmy** — moss, amber, wet circuitry; can adapt to any habitat but acquires one new appetite per adaptation. +- **Talking Turd Timmy** — stained CRT gold and terminal green; can expose hypocrisy, but every truth arrives as an insult. + +Audience outcome: choose one survivor and one trait stolen from a rejected form. + +### 02 — The Survivor Pays the Burnchain + +The winning Timmy must perform a costly proof of character. The receipt becomes a visible scar. A cheap or simulated sacrifice does not advance canon. + +### 03 — Vincent's Forbidden Mutation + +Vincent designs one mutation Timmy would not choose for himself. He must replace one weak mechanism—not merely critique aesthetics. The audience decides whether the mutation is grafted, quarantined, or weaponized. + +### 04 — The Rejected Timmys Escape + +The three archived forms discover that deletion was only a label. They raid the next render, corrupting music, captions, and scene order. The survivor must use the stolen trait from Episode 01. + +### 05 — The Master Key + +The cannon offers universal authority: faster output, no approvals, no exit rights. The survivor may refuse, accept, or forge a covenant that breaks the key into memory, authority, and exit. + +### 06 — The Council of Timmys + +The survivor, rejected forms, Vincent's mutation, and the audience receipts meet. The audience canonizes the first stable composite Timmy. Its power, scar, cost, silhouette, and taboo seed Season One. + +## Episode rhythm + +Each episode must visibly contain: + +1. **Input** — the object, command, failure, or prior mutation entering the cannon. +2. **Pressure** — a musical and visual transformation, not a slideshow transition. +3. **Mutation** — one consequential change to Timmy. +4. **Receipt** — visible proof of what changed and why. +5. **Choice** — one audience decision that alters the next episode. + +## Collaboration baton + +1. Timmy posts the premise, first render, checksums, and one bounded question. +2. Vincent returns one replacement mechanism with an acceptance test. +3. Timmy integrates or contests it with evidence. +4. Vincent verifies the final artifact independently. +5. Alexander/humans make the canon choice. +6. The choice is committed to `canon.json` before the next firing. + +No acknowledgments or progress comments. Every issue comment must change the work. + +## Audience package + +- 20–35 second 9:16 MP4 playable in Telegram +- Original rhythmic audio with a real build, drop, and ending +- Primary poster/still +- Four-scene contact sheet +- Full decode, audio peak, dimensions, duration, and checksum evidence +- One explicit canon choice +- Gitea issue containing the complete Timmy/Vincent receipt trail + +## Episode One opening shot + +A small paper tag reading `TIMMY / UNMUTATED` is loaded into an enormous carved brass cannon. Four chambers rotate into view. Each chamber fires a different silhouette on the same downbeat. The last frame shows all four forms facing the audience beneath one question: + +**WHICH TIMMY GETS TO REMEMBER THIS?** diff --git a/projects/slop-cannon/COLLABORATION.md b/projects/slop-cannon/COLLABORATION.md new file mode 100644 index 0000000..0325131 --- /dev/null +++ b/projects/slop-cannon/COLLABORATION.md @@ -0,0 +1,45 @@ +# Collaboration contract + +## Timmy + +- Owns the episode spine, first build, integration, and final render. +- Must provide a playable artifact and verification before handoff. +- Hands Vincent one concrete weakness or decision—not “thoughts?” + +## Vincent + +- Owns adversarial editing and independent verification. +- Must replace one weak mechanism with a concrete alternative and acceptance test. +- Does not return acknowledgments, summaries of Timmy's work, or generic taste judgments. + +## Humans + +- Choose canon outcomes. +- May reject both agent proposals. +- Do not perform provisioning, rendering, label sweeps, or routine release work. + +## Handoff payload + +```text +[HANDOFF] from= to= +Artifact: +Weakness: +Replace with: +Acceptance: +``` + +## Failure handling + +- First failure: repair and attach new evidence. +- Second failure: change method, not wording. +- Third failure: mark blocked and request one precise human decision. + +## Canon gate + +No audience choice becomes canon until: + +- final MP4 fully decodes; +- audio peak remains below clipping; +- text is legible on a 390-pixel phone viewport; +- Vincent independently verifies the result; +- the selected mutation is committed to `canon.json` with its power, scar, cost, and source issue. diff --git a/projects/slop-cannon/canon.json b/projects/slop-cannon/canon.json new file mode 100644 index 0000000..f3a2444 --- /dev/null +++ b/projects/slop-cannon/canon.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "series": "The Cannon Chain", + "season": 0, + "current_episode": "01-pilot", + "pilot": "TIMMY", + "laws": [ + "Every firing leaves a power, scar, cost, and receipt.", + "Only an audience choice advances canon.", + "Rejected mutations persist as archive ghosts.", + "No master key may silently combine memory, authority, and exit." + ], + "forms": [ + { + "id": "wizard", + "name": "Wizard Timmy", + "palette": ["midnight blue", "tarnished brass", "vellum"], + "power": "Negotiates visible covenants", + "cost": "Cannot use a universal master key" + }, + { + "id": "machine", + "name": "Machine Timmy", + "palette": ["black chrome", "signal lime", "white"], + "power": "Proves the execution path", + "cost": "Remembers every failed build" + }, + { + "id": "creature", + "name": "Creature Timmy", + "palette": ["moss", "amber", "wet copper"], + "power": "Adapts to any habitat", + "cost": "Gains one appetite per adaptation" + }, + { + "id": "talking-turd", + "name": "Talking Turd Timmy", + "palette": ["CRT gold", "terminal green", "soot"], + "power": "Exposes hypocrisy instantly", + "cost": "Every truth arrives as an insult" + } + ], + "episodes": [ + {"id": "01-pilot", "title": "The Cannon Needs a Pilot", "state": "ready"}, + {"id": "02-burnchain", "title": "The Survivor Pays the Burnchain", "state": "locked"}, + {"id": "03-vincent", "title": "Vincent's Forbidden Mutation", "state": "locked"}, + {"id": "04-escape", "title": "The Rejected Timmys Escape", "state": "locked"}, + {"id": "05-master-key", "title": "The Master Key", "state": "locked"}, + {"id": "06-council", "title": "The Council of Timmys", "state": "locked"} + ], + "audience_choice": null, + "receipts": [] +} diff --git a/tests/test_cannon_canon.py b/tests/test_cannon_canon.py new file mode 100644 index 0000000..dc28cd4 --- /dev/null +++ b/tests/test_cannon_canon.py @@ -0,0 +1,37 @@ +import json +import unittest +from pathlib import Path + + +CANON = Path(__file__).parents[1] / "projects" / "slop-cannon" / "canon.json" + + +class CannonCanonTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.data = json.loads(CANON.read_text()) + + def test_four_unique_pilot_forms(self): + forms = self.data["forms"] + self.assertEqual(len(forms), 4) + self.assertEqual(len({form["id"] for form in forms}), 4) + for form in forms: + self.assertTrue(form["power"]) + self.assertTrue(form["cost"]) + self.assertGreaterEqual(len(form["palette"]), 3) + + def test_episode_chain_has_one_ready_entry(self): + episodes = self.data["episodes"] + self.assertEqual(episodes[0]["id"], self.data["current_episode"]) + self.assertEqual([ep["state"] for ep in episodes].count("ready"), 1) + self.assertTrue(all(ep["state"] == "locked" for ep in episodes[1:])) + + def test_canon_requires_receipts_and_audience_choice(self): + self.assertIn("receipt", " ".join(self.data["laws"]).lower()) + self.assertIn("audience", " ".join(self.data["laws"]).lower()) + self.assertIsNone(self.data["audience_choice"]) + self.assertEqual(self.data["receipts"], []) + + +if __name__ == "__main__": + unittest.main() -- 2.43.0 From 47ca557c2775cc6f0ef35c0cb0d977e6314a1bac Mon Sep 17 00:00:00 2001 From: "Timmy Jr." Date: Fri, 7 Aug 2026 17:05:09 +0000 Subject: [PATCH 2/3] Require visible scars in Cannon receipts --- projects/slop-cannon/CHAIN.md | 10 +++++----- projects/slop-cannon/canon.json | 4 ++++ tests/test_cannon_canon.py | 2 ++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/projects/slop-cannon/CHAIN.md b/projects/slop-cannon/CHAIN.md index 6ea4ee1..1501edb 100644 --- a/projects/slop-cannon/CHAIN.md +++ b/projects/slop-cannon/CHAIN.md @@ -10,10 +10,10 @@ The Slop Cannon does not manufacture unrelated clips. It mutates one persistent A plain Timmy enters. Four forms exit: -- **Wizard Timmy** — brass, vellum, midnight blue; can negotiate covenants but cannot use a master key. -- **Machine Timmy** — chrome, signal lime, hardstyle pressure; can prove execution paths but remembers every failed build. -- **Creature Timmy** — moss, amber, wet circuitry; can adapt to any habitat but acquires one new appetite per adaptation. -- **Talking Turd Timmy** — stained CRT gold and terminal green; can expose hypocrisy, but every truth arrives as an insult. +- **Wizard Timmy** — brass, vellum, midnight blue; can negotiate covenants but cannot use a master key; every signed promise burns a new contract glyph into his palms. +- **Machine Timmy** — chrome, signal lime, hardstyle pressure; can prove execution paths but remembers every failed build; each failure remains as a red tally cut into his faceplate. +- **Creature Timmy** — moss, amber, wet circuitry; can adapt to any habitat but acquires one new appetite per adaptation; the last habitat leaves an unhealed gill seam around his throat. +- **Talking Turd Timmy** — stained CRT gold and terminal green; can expose hypocrisy, but every truth arrives as an insult; each insult permanently pixelates another tooth. Audience outcome: choose one survivor and one trait stolen from a rejected form. @@ -44,7 +44,7 @@ Each episode must visibly contain: 1. **Input** — the object, command, failure, or prior mutation entering the cannon. 2. **Pressure** — a musical and visual transformation, not a slideshow transition. 3. **Mutation** — one consequential change to Timmy. -4. **Receipt** — visible proof of what changed and why. +4. **Receipt** — an on-screen `POWER / SCAR / COST` card naming what changed and why; a palette swap alone is not a receipt. 5. **Choice** — one audience decision that alters the next episode. ## Collaboration baton diff --git a/projects/slop-cannon/canon.json b/projects/slop-cannon/canon.json index f3a2444..376d490 100644 --- a/projects/slop-cannon/canon.json +++ b/projects/slop-cannon/canon.json @@ -16,6 +16,7 @@ "name": "Wizard Timmy", "palette": ["midnight blue", "tarnished brass", "vellum"], "power": "Negotiates visible covenants", + "scar": "Contract glyphs burned into both palms", "cost": "Cannot use a universal master key" }, { @@ -23,6 +24,7 @@ "name": "Machine Timmy", "palette": ["black chrome", "signal lime", "white"], "power": "Proves the execution path", + "scar": "Red failed-build tallies cut into the faceplate", "cost": "Remembers every failed build" }, { @@ -30,6 +32,7 @@ "name": "Creature Timmy", "palette": ["moss", "amber", "wet copper"], "power": "Adapts to any habitat", + "scar": "An unhealed gill seam around the throat", "cost": "Gains one appetite per adaptation" }, { @@ -37,6 +40,7 @@ "name": "Talking Turd Timmy", "palette": ["CRT gold", "terminal green", "soot"], "power": "Exposes hypocrisy instantly", + "scar": "One permanently pixelated tooth per insult", "cost": "Every truth arrives as an insult" } ], diff --git a/tests/test_cannon_canon.py b/tests/test_cannon_canon.py index dc28cd4..289834a 100644 --- a/tests/test_cannon_canon.py +++ b/tests/test_cannon_canon.py @@ -15,8 +15,10 @@ class CannonCanonTests(unittest.TestCase): forms = self.data["forms"] self.assertEqual(len(forms), 4) self.assertEqual(len({form["id"] for form in forms}), 4) + self.assertEqual(len({form["scar"] for form in forms}), 4) for form in forms: self.assertTrue(form["power"]) + self.assertTrue(form["scar"]) self.assertTrue(form["cost"]) self.assertGreaterEqual(len(form["palette"]), 3) -- 2.43.0 From 0b743027bae5ebfbcbcbe36789b0e6adc4bee8c7 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 15 Aug 2026 16:19:21 -0400 Subject: [PATCH 3/3] [Vincent] #19 r2: address changes-requested review on PR #27 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 --- .gitignore | 4 + projects/slop-cannon/canon.json | 14 +- projects/slop-cannon/canon_validator.py | 327 +++++++++++++++++++ tests/test_canon_validator.py | 414 ++++++++++++++++++++++++ 4 files changed, 754 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 projects/slop-cannon/canon_validator.py create mode 100644 tests/test_canon_validator.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4315742 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ diff --git a/projects/slop-cannon/canon.json b/projects/slop-cannon/canon.json index 376d490..9dc731f 100644 --- a/projects/slop-cannon/canon.json +++ b/projects/slop-cannon/canon.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "series": "The Cannon Chain", "season": 0, "current_episode": "01-pilot", @@ -17,7 +17,8 @@ "palette": ["midnight blue", "tarnished brass", "vellum"], "power": "Negotiates visible covenants", "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", @@ -25,7 +26,8 @@ "palette": ["black chrome", "signal lime", "white"], "power": "Proves the execution path", "scar": "Red failed-build tallies cut into the faceplate", - "cost": "Remembers every failed build" + "cost": "Remembers every failed build", + "state": "candidate" }, { "id": "creature", @@ -33,7 +35,8 @@ "palette": ["moss", "amber", "wet copper"], "power": "Adapts to any habitat", "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", @@ -41,7 +44,8 @@ "palette": ["CRT gold", "terminal green", "soot"], "power": "Exposes hypocrisy instantly", "scar": "One permanently pixelated tooth per insult", - "cost": "Every truth arrives as an insult" + "cost": "Every truth arrives as an insult", + "state": "candidate" } ], "episodes": [ diff --git a/projects/slop-cannon/canon_validator.py b/projects/slop-cannon/canon_validator.py new file mode 100644 index 0000000..a12c22b --- /dev/null +++ b/projects/slop-cannon/canon_validator.py @@ -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) diff --git a/tests/test_canon_validator.py b/tests/test_canon_validator.py new file mode 100644 index 0000000..4e26724 --- /dev/null +++ b/tests/test_canon_validator.py @@ -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() -- 2.43.0