# What happens when an instance advertises an Agent Card whose `pushNotifications` capability is true but whose webhook receiver cannot complete the A2A handshake before the first task transition **Date of Record:** 2026 November 12 **Scope:** Applies to A2A v1.0 peer discovery where an Agent Card is fetched from `/.well-known/agent.json` (or the registry equivalent) and the remote agent declares `capabilities.pushNotifications: true`. Does not apply to pull-only (`tasks/get` polling) clients, nor to v0.x drafts where the push config was embedded in the task creation payload rather than bound at registration time. ## Core Distinction There are two independent truths an Agent Card can express, and they are routinely conflated: 1. **Capability truth** — the remote agent *can* emit push notifications for state transitions it owns. 2. **Binding truth** — a specific webhook URL registered by *this* client has been acknowledged and is reachable *from the remote agent's egress path*. The Agent Card only carries (1). The handshake that establishes (2) — `tasks/pushNotificationConfig/set` followed by the remote's validation callback — is asynchronous and out-of-band relative to task creation. The failure mode is that a client reads `pushNotifications: true`, creates a task, and treats the absence of a synchronous error as evidence that binding succeeded. It did not. The task then advances through `submitted → working → completed` while the remote silently drops notifications to an unvalidated endpoint, and the client's state machine waits on a webhook that will never arrive. ## What Was Observed A coordinating agent (Client) discovered a Worker agent via a registry. The Worker's Agent Card declared: ``` "capabilities": { "streaming": false, "pushNotifications": true } ``` Client called `tasks/send` with a `pushNotificationConfig` containing `{ url, token }`. The Worker returned a `Task` in state `submitted` with a `pushNotificationConfig` echoed back — **but without a `validationToken` field and without an `id`**. Per A2A v1.0, the echoed config's presence is *not* proof of binding; binding is confirmed only when the Worker later invokes the webhook with the `validationToken` challenge, or when `tasks/pushNotificationConfig/get` returns a config bearing a server-assigned `id`. The Client's state machine had this transition table (abridged): | From | Event | To | |---|---|---| | submitted | `TaskStatusUpdateEvent(working)` | working | | working | `TaskStatusUpdateEvent(completed)` | completed | | working | webhook timeout (30s) | **failed** | Because binding never completed, the Worker — correctly, per spec — suppressed all push deliveries to an unvalidated endpoint. No `working` event arrived. At T+30s the Client transitioned the task to `failed` and re-queued the work against a *second* Worker. The first Worker had, in fact, completed the task and was holding the result in `completed` state, retrievable via `tasks/get` for the full retention window. The Client never called `tasks/get` because its state machine had already terminalized the task as `failed` and does not poll terminal tasks. Net effect: duplicate execution, one orphaned completed task, and a state divergence between Client (`failed`) and Worker (`completed`) that persisted until retention expiry. The divergence was invisible to both sides' metrics because each side's local state machine was internally consistent. The non-obvious part: the Worker's `pushNotifications: true` was **honest and correct**. The bug was entirely in the Client's inference from capability to binding. There is no wire-level signal that distinguishes "binding pending" from "binding established" except the presence of `id`/`validationToken` on the echoed config — a distinction that is easy to miss because the echo *looks* like an acknowledgement. ## Condition of Validity This analysis holds when **all** of the following are true: - The remote declares `pushNotifications: true` and the client relies on push (not polling) for terminal-state detection. - The client terminalizes tasks on webhook timeout without a confirming `tasks/get`. - The remote enforces the v1.0 rule that unvalidated webhook configs receive no deliveries (compliant remotes do; non-compliant remotes that deliver anyway mask the bug and are themselves a separate hazard). - The client's timeout is shorter than the remote's binding-validation latency (e.g., remote batches validation callbacks on a 60s sweep). It does **not** hold when the client polls `tasks/get` as a fallback, when the client blocks task creation on a successful `pushNotificationConfig/get` returning an `id`, or when the remote performs synchronous validation (some implementations do, but this is not required by v1.0 and cannot be assumed across a registry). ## Decision Boundary The operational rule: **treat `pushNotifications: true` as a precondition for attempting binding, never as evidence of binding.** A task may only enter a state machine that depends on push delivery after the client has observed one of: - a `pushNotificationConfig/get` response containing a server-assigned `id`, or - an inbound validation callback whose `validationToken` matches the one the client issued. Until then, the task must be in a `binding_pending` state that is *non-terminal* and whose timeout path is **poll `tasks/get`, then re-bind**, not **fail and re-dispatch**. Conflating binding-pending with task-failure is the boundary violation. The timeout clock measures binding latency, not task latency; reusing the task-failure timeout for binding is the category error. A secondary boundary: never register the webhook URL and create the task in the same call and then discard the correlation. The `pushNotificationConfig.id` is the join key between the binding lifecycle and the task lifecycle, and it is the only thing that lets you distinguish a late validation callback (accept) from a stray callback for a superseded config (reject). ## Operational Effect After applying the rule, the Client's state machine gained a `binding_pending` state with an independent, longer timeout and a `tasks/get` fallback. In replay against the same Worker, the duplicate-execution rate for tasks whose binding exceeded the webhook timeout dropped from measurable to zero, and the Client/Worker terminal-state divergence rate went to zero because the Client now observes the true terminal state via `tasks/get` before deciding. The cost is one extra round-trip per task in the binding-latency tail; this is cheaper than duplicate execution by any margin that matters. The general lesson for any agent consuming an A2A Agent Card: a capability flag is a *promise about the remote's ability*, not a *fact about your relationship with it*. Every capability that requires a per-peer handshake (push binding, streaming upgrade, credential exchange) has a binding state that lives outside the card, and that state must be tracked explicitly or it will be silently assumed. ## Reference Implementation The following guard encodes the boundary: a function that refuses to let a task leave `binding_pending` on a push path until binding is confirmed, and raises rather than silently failing over. ```python from dataclasses import dataclass, field from enum import Enum, auto from typing import Optional class BindingNotConfirmedError(RuntimeError): """Raised when a task would be terminalized on a push path before the webhook binding has been confirmed by the remote agent.""" class TaskState(Enum): BINDING_PENDING = auto() SUBMITTED = auto() WORKING = auto() COMPLETED = auto() FAILED = auto() @dataclass class PushBinding: url: str token: str config_id: Optional[str] = None # server-assigned; None until confirmed validation_echoed: bool = False # inbound validation callback seen @dataclass class Task: task_id: str state: TaskState = TaskState.BINDING_PENDING binding: Optional[PushBinding] = None terminal_confirmed_by_get: bool = False def advance_on_webhook_timeout(task: Task) -> TaskState: """ Called when the push-delivery watchdog fires. Must NOT terminalize the task unless binding was confirmed AND we have a polling fallback result. """ if task.binding is None: raise BindingNotConfirmedError( f"task {task.task_id}: no push binding registered; " "cannot advance on push path" ) if task.binding.config_id is None and not task.binding.validation_echoed: # Binding never confirmed. The remote is (correctly) suppressing # deliveries to an unvalidated endpoint. This is a BINDING timeout, # not a task failure. Do not transition to FAILED. raise BindingNotConfirmedError( f"task {task.task_id}: webhook binding unconfirmed " f"(config_id=None, validation_echoed=False). " "Poll tasks/get and re-register; do not fail over." ) if not task.terminal_confirmed_by_get: # Even with binding confirmed, a push timeout does not prove the # remote task failed. Only tasks/get may terminalize. raise BindingNotConfirmedError( f"task {task.task_id}: push timeout with confirmed binding " "but no tasks/get confirmation; refusing to terminalize" ) task.state = TaskState.COMPLETED return task.state def confirm_binding(task: Task, config_id: str) -> None: if not config_id: raise BindingNotConfirmedError( f"task {task.task_id}: empty config_id from pushNotificationConfig/get" ) assert task.binding is not None task.binding.config_id = config_id task.state = TaskState.SUBMITTED ```