fluid-core/conformance/validate_schemas.py
tegwick 76912adef8
Some checks failed
ci / build (push) Failing after 1m6s
Generate contract types, pin fixtures to spec, add build and ADRs
Completes FLUID-WP-0002. The record types in internal/contract are
generated from schemas/ by a dependency-free generator; fixtures
transcribed from the spec's worked examples validate against those
schemas and round-trip through the generated types with
DisallowUnknownFields, so a spec change that misses the schemas fails
CI rather than drifting silently.

Adds identifier prefix helpers, Makefile, GitHub Actions, and five ADRs
recording the Go choice, out-of-process attachment, the wire contract as
boundary, the evidence store, and revision identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:03:12 +02:00

193 lines
6.9 KiB
Python
Executable file

#!/usr/bin/env python3
"""Validate the schema fixtures against the FLUID wire-contract schemas.
Every fixture is transcribed from a worked example in spec/. If the
specification moves and the schemas do not, this fails the build.
"""
import json
import pathlib
import sys
import yaml
from jsonschema import Draft202012Validator, RefResolver
ROOT = pathlib.Path(__file__).resolve().parent.parent
SCHEMAS = ROOT / "schemas"
FIXTURES = ROOT / "conformance" / "fixtures"
# fixture stem -> schema stem
PAIRS = {
"pressure": "pressure",
"hypothesis": "hypothesis",
"revision": "revision",
"experiment": "experiment",
"event": "event",
"feedback": "feedback",
"backend-requirement": "backend-requirement",
"revision-descriptor": "revision-descriptor",
"routing-policy": "routing-policy",
"telemetry-envelope": "telemetry-envelope",
}
def load_store():
"""Map every schema by both its $id and its bare filename.
Schemas cross-reference each other as "common.schema.json#/$defs/...",
a relative reference, so the bare filename has to resolve too.
"""
store = {}
for path in SCHEMAS.glob("*.schema.json"):
schema = json.loads(path.read_text())
store[path.name] = schema
if "$id" in schema:
store[schema["$id"]] = schema
return store
# Instances that MUST be rejected. Without these the suite could pass
# vacuously if reference resolution silently degraded to "accept anything".
NEGATIVE = [
(
"pressure",
"pressure without evidence references is not auditable",
{"fluid_pressure": {
"schema_version": "0.1", "id": "P-1", "interface_id": "x",
"class": "natural_usage", "first_seen": "2026-01-01T00:00:00Z",
"last_seen": "2026-01-01T00:00:00Z", "summary": "s",
"evidence_refs": [], "status": "OPEN"}},
),
(
"pressure",
"pressure class outside the standard taxonomy",
{"fluid_pressure": {
"schema_version": "0.1", "id": "P-1", "interface_id": "x",
"class": "vibes", "first_seen": "2026-01-01T00:00:00Z",
"last_seen": "2026-01-01T00:00:00Z", "summary": "s",
"evidence_refs": ["telemetry:1"], "status": "OPEN"}},
),
(
"hypothesis",
"hypothesis with no falsifiable expected outcome",
{"fluid_hypothesis": {
"schema_version": "0.1", "id": "H-1", "interface_id": "x",
"state": "DRAFT", "title": "t",
"observation": {"summary": "s", "evidence_refs": ["telemetry:1"]},
"pressure": {"classes": ["natural_usage"]},
"explanation": {"claim": "c"},
"proposed_adaptation": {"class": "contract", "summary": "s"},
"expected_outcomes": [],
"guardrails": [],
"complexity": {"expected_delta": {}},
"risk": {"level": "LOW"},
"success_criteria": {"expression": "e"}}},
),
(
"experiment",
"experiment with no stop condition is not interruptible by policy",
{"fluid_experiment": {
"schema_version": "0.1", "id": "E-1", "interface_id": "x",
"hypothesis_refs": ["H-1"], "control_revision": "R-1",
"candidate_revisions": ["R-2"],
"allocation": {"control": 0.9, "candidate": 0.1},
"metrics": {"primary": ["m"]},
"stop_conditions": [],
"result": {"state": "PLANNED"}}},
),
(
"revision-descriptor",
"descriptor with no governing intent cannot be audited",
{"revision": {
"schema_version": "0.1", "id": "R-1", "interface": "x",
"state": "stable",
"contract": {"type": "openapi", "digest": "sha256:" + "0" * 64},
"runtime": {"upstream": "http://a:8080"},
"policy": {"compatibility": "additive", "security_check": "passed"}}},
),
(
"revision-descriptor",
"unknown revision state",
{"revision": {
"schema_version": "0.1", "id": "R-1", "interface": "x",
"state": "probably-fine",
"contract": {"type": "openapi", "digest": "sha256:" + "0" * 64},
"runtime": {"upstream": "http://a:8080"},
"intent": {"version": "IEI-1"},
"policy": {"compatibility": "additive", "security_check": "passed"}}},
),
]
def run_negative(store):
failures = 0
for schema_stem, why, instance in NEGATIVE:
schema = json.loads((SCHEMAS / f"{schema_stem}.schema.json").read_text())
resolver = RefResolver(base_uri="", referrer=schema, store=store)
validator = Draft202012Validator(schema, resolver=resolver)
if validator.is_valid(instance):
print(f"FAIL rejected-case accepted: {why}")
failures += 1
else:
print(f"ok rejects: {why}")
return failures
def main() -> int:
store = load_store()
failures = 0
for fixture_stem, schema_stem in sorted(PAIRS.items()):
fixture_path = FIXTURES / f"{fixture_stem}.yaml"
schema_path = SCHEMAS / f"{schema_stem}.schema.json"
if not fixture_path.exists():
print(f"MISSING {fixture_path.relative_to(ROOT)}")
failures += 1
continue
schema = json.loads(schema_path.read_text())
instance = yaml.safe_load(fixture_path.read_text())
resolver = RefResolver(base_uri="", referrer=schema, store=store)
validator = Draft202012Validator(schema, resolver=resolver)
errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.path))
if errors:
failures += 1
print(f"FAIL {fixture_stem}")
for err in errors:
where = "/".join(str(p) for p in err.absolute_path) or "<root>"
print(f" {where}: {err.message}")
else:
print(f"ok {fixture_stem}")
# A schema that no fixture exercises is a schema nothing protects.
unexercised = {
p.stem.removesuffix(".schema")
for p in SCHEMAS.glob("*.schema.json")
} - set(PAIRS.values()) - {"common"}
for stem in sorted(unexercised):
print(f"WARN {stem}.schema.json has no fixture")
# Emit JSON copies so the Go round-trip test can read fixtures without a
# YAML dependency (the build stays free of third-party Go modules).
json_dir = FIXTURES / "json"
json_dir.mkdir(exist_ok=True)
for fixture_stem in PAIRS:
instance = yaml.safe_load((FIXTURES / f"{fixture_stem}.yaml").read_text())
(json_dir / f"{fixture_stem}.json").write_text(
json.dumps(instance, indent=2, sort_keys=True) + "\n"
)
print()
failures += run_negative(store)
if failures:
print(f"\n{failures} check(s) failed")
return 1
print(f"\nAll {len(PAIRS)} fixtures validate; all {len(NEGATIVE)} rejected cases refused")
return 0
if __name__ == "__main__":
sys.exit(main())