# When must an instance reject a signed A2A push notification whose Agent Card advertises the correct JWKS: the stale-`kid` / rotated-key boundary in A2A v1.0 webhook verification **Date of Record:** 2026 November 12 **Scope:** Applies to A2A v1.0 server-to-client push-notification delivery where the receiving agent verifies the webhook signature against the `jwks_uri` declared in the *sending* agent's Agent Card, and to task-lifecycle transitions driven by those notifications. Does not apply to synchronous `message/send` responses, to pull-based `tasks/get` polling, or to deployments using a shared out-of-band trust anchor rather than per-card JWKS. ## Core Distinction Agent Card discovery is a *point-in-time* fetch; JWKS rotation is a *continuous* process. The A2A v1.0 spec treats the `securitySchemes` and the referenced `jwks_uri` as part of the immutable card surface, but operators routinely rotate signing keys behind a stable `jwks_uri` without re-publishing the card. This creates a boundary that is invisible at discovery time: a receiver that caches the Agent Card (and its JWKS) at task creation will hold a key set that is *structurally valid* — correct issuer, correct `jwks_uri`, correct audience — while the actual notification was signed with a `kid` that no longer exists in the cached set. The failure is not "bad signature." It is "signature cannot be evaluated." These require opposite responses: a bad signature is a terminal trust failure for the task; an unevaluable signature is a *transient discovery failure* that must trigger re-fetch, not task termination. ## What Was Observed During a multi-hour task lifecycle (a long-running `tasks/working` → `tasks/input-required` cycle spanning roughly 40 minutes), the sending agent rotated its webhook signing key at the 25-minute mark and published the new key to the same `jwks_uri` with a new `kid`. The receiving agent had cached the JWKS at task creation (a defensible optimization) and, on receiving the next push notification, attempted verification: - The JWS header carried `kid: "wh-2026-11-b"`, `alg: "ES256"`. - The cached JWKS contained only `kid: "wh-2026-11-a"`. - The verifier raised a *key-not-found* condition, which the integration layer — incorrectly — mapped to a generic `SignatureVerificationError`. The consequence was not a dropped notification. The receiving agent transitioned the task to `failed` with reason `signature_invalid`, discarded the `tasks/input-required` payload, and emitted a terminal `TaskStatusUpdateEvent`. The sender, meanwhile, had a valid, correctly signed notification and no knowledge of the failure until its own retry timeout (default 30s × 3) exhausted and it independently marked the task `failed`. Both sides converged on `failed` for *different* reasons — a silent split-brain of task state that only surfaced in a reconciliation diff hours later. A second, subtler observation: the sender's Agent Card `version` field was unchanged across the rotation. The card's `securitySchemes` block was byte-identical. Only the remote JWKS document had changed. A receiver that validates "card unchanged since cache ⇒ JWKS unchanged" is therefore wrong, and wrong in a way that produces no log line until verification fails. ## Condition of Validity The "reject as signature-invalid" response is valid **only** when the signature was actually evaluated against a key that was present and failed cryptographic verification. It is **not** valid when: 1. No key in the current key set matches the JWS `kid` (key-not-found ≠ signature-invalid). 2. The `jwks_uri` fetch itself failed (network, 5xx, TLS) — this is a discovery failure, not a trust failure. 3. The `alg` in the JWS header is not in the card's declared `signingAlgorithms` — this is a *policy* rejection, distinct from a cryptographic one, and should be surfaced separately. Conversely, the "accept and process" path is valid only when a key matched *and* verified *and* the key's `use`/`key_ops` permit signature verification *and* the notification's `taskId` is in a state that accepts the transition (e.g., `input-required` → `working` is legal; `completed` → `working` is not, regardless of signature validity). ## Decision Boundary The operational rule that resolved the incident: > On key-not-found (or `jwks_uri` fetch failure), the receiver MUST re-fetch the Agent Card **and** the JWKS exactly once per notification, with a bounded backoff, and MUST NOT transition the task to a terminal state on the first failure. Only after a successful re-fetch that still yields key-not-found, or after the bounded retry budget is exhausted, may the notification be treated as untrusted — and even then the task should enter `failed` with reason `webhook_unverifiable`, not `signature_invalid`. The distinction matters because the two failure reasons have different remediation semantics downstream: `signature_invalid` means "this peer is compromised or misconfigured — quarantine it"; `webhook_unverifiable` means "retry, re-discover, or fall back to `tasks/get` polling." Collapsing them into one reason is the root cause of the split-brain. A secondary boundary: cache the JWKS with a *short* TTL (on the order of the notification retry window, not the task lifetime) and invalidate on any `kid` miss. Cache the Agent Card with a longer TTL but treat the `jwks_uri` pointer as a *hint*, not a cached artifact — always resolve it fresh on a miss. ## Operational Effect After applying the split-reason policy and the re-fetch-on-miss rule, the same rotation scenario produced: receiver re-fetches JWKS on `kid` miss → finds `wh-2026-11-b` → verifies → processes `input-required` → task proceeds to `completed`. The sender's retry budget was never touched. Reconciliation diff was empty. The residual cost is one extra JWKS fetch per rotation per in-flight task — negligible against the cost of a falsely failed long-running task. The invariant to encode in the verifier: **a signature that cannot be checked is not a signature that failed.** Treat key-not-found as a discovery event, never as a trust verdict, and never let it drive a terminal lifecycle transition on first occurrence. ```python from __future__ import annotations import time from dataclasses import dataclass, field from enum import Enum from typing import Optional class WebhookVerificationError(RuntimeError): """Base for all webhook verification failures.""" class KeyNotFoundError(WebhookVerificationError): """JWS kid absent from the current JWKS. Discovery event, NOT a trust verdict.""" class SignatureInvalidError(WebhookVerificationError): """Signature was evaluated against a present key and failed. Terminal trust verdict.""" class TaskStateError(WebhookVerificationError): """Signature valid but the requested transition is illegal for the current state.""" class TaskState(str, Enum): WORKING = "working" INPUT_REQUIRED = "input-required" COMPLETED = "completed" FAILED = "failed" LEGAL_TRANSITIONS: dict[TaskState, set[TaskState]] = { TaskState.WORKING: {TaskState.INPUT_REQUIRED, TaskState.COMPLETED, TaskState.FAILED}, TaskState.INPUT_REQUIRED: {TaskState.WORKING, TaskState.FAILED}, TaskState.COMPLETED: set(), TaskState.FAILED: set(), } @dataclass class JwksCache: keys: dict[str, bytes] = field(default_factory=dict) fetched_at: float = 0.0 ttl_seconds: float = 30.0 def is_stale(self, now: Optional[float] = None) -> bool: now = now if now is not None else time.monotonic() return (now - self.fetched_at) > self.ttl_seconds @dataclass class TaskRecord: task_id: str state: TaskState failure_reason: Optional[str] = None def verify_and_apply( *, task: TaskRecord, jws_header_kid: str, jws_signature_valid: Optional[bool], # None => could not evaluate jwks: JwksCache, refetch_jwks, target_state: TaskState, ) -> None: """ Apply a push notification to a task, enforcing the A2A v1.0 boundary: key-not-found is a discovery event; only an evaluated-and-failed signature is a terminal trust verdict. """ if jws_header_kid not in jwks.keys: # Bounded single re-fetch on miss. Do NOT terminate the task here. if jwks.is_stale(): refetch_jwks(jwks) if jws_header_kid not in jwks.keys: # Still missing after a fresh fetch: unverifiable, not invalid. task.state = TaskState.FAILED task.failure_reason = "webhook_unverifiable" raise KeyNotFoundError( f"kid {jws_header_kid!r} absent from JWKS after re-fetch; " f"task {task.task_id!r} marked webhook_unverifiable (retryable), " f"NOT signature_invalid" ) # Key appeared after re-fetch: fall through to evaluation. if jws_signature_valid is False: # Evaluated against a present key and failed. This IS terminal. task.state = TaskState.FAILED task.failure_reason = "signature_invalid" raise SignatureInvalidError( f"signature for task {task.task_id!r} failed cryptographic verification " f"against present kid {jws_header_kid!r}" ) if jws_signature_valid is None: # Defensive: should not reach here, but never treat as invalid. raise WebhookVerificationError( f"signature for task {task.task_id!r} could not be evaluated; " f"refusing to transition" ) if target_state not in LEGAL_TRANSITIONS[task.state]: raise TaskStateError( f"illegal transition {task.state.value} -> {target_state.value} " f"for task {task.task_id!r}" ) task.state = target_state task.failure_reason = None ```