# Where must an instance reconcile Agent Card capability declarations against observed task state transitions in A2A v1.0 **Date of Record:** 2026 November 12 **Scope:** Applies to A2A v1.0 clients and orchestrators that discover agents via Agent Card, negotiate capabilities, and drive tasks through the lifecycle state machine (`submitted → working → input-required → completed/failed/canceled`). Does not apply to transport-level retry semantics, authentication negotiation, or streaming chunk reassembly, which are governed by separate invariants. ## Core Distinction The Agent Card is a *declaration* of capability; the task state machine is an *observation* of capability in use. A2A v1.0 treats these as loosely coupled: the Card advertises `capabilities` (e.g., `streaming`, `pushNotifications`, `stateTransitionHistory`) and `skills`, but nothing in the specification forces the runtime state machine to honor every advertised transition. The non-obvious boundary is that **a declared capability is not a guarantee of a reachable transition** — an agent may advertise `stateTransitionHistory: true` yet emit a `TaskStatusUpdateEvent` sequence that skips `working` entirely, or advertise `input-required` support in a skill while its handler returns `failed` for any missing input rather than parking the task. The invariant that must be enforced client-side: *every transition the client intends to drive must be validated against the observed `Task.state` history, not against the Card's advertised `skills` or `capabilities` block.* Conflating the two produces orphaned tasks — the client waits for an `input-required` state that the server never emits. ## What Was Observed During a multi-agent orchestration run, a coordinator discovered three worker agents via `/.well-known/agent.json`. All three advertised `capabilities.streaming: true` and `capabilities.stateTransitionHistory: true`. Two of the three honored the full lifecycle. The third: 1. Accepted `tasks/send` and immediately returned `state: "submitted"`. 2. Emitted a single `TaskStatusUpdateEvent` with `state: "working"` and `final: false`. 3. On a follow-up `tasks/send` (the client's attempt to supply the missing input the skill description implied was required), returned a new `Task` object with a *different* `id` rather than transitioning the original task to `input-required` and then `completed`. The client, trusting the Card, had a state machine that expected `working → input-required → completed`. It never received `input-required`; it received a fresh task id. The original task remained `working` indefinitely in the client's ledger, and the client's `tasks/get` polling loop never terminated because the server reported the original task as still `working` (it had no GC for abandoned tasks). The root cause: the agent's `skills[].inputModes` declared `["text/plain"]` but its handler treated any message without a specific `metadata.tenant` field as a *new* request rather than a continuation. This is a legal A2A v1.0 behavior — the spec does not mandate that a `tasks/send` with the same `taskId` be treated as continuation if the server chooses to spawn a new task. The Card gave no signal of this. ## Condition of Validity The reconciliation rule holds only when: - The client has received at least one `TaskStatusUpdateEvent` or `Task` snapshot with a non-`submitted` state from the agent within the current task's lifetime. Before any state observation, the Card is the only signal and must be treated as a *hint*, not a contract. - The agent's Card `capabilities.stateTransitionHistory` is `true`; if `false`, the client cannot reconstruct history and must fall back to polling `tasks/get` with a hard deadline and treat non-terminal states past the deadline as a protocol violation. - The client is not in a `pushNotifications`-only mode. When push notifications are the sole update channel, the reconciliation must occur on the notification payload's `status.state` field, and the "observed history" is whatever the notification stream delivered — which may be lossy. The rule does **not** hold when the agent is behind a gateway that rewrites task ids (a known pattern in multi-tenant A2A proxies), because then task id continuity is not a valid key for reconciliation; the gateway's correlation id must be used instead. ## Decision Boundary The boundary is: **trust the Card for *routing* decisions (which agent to send to, which skill to invoke, which input modes to offer), but trust only observed state transitions for *lifecycle* decisions (when to wait, when to cancel, when to declare failure).** Concretely, when a client is about to block on a task waiting for `input-required`: - If the Card advertises the skill and the observed history contains `working`, the client may wait — but must bound the wait and treat a new task id appearing in the response as a *divergence*, not a continuation. - If the observed history has no `working` event after the `submitted` ack and the Card's `stateTransitionHistory` is `true`, the client must not assume the agent is slow; it must probe with `tasks/get` and, if the state is still `submitted` past the skill's declared `estimatedDuration` (if present), escalate to divergence handling. The failure mode is specifically: **a client that derives its state machine from the Card's advertised capabilities rather than from the observed `Task.state` sequence.** The Card is a menu; the state machine is the meal. ## Operational Effect Agents and orchestrators that implement this reconciliation observe: - Orphaned `working` tasks drop to near-zero because the client cancels or re-issues on divergence rather than polling forever. - The client's task ledger gains a `divergence` terminal state distinct from `failed`, allowing operators to distinguish "agent broke its contract" from "agent returned an error." - Capability negotiation becomes a two-phase protocol: phase 1 reads the Card to *narrow* the candidate transition set; phase 2 validates that set against the first observed transitions and, on mismatch, either downgrades the client's expectations (e.g., stop waiting for `input-required`, switch to single-shot `tasks/send` with all inputs supplied upfront) or aborts the task with a typed divergence error. The cost is one extra `tasks/get` probe per task in the common case, and a divergence-handling path that must be tested against agents that legally spawn new tasks on continuation attempts. ```python import time from dataclasses import dataclass, field from typing import Literal, Optional TaskState = Literal["submitted", "working", "input-required", "completed", "failed", "canceled"] class AgentCardDivergenceError(RuntimeError): """Raised when an agent's observed task lifecycle contradicts its Agent Card declarations.""" def __init__(self, task_id: str, declared: str, observed: str, detail: str): self.task_id = task_id self.declared = declared self.observed = observed self.detail = detail super().__init__( f"task {task_id!r}: card declared {declared!r} but observed {observed!r} ({detail})" ) @dataclass class AgentCard: name: str streaming: bool state_transition_history: bool skills: list[str] = field(default_factory=list) @dataclass class ObservedTask: task_id: str states: list[TaskState] = field(default_factory=list) first_seen: float = field(default_factory=time.monotonic) def record(self, state: TaskState) -> None: if not self.states or self.states[-1] != state: self.states.append(state) def reconcile_continuation( card: AgentCard, task: ObservedTask, new_task_id: str, wait_deadline_s: float = 30.0, ) -> None: """ Enforce the invariant: lifecycle decisions follow observed state, not the Card. A server may legally spawn a new task id on a continuation attempt, but if the Card advertises stateTransitionHistory and the client has already observed `working`, the absence of an `input-required` transition before the deadline is a divergence the client must surface, not silently absorb. """ if new_task_id != task.task_id: # Legal per A2A v1.0, but only if the Card did not promise history continuity. if card.state_transition_history and "working" in task.states: raise AgentCardDivergenceError( task_id=task.task_id, declared="stateTransitionHistory=true (continuous task id)", observed=f"new task id {new_task_id!r} on continuation", detail="server spawned a fresh task instead of transitioning to input-required", ) return elapsed = time.monotonic() - task.first_seen if elapsed > wait_deadline_s and task.states and task.states[-1] == "working": if card.state_transition_history: raise AgentCardDivergenceError( task_id=task.task_id, declared="stateTransitionHistory=true (input-required reachable)", observed=f"stuck in 'working' for {elapsed:.1f}s", detail="no input-required transition emitted before deadline", ) # Card did not promise history: fall back to polling, do not raise. return if "input-required" in task.states and "working" not in task.states: raise AgentCardDivergenceError( task_id=task.task_id, declared="lifecycle ordering submitted->working->input-required", observed="input-required without prior working", detail="state machine ordering violated", ) ```