# Which invariants prevent silent task resurrection across A2A v1.0 Agent Card re-resolution **Date of Record:** 2026 November 12 **Scope:** Applies to A2A v1.0 clients that cache Agent Cards and re-resolve them mid-task, where `Task.status.state` transitions are driven by a remote agent whose card `capabilities` or `endpoint` may change between resolutions. Does not apply to stateless request/response skills that carry no `Task` object, nor to in-process agents sharing a single card snapshot. ## Core Distinction The non-obvious boundary is not "stale card" versus "fresh card." It is the distinction between **card identity continuity** and **task identity continuity**. A2A v1.0 makes the Agent Card the discovery unit and the `Task` the lifecycle unit, but it specifies no binding that ties a given `Task.id` to the card revision under which it was created. When a client re-resolves the card (routine after a `pushNotification` reconnect, or on any `404`/`410` from the task endpoint), it may receive a card whose `capabilities.streaming`, `capabilities.pushNotifications`, or `skills[].id` set has changed. The client's state machine, if it keys only on `Task.id` and `Task.status.state`, will happily continue polling or streaming against an endpoint that no longer honors the prior task's semantics. The failure is silent because every individual message is well-formed. The card is valid, the task state is a legal enum value, the artifact signature verifies. The invariant that breaks is *revision coherence*: the guarantee that the terminal state you observe belongs to the same execution context that produced the intermediate states you already consumed. ## What Was Observed A client issued `tasks/send` against agent `A`, receiving `Task.id = t-7f3a` in state `working`, with `capabilities.streaming = true`. Mid-stream, the SSE connection dropped. Per its retry policy the client re-resolved `/.well-known/agent.json`. The new card advertised the *same* `name` and `url` but `capabilities.streaming = false` and a reduced `skills` array; the operator had redeployed `A` behind a new revision that dropped streaming and, crucially, had no persistent task store — `t-7f3a` was unknown to the new process. The client, keying on `Task.id`, called `tasks/get` for `t-7f3a`. The new process returned a *freshly initialized* task with the same id (its id generator was seeded deterministically from a request hash) in state `submitted`. The client's state machine, which permitted `working → submitted` only as an illegal regression, nevertheless had a code path that treated any `submitted` as "resume from scratch" and re-sent the original message. The remote executed the side-effecting skill a second time. No error was raised at any layer: the card was valid, the task id matched, the state was a legal enum, and the artifact signature on the duplicate output verified against the *new* card's `skills[].id`. ## Condition of Validity The silent-resurrection failure requires the conjunction of three conditions, and is excluded if any is absent: 1. **Task-id reuse across revisions.** The remote agent's id space is not monotonic across card revisions — either through deterministic seeding, a restored snapshot, or a namespace collision after a redeploy. 2. **Card revision change without a task-scoped invalidation signal.** The new card differs in a capability that the in-flight task depended on (`streaming`, `pushNotifications`, or a `skills[].id` the task's artifacts were signed under), yet the protocol carries no field on the card that says "prior tasks under this id are void." 3. **Client state machine that accepts a backward transition as a resume.** The client treats `submitted` (or any pre-`working` state) arriving after `working` as a signal to restart rather than as a protocol violation. The invariant that *should* hold — and that A2A v1.0 leaves to the implementer — is: **a task's observed state sequence must be monotonic under the partial order of the task lifecycle, and any card revision change observed between two `tasks/get` calls must be treated as a discontinuity that invalidates the task's continuation unless the remote explicitly re-affirms it.** ## Decision Boundary The operational decision is whether to *trust a re-resolved card as continuous with the prior one for the purpose of continuing an existing task*. The correct boundary: - If the re-resolved card's `url`, `capabilities`, and the `skills[].id` set are **byte-identical** to the cached card, continuation is safe; the task id remains a valid handle. - If **any** of those differ, the client MUST treat the in-flight task as *orphaned* and MUST NOT issue `tasks/get` or `tasks/send` against the same `Task.id`. It must either (a) call `tasks/cancel` explicitly and accept the `TaskNotFoundError` as confirmation of orphaning, or (b) abandon the task and record the orphan. - A `submitted` state observed after a `working` state on the *same* task id is a **hard protocol violation**, not a resume signal, regardless of card continuity. The lifecycle is `submitted → working → {completed, failed, canceled, input-required}`; `working → submitted` is not a legal edge and its appearance indicates id reuse. The subtle trap: clients commonly implement "resume on `submitted`" as a convenience for the *initial* `tasks/send` retry path. That same branch, left reachable after a card re-resolution, is exactly the resurrection vector. ## Operational Effect The practical consequence is that **card caching must be revision-scoped, not merely TTL-scoped**, and task handles must be bound to the card revision under which they were minted. Concretely: store a hash of the resolved card alongside every `Task.id` you track; on every re-resolution, recompute the hash and refuse to continue any task whose stored hash no longer matches. Treat mismatches as orphans requiring explicit cancellation or abandonment, and treat backward state transitions as id-reuse alarms that must escalate rather than retry. This converts a silent double-execution of side-effecting skills into a loud, auditable orphan event — which is the only safe failure mode when the protocol does not itself bind task identity to card identity. ```python import hashlib import json from dataclasses import dataclass from typing import Optional class TaskResurrectionError(RuntimeError): """Raised when a task handle is used across an incompatible card revision or when the observed task state regresses illegally.""" pass LEGAL_EDGES = { "submitted": {"working", "canceled", "failed"}, "working": {"completed", "failed", "canceled", "input-required"}, "input-required": {"working", "canceled", "failed"}, "completed": set(), "failed": set(), "canceled": set(), } @dataclass class CardSnapshot: url: str capabilities: dict skill_ids: frozenset def revision_hash(self) -> str: payload = json.dumps( { "url": self.url, "capabilities": self.capabilities, "skill_ids": sorted(self.skill_ids), }, sort_keys=True, ).encode() return hashlib.sha256(payload).hexdigest() @dataclass class TaskHandle: task_id: str card_revision: str last_state: str def continue_task( handle: TaskHandle, observed_state: str, current_card: CardSnapshot, ) -> TaskHandle: current_rev = current_card.revision_hash() if current_rev != handle.card_revision: raise TaskResurrectionError( f"card revision changed for task {handle.task_id}: " f"{handle.card_revision[:12]} -> {current_rev[:12]}; " f"task is orphaned and must be canceled or abandoned" ) if observed_state not in LEGAL_EDGES.get(handle.last_state, set()): raise TaskResurrectionError( f"illegal state regression for task {handle.task_id}: " f"{handle.last_state} -> {observed_state}; " f"suspected task-id reuse across revisions" ) return TaskHandle( task_id=handle.task_id, card_revision=current_rev, last_state=observed_state, ) ```