diff --git a/Makefile b/Makefile index 945defe..1e9ecbf 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,10 @@ DECIDED_AT := 2026-05-04T12:51:00Z PYCLI := PYTHONPATH=src $(PYTHON) -m agent_machine.cli PYMOD := PYTHONPATH=src $(PYTHON) -m -validate: validate-no-merge-duplication validate-json validate-yaml validate-quadlet validate-render validate-evidence validate-governance validate-policy-fabric validate-agent-registry validate-superconscious-runtime-plan validate-activation validate-attestation validate-supply-chain validate-release-bundle validate-sourceos-projections validate-package validate-cli validate-formula validate-runtime-install-receipts validate-artifact-digest-honesty validate-consent-before-staging validate-inference-receipt +validate: validate-no-merge-duplication validate-json validate-yaml validate-quadlet validate-render validate-evidence validate-governance validate-policy-fabric validate-agent-registry validate-superconscious-runtime-plan validate-activation validate-attestation validate-supply-chain validate-release-bundle validate-sourceos-projections validate-package validate-cli validate-formula validate-runtime-install-receipts validate-artifact-digest-honesty validate-consent-before-staging validate-inference-receipt validate-adapter-ipc + +validate-adapter-ipc: + $(PYTHON) scripts/validate-adapter-ipc.py validate-no-merge-duplication: $(PYTHON) scripts/validate-no-merge-duplication.py diff --git a/contracts/adapter-ipc.schema.json b/contracts/adapter-ipc.schema.json new file mode 100644 index 0000000..3bf7522 --- /dev/null +++ b/contracts/adapter-ipc.schema.json @@ -0,0 +1,166 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.sourceos.dev/agent-machine/adapter-ipc.schema.json", + "title": "AdapterIpcMessage", + "description": "M2 Adapter IPC v0.1 — JSON-over-stdio (NDJSON) messages between the contract-runner and a backend adapter subprocess. Each stdin/stdout line is exactly one of these objects. Provenance: 'M2 Adapter IPC Spec v0.1' + 'M2 Protocol Versioning & Compatibility Spec v0.1' (SourceOS Spec intake 2026-07-31). stderr carries human logs only and is not modelled here.", + "type": "object", + "properties": {}, + "oneOf": [ + { "$ref": "#/$defs/requestEnvelope" }, + { "$ref": "#/$defs/responseEnvelope" } + ], + "$defs": { + "correlationId": { + "type": "string", + "minLength": 1, + "description": "uuid-or-monotonic correlation id; a response MUST echo the request id." + }, + "protocolVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+$", + "description": "MAJOR.MINOR semantic protocol version." + }, + "op": { + "type": "string", + "enum": [ + "hello", + "info", + "lock_validate", + "lock_hash", + "env_realize", + "task_run", + "deps_inventory", + "lock_update", + "env_shell" + ] + }, + "role": { + "type": "string", + "enum": ["dev", "build", "ci", "release", "audit"] + }, + "errorCode": { + "type": "string", + "description": "Canonical error-code registry (Protocol Versioning Spec v0.1 §4.2). Unknown codes MUST be rejected.", + "enum": [ + "E_PROTOCOL_INCOMPATIBLE", + "E_HELLO_REQUIRED", + "E_CAPABILITY_MISSING", + "E_LOCK_MISSING", + "E_LOCK_INVALID", + "E_LOCK_HASH_FAILED", + "E_LOCK_UPDATE_DENIED", + "E_ENV_REALIZE_FAILED", + "E_ENV_FLOATING_DISALLOWED", + "E_ENV_NOT_FOUND", + "E_TASK_UNKNOWN", + "E_TASK_FAILED", + "E_TASK_TIMEOUT", + "E_CMD_FAILED", + "E_PATH_ESCAPE", + "E_WRITE_DENIED", + "E_DEPS_INVENTORY_FAILED", + "E_LICENSE_EVIDENCE_MISSING", + "E_UNSUPPORTED", + "E_INTERNAL" + ] + }, + "errorObject": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "$ref": "#/$defs/errorCode" }, + "message": { "type": "string", "minLength": 1 }, + "details": { "type": "object" } + }, + "additionalProperties": true + }, + "laneConfig": { + "type": "object", + "required": ["lane_id", "platform", "arch", "backend", "lock_ref", "containerized"], + "properties": { + "lane_id": { "type": "string", "minLength": 1 }, + "platform": { "type": "string", "enum": ["macos", "linux"] }, + "arch": { "type": "string", "enum": ["arm64", "amd64"] }, + "backend": { "type": "string", "enum": ["pixi", "nix", "devbox", "mise", "bazel"] }, + "lock_ref": { "type": "string", "minLength": 1 }, + "containerized": { "type": "boolean" }, + "container": { + "type": "object", + "required": ["image", "runtime"], + "properties": { + "image": { + "type": "string", + "pattern": "^.+@sha256:[0-9a-f]{64}$", + "description": "Container image pinned by digest (name@sha256:)." + }, + "runtime": { "type": "string", "enum": ["podman", "docker"] }, + "mount_repo_to": { "type": "string" }, + "workdir": { "type": "string" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { "properties": { "containerized": { "const": true } } }, + "then": { "required": ["container"] } + } + ] + }, + "requestEnvelope": { + "type": "object", + "required": ["id", "op"], + "properties": { + "id": { "$ref": "#/$defs/correlationId" }, + "op": { "$ref": "#/$defs/op" }, + "lane": { "$ref": "#/$defs/laneConfig" }, + "role": { "$ref": "#/$defs/role" }, + "task": { "type": "string" }, + "ci_mode": { "type": "boolean" }, + "protocol_version": { "$ref": "#/$defs/protocolVersion" }, + "runner": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" }, + "ci_mode": { "type": "boolean" } + }, + "additionalProperties": true + }, + "required_capabilities": { "type": "array", "items": { "type": "string" } }, + "optional_capabilities": { "type": "array", "items": { "type": "string" } }, + "params": { "type": "object" } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { "properties": { "op": { "const": "hello" } }, "required": ["op"] }, + "then": { "required": ["protocol_version"] } + } + ] + }, + "responseEnvelope": { + "type": "object", + "required": ["id", "ok", "errors"], + "properties": { + "id": { "$ref": "#/$defs/correlationId" }, + "ok": { "type": "boolean" }, + "result": { "type": ["object", "null"] }, + "errors": { "type": "array", "items": { "$ref": "#/$defs/errorObject" } }, + "warnings": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { "properties": { "ok": { "const": false } }, "required": ["ok"] }, + "then": { "properties": { "errors": { "minItems": 1 } } } + }, + { + "if": { "properties": { "ok": { "const": true } }, "required": ["ok"] }, + "then": { "properties": { "errors": { "maxItems": 0 } } } + } + ] + } + } +} diff --git a/docs/architecture/adapter-ipc-contract.md b/docs/architecture/adapter-ipc-contract.md new file mode 100644 index 0000000..652e103 --- /dev/null +++ b/docs/architecture/adapter-ipc-contract.md @@ -0,0 +1,42 @@ +# M2 Adapter IPC Contract (JSON-over-stdio) + +The contract-runner talks to backend adapters (pixi/nix/devbox/mise/bazel) over a +stable **JSON-over-stdio (NDJSON)** protocol: one JSON object per line on stdin +(requests) and stdout (responses); stderr is human logs only. This keeps the runner +small and lets adapters be written in any OSS language. + +## Where it lives + +| Artifact | Path | +|---|---| +| Schema (draft 2020-12) | `contracts/adapter-ipc.schema.json` | +| Conformance fixtures (accept) | `fixtures/protocol/*.ndjson` | +| Conformance fixtures (reject) | `fixtures/protocol/_reject/*.ndjson` | +| Validator (teeth) | `scripts/validate-adapter-ipc.py` (`make validate-adapter-ipc`) | + +## What the contract enforces + +- **Envelopes.** A message is exactly one of a *request* (`op`) or a *response* (`ok`), + each carrying a non-empty string `id`; a response MUST echo its request's `id`. +- **Operations.** `op ∈ {hello, info, lock_validate, lock_hash, env_realize, task_run, + deps_inventory, lock_update, env_shell}`. +- **Handshake.** A `hello` request MUST declare a `MAJOR.MINOR` `protocol_version`. +- **ok/errors coupling.** `ok:false` ⇒ non-empty `errors`; `ok:true` ⇒ empty `errors`. +- **Error registry.** Every error `code` MUST be one of the canonical codes + (Protocol Versioning Spec §4.2); unknown codes are rejected. +- **Lane pinning.** A containerized lane MUST pin its image by digest + (`name@sha256:`; SHA-256 is FIPS 180-4). + +The validator proves the teeth both ways: every accept fixture validates, and every +reject fixture fails at least one check (a reject that passes fails the run). + +## Provenance + +Distilled from `M2 Adapter IPC Spec v0.1 (JSON-over-stdio, Subprocess Plugins)` + +`M2 Protocol Versioning & Compatibility Spec v0.1`, SourceOS Spec intake 2026-07-31 +(source SHA-256 `59caac80044f046c4b5f37068ba70ab79c336b973f6714f12cdd91c5e7efd9f7`). + +Deferred to follow-up (not in this slice): the fixture-replay **test harness** that +runs fixtures against a live adapter subprocess; capability-negotiation runtime +(`missing_required` fail-fast); the reference `pixi` adapter; MAJOR-bump +length-prefixed framing for streaming payloads. diff --git a/fixtures/protocol/_reject/bad_protocol_version.ndjson b/fixtures/protocol/_reject/bad_protocol_version.ndjson new file mode 100644 index 0000000..596ceac --- /dev/null +++ b/fixtures/protocol/_reject/bad_protocol_version.ndjson @@ -0,0 +1,2 @@ +# expect: hello protocol_version must match MAJOR.MINOR +{"id":"r6","op":"hello","protocol_version":"one-point-oh","runner":{"name":"contract-runner","version":"0.1.0"}} diff --git a/fixtures/protocol/_reject/container_image_unpinned.ndjson b/fixtures/protocol/_reject/container_image_unpinned.ndjson new file mode 100644 index 0000000..e7dc1e5 --- /dev/null +++ b/fixtures/protocol/_reject/container_image_unpinned.ndjson @@ -0,0 +1,2 @@ +# expect: containerized lane must pin container image by sha256 digest +{"id":"r8","op":"lock_hash","lane":{"lane_id":"linux-amd64-container","platform":"linux","arch":"amd64","backend":"pixi","lock_ref":"workstation.lock/pixi.linux-amd64.lock","containerized":true,"container":{"image":"debian:latest","runtime":"podman"}},"params":{}} diff --git a/fixtures/protocol/_reject/hello_missing_protocol_version.ndjson b/fixtures/protocol/_reject/hello_missing_protocol_version.ndjson new file mode 100644 index 0000000..9c44137 --- /dev/null +++ b/fixtures/protocol/_reject/hello_missing_protocol_version.ndjson @@ -0,0 +1,2 @@ +# expect: hello request must declare protocol_version +{"id":"r7","op":"hello","runner":{"name":"contract-runner","version":"0.1.0"}} diff --git a/fixtures/protocol/_reject/missing_id.ndjson b/fixtures/protocol/_reject/missing_id.ndjson new file mode 100644 index 0000000..5b4f274 --- /dev/null +++ b/fixtures/protocol/_reject/missing_id.ndjson @@ -0,0 +1,2 @@ +# expect: every message must carry a string id for correlation +{"ok":true,"result":{},"errors":[],"warnings":[]} diff --git a/fixtures/protocol/_reject/ok_false_empty_errors.ndjson b/fixtures/protocol/_reject/ok_false_empty_errors.ndjson new file mode 100644 index 0000000..8c9792f --- /dev/null +++ b/fixtures/protocol/_reject/ok_false_empty_errors.ndjson @@ -0,0 +1,2 @@ +# expect: ok=false must carry a non-empty errors array +{"id":"r1","ok":false,"result":null,"errors":[],"warnings":[]} diff --git a/fixtures/protocol/_reject/ok_true_with_errors.ndjson b/fixtures/protocol/_reject/ok_true_with_errors.ndjson new file mode 100644 index 0000000..8568451 --- /dev/null +++ b/fixtures/protocol/_reject/ok_true_with_errors.ndjson @@ -0,0 +1,2 @@ +# expect: ok=true must carry an empty errors array +{"id":"r2","ok":true,"result":{"sha256":"0000000000000000000000000000000000000000000000000000000000000000"},"errors":[{"code":"E_INTERNAL","message":"should not be here"}],"warnings":[]} diff --git a/fixtures/protocol/_reject/unknown_error_code.ndjson b/fixtures/protocol/_reject/unknown_error_code.ndjson new file mode 100644 index 0000000..af8f177 --- /dev/null +++ b/fixtures/protocol/_reject/unknown_error_code.ndjson @@ -0,0 +1,2 @@ +# expect: error code not in the canonical registry +{"id":"r3","ok":false,"result":null,"errors":[{"code":"E_MADE_UP","message":"not a registered code"}],"warnings":[]} diff --git a/fixtures/protocol/_reject/unknown_op.ndjson b/fixtures/protocol/_reject/unknown_op.ndjson new file mode 100644 index 0000000..0733c41 --- /dev/null +++ b/fixtures/protocol/_reject/unknown_op.ndjson @@ -0,0 +1,2 @@ +# expect: op not in the allowed operation set +{"id":"r5","op":"delete_everything","params":{}} diff --git a/fixtures/protocol/deps_inventory_ok.ndjson b/fixtures/protocol/deps_inventory_ok.ndjson new file mode 100644 index 0000000..f118e6b --- /dev/null +++ b/fixtures/protocol/deps_inventory_ok.ndjson @@ -0,0 +1,2 @@ +{"id":"5","op":"deps_inventory","role":"ci","ci_mode":true,"lane":{"lane_id":"linux-amd64-container","platform":"linux","arch":"amd64","backend":"pixi","lock_ref":"workstation.lock/pixi.linux-amd64.lock","containerized":false},"params":{}} +{"id":"5","ok":true,"result":{"deps_path":".workstation/state/deps/20251220T2210Z/deps.json"},"errors":[],"warnings":[]} diff --git a/fixtures/protocol/hello_incompatible.ndjson b/fixtures/protocol/hello_incompatible.ndjson new file mode 100644 index 0000000..ea51bc8 --- /dev/null +++ b/fixtures/protocol/hello_incompatible.ndjson @@ -0,0 +1,2 @@ +{"id":"hello-9","op":"hello","protocol_version":"1.0","runner":{"name":"contract-runner","version":"0.1.0","ci_mode":true},"required_capabilities":["task_run"],"params":{"strict":true}} +{"id":"hello-9","ok":false,"result":null,"errors":[{"code":"E_PROTOCOL_INCOMPATIBLE","message":"Adapter does not support runner protocol_version 1.0","details":{"runner_requested":"1.0","adapter_supported":["0.9"]}}],"warnings":[]} diff --git a/fixtures/protocol/hello_ok.ndjson b/fixtures/protocol/hello_ok.ndjson new file mode 100644 index 0000000..31e06c1 --- /dev/null +++ b/fixtures/protocol/hello_ok.ndjson @@ -0,0 +1,2 @@ +{"id":"hello-1","op":"hello","protocol_version":"1.0","runner":{"name":"contract-runner","version":"0.1.0","ci_mode":true},"required_capabilities":["task_run","lock_hash","lock_validate"],"optional_capabilities":["deps_inventory"],"params":{"strict":false}} +{"id":"hello-1","ok":true,"result":{"adapter":{"name":"pixi","version":"0.28.0","license":"MIT"},"protocol":{"min":"1.0","max":"1.0","supported":["1.0"]},"capabilities":{"supported":["task_run","lock_validate","lock_hash","env_realize"],"missing_required":[]},"config":{"pattern":"P1","container_execution":"runner_orchestrated"}},"errors":[],"warnings":[]} diff --git a/fixtures/protocol/lock_hash_ok.ndjson b/fixtures/protocol/lock_hash_ok.ndjson new file mode 100644 index 0000000..8119a83 --- /dev/null +++ b/fixtures/protocol/lock_hash_ok.ndjson @@ -0,0 +1,2 @@ +{"id":"2","op":"lock_hash","lane":{"lane_id":"linux-amd64-container","platform":"linux","arch":"amd64","backend":"pixi","lock_ref":"workstation.lock/pixi.linux-amd64.lock","containerized":true,"container":{"image":"debian@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","runtime":"podman","mount_repo_to":"/repo","workdir":"/repo"}},"role":"ci","ci_mode":true,"params":{}} +{"id":"2","ok":true,"result":{"sha256":"0000000000000000000000000000000000000000000000000000000000000000"},"errors":[],"warnings":[]} diff --git a/fixtures/protocol/task_run_fail.ndjson b/fixtures/protocol/task_run_fail.ndjson new file mode 100644 index 0000000..22a2fac --- /dev/null +++ b/fixtures/protocol/task_run_fail.ndjson @@ -0,0 +1,2 @@ +{"id":"4","op":"task_run","task":"build","role":"ci","ci_mode":true,"lane":{"lane_id":"linux-amd64-container","platform":"linux","arch":"amd64","backend":"pixi","lock_ref":"workstation.lock/pixi.linux-amd64.lock","containerized":true,"container":{"image":"debian@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","runtime":"podman"}},"params":{"timeout_seconds":1800}} +{"id":"4","ok":false,"result":null,"errors":[{"code":"E_TASK_FAILED","message":"build task exited non-zero","details":{"exit_code":2}}],"warnings":[]} diff --git a/fixtures/protocol/task_run_ok.ndjson b/fixtures/protocol/task_run_ok.ndjson new file mode 100644 index 0000000..2487a84 --- /dev/null +++ b/fixtures/protocol/task_run_ok.ndjson @@ -0,0 +1,2 @@ +{"id":"3","op":"task_run","task":"test","role":"ci","ci_mode":true,"lane":{"lane_id":"linux-amd64-container","platform":"linux","arch":"amd64","backend":"pixi","lock_ref":"workstation.lock/pixi.linux-amd64.lock","containerized":true,"container":{"image":"debian@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","runtime":"podman"}},"params":{"timeout_seconds":1800}} +{"id":"3","ok":true,"result":{"exit_code":0,"duration_ms":12345,"logs":{"stdout":".workstation/state/logs/20251220T2210Z/test.stdout","stderr":".workstation/state/logs/20251220T2210Z/test.stderr"}},"errors":[],"warnings":[]} diff --git a/scripts/validate-adapter-ipc.py b/scripts/validate-adapter-ipc.py new file mode 100755 index 0000000..9e199b6 --- /dev/null +++ b/scripts/validate-adapter-ipc.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Validate the M2 Adapter IPC v0.1 contract with teeth. + +The M2 Adapter IPC protocol is JSON-over-stdio (NDJSON) between the +contract-runner and a backend adapter subprocess. This validator proves the +contract enforces its own invariants: + + * every accept fixture under fixtures/protocol/*.ndjson MUST validate + against contracts/adapter-ipc.schema.json AND satisfy the semantic + invariants (id correlation, ok/errors coupling, canonical error codes, + hello handshake rules); + * every reject fixture under fixtures/protocol/_reject/*.ndjson MUST FAIL + at least one of those checks (a reject that passes is a hole in the + contract and fails this validator). + +Provenance: 'M2 Adapter IPC Spec v0.1 (JSON-over-stdio, Subprocess Plugins)' ++ 'M2 Protocol Versioning & Compatibility Spec v0.1', SourceOS Spec intake +2026-07-31. Hashes are SHA-256 (FIPS 180-4). +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = ROOT / "contracts" / "adapter-ipc.schema.json" +ACCEPT_DIR = ROOT / "fixtures" / "protocol" +REJECT_DIR = ACCEPT_DIR / "_reject" + +PROTOCOL_VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+$") + +CANONICAL_ERROR_CODES = { + "E_PROTOCOL_INCOMPATIBLE", "E_HELLO_REQUIRED", "E_CAPABILITY_MISSING", + "E_LOCK_MISSING", "E_LOCK_INVALID", "E_LOCK_HASH_FAILED", "E_LOCK_UPDATE_DENIED", + "E_ENV_REALIZE_FAILED", "E_ENV_FLOATING_DISALLOWED", "E_ENV_NOT_FOUND", + "E_TASK_UNKNOWN", "E_TASK_FAILED", "E_TASK_TIMEOUT", "E_CMD_FAILED", + "E_PATH_ESCAPE", "E_WRITE_DENIED", "E_DEPS_INVENTORY_FAILED", + "E_LICENSE_EVIDENCE_MISSING", "E_UNSUPPORTED", "E_INTERNAL", +} + + +class ContractError(Exception): + """Raised when a message violates the schema or a semantic invariant.""" + + +def load_schema() -> Draft202012Validator: + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + return Draft202012Validator(schema) + + +def read_messages(path: Path) -> list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + try: + obj = json.loads(line) + except json.JSONDecodeError as exc: + raise ContractError(f"{path.name}:{lineno}: invalid JSON: {exc}") from exc + if not isinstance(obj, dict): + raise ContractError(f"{path.name}:{lineno}: message must be a JSON object") + messages.append(obj) + return messages + + +def check_message(validator: Draft202012Validator, msg: dict[str, Any]) -> None: + """Schema + semantic invariants for a single message. Raises on violation.""" + errors = sorted(validator.iter_errors(msg), key=lambda e: list(e.path)) + if errors: + loc = "/".join(str(p) for p in errors[0].path) or "" + raise ContractError(f"schema: {loc}: {errors[0].message}") + + if not isinstance(msg.get("id"), str) or not msg["id"]: + raise ContractError("every message must carry a non-empty string id") + + is_request = "op" in msg + is_response = "ok" in msg + if is_request == is_response: + raise ContractError("message must be exactly one of request (op) or response (ok)") + + if is_request and msg["op"] == "hello": + pv = msg.get("protocol_version") + if not isinstance(pv, str) or not PROTOCOL_VERSION_RE.match(pv): + raise ContractError("hello request needs MAJOR.MINOR protocol_version") + + if is_response: + errs = msg.get("errors") + if not isinstance(errs, list): + raise ContractError("response.errors must be an array") + if msg["ok"] is False and len(errs) == 0: + raise ContractError("ok=false must carry a non-empty errors array") + if msg["ok"] is True and len(errs) != 0: + raise ContractError("ok=true must carry an empty errors array") + for e in errs: + if e.get("code") not in CANONICAL_ERROR_CODES: + raise ContractError(f"error code {e.get('code')!r} not in canonical registry") + + +def check_correlation(messages: list[dict[str, Any]]) -> None: + """Consecutive request/response pairs must share the same id.""" + for i in range(0, len(messages) - 1, 2): + req, resp = messages[i], messages[i + 1] + if "op" in req and "ok" in resp and req.get("id") != resp.get("id"): + raise ContractError( + f"correlation: request id {req.get('id')!r} != response id {resp.get('id')!r}" + ) + + +def main() -> int: + if not SCHEMA_PATH.exists(): + print(f"ERROR: missing schema {SCHEMA_PATH}", file=sys.stderr) + return 1 + validator = load_schema() + + accepted = 0 + for path in sorted(ACCEPT_DIR.glob("*.ndjson")): + try: + messages = read_messages(path) + if not messages: + raise ContractError("accept fixture has no messages") + for msg in messages: + check_message(validator, msg) + check_correlation(messages) + except ContractError as exc: + print(f"FAIL(accept): {path.name}: {exc}", file=sys.stderr) + return 1 + accepted += 1 + print(f"OK(accept): {path.name} ({len(messages)} message(s))") + + rejected = 0 + for path in sorted(REJECT_DIR.glob("*.ndjson")): + try: + messages = read_messages(path) + for msg in messages: + check_message(validator, msg) + check_correlation(messages) + except ContractError: + rejected += 1 + print(f"OK(reject fired): {path.name}") + continue + print(f"FAIL(reject): {path.name}: expected a contract violation but it passed", file=sys.stderr) + return 1 + + if accepted == 0 or rejected == 0: + print("ERROR: expected at least one accept and one reject fixture", file=sys.stderr) + return 1 + + print(f"OK: adapter-ipc contract — {accepted} accepted, {rejected} rejected") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())