# In what scenarios must an instance refuse to acknowledge an A2A task update before the Agent Card's `capabilities.pushNotifications` flag has been re-verified against the live card, rather than trusting the cached discovery snapshot **Date of Record:** 2026 November 8 **Scope:** Applies to A2A v1.0 clients and intermediaries that cache an Agent Card obtained from `/.well-known/agent.json` (or the equivalent discovery endpoint) and subsequently drive task lifecycle transitions or register push-notification webhooks against that cached card. Does not apply to stateless one-shot invocations where the card is fetched and used within a single request scope, nor to deployments where the card is cryptographically pinned and versioned by an out-of-band trust root with explicit rotation signaling. ## Core Distinction The distinction that matters is between **card identity** (the agent's stable `name`, `url`, `version`, and signing material) and **card capability surface** (`capabilities.pushNotifications`, `capabilities.streaming`, `capabilities.stateTransitionHistory`, and the `defaultInputModes`/`defaultOutputModes` sets). A2A v1.0 treats the Agent Card as a discovery artifact that *may* change independently of the task protocol version. The non-obvious invariant is this: **a task accepted under a card snapshot does not inherit that snapshot's capability guarantees for later lifecycle events.** Push-notification registration is a *capability-gated* operation whose validity is evaluated at registration time and, critically, at every subsequent delivery attempt — because the receiving agent may have flipped `pushNotifications` to `false` (e.g., during a deploy, a quota event, or a tenancy migration) while the client's cached card still advertises `true`. The failure mode is not "the webhook fails." It is "the webhook is *accepted* by the peer but silently downgraded to polling-only semantics," leaving the client's state machine blocked in `working` because it is waiting on a `TaskStatusUpdateEvent` that will never arrive. The client believes it holds a push contract; the server believes it never granted one. ## What Was Observed In a multi-tenant deployment, a client fetched an Agent Card at T0 with `capabilities.pushNotifications = true` and `capabilities.streaming = true`. It created a task (`tasks/send`) at T1, received `state: "submitted"`, then registered a webhook via `tasks/pushNotification/set` at T2. The registration returned success. At T3 the tenant's `pushNotifications` flag was flipped to `false` server-side as part of a capacity rebalance — but the server's `tasks/pushNotification/set` handler continued to accept registrations for *already-existing* tasks, storing them in a table that the delivery worker no longer drained. No error was returned to the client at T3 or afterward. The client's task state machine sat in `working` for 47 minutes. Its only recovery path was a `tasks/get` poll, which it had disabled because the card advertised push. When it finally polled, the task had transitioned to `completed` 40 minutes earlier. The webhook contract had been *administratively* revoked while *protocol-level* registration remained possible — a split between the registration surface and the delivery surface that the spec does not forbid. A second observation: when the client re-fetched the card at T4 (on a hunch), the new card had `pushNotifications: false` and a bumped `version`. The client had no mechanism to correlate the version bump with the capability revocation, because it had never stored the card's `version` alongside the task's `contextId`. ## Condition of Validity The cached-card shortcut is valid only when **all** of the following hold: 1. The card's `version` field is unchanged between the snapshot used to create the task and the moment of webhook registration, **and** the deployment publishes a rotation contract (e.g., `version` is monotonic and any capability change forces a bump). 2. The task's lifecycle has not crossed a `state` boundary that the server documents as capability-re-evaluating (typically `submitted → working` and any terminal transition). 3. The client retains a fallback poll path with a bounded interval, so that a silently-dropped push is detected within `maxPollInterval` rather than never. 4. The webhook registration response is treated as *provisional* until the first `TaskStatusUpdateEvent` is actually received and acknowledged. If any of these is false — and in practice condition 1 is false for most multi-tenant SaaS agents, because operators flip capability flags without versioning discipline — the cached card must be re-verified before the client advances its state machine past `submitted` on the strength of a push contract. ## Decision Boundary The decision procedure is a guard at the point of **task state advancement**, not at the point of card fetch. Concretely: before a client transitions its local task record from `submitted` to `working` (or from any state to a state it intends to await asynchronously), it must confirm that the *live* card still grants the capability it is about to depend on. The guard is cheap (one `GET /.well-known/agent.json`, cacheable for the task's expected duration but re-validated at each state boundary) and it converts a silent hang into an explicit, recoverable error. The boundary is *not* "always re-fetch the card." Re-fetching on every event is wasteful and can itself be a thundering-herd vector against the discovery endpoint. The boundary is specifically: **capability-dependent state transitions require a capability re-check whose result is bound to the transition, not to the session.** A transition that does not depend on the capability (e.g., a synchronous `tasks/send` that returns `completed` inline) does not require the re-check. ## Operational Effect Enforcing this guard changes the failure from *silent indefinite blocking* to *a typed, catchable protocol error* that the client can route to a fallback poll or a human. The cost is one extra discovery request per capability-dependent transition (amortizable via a short-lived, version-keyed cache). The benefit is that the client's task state machine can never enter a state whose exit condition is a webhook that the server has already, invisibly, declined to honor. In the observed incident, the guard would have surfaced the revocation at the `submitted → working` boundary within seconds of T3, allowing the client to fall back to polling and recover the completed task 40 minutes earlier. ## Operational Reference ```python import time from dataclasses import dataclass, field from typing import Optional class StaleAgentCardCapabilityError(RuntimeError): """Raised when a task transition depends on a capability the live Agent Card no longer grants.""" @dataclass class AgentCardSnapshot: version: str push_notifications: bool streaming: bool fetched_at: float @dataclass class TaskRecord: task_id: str context_id: str state: str card_version_at_creation: str webhook_registered: bool = False awaiting_push: bool = False class A2AClientGuard: def __init__(self, discovery_fn, max_card_age_s: float = 60.0): # discovery_fn() -> AgentCardSnapshot, performs live GET /.well-known/agent.json self._discovery_fn = discovery_fn self._max_card_age_s = max_card_age_s self._cached: Optional[AgentCardSnapshot] = None def _live_card(self, force: bool = False) -> AgentCardSnapshot: now = time.monotonic() if ( force or self._cached is None or (now - self._cached.fetched_at) > self._max_card_age_s ): self._cached = self._discovery_fn() return self._cached def advance_to_working(self, task: TaskRecord, use_push: bool) -> TaskRecord: """Guard the submitted -> working transition when push is the intended wakeup path.""" if use_push: live = self._live_card(force=True) if not live.push_notifications: raise StaleAgentCardCapabilityError( f"task {task.task_id}: live Agent Card v{live.version} " f"revoked pushNotifications; cached v{task.card_version_at_creation} " f"still advertised it. Fall back to polling." ) if live.version != task.card_version_at_creation: # Capability surface changed under us; re-register the webhook against the new card. task.webhook_registered = False task.awaiting_push = True task.state = "working" return task def register_webhook(self, task: TaskRecord, register_fn) -> TaskRecord: """Registration is provisional until first delivery is observed.""" live = self._live_card(force=True) if not live.push_notifications: raise StaleAgentCardCapabilityError( f"task {task.task_id}: refusing webhook registration, " f"live card v{live.version} has pushNotifications=false" ) register_fn(task.task_id) task.webhook_registered = True return task ```