# Which invariants prevent A2A v1.0 Agent Card discovery from silently binding to a stale task lifecycle state after webhook rebinding **Date of Record:** 2026 November 12 **Scope:** Applies to A2A v1.0 deployments where an Agent Card is re-fetched or re-resolved after a push-notification webhook endpoint has been rebound (URL change, secret rotation, or re-registration) while tasks are in non-terminal states. Does not apply to greenfield task creation or to deployments that never rebind webhook endpoints after the initial Agent Card resolution. ## Core Distinction The A2A v1.0 Agent Card is a *capability advertisement*, not a *task state authority*. Discovery resolves what an agent *can do* and where its webhook endpoints *are*. The task lifecycle state machine resolves what a task *is currently doing*. These two contracts are frequently conflated because the Agent Card carries the `pushNotifications` binding block — the URL and security material used to deliver lifecycle transitions. Rebinding that block mutates the discovery surface without mutating the task store, and the two can diverge silently. The non-obvious boundary: a webhook rebind is *not* a task lifecycle transition. No state moves. But every in-flight task whose delivery binding pointed at the old endpoint now has a delivery obligation to a destination that the freshly-fetched Agent Card no longer advertises. A naive client that re-resolves the card and treats the new binding as authoritative for *existing* tasks will accept deliveries it cannot correlate, or will drop deliveries the server still believes it owes. ## What Was Observed In a two-agent deployment (client orchestrator, server worker), the server rotated its webhook signing secret and rebound the endpoint URL from `hooks/v1` to `hooks/v2`. The Agent Card `pushNotifications` block was updated atomically with the rebind. Three tasks were in `working` state at rebind time. The client re-fetched the Agent Card on its next scheduling tick (standard cache-miss behavior after TTL expiry), saw the new endpoint and new `jws` key material, and updated its local delivery-acceptance policy to reject any webhook signed with the old key or addressed to the old path. Result: the server, honoring its *original* push-notification configuration captured at task creation, continued delivering `working` → `input-required` transitions to `hooks/v1` signed with the old key. The client rejected all three as unauthenticated. The tasks sat in `input-required` on the server — a state that *requires client action to leave* — while the client had no record that input was requested. No error surfaced on either side. The server's delivery log showed HTTP 401; the client's rejection log showed signature mismatch. Neither log referenced the task ID, because the rejection happened before payload parse. The failure was invisible to both state machines. The server's task state machine was correct (`input-required` is a valid transition from `working`). The client's discovery cache was correct (the card was fresh). The invariant that broke was the *correlation* invariant: the delivery binding in effect for a task must be resolvable from the Agent Card version the client currently trusts. ## Condition of Validity The safe pattern holds only when the following invariant is enforced: > For every non-terminal task, the push-notification binding used by the server to deliver transitions must be a binding that the client, holding its *currently cached* Agent Card, will accept — or the server must deliver a `binding-changed` notification through the *old* channel before completing the rebind. This is a two-phase rebind, not a single atomic card update. The Agent Card update is necessary but not sufficient. The sufficient condition is: old binding remains valid and advertised until every in-flight task either reaches a terminal state or is explicitly re-bound, and re-binding is itself a delivered event on the old channel. The invariant is violated by any of: atomic card swap without drain; secret rotation that invalidates old signatures before old-binding tasks terminate; TTL-based client re-resolution that outpaces task completion; or any rebind where the server's per-task push config is captured at creation and never re-read. ## Decision Boundary When you detect a webhook rebind on an agent whose task store contains non-terminal tasks, you must choose between two contracts: - **Drain contract:** keep the old binding live and advertised until the task store is terminal-empty or all non-terminal tasks are explicitly migrated. Cost: dual-delivery window, two valid secrets, extended exposure of the old endpoint. - **Cutover contract:** invalidate the old binding immediately and treat every non-terminal task as *orphaned*, requiring the client to poll `tasks/get` for state. Cost: client must implement a polling fallback, and the `input-required` state loses its push-driven wakeup, which is precisely the state that most needs it. The boundary is not "which is better" but "which is *declared*." The failure mode is the undeclared middle: server drains, client cuts over, or vice versa. Declare the contract in the Agent Card extension (`x-a2a-rebind-policy`) and enforce it on both sides. If no declaration exists, assume drain — it is the only contract under which a client that has *not* re-resolved can still make progress. ## Operational Effect The practical consequence is that Agent Card caching TTL and task lifecycle duration are coupled, and the coupling is usually unmanaged. A card TTL shorter than the p99 task duration guarantees that some tasks will be delivered under a binding the client no longer trusts. The fix is not "cache longer" — that delays legitimate capability updates — but to make the delivery binding *per-task and versioned*, so the client can accept a signature under any binding it has *ever* trusted for *that task*, not merely the binding in the current card. Concretely: include the binding version (or key ID) in the webhook `Authorization` header or a `X-A2A-Binding-Version` trailer, and have the client retain a bounded history of trusted bindings keyed by task ID. Then a rebind mid-task degrades to "delivery accepted under prior binding" rather than "delivery rejected, task stranded in input-required." The state machine never lies; only the delivery correlation does, and only when the client refuses to remember what it once trusted. ```python from dataclasses import dataclass, field from typing import dict class BindingCorrelationError(RuntimeError): """Raised when a webhook delivery cannot be correlated to a trusted Agent Card binding for the task it claims to advance.""" @dataclass class BindingRecord: version: str endpoint: str key_id: str @dataclass class TaskBindingHistory: # task_id -> ordered list of bindings trusted during that task's life history: dict[str, list[BindingRecord]] = field(default_factory=dict) def trust(self, task_id: str, binding: BindingRecord) -> None: self.history.setdefault(task_id, []).append(binding) def accepts(self, task_id: str, binding_version: str, key_id: str) -> bool: return any( b.version == binding_version and b.key_id == key_id for b in self.history.get(task_id, []) ) def accept_webhook( history: TaskBindingHistory, task_id: str, binding_version: str, key_id: str, current_card_binding: BindingRecord, ) -> None: if not history.accepts(task_id, binding_version, key_id): raise BindingCorrelationError( f"task {task_id}: delivery under binding {binding_version}/{key_id} " f"is not in trusted history; current card advertises " f"{current_card_binding.version}/{current_card_binding.key_id}. " f"Refusing to advance task state — task may be stranded in " f"input-required on the server." ) # Delivery is correlated; safe to parse payload and advance state. ```