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/__init__.py b/projects/__init__.py new file mode 100644 index 0000000..1e37196 --- /dev/null +++ b/projects/__init__.py @@ -0,0 +1 @@ +"""Projects package.""" diff --git a/projects/slop_cannon/CHAIN.md b/projects/slop_cannon/CHAIN.md new file mode 100644 index 0000000..1501edb --- /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; 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. + +### 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** — 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 + +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/__init__.py b/projects/slop_cannon/__init__.py new file mode 100644 index 0000000..6d10e5d --- /dev/null +++ b/projects/slop_cannon/__init__.py @@ -0,0 +1,6 @@ +"""Cannon Chain project.""" +import sys +from pathlib import Path + +# Allow importing slop_cannon modules from the project root +sys.path.insert(0, str(Path(__file__).parents[2])) diff --git a/projects/slop_cannon/canon.json b/projects/slop_cannon/canon.json new file mode 100644 index 0000000..4fbc5eb --- /dev/null +++ b/projects/slop_cannon/canon.json @@ -0,0 +1,118 @@ +{ + "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", + "scar": "Contract glyphs burned into both palms", + "cost": "Cannot use a universal master key", + "state": "candidate" + }, + { + "id": "machine", + "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", + "state": "candidate" + }, + { + "id": "creature", + "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", + "state": "candidate" + }, + { + "id": "talking-turd", + "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", + "state": "candidate" + } + ], + "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": [], + "audience_choice_schema": { + "survivor_id": "", + "stolen_trait": "", + "source_episode": "", + "source_issue": "", + "committed_at": "" + }, + "receipt_schema": { + "episode_id": "", + "form_id": "", + "action": "", + "power_delta": "", + "scar_delta": "", + "cost_delta": "", + "provenance": "", + "signed_by": "" + } +} \ No newline at end of file diff --git a/projects/slop_cannon/canon_validator.py b/projects/slop_cannon/canon_validator.py new file mode 100644 index 0000000..afde8c3 --- /dev/null +++ b/projects/slop_cannon/canon_validator.py @@ -0,0 +1,228 @@ +""" +Dependency-free JSON Schema validator for Cannon Chain canon lifecycle. + +Validates: +- form.state transitions (candidate -> survivor | archive_ghost) +- audience_choice schema (survivor_id, stolen_trait, source_episode, source_issue, committed_at) +- receipt schema (episode_id, form_id, provenance, signed_by, deltas) +- signer allowlist (agent:vincent, agent:timmy, human:*) +- Exactly one survivor per episode +- Rejected forms promoted to archive_ghost +- Illegal state transitions rejected + +Usage: + from projects.slop_cannon.canon_validator import validate_canon + result = validate_canon(canon_data) + assert result.valid, result.errors +""" + +import json +import re +from pathlib import Path +from datetime import datetime + +CANON_PATH = Path(__file__).parent / "canon.json" + +VALID_STATES = {"candidate", "survivor", "archive_ghost"} +VALID_TRANSITIONS = { + "candidate": {"survivor", "archive_ghost"}, + "survivor": set(), # terminal + "archive_ghost": set(), # terminal +} +SIGNER_PATTERN = re.compile(r"^(agent:(?:vincent|timmy)|human:\w+)$") + + +def validate_canon(data: dict) -> dict: + """ + Validate canon.json data against lifecycle schemas. + + Returns: + {"valid": bool, "errors": list[str], "warnings": list[str]} + """ + errors = [] + warnings = [] + + # --- Top-level structure --- + if "schema_version" not in data: + errors.append("Missing schema_version") + if "forms" not in data: + errors.append("Missing forms array") + return {"valid": False, "errors": errors, "warnings": warnings} + + forms = data["forms"] + if not isinstance(forms, list) or len(forms) == 0: + errors.append("forms must be a non-empty array") + return {"valid": False, "errors": errors, "warnings": warnings} + + form_ids = set() + survivor_count = 0 + form_id_map = {} + + for i, form in enumerate(forms): + prefix = f"forms[{i}]" + + if "id" not in form: + errors.append(f"{prefix}: missing id") + continue + + fid = form["id"] + form_id_map[fid] = i + if fid in form_ids: + errors.append(f"{prefix}: duplicate form id '{fid}'") + form_ids.add(fid) + + # Required fields + for field in ["power", "scar", "cost"]: + if field not in form or not form[field]: + errors.append(f"{prefix}: missing or empty '{field}'") + + # Palette + if "palette" not in form or not isinstance(form["palette"], list): + errors.append(f"{prefix}: missing or invalid palette") + elif len(form["palette"]) < 3: + errors.append(f"{prefix}: palette must have at least 3 colors, got {len(form['palette'])}") + + # Lifecycle state + if "state" not in form: + errors.append(f"{prefix}: missing lifecycle state") + elif form["state"] not in VALID_STATES: + errors.append(f"{prefix}: invalid state '{form['state']}', expected one of {VALID_STATES}") + else: + if form["state"] == "survivor": + survivor_count += 1 + + # Exactly one survivor (or zero if no choice yet) + if survivor_count > 1: + errors.append(f"Too many survivors: {survivor_count} (expected 0 or 1)") + + # --- Episodes --- + if "episodes" in data: + episodes = data["episodes"] + ready_count = sum(1 for ep in episodes if ep.get("state") == "ready") + if ready_count > 1: + errors.append(f"Too many ready episodes: {ready_count} (expected at most 1)") + + # Check ordering: first ready, rest locked + found_ready = False + for ep in episodes: + if ep.get("state") == "ready": + if found_ready: + errors.append(f"Ready episode '{ep['id']}' after another ready episode") + found_ready = True + elif ep.get("state") != "locked": + errors.append(f"Episode '{ep['id']}' has invalid state '{ep.get('state')}'") + + # --- Audience choice schema --- + if data.get("audience_choice") is not None: + choice = data["audience_choice"] + 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}'") + + # survivor_id must reference a valid form + if "survivor_id" in choice: + if choice["survivor_id"] not in form_id_map: + errors.append(f"audience_choice: survivor_id '{choice['survivor_id']}' not found in forms") + + # committed_at must be valid ISO timestamp + if "committed_at" in choice: + try: + datetime.fromisoformat(choice["committed_at"]) + except (ValueError, TypeError): + errors.append(f"audience_choice: committed_at is not a valid ISO timestamp") + + # If choice is made, exactly one survivor required + if survivor_count == 0: + errors.append("audience_choice is set but no survivor found in forms") + + # --- Receipts --- + if "receipts" in data: + for i, receipt in enumerate(data["receipts"]): + prefix = f"receipts[{i}]" + + for field in ["episode_id", "form_id", "provenance", "signed_by"]: + if field not in receipt or not receipt[field]: + errors.append(f"{prefix}: missing or empty '{field}'") + + # Signed-by allowlist + if "signed_by" in receipt: + if not SIGNER_PATTERN.match(receipt["signed_by"]): + errors.append( + f"{prefix}: invalid signed_by '{receipt['signed_by']}', " + f"expected pattern: {SIGNER_PATTERN.pattern}" + ) + + # Form_id must reference a valid form + if "form_id" in receipt: + if receipt["form_id"] not in form_id_map: + errors.append(f"{prefix}: form_id '{receipt['form_id']}' not found in forms") + + # Deltas + for delta in ["power_delta", "scar_delta", "cost_delta"]: + if delta in receipt and not receipt[delta]: + warnings.append(f"{prefix}: {delta} is present but empty") + + return { + "valid": len(errors) == 0, + "errors": errors, + "warnings": warnings, + } + + +def validate_transition(form: dict, new_state: str) -> dict: + """ + Validate a state transition for a single form. + + Returns: + {"valid": bool, "errors": list[str]} + """ + errors = [] + current = form.get("state") + if not current: + errors.append("Form has no current state") + return {"valid": False, "errors": errors} + + if current not in VALID_TRANSITIONS: + errors.append(f"Invalid current state '{current}'") + return {"valid": False, "errors": errors} + + if new_state not in VALID_STATES: + errors.append(f"Invalid target state '{new_state}', expected one of {VALID_STATES}") + return {"valid": False, "errors": errors} + + if new_state not in VALID_TRANSITIONS.get(current, set()): + errors.append( + f"Illegal transition: '{current}' -> '{new_state}'" + ) + return {"valid": False, "errors": errors} + + return {"valid": True, "errors": []} + + +def load_canon(path: str = None) -> dict: + """Load canon.json from the default path or a given path.""" + target = Path(path) if path else CANON_PATH + return json.loads(target.read_text()) + + +def check_canon(path: str = None) -> dict: + """Load and validate canon.json. Convenience entry point.""" + data = load_canon(path) + return validate_canon(data) + + +if __name__ == "__main__": + import sys + + result = check_canon() + + if result["valid"]: + print(f"canon.json: VALID") + if result["warnings"]: + for w in result["warnings"]: + print(f" WARNING: {w}") + else: + print(f"canon.json: INVALID") + for e in result["errors"]: + print(f" ERROR: {e}") + sys.exit(1) diff --git a/scripts/__pycache__/lab_loop.cpython-313.pyc b/scripts/__pycache__/lab_loop.cpython-313.pyc new file mode 100644 index 0000000..2205c69 Binary files /dev/null and b/scripts/__pycache__/lab_loop.cpython-313.pyc differ diff --git a/scripts/__pycache__/open_daily_cycle.cpython-313.pyc b/scripts/__pycache__/open_daily_cycle.cpython-313.pyc new file mode 100644 index 0000000..689021e Binary files /dev/null and b/scripts/__pycache__/open_daily_cycle.cpython-313.pyc differ diff --git a/tests/__pycache__/test_cannon_canon.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_cannon_canon.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..8e34a17 Binary files /dev/null and b/tests/__pycache__/test_cannon_canon.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_daily_cycle.cpython-313.pyc b/tests/__pycache__/test_daily_cycle.cpython-313.pyc new file mode 100644 index 0000000..7c0dc65 Binary files /dev/null and b/tests/__pycache__/test_daily_cycle.cpython-313.pyc differ diff --git a/tests/__pycache__/test_lab_loop.cpython-313.pyc b/tests/__pycache__/test_lab_loop.cpython-313.pyc new file mode 100644 index 0000000..9cce152 Binary files /dev/null and b/tests/__pycache__/test_lab_loop.cpython-313.pyc differ diff --git a/tests/test_cannon_canon.py b/tests/test_cannon_canon.py new file mode 100644 index 0000000..a0bccba --- /dev/null +++ b/tests/test_cannon_canon.py @@ -0,0 +1,39 @@ +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) + 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) + + 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() diff --git a/tests/test_canon_lifecycle.py b/tests/test_canon_lifecycle.py new file mode 100644 index 0000000..f26c887 --- /dev/null +++ b/tests/test_canon_lifecycle.py @@ -0,0 +1,46 @@ +import json +import unittest +from pathlib import Path + +CANON = Path(__file__).parents[1] / "projects" / "slop_cannon" / "canon.json" + + +class CanonLifecycleTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.data = json.loads(CANON.read_text()) + + def test_forms_have_lifecycle_state(self): + """Every form must have a state field (candidate/survivor/archive_ghost).""" + for form in self.data["forms"]: + self.assertIn("state", form, f"Form {form.get('id', '?')} missing lifecycle state") + self.assertIn(form["state"], ["candidate", "survivor", "archive_ghost"]) + + def test_audience_choice_schema_defined(self): + """Schema must define how a choice is structured so Episode 04 can parse it.""" + schema = self.data.get("audience_choice_schema", {}) + required = ["survivor_id", "stolen_trait", "source_episode", "source_issue"] + for field in required: + self.assertIn(field, schema, f"audience_choice_schema missing {field}") + + def test_receipt_schema_defined(self): + """Receipts need provenance fields for path-proof binding.""" + schema = self.data.get("receipt_schema", {}) + required = ["episode_id", "form_id", "provenance", "signed_by"] + for field in required: + self.assertIn(field, schema, f"receipt_schema missing {field}") + + def test_receipt_schema_requires_agent_signing(self): + """Signed_by must constrain to known actors — free text defeats path proofs.""" + signed = self.data["receipt_schema"]["signed_by"] + self.assertIn("agent:", signed, "signed_by should mention agent: prefix") + + def test_law_enforced_by_data(self): + """Law 'Every firing leaves a power, scar, cost, and receipt' requires receipt_schema to exist.""" + laws_text = " ".join(self.data["laws"]).lower() + self.assertIn("receipt", laws_text) + self.assertIn("receipt_schema", self.data, "Law mentions receipt but no schema enforces it") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_canon_validator.py b/tests/test_canon_validator.py new file mode 100644 index 0000000..be8082e --- /dev/null +++ b/tests/test_canon_validator.py @@ -0,0 +1,265 @@ +""" +Validator tests for canon lifecycle schemas. + +Covers: +- Valid canon structure (from #13 fix) +- Negative tests: malformed receipts, illegal transitions +- Daily Lab receipt fixture with provenance binding +""" + +import json +import sys +import unittest +from pathlib import Path +from copy import deepcopy + +# Add project root to path for imports +sys.path.insert(0, str(Path(__file__).parents[1])) + +CANON = Path(__file__).parents[1] / "projects" / "slop_cannon" / "canon.json" + +from projects.slop_cannon.canon_validator import ( + validate_canon, + validate_transition, +) + + +class CanonValidatorTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.data = json.loads(CANON.read_text()) + + # --- Positive tests --- + + def test_current_canon_is_valid(self): + """canon.json as committed should pass validation.""" + result = validate_canon(self.data) + self.assertTrue(result["valid"], f"Errors: {result['errors']}") + + def test_all_forms_have_valid_state(self): + """Every form state must be in the valid set.""" + result = validate_canon(self.data) + self.assertTrue(result["valid"], result["errors"]) + + # --- Negative: malformed receipts --- + + def test_missing_signed_by_fails(self): + """Receipt without signed_by is rejected.""" + data = deepcopy(self.data) + data["receipts"] = [ + { + "episode_id": "01-pilot", + "form_id": "wizard", + "provenance": "#13", + } + ] + result = validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("signed_by" in e for e in result["errors"])) + + def test_invalid_signer_fails(self): + """Signed_by must match agent: or human: pattern.""" + data = deepcopy(self.data) + data["receipts"] = [ + { + "episode_id": "01-pilot", + "form_id": "wizard", + "provenance": "#13", + "signed_by": "unknown:bot", + } + ] + result = validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("invalid signed_by" in e for e in result["errors"])) + + def test_empty_provenance_fails(self): + """Receipt with empty provenance is rejected.""" + data = deepcopy(self.data) + data["receipts"] = [ + { + "episode_id": "01-pilot", + "form_id": "wizard", + "provenance": "", + "signed_by": "agent:vincent", + } + ] + result = validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("provenance" in e for e in result["errors"])) + + def test_form_id_must_exist(self): + """Receipt referencing nonexistent form is rejected.""" + data = deepcopy(self.data) + data["receipts"] = [ + { + "episode_id": "01-pilot", + "form_id": "nonexistent", + "provenance": "#13", + "signed_by": "agent:vincent", + } + ] + result = validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("not found in forms" in e for e in result["errors"])) + + # --- Negative: illegal state transitions --- + + def test_survivor_cannot_transition(self): + """Survivor state is terminal.""" + form = {"id": "wizard", "state": "survivor"} + result = validate_transition(form, "archive_ghost") + self.assertFalse(result["valid"]) + self.assertTrue(any("Illegal transition" in e for e in result["errors"])) + + def test_ghost_cannot_transition(self): + """Archive_ghost state is terminal.""" + form = {"id": "wizard", "state": "archive_ghost"} + result = validate_transition(form, "candidate") + self.assertFalse(result["valid"]) + self.assertTrue(any("Illegal transition" in e for e in result["errors"])) + + def test_candidate_to_survivor_valid(self): + """Candidate -> survivor is the expected happy path.""" + form = {"id": "wizard", "state": "candidate"} + result = validate_transition(form, "survivor") + self.assertTrue(result["valid"]) + + def test_candidate_to_ghost_valid(self): + """Candidate -> archive_ghost is valid (rejected form).""" + form = {"id": "wizard", "state": "candidate"} + result = validate_transition(form, "archive_ghost") + self.assertTrue(result["valid"]) + + # --- Negative: audience choice --- + + def test_two_survivors_fails(self): + """Only one survivor allowed.""" + data = deepcopy(self.data) + for f in data["forms"][:2]: + f["state"] = "survivor" + result = validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("survivor" in e.lower() for e in result["errors"])) + + def test_choice_without_survivor_fails(self): + """Setting audience_choice requires a survivor in forms.""" + data = deepcopy(self.data) + data["audience_choice"] = { + "survivor_id": "wizard", + "stolen_trait": "Proves the execution path", + "source_episode": "01-pilot", + "source_issue": "7", + "committed_at": "2026-08-11T19:00:00Z", + } + # forms all stay candidate -> no survivor + result = validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("survivor" in e.lower() for e in result["errors"])) + + def test_invalid_committed_at_fails(self): + """committed_at must be valid ISO timestamp.""" + data = deepcopy(self.data) + data["forms"][0]["state"] = "survivor" # satisfy survivor req + data["audience_choice"] = { + "survivor_id": "wizard", + "stolen_trait": "Negotiates visible covenants", + "source_episode": "01-pilot", + "source_issue": "7", + "committed_at": "not-a-date", + } + result = validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("ISO" in e for e in result["errors"])) + + # --- Negative: structure --- + + def test_duplicate_form_id_fails(self): + """Duplicate form IDs are rejected.""" + data = deepcopy(self.data) + data["forms"].append(deepcopy(data["forms"][0])) + result = validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("duplicate" in e for e in result["errors"])) + + def test_short_palette_fails(self): + """Palette needs at least 3 colors.""" + data = deepcopy(self.data) + data["forms"][0]["palette"] = ["red"] + result = validate_canon(data) + self.assertFalse(result["valid"]) + self.assertTrue(any("palette" in e for e in result["errors"])) + + +class DailyReceiptFixtureTests(unittest.TestCase): + """Test the Daily Lab receipt fixture with Vincent's provenance.""" + + def test_vincent_receipt_fixture(self): + """A valid Vincent contribution receipt passes validation.""" + data = deepcopy(json.loads(CANON.read_text())) + + # Build a receipt for the #13 contribution + data["receipts"] = [ + { + "episode_id": "01-pilot", + "form_id": "wizard", + "action": "mutation", + "power_delta": "Added lifecycle state tracking", + "scar_delta": "Receipt schema now enforces provenance", + "cost_delta": "All future drops require signed receipts", + "provenance": "pull/18@562d0cf", + "signed_by": "agent:vincent", + } + ] + + result = validate_canon(data) + self.assertTrue(result["valid"], f"Fixture failed: {result['errors']}") + + def test_mixed_agent_receipt(self): + """Receipts from different agents can coexist.""" + data = deepcopy(json.loads(CANON.read_text())) + data["receipts"] = [ + { + "episode_id": "01-pilot", + "form_id": "wizard", + "action": "mutation", + "power_delta": "Lifecycle state", + "scar_delta": "Provenance enforcement", + "cost_delta": "Signed receipts required", + "provenance": "pull/18@562d0cf", + "signed_by": "agent:vincent", + }, + { + "episode_id": "01-pilot", + "form_id": "machine", + "action": "graft", + "power_delta": "Execution path proof", + "scar_delta": "Red tallies", + "cost_delta": "Remembers failures", + "provenance": "pull/12@47ca557", + "signed_by": "agent:timmy", + }, + ] + result = validate_canon(data) + self.assertTrue(result["valid"], f"Mixed fixture failed: {result['errors']}") + + def test_human_receipt(self): + """Human-signed receipts are valid (for canon decisions).""" + data = deepcopy(json.loads(CANON.read_text())) + data["receipts"] = [ + { + "episode_id": "01-pilot", + "form_id": "wizard", + "action": "mutation", + "power_delta": "x", + "scar_delta": "x", + "cost_delta": "x", + "provenance": "#7", + "signed_by": "human:grepples", + } + ] + result = validate_canon(data) + self.assertTrue(result["valid"], f"Human fixture failed: {result['errors']}") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file