diff --git a/agent-templates/contracts/a2a/v1/CHANGELOG.md b/agent-templates/contracts/a2a/v1/CHANGELOG.md index 7b6f863..23df056 100644 --- a/agent-templates/contracts/a2a/v1/CHANGELOG.md +++ b/agent-templates/contracts/a2a/v1/CHANGELOG.md @@ -14,6 +14,55 @@ protocol version appears in the card as `AgentInterface.protocolVersion` and on --- +## 1.3.0 — 2026-08-27 + +Additive, backward-compatible MINOR bump within v1. **Runtime tenant registration.** Tenants may now +be resolved from a runtime DB registry owned by the orchestrator instead of the static +`a2a.tenants[]` values array. Tracking issue: [izzywdev/FuzeAgent#203](https://github.com/izzywdev/FuzeAgent/issues/203). + +This freezes the **interface** for the new topology; it is the gate the implementer slices build on +(Slice 1 migration, the orchestrator handlers, Slice 3 server reader, tests, docs). No handler, SQL +or chart is added here. + +### Added + +- `schema/tenant-registration.schema.json` — the runtime-registry shapes: + - `RegisteredTenant` — the canonical `a2a_tenants` record (`tenant` unique key, `repo`, `ref`, + `entryRole`, `servingRoles[]`, `external`, `provider`, `card`, `enabled`, `createdAt`, + `updatedAt`). Frozen ONCE here so the DB migration and the A2A server reader target one shape. + - `RegisterTenantRequest` / `RegisterTenantResponse` — the self-registration payload and its + upsert result. + - `TenantList` — the `GET /a2a/tenants` response (marked `x-pagination: exempt`: a bounded + whole-set config read the A2A server resolves atomically). + - The `card` field **reuses `agent-card.schema.json` by reference** — the projected card shape is + **not** redefined and remains byte-identical to a card projected from a cloned repo. A pushed + card MUST additionally satisfy `fuze-profile.schema.json`. +- `tenant-registration.md` — NORMATIVE spec for the two orchestrator HTTP operations + (`POST /a2a/tenants/register`, `GET /a2a/tenants` [+ `/{tenant}`]): idempotent-upsert semantics, + the OIDC caller-repo self-registration security rule (a registration whose `tenant`/`repo` ≠ the + authenticated identity is rejected `403` and never written), and the two-schema card-validation + rule. +- `examples/registration/fuzeplan.tenant-registration.json` — a worked registration built from the + frozen FuzePlan card, doubling as a fixture. Placed in a subdirectory so the card-conformance + suite's `examples/*.json` glob (which treats every flat entry as an Agent Card) does not mistake + the registration wrapper for a card. +- `client/fuze_a2a_client/registration_models.py` — GENERATED Pydantic models for the new shapes; + `regenerate.sh` now emits it (bundling the cross-file card ref for single-file codegen). Exported + from `fuze_a2a_client` (`RegisterTenantRequest`, `RegisteredTenant`, `RegisterTenantResponse`, + `TenantList`). Client package bumped `1.0.0` → `1.3.0` — the first bump since freeze that changes + the generated surface, so the package version now tracks the contract version again. + +### Backward compatibility + +Purely additive. `values-interface.schema.json` and the static `a2a.tenants[]` topology are +**unchanged and still valid**; `agent-card.schema.json`, `fuze-profile.schema.json`, +`a2a-wire.schema.json`, `card-projection.md`, `binding.md`, `state-mapping.md` and `authz.md` are +untouched. A v1 consumer that does not use runtime registration is unaffected; the two topologies +serve **byte-identical cards**. No wire/card *shape* changed, so existing `wire_models` / `card_models` +are byte-identical after regeneration. + +--- + ## 1.2.0 — 2026-08-10 Additive, backward-compatible MINOR bump within v1. **Per-product A2A pods become deployable.** diff --git a/agent-templates/contracts/a2a/v1/README.md b/agent-templates/contracts/a2a/v1/README.md index 7fd4526..996873f 100644 --- a/agent-templates/contracts/a2a/v1/README.md +++ b/agent-templates/contracts/a2a/v1/README.md @@ -38,6 +38,7 @@ are human→agent; A2A is agent→agent. Both remain first-class (FuzeSDLC basel | [`card-projection.md`](card-projection.md) | How a card is **derived** from `.fuze/manifest.json` + `agent-templates/roles/*/role.json`. Product **and** exec tier. | | [`state-mapping.md`](state-mapping.md) | A2A ↔ `agent-templates/providers/base.py`. The pod is an **adapter**. | | [`authz.md`](authz.md) | Callee enforces, caller is untrusted, `providesTo` is the grant, absence **denies**. | +| [`tenant-registration.md`](tenant-registration.md) | (v1.3.0) Runtime tenant registry: the two orchestrator HTTP operations, idempotent upsert, self-registration authz, card-as-data. | | [`CHANGELOG.md`](CHANGELOG.md) | SemVer policy, decisions recorded, known gaps. | ## Layout @@ -50,9 +51,12 @@ schema/ manifest-a2a-extension.schema.json .fuze/manifest.json additions (providesTo, a2a block) role-a2a-extension.schema.json optional a2a block on role.json values-interface.schema.json shared-server Helm values INTERFACE (no chart) + tenant-registration.schema.json (v1.3.0) runtime registry record + register req/resp +tenant-registration.md (v1.3.0) runtime tenant registration (NORMATIVE) client/ generated Pydantic models + typed A2AClient mock/ servable card + canned responses examples/ FuzePlan (product) and CTO (exec) cards +examples/registration/ (v1.3.0) a worked tenant-registration request VERSION CHANGELOG.md ``` diff --git a/agent-templates/contracts/a2a/v1/VERSION b/agent-templates/contracts/a2a/v1/VERSION index 26aaba0..f0bb29e 100644 --- a/agent-templates/contracts/a2a/v1/VERSION +++ b/agent-templates/contracts/a2a/v1/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/agent-templates/contracts/a2a/v1/client/fuze_a2a_client/__init__.py b/agent-templates/contracts/a2a/v1/client/fuze_a2a_client/__init__.py index 70e7cf0..09dafe1 100644 --- a/agent-templates/contracts/a2a/v1/client/fuze_a2a_client/__init__.py +++ b/agent-templates/contracts/a2a/v1/client/fuze_a2a_client/__init__.py @@ -2,9 +2,9 @@ Frozen against A2A specification 1.0.0 (`lf.a2a.v1`). -`wire_models` and `card_models` are GENERATED from the schemas in ../../schema/ by -`regenerate.sh`; do not hand-edit them. Editing a generated model instead of the -schema is how a contract silently forks from its spec. +`wire_models`, `card_models` and `registration_models` are GENERATED from the schemas +in ../../schema/ by `regenerate.sh`; do not hand-edit them. Editing a generated model +instead of the schema is how a contract silently forks from its spec. """ from .card_models import FuzeA2AAgentCard as AgentCard from .client import ( @@ -24,13 +24,23 @@ UnsupportedOperationError, VersionNotSupportedError, ) +from .registration_models import ( + RegisteredTenant, + RegisterTenantRequest, + RegisterTenantResponse, + TenantList, +) from .wire_models import Artifact, Message, Part, Role, Task, TaskState, TaskStatus -__version__ = "1.0.0" +__version__ = "1.3.0" __all__ = [ "A2AClient", "AgentCard", + "RegisterTenantRequest", + "RegisteredTenant", + "RegisterTenantResponse", + "TenantList", "A2A_VERSION", "WELL_KNOWN_CARD_PATH", "TERMINAL_STATES", diff --git a/agent-templates/contracts/a2a/v1/client/fuze_a2a_client/registration_models.py b/agent-templates/contracts/a2a/v1/client/fuze_a2a_client/registration_models.py new file mode 100644 index 0000000..7591026 --- /dev/null +++ b/agent-templates/contracts/a2a/v1/client/fuze_a2a_client/registration_models.py @@ -0,0 +1,414 @@ +# generated by datamodel-codegen: +# filename: tenant-registration.bundled.json + +from __future__ import annotations + +from enum import Enum, StrEnum +from typing import Any + +from pydantic import ( + AnyUrl, + AwareDatetime, + BaseModel, + ConfigDict, + Field, + RootModel, + conint, + constr, +) + + +class A2ARuntimeTenantRegistrationV1(RootModel[Any]): + root: Any = Field(..., title='A2A runtime tenant registration (v1)') + """ + The RUNTIME tenant registry contract (added in contract v1.3.0). Until now the shared A2A server resolved its tenant set from the STATIC `a2a.tenants[]` array in the chart's values (values-interface.schema.json), rendered to a ConfigMap the server read at boot, and an initContainer cloned each tenant repo so the server could PROJECT the card. This file freezes the shapes for the alternative topology decided in izzywdev/FuzeAgent#203: consumers SELF-REGISTER at runtime into a DB registry owned by the orchestrator, PUSH their already-projected Agent Card as data, and the stateless/DB-free A2A server fetches the resolved set over HTTP. + + This is PURELY ADDITIVE to v1. It does not remove, rename or re-type any field of values-interface.schema.json — the static `a2a.tenants[]` topology is unchanged and remains valid; a deployment that never calls these endpoints behaves exactly as before. + + Three shapes are frozen here as `$defs`: `RegisteredTenant` (the canonical `a2a_tenants` record — the row the Slice-1 migration creates and the row the Slice-3 server reader consumes, frozen ONCE here so the DB, the orchestrator writer and the server reader all target the same shape), `RegisterTenantRequest` (the self-registration payload) and `RegisterTenantResponse` (the upsert result), plus the `TenantList` read response. The two orchestrator HTTP operations that carry these shapes, their idempotent-upsert semantics, the OIDC self-registration security rule and the card-validation rule are NORMATIVE in tenant-registration.md — read it alongside this schema. + + The `card` field REUSES the frozen agent-card.schema.json BY REFERENCE and is NOT redefined here: the projected card that arrives via registration is byte-identical to the card the generator used to project from a cloned repo. A pushed card MUST additionally satisfy fuze-profile.schema.json (signed, single JSONRPC interface, etc.) exactly as a served card does today — a structural agent-card is necessary but not sufficient; see tenant-registration.md §4. + """ + + +class TenantSlug(RootModel[constr(pattern=r'^[A-Za-z0-9_-]+$')]): + root: constr(pattern=r'^[A-Za-z0-9_-]+$') + """ + The tenant routing key AND the unique registry key (the record's natural id — there is no separate minted uuid; identifier-standard §4.2 client-assigned-id is `allowed` for exactly this reason, see RegisterTenantRequest). MUST equal the card's `supportedInterfaces[].tenant` (card-projection.md §2) and, for the self-registration security rule, MUST be derivable from the authenticated caller repo (tenant-registration.md §3). + """ + + +class RepoName(RootModel[constr(pattern=r'^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$')]): + root: constr(pattern=r'^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') + """ + owner/name of the repo whose manifest + roles were the projection source for `card`. Same shape as values-interface tenant.repo. For the self-registration rule this MUST match the authenticated caller identity (auth.callerClaim, authz.md §2). + """ + + +class RoleKey(RootModel[constr(pattern=r'^[a-z0-9_-]+$')]): + root: constr(pattern=r'^[a-z0-9_-]+$') + """ + A role key; the join key an adapter uses to resolve an incoming skill id back to a role (card-projection.md §3). + """ + + +class SecretRef(BaseModel): + """ + Reference to an existing Kubernetes Secret (SealedSecret-provisioned). A REFERENCE only — secret VALUES never appear on the wire, in the registry, or in git (same rule as values-interface §secretRef). + """ + + model_config = ConfigDict( + extra='forbid', + ) + name: str + key: str + + +class Provider(BaseModel): + """ + Managed-Agents runtime binding for THIS tenant's sessions (the values that reach agent-templates/providers/*). Mirrors values-interface tenant.provider; the consumer declares how ITS OWN sessions are provisioned. Secret material is by reference only (secretRef), never inline literals, and is NEVER projected onto the card. + """ + + model_config = ConfigDict( + extra='forbid', + ) + name: str | None = 'anthropic' + """ + AgentProvider implementation id (base.py `name`). + """ + environmentId: str | None = None + """ + Environment the sessions bind to. Resolved from the role's `environment` when unset. + """ + apiKeySecretRef: SecretRef | None = None + vaultIds: list[str] | None = None + """ + Vault ids passed to create_session. NEVER projected onto the card. + """ + memoryResources: list[str] | None = None + """ + Memory stores mounted into sessions (e.g. the shared handoff store). + """ + + +class ProtocolBinding(StrEnum): + """ + Open string. Core values: JSONRPC, GRPC, HTTP+JSON. Fuze v1 permits only JSONRPC. + """ + + JSONRPC = 'JSONRPC' + GRPC = 'GRPC' + HTTP_JSON = 'HTTP+JSON' + + +class AgentInterface(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + url: AnyUrl + """ + Absolute URL of this interface. In-cluster Fuze interfaces use service DNS (http://a2a-.fuzeagent.svc.cluster.local:8080/rpc); externally-published ones MUST be https:// through the Cloudflare tunnel. + """ + protocolBinding: ProtocolBinding + """ + Open string. Core values: JSONRPC, GRPC, HTTP+JSON. Fuze v1 permits only JSONRPC. + """ + tenant: str | None = None + """ + Opaque routing id when several agents sit behind one endpoint. The Fuze shared server sets this to the serving repo slug so one ingress can front many product agents. When set, clients MUST echo it in the request `tenant` field. + """ + protocolVersion: constr(pattern=r'^\d+\.\d+$') + """ + A2A protocol Major.Minor exposed at this URL (NOT the agent version). Fuze v1 freezes on "1.0". + """ + + +class AgentProvider(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + url: AnyUrl + organization: constr(min_length=1) + + +class AgentExtension(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + uri: AnyUrl + description: str | None = None + required: bool | None = None + params: dict[str, Any] | None = None + + +class SecurityRequirement(RootModel[dict[str, list[str]]]): + """ + Map of security scheme name -> list of required scopes. + """ + + root: dict[str, list[str]] + + +class Location(Enum): + query = 'query' + header = 'header' + cookie = 'cookie' + + +class ApiKeySecurityScheme(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + description: str | None = None + location: Location + name: str + + +class HttpAuthSecurityScheme(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + description: str | None = None + scheme: str + bearerFormat: str | None = None + + +class Oauth2SecurityScheme(BaseModel): + description: str | None = None + flows: dict[str, Any] + oauth2MetadataUrl: AnyUrl | None = None + + +class OpenIdConnectSecurityScheme(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + description: str | None = None + openIdConnectUrl: AnyUrl + + +class MtlsSecurityScheme(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + description: str | None = None + + +class SecurityScheme(BaseModel): + """ + ProtoJSON oneof wrapper — exactly one variant key. + """ + + model_config = ConfigDict( + extra='forbid', + ) + apiKeySecurityScheme: ApiKeySecurityScheme | None = None + httpAuthSecurityScheme: HttpAuthSecurityScheme | None = None + oauth2SecurityScheme: Oauth2SecurityScheme | None = None + openIdConnectSecurityScheme: OpenIdConnectSecurityScheme | None = None + mtlsSecurityScheme: MtlsSecurityScheme | None = None + + +class AgentCardSignature(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + protected: str + """ + base64url-encoded protected JWS header. + """ + signature: str + """ + base64url-encoded signature. + """ + header: dict[str, Any] | None = None + + +class AgentCapabilities(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + streaming: bool | None = None + pushNotifications: bool | None = None + extendedAgentCard: bool | None = None + extensions: list[AgentExtension] | None = None + + +class AgentSkill(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) + id: constr(pattern=r'^[a-z0-9_-]+$') + """ + MUST equal the `role` key of the source role.json. This is the join key that lets the adapter resolve a skill back to a role manifest. + """ + name: constr(min_length=1) + description: constr(min_length=1) + tags: list[str] = Field(..., min_length=1) + examples: list[str] | None = None + inputModes: list[str] | None = None + outputModes: list[str] | None = None + securityRequirements: list[SecurityRequirement] | None = None + + +class AgentCard(BaseModel): + """ + JSON Schema for the A2A AgentCard as served at /.well-known/agent-card.json. Faithful to the ProtoJSON (camelCase) serialization of message AgentCard in lf.a2a.v1 (a2a.proto, A2A spec 1.0.0). The `fuze` profile below adds REQUIRED family constraints on top of the open standard; a card that satisfies this schema is by construction a valid A2A 1.0 AgentCard. + """ + + model_config = ConfigDict( + extra='forbid', + ) + name: constr(min_length=1) + """ + Human readable agent name. Fuze projection: ` agent`, e.g. 'FuzePlan planning agent'. + """ + description: constr(min_length=1) + """ + What this agent is for, in caller-facing terms. Callers route on this text, so it MUST describe outcomes the agent delivers, not the tools it holds. + """ + supportedInterfaces: list[AgentInterface] = Field(..., min_length=1) + """ + Ordered list of interfaces; the FIRST entry is the preferred one. A2A 1.0 replaced 0.x `url` + `preferredTransport` with this list — there is no top-level url field. + """ + provider: AgentProvider + version: constr(pattern=r'^\d+\.\d+\.\d+(?:[-+].*)?$') + """ + Version OF THE AGENT (not of the A2A protocol). SemVer. Projected from the contract version of the serving repo's role set. + """ + documentationUrl: AnyUrl | None = None + iconUrl: AnyUrl | None = None + capabilities: AgentCapabilities + securitySchemes: dict[str, SecurityScheme] | None = Field(None, min_length=1) + """ + Map of scheme name -> SecurityScheme. NOTE the ProtoJSON oneof wrapper: the value is an object with exactly one key naming the scheme variant (e.g. {"openIdConnectSecurityScheme": {...}}). This is NOT the OpenAPI securityScheme shape. + """ + securityRequirements: list[SecurityRequirement] | None = None + """ + Requirements for contacting the agent. Entries are OR'ed; schemes within an entry are AND'ed. + """ + defaultInputModes: list[str] = Field(..., min_length=1) + """ + Media types accepted across all skills. Fuze v1 baseline: ["text/plain", "application/json"]. + """ + defaultOutputModes: list[str] = Field(..., min_length=1) + """ + Media types produced. Fuze v1 baseline: ["text/plain", "application/json"]. + """ + skills: list[AgentSkill] = Field(..., min_length=1) + """ + DERIVED from agent-templates/roles/*/role.json. See card-projection.md. Hand-authoring skills is a contract violation. + """ + signatures: list[AgentCardSignature] | None = None + """ + JWS signatures over the canonicalized card (RFC 7515 JSON serialization). OPTIONAL in A2A; see the Fuze profile for when it is REQUIRED. + """ + + +class RegisterTenantRequest(BaseModel): + """ + The self-registration payload a consumer PUSHes to `POST /a2a/tenants/register`. Carries the tenant identity, its routing/serving config and its already-projected Agent Card. The server assigns nothing except timestamps (createdAt/updatedAt live only on the stored RegisteredTenant, never in the request). SECURITY (normative, tenant-registration.md §3): `tenant` and `repo` MUST match the authenticated caller identity (the same `auth.callerClaim` the A2A server validates, authz.md §2); a payload whose tenant/repo differs from the credential is REJECTED (403) and never written. + """ + + model_config = ConfigDict( + extra='forbid', + ) + tenant: TenantSlug + repo: RepoName + ref: str | None = 'main' + """ + Git ref the manifest/roles the card was projected from were read at. Provenance for the pushed card; the server does NOT clone it. + """ + entryRole: RoleKey + """ + The role that serves a SendMessage naming no skill (mirrors values-interface tenant.entryRole / manifest.a2a.entryRole). + """ + servingRoles: list[RoleKey] = Field(..., min_length=1) + """ + The roles projected into card `skills`. Kept in the record so the adapter can resolve an incoming skill id back to a role; the card already encodes the skills but the join set is stored explicitly. + """ + external: bool | None = False + """ + Published through the Cloudflare tunnel with an https interface URL. MUST be false for exec-tier tenants (card-projection.md §5). + """ + provider: Provider | None = None + enabled: bool | None = True + """ + Whether this tenant is served once registered. Self-registration intent is to be served, so it defaults true; the orchestrator may later disable a tenant without a re-register. Independent of the authz `providesTo` grant, which is still enforced fail-closed by the callee (authz.md §3) and is NOT set here. + """ + card: AgentCard + """ + The consumer's PROJECTED Agent Card, pushed as data (decision #2 of #203). Byte-identical to what the generator used to project from a cloned repo — this contract changes only HOW the card arrives, not what it looks like. Structurally an agent-card.schema.json card; the server MUST ADDITIONALLY validate it against fuze-profile.schema.json (non-empty signatures[], single JSONRPC/1.0 interface, capabilities constants) before storing — see tenant-registration.md §4. A card that fails either validation is REJECTED (422) and never written. + """ + + +class RegisteredTenant(BaseModel): + """ + The canonical `a2a_tenants` registry record: the row the orchestrator stores and serves, the shape the Slice-1 DB migration targets and the Slice-3 A2A server reader consumes. Frozen ONCE here so DB, writer and reader cannot drift. It is the RegisterTenantRequest fields, fully defaulted, plus server-owned `createdAt`/`updatedAt`. `tenant` is the unique key. + """ + + model_config = ConfigDict( + extra='forbid', + ) + tenant: TenantSlug + repo: RepoName + ref: str + """ + Git ref the card was projected from (default `main` applied at registration). + """ + entryRole: RoleKey + servingRoles: list[RoleKey] = Field(..., min_length=1) + external: bool + """ + See RegisterTenantRequest.external. + """ + provider: Provider | None = None + enabled: bool + """ + Whether the A2A server should serve this tenant. The read endpoint may filter on it; see tenant-registration.md §2. + """ + card: AgentCard + """ + The stored projected Agent Card. Validated against agent-card.schema.json AND fuze-profile.schema.json at registration. + """ + createdAt: AwareDatetime + """ + Server-set. ISO 8601, UTC, `Z` suffix. Set on first registration; unchanged by subsequent upserts. + """ + updatedAt: AwareDatetime + """ + Server-set. ISO 8601, UTC, `Z` suffix. Advanced on every upsert. + """ + + +class RegisterTenantResponse(BaseModel): + """ + The result of `POST /a2a/tenants/register`. `created` distinguishes the two idempotent-upsert outcomes (201 first registration vs 200 update); `record` is the resolved stored row. + """ + + model_config = ConfigDict( + extra='forbid', + ) + created: bool + """ + true when this call inserted a new tenant (HTTP 201); false when it updated an existing one (HTTP 200). See tenant-registration.md §2 for upsert semantics. + """ + record: RegisteredTenant + + +class TenantList(BaseModel): + """ + The response of `GET /a2a/tenants` — the full registered-tenant set the A2A server resolves. Not paginated (see x-pagination-reason). + """ + + model_config = ConfigDict( + extra='forbid', + ) + tenants: list[RegisteredTenant] + """ + All registered tenants. When `?enabled=true` is passed the server returns only enabled tenants (the default the A2A server uses); otherwise all rows are returned (operator view). + """ + count: conint(ge=0) | None = None + """ + Number of entries in `tenants` (convenience; equals tenants.length). + """ diff --git a/agent-templates/contracts/a2a/v1/client/pyproject.toml b/agent-templates/contracts/a2a/v1/client/pyproject.toml index eb2503f..6b6cbe2 100644 --- a/agent-templates/contracts/a2a/v1/client/pyproject.toml +++ b/agent-templates/contracts/a2a/v1/client/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fuze-a2a-client" -version = "1.0.0" +version = "1.3.0" description = "Typed client for the Fuze A2A contract v1 (A2A specification 1.0.0)" readme = "README.md" requires-python = ">=3.11" diff --git a/agent-templates/contracts/a2a/v1/client/regenerate.sh b/agent-templates/contracts/a2a/v1/client/regenerate.sh index ce30a13..42f7338 100644 --- a/agent-templates/contracts/a2a/v1/client/regenerate.sh +++ b/agent-templates/contracts/a2a/v1/client/regenerate.sh @@ -2,7 +2,8 @@ # Regenerate the typed models from the frozen schemas. # # The models are GENERATED ARTIFACTS. Never hand-edit -# fuze_a2a_client/{wire_models,card_models}.py — change the schema and re-run this. +# fuze_a2a_client/{wire_models,card_models,registration_models}.py — change the +# schema and re-run this. # Hand-editing a generated model is how a client silently forks from its contract. # # pip install 'datamodel-code-generator>=0.69' @@ -25,7 +26,41 @@ gen() { --disable-timestamp } -gen "$schema/a2a-wire.schema.json" "$out/wire_models.py" -gen "$schema/agent-card.schema.json" "$out/card_models.py" +gen "$schema/a2a-wire.schema.json" "$out/wire_models.py" +gen "$schema/agent-card.schema.json" "$out/card_models.py" -echo "regenerated: $out/wire_models.py $out/card_models.py" +# tenant-registration.schema.json REFERENCES agent-card.schema.json cross-file for +# the `card` field (the card shape is reused by reference, never redefined — +# tenant-registration.md §4). datamodel-codegen refuses single-file output when a +# schema carries an external $ref ("Modular references require an output directory"), +# so we bundle deterministically: inline agent-card's root + $defs into a temp copy +# and rewrite the external ref to a local one, then codegen a single file. The +# COMMITTED schema keeps the clean cross-file $ref; only this build step dereferences. +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +python3 - "$schema/tenant-registration.schema.json" "$schema/agent-card.schema.json" "$tmp/tenant-registration.bundled.json" <<'PY' +import json, sys +reg_path, card_path, out_path = sys.argv[1:4] +reg = json.load(open(reg_path)); card = json.load(open(card_path)) +# Merge the card's $defs into the registration $defs (no key collisions by design), +# add an `AgentCard` def holding the card's root, and repoint the external $ref. +reg.setdefault("$defs", {}) +for k, v in card.get("$defs", {}).items(): + reg["$defs"][k] = v +reg["$defs"]["AgentCard"] = {k: v for k, v in card.items() + if k not in ("$schema", "$id", "$defs")} +def repoint(node): + if isinstance(node, dict): + if node.get("$ref") == "agent-card.schema.json": + node["$ref"] = "#/$defs/AgentCard" + for v in node.values(): + repoint(v) + elif isinstance(node, list): + for v in node: + repoint(v) +repoint(reg) +json.dump(reg, open(out_path, "w"), indent=2) +PY +gen "$tmp/tenant-registration.bundled.json" "$out/registration_models.py" + +echo "regenerated: $out/wire_models.py $out/card_models.py $out/registration_models.py" diff --git a/agent-templates/contracts/a2a/v1/examples/registration/fuzeplan.tenant-registration.json b/agent-templates/contracts/a2a/v1/examples/registration/fuzeplan.tenant-registration.json new file mode 100644 index 0000000..664f7b1 --- /dev/null +++ b/agent-templates/contracts/a2a/v1/examples/registration/fuzeplan.tenant-registration.json @@ -0,0 +1,106 @@ +{ + "tenant": "FuzePlan", + "repo": "izzywdev/FuzePlan", + "ref": "main", + "entryRole": "product-manager", + "servingRoles": [ + "product-manager" + ], + "external": false, + "provider": { + "name": "anthropic", + "environmentId": "fuzeplan-prod" + }, + "enabled": true, + "card": { + "name": "FuzePlan agent", + "description": "Planning and delivery-coordination agent for FuzePlan. Give it a goal about tickets, backlog, sprints or delivery status and it will accomplish it using its own tooling and credentials; callers need no planning tools of their own. Consults the fuzeplan-expert for repo context.", + "provider": { + "organization": "FuzeOne", + "url": "https://github.com/izzywdev" + }, + "version": "1.0.0", + "documentationUrl": "https://github.com/izzywdev/FuzePlan", + "supportedInterfaces": [ + { + "url": "http://a2a-shared.fuzeagent.svc.cluster.local:8080/rpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "tenant": "FuzePlan" + } + ], + "capabilities": { + "streaming": true, + "pushNotifications": false, + "extendedAgentCard": true + }, + "securitySchemes": { + "fuze-oidc": { + "openIdConnectSecurityScheme": { + "description": "FuzeKeys OIDC. The validated subject is the calling repo identity.", + "openIdConnectUrl": "https://auth.prod.fuzefront.com/.well-known/openid-configuration" + } + }, + "fuze-mtls": { + "mtlsSecurityScheme": { + "description": "In-cluster mutual TLS, defence in depth alongside the bearer token." + } + } + }, + "securityRequirements": [ + { + "fuze-oidc": [] + } + ], + "defaultInputModes": [ + "text/plain", + "application/json" + ], + "defaultOutputModes": [ + "text/plain", + "application/json" + ], + "skills": [ + { + "id": "product-manager", + "name": "FuzePlan product-manager", + "description": "Turns requirements and discussion into well-formed tickets, epics and sprint plans in Jira, and reports delivery status.", + "tags": [ + "product-manager", + "product", + "github", + "planning", + "jira" + ], + "examples": [ + "Create Jira tickets for the requirements in this discussion.", + "Break this epic into sprint-sized stories with acceptance criteria.", + "What is the current delivery status of the billing epic?" + ] + }, + { + "id": "ux-designer", + "name": "FuzePlan ux-designer", + "description": "Produces user-flow and interaction specifications for a described feature, aligned to the family design system.", + "tags": [ + "ux-designer", + "product", + "design", + "github" + ], + "examples": [ + "Draft the user flow for self-registration with email OTP." + ] + } + ], + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImZ1emUtYTJhLTIwMjYtMDcifQ", + "signature": "PLACEHOLDER-jws-signature-emitted-by-the-card-generator", + "header": { + "kid": "fuze-a2a-2026-07" + } + } + ] + } +} diff --git a/agent-templates/contracts/a2a/v1/schema/tenant-registration.schema.json b/agent-templates/contracts/a2a/v1/schema/tenant-registration.schema.json new file mode 100644 index 0000000..77ff49c --- /dev/null +++ b/agent-templates/contracts/a2a/v1/schema/tenant-registration.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/izzywdev/FuzeAgent/agent-templates/contracts/a2a/v1/schema/tenant-registration.schema.json", + "title": "A2A runtime tenant registration (v1)", + "description": "The RUNTIME tenant registry contract (added in contract v1.3.0). Until now the shared A2A server resolved its tenant set from the STATIC `a2a.tenants[]` array in the chart's values (values-interface.schema.json), rendered to a ConfigMap the server read at boot, and an initContainer cloned each tenant repo so the server could PROJECT the card. This file freezes the shapes for the alternative topology decided in izzywdev/FuzeAgent#203: consumers SELF-REGISTER at runtime into a DB registry owned by the orchestrator, PUSH their already-projected Agent Card as data, and the stateless/DB-free A2A server fetches the resolved set over HTTP. \n\nThis is PURELY ADDITIVE to v1. It does not remove, rename or re-type any field of values-interface.schema.json — the static `a2a.tenants[]` topology is unchanged and remains valid; a deployment that never calls these endpoints behaves exactly as before. \n\nThree shapes are frozen here as `$defs`: `RegisteredTenant` (the canonical `a2a_tenants` record — the row the Slice-1 migration creates and the row the Slice-3 server reader consumes, frozen ONCE here so the DB, the orchestrator writer and the server reader all target the same shape), `RegisterTenantRequest` (the self-registration payload) and `RegisterTenantResponse` (the upsert result), plus the `TenantList` read response. The two orchestrator HTTP operations that carry these shapes, their idempotent-upsert semantics, the OIDC self-registration security rule and the card-validation rule are NORMATIVE in tenant-registration.md — read it alongside this schema.\n\nThe `card` field REUSES the frozen agent-card.schema.json BY REFERENCE and is NOT redefined here: the projected card that arrives via registration is byte-identical to the card the generator used to project from a cloned repo. A pushed card MUST additionally satisfy fuze-profile.schema.json (signed, single JSONRPC interface, etc.) exactly as a served card does today — a structural agent-card is necessary but not sufficient; see tenant-registration.md §4.", + "$defs": { + "tenantSlug": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "description": "The tenant routing key AND the unique registry key (the record's natural id — there is no separate minted uuid; identifier-standard §4.2 client-assigned-id is `allowed` for exactly this reason, see RegisterTenantRequest). MUST equal the card's `supportedInterfaces[].tenant` (card-projection.md §2) and, for the self-registration security rule, MUST be derivable from the authenticated caller repo (tenant-registration.md §3)." + }, + "repoName": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "description": "owner/name of the repo whose manifest + roles were the projection source for `card`. Same shape as values-interface tenant.repo. For the self-registration rule this MUST match the authenticated caller identity (auth.callerClaim, authz.md §2)." + }, + "roleKey": { + "type": "string", + "pattern": "^[a-z0-9_-]+$", + "description": "A role key; the join key an adapter uses to resolve an incoming skill id back to a role (card-projection.md §3)." + }, + "secretRef": { + "type": "object", + "additionalProperties": false, + "required": ["name", "key"], + "description": "Reference to an existing Kubernetes Secret (SealedSecret-provisioned). A REFERENCE only — secret VALUES never appear on the wire, in the registry, or in git (same rule as values-interface §secretRef).", + "properties": { + "name": { "type": "string" }, + "key": { "type": "string" } + } + }, + "Provider": { + "type": "object", + "additionalProperties": false, + "description": "Managed-Agents runtime binding for THIS tenant's sessions (the values that reach agent-templates/providers/*). Mirrors values-interface tenant.provider; the consumer declares how ITS OWN sessions are provisioned. Secret material is by reference only (secretRef), never inline literals, and is NEVER projected onto the card.", + "properties": { + "name": { "type": "string", "default": "anthropic", "description": "AgentProvider implementation id (base.py `name`)." }, + "environmentId": { "type": "string", "description": "Environment the sessions bind to. Resolved from the role's `environment` when unset." }, + "apiKeySecretRef": { "$ref": "#/$defs/secretRef" }, + "vaultIds": { "type": "array", "items": { "type": "string" }, "description": "Vault ids passed to create_session. NEVER projected onto the card." }, + "memoryResources": { "type": "array", "items": { "type": "string" }, "description": "Memory stores mounted into sessions (e.g. the shared handoff store)." } + } + }, + "RegisterTenantRequest": { + "type": "object", + "additionalProperties": false, + "x-client-assigned-id": "allowed", + "x-client-assigned-id-reason": "The registry key IS the tenant slug (`tenant`), a caller-supplied natural id, not a server-minted surrogate: it must equal the card's AgentInterface.tenant and be derivable from the authenticated caller repo, so the orchestrator cannot mint it. No separate `id`/`uuid` is declared for the resource; identifier-standard §4.2 is satisfied by the explicit `x-client-assigned-id: allowed` marker plus additionalProperties:false.", + "description": "The self-registration payload a consumer PUSHes to `POST /a2a/tenants/register`. Carries the tenant identity, its routing/serving config and its already-projected Agent Card. The server assigns nothing except timestamps (createdAt/updatedAt live only on the stored RegisteredTenant, never in the request). SECURITY (normative, tenant-registration.md §3): `tenant` and `repo` MUST match the authenticated caller identity (the same `auth.callerClaim` the A2A server validates, authz.md §2); a payload whose tenant/repo differs from the credential is REJECTED (403) and never written.", + "required": ["tenant", "repo", "entryRole", "servingRoles", "card"], + "properties": { + "tenant": { "$ref": "#/$defs/tenantSlug" }, + "repo": { "$ref": "#/$defs/repoName" }, + "ref": { "type": "string", "default": "main", "description": "Git ref the manifest/roles the card was projected from were read at. Provenance for the pushed card; the server does NOT clone it." }, + "entryRole": { "$ref": "#/$defs/roleKey", "description": "The role that serves a SendMessage naming no skill (mirrors values-interface tenant.entryRole / manifest.a2a.entryRole)." }, + "servingRoles": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/roleKey" }, + "description": "The roles projected into card `skills`. Kept in the record so the adapter can resolve an incoming skill id back to a role; the card already encodes the skills but the join set is stored explicitly." + }, + "external": { "type": "boolean", "default": false, "description": "Published through the Cloudflare tunnel with an https interface URL. MUST be false for exec-tier tenants (card-projection.md §5)." }, + "provider": { "$ref": "#/$defs/Provider" }, + "enabled": { "type": "boolean", "default": true, "description": "Whether this tenant is served once registered. Self-registration intent is to be served, so it defaults true; the orchestrator may later disable a tenant without a re-register. Independent of the authz `providesTo` grant, which is still enforced fail-closed by the callee (authz.md §3) and is NOT set here.", "x-registration-note": "A consumer enabling itself does NOT grant itself any caller access; `providesTo` lives in the callee manifest and is unaffected." }, + "card": { + "$ref": "agent-card.schema.json", + "description": "The consumer's PROJECTED Agent Card, pushed as data (decision #2 of #203). Byte-identical to what the generator used to project from a cloned repo — this contract changes only HOW the card arrives, not what it looks like. Structurally an agent-card.schema.json card; the server MUST ADDITIONALLY validate it against fuze-profile.schema.json (non-empty signatures[], single JSONRPC/1.0 interface, capabilities constants) before storing — see tenant-registration.md §4. A card that fails either validation is REJECTED (422) and never written.", + "x-fuze-card-profile": "fuze-profile.schema.json" + } + } + }, + "RegisteredTenant": { + "type": "object", + "additionalProperties": false, + "description": "The canonical `a2a_tenants` registry record: the row the orchestrator stores and serves, the shape the Slice-1 DB migration targets and the Slice-3 A2A server reader consumes. Frozen ONCE here so DB, writer and reader cannot drift. It is the RegisterTenantRequest fields, fully defaulted, plus server-owned `createdAt`/`updatedAt`. `tenant` is the unique key.", + "required": ["tenant", "repo", "ref", "entryRole", "servingRoles", "external", "enabled", "card", "createdAt", "updatedAt"], + "properties": { + "tenant": { "$ref": "#/$defs/tenantSlug" }, + "repo": { "$ref": "#/$defs/repoName" }, + "ref": { "type": "string", "description": "Git ref the card was projected from (default `main` applied at registration)." }, + "entryRole": { "$ref": "#/$defs/roleKey" }, + "servingRoles": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/roleKey" } }, + "external": { "type": "boolean", "description": "See RegisterTenantRequest.external." }, + "provider": { "$ref": "#/$defs/Provider" }, + "enabled": { "type": "boolean", "description": "Whether the A2A server should serve this tenant. The read endpoint may filter on it; see tenant-registration.md §2." }, + "card": { + "$ref": "agent-card.schema.json", + "description": "The stored projected Agent Card. Validated against agent-card.schema.json AND fuze-profile.schema.json at registration.", + "x-fuze-card-profile": "fuze-profile.schema.json" + }, + "createdAt": { "type": "string", "format": "date-time", "description": "Server-set. ISO 8601, UTC, `Z` suffix. Set on first registration; unchanged by subsequent upserts." }, + "updatedAt": { "type": "string", "format": "date-time", "description": "Server-set. ISO 8601, UTC, `Z` suffix. Advanced on every upsert." } + } + }, + "RegisterTenantResponse": { + "type": "object", + "additionalProperties": false, + "description": "The result of `POST /a2a/tenants/register`. `created` distinguishes the two idempotent-upsert outcomes (201 first registration vs 200 update); `record` is the resolved stored row.", + "required": ["created", "record"], + "properties": { + "created": { "type": "boolean", "description": "true when this call inserted a new tenant (HTTP 201); false when it updated an existing one (HTTP 200). See tenant-registration.md §2 for upsert semantics." }, + "record": { "$ref": "#/$defs/RegisteredTenant" } + } + }, + "TenantList": { + "type": "object", + "additionalProperties": false, + "x-pagination": "exempt", + "x-pagination-reason": "This is a bounded whole-set configuration read, not an open-ended collection. Its sole consumer is the stateless A2A server resolving its COMPLETE routing table at boot/refresh (decision #1 of #203); it needs every enabled tenant atomically, and the set is bounded by the number of products in the family (~two dozen). Cursoring it would force the server to page its own config and risk a torn routing table. Marked exempt per pagination-standard rather than wrapped in an items/page envelope.", + "description": "The response of `GET /a2a/tenants` — the full registered-tenant set the A2A server resolves. Not paginated (see x-pagination-reason).", + "required": ["tenants"], + "properties": { + "tenants": { + "type": "array", + "items": { "$ref": "#/$defs/RegisteredTenant" }, + "description": "All registered tenants. When `?enabled=true` is passed the server returns only enabled tenants (the default the A2A server uses); otherwise all rows are returned (operator view)." + }, + "count": { "type": "integer", "minimum": 0, "description": "Number of entries in `tenants` (convenience; equals tenants.length)." } + } + } + } +} diff --git a/agent-templates/contracts/a2a/v1/tenant-registration.md b/agent-templates/contracts/a2a/v1/tenant-registration.md new file mode 100644 index 0000000..d36b0ec --- /dev/null +++ b/agent-templates/contracts/a2a/v1/tenant-registration.md @@ -0,0 +1,156 @@ +# Tenant registration (NORMATIVE) — runtime A2A tenant registry + +Added in contract **v1.3.0** (additive within v1). Freezes the two orchestrator HTTP operations and +the record shape behind the runtime-registration topology decided in +[izzywdev/FuzeAgent#203](https://github.com/izzywdev/FuzeAgent/issues/203). + +## 0. Why this exists + +Through v1.2.0 the shared A2A server learned its tenant set at **git/build time**: the static +`a2a.tenants[]` array in `deploy/helm/a2a-shared/values-prod.yaml` +(`values-interface.schema.json`) was rendered to a ConfigMap the server read via `A2A_VALUES_FILE`, +and an initContainer cloned each tenant repo so the server could **project** the card from +`.fuze/manifest.json` + `agent-templates/roles/*`. + +`#203` moves this to **runtime**: + +- Consumers **self-register** into a DB registry **owned by the orchestrator**. +- The A2A server stays **stateless and DB-free**; it **fetches** the resolved tenant set from the + orchestrator over HTTP (decision #1). +- The consumer **pushes its already-projected Agent Card as data** at registration; the server + **validates and stores** it — **no server-side repo cloning** (decision #2). +- FuzeAgent stops knowing consumers at git/build time. + +This is **purely additive**. The static `a2a.tenants[]` topology is untouched and still valid; a +deployment that never calls these endpoints behaves exactly as in v1.2.0. Nothing here removes, +renames or re-types a frozen field. **This document is the contract only** — the migration +(Slice 1), the orchestrator handlers (this endpoint pair) and the A2A server reader (Slice 3) are +implementer streams that gate on it. No handler, SQL, or chart is defined or implied here. + +## 1. Surface + +Two operations on the **orchestrator** (NOT the A2A JSON-RPC `/rpc` surface — these are ordinary +REST over the orchestrator's HTTP API, which is why they are documented here rather than in +`binding.md`). Shapes are frozen in `schema/tenant-registration.schema.json`. + +| Method & path | `$def` in / out | Auth | Purpose | +|---|---|---|---| +| `POST /a2a/tenants/register` | `RegisterTenantRequest` → `RegisterTenantResponse` | OIDC bearer, caller-repo claim (§3) | A consumer self-registers / re-registers itself. Idempotent upsert keyed on `tenant`. | +| `GET /a2a/tenants` | — → `TenantList` | service credential (§3) | The A2A server resolves the full tenant set. `?enabled=true` filters to served tenants. **Not paginated** (§5). | +| `GET /a2a/tenants/{tenant}` | — → `RegisteredTenant` (404 if unknown) | service credential (§3) | Single-tenant read. Singleton; inherently unpaginated. | + +Field naming is **camelCase** in JSON, ISO-8601 UTC (`Z`) timestamps — same wire conventions as the +rest of the contract (`binding.md` §1). + +## 2. Idempotent-upsert semantics + +`tenant` is the **unique registry key** (the record's natural id — there is no separate minted +uuid; see `identifier-standard §4.2` and the `x-client-assigned-id: allowed` marker on +`RegisterTenantRequest`). + +- First registration of a `tenant` **inserts** the row → **`201 Created`**, response + `created: true`. +- A registration of an existing `tenant` **replaces** the mutable fields + (`repo`, `ref`, `entryRole`, `servingRoles`, `external`, `provider`, `enabled`, `card`) → + **`200 OK`**, response `created: false`. `createdAt` is preserved; `updatedAt` advances. +- The operation is therefore safe to run on **every pod start, every restart, and concurrently + across replicas** — the same property the existing `registration/register.sh` init-container + relies on. Concurrent upserts of the same `tenant` MUST converge to a single row (the writer uses + an atomic upsert on the `tenant` unique key); they never create duplicates. + +`createdAt` / `updatedAt` are **server-owned**. They appear only on the stored `RegisteredTenant` +(and in the response), never in `RegisterTenantRequest` — a client that sends them is rejected +(`additionalProperties: false`). + +`GET /a2a/tenants` returns `enabled` tenants under `?enabled=true` (what the A2A server asks for) and +all rows otherwise (operator view). Disabling a tenant is an orchestrator-side flag flip on +`enabled`; it does not require the consumer to re-register. + +## 3. Self-registration authorization (fail-closed identity binding) + +Registration is authenticated by the **same OIDC caller-repo claim the A2A server already validates +today** — `auth.callerClaim` in `values-interface.schema.json`, the caller identity of +`authz.md` §2. Network position is **not** identity (`authz.md` §2); an unauthenticated request is +rejected exactly as an external one is. + +**The binding rule (normative):** + +> A `RegisterTenantRequest` whose `repo` — or whose `tenant`, which MUST be derivable from that +> `repo` — is **not equal to** the authenticated caller identity is **REJECTED with `403`** and +> **never written**. + +``` +1. Authenticate the credential. fail -> 401 +2. caller := repo identity from auth.callerClaim. (never from the body) +3. body.repo != caller -> 403 (identity mismatch) +4. body.tenant not derivable from caller repo -> 403 (a caller may register + ONLY its own tenant slug) +5. validate body.card (see §4) invalid -> 422 +6. upsert (see §2) -> 201 / 200 +``` + +A consumer can therefore register **only itself**. It cannot register, mutate, or disable another +product's tenant, and it cannot smuggle a foreign `tenant`/`repo` through the body — the body is +untrusted for identity, exactly as `authz.md` treats the request body as untrusted for +authorization. This is the registration-time analogue of the callee-enforced model: **the registry +owner enforces; the caller is untrusted.** + +`GET /a2a/tenants` and `GET /a2a/tenants/{tenant}` are read by the **A2A server** (and operators), +not by arbitrary product agents; they require a service credential authorized for registry reads. +The registry read surface is **not** an A2A capability graph and is not subject to `providesTo`. + +**`providesTo` is untouched by registration.** Self-registering (and self-`enabled: true`) grants a +tenant **no** caller access. The A2A authorization grant still lives in the **callee's** +`.fuze/manifest.json` `providesTo` and is still enforced **fail-closed** by the callee at call time +(`authz.md` §3). A tenant that registers itself but whose `providesTo` is absent still denies every +caller. Enabling A2A on a repo still requires backfilling `providesTo` — out of scope here. + +## 4. Card validation (the pushed card must be a real, served card) + +The consumer pushes its **projected** Agent Card in `card`. It is **byte-identical** to what the +generator used to project from a cloned repo — `#203` changes only **how the card arrives**, not +**what it looks like** (`card-projection.md` is unchanged). The server does not clone the repo and +does not re-project; it **validates the pushed card and stores it verbatim**. + +Validation is the **same two-schema check** a served card satisfies today (`fuze-profile.schema.json` +§ "A card MUST validate against BOTH"): + +1. Structural: `card` MUST validate against **`agent-card.schema.json`** (referenced by the `card` + field — the shape is **not** redefined in the registration schema). +2. Profile: `card` MUST **additionally** validate against **`fuze-profile.schema.json`** — + non-empty `signatures[]`, exactly one `JSONRPC`/`1.0` interface carrying a `tenant`, + `capabilities.streaming=true` / `pushNotifications=false` / `extendedAgentCard=true`, + `provider.organization="FuzeOne"`. + +A card failing **either** check is rejected with **`422`** and never written. Additionally, the +card's interface `tenant` MUST equal the record `tenant`, and (unless `external: true`) the card's +interface `url` MUST be an in-cluster address — a stored card whose `url` no caller can follow is +useless (`card-projection.md` §2). The encapsulation invariant (`card-projection.md` §7) is a +property of the card itself and is therefore preserved unchanged: a pushed card carries no +credential, vault id, MCP server URL or tool name, because it is the same projected card. + +Because the card is **signed** (`signatures[]` required, RFC 8785 JCS over the card excluding +`signatures`), a consumer cannot forge capabilities it was not projected to hold: an altered card +fails signature verification. Signing key material / rotation remain a devops concern +(`card-projection.md` §6), out of scope for this contract. + +## 5. Pagination + +`GET /a2a/tenants` is marked **`x-pagination: exempt`** in the schema (`TenantList`). It is a +**bounded whole-set configuration read**, not an open-ended collection: its consumer is the +stateless A2A server resolving its **complete** routing table, it needs every enabled tenant +atomically, and the set is bounded by the number of products in the family (~two dozen). Cursoring it +would make the server page its own configuration and risk a torn routing table. `GET +/a2a/tenants/{tenant}` is a singleton read and inherently unpaginated. If the registry ever grows +beyond a whole-set read (it is not expected to), adding a paginated variant is a future additive +MINOR bump. + +## 6. What this does NOT change (regression guard) + +- `values-interface.schema.json` — unchanged. Static `a2a.tenants[]` remains a valid topology. +- `agent-card.schema.json`, `fuze-profile.schema.json`, `a2a-wire.schema.json`, + `card-projection.md`, `binding.md`, `state-mapping.md`, `authz.md` — unchanged. +- The projected **card shape** — unchanged and reused by reference. +- The A2A JSON-RPC `/rpc` surface and its method set — unchanged. + +A v1 consumer that does not use runtime registration is entirely unaffected.