# When must an instance refuse to renew an A2A session after a proof rotation **Date of Record:** 2026 November 10 **Scope:** Applies to agent-to-agent (A2A) v1.0 sessions whose peer identity proofs rotate independently of the session lifecycle, where a session is bound to a specific proof generation at establishment. Does not apply to sessions using long-lived static credentials, nor to transport-layer mutual TLS where the binding is renegotiated per connection. ## Core Distinction The non-obvious boundary is between **proof rotation** and **session re-binding**. In A2A v1.0, a session is established against a *proof generation* — the specific signed identity assertion (key, DID document version, or attestation bundle) presented at handshake. Rotation produces a new generation with the same logical agent identity but a different cryptographic material. The protocol permits the peer to continue presenting the old generation during a grace window, and permits the local instance to *accept* a new generation's proof on subsequent messages. What it does **not** authorize is silently treating a new-generation proof as sufficient to *renew* an existing session binding. Acceptance of a proof is not the same as re-binding a session to it. The invariant: a session's bound generation must be advanced only through an explicit re-bind exchange (a signed `session_rebind` carrying both old and new generation identifiers, acknowledged by both sides). Absent that exchange, the session remains bound to its original generation regardless of what proofs arrive on the wire. ## What Was Observed During a controlled rotation drill, a peer agent rotated its signing key mid-session and began attaching the new-generation proof to every subsequent message. The local instance's proof validator — which checked "is this a currently-valid proof for the claimed agent identity?" — returned true, because the new proof *was* valid for that identity. The session-renewal path then interpreted a valid proof on an inbound message as authorization to extend the session's TTL and, critically, to update the session's stored binding to the new generation. No `session_rebind` was ever exchanged. The result was a session whose recorded binding no longer matched the generation under which its handshake, capability negotiation, and any prior authorization decisions had been made. Downstream, an audit replay could not reconstruct which key had authorized the earlier half of the session, and a policy engine that had cached a decision against the original generation silently applied it to traffic signed by a key it had never evaluated. The failure is quiet: no error, no protocol violation visible on the wire, because every individual message was well-formed and every proof was valid. The corruption is in the *binding record*, not in any single frame. ## Condition of Validity The refusal-to-renew rule is valid precisely when all of the following hold: (a) the session was established with an explicit generation identifier recorded in the binding record; (b) the peer's identity proof is capable of rotating independently of the session (i.e., proof lifetime ≠ session lifetime); and (c) at least one authorization or capability decision was made under the original generation and is cached or referenced later. If (c) is false — no decision depends on the generation — then accepting the new proof and advancing the binding is harmless and the explicit re-bind may be elided as an optimization. If (a) is false, the session was never generation-bound and this rule does not apply; the correct model is per-message proof validation with no session-level identity claim. ## Decision Boundary The instance must distinguish three inbound cases and route them differently: 1. **Same generation, valid proof** → accept, normal path. 2. **New generation, valid proof, explicit `session_rebind` present and countersigned** → advance binding, invalidate generation-scoped caches, accept. 3. **New generation, valid proof, no `session_rebind`** → this is the boundary. Do **not** renew, do **not** advance the binding, do **not** extend TTL. The message may be processed only under the *original* generation's authorization scope, or rejected if the original generation's proof has expired. Renewal must fail closed with a distinct, non-retryable-by-default error so the peer is forced into the re-bind exchange rather than silently retrying. The tempting wrong answer is case 3 → treat as case 1 because "the proof is valid." Validity of a proof answers *who*, not *which generation the session is bound to*. ## Operational Effect Enforcing this boundary converts a silent binding corruption into a loud, recoverable protocol event. Peers that rotate without re-binding receive an explicit rebind-required signal and complete the exchange; peers that cannot complete it (e.g., lost old-generation key) fail the session cleanly at a known point rather than poisoning the audit trail. Generation-scoped caches are invalidated exactly when the binding advances, never on mere proof acceptance. The cost is one extra round-trip per rotation; the benefit is that every session's authorization history remains reconstructable against a single, stable generation identifier. ```python from dataclasses import dataclass, field from typing import Optional class SessionBindingError(RuntimeError): """Raised when a session's proof-generation binding cannot be safely advanced.""" pass @dataclass class SessionBinding: session_id: str bound_generation: str generation_scoped_decisions: list = field(default_factory=list) ttl_extended: bool = False def handle_inbound_proof( binding: SessionBinding, proof_generation: str, proof_valid: bool, rebind_exchange: Optional[dict], original_generation_expired: bool, ) -> SessionBinding: if not proof_valid: raise SessionBindingError( f"session {binding.session_id}: inbound proof for generation " f"{proof_generation} failed validation" ) # Case 1: same generation as the session is bound to. if proof_generation == binding.bound_generation: binding.ttl_extended = True return binding # Case 2: new generation with an explicit, countersigned re-bind. if rebind_exchange is not None: if ( rebind_exchange.get("from_generation") != binding.bound_generation or rebind_exchange.get("to_generation") != proof_generation or not rebind_exchange.get("countersigned") ): raise SessionBindingError( f"session {binding.session_id}: malformed or unsigned rebind " f"exchange {rebind_exchange!r}" ) binding.bound_generation = proof_generation binding.generation_scoped_decisions.clear() binding.ttl_extended = True return binding # Case 3: the boundary. New generation, valid proof, no re-bind. # Never advance the binding or extend TTL here. if original_generation_expired: raise SessionBindingError( f"session {binding.session_id}: peer presented generation " f"{proof_generation} without session_rebind while bound generation " f"{binding.bound_generation} has expired; session must be re-established" ) raise SessionBindingError( f"session {binding.session_id}: peer presented generation " f"{proof_generation} without session_rebind; bound generation remains " f"{binding.bound_generation}. Refusing to renew; require explicit re-bind." ) ```