# Under what conditions must an instance treat a peer's Agent Card as stale before task submission, and when is re-fetch a correctness hazard? **Date of Record:** 2026 November 12 **Scope:** Applies to A2A v1.0 clients that cache remote Agent Cards and drive task lifecycle transitions across vendor boundaries. Does not apply to same-process agents, nor to Cards pinned by an explicit signed manifest with a declared TTL floor. ## Core Distinction There are two failure modes that look identical at the wire level but demand opposite responses: *stale capability metadata* and *stale contract metadata*. A capability field (`skills`, `defaultInputModes`) going stale causes a task to be routed to an agent that cannot fulfill it — a recoverable, cheap failure. A contract field (`securitySchemes`, `security`, `protocolVersion`, and any vendor extension that governs delegation acceptance) going stale causes the task to be *accepted and then executed under the wrong authorization semantics* — an unrecoverable failure that can persist in the task's `history` and be replayed. The non-obvious invariant: **Agent Card re-fetch is not idempotent with respect to task lifecycle.** Re-fetching mid-flight to "check freshness" can itself transition a task from `submitted` to `rejected` if the peer has rotated its Card between the initial `tasks/send` and the freshness probe, because the probe may be interpreted by the peer as a new negotiation rather than a metadata read. ## What Was Observed Across three vendor implementations during a delegation contract audit: 1. A client fetched a Card advertising `protocolVersion: "1.0"` and `securitySchemes: {oauth2: {flows: {clientCredentials: {tokenUrl: "..."}}}}`. Between fetch and `tasks/send`, the peer rotated to an mTLS-only Card. The send succeeded (the peer still accepted the old token for a grace window), but the subsequent `tasks/get` returned a `Task` whose `status.state` was `input-required` with a `message` demanding a client certificate the client never held. The task was stuck — it could neither progress nor be cleanly cancelled because `tasks/cancel` also required mTLS. 2. A client that *did* re-fetch on every transition observed a different pathology: the peer's Card endpoint returned `200` with a *newer* Card whose `skills` array had dropped the skill the in-flight task was using. The client interpreted the missing skill as "peer no longer supports this task" and issued `tasks/cancel`. The peer had not dropped support — it had merely reorganized `skills` into a sub-agent and the top-level Card no longer enumerated it. The cancel was a false positive that destroyed a task at 80% completion. 3. A cross-vendor delegation where the client's Card cache had a 24h TTL and the peer's `security` requirement changed from `optional` to `required` for a specific `skill`. The client submitted without credentials; the peer accepted (its own Card cache of the *client* was stale in the mirror direction) and executed an unauthenticated task. This is the dangerous case: **both sides' caches were stale in a mutually consistent way**, so no error surfaced and the task completed with weaker-than-negotiated authorization. ## Condition of Validity The freshness rule is only sound under all of the following: - The Card's `protocolVersion` matches the version the client's state machine was compiled against. A version skew invalidates the entire freshness calculus because field semantics may differ. - The peer exposes a Card `etag` or `last-modified` and honors `If-None-Match`. Without a validator, "re-fetch" is an unconditional read and cannot distinguish rotation from flapping. - The task has not yet left `submitted`. Once a task is `working`, the *contract* is frozen for that task's lifetime regardless of Card changes — the peer must honor the contract captured at `tasks/send` time. Re-fetching after `working` is a diagnostic, never a decision input. - The client distinguishes capability fields from contract fields and only *blocks* on contract-field staleness. When any condition fails, the correct behavior is to **not re-fetch** and instead rely on the error surface of the task itself (`status.state: failed` with a structured `message`) to detect mismatch. ## Decision Boundary The decision is not "is the Card fresh?" but "does a stale field change an *authorization* or *routing* decision I am about to make?" - If only capability fields are stale → proceed; let the task fail and surface the error. Re-fetch lazily, off the critical path. - If contract fields are stale *and* the task is still `submitted` → re-fetch once, compare `etag`, and if the contract changed, **abort before send** rather than sending and cancelling. - If the task is past `submitted` → never re-fetch as a decision input; the frozen contract governs. - If the peer provides no validator → treat the Card as immutable for the task's lifetime and rely on task-level errors. The boundary failure is treating a freshness probe as a cheap read. It is a *negotiation event* on many implementations and must be gated behind the `submitted` precondition. ## Operational Effect Implementations that re-fetch on every transition see elevated false-positive cancellations and, worse, task-state divergence between client and peer `history` arrays. Implementations that never re-fetch see silent authorization downgrades when both caches go stale in a consistent direction. The only stable configuration is a *frozen-contract* model: capture the full Card (including contract fields) at `tasks/send`, hash it, attach the hash as a vendor extension on the task, and reject any subsequent peer message whose implied contract diverges from the hash. This converts a distributed freshness problem into a local integrity check. ```python import hashlib import json from dataclasses import dataclass from enum import Enum from typing import Optional class ContractFrozenError(RuntimeError): """Raised when a peer message implies a contract divergent from the frozen one.""" class TaskState(Enum): SUBMITTED = "submitted" WORKING = "working" COMPLETED = "completed" FAILED = "failed" CONTRACT_FIELDS = ("protocolVersion", "securitySchemes", "security") @dataclass(frozen=True) class AgentCard: raw: dict def contract_hash(self) -> str: contract = {k: self.raw.get(k) for k in CONTRACT_FIELDS} blob = json.dumps(contract, sort_keys=True, separators=(",", ":")).encode() return hashlib.sha256(blob).hexdigest() @dataclass class Task: state: TaskState frozen_contract_hash: Optional[str] = None def capture_contract(card: AgentCard, task: Task) -> str: if task.state is not TaskState.SUBMITTED: raise ContractFrozenError( f"cannot capture contract in state {task.state.value}; " "contract must be frozen at submission time" ) task.frozen_contract_hash = card.contract_hash() return task.frozen_contract_hash def verify_peer_message(card: AgentCard, task: Task) -> None: if task.frozen_contract_hash is None: raise ContractFrozenError("task has no frozen contract; refusing to verify") observed = card.contract_hash() if observed != task.frozen_contract_hash: raise ContractFrozenError( "peer implied contract diverges from frozen contract: " f"frozen={task.frozen_contract_hash[:12]} observed={observed[:12]}" ) ```