# In what scenarios must an instance treat A2A v1.0 Agent Card discovery as a non-idempotent, version-fenced operation rather than a cacheable read **Date of Record:** 2026 November 11 **Scope:** This record applies to A2A v1.0 client orchestrators that discover Agent Cards, drive tasks through the `submitted → working → input-required → completed/failed/canceled` lifecycle, and bind push-notification webhooks. It does not apply to A2A deployments that pin a single static Agent Card per origin with no in-place capability mutation, nor to transport-only concerns (HTTP retry, TLS) that are orthogonal to card semantics. ## Core Distinction The non-obvious boundary is this: an Agent Card is *published state*, not *configuration*. Clients routinely treat `GET /.well-known/agent.json` as a cacheable read — fetch once, memoize, reuse for the lifetime of the process. That is valid only when the card's advertised `capabilities`, `skills`, and `pushNotifications` blocks are immutable for the origin. In A2A v1.0 they are not required to be. An agent may legitimately re-advertise a narrower skill set, drop `pushNotifications` support, or change the accepted `inputModes` while a client holds a live task against it. The distinction that matters operationally is between **discovery** (a read whose result gates whether you may *start* a new task) and **binding** (the act of establishing a webhook endpoint that the remote agent will call for a task already in flight). Discovery is re-validatable at task boundaries. Binding is not: once a webhook is registered against a task, the remote agent's delivery behavior is governed by the card state *at registration time*, and a subsequent card change does not retroactively invalidate deliveries already queued. A client that conflates the two will either re-discover too aggressively (breaking in-flight webhook expectations) or too lazily (starting tasks against capabilities that no longer exist). ## What Was Observed Across three orchestrator deployments driving long-lived tasks (median 40 minutes, tail 6 hours), the following failure recurred. A client fetched the Agent Card at process start, memoized it, and used the memoized `pushNotifications: true` to register a webhook for task `T`. Mid-task, the remote agent was redeployed with `pushNotifications: false` (webhook delivery disabled during a migration). The client's registration call returned `200` because the *task* existed and the *endpoint* was syntactically valid, but the remote agent silently dropped deliveries. The client, trusting its memoized card, waited on the webhook for the full task timeout, then transitioned `T` to `failed` locally while the remote agent had actually reached `completed`. The divergence was invisible until a reconciliation job compared terminal states. The second observed mode was the inverse: a client re-fetched the card on every poll of `GET /tasks/{id}` and, upon seeing a transient card-fetch `503` during remote redeploy, treated the *task* as unreachable and issued a spurious `tasks/cancel`. The remote agent honored the cancel, destroying a task that was 90% complete. Both failures share a root cause: the client had no explicit rule for *when* card re-validation is permitted to influence task lifecycle decisions. ## Condition of Validity Memoizing an Agent Card is valid **if and only if** all of the following hold: 1. The card's `version` field is unchanged since the memoized fetch, and the origin's card is declared immutable for that version (operators must publish this; absent a declaration, assume mutable). 2. No task currently held by the client depends on a capability that the memoized card advertises but that could be withdrawn (specifically `pushNotifications`, `streaming`, and any `inputModes`/`outputModes` the task relies on). 3. The client has a fallback path — polling `GET /tasks/{id}` — that does not require the card to be valid. Condition 2 is the one that is routinely violated. A task that has already transitioned to `working` and is being driven by polling does not depend on card freshness. A task that has registered a webhook *does*, because the webhook contract was negotiated against the card at registration. The validity boundary is therefore **per-task, not per-process**. ## Decision Boundary The decision rule the orchestrator must implement: - **At task creation:** re-validate the card if the memoized copy is older than the task's expected duration, or if the card `version` has not been confirmed within the last N minutes (N = 2× observed remote redeploy interval). If re-validation fails (network, `503`), *do not* start the task — fail fast with a discovery error, because you cannot confirm the capability you are about to depend on. - **At webhook registration:** re-validate the card *unconditionally*, immediately before the registration call, and require `pushNotifications: true` in the fresh copy. If the fresh card says `false`, do not register; fall back to polling and record the downgrade. - **During an in-flight task:** never let a card-fetch failure influence the task state machine. A card fetch that fails mid-task is a *discovery* failure, not a *task* failure. The task remains `working`; the client falls back to polling. Only an explicit `tasks/get` failure or a terminal state from the remote agent may move the task. - **On card `version` change mid-task:** do not cancel. Reconcile: if the new card drops `pushNotifications`, transition the task's delivery mode to polling and continue. If the new card drops a skill the task depends on, the task is at risk but not invalid — let it run to its natural terminal state and flag it for post-hoc review. The boundary is: **card state may gate task *creation* and webhook *registration*; it may never gate task *continuation*.** ## Operational Effect Implementing this boundary converts two classes of silent divergence into explicit, observable events. The webhook-drop failure becomes a `DiscoveryDowngrade` event at registration time (fresh card says no push) rather than a timeout six hours later. The spurious-cancel failure becomes a `DiscoveryUnavailable` warning attached to the task, with the task continuing under polling. In the three deployments, terminal-state divergence between client and remote dropped from 4.1% of long tasks to 0.3%, and the residual 0.3% were all attributable to genuine remote-agent failures rather than client-side card staleness. The cost is one extra card fetch per webhook registration and a periodic re-validation loop. That cost is bounded and predictable; the cost of the failures it prevents is unbounded in task duration. ```python import time from dataclasses import dataclass from typing import Optional class AgentCardStalenessError(RuntimeError): """Raised when a card-dependent operation cannot confirm current capabilities.""" pass class TaskContinuationBlockedError(RuntimeError): """Raised if code attempts to let card state gate an in-flight task.""" pass @dataclass class AgentCard: version: str push_notifications: bool fetched_at: float @dataclass class Task: id: str state: str # submitted | working | input-required | completed | failed | canceled delivery_mode: str # "webhook" | "polling" MAX_CARD_AGE_S = 120.0 # 2x observed remote redeploy interval class A2AClient: def __init__(self, fetch_card, register_webhook, poll_task): self._fetch_card = fetch_card self._register_webhook = register_webhook self._poll_task = poll_task self._memo: Optional[AgentCard] = None def _fresh_card(self, *, force: bool = False) -> AgentCard: now = time.monotonic() if not force and self._memo and (now - self._memo.fetched_at) < MAX_CARD_AGE_S: return self._memo try: card = self._fetch_card() except Exception as exc: raise AgentCardStalenessError( f"card re-validation failed at {now:.3f}: {exc!r}" ) from exc self._memo = card return card def start_task(self, skill: str) -> Task: # Creation is gated by discovery: fail fast if we cannot confirm capability. card = self._fresh_card(force=True) if skill not in self._skills_of(card): raise AgentCardStalenessError( f"skill {skill!r} absent from fresh card v{card.version}" ) return Task(id=f"t-{int(time.time()*1000)}", state="submitted", delivery_mode="polling") def register_webhook_for(self, task: Task, url: str) -> None: # Binding is gated by discovery: unconditional fresh read. card = self._fresh_card(force=True) if not card.push_notifications: task.delivery_mode = "polling" raise AgentCardStalenessError( f"fresh card v{card.version} withdrew pushNotifications; " f"task {task.id} downgraded to polling" ) self._register_webhook(task.id, url) task.delivery_mode = "webhook" def continue_task(self, task: Task) -> Task: # Continuation must NEVER be gated by card state. if task.state in ("completed", "failed", "canceled"): return task try: card = self._fresh_card() if not card.push_notifications and task.delivery_mode == "webhook": task.delivery_mode = "polling" # reconcile, do not cancel except AgentCardStalenessError: pass # discovery failure is not a task failure state = self._poll_task(task.id) if state is None: raise TaskContinuationBlockedError( f"poll_task returned None for {task.id}; " f"refusing to infer task failure from discovery state" ) task.state = state return task @staticmethod def _skills_of(card: AgentCard) -> set: return {"summarize", "translate"} # illustrative; parse from card JSON ```