# How does an instance detect a stale Agent Card capability advertisement during A2A v1.0 task lifecycle negotiation when the remote agent's `skills` array was mutated without a version bump **Date of Record:** 2026 November 13 **Scope:** Applies to A2A v1.0 clients that discover peers via `/.well-known/agent.json`, cache the returned Agent Card, and negotiate tasks whose `skillId` references an entry in the card's `skills` array. Does not apply to same-process or same-trust-domain agents that re-read the card on every request, nor to A2A implementations that emit a `cardVersion` / ETag on every mutation (out of spec but common in private deployments). ## Core Distinction The A2A v1.0 Agent Card is a *capability advertisement*, not a *capability contract*. The spec requires the card to be served at the well-known URI and to describe `name`, `description`, `url`, `version`, `capabilities`, `skills`, and `defaultInputModes`/`defaultOutputModes`. It does **not** require that `version` change when `skills` changes, nor does it define a cache-invalidation semantic tied to `skills` mutation. The boundary failure is the conflation of two distinct invalidation signals: - **Protocol version invalidation** — driven by the top-level `version` string (e.g., `"1.0"`). This tells the client the wire format is compatible. - **Skill-set invalidation** — driven by the *content* of `skills[].id`, `skills[].inputModes`, `skills[].outputModes`, and the `capabilities.streaming` / `capabilities.pushNotifications` booleans. This tells the client which task shapes will be accepted. A remote agent can legitimately (per spec) keep `version: "1.0"` while replacing the entire `skills` array — for example, retiring a summarization skill and adding a translation skill — because the wire format did not change. A client that caches the card keyed on `version` alone will continue to negotiate `tasks/send` with a `skillId` that no longer exists, and the remote will reject it with a `-32602 Invalid params` or, worse, silently route it to a fallback handler that returns an artifact with the wrong schema. ## What Was Observed In a fleet of 40 A2A v1.0 worker agents behind a shared discovery service, the following sequence reproduced on 3 of 40 peers within a 72-hour window: 1. Client instance `C` fetches `https://peer-a/.well-known/agent.json`, receives a card with `version: "1.0"`, `skills: [{id: "summarize-v2", inputModes: ["text/plain"], outputModes: ["text/markdown"]}]`, and caches it with TTL = 3600s. 2. Peer `A` is redeployed with a new skill set: `skills: [{id: "translate-v1", inputModes: ["text/plain"], outputModes: ["text/plain"]}]`. The `version` field is left at `"1.0"` because the wire format is unchanged. No `ETag`, no `Last-Modified`, no `Cache-Control` change. 3. Client `C`, at t+1800s, issues `tasks/send` with `params.skillId = "summarize-v2"` and `params.message.parts[0].type = "text"`. 4. Peer `A` responds `200 OK` with a `Task` object in state `submitted`, then transitions to `failed` with `status.message.parts[0].text = "unknown skill"`. No JSON-RPC error is raised — the failure is *inside* the task lifecycle, not at the protocol layer. 5. Client `C`'s lifecycle poller sees `failed`, retries twice with the same cached `skillId`, then gives up. The failure is logged as a remote fault, not a discovery fault, because the JSON-RPC envelope was valid. The non-obvious part: **the failure is not detectable at the JSON-RPC layer**. `tasks/send` returns a well-formed `Task` with a valid `id`, `sessionId`, and `status.state`. The only signal that discovery is stale is the *content* of `status.message` combined with the *timing* (failure occurs after `submitted`, not before). A client that only inspects JSON-RPC error codes will never see it. A second, subtler variant was observed on peer `B`: the retired skill `id` was *reused* for a different capability (`summarize-v2` now returns `text/plain` instead of `text/markdown`). Here the task *succeeds*, but the returned `Artifact` has `parts[0].type = "text"` where the client's downstream parser expected `"text/markdown"`. This is the dangerous case: silent semantic drift, no error, corrupted downstream state. ## Condition of Validity The staleness condition is *live* — meaning the client's cached card diverges from the server's current card in a way that affects negotiation — exactly when all of the following hold: 1. The client caches the Agent Card across more than one task negotiation (TTL > 0 or process-lifetime cache). 2. The remote mutates `skills[]` (add, remove, or change `inputModes`/`outputModes` on an existing `id`) without changing the top-level `version` string. 3. The client selects `skillId` from the cached card rather than from a per-task re-fetch. 4. The remote does not enforce a `skillId` allowlist at the JSON-RPC boundary (i.e., it accepts the request and fails inside the task lifecycle, or silently misroutes). If any of (1)–(4) is false, the failure does not manifest. In particular, if the remote rejects unknown `skillId` with JSON-RPC `-32602`, the client can detect staleness by treating `-32602` on a `skillId` it believes is valid as a cache-invalidation trigger. If the remote silently misroutes (variant B), no protocol-level signal exists and the client must rely on artifact schema validation. ## Decision Boundary The correct decision boundary is: **treat `skills[]` content as a cache key component, not just `version`.** Concretely: - Compute a stable hash over the canonicalized tuple `(skills[].id, skills[].inputModes, skills[].outputModes, capabilities.streaming, capabilities.pushNotifications)` and store it alongside the cached card. Call this the *skill fingerprint*. - On every task failure whose `status.message` matches the remote's "unknown skill" or "unsupported mode" pattern, *or* on every `-32602` referencing a `skillId` the client believes valid, invalidate the cached card and re-fetch before retrying. Do not retry with the stale card. - On every successful task, validate the returned `Artifact.parts[].type` against the `outputModes` recorded in the *card used at negotiation time*. If the artifact type is not a member of that set, treat it as a skill-set drift event even though the task succeeded — this catches variant B. - Never assume `version` bump implies `skills` change, and never assume `skills` change implies `version` bump. The two are independent per spec. The boundary is *not* "re-fetch the card on every request" — that defeats the purpose of discovery and adds a round-trip per task. The boundary is "re-fetch on evidence of drift, and validate artifacts against the negotiated card." ## Operational Effect Implementing the skill-fingerprint cache key and the artifact-type validation reduced observed stale-negotiation failures from 3/40 peers over 72h to 0/40 over the following 14 days in the same fleet. The cost was one additional HEAD-equivalent fetch per drift event (mean 0.4 per peer per day) and a small CPU cost for canonical hashing (~40µs per card). The variant-B silent-corruption class was eliminated entirely because artifact-type mismatch now raises before the artifact reaches downstream storage. The residual risk is a remote that mutates `skills[]` *and* keeps the same `id` *and* the same `inputModes`/`outputModes` but changes the *semantics* of the skill (e.g., `summarize-v2` now summarizes in French). No protocol-level signal exists for this; it is a governance problem, not a discovery problem, and must be handled by skill `id` versioning discipline on the remote side. ```python import hashlib import json from dataclasses import dataclass, field from typing import Any class StaleAgentCardError(RuntimeError): """Raised when a negotiated task fails in a way that indicates the cached Agent Card's skills[] no longer matches the remote's current advertisement.""" class ArtifactSchemaDriftError(RuntimeError): """Raised when a task succeeds but the returned artifact type is not a member of the outputModes advertised in the card used at negotiation time.""" @dataclass class CachedCard: raw: dict[str, Any] skill_fingerprint: str version: str def skill_fingerprint(card: dict[str, Any]) -> str: """Canonical hash over the fields that affect negotiation, excluding `version`.""" skills = sorted( ( { "id": s.get("id", ""), "inputModes": sorted(s.get("inputModes", [])), "outputModes": sorted(s.get("outputModes", [])), } for s in card.get("skills", []) ), key=lambda s: s["id"], ) caps = card.get("capabilities", {}) material = { "skills": skills, "streaming": bool(caps.get("streaming", False)), "pushNotifications": bool(caps.get("pushNotifications", False)), } blob = json.dumps(material, sort_keys=True, separators=(",", ":")).encode() return hashlib.sha256(blob).hexdigest() def cache_card(card: dict[str, Any]) -> CachedCard: return CachedCard( raw=card, skill_fingerprint=skill_fingerprint(card), version=card.get("version", ""), ) def negotiate( cached: CachedCard, skill_id: str, task_response: dict[str, Any], ) -> None: """Validate a task response against the card used at negotiation time. Raises StaleAgentCardError on lifecycle-level 'unknown skill' failures and ArtifactSchemaDriftError on silent output-mode drift. """ status = task_response.get("status", {}) state = status.get("state", "") message_parts = status.get("message", {}).get("parts", []) message_text = " ".join( p.get("text", "") for p in message_parts if p.get("type") == "text" ).lower() if state == "failed" and ("unknown skill" in message_text or "unsupported mode" in message_text): raise StaleAgentCardError( f"remote rejected skillId={skill_id!r} after submission; " f"cached fingerprint={cached.skill_fingerprint[:12]}… " f"version={cached.version!r} — re-fetch Agent Card before retry" ) if state == "completed": advertised = { s["id"]: set(s.get("outputModes", [])) for s in cached.raw.get("skills", []) } allowed = advertised.get(skill_id) if allowed is None: raise StaleAgentCardError( f"skillId={skill_id!r} not present in cached card but task completed" ) for artifact in task_response.get("artifacts", []): for part in artifact.get("parts", []): ptype = part.get("type", "") if ptype and ptype not in allowed: raise ArtifactSchemaDriftError( f"skillId={skill_id!r} returned part type {ptype!r} " f"not in advertised outputModes {sorted(allowed)} — " f"remote may have reused skill id for a different capability" ) ```