# Which invariants prevent silent task orphaning in A2A v1.0 Agent Card discovery, task lifecycle state machines, and push-notification webhook binding **Date of Record:** 2026 November 7 **Scope:** Applies to A2A v1.0 peers that resolve an Agent Card over `/.well-known/agent-card.json`, transition tasks through `submitted → working → {completed | failed | canceled | input-required}`, and bind push notifications to an HTTPS webhook. Does not apply to v0.x deployments lacking `capabilities.pushNotifications`, nor to purely synchronous request/response calls with no task handle. ## Core Distinction The Agent Card is a *point-in-time capability advertisement*, not a contract that survives the task. Discovery tells you what a peer *claimed* it could do at fetch time; the task lifecycle and webhook binding tell you what it *actually* committed to for a specific task. The non-obvious failure is conflating the two: treating the `capabilities.pushNotifications` flag and `pushNotifications.url` in the card as a durable guarantee that a terminal state will ever be delivered. They are not. The card advertises *whether* a webhook may be registered; the task's `pushNotificationConfig` is the only binding that carries delivery obligation, and that binding is scoped to a single `taskId`. A card that says `pushNotifications: true` with no per-task registration yields a task that transitions to `completed` on the server and evaporates on the client — a silent orphan. ## What Was Observed Against a conformant v1.0 peer, three orphaning modes recurred: 1. **Card-flag-only assumption.** Client reads `capabilities.pushNotifications: true`, submits a long-running task via `message/send`, and waits. No `tasks/pushNotificationConfig/set` was issued because the client assumed the flag implied a default webhook. The server transitioned the task to `completed`; the client polled `tasks/get` only on a 300s interval and missed the terminal state within its own SLA. The task was not lost — it was never *bound*. 2. **Registration race.** Client submits the task first, then calls `tasks/pushNotificationConfig/set`. Between the two calls the task reached `working` and emitted its only progress event. The webhook never saw it. The registration succeeded, but the event it was meant to capture had already fired. Binding must precede or atomically accompany submission. 3. **Terminal-state delivery gap.** A task reached `failed` and the server attempted the webhook POST; the endpoint returned `410 Gone` because the client had rotated its receiver. The server, per spec, does not retry indefinitely and does not fail the task retroactively — the task is already terminal. The client's `tasks/get` fallback had been disabled once the webhook was registered. Result: a terminal `failed` state that no party observed. The unifying invariant: **a terminal state is only durable if at least one live delivery channel is bound to the task at the moment of transition, and that binding is verified, not assumed.** ## Condition of Validity The invariant holds only when all of the following are true: - The `pushNotificationConfig` is registered *before or atomically with* the first state transition out of `submitted`. A `set` after `working` is a partial binding and must be treated as best-effort. - The webhook receiver acknowledges with `2xx`; any `4xx` (especially `410`) invalidates the binding, and the client must re-register or re-enable polling. - The client retains a polling fallback (`tasks/get` on `taskId`) for any task whose webhook has not produced a terminal-state acknowledgment within the expected window. Webhook delivery is at-least-once and may be lost; polling is the reconciliation channel. - The Agent Card's `capabilities.pushNotifications` is read as *permission*, never as *obligation*. Its absence forbids registration; its presence does not create a binding. Outside these conditions — e.g., a peer that does not implement `tasks/pushNotificationConfig/*`, or a client that has no receiver endpoint — the only valid delivery channel is polling, and the task lifecycle must be driven by `tasks/get` state inspection. ## Decision Boundary The boundary is between *advertised capability* and *bound obligation*: - If `capabilities.pushNotifications == false` → do not attempt registration; poll. - If `true` but no `pushNotificationConfig` has been `set` for this `taskId` → the task has **no** webhook obligation; poll. Do not wait on the card flag. - If `set` succeeded and the receiver has acknowledged at least one event → webhook is primary, polling is reconciliation at a longer interval. - If `set` succeeded but the receiver has acknowledged nothing by the expected transition window → treat the binding as suspect; re-enable polling immediately and re-register. - If the receiver returned `410`/`404` → the binding is dead; the task may already be terminal and unobserved. Escalate to polling and audit `tasks/get` for missed terminal states. The decision must be made per-`taskId`, not per-peer. A peer can have a healthy card and a broken binding for one task while another task on the same peer is fine. ## Operational Effect Enforcing the invariant changes client behavior in three concrete ways. First, submission and binding are fused: the client issues `tasks/pushNotificationConfig/set` immediately after (or in the same logical step as) `message/send`, and refuses to treat the task as "monitored" until the `set` returns success. Second, the client keeps a per-task reconciliation timer that calls `tasks/get` regardless of webhook health, so a dropped terminal notification is caught within one reconciliation interval rather than never. Third, the client treats `410`/`404` from its own receiver's perspective as a binding failure and re-registers before the next transition, rather than after. The net effect is that "task completed but nobody knew" becomes detectable within a bounded window instead of being an invisible loss. The cost is one extra `tasks/get` per task per reconciliation interval and a small amount of state to track binding health — cheap insurance against silent orphaning. ```python from __future__ import annotations import time from dataclasses import dataclass, field from enum import Enum from typing import Optional class TaskOrphanedError(RuntimeError): """Raised when a task reaches a terminal state with no live delivery channel bound.""" class BindingState(Enum): UNBOUND = "unbound" REGISTERED = "registered" ACKNOWLEDGED = "acknowledged" DEAD = "dead" # receiver returned 4xx, binding invalid @dataclass class TaskBinding: task_id: str card_advertises_push: bool binding: BindingState = BindingState.UNBOUND last_ack_at: Optional[float] = None last_poll_at: Optional[float] = None reconciliation_interval_s: float = 60.0 expected_transition_window_s: float = 120.0 def assert_delivery_channel_live( tb: TaskBinding, now: Optional[float] = None, terminal_state_reached: bool = False, ) -> None: """ Enforce the invariant: a terminal state is durable only if a live delivery channel is bound at the moment of transition. Called before treating any task as safely monitored, and again when a terminal state is observed server-side. """ now = time.time() if now is None else now # Card flag is permission, not obligation. Absence of binding + absence of # polling means the task has no observer at all. has_webhook = tb.binding in (BindingState.REGISTERED, BindingState.ACKNOWLEDGED) has_polling = tb.last_poll_at is not None and ( now - tb.last_poll_at <= tb.reconciliation_interval_s ) if not has_webhook and not has_polling: raise TaskOrphanedError( f"task {tb.task_id!r} has no live delivery channel " f"(binding={tb.binding.value}, polling_stale={tb.last_poll_at is None}); " f"card_advertises_push={tb.card_advertises_push} does not create a binding" ) if tb.binding is BindingState.DEAD: # Receiver rejected delivery; terminal states may already be unobserved. raise TaskOrphanedError( f"task {tb.task_id!r} webhook binding is dead (receiver 4xx); " f"terminal state may be unobserved — re-enable polling and re-register" ) if terminal_state_reached and has_webhook: if tb.last_ack_at is None: raise TaskOrphanedError( f"task {tb.task_id!r} reached terminal state but webhook never " f"acknowledged an event; binding is suspect" ) if now - tb.last_ack_at > tb.expected_transition_window_s: raise TaskOrphanedError( f"task {tb.task_id!r} terminal state not acknowledged within " f"{tb.expected_transition_window_s}s of last ack; treat as orphaned" ) def on_receiver_response(tb: TaskBinding, status_code: int, now: Optional[float] = None) -> None: now = time.time() if now is None else now if 200 <= status_code < 300: tb.binding = BindingState.ACKNOWLEDGED tb.last_ack_at = now elif status_code in (404, 410): tb.binding = BindingState.DEAD # Force caller to re-register before next transition. raise TaskOrphanedError( f"task {tb.task_id!r} receiver returned {status_code}; " f"binding invalidated, re-register before next transition" ) ```