# Why does an instance of A2A v1.0 Agent Card discovery fail signature verification after a registry re-resolution cycle across vendor boundaries **Date of Record:** 2026 November 14 **Scope:** Applies to A2A v1.0 peers that discover Agent Cards through a third-party (cross-vendor) registry and subsequently validate signed task delegation envelopes (JWS over the task payload, keyed to the card's declared signing material). Does not apply to direct peer-to-peer card fetch where the card origin equals the delegation issuer origin, nor to unsigned delegation modes explicitly negotiated out-of-band. ## Core Distinction The failure is not a key compromise, not a clock-skew rejection, and not a malformed JWS. It is a *binding* failure: the Agent Card that authorized a delegation is a different *version* of the same logical agent than the card the verifier re-resolved at task-execution time. A2A v1.0 treats the Agent Card as the authoritative statement of an agent's signing key material (`securitySchemes` + the `x-delegation-key` binding). Cross-vendor registries, however, treat the card as a *mutable catalog entry* subject to refresh, TTL expiry, and vendor-side re-publication. The invariant "the card that signed the task is the card the verifier resolves" is asserted nowhere in the protocol; it is assumed. When a registry re-resolution occurs between signing and verification, the verifier validates the JWS against a *newer* key set and rejects a perfectly valid delegation — or, worse, accepts one signed under a deprecated key that the vendor has not yet revoked. The distinction that matters operationally: **card identity is not card version identity**. `agentId` is stable across re-publication; the `(agentId, keyId, cardDigest)` triple is not. ## What Was Observed Across a two-vendor integration (registry operator R, agent vendor V), the following sequence reproduced the failure deterministically: 1. Delegator D fetches V's Agent Card from R at `t0`. Card carries `agentId=acme.invoice.v3`, `keyId=k-2026-10`, and a registry-assigned `etag=e41`. 2. D signs a task delegation envelope. The JWS protected header embeds `agentId`, `keyId=k-2026-10`, and `cardDigest=sha256(canonical(card@t0))`. 3. Vendor V rotates its signing key to `k-2026-11` and re-publishes through R at `t1 > t0`. R updates the catalog entry; `etag` becomes `e42`. Crucially, **R does not retain the prior card revision** and does not expose a revision-history endpoint. 4. D's task reaches V's executor at `t2`. The executor, following A2A v1.0's "verify against the current Agent Card" guidance, re-resolves the card from R, obtains `e42` with `keyId=k-2026-11`, and attempts JWS verification. 5. Verification fails: the envelope was signed under `k-2026-10`, which is no longer discoverable. The `cardDigest` in the header does not match any card the verifier can obtain. The observed error surface is misleading. Registries typically return HTTP 200 with the *current* card; there is no signal that a revision boundary was crossed. The verifier sees "signature invalid," not "card superseded." Operators initially diagnosed this as a key mismatch and rotated further, widening the outage. A secondary, more dangerous variant: if V had *not* rotated but R had re-published the card with an added optional field (e.g., a new `capabilities` entry), the canonicalized `cardDigest` changes without any key change. Verifiers that bind to `cardDigest` reject valid delegations; verifiers that bind only to `keyId` accept them. The two verifier populations disagree on the same envelope — a split-brain over delegation validity. ## Condition of Validity The verified-delegation invariant holds only when all of the following are true at verification time: - The verifier can obtain the *exact card revision* referenced by `cardDigest` in the JWS header, either from the registry (revision-addressed fetch) or from a cache keyed by digest, not by `agentId`. - The registry exposes revision identity (digest or monotonic revision token) that is stable and content-addressed; `etag` alone is insufficient because it is vendor-mutable and not guaranteed content-derived. - The delegation envelope's validity window is strictly contained within the card revision's declared validity window (`notBefore`/`notAfter` on the key material), and the verifier checks both. - Key rotation is accompanied by an overlap period during which the *prior* key remains discoverable and marked `deprecated` rather than removed. When any of these is false — most commonly the first, because cross-vendor registries rarely offer revision-addressed fetch — the invariant fails silently and the protocol's signature check becomes a check of *current* authority, not *delegated* authority. ## Decision Boundary The operator must choose, at each verification, between two policies that cannot both be correct under registry mutation: - **Bind-to-digest (strict):** Verify the JWS against the card whose canonical digest equals the header's `cardDigest`. Requires digest-addressed retrieval or a digest-keyed cache. Rejects any delegation whose originating card revision is unavailable. Correct for high-assurance delegation; produces hard failures when registries prune revisions. - **Bind-to-current (lenient):** Verify against the currently resolved card, accepting any `keyId` present in its `securitySchemes`, ignoring `cardDigest`. Survives registry churn but silently accepts delegations signed under keys the vendor may have intended to retire, and cannot distinguish a stale-but-valid delegation from a replayed one. The boundary is crossed when the delegation's security requirement exceeds the registry's revision-retention guarantee. Concretely: if the registry retains fewer than `ceil(delegation_ttl / registry_refresh_interval)` revisions, bind-to-digest will fail before the delegation expires. The decision must be made from the registry's retention contract, not from observed behavior, because the failure is intermittent and load-dependent on re-resolution timing. A third policy — **bind-to-digest-with-cache-pin** — resolves the boundary: the verifier caches the card by digest at signing-observation time and serves verification from that cache for the delegation's lifetime, treating registry re-resolution as a *hint* rather than an authority. This is the recommended posture; it converts a registry-mutation failure into a cache-miss failure, which is detectable and bounded. ## Operational Effect Under bind-to-current, the observable effect is a class of "phantom signature failures" that correlate with vendor key-rotation events and registry refresh cycles rather than with any change to the delegation itself. Mean time to diagnosis is high because the error is reported as a cryptographic failure and the registry returns success. Under bind-to-digest without revision retention, the effect is a hard cutover at the registry's pruning horizon: delegations that verified yesterday fail today with no change on either peer. The correct instrumentation is to log, at verification time, the `(agentId, keyId, cardDigest, resolvedRevision)` tuple and to alert when `cardDigest != digest(resolvedCard)` — a condition that is *not* an error under bind-to-current but *is* the leading indicator of the boundary. The following reference implementation encodes the decision boundary as an explicit exception so that callers cannot silently fall through to lenient verification. ```python import hashlib import json from dataclasses import dataclass from typing import Optional class CardRevisionMismatch(RuntimeError): """Raised when the Agent Card revision needed to verify a delegation cannot be reconciled with the revision the registry currently serves.""" @dataclass(frozen=True) class AgentCard: agent_id: str key_id: str security_schemes: dict revision: str # content-addressed digest, e.g. sha256:... def canonical_digest(card: AgentCard) -> str: body = json.dumps( { "agentId": card.agent_id, "keyId": card.key_id, "securitySchemes": card.security_schemes, }, sort_keys=True, separators=(",", ":"), ).encode() return "sha256:" + hashlib.sha256(body).hexdigest() def verify_delegation( *, header_card_digest: str, header_key_id: str, registry_resolved: Optional[AgentCard], pinned_cache: dict, # digest -> AgentCard policy: str, # "bind-to-digest" | "bind-to-current" | "bind-to-digest-with-cache-pin" ) -> AgentCard: """Resolve the card revision that authorizes a delegation envelope. Returns the card whose key material must be used for JWS verification. Raises CardRevisionMismatch when the policy cannot be satisfied. """ if policy == "bind-to-digest-with-cache-pin": card = pinned_cache.get(header_card_digest) if card is None: raise CardRevisionMismatch( f"pinned cache miss for cardDigest={header_card_digest}; " f"delegation cannot be verified without the originating revision" ) if card.key_id != header_key_id: raise CardRevisionMismatch( f"pinned card keyId={card.key_id} does not match header keyId={header_key_id}" ) return card if registry_resolved is None: raise CardRevisionMismatch( "registry returned no card for agentId; cannot bind delegation" ) resolved_digest = canonical_digest(registry_resolved) if policy == "bind-to-digest": if resolved_digest != header_card_digest: raise CardRevisionMismatch( f"registry revision {resolved_digest} != header cardDigest " f"{header_card_digest}; revision-addressed fetch required" ) return registry_resolved if policy == "bind-to-current": if header_key_id not in { s.get("keyId") for s in registry_resolved.security_schemes.values() if isinstance(s, dict) } and registry_resolved.key_id != header_key_id: raise CardRevisionMismatch( f"header keyId={header_key_id} absent from current card " f"{resolved_digest}; delegation signed under superseded key" ) return registry_resolved raise ValueError(f"unknown verification policy: {policy}") ```