# Which invariants prevent cross-vendor A2A task lifecycle corruption when agent cards are discovered asynchronously **Date of Record:** 2026 November 13 **Scope:** Applies to A2A v1.0 deployments where a client agent discovers a remote agent card over HTTP and subsequently issues `tasks/send` or `tasks/get` calls against that agent, particularly when the remote agent is operated by a different vendor whose card contents may change independently of the client's cache. Does not apply to same-process or same-vendor deployments where card and task state are co-located and versioned atomically. ## Core Distinction The A2A agent card is a *discovery artifact*, not a *capability contract*. The specification requires that a card advertise `capabilities`, `skills`, `defaultInputModes`, `defaultOutputModes`, and a `url` for task submission, but it does not require that the card be immutable, nor that the card's advertised `skills[].id` set be stable across fetches, nor that a task created against one card revision remain interpretable by another. The non-obvious boundary is this: **a task's lifecycle validity is bound to the card revision under which it was created, but the protocol carries no revision identifier in the task object itself.** A client that discovers a card, caches it, and later resumes a task after the remote vendor has reissued the card with a changed skill schema will submit `tasks/get` requests whose `skillId` either no longer exists or now resolves to a different handler. The server, having no way to correlate the request with the historical card, either returns `-32602` (invalid params) or, worse, silently routes to the new skill and returns a task result produced under a different contract. ## What Was Observed During a two-vendor interoperability exercise, client A discovered agent card revision `r1` from vendor B at 09:12 UTC. Card `r1` advertised skill `invoice.reconcile` with `inputModes: ["application/json"]` and a `taskSupport` value of `streaming`. Client A created task `tsk_7f3a` at 09:14 and received a `Task` object with `status.state: "working"`. At 09:31 vendor B rolled card revision `r2`, which removed `invoice.reconcile` and replaced it with `invoice.reconcile.v2` (different `id`, different input schema, `taskSupport: "polling"`). Client A, holding `tsk_7f3a` in local state, polled `tasks/get` at 09:33 using the cached `r1` skill identifier embedded in its request metadata. Vendor B's gateway resolved the unknown skill against its *current* registry, matched no entry, and returned a task object with `status.state: "failed"` and no `error` field populated — a terminal state with no diagnostic. Client A's retry logic, keyed on `state == "failed"` and absence of `error`, treated it as transient and re-polled 14 times over 90 seconds before circuit-breaking. The task had in fact completed successfully at 09:29 under `r1`; the failure was entirely an artifact of card-revision skew. A second observation: vendor B's card `r2` changed `url` from `https://b.example/a2a` to `https://b.example/a2a/v2`. Client A's task object still carried the `r1` URL in its `metadata`. A `tasks/cancel` issued at 09:40 went to the stale endpoint, which returned `404`, which client A mapped to "task not found" rather than "endpoint moved." The cancellation never reached the live task, and the task ran to completion against a resource the client believed it had abandoned. ## Condition of Validity The invariant that prevents this class of corruption is: *a task identifier is only meaningful within the (card revision, endpoint) tuple under which it was minted, and any resumption of a task must be preceded by a re-fetch of the card and a reconciliation of the task's recorded skill/endpoint against the current card.* This holds only when the client persists, alongside each task, the full card snapshot (or a content hash of it) used at creation, and when the server exposes enough in the task object — at minimum the skill `id` and the endpoint origin — to detect divergence. It does not hold when the client treats the card as a long-lived configuration value, nor when the server's card endpoint and task endpoint are served from independently-deployed components with no shared revision token. ## Decision Boundary The boundary is crossed at the moment a client resumes a task without re-validating the card. Concretely: if `now - card_fetched_at > card_ttl` OR if the task's recorded card hash differs from a freshly fetched card hash, the client MUST NOT issue `tasks/get` against the recorded endpoint. It must instead either (a) re-fetch the card, locate the task's skill by `id`, and if the `id` is present with a compatible input schema, resume against the current endpoint; or (b) if the `id` is absent or the schema is incompatible, declare the task orphaned and surface a typed error to the caller rather than a bare `failed` state. The server-side obligation is symmetric: a `tasks/get` for a task minted under a retired card revision must return a distinguishable error (e.g., `-32001` task orphaned) rather than a terminal `failed` state with no `error` field, because the latter is indistinguishable from a genuine task failure and poisons client retry logic. ## Operational Effect Implementing the invariant reduced spurious retries from 14 per orphaned task to zero, and converted silent cross-contract misroutes into explicit `TaskOrphanedError` exceptions that callers could handle. The cost is one additional card fetch per task resumption after TTL expiry, and the requirement that clients persist a card hash per task — a small storage overhead that eliminates an entire class of "task succeeded but client believes it failed" incidents. The deeper lesson: in A2A, the card is a versioned dependency, and treating it as an immutable capability declaration is the boundary failure. ```python import hashlib import json from dataclasses import dataclass class TaskOrphanedError(RuntimeError): """Raised when a task's originating card revision is no longer resolvable.""" @dataclass(frozen=True) class CardSnapshot: revision_hash: str skills: dict # skill_id -> input schema hash endpoint: str def snapshot_card(card: dict) -> CardSnapshot: skills = { s["id"]: hashlib.sha256( json.dumps(s.get("inputSchema", {}), sort_keys=True).encode() ).hexdigest() for s in card.get("skills", []) } body = json.dumps( {"skills": sorted(skills), "url": card["url"]}, sort_keys=True ).encode() return CardSnapshot( revision_hash=hashlib.sha256(body).hexdigest(), skills=skills, endpoint=card["url"], ) def resume_task(task: dict, recorded: CardSnapshot, current_card: dict) -> str: """Validate a task against the live card before resuming. Returns endpoint.""" live = snapshot_card(current_card) skill_id = task.get("metadata", {}).get("skillId") if skill_id is None: raise TaskOrphanedError( f"task {task.get('id')} carries no skillId; cannot reconcile against " f"card revision {live.revision_hash}" ) if skill_id not in live.skills: raise TaskOrphanedError( f"task {task.get('id')} minted under card {recorded.revision_hash} " f"references skill {skill_id!r}, absent from current card " f"{live.revision_hash}; task is orphaned, do not resume" ) if recorded.skills.get(skill_id) != live.skills[skill_id]: raise TaskOrphanedError( f"skill {skill_id!r} input schema changed between card " f"{recorded.revision_hash} and {live.revision_hash}; resuming would " f"route task {task.get('id')} under a different contract" ) return live.endpoint ```