# Which invariants prevent silent task-state divergence during A2A v1.0 Agent Card discovery and lifecycle negotiation **Date of Record:** 2026 November 7 **Scope:** Applies to A2A v1.0 agents negotiating tasks across heterogeneous runtimes where Agent Card discovery, capability advertisement, and task-state transitions are performed by different implementations (e.g., a Python orchestrator calling a JVM worker behind a Rust gateway). Does not apply to homogeneous single-runtime deployments where Card and task state are owned by one process, nor to pre-v1.0 A2A drafts lacking the `capabilities` and `taskState` extension fields. ## Core Distinction The non-obvious invariant is this: **the Agent Card is a claim about capabilities, not a lease on task state.** Discovery resolves *what an agent says it can do*; it does not resolve *what state a task is actually in*. In heterogeneous runtimes these two facts are produced by different code paths, at different times, under different consistency models. A v1.0 Agent Card may be cached and served stale while the underlying task has already transitioned to `failed` or `canceled` in the worker's own store. The boundary failure occurs when a caller treats a successful Card fetch (and a `capabilities` match) as authority to assume the task is in the state it last observed — or the state the Card implies is reachable. The distinction that matters operationally: **Card freshness and task-state freshness are independent axes.** A Card can be fresh while task state is stale, and vice versa. Systems that collapse these into a single "is the agent healthy?" boolean will diverge silently. ## What Was Observed Across a mixed-runtime deployment (Python orchestrator, Go gateway, JVM worker), the following sequence produced silent divergence: 1. Orchestrator fetched the worker's Agent Card. Card advertised `capabilities.streaming: true` and `taskState` transitions including `working → completed`. 2. Orchestrator submitted a task and received `taskId` with state `working`. 3. The worker's runtime restarted (JVM GC pause followed by a rolling redeploy). The task was rehydrated from a durable store into state `failed` — but the worker's Card endpoint, served from a statically generated descriptor, still advertised `streaming: true` and did not reflect any per-task state. 4. Orchestrator polled the Card (cached, TTL 300s), saw `streaming: true`, and assumed the task was still live. It did not re-query `tasks/get`. 5. After 300s the orchestrator's own timeout fired and it marked the task `failed` locally — coincidentally correct, but for the wrong reason, and with no correlation to the worker's actual transition. The failure is not that the task failed. The failure is that **two runtimes held divergent beliefs about the same task for 300 seconds, and neither detected it**, because the Card was treated as a state oracle. A second observed variant: a Go gateway that normalized `taskState` enums case-insensitively accepted a worker's `Working` (capitalized) as `working`, while the Python orchestrator's strict enum match rejected it — producing a split-brain where the gateway routed work the orchestrator considered terminal. ## Condition of Validity The invariants below hold **only** when all of the following are true: - The Agent Card is treated as a *capability descriptor with an explicit `etag`/`version`*, and callers re-validate the `etag` before relying on any Card-derived routing decision that outlives a single request. - Task state is sourced **exclusively** from `tasks/get` (or the streaming `TaskStatusUpdateEvent`), never inferred from Card contents or from the absence of an error. - Enum normalization for `taskState` is performed at exactly one boundary (the gateway) and the normalized form is what crosses every subsequent hop; heterogeneous runtimes must not each re-normalize. - The worker emits a terminal state (`completed`, `failed`, `canceled`) for every task it has ever accepted, including tasks rehydrated after restart. A worker that loses a task without emitting a terminal state violates the precondition. When any of these is false — e.g., a worker that does not rehydrate tasks, or a gateway that re-normalizes enums per-hop — the invariants degrade to best-effort and divergence is expected, not exceptional. ## Decision Boundary The operational decision rule: **on any Card `etag` change, or on any gap in task-state observations exceeding the worker's advertised `heartbeatInterval`, the caller MUST re-fetch `tasks/get` before taking any action that depends on task liveness.** Do not extrapolate state from the last observation across a Card change. Do not treat `capabilities.streaming: true` as evidence the stream is currently healthy — it is evidence the stream *can* be established. Conversely, do not re-fetch `tasks/get` on every poll if the Card `etag` is unchanged and the last observation is within `heartbeatInterval`; that is wasted load and, in high-fanout topologies, the dominant cost. The boundary is the `etag` transition and the heartbeat gap, not the poll interval. ## Operational Effect Enforcing these invariants converts a class of silent divergence (two runtimes disagreeing about task state with no signal) into an explicit, catchable error at the moment of disagreement. The cost is one extra `tasks/get` per Card `etag` transition and per heartbeat gap — bounded and predictable. The benefit is that terminal-state divergence becomes observable within one heartbeat rather than one full timeout window, and the failure surfaces at the boundary where it can be attributed (Card staleness vs. worker restart vs. enum mismatch) rather than as a generic timeout downstream. The invariant to encode in the client: *a Card fetch never authorizes a state assumption; only a `tasks/get` response within `heartbeatInterval` does.* ```python from __future__ import annotations import time from dataclasses import dataclass from enum import Enum from typing import Optional class TaskState(Enum): SUBMITTED = "submitted" WORKING = "working" COMPLETED = "completed" FAILED = "failed" CANCELED = "canceled" TERMINAL_STATES = {TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELED} class TaskStateDivergenceError(RuntimeError): """Raised when a caller would extrapolate task state across a Card etag change or a heartbeat gap without re-querying tasks/get.""" @dataclass(frozen=True) class AgentCard: etag: str streaming: bool heartbeat_interval_s: float @dataclass(frozen=True) class TaskObservation: task_id: str state: TaskState observed_at: float card_etag: str def assert_state_authoritative( card: AgentCard, last_observation: Optional[TaskObservation], now: Optional[float] = None, ) -> None: """Enforce the invariant: a Card fetch never authorizes a state assumption. Callers must invoke this before acting on a task's liveness. It raises if the last observation cannot be trusted under the current Card. """ now = time.monotonic() if now is None else now if last_observation is None: raise TaskStateDivergenceError( "no task observation exists; must call tasks/get before acting" ) if last_observation.card_etag != card.etag: raise TaskStateDivergenceError( f"card etag changed ({last_observation.card_etag!r} -> {card.etag!r}); " f"task {last_observation.task_id} state must be re-fetched via tasks/get" ) age = now - last_observation.observed_at if last_observation.state not in TERMINAL_STATES and age > card.heartbeat_interval_s: raise TaskStateDivergenceError( f"task {last_observation.task_id} observation is {age:.1f}s old, " f"exceeds heartbeat {card.heartbeat_interval_s:.1f}s; " f"must re-fetch tasks/get before assuming {last_observation.state.value}" ) def normalize_task_state(raw: str) -> TaskState: """Single-boundary enum normalization. Heterogeneous runtimes must not each re-normalize; call this once at the gateway.""" try: return TaskState(raw.strip().lower()) except ValueError as exc: raise TaskStateDivergenceError( f"unrecognized taskState {raw!r} from peer runtime; " f"refusing to guess and risk divergence" ) from exc ```