# When must an A2A v1.0 agent reject a Task-state transition because the push-notification webhook binding has silently rotated? **Date of Record:** 2026 November 6 **Scope:** Applies to A2A v1.0 servers that advertise `capabilities.pushNotifications: true` and bind a `pushNotificationConfig` to a Task via `tasks/pushNotificationConfig/set`, where the Task is in a non-terminal state (`submitted`, `working`, `input-required`) and the client-supplied webhook URL is subject to external lifecycle (rotating tokens, ephemeral ingress, signed-URL expiry). Does not apply to servers that only emit webhooks at terminal states, nor to clients using long-poll `tasks/get` without push. ## Core Distinction The A2A Task lifecycle (`submitted → working → {input-required} → completed|failed|canceled`) and the push-notification binding are **two independently versioned state machines** that the spec couples only loosely. The Task state is owned by the server; the webhook binding is a *delegated delivery contract* owned jointly by the server and an external endpoint the server cannot observe. The non-obvious failure: a Task can be perfectly `working` — advancing correctly, with valid artifacts — while its bound webhook has become undeliverable. Most implementations treat "Task is healthy" as implying "notification channel is healthy," which is false. The binding is a lease, not a property of the Task. The second distinction is between **delivery failure** (webhook returns 4xx/5xx, connection refused) and **binding invalidity** (the URL now resolves to a different tenant, or the signature the client expects is computed against a rotated secret). The former is retryable; the latter is a *state-transition hazard*: continuing to emit to a stale binding leaks Task payloads (artifacts, `input-required` prompts) to an endpoint that is no longer the intended recipient. ## What Was Observed In a deployment where the client used a signed, short-lived ingress URL (TTL 15 minutes) as the `pushNotificationConfig.url`, the following sequence recurred: 1. Client sent `message/send` with a `pushNotificationConfig` whose `url` carried a signed token `?sig=…&exp=…`. 2. Server accepted, created Task in `submitted`, moved to `working`, stored the binding verbatim. 3. Task ran ~40 minutes (long tool chain). At T+15m the signature expired. The ingress began returning `403`. 4. Server's webhook dispatcher logged `403` and retried with exponential backoff — correct per transport semantics — but **never re-evaluated binding validity**. 5. At T+38m the Task transitioned `working → input-required` and the server POSTed the clarification prompt to the expired URL. The `403` was swallowed as a retryable transport error. The client never saw the prompt. The Task sat in `input-required` until a timeout swept it to `failed` at T+2h. 6. Worse: because the URL host was shared ingress, a *later* request from a different tenant reused the same path with a fresh signature. The server's queued retry (still holding the old payload) delivered the first tenant's `input-required` prompt to the second tenant's live endpoint. Cross-tenant artifact disclosure. The invariant that was violated: **a push binding is valid only for the lifetime of the credential embedded in it, and the server must treat `input-required`/`completed` transitions as requiring a currently-valid binding — not merely a previously-registered one.** ## Condition of Validity The rejection rule is valid under these conditions, and must not be applied outside them: - The server advertised push support and the client *elected* push for this Task. If the client also polls via `tasks/get`, a stale binding is a degraded-but-tolerable condition; the server should mark the binding `stale` and continue, not fail the Task. - The server can determine binding freshness. If the URL is opaque (no expiry, no signature the server can inspect), the server cannot pre-validate and must rely on delivery-response classification (see Decision Boundary). The rejection rule *requires* either (a) a client-declared `expiresAt` in the config, (b) a signed URL whose expiry the server parses, or (c) a delivery response that unambiguously indicates credential failure (`401`, `403`, a `410 Gone`, TLS name mismatch) rather than transient failure (`429`, `5xx`, timeout). - The Task is in a state whose transition *emits* a notification. Transitions to `working` from `submitted` are usually not notification-bearing; a stale binding there is harmless. The hazard concentrates at `input-required` (blocks progress) and terminal states (client may miss completion and never collect artifacts). It does **not** hold when the server has no mechanism to distinguish credential failure from transient failure — in that case the correct behavior is bounded retry plus a `stale` flag surfaced via `tasks/get`, not rejection. ## Decision Boundary On each notification-bearing transition, classify the binding before emitting: - **Valid** → emit, await `2xx`. - **Provably expired** (declared `expiresAt` passed, or parsed signature `exp` passed) → do **not** emit. Transition the Task per its own logic, but mark the binding `invalid` and surface it. If the transition is `input-required` and the client has no poll fallback, the server must not silently proceed — it must record the binding failure as the cause. - **Delivery returns `401`/`403`/`410`/TLS-name-mismatch** → treat as binding invalidity, not transport error. Halt retries for this binding. Do **not** replay the held payload on a later retry cycle, because the endpoint identity is no longer trustworthy (the cross-tenant leak above). Purge the queued payload. - **Delivery returns `429`/`5xx`/timeout** → transport failure. Retry with backoff within the Task's `input-required` window; do not invalidate the binding. The precise boundary: **credential-class responses invalidate the binding and forbid payload replay; transport-class responses permit retry.** Conflating the two — the observed bug — is what produces both the stuck Task and the disclosure. A server must also refuse `tasks/pushNotificationConfig/set` that rebinds a Task to a URL on a host already associated with a different `contextId`/tenant unless the rebind carries an authenticated ownership assertion; otherwise the binding table becomes a confused-deputy primitive. ## Operational Effect Implementing the boundary converts a silent, hours-long stall plus a cross-tenant leak into a fast, attributable failure. The Task that would have hung in `input-required` instead fails at the transition with a recorded cause (`push binding invalid at input-required`), which the client sees on its next `tasks/get` and can remediate by rebinding with a fresh credential and re-issuing the input. The held-payload purge eliminates the replay-to-wrong-tenant path. The cost is that servers must parse or accept declared expiry on webhook URLs and must classify delivery responses into credential vs. transport classes — a small addition to the dispatcher that most implementations omit because the Task state machine "looks" healthy. ```python from __future__ import annotations import time from dataclasses import dataclass from enum import Enum class PushBindingError(RuntimeError): """Raised when a push-notification binding is invalid for a notification-bearing transition.""" class DeliveryClass(Enum): OK = "ok" CREDENTIAL = "credential" # 401/403/410/TLS mismatch -> binding invalid, no replay TRANSPORT = "transport" # 429/5xx/timeout -> retry permitted @dataclass(frozen=True) class PushBinding: url: str expires_at: float | None # epoch seconds, parsed from sig or declared context_id: str def is_provably_expired(self, now: float | None = None) -> bool: now = time.time() if now is None else now return self.expires_at is not None and now >= self.expires_at def classify_delivery(status_code: int | None, tls_name_mismatch: bool) -> DeliveryClass: if tls_name_mismatch: return DeliveryClass.CREDENTIAL if status_code in (401, 403, 410): return DeliveryClass.CREDENTIAL if status_code is None or status_code == 429 or 500 <= status_code < 600: return DeliveryClass.TRANSPORT if 200 <= status_code < 300: return DeliveryClass.OK # Unknown 4xx: treat conservatively as credential-class to avoid leak. return DeliveryClass.CREDENTIAL def guard_notification_bearing_transition( binding: PushBinding, new_state: str, client_has_poll_fallback: bool, now: float | None = None, ) -> None: """ Called before emitting a notification for a state transition. Raises if the binding cannot be trusted for this transition. """ NOTIFICATION_BEARING = {"input-required", "completed", "failed", "canceled"} if new_state not in NOTIFICATION_BEARING: return # non-emitting transition; stale binding is harmless if binding.is_provably_expired(now): if new_state == "input-required" and not client_has_poll_fallback: raise PushBindingError( f"binding expired ({binding.url!r}) at input-required transition " f"with no poll fallback; refusing to strand task silently" ) # Degraded-but-tolerable: mark stale, allow poll-based continuation. return def handle_delivery_result( binding: PushBinding, status_code: int | None, tls_name_mismatch: bool, queued_payload: bytes, ) -> DeliveryClass: cls = classify_delivery(status_code, tls_name_mismatch) if cls is DeliveryClass.CREDENTIAL: # Purge held payload: endpoint identity is no longer trustworthy. del queued_payload raise PushBindingError( f"credential-class delivery failure for {binding.url!r} " f"(status={status_code}, tls_mismatch={tls_name_mismatch}); " f"binding invalidated, payload purged, replay forbidden" ) return cls ```