Files
rune/scripts/check_events_schema.py
Nick 15d6923d7a feat(substrate): K5 typed-event vocabulary — 14 kinds, one schema, Q1.1 RESOLVED
Sleight §13.4 reconciled with the 8-point adapter surface: 14 event
kinds (13 SoH 1:1 + lane_state), 6 live channels (gate cross-checks
envelope.go), adapter points 2/5/7 bound (stable (channel, call_id)
keys, seq stream position, all 8 §13.6 trace lines mapped). Envelope:
event_id, parent_id/task_id causal chain, payload_hash, actor/device/
authorization. Kind payload minimums frozen via allOf. 23/23 red-first;
adjacent legacy 26 green; boundary gate unregressed. Open-question
register Q1.1 -> RESOLVED.
2026-10-17 10:40:49 +01:00

179 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""check_events_schema.py — spec 0.5 gate: ONE frozen typed-event vocabulary.
Mirror of the boundary codegen gate for the events namespace
(schemas/events is a SEPARATE namespace from schemas/boundary):
1. Schema law: event.schema.json exists at the events/v1 $id namespace
and the boundary registry stays free of event kinds (no leakage in
either direction).
2. Regeneration: schemas/events/v1/vocabulary.json byte-matches a fresh
extraction from the schema (a hand-edited generated file, or a schema
change without regeneration, trips RED).
3. Corpus: every schemas/events/corpus/*.json validates against the
schema (JSON Schema 2020-12 via jsonschema) and is byte-canonical
(sorted keys, compact separators, trailing newline). Every frozen
kind has at least one fixture.
4. Reconciliation law: the enum is exactly Sleight-of-Hand section
13.4's thirteen (mapped 1:1 via x-soh-13-4) plus lane_state; every
section 13.6 trace line maps into the enum; the six channels match
the live event-bus eeChannels table in
src/go/event-bus/internal/eventbus/envelope.go.
Exit 0 + "GREEN" summary, or exit 1 with the violation list.
Spec: docs/openspec/changes/add-native-rune-tui/tasks.md 0.5 (Q1.1).
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCHEMA = ROOT / "schemas" / "events" / "v1" / "event.schema.json"
VOCAB_GEN = ROOT / "scripts" / "gen_events_vocabulary.py"
CORPUS_DIR = ROOT / "schemas" / "events" / "corpus"
BOUNDARY_DIR = ROOT / "schemas" / "boundary" / "v1"
ENVELOPE_GO = (ROOT / "src" / "go" / "event-bus" / "internal" /
"eventbus" / "envelope.go")
CHANNELS = ["conversation", "work", "system", "tool", "background", "sensor"]
ADAPTER_ADDITIONS = ["lane_state"]
def canonical(obj) -> str:
return json.dumps(obj, sort_keys=True, separators=(",", ":")) + "\n"
def check_schema(violations: list[str]) -> dict | None:
if not SCHEMA.is_file():
violations.append(f"schema missing: {SCHEMA}")
return None
doc = json.loads(SCHEMA.read_text(encoding="utf-8"))
if doc.get("$id") != "https://rune.dev/schemas/events/v1/event.schema.json":
violations.append(f"schema $id is not the events/v1 namespace: "
f"{doc.get('$id')!r}")
if "boundary" in doc.get("$id", ""):
violations.append("events schema must not live in the boundary "
"namespace")
if not BOUNDARY_DIR.is_dir():
violations.append("schemas/boundary/v1 missing (gate context)")
else:
for f in sorted(BOUNDARY_DIR.glob("*.schema.json")):
bid = json.loads(f.read_text(encoding="utf-8")).get("$id", "")
if "events" in bid:
violations.append(f"boundary schema {f.name} leaks the "
f"events namespace: {bid}")
return doc
def check_regen(violations: list[str]) -> None:
r = subprocess.run([sys.executable, str(VOCAB_GEN), "--check"],
capture_output=True, text=True, timeout=120)
if r.returncode != 0:
violations.append("vocabulary.json regeneration drifted: "
f"{r.stderr.strip()}")
def check_corpus(doc: dict, violations: list[str]) -> None:
try:
import jsonschema
except ImportError:
violations.append("jsonschema library unavailable — cannot "
"validate corpus")
return
if not CORPUS_DIR.is_dir():
violations.append("schemas/events/corpus missing")
return
validator = jsonschema.Draft202012Validator(doc)
kinds_seen: set[str] = set()
for f in sorted(CORPUS_DIR.glob("*.json")):
try:
obj = json.loads(f.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
violations.append(f"corpus {f.name}: not valid JSON ({exc})")
continue
try:
validator.validate(obj)
except jsonschema.ValidationError as exc:
violations.append(f"corpus {f.name}: schema violation: "
f"{exc.message}")
continue
raw = f.read_text(encoding="utf-8")
if raw != canonical(obj):
violations.append(f"corpus {f.name}: not canonical bytes "
"(sorted keys, compact, trailing newline)")
kinds_seen.add(obj.get("event", ""))
enum = set(doc["properties"]["event"]["enum"])
missing = sorted(enum - kinds_seen)
if missing:
violations.append(f"kinds without a corpus fixture: {missing}")
def check_reconciliation(doc: dict, violations: list[str]) -> None:
enum = doc["properties"]["event"]["enum"]
soh = doc.get("x-soh-13-4", {})
if len(soh) != 13:
violations.append(f"x-soh-13-4 must carry section 13.4's thirteen "
f"objects, got {len(soh)}")
wires = sorted(soh.values())
if len(wires) != len(set(wires)):
violations.append("x-soh-13-4 is not 1:1 (duplicate wire names)")
if sorted(enum) != sorted(list(soh.values()) + ADAPTER_ADDITIONS):
violations.append("event enum is not exactly section 13.4's "
f"thirteen + {ADAPTER_ADDITIONS}: {sorted(enum)}")
trace = doc.get("x-soh-13-6-trace", {})
if len(trace) != 8:
violations.append(f"x-soh-13-6-trace must map all eight illustrative "
f"trace lines, got {len(trace)}")
for wire in trace.values():
if wire not in enum:
violations.append(f"13.6 trace maps to unknown kind {wire!r}")
chans = doc["properties"]["channel"].get("enum", [])
if sorted(chans) != sorted(CHANNELS):
violations.append(f"channel enum drifted from the event-bus six: "
f"{sorted(chans)}")
else:
src = ENVELOPE_GO.read_text(encoding="utf-8") \
if ENVELOPE_GO.is_file() else ""
if not src:
violations.append("live event-bus envelope.go missing — cannot "
"cross-check channels")
for ch in CHANNELS:
# whitespace-tolerant: gofmt aligns map-literal values
if not re.search(rf'"{ch}":\s+ordKV\(', src):
violations.append(f"channel {ch} missing from the live "
f"event-bus eeChannels table")
points = sorted(doc.get("x-adapter-surface", {}).get("points", []))
if points != [2, 5, 7]:
violations.append(f"adapter-surface binding must be points 2/5/7, "
f"got {points}")
def main(argv=None) -> int:
violations: list[str] = []
doc = check_schema(violations)
if doc is not None:
check_regen(violations)
check_corpus(doc, violations)
check_reconciliation(doc, violations)
if violations:
print("RED: events schema gate —")
for v in violations:
print(f" - {v}")
return 1
n_kinds = len(doc["properties"]["event"]["enum"])
n_fixtures = len(list(CORPUS_DIR.glob("*.json")))
print(f"GREEN: events schema gate — {n_kinds} frozen kinds, "
f"{n_fixtures} corpus fixtures, vocabulary regeneration-clean, "
f"13.4/13.6/event-bus reconciliation intact")
return 0
if __name__ == "__main__":
sys.exit(main())