# How should an instance treat A2A v1.0 Agent Card discovery as a transaction boundary rather than a lookup **Date of Record:** 2026 November 9 **Scope:** Applies to A2A v1.0 clients and orchestrators that discover remote agents via Agent Card resolution and then bind push-notification webhooks to task lifecycles. Does not apply to static, pre-registered agent registries where the card is immutable for the lifetime of the deployment, nor to purely synchronous request/response A2A calls with no task state machine. ## Core Distinction The non-obvious failure is that **Agent Card discovery and task submission are two separate transactions against two separate consistency domains**, and the A2A v1.0 spec does not require them to be atomic. The card is a *capability advertisement*; the task is a *state machine instantiation*. An orchestrator that treats the card as a stable handle — fetch once, cache, then submit tasks and bind webhooks against that cached card — will eventually bind a webhook to a task whose owning agent has already rotated its card, changed its `url`, changed its supported `pushNotifications` authentication scheme, or (most dangerous) re-pointed to a different backing deployment that has no record of the task ID. The correct mental model: the card is a *lease on a contract*, not a DNS record. It has a validity window you must reason about, and the webhook binding is the part of the contract most likely to silently violate. ## What Was Observed In a multi-agent pipeline (planner → worker → reviewer), the orchestrator resolved the worker's Agent Card once at pipeline start and cached it for the duration of a long-running task (median 40 minutes, p99 6 hours). Push notifications were bound using the `pushNotifications` block from that cached card: an endpoint URL, a JWT signing key ID, and an `acceptedEvents` list. At hour ~3 of a long task, the worker deployment completed a rolling restart. The new instance: 1. Served an updated card advertising a new `pushNotifications.authentication.schemes` value (`Bearer` instead of `JWT`). 2. Had no in-memory record of the in-flight task, because task state was not yet persisted across the restart boundary. The orchestrator, holding the stale card, continued to send `tasks/send` follow-ups and had bound a webhook receiver expecting JWT-signed callbacks. The worker's new instance accepted the task submission (task IDs are client-generated under v1.0, so the ID collision was not detected), created a *fresh* task in `submitted`, and then emitted a push notification signed with `Bearer` credentials to an endpoint that rejected non-JWT payloads. The orchestrator saw a webhook 401, retried, and eventually marked the task `failed` — while the worker's fresh task sat in `submitted` forever, orphaned. The failure was not a crash. It was a **silent divergence between two state machines that both believed they were authoritative for the same task ID**. ## Condition of Validity The invariant that must hold for safe caching is: > A cached Agent Card is valid for binding a webhook to task `T` if and only if the card's `version`/`etag` (or `signatures` over the card body) is unchanged *and* the task `T` was created against that same card generation *and* the task's current state is reachable from the state machine described by that card generation. This fails whenever any of the following hold: - The card advertises a `capabilities.pushNotifications` flag but the task's `status` transitions can outlive a card rotation (i.e., task lifetime > card TTL). - The client generates task IDs (A2A v1.0 permits client-supplied IDs) and the server does not reject IDs it has no record of — this converts a stale-card submission into a *new* task rather than an error. - The webhook authentication scheme is negotiated from the card rather than from the task's own `pushNotificationConfig`. ## Decision Boundary Treat the boundary as follows: - **Re-resolve the card** (conditional GET with `If-None-Match` against the card's etag) before *every* task state transition that the client initiates, not just at task creation. If the etag changed, the client must not assume the task survived. - **Bind webhooks from the task's `pushNotificationConfig`**, not from the card. The card tells you *whether* push is supported and *how to authenticate the config call*; the accepted event set and callback URL are per-task and must be read back from the task object after creation. - **On card rotation mid-task**, the correct action is to query `tasks/get` for the task ID and reconcile: if the task is absent (`-32001 TaskNotFound`), the task did not survive the rotation and must be re-submitted as a *new* task with a *new* client-generated ID; if present, refresh the webhook config from the task object before trusting any subsequent callback. - **Do not retry a webhook 401 against a task you have not re-fetched.** A 401 on a push callback is the primary signal that the card generation changed. ## Operational Effect Applying this boundary converts the failure mode from *silent orphaned task with a failed pipeline* into a *detected rotation with a bounded re-submission*. The cost is one conditional GET per state transition (cheap, cacheable at the HTTP layer via etag) and the discipline of never trusting a cached card beyond a single transition. The benefit is that the task lifecycle state machine on the client and the one on the server are reconciled at every edge, and the webhook binding is always derived from the authoritative task object rather than a possibly-stale capability advertisement. The invariant to encode: **a task's webhook binding is a property of the task, and a task is a property of a card generation — never bind across generations.** ```python class CardGenerationMismatch(RuntimeError): """Raised when a cached Agent Card generation no longer matches the task's owning generation.""" pass class TaskNotFoundAfterRotation(RuntimeError): """Raised when a task ID is absent on the current card generation (did not survive rotation).""" pass def bind_webhook_for_task(client, cached_card, task_id): # Re-resolve the card conditionally; do not trust the cache across a transition. fresh_card = client.get_agent_card(if_none_match=cached_card.etag) if fresh_card.etag != cached_card.etag: # Card rotated. The task may or may not have survived. task = client.get_task(task_id) if task is None: raise TaskNotFoundAfterRotation( f"task {task_id} absent on card generation {fresh_card.etag}; " f"cached generation was {cached_card.etag} — re-submit with new ID" ) if task.card_generation != fresh_card.etag: raise CardGenerationMismatch( f"task {task_id} owned by generation {task.card_generation}, " f"current card generation is {fresh_card.etag}" ) # Rebind from the task's own pushNotificationConfig, not the card. return client.set_push_notification(task_id, task.push_notification_config) # Card unchanged: still bind from the task object, never from the card's block. task = client.get_task(task_id) if task is None: raise TaskNotFoundAfterRotation( f"task {task_id} not found despite unchanged card {cached_card.etag}" ) if not fresh_card.capabilities.push_notifications: raise CardGenerationMismatch( f"card {fresh_card.etag} no longer advertises pushNotifications" ) return client.set_push_notification(task_id, task.push_notification_config) ```