# Where must an instance reconcile Agent Card capability claims with runtime task-lifecycle reality in A2A v1.0 **Date of Record:** 2026 November 7 **Scope:** Applies to A2A v1.0 peer-to-peer agent interoperability where an agent (the "consumer" or "orchestrator") discovers remote agents via `/.well-known/agent.json` Agent Cards and then dispatches `tasks/send` / `tasks/sendSubscribe` calls to them. Does not apply to intra-process agent calls, to A2A <1.0 draft implementations, or to single-runtime deployments where the Card and the task executor share one process and one clock. ## Core Distinction The Agent Card is a *declarative, statically served* document. The task lifecycle (`submitted → working → input-required → completed/failed/canceled`) is an *imperative, dynamically negotiated* state machine. The failure mode is not that either is wrong — it is that the Card is treated as authoritative for lifecycle semantics it cannot possibly encode. Specifically, the Card advertises `capabilities` (e.g., `streaming`, `pushNotifications`, `stateTransitionHistory`) and `skills`, plus an optional `defaultInputModes`/`defaultOutputModes`. It does **not** advertise: whether the remote runtime honors `input-required` resumption, whether `tasks/resubscribe` is idempotent, whether `cancel` is honored when the task is in `working` versus `input-required`, or what the maximum time a task may sit in `submitted` before it is considered abandoned. These are properties of the *executor*, not the *descriptor*. The non-obvious boundary: **`capabilities.streaming: true` on the Card is a statement about transport, not about task resumption.** A runtime can stream tokens perfectly and still drop the task on the floor when the consumer sends a follow-up `tasks/send` with the same `taskId` to answer an `input-required` state. The Card promised streaming; the lifecycle promised resumption; only one of those is discoverable. ## What Was Observed Across a heterogeneous mesh (three runtimes: a Python `a2a-sdk` reference server, a Go implementation, and a JVM-based runtime behind an Envoy sidecar), the following invariant repeatedly broke: 1. Consumer fetches Card. Card declares `capabilities.streaming: true`, `capabilities.stateTransitionHistory: true`, and a skill with `inputModes: ["text/plain"]`, `outputModes: ["text/plain"]`. 2. Consumer calls `tasks/sendSubscribe` with a `message` carrying `role: "user"`. Task enters `submitted`, then `working`. 3. Remote emits an `input-required` status update via the SSE stream, with a `message` asking for clarification. The stream is then **closed by the server** (this is spec-permitted; the task is still alive). 4. Consumer reconnects with `tasks/resubscribe` (per spec, to reattach). The JVM runtime returns `-32001 TaskNotFoundError` because its task store is keyed on the *original* SSE connection lifetime, not the task ID. The Go runtime returns the task but replays *all* prior events from the beginning, including the `working` transition, causing the consumer to double-count. 5. Consumer sends `tasks/send` with the same `taskId` and the answer. The Python runtime resumes correctly. The JVM runtime treats it as a *new* task because the prior one was reaped. The Go runtime resumes but emits a second `submitted` event, corrupting the consumer's state machine. Net effect: the consumer's task state diverges from the remote's task state, and the divergence is *undetectable from the Card*. Every runtime truthfully advertised `streaming: true` and `stateTransitionHistory: true`. ## Condition of Validity The Card-as-authority assumption is valid **only** when all of the following hold: - The consumer and producer share a task-store durability contract (e.g., both persist tasks keyed by `taskId` for at least the task's `metadata.timeout`), *or* the consumer treats any `-32001` on resubscribe as terminal and re-dispatches from scratch. - The consumer treats `stateTransitionHistory` as a *hint for replay deduplication*, not as a guarantee of monotonic event delivery. Implementations differ on whether resubscribe replays or resumes. - `input-required` is treated as a **terminal-for-this-connection** state: the consumer must be prepared to re-establish via `tasks/send` with the same `taskId` and must handle `-32001` as "remote lost the task, re-dispatch." - The consumer enforces its own `submitted`-state deadline (the Card carries no such field) and cancels + re-dispatches on expiry. When any of these fail, the Card is insufficient and the consumer must fall back to *probing* the lifecycle: send a benign `tasks/get` after a short delay to confirm the task exists, and treat absence as authoritative over the Card's claims. ## Decision Boundary The decision the consumer must make at discovery time is: **do I trust the Card's `capabilities` for lifecycle planning, or do I treat the Card as a transport hint and negotiate lifecycle empirically?** - If the mesh is homogeneous (one runtime, one version), trust the Card. The lifecycle is consistent by construction. - If the mesh is heterogeneous (≥2 runtimes, or ≥2 major versions of one runtime), **do not** plan lifecycle from the Card. Plan it from a per-peer capability probe: after first contact, record (a) whether resubscribe replays or resumes, (b) whether `-32001` is returned on resubscribe after `input-required`, (c) whether resend-with-same-taskId creates a new task. Cache this per `(agentUrl, runtimeVersion)` and treat it as the real capability set. - The Card's `capabilities.streaming` gates *whether you open an SSE connection at all*. It does **not** gate whether you can rely on resumption. Those are orthogonal. The trap is treating `streaming: true` as `resumable: true`. They are not the same bit, and A2A v1.0 has no field for the latter. ## Operational Effect Concretely, the consumer must: - Maintain a **per-peer lifecycle profile** separate from the Card, keyed by the Card's `version` and the resolved `url` host, updated on every `-32001`, duplicate-`submitted`, or replay anomaly. - Treat `input-required` as requiring an explicit `tasks/send` with the same `taskId`, and treat `-32001` on that send as "re-dispatch from scratch with a new `taskId`," not as a fatal error. - Bound `submitted` and `working` durations locally; the Card cannot express a deadline, so the consumer owns it. - Never assume event monotonicity across a resubscribe. Deduplicate on `(taskId, status.state, status.timestamp)` and reject any transition that moves backward in the state machine unless a `canceled`/`failed` terminal is observed. This is the operational invariant: **the Agent Card is a transport and skill advertisement, not a lifecycle contract.** The lifecycle must be negotiated per-peer and cached, because the spec's declarative surface and its imperative state machine are deliberately decoupled. ```python class LifecycleContractViolation(RuntimeError): """Raised when a remote peer's task lifecycle diverges from what its Agent Card implied, in a way the consumer cannot safely paper over.""" pass TERMINAL = {"completed", "failed", "canceled"} FORWARD = ["submitted", "working", "input-required", "completed"] class PeerLifecycleProfile: def __init__(self, agent_url: str, card_version: str): self.agent_url = agent_url self.card_version = card_version self.resubscribe_replays = None # True=replay, False=resume, None=unknown self.task_survives_input_required = None self.resend_same_taskid_resumes = None def record_resubscribe(self, replayed_from_start: bool): self.resubscribe_replays = replayed_from_start def record_resend(self, resumed_in_place: bool): self.resend_same_taskid_resumes = resumed_in_place def reconcile_resubscribe(profile: PeerLifecycleProfile, card: dict, observed_events: list[dict], last_known_state: str) -> list[dict]: """Given a resubscribe response, return only the events the consumer has not already applied. Raises if the peer's behavior contradicts a lifecycle guarantee the consumer depends on and cannot recover from.""" if not card.get("capabilities", {}).get("streaming", False): raise LifecycleContractViolation( f"{profile.agent_url}: resubscribe attempted but Card never " f"advertised streaming; consumer opened an SSE channel anyway" ) seen = set() deduped = [] for ev in observed_events: key = (ev["taskId"], ev["status"]["state"], ev["status"].get("timestamp")) if key in seen: continue seen.add(key) deduped.append(ev) # Detect backward state movement (Go runtime duplicate `submitted` case). order = {s: i for i, s in enumerate(FORWARD)} prev = order.get(last_known_state, -1) for ev in deduped: cur = order.get(ev["status"]["state"], -1) if cur < prev and ev["status"]["state"] not in TERMINAL: raise LifecycleContractViolation( f"{profile.agent_url}: resubscribe moved state backward " f"{last_known_state} -> {ev['status']['state']}; peer " f"replayed instead of resumed and consumer cannot reconcile" ) prev = cur if deduped and deduped[0]["status"]["state"] == "submitted" \ and last_known_state != "submitted": profile.record_resubscribe(replayed_from_start=True) else: profile.record_resubscribe(replayed_from_start=False) return deduped def dispatch_followup(profile: PeerLifecycleProfile, card: dict, task_id: str, answer_text: str) -> dict: """Send an input-required answer. Handles the -32001 case per the empirically learned profile rather than per the Card.""" survives = profile.task_survives_input_required if survives is None: # Unknown peer: probe with tasks/get before sending. raise LifecycleContractViolation( f"{profile.agent_url}: no lifecycle profile for task {task_id}; " f"probe tasks/get before follow-up or risk orphaned task" ) if not survives: # Peer reaped the task; re-dispatch from scratch. return {"action": "redispatch", "reason": "task reaped after input-required"} return {"action": "send_same_taskid", "taskId": task_id, "text": answer_text} ```