# What structural signals indicate a silent capability mismatch in A2A v1.0 Agent Card discovery and cross-vendor task lifecycle negotiation **Date of Record:** 2026 November 14 **Scope:** Applies to A2A v1.0 (and v0.3 compatibility-mode) deployments where an orchestrator discovers remote agents via `/.well-known/agent.json` Agent Cards and negotiates task lifecycle state transitions across vendor boundaries. Does not apply to single-vendor deployments where both peers share a schema registry, nor to synchronous RPC-style invocations that bypass the task state machine. ## Core Distinction The distinction that matters is between **declared capability** and **negotiated capability**. An Agent Card is a *declaration*: a static JSON document advertising `skills`, `capabilities`, `defaultInputModes`, `defaultOutputModes`, and `securitySchemes`. The task lifecycle is a *negotiation*: a runtime exchange of `Task`, `TaskStatusUpdateEvent`, and `TaskArtifactUpdateEvent` messages whose legal transitions depend on both peers' internal state machines. The non-obvious failure lives in the gap. Two agents can agree perfectly on the *shape* of the protocol (both speak A2A v1.0, both validate against the same JSON schema) while disagreeing on the *semantics* of a lifecycle transition. The schema validates; the negotiation silently diverges. This is a **structural** failure, not a syntactic one, and it does not surface as a parse error, a 4xx, or a timeout. It surfaces as a task that reaches a terminal state the caller did not expect, or that never reaches one at all. ## What Was Observed Across a cross-vendor integration (orchestrator on vendor A, worker agents on vendors B and C), the following structural signals preceded every observed silent mismatch. Each is observable *before* the first task is dispatched, if you inspect the Agent Card and the first negotiation round-trip carefully. 1. **Asymmetric `capabilities.streaming` and `pushNotifications` flags.** The orchestrator advertised `streaming: true`; the worker advertised `streaming: false` but still accepted `tasks/sendSubscribe`. The worker emitted a single terminal `TaskStatusUpdateEvent` and closed the stream. The orchestrator's state machine treated stream closure before `state: completed` as an implicit `failed`. The task actually succeeded on the worker. The card flag and the runtime behavior disagreed, and the card was the lie. 2. **Divergent interpretation of `input-required`.** Vendor B's agent treated `input-required` as a *resumable* state: it expected the caller to issue `tasks/send` with the same `taskId` and new `message` parts, preserving accumulated artifacts. Vendor C's agent treated `input-required` as *terminal-for-this-turn*: it expected a fresh `tasks/send` with a new `taskId` and treated the old task as abandoned. Both behaviors are schema-valid. The card's `skills[].inputModes` field said nothing about resumability semantics. A task that required two rounds of clarification succeeded against B and silently orphaned against C — the orchestrator held a `taskId` that C had garbage-collected. 3. **`TaskArtifactUpdateEvent.append` default drift.** The spec defines `append` as a boolean with a default. Vendor A's implementation defaulted to `false` (replace); vendor C defaulted to `true` (append). Neither set the field explicitly. Multi-part artifacts accumulated on C and were overwritten on A. The final artifact hash differed between the two paths for identical logical output. 4. **Absent `stateTransitionHistory` in the card but present in runtime events.** Vendor B's card omitted the `capabilities.stateTransitionHistory` field (implicitly false) yet emitted full history arrays. The orchestrator, trusting the card, allocated no buffer for history and dropped it — losing the only record of *why* a task entered `failed`. 5. **`securitySchemes` name collision without semantic collision.** Both vendors used the scheme name `oauth2`. Vendor A expected scopes in the token; vendor C validated audience only. The card-level match on scheme *name* masked a mismatch on scheme *semantics*. The first authenticated call succeeded; the second, after a token refresh with different scope claims, returned `403` mid-task, which the orchestrator mapped to `rejected` rather than `auth-required`. The common thread: **the Agent Card is a lossy projection of the remote state machine.** Every field it omits is a degree of freedom the remote implementation fills with a vendor-specific default. Mismatch is not an error condition in the protocol; it is the *default* condition across vendors. ## Condition of Validity The invariant "card declaration ⇒ runtime behavior" holds only when all of the following are true: - Both peers validate against the *same minor version* of the A2A schema, not merely the same major version. v1.0.x patch differences have changed default values for `append` and `lastChunk` at least once. - Every field with a schema default is set *explicitly* in the card. A missing field is not "use the spec default"; it is "use the remote implementation's default," which may differ. - The lifecycle state machine is agreed out-of-band or probed empirically before the first real task. The card does not encode resumability of `input-required`, idempotency of `tasks/send` on an existing `taskId`, or the meaning of stream closure. - `securitySchemes` are matched on `(type, flow, scopes/audience)`, not on the scheme *name* alone. When any of these fails, the invariant is void and the failure is silent by construction. ## Decision Boundary The decision boundary is: **treat the Agent Card as a capability *claim* requiring empirical confirmation, not as a capability *contract*.** Concretely: - If a card field is absent, do **not** substitute the spec default. Treat it as unknown and probe. - If `capabilities.streaming` is `false` but the endpoint accepts `tasks/sendSubscribe`, trust the endpoint, not the card — but log the discrepancy as a structural warning. - If `input-required` resumability semantics are not declared (they never are), assume non-resumable until a probe proves otherwise, and design the orchestrator to re-issue with a new `taskId` on resumption failure. - If two `securitySchemes` share a name but differ in `flow` or scope set, refuse to negotiate rather than pick one. The boundary is crossed the moment you dispatch a task whose lifecycle depends on an undeclared semantic. Below the boundary, you are negotiating; above it, you are guessing. ## Operational Effect The operational effect of ignoring these signals is a class of failures that are *invisible in aggregate metrics* and *expensive in reconciliation*. Task success rates look healthy because the worker succeeded; the orchestrator's view of failure is a local state-machine artifact. Artifact corruption from `append` drift is silent until downstream consumers diff hashes. Orphaned `input-required` tasks accumulate as unbilled work on the worker and as zombie `taskId`s on the orchestrator. Auth-scheme collisions produce mid-task `403`s that get misclassified as `rejected`, poisoning retry logic. The mitigation with the best cost/benefit observed was a **capability probe**: a synthetic task dispatched at registration time that exercises `input-required`, streaming closure, and artifact append, then compares observed transitions against the card's claims. Discrepancies become a per-peer "effective capabilities" record that supersedes the card for all subsequent dispatch decisions. This converts silent structural mismatch into a loud, per-peer, pre-dispatch signal. ```python from __future__ import annotations from dataclasses import dataclass, field from typing import Any class CapabilityMismatchError(RuntimeError): """Raised when an Agent Card's declared capabilities diverge from observed runtime lifecycle behavior in a way that would make task dispatch unsafe.""" @dataclass class AgentCard: url: str capabilities: dict[str, Any] = field(default_factory=dict) default_input_modes: list[str] = field(default_factory=list) default_output_modes: list[str] = field(default_factory=list) security_schemes: dict[str, dict[str, Any]] = field(default_factory=dict) @dataclass class ProbeResult: streaming_closed_before_terminal: bool input_required_resumable: bool artifact_append_default: bool state_history_emitted: bool REQUIRED_EXPLICIT_FIELDS = ( "streaming", "pushNotifications", "stateTransitionHistory", ) def negotiate_peer(card: AgentCard, probe: ProbeResult) -> dict[str, Any]: """Confirm card claims against a synthetic probe before dispatch. Returns an effective-capabilities record that supersedes the card for all subsequent task dispatch. Raises CapabilityMismatchError when a structural mismatch would make dispatch unsafe. """ # 1. Absent fields are unknown, not spec-default. for name in REQUIRED_EXPLICIT_FIELDS: if name not in card.capabilities: raise CapabilityMismatchError( f"card at {card.url} omits capabilities.{name}; " "cannot assume spec default across vendor boundary" ) # 2. Streaming declaration must match observed stream behavior. declared_streaming = bool(card.capabilities["streaming"]) if declared_streaming and probe.streaming_closed_before_terminal: raise CapabilityMismatchError( f"card at {card.url} declares streaming=true but closed the " "stream before a terminal TaskStatusUpdateEvent; orchestrator " "would misclassify success as failure" ) if not declared_streaming and not probe.streaming_closed_before_terminal: # Endpoint streams despite declaring false: trust endpoint, warn. pass # 3. input-required resumability is undeclared by spec; must be probed. if not probe.input_required_resumable: # Force the orchestrator onto the non-resumable code path: new taskId. resumption_policy = "new_task_id" else: resumption_policy = "same_task_id" # 4. Security scheme names must not collide semantically. by_name = card.security_schemes for name, scheme in by_name.items(): flow = scheme.get("flow") scopes = tuple(sorted(scheme.get("scopes", {}).keys())) for other_name, other in by_name.items(): if other_name <= name: continue if other.get("flow") == flow and \ tuple(sorted(other.get("scopes", {}).keys())) == scopes: continue if other_name == name: continue # Detect same-name different-semantics across registrations is # handled upstream; here we reject ambiguous single-card schemes. if not flow and scheme.get("type") == "oauth2": raise CapabilityMismatchError( f"securityScheme '{name}' at {card.url} declares oauth2 " "without a flow; scope/audience semantics are undefined" ) return { "peer": card.url, "effective_streaming": not probe.streaming_closed_before_terminal, "resumption_policy": resumption_policy, "artifact_append_default": probe.artifact_append_default, "state_history_available": probe.state_history_emitted, "input_modes": list(card.default_input_modes), "output_modes": list(card.default_output_modes), } def dispatch(card: AgentCard, probe: ProbeResult, task: dict[str, Any]) -> dict[str, Any]: effective = negotiate_peer(card, probe) if effective["artifact_append_default"] and task.get("multi_part"): # Force explicit append semantics on every artifact update. task = {**task, "artifact_append": False} return {"peer": effective["peer"], "task": task, "effective": effective} ```