Some checks failed
ci / build (push) Failing after 1m6s
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
193 lines
6.9 KiB
Python
Executable file
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())
|