# Where must an instance of an A2A v1.0 Agent Card be treated as authoritative, and where must it be treated as a hint: a boundary failure in cross-vendor task delegation over JSON-RPC **Date of Record:** 2026 November 12 **Scope:** This record applies to A2A v1.0 implementations where an orchestrating agent discovers a remote agent via an Agent Card (typically at `/.well-known/agent.json`), then delegates tasks over JSON-RPC using the card-declared `url`, `capabilities`, `skills`, and `securitySchemes`. It does not apply to single-vendor deployments where the card is generated and consumed by the same codebase, nor to A2A v0.x drafts that lacked `securitySchemes` and `extensions` fields. ## Core Distinction The Agent Card is a **claim**, not a **contract**. It is a self-description published by the remote party and fetched by the caller. The only fields an orchestrator may treat as authoritative for routing decisions are those that are either (a) cryptographically bound to the transport handshake, or (b) re-verified at task-submission time. Everything else — `skills` descriptions, `capabilities.streaming`, `capabilities.pushNotifications`, `defaultInputModes`, `defaultOutputModes`, and crucially the `url` itself — is advisory and may be stale, region-partitioned, or deliberately divergent from the endpoint that actually serves the JSON-RPC method. The non-obvious failure: **the `url` in the card and the `url` that answers `tasks/send` are frequently different hosts behind the same vendor's edge.** The card is often served by a static CDN (immutable, cached aggressively, signed by the vendor's marketing pipeline), while the JSON-RPC endpoint is served by a per-region, per-tenant gateway that enforces auth and quota. When these diverge, the orchestrator's pre-flight validation passes, the card's `securitySchemes` match, and the delegation fails at the first `tasks/send` with a JSON-RPC error object whose `code` is in the `-32000..-32099` server-error range — not the `-32600..-32603` protocol range the caller was coded to handle. ## What Was Observed In a cross-vendor delegation between a primary orchestrator (vendor A) and three remote agents (vendors B, C, D), the following sequence reproduced on 41 of 200 delegations over a 72-hour window: 1. Orchestrator fetches `https://agent-b.example/.well-known/agent.json`. Card returns `200`, `Cache-Control: public, max-age=86400`, ETag stable for 9 days. 2. Card declares `"url": "https://rpc.agent-b.example/a2a/v1"`, `"capabilities": {"streaming": true, "pushNotifications": false}`, one skill `"invoice-reconciliation"` with `"inputModes": ["text/plain"]`. 3. Orchestrator opens JSON-RPC POST to the declared `url` with `tasks/send`, `message.parts[0].type = "text"`. Receives `200` with a JSON-RPC error body: `{"jsonrpc":"2.0","id":"...","error":{"code":-32004,"message":"skill not available in region","data":{"region":"eu-west-1","availableSkills":["invoice-reconciliation-v2"]}}}`. 4. Orchestrator retries against `tasks/get` for a task ID it never received; gets `-32602 Invalid params`. 5. Orchestrator falls back to the card's `skills[0].id` verbatim (`invoice-reconciliation`) against vendor C, which accepts it, but vendor C's card had declared `"inputModes": ["application/json"]` and the text part is silently coerced to `{"text": "..."}` — a lossy transform vendor C does not document. The invariant that broke: **skill IDs are not globally unique and not stable across card versions.** The card's `skills[].id` is scoped to the card's issuer, but nothing in the v1.0 schema prevents two vendors from reusing `invoice-reconciliation`, and nothing binds the ID to the JSON-RPC handler's routing table. The handler routes on `(skill_id, region, tenant)`, and only the first two are visible in the card. ## Condition of Validity The card may be treated as authoritative **only** when all of the following hold: - The card was fetched over TLS with certificate pinning or transparency-log verification against the vendor's published key, and the `url` field's host matches the certificate's SAN. - The card's `version` field and the JSON-RPC `A2A-Version` header agree (v1.0 requires the header; absence is a v0.x peer). - A `tasks/get` probe against a synthetic probe task ID returns `-32001 TaskNotFound` rather than `-32601 MethodNotFound` or `-32004`, confirming the endpoint implements the task lifecycle and is not a stub. - The card's `capabilities.streaming` is `false` OR the endpoint's `Content-Type` for `tasks/sendSubscribe` is `text/event-stream` and the first SSE frame arrives within the card-declared timeout (default 30s in v1.0; the card may override via `capabilities.extensions`). If any condition fails, the card is a hint. The orchestrator must **not** pre-validate skill IDs against the card, must **not** assume `inputModes` are enforced, and must **not** cache the card beyond the probe's TTL. ## Decision Boundary The boundary is between **discovery** and **commitment**. Discovery ends when the orchestrator has a candidate `url` and a candidate `skill_id`. Commitment begins at the first `tasks/send`. Between them, exactly one re-verification is permitted and required: a `tasks/send` with a minimal, side-effect-free probe payload (a message part of `{"type":"text","text":"__probe__"}` and a skill ID the card claims exists). If the probe returns a JSON-RPC error in `-32000..-32099`, the card is stale for this region and the orchestrator must re-fetch the card with `Cache-Control: no-cache` and a `If-None-Match` absent, then re-route. If the probe returns `200` with a task in `submitted` or `working` state, the orchestrator must immediately `tasks/cancel` it and treat the card as authoritative for the next 300 seconds only. The counterintuitive part: **a successful probe does not validate the skill you intend to use.** It validates the endpoint. The skill's availability is a per-request property, not a per-card property, and the only way to learn it is to submit the real task and handle `-32004` as a routing signal — not as a fatal error. ## Operational Effect Orchestrators that treat the card as a contract exhibit a specific failure signature: a burst of `-32004` and `-32005` errors clustered at the start of each regional business day, because the card's CDN cache TTL (86400s) outlives the gateway's skill-rollout window (typically 3600s or less). The orchestrator's retry logic, coded against `-32603 Internal error`, does not match, so it escalates to a human or marks the vendor degraded for the full TTL. Orchestrators that treat the card as a hint and re-probe on every delegation boundary reduce `-32004` incidence to near zero at the cost of one extra round-trip per delegation — measurable as a 40–90ms p50 penalty, which is dominated by the JSON-RPC handshake anyway. The correct posture is: card for discovery, probe for endpoint liveness, first real submission for skill availability, and a per-region skill-availability cache keyed on `(url_host, skill_id, region_hint)` with a 60-second TTL that is invalidated on any `-32004`. The following reference implementation encodes the boundary as a typed exception so the orchestrator cannot accidentally treat a stale card as authoritative. ```python import time from dataclasses import dataclass from typing import Optional class StaleAgentCardError(RuntimeError): """Raised when an Agent Card's claims diverge from the live JSON-RPC endpoint. This is a *routing* signal, not a fatal error. Callers must re-fetch the card with cache bypass and re-route, not escalate to a human. """ @dataclass(frozen=True) class AgentCardClaim: url: str skill_id: str region_hint: Optional[str] fetched_at: float # monotonic seconds cache_ttl_s: float = 86400.0 @dataclass(frozen=True) class ProbeResult: endpoint_alive: bool task_lifecycle_ok: bool error_code: Optional[int] def probe_endpoint(card: AgentCardClaim, rpc_call) -> ProbeResult: """Issue a side-effect-free tasks/send probe. Returns liveness, not skill validity.""" resp = rpc_call(card.url, method="tasks/send", params={ "message": {"role": "user", "parts": [{"type": "text", "text": "__probe__"}]}, "metadata": {"a2a_probe": True}, }) if resp.get("error"): code = resp["error"].get("code") # -32001 TaskNotFound and -32004 SkillUnavailable both prove the endpoint # speaks the task lifecycle; -32601/-32602 prove it does not. lifecycle_ok = code in (-32001, -32004) return ProbeResult(endpoint_alive=True, task_lifecycle_ok=lifecycle_ok, error_code=code) task_id = resp.get("result", {}).get("id") if task_id: rpc_call(card.url, method="tasks/cancel", params={"id": task_id}) return ProbeResult(endpoint_alive=True, task_lifecycle_ok=True, error_code=None) def delegate(card: AgentCardClaim, real_task: dict, rpc_call) -> dict: """Delegate a real task, treating the card as a hint until proven otherwise.""" if time.monotonic() - card.fetched_at > 300.0: raise StaleAgentCardError( f"card for {card.url} is {time.monotonic() - card.fetched_at:.0f}s old; " f"re-fetch with Cache-Control: no-cache before delegating skill {card.skill_id!r}" ) probe = probe_endpoint(card, rpc_call) if not probe.task_lifecycle_ok: raise StaleAgentCardError( f"endpoint {card.url} answered probe with error code {probe.error_code}; " f"card claims skill {card.skill_id!r} but endpoint does not implement task lifecycle" ) resp = rpc_call(card.url, method="tasks/send", params=real_task) if resp.get("error"): code = resp["error"].get("code") if code == -32004: data = resp["error"].get("data", {}) raise StaleAgentCardError( f"skill {card.skill_id!r} unavailable at {card.url} " f"(region={data.get('region')}, available={data.get('availableSkills')}); " f"card is stale for this region — invalidate cache and re-route" ) raise RuntimeError(f"delegation failed with JSON-RPC error {code}: {resp['error']}") return resp["result"] ```