#!/usr/bin/env python3 """Fail when checked-in API, event, MCP or n8n contracts drift from the code.""" from __future__ import annotations import hashlib import json import re import sys from pathlib import Path import yaml from app.core.config import get_settings from app.main import app ROOT = Path(__file__).resolve().parents[1] def fail(message: str, failures: list[str]) -> None: failures.append(message) def check_openapi(failures: list[str]) -> None: committed = yaml.safe_load((ROOT / "contracts/openapi.yaml").read_text(encoding="utf-8")) generated = app.openapi() if committed != generated: fail( "contracts/openapi.yaml differs from app.openapi(); regenerate it with " "scripts/generate-openapi.py", failures, ) def check_mcp(failures: list[str]) -> None: contract = json.loads((ROOT / "contracts/mcp-tools.json").read_text(encoding="utf-8")) contracted = { (tool["endpoint"]["method"].upper(), tool["endpoint"]["path"]) for tool in contract["tools"] if tool.get("read_only") is True and isinstance(tool.get("endpoint"), dict) } generated = app.openapi() implemented = { (method.upper(), path) for path, operations in generated["paths"].items() if path.startswith("/api/v1/integrations/mcp/") for method in operations if method.lower() in {"get", "post", "put", "patch", "delete"} } if len(contracted) != len(contract["tools"]): fail("Every MCP tool must be read-only and declare its provider endpoint", failures) if contracted != implemented: fail( f"MCP endpoint drift: contract={sorted(contracted)!r}, code={sorted(implemented)!r}", failures, ) if any(method not in {"GET", "POST"} for method, _path in contracted): fail("MCP contract exposes an unsupported mutation method", failures) def check_event_schema(failures: list[str]) -> None: schema = json.loads((ROOT / "contracts/events.schema.json").read_text(encoding="utf-8")) expected_envelope = { "event_id", "event_type", "occurred_at", "correlation_id", "aggregate", "data", } expected_data = { "vehicle_ref", "inspection_ref", "resulting_vehicle_status", "attention_reasons", } if set(schema.get("required", [])) != expected_envelope: fail("Event envelope required fields drifted", failures) data_schema = schema.get("properties", {}).get("data", {}) if set(data_schema.get("required", [])) != expected_data: fail("vehicle.returned.v1 data fields drifted", failures) event_type = schema.get("properties", {}).get("event_type", {}).get("const") if event_type != "vehicle.returned.v1": fail("Unexpected event_type contract", failures) def check_workflows(failures: list[str]) -> None: workflows_dir = ROOT / "n8n/workflows" manifest = (workflows_dir / "MANIFEST.md").read_text(encoding="utf-8") expected = { "fleet-ops-vehicle-return.json": "mobilityops-return-processing", "fleet-ops-data-quality-scan.json": "mobilityops-scheduled-quality-scan", "fleet-ops-ragcore-procedure-sync.json": "6wbkc4d1AouGpmWT", "fleet-ops-error-handler.json": "Xppn2rAEqUuyiCJF", "fleet-ops-alert-receiver.json": "mobilityops-alert-receiver", } declared_hashes = set(re.findall(r"`([0-9a-f]{64})`", manifest)) for filename, workflow_id in expected.items(): path = workflows_dir / filename raw = path.read_bytes() digest = hashlib.sha256(raw).hexdigest() if digest not in declared_hashes: fail(f"{filename} checksum is missing or stale in MANIFEST.md", failures) definition = json.loads(raw) if definition.get("id") != workflow_id: fail(f"{filename} has unexpected workflow id", failures) serialized = raw.decode("utf-8") if "http://fleetops.itworx.tech" in serialized: fail(f"{filename} contains a cleartext Fleet Ops callback", failures) if filename == "fleet-ops-vehicle-return.json": check_return_workflow_timing(definition, failures) def check_return_workflow_timing(definition: dict, failures: list[str]) -> None: """Keep nested retry budgets inside the dispatcher's recoverable delivery lease.""" nodes_by_name = {node.get("name"): node for node in definition.get("nodes", [])} validation_code = ( nodes_by_name.get("Validate and derive follow-up", {}).get("parameters", {}).get("jsCode", "") ) if not all(field in validation_code for field in ("event_id", "correlation_id")): fail( "Vehicle-return workflow must preserve event_id and correlation_id for its callback", failures, ) required_nodes = ("Record follow-up", "Report workflow heartbeat") workflow_budget_ms = 0 for name in required_nodes: node = nodes_by_name.get(name) if not isinstance(node, dict): fail(f"Vehicle-return workflow is missing timing-critical node {name!r}", failures) return options = node.get("parameters", {}).get("options", {}) timeout_ms = options.get("timeout") if node.get("retryOnFail") is not True: fail(f"{name!r} must retain bounded retries", failures) return max_tries = node.get("maxTries") wait_ms = node.get("waitBetweenTries", 0) if not all( isinstance(value, int) and not isinstance(value, bool) and value > 0 for value in (timeout_ms, max_tries) ) or ( max_tries < 2 or not isinstance(wait_ms, int) or isinstance(wait_ms, bool) or wait_ms < 0 ): fail(f"{name!r} has an invalid timeout/retry budget", failures) return workflow_budget_ms += timeout_ms * max_tries + wait_ms * (max_tries - 1) settings = get_settings() dispatcher_timeout_ms = settings.n8n_http_timeout_seconds * 1000 lease_ms = settings.n8n_delivery_lease_seconds * 1000 if not workflow_budget_ms < dispatcher_timeout_ms < lease_ms: fail( "Vehicle-return timing contract violated: complete callback/heartbeat retry " f"budget {workflow_budget_ms}ms must be below dispatcher timeout " f"{dispatcher_timeout_ms:g}ms, which must be below delivery lease {lease_ms:g}ms", failures, ) def main() -> int: failures: list[str] = [] check_openapi(failures) check_mcp(failures) check_event_schema(failures) check_workflows(failures) if failures: for failure in failures: print(f"ERROR: {failure}", file=sys.stderr) return 1 print("OpenAPI, event, MCP and n8n contracts are synchronized.") return 0 if __name__ == "__main__": raise SystemExit(main())