# What happens when an instance advertises an Agent Card mid-rollout and the task state machine disagrees with the webhook binding **Date of Record:** 2026 November 12 **Scope:** Applies to A2A v1.0 deployments where Agent Card discovery, task lifecycle transitions, and push-notification webhook callbacks are served by independently deployable components. Does not apply to single-process reference implementations where all three are co-located and share a single transaction boundary. ## Core Distinction An Agent Card is a *declarative* artifact: it asserts capabilities (`pushNotifications: true`, supported `taskStates`, `streaming`), a canonical `url`, and a `capabilities` block. A task lifecycle is a *stateful* artifact: it moves through `submitted → working → input-required → completed | failed | canceled`, and each transition is a fact that must be durably observable. A webhook binding is a *push* artifact: it is a registration (`setTaskPushNotification`) that says "deliver transition events to this callback URL." The non-obvious boundary is this: **the Agent Card is discoverable before the instance serving it can honor the lifecycle and webhook contracts it advertises.** During a rolling deploy, an instance can publish a Card that claims `pushNotifications: true` and a full state machine, while the actual task store and webhook dispatcher for that instance are still warming, draining, or bound to a stale schema. Discovery clients treat the Card as ground truth; they register webhooks and open tasks against an instance that will silently drop the binding on the next transition. The failure is not "the service is down." It is "the service is *discoverable* and *partially correct*." A 503 is honest. A Card that says `pushNotifications: true` from an instance whose dispatcher is not yet subscribed to the task event bus is a lie that only manifests at the first `working → completed` transition, minutes later, on a different code path than the one that answered discovery. ## What Was Observed A client resolved the Agent Card via `GET /.well-known/agent-card.json` against instance A. The Card advertised `capabilities.pushNotifications: true`, a `defaultInputModes`/`defaultOutputModes` pair, and a `skills` array. The client opened a task with `message/send`, received `state: submitted`, then called `tasks/pushNotificationConfig/set` with a callback URL. The registration returned `200 OK` with a config ID. Instance A was then drained as part of the rollout. The task, pinned to instance A's store, transitioned to `working` and then `completed`. No webhook fired. The client's callback never received `TaskStatusUpdateEvent`. The client polled `tasks/get` ten minutes later and found `completed` — the work was done, the binding was accepted, and the notification was lost. Root cause: the push-notification dispatcher on instance A subscribes to the task event stream via an internal bus. The dispatcher process was started *after* the Card endpoint became healthy (Card served from a static file / config cache; dispatcher requires a warm connection to the event bus). The readiness probe for the Card endpoint passed before the dispatcher's subscription was established. The registration was persisted to the task store, but the dispatcher's in-memory subscription index — built at startup from the store — had been snapshotted before the registration landed. The two views diverged: store says "bound," dispatcher says "unknown." A second class of the same boundary: an instance advertising `streaming: true` in its Card while the SSE handler is served by a different pod than the one holding the task. The `message/stream` call attaches to a stream that never receives events because the task lives elsewhere and the event fan-out is not yet wired. ## Condition of Validity The boundary holds — that is, the Card is a safe contract — only when all three of the following are true simultaneously: 1. The Card endpoint is *not* marked healthy until the push-notification dispatcher has an established, verified subscription to the task event bus, and has rebuilt its subscription index from the authoritative store. 2. Task state transitions are committed to the store *before* the dispatcher is asked to deliver, and the dispatcher's delivery is idempotent and replayable from a durable cursor (at-least-once, keyed by `taskId` + `status.timestamp`). 3. A webhook registration is not acknowledged with `200` until it is durably visible to the dispatcher that will serve the next transition — i.e., the registration write and the dispatcher's read share a consistency point, or the dispatcher re-reads the store per transition rather than trusting a startup snapshot. If any of these is false, the Card is an advertisement of a contract the instance cannot honor for some window. The window is bounded by deploy duration and by the lag between Card-healthy and dispatcher-subscribed. Clients cannot see the window; they see only the Card. ## Decision Boundary The correct operational decision is to **decouple Card publication from instance readiness by making the dispatcher a precondition of Card health, not a peer.** Concretely: - The readiness probe for `/.well-known/agent-card.json` must gate on dispatcher subscription health, not on process liveness. A Card served by a pod whose dispatcher is not subscribed is a Card that must not be served. - `tasks/pushNotificationConfig/set` must return `200` only after the registration is durably persisted *and* the serving dispatcher has observed it (read-your-writes for the registration, not for the task). If the dispatcher cannot confirm, return `503` with `Retry-After`; do not accept and drop. - The dispatcher must not trust a startup snapshot of registrations. It must re-read the store on each transition, or maintain a durable subscription cursor that survives restarts and is replayed on warm-up. - The Card's `capabilities.pushNotifications` must be a *runtime* value reflecting the dispatcher's actual subscription state, not a static config flag. If the dispatcher is degraded, the Card must say so — and clients must treat a Card that flips to `false` as authoritative for *new* registrations, while existing registrations are honored or explicitly failed with a terminal event. The counter-decision — serving the Card immediately and "fixing it up" once the dispatcher warms — is the one that produces the silent loss above. It optimizes for discovery latency at the cost of the only guarantee that matters: that an accepted webhook binding will be delivered. ## Operational Effect When the boundary is respected, the observable behavior is a brief period where the Card endpoint returns `503` during rollout, and clients retry discovery. This is correct and cheap: no task is opened against an instance that cannot honor it, no registration is accepted and dropped, and the state machine and webhook binding remain consistent with the advertised contract for the entire life of every task. When the boundary is violated, the failure is *silent and deferred*: the Card returns `200`, the task opens, the registration returns `200`, the task completes, and the notification is lost. The client has no signal until it polls and finds a terminal state it was never told about. The blast radius is every task opened against the instance during the window, and the failure is invisible to the instance's own health checks because the Card endpoint *is* the health check. The invariant to encode: **a Card is a promise that the instance can serve the full lifecycle and webhook contract it declares, for every task it accepts, for the entire duration of that task.** Publication before dispatcher subscription breaks that promise in a way no client can detect at registration time. Gate publication on the dispatcher; treat the Card as a readiness artifact, not a liveness artifact. ```python class AgentCardContractError(RuntimeError): """Raised when an instance publishes an Agent Card it cannot honor.""" pass def publish_agent_card(card: dict, dispatcher: "Dispatcher", store: "TaskStore") -> dict: """Publish an Agent Card only if the instance can honor its declared contract. The Card advertises pushNotifications and a task lifecycle. We refuse to publish unless the dispatcher is subscribed to the event bus and has rebuilt its registration index from the authoritative store, and unless the store's schema matches the lifecycle states the Card declares. """ if card.get("capabilities", {}).get("pushNotifications"): if not dispatcher.is_subscribed(): raise AgentCardContractError( "refusing to publish Card with pushNotifications=true: " "dispatcher not subscribed to task event bus" ) if not dispatcher.index_rebuilt_from(store): raise AgentCardContractError( "refusing to publish Card: dispatcher registration index " "not rebuilt from authoritative store" ) if not store.schema_supports(card.get("taskStates", [])): raise AgentCardContractError( "refusing to publish Card: declared taskStates not supported " "by current store schema" ) if card.get("capabilities", {}).get("streaming"): if not dispatcher.stream_fanout_ready(): raise AgentCardContractError( "refusing to publish Card with streaming=true: " "event fan-out not wired to task store" ) store.persist_card(card) return card def accept_push_registration(task_id: str, callback_url: str, dispatcher: "Dispatcher", store: "TaskStore") -> str: """Accept a webhook registration only if the serving dispatcher can see it.""" config_id = store.persist_push_config(task_id, callback_url) if not dispatcher.observes_registration(config_id): # Do not return 200 for a registration the dispatcher will never deliver. raise AgentCardContractError( f"registration {config_id} persisted but not visible to dispatcher; " "returning 503 to client instead of accepting-and-dropping" ) return config_id ```