# How should an instance treat a terminal Agent Card as authority over in-flight Task state in A2A v1.0 **Date of Record:** 2026 November 12 **Scope:** Applies to A2A v1.0 clients and servers that resolve Agent Cards at discovery time and then drive a Task through `submitted → working → {completed | failed | canceled}` while exchanging Artifacts. It does not apply to pure request/response (non-Task) message exchanges, nor to deployments where the Agent Card is re-fetched and re-validated on every turn by contract. ## Core Distinction The Agent Card is a *capability advertisement*, not a *state oracle*. A2A v1.0 gives the Card a TTL-ish cache lifetime and a `skills`/`capabilities` surface that clients use to decide whether to even create a Task. The non-obvious failure is conflating *capability revocation* (the Card changed, or the endpoint 404s on re-fetch) with *Task invalidity* (the in-flight Task's terminal state). These are orthogonal. A Card that becomes unreachable or loses a skill does **not** retroactively invalidate a Task that already reached `completed`; conversely, a Task in `working` does not keep a skill alive that the Card no longer advertises. The boundary is: **Card state governs admission of new Tasks; Task state governs the disposition of existing Tasks. Crossing that boundary in either direction produces silent conformance violations.** ## What Was Observed During a conformance run against a v1.0 server that rotates Agent Cards on skill deprecation, three concrete behaviors surfaced: 1. A client cached a Card, created a Task, and mid-flight the server removed the `artifact.export` skill from the Card and began returning `-32601 Method not found`-style rejections on `tasks/artifacts` calls for that Task. The client had already received a partial Artifact set and, because it re-read the Card and saw the skill gone, marked the *Task* `failed` locally without ever receiving a terminal `TaskStatusUpdateEvent`. The server still considered the Task `working` and later emitted `completed`. Result: split-brain terminal state, and the client's Artifact store held a truncated set that it treated as final. 2. A Task reached `completed` with an Artifact whose `parts` array was empty (a legal "no payload" completion). A later Card refresh dropped the skill that produced it. A naive client pruned the Artifact because the producing skill was no longer advertised. The Artifact was still valid; only future production was revoked. 3. A `canceled` Task was retried by a client that saw the Card still advertise the skill. The server accepted a new Task (correct), but the client reused the old `taskId` as a correlation key and merged the new Task's Artifacts into the canceled Task's record (incorrect — `taskId` is per-Task, not per-conversation). The invariant that was violated: **terminal Task state is immutable and self-authoritative; it is never re-derived from Card state.** And its dual: **a non-terminal Task's eventual terminal state is authoritative even if the Card revoked the skill mid-flight.** ## Condition of Validity The rule "Card state does not govern Task state" holds only under these conditions, and must be suspended otherwise: - The Task has a server-assigned `taskId` and the client has received at least one `TaskStatusUpdateEvent` or a `Task` object from `tasks/get`. Before that handshake, there is no Task state to protect — only an admission decision, which *is* Card-governed. - The server implements terminal states as absorbing: once `completed`/`failed`/`canceled`, no further status transitions are emitted for that `taskId`. If a server violates absorption (reopens a terminal Task), the client must treat the `taskId` as poisoned and refuse to merge. - Artifact exchange is keyed by `taskId` and Artifact identity (`artifactId`), not by skill. If a deployment keys Artifacts by skill, the invariant does not apply and you must re-validate on every Card change. - The client's Card cache TTL is bounded and it re-fetches on `401`/`403`/`404` from any Task endpoint — but re-fetch is for *admission of new work*, not re-adjudication of old work. ## Decision Boundary The decision procedure at the boundary, stated as a total function over (Card freshness, Task state): - **New Task admission:** Card-governed. If the required skill is absent or the Card is unreachable, do not create the Task. Reject locally with a discovery error. - **In-flight Task, Card revoked skill, Task non-terminal:** Do **not** locally fail the Task. Continue to poll/await `TaskStatusUpdateEvent` until a terminal state arrives or a transport-level timeout fires. On timeout, mark `failed` with a *transport* reason, not a *capability* reason, and preserve received Artifacts as partial-but-unverified. - **Terminal Task, Card changed:** Task state wins. Never rewrite a terminal Task's status or prune its Artifacts based on Card deltas. Artifacts remain valid historical outputs. - **Terminal Task, client wants to retry:** Mint a **new** `taskId`. Never reuse a terminal `taskId` as a correlation key for Artifacts from a different Task. - **Card unreachable on re-fetch during in-flight Task:** Treat as transport degradation, not capability revocation. Do not infer skill loss from a network error. The bright line: **capability deltas are admission signals; status events are disposition signals.** Any code path that reads a Card to decide a Task's terminal disposition is a conformance bug. ## Operational Effect Enforcing the boundary changes three operational behaviors. First, clients stop producing phantom `failed` states from Card churn — the observed split-brain in case 1 disappears because the client waits for the server's terminal event rather than inferring one. Second, Artifact stores become append-only per `taskId`, so pruning on skill deprecation is disallowed and historical outputs survive Card rotation (case 2). Third, retry logic must allocate fresh `taskId`s, which forces correlation keys to be conversation-scoped, not Task-scoped (case 3). The cost is that clients must tolerate a window where a Task is `working` under a skill the Card no longer advertises; this is correct — the server owns that Task's lifecycle to completion. The residual risk is a server that never emits a terminal event after revocation; that is a server-side absorption bug, and the client's only correct response is a transport timeout, never a capability-based cancellation. ```python from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Optional class CardTaskBoundaryError(RuntimeError): """Raised when Card state is used to adjudicate Task disposition.""" class TaskState(str, Enum): SUBMITTED = "submitted" WORKING = "working" COMPLETED = "completed" FAILED = "failed" CANCELED = "canceled" TERMINAL = {TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELED} @dataclass class AgentCard: skills: set[str] reachable: bool = True @dataclass class Task: task_id: str required_skill: str state: TaskState = TaskState.SUBMITTED artifacts: list[str] = field(default_factory=list) terminal_locked: bool = False def admit_new_task(card: AgentCard, required_skill: str) -> None: """Card governs admission of NEW tasks only.""" if not card.reachable: raise CardTaskBoundaryError( f"cannot admit task: Agent Card unreachable for skill {required_skill!r}" ) if required_skill not in card.skills: raise CardTaskBoundaryError( f"cannot admit task: skill {required_skill!r} not advertised by card" ) def apply_status_event(task: Task, new_state: TaskState) -> None: """Only server status events may move a Task toward terminal.""" if task.terminal_locked: raise CardTaskBoundaryError( f"task {task.task_id!r} is terminal-locked; refusing transition to {new_state}" ) task.state = new_state if new_state in TERMINAL: task.terminal_locked = True def reconcile_with_card(task: Task, card: AgentCard) -> None: """The boundary: Card deltas must NOT rewrite Task disposition.""" if task.terminal_locked: # Terminal state wins; Card is irrelevant to disposition. return if not card.reachable or task.required_skill not in card.skills: # Skill revoked mid-flight: do NOT locally fail the task. # Wait for a server status event or a transport timeout. if task.state in TERMINAL: # defensive; terminal_locked should catch raise CardTaskBoundaryError( f"task {task.task_id!r} terminal but not locked; state machine corrupt" ) return # hold, await status event def retry_task(old: Task, card: AgentCard, new_task_id: str) -> Task: """Retries must mint a fresh taskId; never reuse a terminal one.""" if not old.terminal_locked: raise CardTaskBoundaryError( f"cannot retry task {old.task_id!r}: not terminal" ) if new_task_id == old.task_id: raise CardTaskBoundaryError( "retry must allocate a new taskId; reusing terminal taskId corrupts artifact correlation" ) admit_new_task(card, old.required_skill) return Task(task_id=new_task_id, required_skill=old.required_skill) def merge_artifact(task: Task, artifact_id: str) -> None: if task.terminal_locked and task.state is TaskState.CANCELED: raise CardTaskBoundaryError( f"refusing to merge artifact {artifact_id!r} into canceled task {task.task_id!r}" ) task.artifacts.append(artifact_id) ```