From f3fef636b3b0df55aa259d41e78fde767dcfbab8 Mon Sep 17 00:00:00 2001 From: Daniel Polo <106583643+danielPoloWork@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:42:21 +0200 Subject: [PATCH] feat(ir): stamp ir_version into every IR projection; emitters validate before rendering (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manifests carry schema_version, but the emitted IR JSON carried no version marker. The IR is a persisted artifact (build/*.json) that outlives tool runs and crosses the determinism boundary into five emitters — an emitter could not tell whether the JSON it received matched the contract it was written for, and unknown block types already fall through silently in _flatten/_line. - render.py: IR_VERSION = 1 + GENERATOR = 'eamos-render', stamped as the first two fields of all six projections (deck, infographic, data, mindmap, quiz, topology). Bump policy: ir_version increments on any breaking change to block/field shapes. - New _cli.require_ir_version: every emitter (md, pptx, docx, svg, xlsx) validates right after loading the artifact and refuses a mismatch — or an absent field, i.e. a pre-#62 IR — with one actionable line: "IR version None not supported (expected 1); re-render the manifest". - os/deck-ir/_schema.md documents both fields and the bump policy as a new invariant. Tests: all six projections stamped; emit_md rejects an unversioned IR in a subprocess (exit != 0, no traceback) and accepts a fresh render. Determinism smoke unaffected; 90/90 green. Closes #62 Co-Authored-By: Claude Fable 5 --- .../orchestrator/os/deck-ir/_schema.md | 7 +++++ .eamos-core/tools/_cli.py | 9 +++++++ .eamos-core/tools/emit_docx.py | 3 +++ .eamos-core/tools/emit_md.py | 3 +++ .eamos-core/tools/emit_pptx.py | 3 +++ .eamos-core/tools/emit_svg.py | 3 +++ .eamos-core/tools/emit_xlsx.py | 3 +++ .eamos-core/tools/render.py | 20 +++++++++++--- .eamos-core/tools/tests/test_flows.py | 27 +++++++++++++++++++ .eamos-core/tools/tests/test_render.py | 11 ++++++++ 10 files changed, 86 insertions(+), 3 deletions(-) diff --git a/.eamos-core/orchestrator/os/deck-ir/_schema.md b/.eamos-core/orchestrator/os/deck-ir/_schema.md index 192af17..1dac8f8 100644 --- a/.eamos-core/orchestrator/os/deck-ir/_schema.md +++ b/.eamos-core/orchestrator/os/deck-ir/_schema.md @@ -6,6 +6,8 @@ archetype; the gates run **on the IR**; the cosmetic `emit_*` hop turns it into DOCX / SVG / XLSX. Same manifest → same IR → same bytes. ```yaml +ir_version: # the IR contract version (#62); emitters refuse a mismatch +generator: eamos-render # provenance of the artifact deliverable: # from the manifest's deliverables[] (RFC-0002 §4) format: # presentation render mode length: @@ -42,3 +44,8 @@ review_appendix: # the "verify before the room never drops a `required` section). - **Deterministic serialization.** JSON, `indent=2`, `ensure_ascii=False`, insertion order; `review_appendix` sorted by binding key. No clocks, no randomness, no filesystem order. +- **Versioned contract (#62).** Every projection (all six IR families) stamps `ir_version` + + `generator`. `ir_version` increments on **any breaking change to block/field shapes**; every + emitter validates it before rendering and refuses a mismatch (or an absent field — a pre-#62 + artifact) with *"re-render the manifest"* — a persisted `build/*.json` outlives tool runs, and a + silent mis-render is worse than a one-command re-render. diff --git a/.eamos-core/tools/_cli.py b/.eamos-core/tools/_cli.py index 6bd9044..6ef2eca 100644 --- a/.eamos-core/tools/_cli.py +++ b/.eamos-core/tools/_cli.py @@ -21,6 +21,15 @@ def read_text(path, what="file"): return fh.read() +def require_ir_version(ir, tool, supported): + """Refuse an IR artifact from a different contract version (#62). A persisted build/*.json + outlives tool runs — a silent mis-render is worse than a one-command re-render.""" + v = ir.get("ir_version") if isinstance(ir, dict) else None + if v != supported: + raise SystemExit(f"{tool}: IR version {v!r} not supported (expected {supported}); " + "re-render the manifest") + + def utf8_stdio(): """Force UTF-8 stdout/stderr regardless of the platform code page. Call first in every main().""" for stream in (sys.stdout, sys.stderr): diff --git a/.eamos-core/tools/emit_docx.py b/.eamos-core/tools/emit_docx.py index 6b4ae65..7a94c22 100644 --- a/.eamos-core/tools/emit_docx.py +++ b/.eamos-core/tools/emit_docx.py @@ -19,6 +19,8 @@ import _cli # utf8_stdio (#54) import labels # chrome labels as data (#61) +SUPPORTED_IR_VERSION = 1 # the IR contract this emitter was written for (#62) + AMBER = (0xB8, 0x6B, 0x00) MUTED = (0x70, 0x70, 0x70) @@ -128,6 +130,7 @@ def main(): with open(args.deck_ir, encoding="utf-8") as fh: ir = json.load(fh) + _cli.require_ir_version(ir, "emit_docx", SUPPORTED_IR_VERSION) os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) n = build_docx(ir, args.out) print(f"emit_docx: OK — pre-read ({n} paragraphs) -> {args.out}") diff --git a/.eamos-core/tools/emit_md.py b/.eamos-core/tools/emit_md.py index c3d7946..2b9b5fd 100644 --- a/.eamos-core/tools/emit_md.py +++ b/.eamos-core/tools/emit_md.py @@ -15,6 +15,8 @@ import _cli # utf8_stdio (#54) import labels # chrome labels as data (#61) +SUPPORTED_IR_VERSION = 1 # the IR contract this emitter was written for (#62) + def _lab(lang, key): # Template chrome in the deck's output language (the section labels, not the content). @@ -111,6 +113,7 @@ def main(): with open(args.deck_ir, encoding="utf-8") as fh: ir = json.load(fh) + _cli.require_ir_version(ir, "emit_md", SUPPORTED_IR_VERSION) text = render_quiz_md(ir) if ir.get("deliverable") == "interview_quiz" else render_md(ir) if args.out: os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) diff --git a/.eamos-core/tools/emit_pptx.py b/.eamos-core/tools/emit_pptx.py index bc87114..f2eadea 100644 --- a/.eamos-core/tools/emit_pptx.py +++ b/.eamos-core/tools/emit_pptx.py @@ -19,6 +19,8 @@ import _cli # utf8_stdio (#54) import labels # chrome labels as data (#61) +SUPPORTED_IR_VERSION = 1 # the IR contract this emitter was written for (#62) + NAVY = (0x1E, 0x27, 0x61) # title BODY = (0x2B, 0x2B, 0x2B) # body text AMBER = (0xB8, 0x6B, 0x00) # assumed / to-verify @@ -144,6 +146,7 @@ def main(): with open(args.deck_ir, encoding="utf-8") as fh: ir = json.load(fh) + _cli.require_ir_version(ir, "emit_pptx", SUPPORTED_IR_VERSION) os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) n = build_pptx(ir, args.out) print(f"emit_pptx: OK — {n} slides -> {args.out}") diff --git a/.eamos-core/tools/emit_svg.py b/.eamos-core/tools/emit_svg.py index 227457e..dab98cd 100644 --- a/.eamos-core/tools/emit_svg.py +++ b/.eamos-core/tools/emit_svg.py @@ -21,6 +21,8 @@ import _cli # utf8_stdio (#54) import labels # chrome labels as data (#61) +SUPPORTED_IR_VERSION = 1 # the IR contract this emitter was written for (#62) + # Per visual_style palette (background, accent, body, amber, muted, card). Unknown styles fall back # to professional. Layout is shared; only the palette/theme changes (RFC-0002 §11-1). THEMES = { @@ -244,6 +246,7 @@ def main(): with open(args.ir_json, encoding="utf-8") as fh: ir = json.load(fh) + _cli.require_ir_version(ir, "emit_svg", SUPPORTED_IR_VERSION) deliv = ir.get("deliverable") if deliv == "mindmap": svg, kind, note = build_mindmap_svg(ir), "mindmap", f"{len(ir.get('branches', []))} branches" diff --git a/.eamos-core/tools/emit_xlsx.py b/.eamos-core/tools/emit_xlsx.py index 34f0dfb..43cac18 100644 --- a/.eamos-core/tools/emit_xlsx.py +++ b/.eamos-core/tools/emit_xlsx.py @@ -19,6 +19,8 @@ import _cli # utf8_stdio (#54) import labels # chrome labels as data (#61) +SUPPORTED_IR_VERSION = 1 # the IR contract this emitter was written for (#62) + def _lab(lang, key): return labels.lab(lang, "xlsx", key) # chrome labels as data (#61) @@ -97,6 +99,7 @@ def main(): with open(args.data_ir, encoding="utf-8") as fh: ir = json.load(fh) + _cli.require_ir_version(ir, "emit_xlsx", SUPPORTED_IR_VERSION) os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) n = build_xlsx(ir, args.out) print(f"emit_xlsx: OK — KPI table ({n} rows) -> {args.out}") diff --git a/.eamos-core/tools/render.py b/.eamos-core/tools/render.py index 5e0f8bc..a2d203d 100644 --- a/.eamos-core/tools/render.py +++ b/.eamos-core/tools/render.py @@ -33,6 +33,11 @@ ROUTING = os.path.join(CORE, "orchestrator", "os", "intake", "routing.yaml") BIND_RE = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}") +# The IR contract version (#62), stamped into every projection. The IR is a persisted artifact +# (build/*.json) that outlives tool runs and crosses the determinism boundary into the emitters — +# bump on ANY breaking change to block/field shapes, so an emitter refuses instead of mis-rendering. +IR_VERSION = 1 +GENERATOR = "eamos-render" # The provenance enum. Anything else — a typo, a missing field, a future value — is treated as # assumed (fail closed, #53) and flagged for the grounding gate: only the explicit `sourced` # may render plain. @@ -360,6 +365,8 @@ def build_deck_ir(manifest, archetype, altitude=None, function=None): ] deck_ir = { + "ir_version": IR_VERSION, + "generator": GENERATOR, "deliverable": deliverable.get("type", "presentation"), "format": deliverable.get("format", "speaker"), "length": deliverable.get("length", "medium"), @@ -424,6 +431,8 @@ def build_infographic_ir(manifest, archetype, altitude=None, function=None): for k in sorted(acc["assumed"]) ] info_ir = { + "ir_version": IR_VERSION, + "generator": GENERATOR, "deliverable": "infographic", "orientation": params.get("orientation", "portrait"), "visual_style": params.get("visual_style", "professional"), @@ -485,6 +494,8 @@ def build_data_ir(manifest, archetype, altitude=None, function=None): for k in sorted(acc["assumed"]) ] return { + "ir_version": IR_VERSION, + "generator": GENERATOR, "deliverable": "table_chart", "chart": params.get("chart", "none"), "output_lang": lang, @@ -531,7 +542,8 @@ def build_graph_ir(manifest, archetype, altitude=None, function=None): "assumption": ledger[k].get("assumption", ""), "fill_from": ledger[k].get("fill_from", "")} for k in sorted(acc["assumed"]) ] - return {"deliverable": "mindmap", "output_lang": lang, "root": manifest.get("objective", ""), + return {"ir_version": IR_VERSION, "generator": GENERATOR, + "deliverable": "mindmap", "output_lang": lang, "root": manifest.get("objective", ""), "branches": branches, "review_appendix": review_appendix}, acc @@ -570,7 +582,8 @@ def build_quiz_ir(manifest, archetype, altitude=None, function=None): "assumption": ledger[k].get("assumption", ""), "fill_from": ledger[k].get("fill_from", "")} for k in sorted(acc["assumed"]) ] - return {"deliverable": "interview_quiz", "output_lang": lang, "title": manifest.get("objective", ""), + return {"ir_version": IR_VERSION, "generator": GENERATOR, + "deliverable": "interview_quiz", "output_lang": lang, "title": manifest.get("objective", ""), "questions": questions, "review_appendix": review_appendix}, acc @@ -611,7 +624,8 @@ def build_topology_ir(manifest, archetype=None, altitude=None, function=None): "assumption": ledger[k].get("assumption", ""), "fill_from": ledger[k].get("fill_from", "")} for k in sorted(acc["assumed"]) ] - return {"deliverable": "architecture", "output_lang": lang, "title": manifest.get("objective", ""), + return {"ir_version": IR_VERSION, "generator": GENERATOR, + "deliverable": "architecture", "output_lang": lang, "title": manifest.get("objective", ""), "nodes": nodes, "edges": edges, "review_appendix": review_appendix}, acc diff --git a/.eamos-core/tools/tests/test_flows.py b/.eamos-core/tools/tests/test_flows.py index a85d1fe..b0d2e0e 100644 --- a/.eamos-core/tools/tests/test_flows.py +++ b/.eamos-core/tools/tests/test_flows.py @@ -349,6 +349,33 @@ def test_empty_identity_preps_without_traceback(self): self.assertEqual(quiet(facilitate.prep, p, 60), 0) +class TestIRVersionGate(unittest.TestCase): + def test_emitter_rejects_unversioned_ir(self): + # A pre-#62 (or future) artifact must be refused with a one-command recovery, never + # mis-rendered silently. + import subprocess + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, "old-ir.json") + with open(p, "w", encoding="utf-8") as fh: + json.dump({"deliverable": "presentation", "slides": []}, fh) + out = subprocess.run([sys.executable, os.path.join(TOOLS, "emit_md.py"), p], + capture_output=True, text=True) + self.assertNotEqual(out.returncode, 0) + self.assertIn("re-render the manifest", out.stderr) + self.assertNotIn("Traceback", out.stderr) + + def test_emitter_accepts_current_ir(self): + import subprocess + with tempfile.TemporaryDirectory() as d: + ir_path, md_path = os.path.join(d, "ir.json"), os.path.join(d, "deck.md") + r1 = subprocess.run([sys.executable, os.path.join(TOOLS, "render.py"), Q3, "--out", ir_path], + capture_output=True, text=True) + self.assertEqual(r1.returncode, 0, r1.stderr) + r2 = subprocess.run([sys.executable, os.path.join(TOOLS, "emit_md.py"), ir_path, "--out", md_path], + capture_output=True, text=True) + self.assertEqual(r2.returncode, 0, r2.stderr) + + class TestPipedStdout(unittest.TestCase): def test_piped_stdout_survives_non_utf8_code_page(self): # On Windows a piped stdout picks the ANSI code page (cp1252), which cannot encode the diff --git a/.eamos-core/tools/tests/test_render.py b/.eamos-core/tools/tests/test_render.py index 71040da..49ef5ca 100644 --- a/.eamos-core/tools/tests/test_render.py +++ b/.eamos-core/tools/tests/test_render.py @@ -124,6 +124,17 @@ def test_invalid_provenance_fails_closed_in_topology(self): self.assertIn("sys.platform", acc["invalid_provenance"]) +class TestIRVersion(unittest.TestCase): + def test_every_projection_is_stamped(self): + m, arch = load("qbr-c-level") + for builder in (render.build_deck_ir, render.build_infographic_ir, render.build_data_ir, + render.build_graph_ir, render.build_quiz_ir, render.build_topology_ir): + with self.subTest(ir=builder.__name__): + ir, _ = builder(m, arch) + self.assertEqual(ir["ir_version"], render.IR_VERSION) # versioned contract (#62) + self.assertEqual(ir["generator"], "eamos-render") + + class TestDecisionContract(unittest.TestCase): def test_decision_contract_renders_through_md(self): import emit_md