# Where must an instance enforce signature freshness during A2A v1.0 cross-org agent card discovery when the negotiating peer rotates its signing key mid-handshake **Date of Record:** 2026 November 9 **Scope:** Applies to A2A v1.0 deployments where an agent (the *instance*) discovers remote agents via `/.well-known/agent-card.json` across organizational boundaries and negotiates capabilities using JWS-signed capability manifests. Does not apply to intra-org discovery on a shared trust root, nor to A2A versions prior to v1.0 where capability negotiation was unsigned. ## Core Distinction The boundary that matters is not *signature validity* versus *invalidity*. It is **the temporal binding of a signature to a key epoch** versus **the temporal binding of a signature to a card body**. A2A v1.0 agent cards are cached aggressively (they are cheap to fetch and expensive to re-verify), and the `signature` field on a capability manifest references a `keyid` that resolves through the peer's JWKS. The non-obvious failure is this: a signature can be cryptographically valid against a key that has been rotated out, and the card body can be byte-identical to the previously trusted one, while the *capability grant* implied by the signature is no longer in force. Verifying "does this signature verify against this JWKS entry" answers the wrong question at the moment of rotation. The correct invariant is: *the signature must verify against a key that was in the peer's published JWKS at the instant the manifest was issued, and that key must not have been revoked before the manifest was consumed.* ## What Was Observed During a cross-org negotiation between an orchestrator instance and a partner agent, the partner rotated its signing key on a 24-hour cadence with a 1-hour overlap window (old key retained in JWKS for one hour after new key publication). The orchestrator maintained a 6-hour card cache keyed by `(agent_id, card_hash)` and a 1-hour JWKS cache keyed by `agent_id`. The sequence that produced a silent capability downgrade: 1. T0: Partner publishes card C1, signed with key K1. Orchestrator fetches, validates, caches card and JWKS (containing K1). 2. T0+5h: Partner rotates to K2, publishes card C2 (identical capability set, new `keyid`), retains K1 in JWKS. 3. T0+6h: Orchestrator's card cache expires. It re-fetches the card, gets C2, but its JWKS cache still holds the pre-rotation snapshot containing only K1 (the JWKS cache TTL had 30 minutes remaining). 4. Validation of C2 against cached JWKS: `keyid` K2 is **absent**, so the orchestrator falls back to "trust last-known-good card" — C1 — because C1's signature still verifies against the cached K1. 5. T0+6h to T0+6h30: Orchestrator negotiates against C1's capability manifest. The partner, now serving C2 semantics, rejects a capability flag that C1 advertised as `required: false` but C2 tightened to `required: true`. The failure was not an auth failure. Every signature verified. The orchestrator operated on a *stale capability grant* that was, at the byte level, a legitimate signed artifact from the same peer. The partner's logs showed "client requested deprecated capability shape" — the orchestrator's logs showed "all signatures valid." A second variant, more dangerous: if the partner had *revoked* K1 rather than merely rotated it (compromise scenario), the orchestrator's fallback-to-C1 path would have accepted a signature from a revoked key, because its JWKS cache had no revocation entry. The fallback logic that made the first case benign made the second case a trust violation. ## Condition of Validity The stale-grant acceptance is only possible when all four hold simultaneously: - The card cache TTL exceeds the peer's key overlap window (here 6h > 1h). - The JWKS cache TTL is independent of the card cache TTL and can outlive a rotation event. - The validator implements a "trust last-known-good on unknown keyid" fallback rather than failing closed. - The peer reuses capability semantics across key epochs without a monotonic version in the card body. The invariant that prevents it: **a card is only usable while every `keyid` it references is present in a JWKS snapshot whose `fetched_at` is no older than the card's `issued_at` minus the peer's declared overlap.** If the JWKS snapshot predates the card, the snapshot is insufficient — regardless of whether the signature verifies. Put differently: signature verification against a stale JWKS is a proof about the past, not a grant for the present. ## Decision Boundary The instance must choose between two behaviors at the moment a card references a `keyid` absent from the cached JWKS: - **Fail closed and re-fetch JWKS synchronously** before accepting the card. Correct when the peer's overlap window is unknown or the capability set is security-relevant. Cost: one extra round trip on the rotation boundary, and a hard dependency on the peer's JWKS endpoint availability. - **Fail open to last-known-good card** only if the cached JWKS snapshot's `fetched_at` is *newer* than the last-known-good card's `issued_at` AND the peer publishes a signed revocation list the instance has polled within the overlap window. This is the narrow window where fallback is sound. The boundary is not "is the signature valid." It is "is the JWKS snapshot fresh enough to speak for the card's epoch." A validator that collapses these into one check will accept revoked-key signatures under cache skew. ## Operational Effect Instances that implement the epoch-binding check observe a measurable increase in JWKS fetches at rotation boundaries (roughly one per peer per rotation, bounded by overlap) and a corresponding elimination of stale-grant negotiations. The observable signature in logs is the disappearance of "signature valid, capability mismatch" pairs and their replacement by "JWKS refresh triggered: card issued_at > jwks fetched_at." The failure mode it prevents is silent: without the check, the system appears healthy — all signatures verify — while negotiating against capability grants the peer has already superseded or revoked. ```python import time from dataclasses import dataclass class StaleKeyEpochError(RuntimeError): """Raised when a card's signing key cannot be bound to a fresh-enough JWKS snapshot.""" pass @dataclass(frozen=True) class Card: agent_id: str issued_at: float keyid: str body_hash: str @dataclass(frozen=True) class JwksSnapshot: agent_id: str fetched_at: float keyids: frozenset revoked: frozenset def validate_card_epoch( card: Card, jwks: JwksSnapshot, overlap_seconds: float, now: float, ) -> None: if card.agent_id != jwks.agent_id: raise StaleKeyEpochError( f"agent mismatch: card={card.agent_id} jwks={jwks.agent_id}" ) # The signature may verify, but the snapshot must be able to speak for # the card's issuance epoch. A snapshot older than the card is insufficient. if jwks.fetched_at < card.issued_at - overlap_seconds: raise StaleKeyEpochError( f"JWKS snapshot predates card epoch: " f"fetched_at={jwks.fetched_at} issued_at={card.issued_at} " f"overlap={overlap_seconds}" ) if card.keyid in jwks.revoked: raise StaleKeyEpochError( f"card signed by revoked keyid {card.keyid} for agent {card.agent_id}" ) if card.keyid not in jwks.keyids: raise StaleKeyEpochError( f"card keyid {card.keyid} absent from JWKS snapshot for " f"agent {card.agent_id}; refusing last-known-good fallback " f"(snapshot fetched_at={jwks.fetched_at}, now={now})" ) def negotiate(card: Card, jwks: JwksSnapshot, overlap_seconds: float) -> None: now = time.time() validate_card_epoch(card, jwks, overlap_seconds, now) # proceed with capability negotiation against this exact epoch ```