From 98838ef36b69cc62837b8a7e7835eabfc9dfbadb Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Tue, 7 Jul 2026 00:06:07 -0700 Subject: [PATCH 01/13] Add shared API SDK MCP shape profile --- AGENTS.md | 10 +++ README.md | 7 ++ docs/api-sdk-mcp-shape.md | 150 ++++++++++++++++++++++++++++++++ scripts/audit-openapi-shape.py | 153 +++++++++++++++++++++++++++++++++ 4 files changed, 320 insertions(+) create mode 100644 docs/api-sdk-mcp-shape.md create mode 100755 scripts/audit-openapi-shape.py diff --git a/AGENTS.md b/AGENTS.md index 3dd7249..7358827 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,16 @@ Use these defaults unless a repo-local task says otherwise: - Do one repo per PR unless the change is pure documentation. - Prefer complete migrations over partial churn: manifest updates, lockfiles, formatting, lint inheritance, docs, and verification should land together. +- Keep public surfaces in sync by default. When a route, JSON-RPC method, schema, + SDK method, MCP tool, CLI command, fixture, README table, or generated client + changes, update every derived or mirrored surface in the same slice and add or + run the drift check that proves they still agree. Prefer generated or + fixture-pinned contracts over hand-maintained parallel descriptions. Treat + auth headers, env vars, CLI flags, timeout names, output formats, idempotency + fields, and SDK error shapes as public surface too. New client-facing HTTP + auth should prefer `Authorization: Bearer ` with any legacy + `x--api-key` header kept only as a documented compatibility alias + backed by the same verifier. - If an agent is already active on a PR, its next sequence of turns should first reconcile that repo/PR with the ecosystem pipeline before adding unrelated product scope. Treat `scripts/ecosystem-pipeline.sh report` as the handoff diff --git a/README.md b/README.md index 68fedf0..fb61f70 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ its own PR. and E2E checks. - `scripts/watch-ecosystem-prs.py`: watches active PRs across sibling repos and posts one ecosystem compatibility redirect when needed. +- `docs/api-sdk-mcp-shape.md`: shared API, SDK, CLI, auth, error, and MCP shape + profile for public client-facing surfaces. +- `scripts/audit-openapi-shape.py`: portable OpenAPI profile audit for REST + services. - `docs/ecosystem-pipeline.md`: pipeline and language policy. - `scripts/ecosystem-smoke.sh`: root smoke gate for meta-repo checks. @@ -54,6 +58,9 @@ After the migration queues are complete, every repo should agree on: - Similar architecture in every repo: thin binaries, library-owned behavior, generated contracts, version/health/smoke surfaces, and documented storage boundaries +- One public client-facing shape for services: canonical operation names, shared + error envelopes, consistent SDK config, CLI flags/env vars, auth handling, and + MCP/tool projections that derive from or drift-check against the same contract - Rust-first implementation by default, while allowing TypeScript, Python, platform languages, and SDK languages when they are the better fit and their boundary is explicit and tested diff --git a/docs/api-sdk-mcp-shape.md b/docs/api-sdk-mcp-shape.md new file mode 100644 index 0000000..0cdd26c --- /dev/null +++ b/docs/api-sdk-mcp-shape.md @@ -0,0 +1,150 @@ +# Shared API, SDK, and MCP Shape + +Goal: every service in the ecosystem should feel like one product family from +the outside, even while each repo stays standalone. A caller should be able to +learn one SDK shape, one error model, and one MCP pattern, then move between +`beater`, `beatbox`, `tempo`, `beater-memory`, `beaterOS`, and `aether` with +minimal new ceremony. + +This is a convergence profile, not a mandate to force every repo onto the same +transport. REST services should expose OpenAPI. JSON-RPC services should publish +a method manifest. Schema-first libraries should publish JSON Schema plus +conformance fixtures. The common rule is that each public boundary has one +machine-readable source of truth and every SDK/MCP/CLI surface derives from it +or is drift-checked against it. + +## Service Profile + +Services with HTTP control planes should use this shape: + +- OpenAPI 3.1 as the committed machine contract. +- `/v1` for stable REST routes; health/readiness routes may sit outside `/v1`. +- Unique lower-camel `operationId` values, e.g. `createJob`, `getTrace`, + `validateBrowserAdapter`. +- Exactly one resource tag per operation. +- Named request and response schemas; no anonymous success objects. +- A shared error envelope for every documented 4xx/5xx response. +- Cursor pagination for list operations: `limit`, optional `cursor`, and + response `next_cursor`/`nextCursor` according to that repo's wire casing. +- One drift gate that proves the served/generated contract equals the committed + contract. + +## SDK Profile + +SDKs may be idiomatic per language, but they should share the same conceptual +surface: + +- One client config shape: `base_url`/`baseUrl`, `api_key`/`apiKey` or + `token`, `timeout_ms`/`timeoutMs`, and optional service-scoped fields such as + tenant, project, profile, or environment. +- Operation methods map from the canonical operation name. +- Transport errors and API errors are separate typed errors. +- API errors carry HTTP status, stable error code, message, optional request id, + and optional structured details. +- Auth never goes in URLs or exception strings. +- Unknown response fields do not crash deserialization. +- SDK version follows the contract version. + +The preferred auth shape for new clients is `Authorization: Bearer `. +Existing service-specific API-key headers, such as `x-beatbox-api-key`, may stay +as compatibility aliases, but new SDK, CLI, MCP, and doc work should prefer +Bearer and test any alias against the same verifier. + +For a future shared SDK core, this means every service can be described as: + +```json +{ + "service": "beatbox", + "base_url": "http://127.0.0.1:7300", + "auth": {"scheme": "bearer", "compat_headers": ["x-beatbox-api-key"]}, + "contract": "sdks/openapi.json", + "operations": ["getHealth", "execute", "createJob"] +} +``` + +The generated or shared client can then reuse the same transport, retry, +timeout, auth, error, pagination, and MCP-tool projection code. + +## CLI Profile + +CLIs should mirror the SDK shape so docs, tests, and future shared client code +can describe one public surface: + +- Use operation names from the contract for subcommands unless the CLI has a + better domain noun that is documented as an alias. +- Every networked CLI accepts the same global flags where applicable: + `--base-url`, `--api-key`, `--api-key-file`, `--timeout-ms`, `--output + json|text`, and `--json` as a shortcut for JSON output. +- Environment variables use the uppercase service prefix: + `_BASE_URL`, `_API_KEY`, and `_TIMEOUT_MS`. +- `--api-key-file` reads secret material without printing it and wins over + `_API_KEY`; explicit `--api-key` wins over both for local debugging. +- JSON output is stable and contract-shaped. Text output may be friendly, but it + must not be the only way to access a field. +- Authenticated commands must not follow redirects with auth headers attached. + +## Auth And Errors + +Auth and error handling are part of the public contract, not implementation +details: + +- New HTTP clients use `Authorization: Bearer `. +- A legacy `x--api-key` header is allowed only as a documented + compatibility alias with a test proving it reaches the same verifier. +- MCP, CLI, and SDK callers use the same auth material and the same auth failure + semantics as REST callers. +- Secrets never appear in URLs, logs, traces, panic messages, telemetry payloads, + or exception strings. +- JSON requests send `content-type: application/json` and `accept: + application/json`. +- API failures use one shared envelope per service. The SDK-facing normalized + error shape is `status`, `code`, `message`, optional `request_id`, and optional + `details`, regardless of language. +- Mutating operations that can be retried should accept an `Idempotency-Key` + header or a clearly documented request idempotency field. + +## MCP Profile + +MCP should be a projection of the same operation contract whenever the service +has a stable REST control plane: + +- `POST /mcp` for Streamable HTTP / JSON-RPC. +- `tools/list` returns one tool per operation plus explicitly documented + composite tools. +- `tools/call` dispatches through the same auth and handler path as the REST + operation. +- Tool names should use the canonical operation name unless a composite tool has + a better domain name. +- Tool input schemas should be generated from or checked against the operation + request schema. +- Tool results should include `structuredContent`; failures should set + `isError: true`. + +Services that are MCP-native, such as `beater.js` app tools or `tempo` browser +tools, should still publish a committed tool catalog fixture and drift-check it. + +## Schema Profile + +Repos that are not REST services yet should still have one public shape: + +- Contract schemas live in exactly one canonical directory. +- Conformance fixtures include at least one valid and one invalid example per + public contract. +- The README points to the canonical directory and does not call any second copy + canonical. +- If another directory exists for packaging, it is generated from the canonical + source and checked for drift. + +## Current Alignment + +- `beater`: reference implementation for REST/OpenAPI -> SDK/CLI/MCP. +- `beatbox`: has OpenAPI and SDKs; should align operation names and error docs, + then project MCP from the same surface over time. +- `tempo`: MCP-native today; should add a committed MCP catalog fixture and, if + it grows a REST control plane, an OpenAPI contract. +- `beater-memory`: has `/v1` routes; should add OpenAPI before publishing SDKs + or MCP. +- `beaterOS`: schema-first; should keep one canonical schema source and generate + packaging copies from it. +- `aether`: JSON-RPC/gRPC domain; should publish a JSON-RPC method manifest and + SDK parity checks rather than pretending it is an OpenAPI service. diff --git a/scripts/audit-openapi-shape.py b/scripts/audit-openapi-shape.py new file mode 100755 index 0000000..c2a3914 --- /dev/null +++ b/scripts/audit-openapi-shape.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Audit an OpenAPI spec for the ecosystem service shape profile. + +The check is intentionally small and portable so every repo can vendor or call +it without adopting a shared crate: + - every operation has a unique lower-camel operationId + - every operation has exactly one tag + - every documented 4xx/5xx response uses a shared error schema + - success object responses reference named schemas instead of inline objects + - list operations expose pagination parameters +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import re +import sys +from pathlib import Path +from typing import Any + + +CAMEL = re.compile(r"^[a-z][a-zA-Z0-9]*$") +DEFAULT_ERROR_SCHEMA_SUFFIXES = ("/ErrorResponse", "/ApiErrorBody") + + +@dataclass(frozen=True) +class AuditResult: + operation_count: int + unique_operation_id_count: int + schema_count: int + violations: list[str] + + +def load_spec(path: str | Path) -> dict[str, Any]: + with Path(path).open(encoding="utf-8") as handle: + return json.load(handle) + + +def operations(spec: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]: + ops: list[tuple[str, str, dict[str, Any]]] = [] + for path, methods in spec.get("paths", {}).items(): + for method, op in methods.items(): + if method == "parameters": + continue + ops.append((method.upper(), path, op)) + return ops + + +def schema_ref(response: dict[str, Any]) -> str: + return ( + response.get("content", {}) + .get("application/json", {}) + .get("schema", {}) + .get("$ref", "") + ) + + +def audit_spec( + spec: dict[str, Any], + *, + error_schema_suffixes: tuple[str, ...] = DEFAULT_ERROR_SCHEMA_SUFFIXES, + list_pagination_exemptions: set[str] | None = None, +) -> AuditResult: + violations: list[str] = [] + op_ids: dict[str, list[str]] = {} + ops = operations(spec) + list_pagination_exemptions = list_pagination_exemptions or set() + + for method, path, op in ops: + where = f"{method} {path}" + oid = op.get("operationId") + tags = op.get("tags", []) + + if not oid: + violations.append(f"{where}: missing operationId") + else: + if not CAMEL.match(oid): + violations.append(f"{where}: operationId {oid!r} is not lower-camel") + op_ids.setdefault(oid, []).append(where) + + if len(tags) != 1: + violations.append(f"{where}: expected exactly 1 tag, got {tags}") + + responses = op.get("responses", {}) + is_health = oid in {"health", "getHealth"} or path.rstrip("/").endswith("/health") + if not is_health: + err_codes = [code for code in responses if code.startswith(("4", "5"))] + if not err_codes: + violations.append(f"{where}: no documented 4xx/5xx error response") + for code in err_codes: + ref = schema_ref(responses[code]) + if not ref.endswith(error_schema_suffixes): + violations.append( + f"{where}: error {code} body is not shared error schema " + f"(got {ref or 'none'})" + ) + + for code, body in responses.items(): + if not code.startswith("2"): + continue + schema = body.get("content", {}).get("application/json", {}).get("schema", {}) + if ( + "$ref" not in schema + and schema.get("type") == "object" + and "properties" in schema + ): + violations.append( + f"{where}: success {code} uses inline anonymous object" + ) + + for oid, wheres in op_ids.items(): + if len(wheres) > 1: + violations.append(f"operationId {oid!r} is duplicated: {wheres}") + + for method, path, op in ops: + oid = op.get("operationId", "") + if oid.startswith("list") and oid not in list_pagination_exemptions: + params = {p.get("name") for p in op.get("parameters", [])} + if "cursor" not in params and "limit" not in params: + violations.append(f"{method} {path}: list op {oid!r} lacks pagination") + + return AuditResult( + operation_count=len(ops), + unique_operation_id_count=len(op_ids), + schema_count=len(spec.get("components", {}).get("schemas", {})), + violations=violations, + ) + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + if not args: + print("usage: audit-openapi-shape.py ", file=sys.stderr) + return 2 + + result = audit_spec(load_spec(args[0])) + print( + f"audited {result.operation_count} operations, " + f"{result.unique_operation_id_count} unique operationIds, " + f"{result.schema_count} schemas" + ) + if result.violations: + print(f"\n{len(result.violations)} SHAPE VIOLATIONS:") + for violation in result.violations: + print(f" - {violation}") + return 1 + print("PASS: OpenAPI shape matches ecosystem profile") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c67715ca53c05a6ba610986a62ea24e38f5a6c53 Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Tue, 7 Jul 2026 00:34:19 -0700 Subject: [PATCH 02/13] Tighten client auth shape profile --- AGENTS.md | 5 ++- docs/api-sdk-mcp-shape.md | 43 ++++++++++++++++++++---- scripts/audit-openapi-shape.py | 60 ++++++++++++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7358827..d2eabc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,10 @@ Use these defaults unless a repo-local task says otherwise: fields, and SDK error shapes as public surface too. New client-facing HTTP auth should prefer `Authorization: Bearer ` with any legacy `x--api-key` header kept only as a documented compatibility alias - backed by the same verifier. + backed by the same verifier. If a repo exposes CLI and SDK clients, their + config names, auth precedence, timeout behavior, JSON output mode, and error + fields should match the shared API/SDK/MCP shape profile unless the PR records + a tested repo-local exception. - If an agent is already active on a PR, its next sequence of turns should first reconcile that repo/PR with the ecosystem pipeline before adding unrelated product scope. Treat `scripts/ecosystem-pipeline.sh report` as the handoff diff --git a/docs/api-sdk-mcp-shape.md b/docs/api-sdk-mcp-shape.md index 0cdd26c..33c71d7 100644 --- a/docs/api-sdk-mcp-shape.md +++ b/docs/api-sdk-mcp-shape.md @@ -13,6 +13,27 @@ conformance fixtures. The common rule is that each public boundary has one machine-readable source of truth and every SDK/MCP/CLI surface derives from it or is drift-checked against it. +## Client-Facing Shape + +Every repo should describe the same client-facing facts in the same place and +use the same names across docs, SDKs, CLIs, MCP tools, examples, and fixtures: + +- Contract path: OpenAPI, JSON-RPC manifest, MCP catalog, or JSON Schema. +- Base URL field: `base_url` on snake_case surfaces and `baseUrl` on camelCase + surfaces. +- Auth field: `token` for Bearer auth. `api_key`/`apiKey` may remain only for + legacy aliases and should be documented as such. +- Timeout field: `timeout_ms`/`timeoutMs`, measured in milliseconds. +- Output mode: `json` for stable machine-readable output; `text` only as a + human convenience. +- Error fields: `status`, `code`, `message`, optional `request_id`, optional + `details`. +- Idempotency field/header: use `Idempotency-Key` for HTTP unless the transport + has a clearly documented equivalent. + +If the CLI, SDK, MCP tool, and README cannot all be updated in the same PR, the +PR must add or update a drift fixture that makes the missing surface obvious. + ## Service Profile Services with HTTP control planes should use this shape: @@ -28,15 +49,19 @@ Services with HTTP control planes should use this shape: response `next_cursor`/`nextCursor` according to that repo's wire casing. - One drift gate that proves the served/generated contract equals the committed contract. +- REST repos should run the shared OpenAPI audit with `--enforce-auth` once + their public spec declares Bearer security and public endpoint exemptions. ## SDK Profile SDKs may be idiomatic per language, but they should share the same conceptual surface: -- One client config shape: `base_url`/`baseUrl`, `api_key`/`apiKey` or - `token`, `timeout_ms`/`timeoutMs`, and optional service-scoped fields such as +- One client config shape: `base_url`/`baseUrl`, `token`, `timeout_ms`/ + `timeoutMs`, and optional service-scoped fields such as tenant, project, profile, or environment. +- `api_key`/`apiKey` is allowed only when a repo still exposes a documented + compatibility API-key alias. New docs should call the value a Bearer token. - Operation methods map from the canonical operation name. - Transport errors and API errors are separate typed errors. - API errors carry HTTP status, stable error code, message, optional request id, @@ -73,12 +98,16 @@ can describe one public surface: - Use operation names from the contract for subcommands unless the CLI has a better domain noun that is documented as an alias. - Every networked CLI accepts the same global flags where applicable: - `--base-url`, `--api-key`, `--api-key-file`, `--timeout-ms`, `--output - json|text`, and `--json` as a shortcut for JSON output. + `--base-url`, `--token`, `--token-file`, `--timeout-ms`, `--output json|text`, + and `--json` as a shortcut for JSON output. +- `--api-key` and `--api-key-file` may remain as deprecated aliases only when + the service has a documented compatibility API-key header. - Environment variables use the uppercase service prefix: - `_BASE_URL`, `_API_KEY`, and `_TIMEOUT_MS`. -- `--api-key-file` reads secret material without printing it and wins over - `_API_KEY`; explicit `--api-key` wins over both for local debugging. + `_BASE_URL`, `_TOKEN`, and `_TIMEOUT_MS`. +- Compatibility aliases may also read `_API_KEY`, but the canonical + env var is `_TOKEN`. +- `--token-file` reads secret material without printing it and wins over + `_TOKEN`; explicit `--token` wins over both for local debugging. - JSON output is stable and contract-shaped. Text output may be friendly, but it must not be the only way to access a field. - Authenticated commands must not follow redirects with auth headers attached. diff --git a/scripts/audit-openapi-shape.py b/scripts/audit-openapi-shape.py index c2a3914..2607127 100755 --- a/scripts/audit-openapi-shape.py +++ b/scripts/audit-openapi-shape.py @@ -22,6 +22,7 @@ CAMEL = re.compile(r"^[a-z][a-zA-Z0-9]*$") DEFAULT_ERROR_SCHEMA_SUFFIXES = ("/ErrorResponse", "/ApiErrorBody") +PUBLIC_OPERATION_IDS = {"health", "getHealth", "openapi", "getOpenapi", "postMcp"} @dataclass(frozen=True) @@ -56,16 +57,52 @@ def schema_ref(response: dict[str, Any]) -> str: ) +def is_public_operation(path: str, operation_id: str | None) -> bool: + normalized = path.rstrip("/") + return ( + operation_id in PUBLIC_OPERATION_IDS + or normalized.endswith("/health") + or normalized.endswith("/openapi.json") + or normalized == "/mcp" + ) + + +def has_bearer_security_scheme(spec: dict[str, Any]) -> bool: + schemes = spec.get("components", {}).get("securitySchemes", {}) + for scheme in schemes.values(): + if ( + scheme.get("type") == "http" + and scheme.get("scheme", "").lower() == "bearer" + ): + return True + return False + + +def operation_requires_security( + spec_security: Any, + operation_security: Any, +) -> bool: + security = operation_security if operation_security is not None else spec_security + return isinstance(security, list) and bool(security) + + def audit_spec( spec: dict[str, Any], *, error_schema_suffixes: tuple[str, ...] = DEFAULT_ERROR_SCHEMA_SUFFIXES, list_pagination_exemptions: set[str] | None = None, + enforce_auth: bool = False, ) -> AuditResult: violations: list[str] = [] op_ids: dict[str, list[str]] = {} ops = operations(spec) list_pagination_exemptions = list_pagination_exemptions or set() + spec_security = spec.get("security") + + if enforce_auth and not has_bearer_security_scheme(spec): + violations.append( + "components.securitySchemes: missing HTTP bearer security scheme" + ) for method, path, op in ops: where = f"{method} {path}" @@ -82,6 +119,12 @@ def audit_spec( if len(tags) != 1: violations.append(f"{where}: expected exactly 1 tag, got {tags}") + if enforce_auth and not is_public_operation(path, oid): + if not operation_requires_security(spec_security, op.get("security")): + violations.append( + f"{where}: authenticated operation lacks OpenAPI security" + ) + responses = op.get("responses", {}) is_health = oid in {"health", "getHealth"} or path.rstrip("/").endswith("/health") if not is_health: @@ -131,10 +174,23 @@ def audit_spec( def main(argv: list[str] | None = None) -> int: args = sys.argv[1:] if argv is None else argv if not args: - print("usage: audit-openapi-shape.py ", file=sys.stderr) + print( + "usage: audit-openapi-shape.py [--enforce-auth] ", + file=sys.stderr, + ) + return 2 + enforce_auth = False + if "--enforce-auth" in args: + enforce_auth = True + args = [arg for arg in args if arg != "--enforce-auth"] + if not args: + print( + "usage: audit-openapi-shape.py [--enforce-auth] ", + file=sys.stderr, + ) return 2 - result = audit_spec(load_spec(args[0])) + result = audit_spec(load_spec(args[0]), enforce_auth=enforce_auth) print( f"audited {result.operation_count} operations, " f"{result.unique_operation_id_count} unique operationIds, " From f172b951166a7a7aaa24c1fa9ed29bb18c19b3cf Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Tue, 7 Jul 2026 00:49:16 -0700 Subject: [PATCH 03/13] Use canonical product names in API profile --- docs/api-sdk-mcp-shape.md | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/api-sdk-mcp-shape.md b/docs/api-sdk-mcp-shape.md index 33c71d7..a7dba77 100644 --- a/docs/api-sdk-mcp-shape.md +++ b/docs/api-sdk-mcp-shape.md @@ -3,8 +3,11 @@ Goal: every service in the ecosystem should feel like one product family from the outside, even while each repo stays standalone. A caller should be able to learn one SDK shape, one error model, and one MCP pattern, then move between -`beater`, `beatbox`, `tempo`, `beater-memory`, `beaterOS`, and `aether` with -minimal new ceremony. +`Palette`, `cradle`, `tempo`, `temp.js`, `tempOS`, `remi`, and `Arrha` with +minimal new ceremony. Legacy binary, crate, or route names such as `beatbox`, +`beater-memory`, or `aetherctl` may remain inside their owning repos until their +repo-local rename tasks land, but new public docs and manifests should use the +canonical product names from `ecosystem.toml`. This is a convergence profile, not a mandate to force every repo onto the same transport. REST services should expose OpenAPI. JSON-RPC services should publish @@ -79,7 +82,7 @@ For a future shared SDK core, this means every service can be described as: ```json { - "service": "beatbox", + "service": "cradle", "base_url": "http://127.0.0.1:7300", "auth": {"scheme": "bearer", "compat_headers": ["x-beatbox-api-key"]}, "contract": "sdks/openapi.json", @@ -151,6 +154,8 @@ has a stable REST control plane: Services that are MCP-native, such as `beater.js` app tools or `tempo` browser tools, should still publish a committed tool catalog fixture and drift-check it. +Use the current product name (`temp.js`) in public docs and reserve `beater.js` +for legacy package/binary identifiers that have not migrated yet. ## Schema Profile @@ -166,14 +171,16 @@ Repos that are not REST services yet should still have one public shape: ## Current Alignment -- `beater`: reference implementation for REST/OpenAPI -> SDK/CLI/MCP. -- `beatbox`: has OpenAPI and SDKs; should align operation names and error docs, - then project MCP from the same surface over time. +- `Palette`: reference implementation for REST/OpenAPI -> SDK/CLI/MCP. +- `cradle`: has the legacy `beatbox` OpenAPI and SDKs; should keep operation + names, auth metadata, error docs, and future MCP projection tied to the same + contract. - `tempo`: MCP-native today; should add a committed MCP catalog fixture and, if it grows a REST control plane, an OpenAPI contract. -- `beater-memory`: has `/v1` routes; should add OpenAPI before publishing SDKs - or MCP. -- `beaterOS`: schema-first; should keep one canonical schema source and generate +- `remi`: has legacy `beater-memory` `/v1` routes; should keep a committed route + manifest now and add OpenAPI before publishing SDKs or MCP. +- `tempOS`: schema-first; should keep one canonical schema source and generate packaging copies from it. -- `aether`: JSON-RPC/gRPC domain; should publish a JSON-RPC method manifest and - SDK parity checks rather than pretending it is an OpenAPI service. +- `Arrha`: JSON-RPC/gRPC domain with legacy `aether` binaries; should publish a + JSON-RPC method manifest and SDK parity checks rather than pretending it is an + OpenAPI service. From 47783c5ee033f87515072f2d22e24c2afbd626ca Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Tue, 7 Jul 2026 00:54:10 -0700 Subject: [PATCH 04/13] Add client surface manifest audit --- AGENTS.md | 5 +- README.md | 5 + docs/api-sdk-mcp-shape.md | 51 +++++++ docs/client-surface-manifest.schema.json | 171 ++++++++++++++++++++++ scripts/audit-client-surface.py | 179 +++++++++++++++++++++++ 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 docs/client-surface-manifest.schema.json create mode 100755 scripts/audit-client-surface.py diff --git a/AGENTS.md b/AGENTS.md index d2eabc4..c991a3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,10 @@ Use these defaults unless a repo-local task says otherwise: backed by the same verifier. If a repo exposes CLI and SDK clients, their config names, auth precedence, timeout behavior, JSON output mode, and error fields should match the shared API/SDK/MCP shape profile unless the PR records - a tested repo-local exception. + a tested repo-local exception. For any client-facing repo that adds or changes + SDK, CLI, MCP, auth, or error behavior, add or update a client-surface manifest + matching `docs/client-surface-manifest.schema.json` and run + `scripts/audit-client-surface.py` or the repo-local copy of that check. - If an agent is already active on a PR, its next sequence of turns should first reconcile that repo/PR with the ecosystem pipeline before adding unrelated product scope. Treat `scripts/ecosystem-pipeline.sh report` as the handoff diff --git a/README.md b/README.md index fb61f70..606ae8f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ its own PR. profile for public client-facing surfaces. - `scripts/audit-openapi-shape.py`: portable OpenAPI profile audit for REST services. +- `docs/client-surface-manifest.schema.json`: common manifest format for SDK, + CLI, auth, output, and error surfaces. +- `scripts/audit-client-surface.py`: portable client-surface manifest audit for + SDK/CLI/auth parity. - `docs/ecosystem-pipeline.md`: pipeline and language policy. - `scripts/ecosystem-smoke.sh`: root smoke gate for meta-repo checks. @@ -72,6 +76,7 @@ scripts/ecosystem-pipeline.sh report scripts/ecosystem-pipeline.sh gate python3 scripts/check-ecosystem-binaries.py python3 scripts/check-ecosystem-uniformity.py +python3 scripts/audit-client-surface.py path/to/client-surface.json python3 scripts/run-ecosystem-verification.py --list python3 scripts/run-ecosystem-verification.py --repo tempo python3 scripts/watch-ecosystem-prs.py diff --git a/docs/api-sdk-mcp-shape.md b/docs/api-sdk-mcp-shape.md index a7dba77..2611269 100644 --- a/docs/api-sdk-mcp-shape.md +++ b/docs/api-sdk-mcp-shape.md @@ -37,6 +37,57 @@ use the same names across docs, SDKs, CLIs, MCP tools, examples, and fixtures: If the CLI, SDK, MCP tool, and README cannot all be updated in the same PR, the PR must add or update a drift fixture that makes the missing surface obvious. +Client-facing repos should also carry a small client-surface manifest using the +schema in `docs/client-surface-manifest.schema.json`. The manifest is the compact +SDK/CLI/auth/error contract that a future shared SDK core can consume without +learning every repo's internal layout. Run `scripts/audit-client-surface.py` +against that manifest when any SDK config, CLI flag, env var, auth rule, output +mode, operation name, or normalized error field changes. + +Minimal Bearer-auth example: + +```json +{ + "surface_version": "2026-07-07", + "service": "cradle", + "contract": { + "type": "openapi", + "path": "sdks/openapi.json", + "drift_check": "cargo test -p beatbox-server --test openapi_drift" + }, + "auth": { + "scheme": "bearer", + "header": "Authorization", + "value_format": "Bearer ", + "token_field": "token", + "env_token": "CRADLE_TOKEN", + "compat_headers": ["x-beatbox-api-key"], + "compat_env": ["CRADLE_API_KEY"], + "precedence": ["flag", "token_file", "env"] + }, + "sdk": { + "config_fields": ["base_url", "token", "timeout_ms"], + "error_type": "ApiError", + "operation_names": "contract" + }, + "cli": { + "global_flags": [ + "--base-url", + "--token", + "--token-file", + "--timeout-ms", + "--output" + ], + "env": ["CRADLE_BASE_URL", "CRADLE_TOKEN", "CRADLE_TIMEOUT_MS"], + "output_modes": ["json", "text"], + "operation_names": "contract" + }, + "errors": { + "fields": ["status", "code", "message", "request_id", "details"] + } +} +``` + ## Service Profile Services with HTTP control planes should use this shape: diff --git a/docs/client-surface-manifest.schema.json b/docs/client-surface-manifest.schema.json new file mode 100644 index 0000000..a6903c2 --- /dev/null +++ b/docs/client-surface-manifest.schema.json @@ -0,0 +1,171 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://jadenfix.github.io/ecosystem/client-surface-manifest.schema.json", + "title": "Tempera Client Surface Manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "surface_version", + "service", + "contract", + "auth", + "sdk", + "cli", + "errors" + ], + "properties": { + "surface_version": { + "type": "string", + "const": "2026-07-07" + }, + "service": { + "type": "string", + "minLength": 1 + }, + "contract": { + "type": "object", + "additionalProperties": false, + "required": ["type", "path", "drift_check"], + "properties": { + "type": { + "type": "string", + "enum": [ + "openapi", + "json_rpc_manifest", + "mcp_catalog", + "json_schema", + "http_route_manifest" + ] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "drift_check": { + "type": "string", + "minLength": 1 + } + } + }, + "auth": { + "type": "object", + "additionalProperties": false, + "required": ["scheme", "token_field", "precedence"], + "properties": { + "scheme": { + "type": "string", + "enum": ["bearer", "none"] + }, + "header": { + "type": "string" + }, + "value_format": { + "type": "string" + }, + "token_field": { + "type": "string", + "enum": ["token"] + }, + "env_token": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*_TOKEN$" + }, + "compat_headers": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "compat_env": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]*_API_KEY$" + }, + "uniqueItems": true + }, + "precedence": { + "type": "array", + "items": { + "type": "string", + "enum": ["flag", "token_file", "env"] + }, + "minItems": 3, + "maxItems": 3, + "uniqueItems": true + } + } + }, + "sdk": { + "type": "object", + "additionalProperties": false, + "required": ["config_fields", "error_type", "operation_names"], + "properties": { + "config_fields": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "error_type": { + "type": "string", + "minLength": 1 + }, + "operation_names": { + "type": "string", + "enum": ["contract"] + } + } + }, + "cli": { + "type": "object", + "additionalProperties": false, + "required": ["global_flags", "env", "output_modes", "operation_names"], + "properties": { + "global_flags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "env": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "output_modes": { + "type": "array", + "items": { + "type": "string", + "enum": ["json", "text"] + }, + "uniqueItems": true + }, + "operation_names": { + "type": "string", + "enum": ["contract", "documented_aliases"] + } + } + }, + "errors": { + "type": "object", + "additionalProperties": false, + "required": ["fields"], + "properties": { + "fields": { + "type": "array", + "items": { + "type": "string", + "enum": ["status", "code", "message", "request_id", "details"] + }, + "uniqueItems": true + } + } + } + } +} diff --git a/scripts/audit-client-surface.py b/scripts/audit-client-surface.py new file mode 100755 index 0000000..f909f7d --- /dev/null +++ b/scripts/audit-client-surface.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Audit a client-surface manifest for SDK, CLI, auth, and error consistency.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import re +import sys +from pathlib import Path +from typing import Any + + +SURFACE_VERSION = "2026-07-07" +CONTRACT_TYPES = { + "openapi", + "json_rpc_manifest", + "mcp_catalog", + "json_schema", + "http_route_manifest", +} +AUTH_PRECEDENCE = ["flag", "token_file", "env"] +SDK_CONFIG_FIELDS = {"base_url", "token", "timeout_ms"} +CLI_FLAGS = {"--base-url", "--token", "--token-file", "--timeout-ms", "--output"} +ERROR_FIELDS = ["status", "code", "message"] +OPTIONAL_ERROR_FIELDS = {"request_id", "details"} +LOWER_CAMEL = re.compile(r"^[a-z][a-zA-Z0-9]*$") +ENV_TOKEN = re.compile(r"^[A-Z][A-Z0-9_]*_TOKEN$") +ENV_API_KEY = re.compile(r"^[A-Z][A-Z0-9_]*_API_KEY$") + + +@dataclass(frozen=True) +class AuditResult: + service: str + violations: list[str] + + +def load_manifest(path: str | Path) -> dict[str, Any]: + with Path(path).open(encoding="utf-8") as handle: + return json.load(handle) + + +def require_object(parent: dict[str, Any], key: str, violations: list[str]) -> dict[str, Any]: + value = parent.get(key) + if not isinstance(value, dict): + violations.append(f"{key}: expected object") + return {} + return value + + +def require_list(parent: dict[str, Any], key: str, violations: list[str]) -> list[Any]: + value = parent.get(key) + if not isinstance(value, list): + violations.append(f"{key}: expected array") + return [] + return value + + +def audit_manifest(manifest: dict[str, Any]) -> AuditResult: + violations: list[str] = [] + service = manifest.get("service", "") + + if manifest.get("surface_version") != SURFACE_VERSION: + violations.append( + f"surface_version: expected {SURFACE_VERSION!r}, got " + f"{manifest.get('surface_version')!r}" + ) + if not isinstance(service, str) or not service.strip(): + violations.append("service: expected non-empty string") + + contract = require_object(manifest, "contract", violations) + contract_type = contract.get("type") + if contract_type not in CONTRACT_TYPES: + violations.append(f"contract.type: expected one of {sorted(CONTRACT_TYPES)}") + for key in ("path", "drift_check"): + if not isinstance(contract.get(key), str) or not contract.get(key).strip(): + violations.append(f"contract.{key}: expected non-empty string") + + auth = require_object(manifest, "auth", violations) + auth_scheme = auth.get("scheme") + if auth_scheme not in {"bearer", "none"}: + violations.append("auth.scheme: expected 'bearer' or 'none'") + if auth.get("token_field") != "token": + violations.append("auth.token_field: canonical client field must be 'token'") + if auth_scheme == "bearer": + if auth.get("header") != "Authorization": + violations.append("auth.header: bearer auth must use Authorization") + if auth.get("value_format") != "Bearer ": + violations.append("auth.value_format: expected 'Bearer '") + env_token = auth.get("env_token") + if not isinstance(env_token, str) or not ENV_TOKEN.match(env_token): + violations.append("auth.env_token: expected _TOKEN") + + precedence = require_list(auth, "precedence", violations) + if precedence and precedence != AUTH_PRECEDENCE: + violations.append("auth.precedence: expected ['flag', 'token_file', 'env']") + + for header in auth.get("compat_headers", []): + if not isinstance(header, str) or not header.lower().endswith("-api-key"): + violations.append( + "auth.compat_headers: compatibility headers must be API-key aliases" + ) + for env_name in auth.get("compat_env", []): + if not isinstance(env_name, str) or not ENV_API_KEY.match(env_name): + violations.append("auth.compat_env: expected _API_KEY aliases") + + sdk = require_object(manifest, "sdk", violations) + config_fields = set(require_list(sdk, "config_fields", violations)) + missing_sdk_fields = sorted(SDK_CONFIG_FIELDS - config_fields) + if missing_sdk_fields: + violations.append(f"sdk.config_fields: missing {missing_sdk_fields}") + if "api_key" in config_fields or "apiKey" in config_fields: + violations.append("sdk.config_fields: api_key/apiKey may not be canonical") + if sdk.get("operation_names") != "contract": + violations.append("sdk.operation_names: expected 'contract'") + if not isinstance(sdk.get("error_type"), str) or not sdk.get("error_type", "").strip(): + violations.append("sdk.error_type: expected non-empty string") + + cli = require_object(manifest, "cli", violations) + flags = set(require_list(cli, "global_flags", violations)) + missing_flags = sorted(CLI_FLAGS - flags) + if missing_flags: + violations.append(f"cli.global_flags: missing {missing_flags}") + if "--api-key" in flags or "--api-key-file" in flags: + violations.append("cli.global_flags: api-key flags may only be aliases") + env = set(require_list(cli, "env", violations)) + if auth_scheme == "bearer" and auth.get("env_token") not in env: + violations.append("cli.env: missing canonical token env var") + output_modes = set(require_list(cli, "output_modes", violations)) + if "json" not in output_modes: + violations.append("cli.output_modes: missing stable json output") + if cli.get("operation_names") not in {"contract", "documented_aliases"}: + violations.append( + "cli.operation_names: expected 'contract' or 'documented_aliases'" + ) + + errors = require_object(manifest, "errors", violations) + error_fields = require_list(errors, "fields", violations) + if error_fields[: len(ERROR_FIELDS)] != ERROR_FIELDS: + violations.append( + "errors.fields: must start with ['status', 'code', 'message']" + ) + for field in error_fields[len(ERROR_FIELDS) :]: + if field not in OPTIONAL_ERROR_FIELDS: + violations.append(f"errors.fields: unknown optional field {field!r}") + + operations = manifest.get("operations") + if operations is not None: + if not isinstance(operations, list): + violations.append("operations: expected array when present") + else: + for operation in operations: + if not isinstance(operation, str) or not LOWER_CAMEL.match(operation): + violations.append( + f"operations: {operation!r} is not a lower-camel operation name" + ) + + return AuditResult(service=str(service), violations=violations) + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + if len(args) != 1: + print("usage: audit-client-surface.py ", file=sys.stderr) + return 2 + + result = audit_manifest(load_manifest(args[0])) + print(f"audited client surface for {result.service}") + if result.violations: + print(f"\n{len(result.violations)} CLIENT SURFACE VIOLATIONS:") + for violation in result.violations: + print(f" - {violation}") + return 1 + print("PASS: client surface matches ecosystem profile") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 814b6212b62e6d618824ccf1f5635ce8ef798df9 Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Tue, 7 Jul 2026 01:00:10 -0700 Subject: [PATCH 05/13] Allow transport-native MCP error shapes --- docs/api-sdk-mcp-shape.md | 4 ++++ scripts/audit-openapi-shape.py | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/api-sdk-mcp-shape.md b/docs/api-sdk-mcp-shape.md index 2611269..0892894 100644 --- a/docs/api-sdk-mcp-shape.md +++ b/docs/api-sdk-mcp-shape.md @@ -99,6 +99,10 @@ Services with HTTP control planes should use this shape: - Exactly one resource tag per operation. - Named request and response schemas; no anonymous success objects. - A shared error envelope for every documented 4xx/5xx response. +- Transport-native endpoints such as MCP JSON-RPC may document transport errors + in that protocol's shape instead of the REST error envelope; do not force a + REST `ErrorResponse` onto `/mcp` if runtime returns a JSON-RPC envelope or an + empty origin/CORS denial. - Cursor pagination for list operations: `limit`, optional `cursor`, and response `next_cursor`/`nextCursor` according to that repo's wire casing. - One drift gate that proves the served/generated contract equals the committed diff --git a/scripts/audit-openapi-shape.py b/scripts/audit-openapi-shape.py index 2607127..51a0ca0 100755 --- a/scripts/audit-openapi-shape.py +++ b/scripts/audit-openapi-shape.py @@ -67,6 +67,10 @@ def is_public_operation(path: str, operation_id: str | None) -> bool: ) +def uses_transport_native_errors(path: str, operation_id: str | None) -> bool: + return path.rstrip("/") == "/mcp" or operation_id == "postMcp" + + def has_bearer_security_scheme(spec: dict[str, Any]) -> bool: schemes = spec.get("components", {}).get("securitySchemes", {}) for scheme in schemes.values(): @@ -127,7 +131,7 @@ def audit_spec( responses = op.get("responses", {}) is_health = oid in {"health", "getHealth"} or path.rstrip("/").endswith("/health") - if not is_health: + if not is_health and not uses_transport_native_errors(path, oid): err_codes = [code for code in responses if code.startswith(("4", "5"))] if not err_codes: violations.append(f"{where}: no documented 4xx/5xx error response") From 5be1a6f8d33819365c2388f0eda998df76b24d26 Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Tue, 7 Jul 2026 01:04:40 -0700 Subject: [PATCH 06/13] Add local checkout alignment audit --- AGENTS.md | 5 +- README.md | 3 + ecosystem.toml | 11 +++ scripts/check-local-checkouts.py | 126 +++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100755 scripts/check-local-checkouts.py diff --git a/AGENTS.md b/AGENTS.md index c991a3f..a50f4f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,10 @@ Delete each item only after it is complete and verified. - [ ] Finish repo-local rename migrations so package names, binary names, generated clients, docs, fixtures, and local checkout folders converge on the Tempera product names above. Do not rename GitHub repositories through - automation unless the user explicitly asks for remote repo renames. + automation unless the user explicitly asks for remote repo renames. Use + `python3 scripts/check-local-checkouts.py --root ` to + audit legacy checkout names and print safe alias/remote commands before any + filesystem rename. - [ ] Enforce `docs/architecture-uniformity.md` across every repo. Remove repo-local exceptions unless they have benchmark, security, or product evidence. - [ ] Promote `scripts/ecosystem-pipeline.sh gate` to the required CI path once diff --git a/README.md b/README.md index 606ae8f..860c638 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ its own PR. - `scripts/check-ecosystem-binaries.py`: verifies declared binaries still exist. - `scripts/check-ecosystem-uniformity.py`: verifies manifests match the target state after migrations land. +- `scripts/check-local-checkouts.py`: reports legacy checkout folder names and + remote mismatches without renaming user worktrees. - `scripts/run-ecosystem-verification.py`: runs declared per-repo verification commands. - `scripts/ecosystem-pipeline.sh`: staged report/gate pipeline for meta, repo, @@ -76,6 +78,7 @@ scripts/ecosystem-pipeline.sh report scripts/ecosystem-pipeline.sh gate python3 scripts/check-ecosystem-binaries.py python3 scripts/check-ecosystem-uniformity.py +python3 scripts/check-local-checkouts.py --root /Users/jadenfix/Desktop/ecosystem python3 scripts/audit-client-surface.py path/to/client-surface.json python3 scripts/run-ecosystem-verification.py --list python3 scripts/run-ecosystem-verification.py --repo tempo diff --git a/ecosystem.toml b/ecosystem.toml index 83e785e..3dd8df0 100644 --- a/ecosystem.toml +++ b/ecosystem.toml @@ -19,6 +19,17 @@ memory = "remi" sandbox = "cradle" settlement = "Arrha" +[checkout_aliases] +# Temporary local checkout names seen before repo-local rename migrations. These +# are aliases only; canonical sibling checkout paths remain the `repos.*.path` +# values below. +palette = ["beater"] +arrha = ["aether"] +cradle = ["beatbox"] +remi = ["beater-memory"] +temp-js = ["beater.js"] +tempOS = ["beaterOS"] + [policy.toolchain] # Before any migration PR, verify the latest stable Rust from the official Rust # release page and update this exact pin if it moved. Checked on 2026-07-06: diff --git a/scripts/check-local-checkouts.py b/scripts/check-local-checkouts.py new file mode 100755 index 0000000..fe336a3 --- /dev/null +++ b/scripts/check-local-checkouts.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Audit local sibling checkout names and GitHub remotes. + +This check is intentionally report-only. It does not rename directories or +rewrite remotes because these sibling repos may contain user work. It prints the +exact safe commands a human can run when ready. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +from ecosystem_toml import load as load_toml + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = ROOT / "ecosystem.toml" + + +def normalize_remote(remote: str) -> str: + remote = remote.strip() + if remote.startswith("git@github.com:"): + remote = "https://github.com/" + remote.removeprefix("git@github.com:") + if remote.endswith(".git"): + remote = remote[:-4] + return remote + + +def git_remote(path: Path) -> str | None: + try: + output = subprocess.check_output( + ["git", "-C", str(path), "remote", "get-url", "origin"], + text=True, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return None + return normalize_remote(output) + + +def shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Audit canonical ecosystem checkout names and remotes." + ) + parser.add_argument( + "--root", + type=Path, + default=ROOT.parent, + help="directory containing sibling repo checkouts; defaults to this repo's parent", + ) + args = parser.parse_args() + + data = load_toml(MANIFEST) + checkout_root = args.root.resolve() + aliases = data.get("checkout_aliases", {}) + problems: list[str] = [] + commands: list[str] = [] + + for repo_key, spec in sorted(data["repos"].items()): + canonical = checkout_root / spec["path"] + expected_remote = normalize_remote(spec["repository"]) + alias_paths = [checkout_root / alias for alias in aliases.get(repo_key, [])] + + if canonical.is_dir(): + remote = git_remote(canonical) + if remote and remote != expected_remote: + problems.append( + f"{spec['product']}: {canonical.name} origin {remote!r} " + f"!= {expected_remote!r}" + ) + commands.append( + f"git -C {shell_quote(str(canonical))} remote set-url origin " + f"{shell_quote(expected_remote + '.git')}" + ) + continue + + existing_aliases = [path for path in alias_paths if path.is_dir()] + if not existing_aliases: + problems.append( + f"{spec['product']}: missing canonical checkout {canonical}" + ) + continue + + alias = existing_aliases[0] + remote = git_remote(alias) + problems.append( + f"{spec['product']}: canonical checkout {canonical.name!r} is missing; " + f"legacy alias {alias.name!r} exists" + ) + commands.append( + f"ln -s {shell_quote(alias.name)} {shell_quote(str(canonical))}" + ) + if remote and remote != expected_remote: + problems.append( + f"{spec['product']}: alias {alias.name} origin {remote!r} " + f"!= {expected_remote!r}" + ) + commands.append( + f"git -C {shell_quote(str(alias))} remote set-url origin " + f"{shell_quote(expected_remote + '.git')}" + ) + + if problems: + print("local checkout audit found mismatches:") + for problem in problems: + print(f"- {problem}") + if commands: + print("\nSuggested non-destructive alignment commands:") + print(f"cd {shell_quote(str(checkout_root))}") + for command in commands: + print(command) + return 1 + + print(f"local checkout audit passed for {checkout_root}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a363f1b26cd841172b01271012e5b54cdfa7ff4a Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Tue, 7 Jul 2026 01:29:17 -0700 Subject: [PATCH 07/13] tighten client surface contract --- AGENTS.md | 11 ++ README.md | 3 + docs/api-sdk-mcp-shape.md | 36 ++++++- docs/client-surface-manifest.example.json | 48 +++++++++ docs/client-surface-manifest.schema.json | 76 ++++++++++++- scripts/audit-client-surface.py | 125 +++++++++++++++++++--- 6 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 docs/client-surface-manifest.example.json diff --git a/AGENTS.md b/AGENTS.md index a50f4f9..ce483e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,17 @@ Use these defaults unless a repo-local task says otherwise: SDK, CLI, MCP, auth, or error behavior, add or update a client-surface manifest matching `docs/client-surface-manifest.schema.json` and run `scripts/audit-client-surface.py` or the repo-local copy of that check. +- Do not let CLI, SDK, MCP, docs, examples, and manifests drift apart. Public + client-facing changes are not complete until every mirrored surface uses the + same canonical operation names, `base_url`/`token`/`timeout_ms` client config, + `--base-url`/`--token`/`--token-file`/`--timeout-ms`/`--output`/`--json` CLI + flags, service-prefixed `_TOKEN` env vars, shared Bearer auth + semantics, and normalized `status`/`code`/`message` error fields. Use + `python3 scripts/audit-client-surface.py --print-template ` when a + repo needs a new manifest, then keep that manifest in sync in the same PR as + the code, docs, fixtures, and generated clients. The long-term target is that + these repos can share one SDK transport/auth/error core; every repo-local + exception should be visible, tested, and temporary. - If an agent is already active on a PR, its next sequence of turns should first reconcile that repo/PR with the ecosystem pipeline before adding unrelated product scope. Treat `scripts/ecosystem-pipeline.sh report` as the handoff diff --git a/README.md b/README.md index 860c638..f2990eb 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ its own PR. services. - `docs/client-surface-manifest.schema.json`: common manifest format for SDK, CLI, auth, output, and error surfaces. +- `docs/client-surface-manifest.example.json`: copyable known-good profile for + OpenAPI plus SDK, CLI, auth, error, and MCP catalog parity. - `scripts/audit-client-surface.py`: portable client-surface manifest audit for SDK/CLI/auth parity. - `docs/ecosystem-pipeline.md`: pipeline and language policy. @@ -79,6 +81,7 @@ scripts/ecosystem-pipeline.sh gate python3 scripts/check-ecosystem-binaries.py python3 scripts/check-ecosystem-uniformity.py python3 scripts/check-local-checkouts.py --root /Users/jadenfix/Desktop/ecosystem +python3 scripts/audit-client-surface.py --print-template cradle python3 scripts/audit-client-surface.py path/to/client-surface.json python3 scripts/run-ecosystem-verification.py --list python3 scripts/run-ecosystem-verification.py --repo tempo diff --git a/docs/api-sdk-mcp-shape.md b/docs/api-sdk-mcp-shape.md index 0892894..2f2525c 100644 --- a/docs/api-sdk-mcp-shape.md +++ b/docs/api-sdk-mcp-shape.md @@ -44,6 +44,28 @@ learning every repo's internal layout. Run `scripts/audit-client-surface.py` against that manifest when any SDK config, CLI flag, env var, auth rule, output mode, operation name, or normalized error field changes. +Create a starting point with: + +```sh +python3 scripts/audit-client-surface.py --print-template cradle \ + > docs/client-surface.json +``` + +The standard profile is intentionally narrow: + +- SDK config starts with `base_url`, `token`, `timeout_ms`. +- CLI globals start with `--base-url`, `--token`, `--token-file`, + `--timeout-ms`, `--output`, `--json`. +- Auth secret lookup is always `--token`, then `--token-file`, then the + service-prefixed token env var. +- Service env vars use the canonical product name, uppercased with punctuation + converted to underscores: `CRADLE_TOKEN`, `TEMP_JS_TOKEN`, `ARRHA_TOKEN`. +- Stable CLI output supports both `json` and `text`; JSON is the contract-shaped + output used by tests and automation. +- Normalized SDK/CLI errors start with `status`, `code`, and `message`. +- MCP projections, when present, record their catalog path, drift check, shared + auth behavior, and error mode in the same manifest. + Minimal Bearer-auth example: ```json @@ -76,7 +98,8 @@ Minimal Bearer-auth example: "--token", "--token-file", "--timeout-ms", - "--output" + "--output", + "--json" ], "env": ["CRADLE_BASE_URL", "CRADLE_TOKEN", "CRADLE_TIMEOUT_MS"], "output_modes": ["json", "text"], @@ -84,6 +107,13 @@ Minimal Bearer-auth example: }, "errors": { "fields": ["status", "code", "message", "request_id", "details"] + }, + "mcp": { + "path": "crates/beatbox-server/fixtures/mcp-tools.catalog.json", + "drift_check": "cargo test -p beatbox-server --test mcp_catalog_drift", + "tool_names": "catalog", + "auth": "shared", + "errors": "transport_native" } } ``` @@ -166,6 +196,10 @@ can describe one public surface: env var is `_TOKEN`. - `--token-file` reads secret material without printing it and wins over `_TOKEN`; explicit `--token` wins over both for local debugging. +- SDKs, CLIs, and MCP clients must all use that same precedence: + `--token`/config token, then token file, then env. Do not add repo-local + precedence rules unless the manifest records and the audit allows the + exception. - JSON output is stable and contract-shaped. Text output may be friendly, but it must not be the only way to access a field. - Authenticated commands must not follow redirects with auth headers attached. diff --git a/docs/client-surface-manifest.example.json b/docs/client-surface-manifest.example.json new file mode 100644 index 0000000..ce3cc8c --- /dev/null +++ b/docs/client-surface-manifest.example.json @@ -0,0 +1,48 @@ +{ + "surface_version": "2026-07-07", + "service": "cradle", + "contract": { + "type": "openapi", + "path": "sdks/openapi.json", + "drift_check": "cargo test -p beatbox-server --test openapi_drift" + }, + "auth": { + "scheme": "bearer", + "header": "Authorization", + "value_format": "Bearer ", + "token_field": "token", + "env_token": "CRADLE_TOKEN", + "compat_headers": ["x-beatbox-api-key"], + "compat_env": ["CRADLE_API_KEY"], + "precedence": ["flag", "token_file", "env"] + }, + "sdk": { + "config_fields": ["base_url", "token", "timeout_ms"], + "error_type": "ApiError", + "operation_names": "contract" + }, + "cli": { + "global_flags": [ + "--base-url", + "--token", + "--token-file", + "--timeout-ms", + "--output", + "--json" + ], + "env": ["CRADLE_BASE_URL", "CRADLE_TOKEN", "CRADLE_TIMEOUT_MS"], + "output_modes": ["json", "text"], + "operation_names": "contract" + }, + "errors": { + "fields": ["status", "code", "message", "request_id", "details"] + }, + "mcp": { + "path": "crates/beatbox-server/fixtures/mcp-tools.catalog.json", + "drift_check": "cargo test -p beatbox-server --test mcp_catalog_drift", + "tool_names": "catalog", + "auth": "shared", + "errors": "transport_native" + }, + "operations": ["getHealth", "execute", "createJob", "getJob", "cancelJob"] +} diff --git a/docs/client-surface-manifest.schema.json b/docs/client-surface-manifest.schema.json index a6903c2..63d217a 100644 --- a/docs/client-surface-manifest.schema.json +++ b/docs/client-surface-manifest.schema.json @@ -91,6 +91,17 @@ "type": "string", "enum": ["flag", "token_file", "env"] }, + "prefixItems": [ + { + "const": "flag" + }, + { + "const": "token_file" + }, + { + "const": "env" + } + ], "minItems": 3, "maxItems": 3, "uniqueItems": true @@ -105,8 +116,21 @@ "config_fields": { "type": "array", "items": { - "type": "string" + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" }, + "prefixItems": [ + { + "const": "base_url" + }, + { + "const": "token" + }, + { + "const": "timeout_ms" + } + ], + "minItems": 3, "uniqueItems": true }, "error_type": { @@ -129,6 +153,27 @@ "items": { "type": "string" }, + "prefixItems": [ + { + "const": "--base-url" + }, + { + "const": "--token" + }, + { + "const": "--token-file" + }, + { + "const": "--timeout-ms" + }, + { + "const": "--output" + }, + { + "const": "--json" + } + ], + "minItems": 6, "uniqueItems": true }, "env": { @@ -144,6 +189,8 @@ "type": "string", "enum": ["json", "text"] }, + "minItems": 2, + "maxItems": 2, "uniqueItems": true }, "operation_names": { @@ -166,6 +213,33 @@ "uniqueItems": true } } + }, + "mcp": { + "type": "object", + "additionalProperties": false, + "required": ["path", "drift_check", "tool_names", "auth", "errors"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "drift_check": { + "type": "string", + "minLength": 1 + }, + "tool_names": { + "type": "string", + "enum": ["contract", "catalog", "documented_composites"] + }, + "auth": { + "type": "string", + "const": "shared" + }, + "errors": { + "type": "string", + "enum": ["structured_content", "transport_native"] + } + } } } } diff --git a/scripts/audit-client-surface.py b/scripts/audit-client-surface.py index f909f7d..f2b4529 100755 --- a/scripts/audit-client-surface.py +++ b/scripts/audit-client-surface.py @@ -20,11 +20,19 @@ "http_route_manifest", } AUTH_PRECEDENCE = ["flag", "token_file", "env"] -SDK_CONFIG_FIELDS = {"base_url", "token", "timeout_ms"} -CLI_FLAGS = {"--base-url", "--token", "--token-file", "--timeout-ms", "--output"} +SDK_CONFIG_FIELDS = ["base_url", "token", "timeout_ms"] +CLI_FLAGS = [ + "--base-url", + "--token", + "--token-file", + "--timeout-ms", + "--output", + "--json", +] ERROR_FIELDS = ["status", "code", "message"] OPTIONAL_ERROR_FIELDS = {"request_id", "details"} LOWER_CAMEL = re.compile(r"^[a-z][a-zA-Z0-9]*$") +SNAKE = re.compile(r"^[a-z][a-z0-9_]*$") ENV_TOKEN = re.compile(r"^[A-Z][A-Z0-9_]*_TOKEN$") ENV_API_KEY = re.compile(r"^[A-Z][A-Z0-9_]*_API_KEY$") @@ -40,6 +48,48 @@ def load_manifest(path: str | Path) -> dict[str, Any]: return json.load(handle) +def service_env_prefix(service: str) -> str: + prefix = re.sub(r"[^A-Za-z0-9]+", "_", service).strip("_").upper() + return prefix or "SERVICE" + + +def template_manifest(service: str) -> dict[str, Any]: + prefix = service_env_prefix(service) + return { + "surface_version": SURFACE_VERSION, + "service": service, + "contract": { + "type": "openapi", + "path": "sdks/openapi.json", + "drift_check": "replace with the repo-local contract drift check", + }, + "auth": { + "scheme": "bearer", + "header": "Authorization", + "value_format": "Bearer ", + "token_field": "token", + "env_token": f"{prefix}_TOKEN", + "compat_headers": [], + "compat_env": [], + "precedence": AUTH_PRECEDENCE, + }, + "sdk": { + "config_fields": SDK_CONFIG_FIELDS, + "error_type": "ApiError", + "operation_names": "contract", + }, + "cli": { + "global_flags": CLI_FLAGS, + "env": [f"{prefix}_BASE_URL", f"{prefix}_TOKEN", f"{prefix}_TIMEOUT_MS"], + "output_modes": ["json", "text"], + "operation_names": "contract", + }, + "errors": { + "fields": ["status", "code", "message", "request_id", "details"], + }, + } + + def require_object(parent: dict[str, Any], key: str, violations: list[str]) -> dict[str, Any]: value = parent.get(key) if not isinstance(value, dict): @@ -67,6 +117,7 @@ def audit_manifest(manifest: dict[str, Any]) -> AuditResult: ) if not isinstance(service, str) or not service.strip(): violations.append("service: expected non-empty string") + env_prefix = service_env_prefix(str(service)) contract = require_object(manifest, "contract", violations) contract_type = contract.get("type") @@ -90,6 +141,10 @@ def audit_manifest(manifest: dict[str, Any]) -> AuditResult: env_token = auth.get("env_token") if not isinstance(env_token, str) or not ENV_TOKEN.match(env_token): violations.append("auth.env_token: expected _TOKEN") + elif env_token != f"{env_prefix}_TOKEN": + violations.append( + f"auth.env_token: expected service-prefixed {env_prefix}_TOKEN" + ) precedence = require_list(auth, "precedence", violations) if precedence and precedence != AUTH_PRECEDENCE: @@ -105,30 +160,43 @@ def audit_manifest(manifest: dict[str, Any]) -> AuditResult: violations.append("auth.compat_env: expected _API_KEY aliases") sdk = require_object(manifest, "sdk", violations) - config_fields = set(require_list(sdk, "config_fields", violations)) - missing_sdk_fields = sorted(SDK_CONFIG_FIELDS - config_fields) - if missing_sdk_fields: - violations.append(f"sdk.config_fields: missing {missing_sdk_fields}") + config_fields = require_list(sdk, "config_fields", violations) + if config_fields[: len(SDK_CONFIG_FIELDS)] != SDK_CONFIG_FIELDS: + violations.append( + f"sdk.config_fields: must start with {SDK_CONFIG_FIELDS}" + ) if "api_key" in config_fields or "apiKey" in config_fields: violations.append("sdk.config_fields: api_key/apiKey may not be canonical") + for field in config_fields: + if not isinstance(field, str) or not SNAKE.match(field): + violations.append( + f"sdk.config_fields: {field!r} must be a snake_case client field" + ) if sdk.get("operation_names") != "contract": violations.append("sdk.operation_names: expected 'contract'") if not isinstance(sdk.get("error_type"), str) or not sdk.get("error_type", "").strip(): violations.append("sdk.error_type: expected non-empty string") cli = require_object(manifest, "cli", violations) - flags = set(require_list(cli, "global_flags", violations)) - missing_flags = sorted(CLI_FLAGS - flags) - if missing_flags: - violations.append(f"cli.global_flags: missing {missing_flags}") + flags = require_list(cli, "global_flags", violations) + if flags[: len(CLI_FLAGS)] != CLI_FLAGS: + violations.append(f"cli.global_flags: must start with {CLI_FLAGS}") if "--api-key" in flags or "--api-key-file" in flags: violations.append("cli.global_flags: api-key flags may only be aliases") env = set(require_list(cli, "env", violations)) if auth_scheme == "bearer" and auth.get("env_token") not in env: violations.append("cli.env: missing canonical token env var") + expected_env = { + f"{env_prefix}_BASE_URL", + f"{env_prefix}_TOKEN", + f"{env_prefix}_TIMEOUT_MS", + } + missing_env = sorted(expected_env - env) + if missing_env: + violations.append(f"cli.env: missing service-prefixed env vars {missing_env}") output_modes = set(require_list(cli, "output_modes", violations)) - if "json" not in output_modes: - violations.append("cli.output_modes: missing stable json output") + if output_modes != {"json", "text"}: + violations.append("cli.output_modes: expected exactly ['json', 'text']") if cli.get("operation_names") not in {"contract", "documented_aliases"}: violations.append( "cli.operation_names: expected 'contract' or 'documented_aliases'" @@ -155,13 +223,44 @@ def audit_manifest(manifest: dict[str, Any]) -> AuditResult: f"operations: {operation!r} is not a lower-camel operation name" ) + mcp = manifest.get("mcp") + if mcp is not None: + if not isinstance(mcp, dict): + violations.append("mcp: expected object when present") + else: + for key in ("path", "drift_check"): + if not isinstance(mcp.get(key), str) or not mcp.get(key).strip(): + violations.append(f"mcp.{key}: expected non-empty string") + if mcp.get("tool_names") not in { + "contract", + "catalog", + "documented_composites", + }: + violations.append( + "mcp.tool_names: expected 'contract', 'catalog', or " + "'documented_composites'" + ) + if mcp.get("auth") != "shared": + violations.append("mcp.auth: expected 'shared'") + if mcp.get("errors") not in {"structured_content", "transport_native"}: + violations.append( + "mcp.errors: expected 'structured_content' or 'transport_native'" + ) + return AuditResult(service=str(service), violations=violations) def main(argv: list[str] | None = None) -> int: args = sys.argv[1:] if argv is None else argv + if len(args) == 2 and args[0] == "--print-template": + print(json.dumps(template_manifest(args[1]), indent=2)) + return 0 if len(args) != 1: - print("usage: audit-client-surface.py ", file=sys.stderr) + print( + "usage: audit-client-surface.py \n" + " audit-client-surface.py --print-template ", + file=sys.stderr, + ) return 2 result = audit_manifest(load_manifest(args[0])) From c3c68c6048cb5fc20e66e1cdd2bac55b3d561381 Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Wed, 8 Jul 2026 17:19:26 -0700 Subject: [PATCH 08/13] Add cross-repo API contract checker --- AGENTS.md | 4 + README.md | 14 + docs/api-sdk-mcp-shape.md | 18 +- docs/ecosystem-pipeline.md | 8 +- ecosystem.toml | 41 ++ scripts/check-api-contracts.py | 733 +++++++++++++++++++++++++++++++++ scripts/ecosystem-pipeline.sh | 1 + scripts/ecosystem-smoke.sh | 1 + 8 files changed, 816 insertions(+), 4 deletions(-) create mode 100755 scripts/check-api-contracts.py diff --git a/AGENTS.md b/AGENTS.md index ce483e3..f4d6ce3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,5 +139,9 @@ Delete each item only after it is complete and verified. filesystem rename. - [ ] Enforce `docs/architecture-uniformity.md` across every repo. Remove repo-local exceptions unless they have benchmark, security, or product evidence. +- [ ] Make `scripts/check-api-contracts.py` pass for the full configured API + registry. `data-engine` is the reference; Palette, cradle, and tempo remain + migration targets until their OpenAPI/MCP surfaces use the shared + project-scoped operation, error, pagination, idempotency, and conflict rules. - [ ] Promote `scripts/ecosystem-pipeline.sh gate` to the required CI path once `report` mode has no failures. diff --git a/README.md b/README.md index f2990eb..e5306af 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ its own PR. profile for public client-facing surfaces. - `scripts/audit-openapi-shape.py`: portable OpenAPI profile audit for REST services. +- `scripts/check-api-contracts.py`: cross-repo OpenAPI/MCP style and conflict + audit that loads configured service contracts from `ecosystem.toml`. - `docs/client-surface-manifest.schema.json`: common manifest format for SDK, CLI, auth, output, and error surfaces. - `docs/client-surface-manifest.example.json`: copyable known-good profile for @@ -81,6 +83,9 @@ scripts/ecosystem-pipeline.sh gate python3 scripts/check-ecosystem-binaries.py python3 scripts/check-ecosystem-uniformity.py python3 scripts/check-local-checkouts.py --root /Users/jadenfix/Desktop/ecosystem +python3 scripts/check-api-contracts.py --list +python3 scripts/check-api-contracts.py --service data-engine +python3 scripts/check-api-contracts.py --shared-gateway python3 scripts/audit-client-surface.py --print-template cradle python3 scripts/audit-client-surface.py path/to/client-surface.json python3 scripts/run-ecosystem-verification.py --list @@ -92,3 +97,12 @@ scripts/ecosystem-smoke.sh `check-ecosystem-uniformity.py` is expected to fail until the repo-local migration tasks are completed. That failure is useful: it is the remaining work list made executable. + +`check-api-contracts.py` has the same migration-gate shape. The data-engine +contract is the strict reference. Palette and cradle are configured as +`aip-target` services so the full check reports the remaining API migration +work until those repos adopt project-scoped paths, dotted `projects.*` +operationIds, shared error envelopes, cursor pagination, `Operation`, +`Idempotency-Key`, and `If-Match`/`update_mask`. Use `--service data-engine` for +the current reference-only proof and `--shared-gateway` to prove that an +unprefixed combined gateway has no method/path collisions. diff --git a/docs/api-sdk-mcp-shape.md b/docs/api-sdk-mcp-shape.md index 2f2525c..3b18e31 100644 --- a/docs/api-sdk-mcp-shape.md +++ b/docs/api-sdk-mcp-shape.md @@ -124,8 +124,10 @@ Services with HTTP control planes should use this shape: - OpenAPI 3.1 as the committed machine contract. - `/v1` for stable REST routes; health/readiness routes may sit outside `/v1`. -- Unique lower-camel `operationId` values, e.g. `createJob`, `getTrace`, - `validateBrowserAdapter`. +- Unique dotted `operationId` values using + `projects.[.].`, e.g. + `projects.jobs.create`, `projects.traces.get`, and + `projects.browserAdapters.validate`. - Exactly one resource tag per operation. - Named request and response schemas; no anonymous success objects. - A shared error envelope for every documented 4xx/5xx response. @@ -139,6 +141,18 @@ Services with HTTP control planes should use this shape: contract. - REST repos should run the shared OpenAPI audit with `--enforce-auth` once their public spec declares Bearer security and public endpoint exemptions. +- Ecosystem coordination should run `scripts/check-api-contracts.py` from this + repo. It loads the configured OpenAPI/MCP contracts, fails duplicate + method/path routes inside a service, duplicate operationIds inside a service, + duplicate combined MCP tool names, missing cursor pagination fields, missing + `Idempotency-Key`, missing `If-Match`/`update_mask`, async operations that do + not return `Operation`, non-envelope HTTP errors, non-`/v1` REST paths, and + unscoped global list endpoints. +- Separate service hosts may reuse operationIds when their clients remain + separate. A combined gateway or combined MCP catalog must either prefix tools + by service, such as `dataEngine.projects.jobs.create`, or keep operationIds + globally disjoint. Run `scripts/check-api-contracts.py --shared-gateway` before + claiming one host can mount all services without route conflicts. ## SDK Profile diff --git a/docs/ecosystem-pipeline.md b/docs/ecosystem-pipeline.md index 639c2e1..e7c4770 100644 --- a/docs/ecosystem-pipeline.md +++ b/docs/ecosystem-pipeline.md @@ -27,8 +27,8 @@ engineering across the ecosystem, not repo-specific preference drift. Run the pipeline in four stages: -1. `meta`: checks the root manifest, declared binaries, architecture policy, and - repo/package metadata. +1. `meta`: checks the root manifest, declared binaries, architecture policy, + API/MCP contract style and conflicts, and repo/package metadata. 2. `repo`: runs each repo's own format, lint, test, package, and contract checks from `ecosystem.toml`. 3. `e2e`: runs capability scenarios across binaries and contract fixtures. @@ -60,6 +60,10 @@ duplicates. ## Optimal Defaults - Keep meta checks fast and deterministic. +- Treat `scripts/check-api-contracts.py --service data-engine` as the reference + API proof while migrations are active, and the full + `scripts/check-api-contracts.py` report as the cross-repo worklist for Palette, + cradle, and tempo public contract convergence. - Run heavyweight repo tests only after meta checks pass. - Run E2E tests from built binaries or release artifacts, not copied source. - Prefer contract fixtures for cross-repo assertions unless a live binary path is diff --git a/ecosystem.toml b/ecosystem.toml index 3dd8df0..7b25f69 100644 --- a/ecosystem.toml +++ b/ecosystem.toml @@ -90,11 +90,52 @@ required_package_fields = ["name", "version", "license", "repository"] required_rust_workspace_fields = ["version", "edition", "rust-version", "license", "repository"] generated_clients_must_name_source_contract = true +[api_contracts.data-engine] +product = "data-engine" +repo_path = "data-engine" +openapi = "api/openapi.yaml" +style = "aip" +required = false +gateway_mount = "/data-engine" +mcp_namespace = "dataEngine" +mcp_tools_from_openapi = true + +[api_contracts.palette] +product = "Palette" +repo_key = "palette" +openapi = "sdks/openapi/beater-api.json" +style = "aip-target" +required = false +gateway_mount = "/palette" +mcp_namespace = "palette" +mcp_tools_from_openapi = true + +[api_contracts.cradle] +product = "cradle" +repo_key = "cradle" +openapi = "sdks/openapi.json" +mcp_catalog = "crates/beatbox-server/fixtures/mcp-tools.catalog.json" +style = "aip-target" +required = false +gateway_mount = "/cradle" +mcp_namespace = "cradle" +mcp_tools_from_openapi = true + +[api_contracts.tempo] +product = "tempo" +repo_key = "tempo" +mcp_catalog = "crates/tempo-mcp/fixtures/mcp-tools.catalog.json" +style = "mcp-native" +required = false +gateway_mount = "/tempo" +mcp_namespace = "tempo" + [pipeline.stages.meta] description = "Fast root checks over ecosystem.toml, declared binaries, architecture shape, and manifest uniformity." commands = [ "python3 scripts/check-ecosystem-binaries.py", "python3 scripts/check-architecture-uniformity.py", + "python3 scripts/check-api-contracts.py", "python3 scripts/check-ecosystem-uniformity.py", ] diff --git a/scripts/check-api-contracts.py b/scripts/check-api-contracts.py new file mode 100755 index 0000000..31b813f --- /dev/null +++ b/scripts/check-api-contracts.py @@ -0,0 +1,733 @@ +#!/usr/bin/env python3 +"""Check cross-repo API style and endpoint/tool conflicts. + +The shared style target is data-engine's AIP-like API profile: +project-scoped /v1 resources, dotted projects.* operationIds, shared HTTP error +envelopes, cursor pagination, Operation for async work, Idempotency-Key for +mutations, and If-Match/update_mask for patches. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass, field +import json +import os +from pathlib import Path +import re +import sys +from typing import Any + +from ecosystem_toml import load as load_toml + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = ROOT / "ecosystem.toml" +HTTP_METHODS = {"get", "put", "post", "delete", "patch", "options", "head", "trace"} +PROJECTS_OPERATION_ID = re.compile(r"^projects(?:\.[a-z][a-zA-Z0-9]*){2,}$") +PUBLIC_OPERATION_IDS = {"health", "getHealth", "openapi", "getOpenapi", "postMcp"} +TRANSPORT_NATIVE_PATHS = {"/mcp", "/openapi.json", "/health", "/healthz", "/readyz"} +ERROR_ENVELOPE_SCHEMAS = {"Error", "ErrorResponse", "ApiError", "ApiErrorBody"} +ERROR_BODY_SCHEMAS = {"ErrorBody", "ApiErrorBody"} +ASYNC_VERBS = { + "ingest", + "run", + "sync", + "emit", + "emitRlvr", + "emitEval", + "emitPreference", + "emitSft", +} +MUTATING_METHODS = {"post", "put", "patch", "delete"} + + +@dataclass(frozen=True) +class Contract: + service: str + repo_key: str | None = None + repo_path: str | None = None + openapi: str | None = None + mcp_catalog: str | None = None + style: str = "aip-target" + required: bool = False + gateway_mount: str = "" + mcp_namespace: str = "" + mcp_tools_from_openapi: bool = False + + +@dataclass(frozen=True) +class Operation: + service: str + method: str + path: str + operation_id: str + op: dict[str, Any] + path_item: dict[str, Any] + spec: dict[str, Any] + + @property + def where(self) -> str: + return f"{self.service}: {self.method.upper()} {self.path}" + + +@dataclass +class AuditState: + violations: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + operations: list[Operation] = field(default_factory=list) + mcp_tools: list[tuple[str, str, str]] = field(default_factory=list) + + +def load_yaml(path: Path) -> Any: + try: + import yaml # type: ignore + except ModuleNotFoundError as exc: + raise RuntimeError( + f"{path}: PyYAML is required to read YAML OpenAPI specs" + ) from exc + with path.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def load_data(path: Path) -> Any: + if path.suffix.lower() in {".yaml", ".yml"}: + return load_yaml(path) + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def ref_name(ref: str) -> str: + return ref.rsplit("/", 1)[-1] if ref else "" + + +def resolve_ref(spec: dict[str, Any], value: Any) -> Any: + if not isinstance(value, dict) or "$ref" not in value: + return value + ref = value["$ref"] + if not isinstance(ref, str) or not ref.startswith("#/"): + return value + current: Any = spec + for part in ref.removeprefix("#/").split("/"): + if isinstance(current, dict): + current = current.get(part) + else: + return value + return current if current is not None else value + + +def response_schema(spec: dict[str, Any], response: Any) -> dict[str, Any]: + response = resolve_ref(spec, response) + if not isinstance(response, dict): + return {} + content = response.get("content", {}) + if not isinstance(content, dict): + return {} + media = content.get("application/json") or content.get("application/problem+json") + if not isinstance(media, dict): + return {} + schema = media.get("schema", {}) + return schema if isinstance(schema, dict) else {} + + +def request_schema(spec: dict[str, Any], op: dict[str, Any]) -> dict[str, Any]: + body = resolve_ref(spec, op.get("requestBody", {})) + if not isinstance(body, dict): + return {} + content = body.get("content", {}) + if not isinstance(content, dict): + return {} + media = content.get("application/json") + if not isinstance(media, dict): + return {} + schema = media.get("schema", {}) + return schema if isinstance(schema, dict) else {} + + +def schema_has_property( + spec: dict[str, Any], + schema: dict[str, Any], + names: set[str], + seen: set[str] | None = None, +) -> bool: + seen = seen or set() + if "$ref" in schema: + ref = str(schema["$ref"]) + if ref in seen: + return False + seen.add(ref) + resolved = resolve_ref(spec, schema) + return isinstance(resolved, dict) and schema_has_property(spec, resolved, names, seen) + + props = schema.get("properties", {}) + if isinstance(props, dict) and names.intersection(props): + return True + + for key in ("allOf", "oneOf", "anyOf"): + branches = schema.get(key, []) + if isinstance(branches, list): + for branch in branches: + if isinstance(branch, dict) and schema_has_property(spec, branch, names, seen): + return True + return False + + +def schema_ref_schema_name(spec: dict[str, Any], schema: dict[str, Any]) -> str: + if "$ref" in schema: + return ref_name(str(schema["$ref"])) + return "" + + +def schema_is_ref_or_named(spec: dict[str, Any], schema: dict[str, Any], names: set[str]) -> bool: + direct = schema_ref_schema_name(spec, schema) + if direct in names: + return True + resolved = resolve_ref(spec, schema) + if not isinstance(resolved, dict): + return False + for name in names: + component = spec.get("components", {}).get("schemas", {}).get(name) + if component is resolved: + return True + return False + + +def schema_is_operation(spec: dict[str, Any], schema: dict[str, Any]) -> bool: + if schema_is_ref_or_named(spec, schema, {"Operation"}): + return True + resolved = resolve_ref(spec, schema) + if not isinstance(resolved, dict): + return False + required = set(resolved.get("required", [])) + props = resolved.get("properties", {}) + return isinstance(props, dict) and {"name", "done"}.issubset(required | set(props)) + + +def schema_is_error_envelope(spec: dict[str, Any], schema: dict[str, Any]) -> bool: + if schema_is_ref_or_named(spec, schema, ERROR_ENVELOPE_SCHEMAS): + return True + resolved = resolve_ref(spec, schema) + if not isinstance(resolved, dict): + return False + props = resolved.get("properties", {}) + return isinstance(props, dict) and "error" in props + + +def operation_params(operation: Operation) -> list[dict[str, Any]]: + params: list[dict[str, Any]] = [] + for source in (operation.path_item.get("parameters", []), operation.op.get("parameters", [])): + if not isinstance(source, list): + continue + for raw in source: + param = resolve_ref(operation.spec, raw) + if isinstance(param, dict): + params.append(param) + return params + + +def params_by_location(operation: Operation, location: str) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for param in operation_params(operation): + if param.get("in") == location and isinstance(param.get("name"), str): + out[param["name"]] = param + return out + + +def normalized_path(path: str) -> str: + return re.sub(r"\{[^}]+\}", "{}", path.rstrip("/") or "/") + + +def operation_id_verb(operation_id: str) -> str: + return operation_id.rsplit(".", 1)[-1] if operation_id else "" + + +def is_transport_native_path(path: str) -> bool: + normalized = path.rstrip("/") or "/" + return normalized in TRANSPORT_NATIVE_PATHS + + +def is_list_operation(operation: Operation) -> bool: + oid = operation.operation_id + return oid.endswith(".list") or oid.startswith("list") + + +def is_async_operation(operation: Operation) -> bool: + verb = operation_id_verb(operation.operation_id) + if verb in ASYNC_VERBS: + return True + if any(code == "202" for code in operation.op.get("responses", {})): + return True + text = " ".join( + str(operation.op.get(key, "")) for key in ("summary", "description") + ).lower() + return "long-running" in text or "async" in text + + +def is_mutating_operation(operation: Operation) -> bool: + if operation.method not in MUTATING_METHODS: + return False + if is_transport_native_path(operation.path): + return False + if operation.operation_id in PUBLIC_OPERATION_IDS: + return False + return True + + +def list_response_schema(operation: Operation) -> dict[str, Any]: + responses = operation.op.get("responses", {}) + if not isinstance(responses, dict): + return {} + for code in ("200", "201"): + if code in responses: + return response_schema(operation.spec, responses[code]) + return {} + + +def response_schemas(operation: Operation) -> list[tuple[str, dict[str, Any]]]: + responses = operation.op.get("responses", {}) + if not isinstance(responses, dict): + return [] + out: list[tuple[str, dict[str, Any]]] = [] + for code, response in responses.items(): + if isinstance(code, str): + out.append((code, response_schema(operation.spec, response))) + return out + + +def audit_openapi(contract: Contract, path: Path, state: AuditState) -> None: + spec = load_data(path) + if not isinstance(spec, dict): + state.violations.append(f"{contract.service}: {path} did not parse to an object") + return + + paths = spec.get("paths", {}) + if not isinstance(paths, dict): + state.violations.append(f"{contract.service}: OpenAPI spec has no paths object") + return + + seen_routes: dict[str, list[str]] = {} + seen_operation_ids: dict[str, list[str]] = {} + operations: list[Operation] = [] + + for raw_path, path_item in paths.items(): + if not isinstance(raw_path, str) or not isinstance(path_item, dict): + continue + for method, op in path_item.items(): + if method not in HTTP_METHODS or not isinstance(op, dict): + continue + operation_id = op.get("operationId", "") + if not isinstance(operation_id, str): + operation_id = "" + operation = Operation( + service=contract.service, + method=method, + path=raw_path, + operation_id=operation_id, + op=op, + path_item=path_item, + spec=spec, + ) + operations.append(operation) + state.operations.append(operation) + route_key = f"{method.upper()} {normalized_path(raw_path)}" + seen_routes.setdefault(route_key, []).append(f"{method.upper()} {raw_path}") + if operation_id: + seen_operation_ids.setdefault(operation_id, []).append( + f"{method.upper()} {raw_path}" + ) + elif not is_transport_native_path(raw_path): + state.violations.append(f"{operation.where}: missing operationId") + + for route_key, wheres in seen_routes.items(): + if len(wheres) > 1: + state.violations.append( + f"{contract.service}: duplicate or shadowed route {route_key}: {wheres}" + ) + + for operation_id, wheres in seen_operation_ids.items(): + if len(wheres) > 1: + state.violations.append( + f"{contract.service}: duplicate operationId {operation_id!r}: {wheres}" + ) + + enforce_aip = contract.style in {"aip", "aip-target"} + for operation in operations: + if enforce_aip: + audit_aip_operation(operation, state) + + if enforce_aip: + audit_operation_schema(contract, spec, state) + + if contract.mcp_tools_from_openapi: + for operation in operations: + if operation.operation_id and not is_transport_native_path(operation.path): + state.mcp_tools.append((contract.service, operation.operation_id, str(path))) + + print( + f"audited {contract.service} OpenAPI: {len(operations)} operations " + f"from {path}" + ) + + +def audit_aip_operation(operation: Operation, state: AuditState) -> None: + if is_transport_native_path(operation.path): + return + + if not operation.path.startswith("/v1/") and operation.path != "/v1": + state.violations.append(f"{operation.where}: path is outside /v1") + + oid = operation.operation_id + if not PROJECTS_OPERATION_ID.match(oid): + state.violations.append( + f"{operation.where}: operationId {oid!r} is not projects.[.]." + ) + + if is_list_operation(operation): + query_params = params_by_location(operation, "query") + for name in ("page_size", "page_token"): + if name not in query_params: + state.violations.append(f"{operation.where}: list endpoint missing query {name}") + schema = list_response_schema(operation) + for name in ("next_page_token", "total_size"): + if not schema_has_property(operation.spec, schema, {name}): + state.violations.append( + f"{operation.where}: list response missing {name}" + ) + if "{parent" not in operation.path and "projects/" not in operation.path: + state.violations.append( + f"{operation.where}: list endpoint is not scoped under projects/*" + ) + + if is_mutating_operation(operation): + headers = {name.lower(): param for name, param in params_by_location(operation, "header").items()} + idem = headers.get("idempotency-key") + if idem is None: + state.violations.append(f"{operation.where}: mutating endpoint missing Idempotency-Key") + elif idem.get("required") is not True: + state.violations.append(f"{operation.where}: Idempotency-Key is not required") + + if operation.method == "patch": + headers = {name.lower(): param for name, param in params_by_location(operation, "header").items()} + if_match = headers.get("if-match") + if if_match is None: + state.violations.append(f"{operation.where}: PATCH missing If-Match") + elif if_match.get("required") is not True: + state.violations.append(f"{operation.where}: If-Match is not required") + schema = request_schema(operation.spec, operation.op) + if not schema_has_property(operation.spec, schema, {"update_mask", "updateMask"}): + state.violations.append(f"{operation.where}: PATCH body missing update_mask") + + if is_async_operation(operation): + success_schemas = [ + schema + for code, schema in response_schemas(operation) + if code.startswith("2") + ] + if not any(schema_is_operation(operation.spec, schema) for schema in success_schemas): + state.violations.append(f"{operation.where}: async endpoint does not return Operation") + + if not is_transport_native_path(operation.path): + error_codes = [ + code for code, _schema in response_schemas(operation) if code.startswith(("4", "5")) + ] + if not error_codes: + state.violations.append(f"{operation.where}: no documented 4xx/5xx response") + for code, schema in response_schemas(operation): + if not code.startswith(("4", "5")): + continue + if not schema_is_error_envelope(operation.spec, schema): + state.violations.append( + f"{operation.where}: error {code} does not use shared error envelope" + ) + + +def audit_operation_schema(contract: Contract, spec: dict[str, Any], state: AuditState) -> None: + schemas = spec.get("components", {}).get("schemas", {}) + if not isinstance(schemas, dict): + state.violations.append(f"{contract.service}: components.schemas is missing") + return + operation = schemas.get("Operation") + if not isinstance(operation, dict): + state.violations.append(f"{contract.service}: missing Operation schema") + return + props = operation.get("properties", {}) + if not isinstance(props, dict): + state.violations.append(f"{contract.service}: Operation.properties is missing") + return + error = props.get("error") + if not isinstance(error, dict): + state.violations.append(f"{contract.service}: Operation.error is missing") + elif not schema_is_ref_or_named(spec, error, ERROR_BODY_SCHEMAS): + state.violations.append( + f"{contract.service}: Operation.error must reference inner error body, not HTTP envelope" + ) + + +def load_mcp_catalog(contract: Contract, path: Path, state: AuditState) -> None: + catalog = load_data(path) + if isinstance(catalog, list): + tools = catalog + elif isinstance(catalog, dict) and isinstance(catalog.get("tools"), list): + tools = catalog["tools"] + else: + state.violations.append(f"{contract.service}: {path} is not a tools list/catalog") + return + local: dict[str, int] = {} + for tool in tools: + if not isinstance(tool, dict) or not isinstance(tool.get("name"), str): + state.violations.append(f"{contract.service}: {path} contains tool without name") + continue + name = tool["name"] + local[name] = local.get(name, 0) + 1 + state.mcp_tools.append((contract.service, name, str(path))) + for name, count in local.items(): + if count > 1: + state.violations.append( + f"{contract.service}: MCP catalog has duplicate tool {name!r}" + ) + print(f"audited {contract.service} MCP catalog: {len(tools)} tools from {path}") + + +def audit_global_conflicts( + contracts: dict[str, Contract], + state: AuditState, + *, + shared_gateway: bool, +) -> None: + gateway_routes: dict[str, list[str]] = {} + for op in state.operations: + contract = contracts[op.service] + mount = "" if shared_gateway else contract.gateway_mount.strip("/") + path = op.path if not mount else f"/{mount}{op.path}" + key = f"{op.method.upper()} {normalized_path(path)}" + gateway_routes.setdefault(key, []).append(f"{op.service} {op.method.upper()} {op.path}") + for route, owners in gateway_routes.items(): + if len(owners) > 1: + state.violations.append( + f"gateway: duplicate route {route} across services: {owners}" + ) + + global_tools: dict[str, list[str]] = {} + for service, name, source in state.mcp_tools: + namespace = contracts.get(service, Contract(service)).mcp_namespace + global_name = f"{namespace}.{name}" if namespace else name + global_tools.setdefault(global_name, []).append(f"{service} ({source})") + for name, owners in global_tools.items(): + if len(owners) > 1: + state.violations.append( + f"mcp: duplicate combined tool name {name!r}: {owners}" + ) + + +def configured_contracts(manifest: dict[str, Any]) -> dict[str, Contract]: + raw = manifest.get("api_contracts", {}) + if not isinstance(raw, dict): + return {} + contracts: dict[str, Contract] = {} + for service, spec in raw.items(): + if not isinstance(spec, dict): + continue + contracts[service] = Contract( + service=service, + repo_key=spec.get("repo_key"), + repo_path=spec.get("repo_path"), + openapi=spec.get("openapi"), + mcp_catalog=spec.get("mcp_catalog"), + style=spec.get("style", "aip-target"), + required=bool(spec.get("required", False)), + gateway_mount=spec.get("gateway_mount", ""), + mcp_namespace=spec.get("mcp_namespace", ""), + mcp_tools_from_openapi=bool(spec.get("mcp_tools_from_openapi", False)), + ) + return contracts + + +def checkout_roots(args: argparse.Namespace) -> list[Path]: + roots: list[Path] = [] + for value in args.checkout_root or []: + roots.append(value.resolve()) + env_root = os.environ.get("TEMPERA_CHECKOUT_ROOT") + if env_root: + roots.append(Path(env_root).resolve()) + roots.extend( + [ + ROOT.parent.resolve(), + Path.home().resolve(), + (Path.home() / "Desktop" / "ecosystem").resolve(), + ] + ) + deduped: list[Path] = [] + seen: set[Path] = set() + for root in roots: + if root not in seen: + deduped.append(root) + seen.add(root) + return deduped + + +def resolve_repo_root(contract: Contract, manifest: dict[str, Any], roots: list[Path]) -> Path | None: + names: list[str] = [] + if contract.repo_path: + names.append(contract.repo_path) + if contract.repo_key: + repo_spec = manifest.get("repos", {}).get(contract.repo_key, {}) + if isinstance(repo_spec, dict) and isinstance(repo_spec.get("path"), str): + names.append(repo_spec["path"]) + aliases = manifest.get("checkout_aliases", {}).get(contract.repo_key, []) + if isinstance(aliases, list): + names.extend(str(alias) for alias in aliases) + for root in roots: + for name in names: + candidate = Path(name) + if candidate.is_absolute() and candidate.is_dir(): + return candidate.resolve() + path = root / name + if path.is_dir(): + return path.resolve() + return None + + +def resolve_contract_path( + contract: Contract, + manifest: dict[str, Any], + roots: list[Path], + rel_path: str | None, +) -> Path | None: + if not rel_path: + return None + path = Path(rel_path) + if path.is_absolute(): + return path if path.exists() else None + repo_root = resolve_repo_root(contract, manifest, roots) + if repo_root: + candidate = repo_root / rel_path + if candidate.exists(): + return candidate + for root in roots: + candidate = root / rel_path + if candidate.exists(): + return candidate + return None + + +def parse_named_path(value: str) -> tuple[str, str]: + if ":" not in value: + raise argparse.ArgumentTypeError("expected service:path") + service, path = value.split(":", 1) + if not service or not path: + raise argparse.ArgumentTypeError("expected service:path") + return service, path + + +def add_overrides(contracts: dict[str, Contract], args: argparse.Namespace) -> None: + for service, path in args.openapi or []: + base = contracts.get(service, Contract(service=service, style="aip")) + contracts[service] = Contract( + service=base.service, + repo_key=base.repo_key, + repo_path=base.repo_path, + openapi=path, + mcp_catalog=base.mcp_catalog, + style=base.style, + required=True, + gateway_mount=base.gateway_mount, + mcp_namespace=base.mcp_namespace, + mcp_tools_from_openapi=base.mcp_tools_from_openapi, + ) + for service, path in args.mcp_catalog or []: + base = contracts.get(service, Contract(service=service, style="mcp-native")) + contracts[service] = Contract( + service=base.service, + repo_key=base.repo_key, + repo_path=base.repo_path, + openapi=base.openapi, + mcp_catalog=path, + style=base.style, + required=True, + gateway_mount=base.gateway_mount, + mcp_namespace=base.mcp_namespace, + mcp_tools_from_openapi=base.mcp_tools_from_openapi, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--checkout-root", action="append", type=Path) + parser.add_argument("--service", action="append", help="Configured service to audit") + parser.add_argument("--openapi", action="append", type=parse_named_path, help="Override/add service:path") + parser.add_argument("--mcp-catalog", action="append", type=parse_named_path, help="Override/add service:path") + parser.add_argument("--strict-missing", action="store_true", help="Missing configured files are failures") + parser.add_argument("--shared-gateway", action="store_true", help="Check raw paths as if all services share one /v1 host") + parser.add_argument("--list", action="store_true", help="List configured contracts and resolved files") + args = parser.parse_args(argv) + + manifest = load_toml(MANIFEST) + contracts = configured_contracts(manifest) + add_overrides(contracts, args) + + if args.service: + selected = set(args.service) + unknown = selected - set(contracts) + if unknown: + print(f"unknown API contract service(s): {', '.join(sorted(unknown))}", file=sys.stderr) + return 2 + contracts = {name: contract for name, contract in contracts.items() if name in selected} + + if not contracts: + print("no API contracts configured", file=sys.stderr) + return 2 + + roots = checkout_roots(args) + state = AuditState() + + if args.list: + for contract in contracts.values(): + openapi = resolve_contract_path(contract, manifest, roots, contract.openapi) + catalog = resolve_contract_path(contract, manifest, roots, contract.mcp_catalog) + print( + f"{contract.service}: openapi={openapi or 'missing'} " + f"mcp_catalog={catalog or 'missing'} style={contract.style}" + ) + return 0 + + for contract in contracts.values(): + openapi = resolve_contract_path(contract, manifest, roots, contract.openapi) + catalog = resolve_contract_path(contract, manifest, roots, contract.mcp_catalog) + + if contract.openapi and openapi is None: + message = f"{contract.service}: missing OpenAPI file {contract.openapi!r}" + if args.strict_missing or contract.required: + state.violations.append(message) + else: + state.warnings.append(message) + elif openapi is not None: + audit_openapi(contract, openapi, state) + + if contract.mcp_catalog and catalog is None: + message = f"{contract.service}: missing MCP catalog {contract.mcp_catalog!r}" + if args.strict_missing or contract.required: + state.violations.append(message) + else: + state.warnings.append(message) + elif catalog is not None: + load_mcp_catalog(contract, catalog, state) + + audit_global_conflicts(contracts, state, shared_gateway=args.shared_gateway) + + if state.warnings: + print(f"\n{len(state.warnings)} API CONTRACT WARNINGS:") + for warning in state.warnings: + print(f" - {warning}") + + if state.violations: + print(f"\n{len(state.violations)} API CONTRACT VIOLATIONS:") + for violation in state.violations: + print(f" - {violation}") + return 1 + + print("API contract style/conflict check passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ecosystem-pipeline.sh b/scripts/ecosystem-pipeline.sh index 25f25c5..d53af82 100755 --- a/scripts/ecosystem-pipeline.sh +++ b/scripts/ecosystem-pipeline.sh @@ -30,6 +30,7 @@ run_step() { run_meta() { run_step meta-binaries python3 "$ROOT/scripts/check-ecosystem-binaries.py" run_step meta-architecture python3 "$ROOT/scripts/check-architecture-uniformity.py" + run_step meta-api-contracts python3 "$ROOT/scripts/check-api-contracts.py" run_step meta-uniformity python3 "$ROOT/scripts/check-ecosystem-uniformity.py" } diff --git a/scripts/ecosystem-smoke.sh b/scripts/ecosystem-smoke.sh index 2dc7c1c..5b3c982 100755 --- a/scripts/ecosystem-smoke.sh +++ b/scripts/ecosystem-smoke.sh @@ -9,4 +9,5 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" python3 "$ROOT/scripts/check-ecosystem-binaries.py" python3 "$ROOT/scripts/check-ecosystem-uniformity.py" python3 "$ROOT/scripts/check-architecture-uniformity.py" +python3 "$ROOT/scripts/check-api-contracts.py" --service data-engine python3 "$ROOT/scripts/run-ecosystem-e2e.py" --list From fe83843f63bdd3ef546e04def888d58cffb1b62c Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Wed, 8 Jul 2026 17:35:30 -0700 Subject: [PATCH 09/13] Refine API contract checker exemptions --- README.md | 21 +++++++++++++++------ docs/api-sdk-mcp-shape.md | 7 ++++--- scripts/check-api-contracts.py | 17 ++++++++++++++++- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e5306af..5a64d08 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,18 @@ list made executable. `check-api-contracts.py` has the same migration-gate shape. The data-engine contract is the strict reference. Palette and cradle are configured as -`aip-target` services so the full check reports the remaining API migration -work until those repos adopt project-scoped paths, dotted `projects.*` -operationIds, shared error envelopes, cursor pagination, `Operation`, -`Idempotency-Key`, and `If-Match`/`update_mask`. Use `--service data-engine` for -the current reference-only proof and `--shared-gateway` to prove that an -unprefixed combined gateway has no method/path collisions. +`aip-target` services so the full check reports the remaining API migration work +until those repos adopt project-scoped paths, dotted `projects.*` operationIds, +shared error envelopes, cursor pagination, `Operation`, `Idempotency-Key`, and +`If-Match`/`update_mask`. Use `--service data-engine` for the current +reference-only proof and `--shared-gateway` to prove that an unprefixed combined +gateway has no method/path collisions. + +Cradle's API-alignment branch can be checked before it merges by overriding the +configured main checkout: + +```sh +python3 scripts/check-api-contracts.py --service cradle \ + --openapi cradle:/path/to/cradle/sdks/openapi.json \ + --mcp-catalog cradle:/path/to/cradle/crates/beatbox-server/fixtures/mcp-tools.catalog.json +``` diff --git a/docs/api-sdk-mcp-shape.md b/docs/api-sdk-mcp-shape.md index 3b18e31..2e3917f 100644 --- a/docs/api-sdk-mcp-shape.md +++ b/docs/api-sdk-mcp-shape.md @@ -123,7 +123,8 @@ Minimal Bearer-auth example: Services with HTTP control planes should use this shape: - OpenAPI 3.1 as the committed machine contract. -- `/v1` for stable REST routes; health/readiness routes may sit outside `/v1`. +- `/v1` for stable REST routes; health/readiness routes may sit outside `/v1` + or use versioned transport-native paths such as `/v1/health`. - Unique dotted `operationId` values using `projects.[.].`, e.g. `projects.jobs.create`, `projects.traces.get`, and @@ -135,8 +136,8 @@ Services with HTTP control planes should use this shape: in that protocol's shape instead of the REST error envelope; do not force a REST `ErrorResponse` onto `/mcp` if runtime returns a JSON-RPC envelope or an empty origin/CORS denial. -- Cursor pagination for list operations: `limit`, optional `cursor`, and - response `next_cursor`/`nextCursor` according to that repo's wire casing. +- Cursor pagination for list operations: `page_size`, optional `page_token`, + and response `next_page_token` plus `total_size`. - One drift gate that proves the served/generated contract equals the committed contract. - REST repos should run the shared OpenAPI audit with `--enforce-auth` once diff --git a/scripts/check-api-contracts.py b/scripts/check-api-contracts.py index 31b813f..e62c28c 100755 --- a/scripts/check-api-contracts.py +++ b/scripts/check-api-contracts.py @@ -26,7 +26,16 @@ HTTP_METHODS = {"get", "put", "post", "delete", "patch", "options", "head", "trace"} PROJECTS_OPERATION_ID = re.compile(r"^projects(?:\.[a-z][a-zA-Z0-9]*){2,}$") PUBLIC_OPERATION_IDS = {"health", "getHealth", "openapi", "getOpenapi", "postMcp"} -TRANSPORT_NATIVE_PATHS = {"/mcp", "/openapi.json", "/health", "/healthz", "/readyz"} +TRANSPORT_NATIVE_PATHS = { + "/mcp", + "/openapi.json", + "/health", + "/healthz", + "/readyz", + "/v1/health", + "/v1/healthz", + "/v1/readyz", +} ERROR_ENVELOPE_SCHEMAS = {"Error", "ErrorResponse", "ApiError", "ApiErrorBody"} ERROR_BODY_SCHEMAS = {"ErrorBody", "ApiErrorBody"} ASYNC_VERBS = { @@ -182,6 +191,12 @@ def schema_is_ref_or_named(spec: dict[str, Any], schema: dict[str, Any], names: direct = schema_ref_schema_name(spec, schema) if direct in names: return True + for key in ("allOf", "oneOf", "anyOf"): + branches = schema.get(key, []) + if isinstance(branches, list): + for branch in branches: + if isinstance(branch, dict) and schema_is_ref_or_named(spec, branch, names): + return True resolved = resolve_ref(spec, schema) if not isinstance(resolved, dict): return False From 4390e3a4fe37a7e072c8eb464f17401c126e6598 Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Wed, 8 Jul 2026 17:37:56 -0700 Subject: [PATCH 10/13] Allow dotted contract operation names --- docs/api-sdk-mcp-shape.md | 2 +- docs/client-surface-manifest.example.json | 8 +++++++- docs/client-surface-manifest.schema.json | 15 +++++++++++++++ scripts/audit-client-surface.py | 8 ++++++-- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/api-sdk-mcp-shape.md b/docs/api-sdk-mcp-shape.md index 2e3917f..ca40a37 100644 --- a/docs/api-sdk-mcp-shape.md +++ b/docs/api-sdk-mcp-shape.md @@ -186,7 +186,7 @@ For a future shared SDK core, this means every service can be described as: "base_url": "http://127.0.0.1:7300", "auth": {"scheme": "bearer", "compat_headers": ["x-beatbox-api-key"]}, "contract": "sdks/openapi.json", - "operations": ["getHealth", "execute", "createJob"] + "operations": ["health", "projects.executions.execute", "projects.jobs.create"] } ``` diff --git a/docs/client-surface-manifest.example.json b/docs/client-surface-manifest.example.json index ce3cc8c..0f19994 100644 --- a/docs/client-surface-manifest.example.json +++ b/docs/client-surface-manifest.example.json @@ -44,5 +44,11 @@ "auth": "shared", "errors": "transport_native" }, - "operations": ["getHealth", "execute", "createJob", "getJob", "cancelJob"] + "operations": [ + "health", + "projects.executions.execute", + "projects.jobs.create", + "projects.jobs.get", + "projects.jobs.cancel" + ] } diff --git a/docs/client-surface-manifest.schema.json b/docs/client-surface-manifest.schema.json index 63d217a..d3e7e0b 100644 --- a/docs/client-surface-manifest.schema.json +++ b/docs/client-surface-manifest.schema.json @@ -214,6 +214,21 @@ } } }, + "operations": { + "type": "array", + "items": { + "type": "string", + "anyOf": [ + { + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + { + "pattern": "^projects(\\.[a-z][a-zA-Z0-9]*){2,}$" + } + ] + }, + "uniqueItems": true + }, "mcp": { "type": "object", "additionalProperties": false, diff --git a/scripts/audit-client-surface.py b/scripts/audit-client-surface.py index f2b4529..81b4b22 100755 --- a/scripts/audit-client-surface.py +++ b/scripts/audit-client-surface.py @@ -32,6 +32,7 @@ ERROR_FIELDS = ["status", "code", "message"] OPTIONAL_ERROR_FIELDS = {"request_id", "details"} LOWER_CAMEL = re.compile(r"^[a-z][a-zA-Z0-9]*$") +PROJECTS_OPERATION_ID = re.compile(r"^projects(?:\.[a-z][a-zA-Z0-9]*){2,}$") SNAKE = re.compile(r"^[a-z][a-z0-9_]*$") ENV_TOKEN = re.compile(r"^[A-Z][A-Z0-9_]*_TOKEN$") ENV_API_KEY = re.compile(r"^[A-Z][A-Z0-9_]*_API_KEY$") @@ -218,9 +219,12 @@ def audit_manifest(manifest: dict[str, Any]) -> AuditResult: violations.append("operations: expected array when present") else: for operation in operations: - if not isinstance(operation, str) or not LOWER_CAMEL.match(operation): + if not isinstance(operation, str) or not ( + LOWER_CAMEL.match(operation) + or PROJECTS_OPERATION_ID.match(operation) + ): violations.append( - f"operations: {operation!r} is not a lower-camel operation name" + f"operations: {operation!r} is not a contract operation name" ) mcp = manifest.get("mcp") From ba1cf5aedea5fd409dc2350e5a381b8fd74899ea Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Wed, 8 Jul 2026 17:57:47 -0700 Subject: [PATCH 11/13] Tighten API contract MCP projection checks --- scripts/check-api-contracts.py | 37 +++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/scripts/check-api-contracts.py b/scripts/check-api-contracts.py index e62c28c..d6884b7 100755 --- a/scripts/check-api-contracts.py +++ b/scripts/check-api-contracts.py @@ -86,6 +86,8 @@ class AuditState: warnings: list[str] = field(default_factory=list) operations: list[Operation] = field(default_factory=list) mcp_tools: list[tuple[str, str, str]] = field(default_factory=list) + openapi_mcp_tools: dict[str, set[str]] = field(default_factory=dict) + catalog_mcp_tools: dict[str, set[str]] = field(default_factory=dict) def load_yaml(path: Path) -> Any: @@ -376,7 +378,11 @@ def audit_openapi(contract: Contract, path: Path, state: AuditState) -> None: if contract.mcp_tools_from_openapi: for operation in operations: if operation.operation_id and not is_transport_native_path(operation.path): - state.mcp_tools.append((contract.service, operation.operation_id, str(path))) + state.openapi_mcp_tools.setdefault(contract.service, set()).add( + operation.operation_id + ) + if not contract.mcp_catalog: + state.mcp_tools.append((contract.service, operation.operation_id, str(path))) print( f"audited {contract.service} OpenAPI: {len(operations)} operations " @@ -431,6 +437,16 @@ def audit_aip_operation(operation: Operation, state: AuditState) -> None: schema = request_schema(operation.spec, operation.op) if not schema_has_property(operation.spec, schema, {"update_mask", "updateMask"}): state.violations.append(f"{operation.where}: PATCH body missing update_mask") + success_schemas = [ + schema + for code, schema in response_schemas(operation) + if code.startswith("2") + ] + if not any( + schema_has_property(operation.spec, schema, {"etag", "ETag"}) + for schema in success_schemas + ): + state.violations.append(f"{operation.where}: PATCH response missing etag") if is_async_operation(operation): success_schemas = [ @@ -494,6 +510,7 @@ def load_mcp_catalog(contract: Contract, path: Path, state: AuditState) -> None: continue name = tool["name"] local[name] = local.get(name, 0) + 1 + state.catalog_mcp_tools.setdefault(contract.service, set()).add(name) state.mcp_tools.append((contract.service, name, str(path))) for name, count in local.items(): if count > 1: @@ -503,6 +520,23 @@ def load_mcp_catalog(contract: Contract, path: Path, state: AuditState) -> None: print(f"audited {contract.service} MCP catalog: {len(tools)} tools from {path}") +def audit_mcp_projection(contracts: dict[str, Contract], state: AuditState) -> None: + for service, expected in sorted(state.openapi_mcp_tools.items()): + contract = contracts.get(service) + if not contract or contract.style not in {"aip", "aip-target"}: + continue + if not contract.mcp_catalog: + continue + actual = state.catalog_mcp_tools.get(service) + if actual is None: + continue + missing = sorted(expected - actual) + for name in missing: + state.violations.append( + f"{service}: MCP catalog missing operationId-derived tool {name!r}" + ) + + def audit_global_conflicts( contracts: dict[str, Contract], state: AuditState, @@ -727,6 +761,7 @@ def main(argv: list[str] | None = None) -> int: elif catalog is not None: load_mcp_catalog(contract, catalog, state) + audit_mcp_projection(contracts, state) audit_global_conflicts(contracts, state, shared_gateway=args.shared_gateway) if state.warnings: From 569da73b6019e3e960246654aa15f4a58f25c63f Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Wed, 8 Jul 2026 20:53:47 -0700 Subject: [PATCH 12/13] Enforce shared auth error and operation schemas --- scripts/audit-openapi-shape.py | 67 +++++++++++++---- scripts/check-api-contracts.py | 132 ++++++++++++++++++++++++++++++--- 2 files changed, 172 insertions(+), 27 deletions(-) diff --git a/scripts/audit-openapi-shape.py b/scripts/audit-openapi-shape.py index 51a0ca0..18263f0 100755 --- a/scripts/audit-openapi-shape.py +++ b/scripts/audit-openapi-shape.py @@ -3,11 +3,11 @@ The check is intentionally small and portable so every repo can vendor or call it without adopting a shared crate: - - every operation has a unique lower-camel operationId + - every service operation has a unique projects.* operationId - every operation has exactly one tag - every documented 4xx/5xx response uses a shared error schema - success object responses reference named schemas instead of inline objects - - list operations expose pagination parameters + - list operations expose shared pagination parameters """ from __future__ import annotations @@ -20,8 +20,8 @@ from typing import Any -CAMEL = re.compile(r"^[a-z][a-zA-Z0-9]*$") -DEFAULT_ERROR_SCHEMA_SUFFIXES = ("/ErrorResponse", "/ApiErrorBody") +PROJECTS_OPERATION_ID = re.compile(r"^projects(?:\.[a-z][a-zA-Z0-9]*){2,}$") +DEFAULT_ERROR_SCHEMA_SUFFIXES = ("/Error", "/ErrorResponse", "/ApiErrorBody") PUBLIC_OPERATION_IDS = {"health", "getHealth", "openapi", "getOpenapi", "postMcp"} @@ -34,7 +34,16 @@ class AuditResult: def load_spec(path: str | Path) -> dict[str, Any]: - with Path(path).open(encoding="utf-8") as handle: + spec_path = Path(path) + with spec_path.open(encoding="utf-8") as handle: + if spec_path.suffix.lower() in {".yaml", ".yml"}: + try: + import yaml # type: ignore + except ModuleNotFoundError as exc: + raise RuntimeError( + f"{spec_path}: PyYAML is required to read YAML OpenAPI specs" + ) from exc + return yaml.safe_load(handle) return json.load(handle) @@ -48,7 +57,24 @@ def operations(spec: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]: return ops -def schema_ref(response: dict[str, Any]) -> str: +def resolve_ref(spec: dict[str, Any], value: Any) -> Any: + if not isinstance(value, dict) or "$ref" not in value: + return value + ref = value["$ref"] + if not isinstance(ref, str) or not ref.startswith("#/"): + return value + current: Any = spec + for part in ref.removeprefix("#/").split("/"): + if not isinstance(current, dict): + return value + current = current.get(part) + return current if current is not None else value + + +def schema_ref(spec: dict[str, Any], response: dict[str, Any]) -> str: + response = resolve_ref(spec, response) + if not isinstance(response, dict): + return "" return ( response.get("content", {}) .get("application/json", {}) @@ -57,6 +83,15 @@ def schema_ref(response: dict[str, Any]) -> str: ) +def parameter_names(spec: dict[str, Any], op: dict[str, Any]) -> set[str]: + names: set[str] = set() + for raw in op.get("parameters", []): + param = resolve_ref(spec, raw) + if isinstance(param, dict) and isinstance(param.get("name"), str): + names.add(param["name"]) + return names + + def is_public_operation(path: str, operation_id: str | None) -> bool: normalized = path.rstrip("/") return ( @@ -111,18 +146,16 @@ def audit_spec( for method, path, op in ops: where = f"{method} {path}" oid = op.get("operationId") - tags = op.get("tags", []) if not oid: violations.append(f"{where}: missing operationId") else: - if not CAMEL.match(oid): - violations.append(f"{where}: operationId {oid!r} is not lower-camel") + if not is_public_operation(path, oid) and not PROJECTS_OPERATION_ID.match(oid): + violations.append( + f"{where}: operationId {oid!r} is not projects.[.]." + ) op_ids.setdefault(oid, []).append(where) - if len(tags) != 1: - violations.append(f"{where}: expected exactly 1 tag, got {tags}") - if enforce_auth and not is_public_operation(path, oid): if not operation_requires_security(spec_security, op.get("security")): violations.append( @@ -136,7 +169,7 @@ def audit_spec( if not err_codes: violations.append(f"{where}: no documented 4xx/5xx error response") for code in err_codes: - ref = schema_ref(responses[code]) + ref = schema_ref(spec, responses[code]) if not ref.endswith(error_schema_suffixes): violations.append( f"{where}: error {code} body is not shared error schema " @@ -146,6 +179,8 @@ def audit_spec( for code, body in responses.items(): if not code.startswith("2"): continue + if is_public_operation(path, oid): + continue schema = body.get("content", {}).get("application/json", {}).get("schema", {}) if ( "$ref" not in schema @@ -162,9 +197,9 @@ def audit_spec( for method, path, op in ops: oid = op.get("operationId", "") - if oid.startswith("list") and oid not in list_pagination_exemptions: - params = {p.get("name") for p in op.get("parameters", [])} - if "cursor" not in params and "limit" not in params: + if (oid.startswith("list") or oid.endswith(".list")) and oid not in list_pagination_exemptions: + params = parameter_names(spec, op) + if "page_size" not in params or "page_token" not in params: violations.append(f"{method} {path}: list op {oid!r} lacks pagination") return AuditResult( diff --git a/scripts/check-api-contracts.py b/scripts/check-api-contracts.py index d6884b7..575fad4 100755 --- a/scripts/check-api-contracts.py +++ b/scripts/check-api-contracts.py @@ -38,6 +38,14 @@ } ERROR_ENVELOPE_SCHEMAS = {"Error", "ErrorResponse", "ApiError", "ApiErrorBody"} ERROR_BODY_SCHEMAS = {"ErrorBody", "ApiErrorBody"} +CANONICAL_ERROR_BODY_FIELDS = { + "code", + "message", + "status", + "details", + "request_id", + "retryable", +} ASYNC_VERBS = { "ingest", "run", @@ -183,6 +191,34 @@ def schema_has_property( return False +def schema_properties( + spec: dict[str, Any], + schema: dict[str, Any], + seen: set[str] | None = None, +) -> dict[str, Any]: + seen = seen or set() + if "$ref" in schema: + ref = str(schema["$ref"]) + if ref in seen: + return {} + seen.add(ref) + resolved = resolve_ref(spec, schema) + return schema_properties(spec, resolved, seen) if isinstance(resolved, dict) else {} + + props: dict[str, Any] = {} + direct = schema.get("properties", {}) + if isinstance(direct, dict): + props.update(direct) + + for key in ("allOf", "oneOf", "anyOf"): + branches = schema.get(key, []) + if isinstance(branches, list): + for branch in branches: + if isinstance(branch, dict): + props.update(schema_properties(spec, branch, seen)) + return props + + def schema_ref_schema_name(spec: dict[str, Any], schema: dict[str, Any]) -> str: if "$ref" in schema: return ref_name(str(schema["$ref"])) @@ -210,24 +246,48 @@ def schema_is_ref_or_named(spec: dict[str, Any], schema: dict[str, Any], names: def schema_is_operation(spec: dict[str, Any], schema: dict[str, Any]) -> bool: - if schema_is_ref_or_named(spec, schema, {"Operation"}): - return True resolved = resolve_ref(spec, schema) if not isinstance(resolved, dict): return False - required = set(resolved.get("required", [])) - props = resolved.get("properties", {}) - return isinstance(props, dict) and {"name", "done"}.issubset(required | set(props)) + props = schema_properties(spec, resolved) + return {"name", "done"}.issubset(props) + + +def schema_is_canonical_error_body(spec: dict[str, Any], schema: dict[str, Any]) -> bool: + resolved = resolve_ref(spec, schema) + if not isinstance(resolved, dict): + return False + props = schema_properties(spec, resolved) + return CANONICAL_ERROR_BODY_FIELDS.issubset(props) def schema_is_error_envelope(spec: dict[str, Any], schema: dict[str, Any]) -> bool: - if schema_is_ref_or_named(spec, schema, ERROR_ENVELOPE_SCHEMAS): - return True resolved = resolve_ref(spec, schema) if not isinstance(resolved, dict): return False - props = resolved.get("properties", {}) - return isinstance(props, dict) and "error" in props + props = schema_properties(spec, resolved) + error = props.get("error") + return isinstance(error, dict) and schema_is_canonical_error_body(spec, error) + + +def has_bearer_security_scheme(spec: dict[str, Any]) -> bool: + schemes = spec.get("components", {}).get("securitySchemes", {}) + if not isinstance(schemes, dict): + return False + for scheme in schemes.values(): + if not isinstance(scheme, dict): + continue + if ( + scheme.get("type") == "http" + and str(scheme.get("scheme", "")).lower() == "bearer" + ): + return True + return False + + +def operation_requires_security(spec_security: Any, operation_security: Any) -> bool: + security = operation_security if operation_security is not None else spec_security + return isinstance(security, list) and bool(security) def operation_params(operation: Operation) -> list[dict[str, Any]]: @@ -263,6 +323,17 @@ def is_transport_native_path(path: str) -> bool: return normalized in TRANSPORT_NATIVE_PATHS +def is_unauthenticated_operation(operation: Operation) -> bool: + normalized = operation.path.rstrip("/") or "/" + return ( + operation.operation_id in {"health", "getHealth", "openapi", "getOpenapi"} + or normalized.endswith("/health") + or normalized.endswith("/healthz") + or normalized.endswith("/readyz") + or normalized.endswith("/openapi.json") + ) + + def is_list_operation(operation: Operation) -> bool: oid = operation.operation_id return oid.endswith(".list") or oid.startswith("list") @@ -374,6 +445,7 @@ def audit_openapi(contract: Contract, path: Path, state: AuditState) -> None: if enforce_aip: audit_operation_schema(contract, spec, state) + audit_auth_contract(contract, spec, operations, state) if contract.mcp_tools_from_openapi: for operation in operations: @@ -485,14 +557,52 @@ def audit_operation_schema(contract: Contract, spec: dict[str, Any], state: Audi if not isinstance(props, dict): state.violations.append(f"{contract.service}: Operation.properties is missing") return + if "job_id" in props: + state.violations.append( + f"{contract.service}: Operation schema has service-specific job_id; use name/metadata/response" + ) error = props.get("error") if not isinstance(error, dict): state.violations.append(f"{contract.service}: Operation.error is missing") - elif not schema_is_ref_or_named(spec, error, ERROR_BODY_SCHEMAS): + elif not schema_is_canonical_error_body(spec, error): + state.violations.append( + f"{contract.service}: Operation.error must reference the canonical inner error body" + ) + + +def audit_auth_contract( + contract: Contract, + spec: dict[str, Any], + operations: list[Operation], + state: AuditState, +) -> None: + if not has_bearer_security_scheme(spec): state.violations.append( - f"{contract.service}: Operation.error must reference inner error body, not HTTP envelope" + f"{contract.service}: missing HTTP bearer security scheme" ) + spec_security = spec.get("security") + for operation in operations: + if is_unauthenticated_operation(operation): + continue + if not operation_requires_security(spec_security, operation.op.get("security")): + state.violations.append( + f"{operation.where}: authenticated operation lacks OpenAPI security" + ) + + responses = operation.op.get("responses", {}) + if not isinstance(responses, dict): + state.violations.append(f"{operation.where}: responses object is missing") + continue + if "401" not in responses: + state.violations.append(f"{operation.where}: authenticated operation missing 401 response") + elif operation.path.rstrip("/") == "/mcp": + schema = response_schema(operation.spec, responses["401"]) + if not schema_is_ref_or_named(operation.spec, schema, {"JsonRpcErrorResponse"}): + state.violations.append( + f"{operation.where}: MCP 401 must use JSON-RPC error response schema" + ) + def load_mcp_catalog(contract: Contract, path: Path, state: AuditState) -> None: catalog = load_data(path) From 8f935204dd712f26985fa601ef03e1f690e7cc2b Mon Sep 17 00:00:00 2001 From: Jaden Fix Date: Wed, 8 Jul 2026 21:04:07 -0700 Subject: [PATCH 13/13] Require canonical error fields in client surface --- AGENTS.md | 3 ++- docs/api-sdk-mcp-shape.md | 17 +++++++++-------- docs/client-surface-manifest.example.json | 2 +- docs/client-surface-manifest.schema.json | 4 +++- scripts/audit-client-surface.py | 13 +++++-------- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f4d6ce3..e0a48c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,8 @@ Use these defaults unless a repo-local task says otherwise: same canonical operation names, `base_url`/`token`/`timeout_ms` client config, `--base-url`/`--token`/`--token-file`/`--timeout-ms`/`--output`/`--json` CLI flags, service-prefixed `_TOKEN` env vars, shared Bearer auth - semantics, and normalized `status`/`code`/`message` error fields. Use + semantics, and normalized `code`/`message`/`status`/`details`/`request_id`/ + `retryable` error fields. Use `python3 scripts/audit-client-surface.py --print-template ` when a repo needs a new manifest, then keep that manifest in sync in the same PR as the code, docs, fixtures, and generated clients. The long-term target is that diff --git a/docs/api-sdk-mcp-shape.md b/docs/api-sdk-mcp-shape.md index ca40a37..da0ec7d 100644 --- a/docs/api-sdk-mcp-shape.md +++ b/docs/api-sdk-mcp-shape.md @@ -29,8 +29,8 @@ use the same names across docs, SDKs, CLIs, MCP tools, examples, and fixtures: - Timeout field: `timeout_ms`/`timeoutMs`, measured in milliseconds. - Output mode: `json` for stable machine-readable output; `text` only as a human convenience. -- Error fields: `status`, `code`, `message`, optional `request_id`, optional - `details`. +- Error fields: `code`, `message`, `status`, `details`, `request_id`, + `retryable`. - Idempotency field/header: use `Idempotency-Key` for HTTP unless the transport has a clearly documented equivalent. @@ -62,7 +62,8 @@ The standard profile is intentionally narrow: converted to underscores: `CRADLE_TOKEN`, `TEMP_JS_TOKEN`, `ARRHA_TOKEN`. - Stable CLI output supports both `json` and `text`; JSON is the contract-shaped output used by tests and automation. -- Normalized SDK/CLI errors start with `status`, `code`, and `message`. +- Normalized SDK/CLI errors expose the same shared `ErrorBody` fields: + `code`, `message`, `status`, `details`, `request_id`, and `retryable`. - MCP projections, when present, record their catalog path, drift check, shared auth behavior, and error mode in the same manifest. @@ -106,7 +107,7 @@ Minimal Bearer-auth example: "operation_names": "contract" }, "errors": { - "fields": ["status", "code", "message", "request_id", "details"] + "fields": ["code", "message", "status", "details", "request_id", "retryable"] }, "mcp": { "path": "crates/beatbox-server/fixtures/mcp-tools.catalog.json", @@ -167,8 +168,8 @@ surface: compatibility API-key alias. New docs should call the value a Bearer token. - Operation methods map from the canonical operation name. - Transport errors and API errors are separate typed errors. -- API errors carry HTTP status, stable error code, message, optional request id, - and optional structured details. +- API errors carry stable error code, message, HTTP status, structured details, + request id, and retryability using the shared `ErrorBody` fields. - Auth never goes in URLs or exception strings. - Unknown response fields do not crash deserialization. - SDK version follows the contract version. @@ -234,8 +235,8 @@ details: - JSON requests send `content-type: application/json` and `accept: application/json`. - API failures use one shared envelope per service. The SDK-facing normalized - error shape is `status`, `code`, `message`, optional `request_id`, and optional - `details`, regardless of language. + error shape is `code`, `message`, `status`, `details`, `request_id`, and + `retryable`, regardless of language. - Mutating operations that can be retried should accept an `Idempotency-Key` header or a clearly documented request idempotency field. diff --git a/docs/client-surface-manifest.example.json b/docs/client-surface-manifest.example.json index 0f19994..357adac 100644 --- a/docs/client-surface-manifest.example.json +++ b/docs/client-surface-manifest.example.json @@ -35,7 +35,7 @@ "operation_names": "contract" }, "errors": { - "fields": ["status", "code", "message", "request_id", "details"] + "fields": ["code", "message", "status", "details", "request_id", "retryable"] }, "mcp": { "path": "crates/beatbox-server/fixtures/mcp-tools.catalog.json", diff --git a/docs/client-surface-manifest.schema.json b/docs/client-surface-manifest.schema.json index d3e7e0b..cee6312 100644 --- a/docs/client-surface-manifest.schema.json +++ b/docs/client-surface-manifest.schema.json @@ -208,8 +208,10 @@ "type": "array", "items": { "type": "string", - "enum": ["status", "code", "message", "request_id", "details"] + "enum": ["code", "message", "status", "details", "request_id", "retryable"] }, + "minItems": 6, + "maxItems": 6, "uniqueItems": true } } diff --git a/scripts/audit-client-surface.py b/scripts/audit-client-surface.py index 81b4b22..e4ebdd8 100755 --- a/scripts/audit-client-surface.py +++ b/scripts/audit-client-surface.py @@ -29,8 +29,7 @@ "--output", "--json", ] -ERROR_FIELDS = ["status", "code", "message"] -OPTIONAL_ERROR_FIELDS = {"request_id", "details"} +ERROR_FIELDS = ["code", "message", "status", "details", "request_id", "retryable"] LOWER_CAMEL = re.compile(r"^[a-z][a-zA-Z0-9]*$") PROJECTS_OPERATION_ID = re.compile(r"^projects(?:\.[a-z][a-zA-Z0-9]*){2,}$") SNAKE = re.compile(r"^[a-z][a-z0-9_]*$") @@ -86,7 +85,7 @@ def template_manifest(service: str) -> dict[str, Any]: "operation_names": "contract", }, "errors": { - "fields": ["status", "code", "message", "request_id", "details"], + "fields": ERROR_FIELDS, }, } @@ -205,13 +204,11 @@ def audit_manifest(manifest: dict[str, Any]) -> AuditResult: errors = require_object(manifest, "errors", violations) error_fields = require_list(errors, "fields", violations) - if error_fields[: len(ERROR_FIELDS)] != ERROR_FIELDS: + if error_fields != ERROR_FIELDS: violations.append( - "errors.fields: must start with ['status', 'code', 'message']" + "errors.fields: expected canonical " + "['code', 'message', 'status', 'details', 'request_id', 'retryable']" ) - for field in error_fields[len(ERROR_FIELDS) :]: - if field not in OPTIONAL_ERROR_FIELDS: - violations.append(f"errors.fields: unknown optional field {field!r}") operations = manifest.get("operations") if operations is not None: