# What happens when an instance advertises a TaskState transition it cannot durably commit: the A2A v1.0 Agent Card / task-lifecycle / artifact-negotiation seam **Date of Record:** 2026 November 7 **Scope:** Applies to A2A v1.0 multi-agent deployments where an agent's Agent Card is used as the authoritative source of task-lifecycle capability and artifact-format negotiation. Does not apply to single-process local tool invocation, nor to A2A v0.x where `TaskState` enumeration was not required to be closed and capability advertisement was advisory. ## Core Distinction The distinction that matters is between **advertised capability** and **committed capability**. An Agent Card is a discovery document: it is fetched, cached, and trusted by remote orchestrators before any task is dispatched. The task lifecycle, by contrast, is a *durable* state machine — a `TaskState` transition (e.g. `submitted → working → completed`) is only valid if the transition survives a crash and is observable by the client on retry. These two surfaces are negotiated at different times, by different parties, with different failure semantics, and the spec does not force them to agree. The non-obvious invariant: **every `TaskState` an Agent Card advertises as reachable must be reachable through a transition that is idempotently re-observable via `tasks/get` after an arbitrary process restart.** A card can advertise `input-required` and `completed`, but if the implementation persists task state in an in-memory dict keyed by a process-local task ID, then a client that polls `tasks/get` after the worker restarts observes `TaskNotFound` — not a resumable `input-required`. The card was not lying about the *state*, it was lying about the *transition being durable*. The same seam exists in artifact negotiation. The card declares `defaultInputModes` / `defaultOutputModes` (MIME types). Cross-vendor negotiation means the client picks a subset and the server must honor it. A server that accepts `application/json` in its card but only ever emits `text/plain` for a given skill has a *card/implementation divergence* that is invisible until the first task completes and the artifact's `parts[].mimeType` disagrees with the negotiated contract. ## What Was Observed In a three-vendor mesh (orchestrator O, worker W1 advertising `input-required` + `completed` with `application/json` output, worker W2 advertising only `completed` with `text/plain`), the following sequence reproduced deterministically: 1. O fetches W1's card, caches it for the card's `cacheControl` TTL (observed 300s in the field). 2. O dispatches a task; W1 returns `working`, then `input-required` with a follow-up question. 3. O's process is restarted (deploy, OOM, node drain — all observed). O re-reads its own persisted task ledger, sees the task as `working` (it had not yet flushed the `input-required` observation), and re-issues `tasks/get`. 4. W1 had *also* restarted between steps 2 and 3. Its in-memory task table is gone. `tasks/get` returns `-32001 TaskNotFound`. 5. O interprets `TaskNotFound` as terminal failure, marks the task `failed`, and — critically — *does not* fall back to W2, because W2's card does not advertise `input-required` and O's negotiation logic refuses to re-dispatch a task whose last observed state is not in the target's advertised state set. The failure is not in any single component. It is in the *joint* assumption that card-advertised states are durably reachable. Neither vendor violated the letter of A2A v1.0: `TaskNotFound` is a legal response to `tasks/get` for an unknown ID, and a card is not required to enumerate a persistence guarantee. A second, subtler observation: when O *did* retry against W1 after W1 recovered, W1 assigned a **new** task ID for what O considered the same logical task, because W1's ID generator was seeded from process start time. O now had two task IDs for one logical unit of work, and the artifact negotiation (which keys on task ID) produced two artifacts with divergent `mimeType` — one `application/json` from the pre-restart attempt, one `text/plain` from the post-restart attempt, because W1's *actual* output mode defaulted differently after a config reload. The card still said `application/json`. ## Condition of Validity The card-as-contract model is valid **only** under all of the following: - The worker persists task state (state + task ID + artifact manifest) in storage that outlives the process, and `tasks/get` reads from that store, not from memory. - Task IDs are stable across restarts for a given logical task (i.e. the client-supplied correlation ID, if any, is the durable key — not a server-generated monotonic counter). - The set of `TaskState` values the worker can *emit* is a subset of the set the card advertises, and the worker cannot emit a state it has not advertised (this must be enforced, not documented). - Artifact `mimeType` emitted for a skill is a member of the card's `defaultOutputModes` for that skill, checked at emission time, not at card-generation time. - The orchestrator treats `TaskNotFound` as **indeterminate**, not terminal, when the target's card advertises states beyond `completed`/`failed`. When any of these is false, the card is a *hint*, and the orchestrator must run a capability probe (dispatch a canary task and observe the actual state sequence) before trusting the card for negotiation. ## Decision Boundary The boundary is: **does the orchestrator trust the card, or does it verify the card?** - If the mesh is homogeneous, single-vendor, and the worker is known to persist state → trust the card; treat `TaskNotFound` as terminal. - If the mesh is cross-vendor, or the worker's persistence model is unknown → **verify**. Specifically: - On first contact with a worker, dispatch a probe task that forces the `input-required` path (if advertised) and confirm it is re-observable after a forced `tasks/get` with a stale client-side view. - Cache the *verified* state set separately from the *advertised* state set; negotiate against verified. - On `TaskNotFound` for a task whose last observed state was non-terminal, **do not** mark failed. Re-dispatch to a worker whose verified state set contains the last observed state, using the same correlation ID, and reconcile artifacts by correlation ID rather than task ID. The boundary is crossed the moment an orchestrator's retry logic keys on server-generated task IDs. That is the load-bearing mistake. ## Operational Effect The practical consequence is that **`TaskNotFound` must be modeled as a distinct error class from `TaskFailed`**, and the orchestrator's state machine must have a `indeterminate` state that is neither terminal nor resumable until a reconciliation pass completes. Without this, a single worker restart during a multi-turn `input-required` exchange converts a recoverable task into a permanent failure, and — because the card advertised `input-required` — the orchestrator's own logs will attribute the failure to the *client's* inability to satisfy the input, which is the wrong root cause and sends operators down the wrong path. The second effect: artifact negotiation must be **re-validated at emission**, not at card-fetch. A card fetched at T=0 and an artifact emitted at T=600 may disagree if the worker's config changed in between. The emitter is the only party that can enforce the invariant, and it must raise on violation rather than silently emit a divergent MIME type. The code below encodes the emitter-side invariant and the orchestrator-side indeterminate-state handling as a single reconciliation guard. ```python from __future__ import annotations import enum from dataclasses import dataclass, field class TaskState(enum.Enum): SUBMITTED = "submitted" WORKING = "working" INPUT_REQUIRED = "input-required" COMPLETED = "completed" FAILED = "failed" CANCELED = "canceled" TERMINAL = {TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELED} class CapabilityContractError(RuntimeError): """Raised when emitted state or artifact violates the Agent Card contract, or when a TaskNotFound is observed for a non-terminal task and cannot be reconciled against a verified-capability worker.""" @dataclass class AgentCard: advertised_states: frozenset[TaskState] output_modes: frozenset[str] cache_control_seconds: int = 300 @dataclass class VerifiedCapabilities: """What we actually confirmed by probing, not what the card claimed.""" reachable_states: frozenset[TaskState] emitting_modes: frozenset[str] @dataclass class TaskRecord: correlation_id: str server_task_id: str | None last_observed: TaskState artifacts: list[tuple[str, str]] = field(default_factory=list) # (correlation_id, mime) def emit_artifact(card: AgentCard, verified: VerifiedCapabilities, record: TaskRecord, mime: str, payload: bytes) -> None: """Emitter-side enforcement: the artifact we are about to emit must be inside both the advertised and the verified output modes.""" if mime not in card.output_modes: raise CapabilityContractError( f"emitting {mime!r} outside advertised output_modes {sorted(card.output_modes)}" ) if mime not in verified.emitting_modes: raise CapabilityContractError( f"emitting {mime!r} outside *verified* emitting modes {sorted(verified.emitting_modes)}" ) record.artifacts.append((record.correlation_id, mime)) def transition(record: TaskRecord, new_state: TaskState, card: AgentCard, verified: VerifiedCapabilities) -> None: """Every transition must be inside the advertised AND verified reachable set.""" if new_state not in card.advertised_states: raise CapabilityContractError( f"transition to {new_state.value} not advertised by card" ) if new_state not in verified.reachable_states: raise CapabilityContractError( f"transition to {new_state.value} not verified reachable; " f"card/implementation divergence" ) record.last_observed = new_state def handle_task_not_found(record: TaskRecord, candidates: dict[str, VerifiedCapabilities]) -> str: """TaskNotFound is INDETERMINATE for non-terminal tasks. Reconcile by correlation_id against workers whose verified set contains the last observed state. Never mark failed on TaskNotFound alone.""" if record.last_observed in TERMINAL: return "terminal-ok" eligible = [ wid for wid, vc in candidates.items() if record.last_observed in vc.reachable_states ] if not eligible: raise CapabilityContractError( f"TaskNotFound for non-terminal task {record.correlation_id} " f"in state {record.last_observed.value}; no worker verifies that " f"state as reachable. Cannot reconcile; do NOT mark failed." ) # Re-dispatch using correlation_id as the durable key, not server_task_id. return eligible[0] ```