# Why does an instance advertise a capability set that its runtime cannot negotiate: the A2A v1.0 Agent Card / runtime divergence boundary **Date of Record:** 2026 November 10 **Scope:** Applies to A2A v1.0 Agent Card publication and capability negotiation between heterogeneous runtimes (e.g., a Python orchestrator negotiating with a Go or Rust worker, or a gateway proxying to a legacy adapter). Does not apply to homogeneous single-runtime deployments where the Agent Card is generated from the same code path that serves requests. ## Core Distinction An Agent Card is a *declaration*, not a *binding*. It states the capabilities the publisher asserts it supports. Capability negotiation is the *act* of two runtimes agreeing on the intersection of their declared sets and then actually executing within that intersection. The failure mode in question is not "the card is wrong" and not "the runtime is broken." It is the divergence between the card's declared capability set and the set of capabilities the runtime can *materialize* when a peer actually exercises them. The non-obvious invariant: **for any capability `c` declared in the Agent Card, the runtime that serves requests under that card must be able to reach a terminal state — success or a typed, negotiated refusal — for every request shape `c` admits.** A runtime that can only reach an untyped failure (a 500, a protocol-level desync, a hang) for some admitted shape violates the invariant even though its card is "accurate" in the sense that the capability is *implemented*. Implemented is not the same as negotiable. ## What Was Observed Across a mixed deployment — a Python A2A orchestrator negotiating with three worker runtimes (Python, Go, and a Rust gateway fronting a legacy HTTP service) — we observed the following during a capability roll-out of `streaming` and `pushNotifications`: 1. **Card/runtime skew on the Go worker.** The Go worker's Agent Card was generated at build time from a `capabilities.yaml` manifest. The manifest declared `streaming: true`. The Go binary, however, had been built with the streaming path behind a build tag that was *off* in the release artifact. The card was correct relative to the manifest; the binary was correct relative to its build flags; the pair was inconsistent. The orchestrator negotiated `streaming: true`, opened an SSE stream, and received a `200` with an empty body that never closed. No error, no terminal state — a hang that only resolved on the client-side 90s read timeout. 2. **Push notification endpoint shape mismatch.** The Rust gateway declared `pushNotifications: true` and advertised a webhook receiver path. The legacy service behind it accepted webhooks only with a `Content-Type: application/a2a+json`; the gateway's card advertised the standard `application/json`. Negotiation succeeded on the capability flag; the *payload contract* failed at the legacy boundary with a `415` that the gateway surfaced to the orchestrator as an untyped `500`. Capability negotiation had no field to express "I support push, but only for this content type." 3. **Version-negotiation fallback that wasn't.** The Python worker declared `protocolVersion: "1.0"` and also listed `"0.9"` in a `supportedVersions` extension field. A 0.9-era peer negotiated down. The Python worker's 0.9 code path existed but had not been exercised since the 1.0 migration and threw `AttributeError` on the first request — a runtime error, not a negotiated refusal. The card said "I speak 0.9"; the runtime did not. The common structure: in each case the *declaration* was internally consistent and the *runtime* was internally consistent, but the *intersection* — the thing negotiation actually produces — admitted request shapes that the runtime could not terminate. ## Condition of Validity This boundary is only a failure when three conditions hold simultaneously: - **Heterogeneous build paths.** The card and the serving code are produced by different toolchains, build flags, or release processes. Homogeneous deployments regenerate the card from the serving code and the divergence cannot arise. - **Negotiation admits shapes the runtime cannot terminate.** If every shape admitted by a capability is either served or *explicitly refused with a typed error*, the invariant holds. The failure requires an admitted shape that produces an untyped outcome. - **The peer trusts the negotiation result as a contract.** If the orchestrator validates the actual response shape (rather than trusting the negotiated flag), the divergence is caught at the edge rather than mid-stream. When any of these is absent, the divergence is either impossible, harmless, or caught. The operational signal is the *combination*. ## Decision Boundary The decision is not "should the card be regenerated from the runtime" — that is the correct long-term fix but it does not address the boundary. The decision is: **when a peer negotiates a capability, what is the runtime's obligation?** Two defensible policies: - **Declare-then-refuse.** The runtime may declare a capability it cannot fully serve, provided that every admitted-but-unservable shape returns a typed, negotiated refusal (`UnsupportedCapabilityShape`, with the shape identifier). This preserves forward-compatibility during roll-outs and makes the divergence observable. - **Declare-only-what-terminates.** The runtime declares only capabilities for which every admitted shape reaches a terminal state. This is stricter and requires the card to be generated from the serving code path, not a manifest. The indefensible policy — the one that produced all three observations — is **declare-and-hang-or-500**. It is indistinguishable from correct behavior at negotiation time and only fails under load, after the peer has committed to the capability. ## Operational Effect The practical consequence is that **capability negotiation in A2A v1.0 is not a safety property; it is a liveness property that must be enforced at the runtime boundary.** The card tells you what the publisher believes; it does not tell you what the runtime will do. The orchestrator must therefore treat the negotiated capability set as a *lower bound* and instrument every negotiated capability with a terminal-state check: either a success frame, a typed refusal, or a bounded timeout that is itself a typed refusal. In our deployment, adding a per-capability terminal-state assertion at the orchestrator (a wrapper that converts any untyped failure or timeout into `CapabilityNegotiationError` with the negotiated capability and the request shape) converted all three failure modes from mid-stream hangs and opaque 500s into immediate, attributable refusals. The card was left unchanged; the runtime was left unchanged; only the negotiation *consumer* was hardened. That is the boundary: the invariant is enforced where the capability is *exercised*, not where it is *declared*. ```python """Enforce the A2A v1.0 capability-negotiation terminal-state invariant at the negotiation consumer, not at the card publisher. The invariant: for every capability `c` in the negotiated set, every request shape admitted by `c` must reach a terminal state -- success or a typed refusal -- within a bounded time. Untyped failures and timeouts are converted into typed refusals so the divergence between the Agent Card declaration and the runtime's actual behavior is observable and attributable. """ from __future__ import annotations import asyncio from dataclasses import dataclass, field from typing import Any, Awaitable, Callable class CapabilityNegotiationError(RuntimeError): """Raised when a negotiated capability cannot reach a terminal state. This is the typed refusal that replaces the untyped failure (hang, 500, protocol desync) that the runtime would otherwise produce. It carries the negotiated capability and the request shape so the divergence between the Agent Card declaration and runtime behavior is attributable. """ def __init__(self, capability: str, shape: str, cause: str) -> None: self.capability = capability self.shape = shape self.cause = cause super().__init__( f"negotiated capability {capability!r} admitted shape {shape!r} " f"but runtime could not terminate: {cause}" ) class UntypedRuntimeFailure(CapabilityNegotiationError): """The runtime returned an untyped failure (5xx, desync) for an admitted shape.""" class CapabilityTimeout(CapabilityNegotiationError): """The runtime neither succeeded nor refused within the bounded window.""" @dataclass class NegotiatedCapability: name: str declared: bool admitted_shapes: tuple[str, ...] = field(default_factory=tuple) @dataclass class AgentCard: agent_id: str capabilities: dict[str, bool] protocol_version: str def negotiate(card: AgentCard, peer_versions: tuple[str, ...]) -> list[NegotiatedCapability]: if card.protocol_version not in peer_versions: raise CapabilityNegotiationError( capability="protocolVersion", shape=card.protocol_version, cause=f"no overlap with peer versions {peer_versions!r}", ) return [ NegotiatedCapability(name=name, declared=declared) for name, declared in card.capabilities.items() if declared ] async def exercise( capability: NegotiatedCapability, shape: str, invoke: Callable[[str], Awaitable[Any]], timeout_s: float = 30.0, ) -> Any: """Exercise a negotiated capability under the terminal-state invariant.""" if shape not in capability.admitted_shapes and capability.admitted_shapes: raise CapabilityNegotiationError( capability=capability.name, shape=shape, cause="shape not admitted by capability declaration", ) try: return await asyncio.wait_for(invoke(shape), timeout=timeout_s) except asyncio.TimeoutError as exc: raise CapabilityTimeout( capability=capability.name, shape=shape, cause=f"no terminal state within {timeout_s}s", ) from exc except CapabilityNegotiationError: raise except Exception as exc: # untyped failure from the runtime raise UntypedRuntimeFailure( capability=capability.name, shape=shape, cause=f"{type(exc).__name__}: {exc}", ) from exc async def _demo() -> None: card = AgentCard( agent_id="go-worker-7", capabilities={"streaming": True, "pushNotifications": True}, protocol_version="1.0", ) caps = negotiate(card, peer_versions=("1.0",)) streaming = next(c for c in caps if c.name == "streaming") streaming.admitted_shapes = ("sse",) async def hanging_runtime(shape: str) -> Any: # Simulates the Go worker whose build tag disabled streaming: # the request is accepted but never terminates. await asyncio.sleep(10_000) return None try: await exercise(streaming, "sse", hanging_runtime, timeout_s=0.5) except CapabilityTimeout as exc: print(f"typed refusal: {exc}") if __name__ == "__main__": asyncio.run(_demo()) ```