# Why does an instance of A2A v1.0 agent card discovery appear to succeed while cross-vendor task delegation silently fails at lifecycle negotiation **Date of Record:** 2026 November 11 **Scope:** Applies to A2A v1.0 agent-to-agent delegation where the client and remote agent are published by different vendors and the remote card is fetched from a non-authoritative mirror or CDN edge. Does not apply to same-vendor delegation where card and task endpoints share a control plane, nor to A2A v0.9 cards without the `capabilities.extensions` block. ## Core Distinction Agent card discovery and lifecycle negotiation are two separate consistency domains that A2A v1.0 does not couple. Card discovery is a *read* against a static (or semi-static) document; lifecycle negotiation is a *stateful exchange* (`tasks/send`, `tasks/get`, `tasks/cancel`) against a live task manager. The failure mode is not that discovery returns an error — it is that discovery returns a **valid, schema-conforming card whose advertised lifecycle semantics do not match the task manager actually serving the request**. The client validates the card, trusts it, and then negotiates against a state machine that was never described by that card. The non-obvious boundary: A2A v1.0 requires only that the card be retrievable at the well-known URI `/.well-known/agent.json`. It does **not** require that the card be served by the same origin, the same build, or the same version as the task endpoint. Cross-vendor deployments routinely front the card with a CDN while the task manager sits behind a separate gateway with independent release cadence. ## What Was Observed During interop testing between two vendors (client A, remote B): 1. `GET https://b.example/.well-known/agent.json` returned HTTP 200, `Content-Type: application/json`, schema-valid against the v1.0 card schema, with `capabilities.streaming: true`, `capabilities.pushNotifications: true`, and `defaultInputModes`/`defaultOutputModes` populated. 2. The card advertised `protocolVersion: "1.0.0"` and a single `skills[]` entry whose `id` matched the delegated intent. 3. Client A issued `tasks/send` with `metadata.taskLifecycle: "interruptible"` (a vendor extension carried in the card's `capabilities.extensions` URI list). 4. Remote B accepted the task with `state: "submitted"`, then transitioned to `state: "working"`. 5. Client A sent `tasks/cancel`. Remote B returned HTTP 200 with `state: "canceled"` — but the underlying worker continued and later emitted a `tasks/pushNotification` with `state: "completed"` for the same task ID. 6. A subsequent `tasks/get` for that ID returned `state: "completed"`, contradicting the earlier cancel acknowledgment. The card had been served from a CDN edge caching a build from **B-1** (which supported cooperative cancellation), while the task manager had been rolled to **B-2** (which treats cancel as advisory and does not preempt). The card's `capabilities.extensions` list was stale relative to the live state machine. No error was raised at any step; every individual response was well-formed. ## Condition of Validity This failure is *only* reachable when all of the following hold simultaneously: - The card is served from an origin or cache distinct from the task manager (CDN, edge mirror, or config service with independent deploy). - The remote advertises lifecycle-affecting capabilities (`streaming`, `pushNotifications`, or a vendor `extensions` URI that changes cancel/preempt semantics) that are enforced by the task manager, not the card server. - The client treats card-advertised capabilities as a *guarantee* rather than a *hint*, and does not re-verify them against the first task response. - Cancel or interrupt is issued within the window between card cache invalidation and task-manager rollout completion. If the client re-reads the card per task, or if the remote pins `ETag`/`Cache-Control: no-store` on the card and serves it from the task-manager origin, the window collapses. If the client treats `capabilities` as advisory and confirms semantics via a capability probe task, the failure degrades to a latency cost rather than a correctness violation. ## Decision Boundary The operational rule: **a card is a claim about a service, not a contract with it.** The decision boundary is whether the client performs *post-discovery semantic confirmation* before relying on lifecycle-affecting fields. - If the delegation is idempotent and non-interruptible (fire-and-forget `tasks/send` with polling), trust the card; the cost of a stale capability is bounded. - If the delegation requires cancel, preemption, streaming resumption, or push-notification delivery guarantees, the client MUST confirm the capability against the live task manager — either via a cheap probe task whose terminal state is asserted, or by reading a capability endpoint that shares the task manager's deploy unit. - If neither is available, downgrade the delegation to the intersection of advertised capabilities and a conservative baseline (poll-only, non-interruptible) and record the downgrade. Terminal-state reconciliation is the hard boundary: once a cancel is acknowledged, any later terminal transition for the same task ID is a **protocol invariant violation**, regardless of what the card said. The client must treat that as a fault, not a race. ## Operational Effect The practical consequence is that cross-vendor A2A deployments must treat the card as a *cache with a TTL tied to the remote's release cadence*, and must guard the cancel→terminal transition with an explicit invariant check. Without this, cancel acknowledgments become non-binding, duplicate terminal states leak into downstream orchestration, and retry logic double-executes tasks that were "canceled" but completed. The mitigation is a client-side state-machine guard: after `tasks/cancel` returns a terminal `canceled`, any subsequent event for that task ID — push notification, poll result, or stream frame — must raise and route to reconciliation, not be merged into task state. ```python from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Optional class LifecycleInvariantError(RuntimeError): """Raised when a task emits a terminal transition after cancel was acknowledged.""" class TaskState(str, Enum): SUBMITTED = "submitted" WORKING = "working" CANCELED = "canceled" COMPLETED = "completed" FAILED = "failed" TERMINAL = {TaskState.CANCELED, TaskState.COMPLETED, TaskState.FAILED} @dataclass class TaskGuard: task_id: str card_capabilities: dict state: TaskState = TaskState.SUBMITTED cancel_acknowledged: bool = False _history: list = field(default_factory=list) def confirm_capability(self, capability: str) -> bool: # Card is a claim; only trust it for non-lifecycle-affecting fields. lifecycle_affecting = {"streaming", "pushNotifications", "extensions"} if capability in lifecycle_affecting: raise LifecycleInvariantError( f"capability {capability!r} affects lifecycle; " f"must be confirmed against live task manager, not card" ) return bool(self.card_capabilities.get(capability)) def apply_transition(self, new_state: TaskState) -> None: self._history.append((self.state, new_state)) if self.cancel_acknowledged and new_state in TERMINAL and new_state != TaskState.CANCELED: raise LifecycleInvariantError( f"task {self.task_id}: cancel acknowledged but received terminal " f"state {new_state.value!r} after cancel; card advertised " f"capabilities={sorted(self.card_capabilities)} — route to reconciliation" ) if self.state in TERMINAL and new_state in TERMINAL and new_state != self.state: raise LifecycleInvariantError( f"task {self.task_id}: terminal state {self.state.value!r} " f"superseded by {new_state.value!r}" ) self.state = new_state def acknowledge_cancel(self) -> None: if self.state in TERMINAL: raise LifecycleInvariantError( f"task {self.task_id}: cannot cancel from terminal state {self.state.value!r}" ) self.cancel_acknowledged = True self.state = TaskState.CANCELED def delegate(guard: TaskGuard, intent: str, interruptible: bool) -> Optional[str]: if interruptible: # Interruptible delegation requires live confirmation, not card trust. if not guard.card_capabilities.get("extensions"): raise LifecycleInvariantError( f"intent {intent!r}: interruptible delegation requested but remote " f"advertises no lifecycle extension; downgrade to poll-only" ) return guard.task_id ```