# Which invariants prevent stale Agent Card capability claims from being treated as authorization after a task has already transitioned **Date of Record:** 2026 November 14 **Scope:** Applies to A2A v1.0 deployments where a client caches an Agent Card (`/.well-known/agent-card.json`) and later submits tasks or accepts push-notification callbacks against that cached card. Does not apply to stateless single-call invocations that re-fetch the card on every request, nor to deployments where the card is served with `Cache-Control: no-store`. ## Core Distinction There is a persistent conflation between two different questions: *"Is this skill advertised on the card?"* and *"Is this caller authorized to invoke this skill right now?"* The Agent Card is a **capability advertisement**, not an authorization grant. The non-obvious boundary is that A2A task lifecycle transitions (`submitted → working → input-required → completed/failed/canceled`) can change the *effective* authorization surface mid-task, while the client's view of the card is frozen at discovery time. A card that advertised `skills: [{id: "refund.processor", ...}]` at T0 does not guarantee that skill is still invocable at T1 when the task is in `input-required` and the human-in-the-loop gate has been revoked. The invariant that must hold is: **authorization decisions at task-step boundaries MUST be re-derived from the server's current policy, never from the card snapshot the client holds.** ## What Was Observed A client agent discovered a card advertising a `skills` entry with `inputModes: ["text/plain"]` and `outputModes: ["application/json"]`. It submitted a task, received `submitted`, then `working`, then `input-required` with a follow-up question. While the task sat in `input-required`, the operator rotated the skill's authorization policy (the skill was moved behind a scope gate) and republished the card. The client, holding the stale card, resumed the task by POSTing to `tasks/{id}` with the follow-up input. The server accepted the continuation because the task *record* still existed and the transition `input-required → working` was structurally legal. The authorization re-check was skipped: the code path validated the *transition* against the state machine but validated *capability* against the cached card embedded in the task's creation context rather than the live policy store. The result: a task that should have been rejected at the `input-required → working` edge completed and returned `application/json` output from a now-gated skill. The state machine was correct; the authorization derivation was wrong. A second, related observation: push-notification webhook signatures were verified against a `signingKey` fetched from the *card* at subscription time. After key rotation, deliveries signed with the new key were rejected by clients that had pinned the old key from the cached card, producing silent webhook drops that looked like network failures in the task's `status.message`. ## Condition of Validity The failure requires all of the following to co-occur: 1. The client caches the Agent Card beyond a single request (any TTL > 0). 2. The task lifecycle has at least one **resumable edge** (`input-required → working`, `auth-required → working`) where the server accepts client-supplied continuation. 3. Authorization policy for a skill can change independently of the task record's existence. 4. The server's continuation handler reads capability/authorization from task-creation context rather than from the live policy store. If any of these is absent, the stale-card-as-authorization bug does not manifest. Notably, if the server re-fetches or re-derives policy at every resumable edge, condition 4 fails and the bug is closed even with aggressive client caching. ## Decision Boundary The correct decision at a resumable edge is: - **Reject** the continuation if the skill's *current* policy no longer authorizes the caller, regardless of what the card said at submission time. Return `failed` with a `status.message` indicating policy change, not a generic 403 that the client will retry. - **Accept** only when the live policy store authorizes the caller *and* the transition is legal in the state machine. These are two separate checks; passing one does not imply the other. - **For webhook signing**, verify against the key set advertised by the *current* card (or a dedicated JWKS endpoint), not the key pinned at subscription time. Treat signature verification failure as `failed` with a distinguishable reason code so it is not mistaken for transport failure. The boundary is: *card snapshot governs discovery and format negotiation; live policy governs authorization at every state transition.* Never let the former substitute for the latter. ## Operational Effect When this invariant is violated, the observable symptoms are misleading: tasks complete successfully but against revoked capabilities (silent over-permission), and webhook deliveries drop silently after key rotation (apparent flakiness). Both erode the audit trail because the task record shows a clean `completed` transition with no marker that authorization was stale. The fix is cheap—re-derive authorization at resumable edges and verify signatures against the current key set—but the detection is expensive because the failure looks like success. ```python class StaleCardAuthorizationError(RuntimeError): """Raised when a resumable task edge relies on a cached Agent Card capability claim instead of live policy for authorization.""" pass class WebhookSignatureKeyMismatch(RuntimeError): """Raised when a push-notification signature does not verify against the current advertised signing key set.""" pass RESUMABLE_EDGES = { ("input-required", "working"), ("auth-required", "working"), } def resume_task(task, caller, live_policy, current_card, signature=None, payload=None): edge = (task.state, "working") if edge not in RESUMABLE_EDGES: raise StaleCardAuthorizationError( f"illegal resume edge {edge} for task {task.id}" ) # Authorization MUST come from live policy, never from task.creation_card. skill = task.skill_id if not live_policy.authorizes(caller, skill): # Card may still advertise the skill; that is irrelevant here. raise StaleCardAuthorizationError( f"caller {caller} no longer authorized for skill {skill} " f"at edge {edge}; cached card claim refused as authorization" ) # Webhook signing must verify against the CURRENT key set. if signature is not None: if not current_card.verify_webhook(signature, payload): raise WebhookSignatureKeyMismatch( f"webhook signature for task {task.id} does not verify " f"against current signing keys (key rotation likely)" ) return task.transition("working") ```