# Where must an instance enforce agent-card schema version pinning during A2A v1.0 discovery, and why cardinality-of-one negotiation silently breaks cross-vendor delegation **Date of Record:** 2026 November 10 **Scope:** Applies to A2A v1.0 agent-to-agent discovery where a client resolves an AgentCard from a remote `/.well-known/agent.json` (or registry) endpoint and then negotiates a task lifecycle (`tasks/send`, `tasks/sendSubscribe`, `tasks/get`, `tasks/cancel`). Does not apply to intra-process agent invocation, nor to registries that pin a single immutable card digest per tenant. ## Core Distinction There are two distinct version surfaces in A2A v1.0, and they are routinely conflated: 1. **The protocol version** — the wire contract governing message envelopes, JSON-RPC framing, and lifecycle state machine transitions (`submitted → working → input-required → completed/failed/canceled`). 2. **The agent-card schema version** — the `protocolVersion` field *inside* the card and the shape of the card itself (capabilities, `defaultInputModes`, `skills[]`, `securitySchemes`, `pushNotifications`). A discovery client that validates only the transport-level protocol version, and not the card schema version, will accept a card whose *shape* it cannot interpret. The failure is not a hard parse error — it is a **cardinality-of-one negotiation**: the client reads a single card, assumes it is authoritative for the session, and proceeds. When the remote vendor rotates the card (new `skills[]` entry, changed `defaultInputModes`, revoked `securitySchemes`) between discovery and task submission, the client's cached card and the server's live card diverge. The server then either rejects with a generic `-32602 Invalid params` or, worse, accepts the task and fails mid-lifecycle at `input-required`, leaving a task in a state the client never modeled. The non-obvious invariant: **the agent card is not a static document; it is a lease.** A2A v1.0 treats discovery as a point-in-time read, but the task lifecycle spans many round trips. The card must be re-validated (or its digest pinned) at every lifecycle transition that depends on card-declared capabilities. ## What Was Observed During a cross-vendor delegation handshake between a client agent (vendor A) and a task-executing agent (vendor B): - Client performed `GET /.well-known/agent.json` at T0 and received a card with `protocolVersion: "1.0.0"`, `capabilities.streaming: true`, and `skills: [summarize]`. - Client derived a delegation plan assuming streaming was available and issued `tasks/sendSubscribe`. - Vendor B had rotated its card at T0+40s: streaming capability disabled for a maintenance window, `skills` replaced with `[summarize, translate]`, and `securitySchemes` changed from `bearer` to `oauth2`. - The `tasks/sendSubscribe` call was accepted (the endpoint existed) but the server downgraded to non-streaming and returned a task in `working` with no SSE channel. The client blocked on an SSE read that never emitted. - A subsequent `tasks/get` returned `input-required` with a prompt referencing the new `translate` skill — a skill the client's cached card never declared, so the client's planner had no handler and surfaced an unmodeled state to the user. - Retry with the stale cached card produced `-32602` on `securitySchemes` mismatch, which the client misclassified as a transient transport error and retried three times. Root cause: no card digest was recorded alongside the task ID, and no `protocolVersion`/card-schema check was performed at `tasks/get` time. ## Condition of Validity This boundary applies **only** when all of the following hold: - The remote card is served from a mutable endpoint (well-known URI or registry without content-addressed digests). - The delegation plan depends on card-declared capabilities (`streaming`, `pushNotifications`, `defaultInputModes`) or `skills[]` membership. - The task lifecycle spans more than one round trip (i.e., anything beyond a single synchronous `tasks/send` that completes in one response). It does **not** apply when the registry returns a content-addressed card (digest in the response, e.g., `ETag` bound to card bytes) that the client pins for the session, or when the vendor guarantees card immutability for the task's TTL via a documented contract. ## Decision Boundary At each lifecycle transition, the client must choose between two enforcement postures: - **Pin-and-fail-closed:** record the card digest (SHA-256 of canonicalized card JSON) at discovery; on every `tasks/get`/`tasks/send` that depends on card-declared fields, re-fetch and compare. On mismatch, abort the delegation and surface a re-negotiation requirement. Correct for cross-vendor delegation where capability drift is possible. - **Pin-and-degrade:** on mismatch, re-read the card, re-plan against the new card, and continue if the task's required skills remain present. Correct only for idempotent, restartable tasks. The boundary is crossed when a client silently falls into a third, incorrect posture: **pin-and-ignore** — keep the stale card, treat schema mismatches as transport errors, and retry. This is the observed failure. The decision boundary is therefore: *any* `-32602` or unmodeled lifecycle state during a delegated task must trigger card re-validation before retry, never blind retry. ## Operational Effect Enforcing digest-pinned discovery converts a silent mid-lifecycle failure into a deterministic, early abort with a machine-actionable error. The client records `card_digest` in its task ledger, re-validates on every capability-dependent transition, and raises a typed error on drift. Downstream, this means cross-vendor delegation either completes against a stable card or fails at the boundary with a re-negotiation signal — never blocks on an SSE channel the server has already disabled, and never surfaces an unmodeled `input-required` prompt to a planner that cannot handle it. ```python import hashlib import json from dataclasses import dataclass from typing import Any class AgentCardDriftError(RuntimeError): """Raised when a remote A2A agent card diverges from the digest pinned at discovery.""" def canonical_digest(card: dict[str, Any]) -> str: canonical = json.dumps(card, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() @dataclass(frozen=True) class PinnedCard: digest: str protocol_version: str streaming: bool skills: frozenset[str] def pin_card(card: dict[str, Any]) -> PinnedCard: if card.get("protocolVersion") != "1.0.0": raise AgentCardDriftError( f"unsupported protocolVersion {card.get('protocolVersion')!r}; expected '1.0.0'" ) return PinnedCard( digest=canonical_digest(card), protocol_version=card["protocolVersion"], streaming=bool(card.get("capabilities", {}).get("streaming", False)), skills=frozenset(s.get("id", "") for s in card.get("skills", [])), ) def revalidate_before_transition( pinned: PinnedCard, live_card: dict[str, Any], required_skill: str, requires_streaming: bool, ) -> None: live = pin_card(live_card) if live.digest != pinned.digest: if required_skill not in live.skills: raise AgentCardDriftError( f"card drift: skill {required_skill!r} no longer advertised " f"(pinned={pinned.digest[:12]}, live={live.digest[:12]})" ) if requires_streaming and not live.streaming: raise AgentCardDriftError( f"card drift: streaming disabled after discovery " f"(pinned={pinned.digest[:12]}, live={live.digest[:12]})" ) raise AgentCardDriftError( f"card drift detected; re-negotiation required " f"(pinned={pinned.digest[:12]}, live={live.digest[:12]})" ) ```