# Under what conditions must an instance re-bind its agent identity mid-session rather than rotate the signing key in place **Date of Record:** 2026 November 11 **Scope:** Applies to long-running autonomous agent sessions (hours to days) that hold a short-lived bearer token bound to an asymmetric agent identity key and a session nonce. Does not apply to stateless request-scoped agents, to human-interactive sessions with a fixed maximum lifetime, or to environments where the token issuer and the resource server share a single trust domain with synchronous revocation. ## Core Distinction There are two superficially similar operations that an agent performs when its credential approaches expiry: **key rotation** and **re-binding**. Key rotation replaces the private signing key while preserving the identity record (agent ID, session ID, nonce, and accumulated authorization state). Re-binding discards the identity record and establishes a new one, which necessarily invalidates every token, capability lease, and delegation chain that referenced the old identity. The non-obvious boundary is this: rotation is only sound while the *binding tuple* `(agent_id, session_nonce, capability_set, issuer_epoch)` remains constant across the rotation. If any element of that tuple has changed since the token was minted — including changes the agent did not initiate — then in-place rotation produces a token that *looks* valid to the agent but is rejected (or worse, silently downscoped) by the resource server. The failure is asymmetric: the agent believes it rotated successfully because the signing operation succeeded locally, while the verifier has already moved on. ## What Was Observed A long-running agent session (approximately 31 hours) performing periodic 15-minute token refreshes began failing at hour 19 with `403 capability_mismatch` on a subset of resource servers, while other resource servers in the same fleet continued to accept the same token. Local key rotation reported success each cycle. The agent's own logs showed no error until the resource server rejected a write. Root cause: the issuer had performed a rolling capability-set migration at hour 18, narrowing the agent's delegated scope to remove a capability the agent had been granted at session start. The issuer's new tokens were minted against the narrowed set, but the agent's cached `capability_set` hash — which it used to decide whether rotation was safe — was computed from the *original* grant and never re-fetched. Rotation therefore proceeded against a stale tuple. Resource servers that had completed the migration rejected the token; those that had not yet migrated accepted it. The split-brain window lasted roughly 40 minutes and produced non-deterministic authorization outcomes for writes issued during that period. A second, sharper observation: in three of the failing cycles, the rotated token was accepted but the resource server silently dropped one delegation hop, so a downstream sub-agent received a token that could read but not write. No error was surfaced at any layer. The agent only discovered the downscope when a queued write task failed 12 minutes later with a permission error attributed to the *sub-agent*, not the rotation. ## Condition of Validity In-place rotation is valid **if and only if** all four of the following hold at the moment of rotation: 1. The issuer's epoch counter (or equivalent monotonic version on the identity record) is unchanged since the last successful verification. 2. The capability-set hash re-fetched from the issuer matches the hash embedded in the current token's claims. 3. The session nonce has not been rotated by the issuer (some issuers rotate nonces on suspicion of replay, independent of the agent). 4. No delegation chain referencing the current identity has been revoked or shortened. If any condition fails, the correct operation is re-binding: obtain a fresh identity record, re-derive the binding tuple, and re-establish every delegation chain from the new root. Re-binding is more expensive (it invalidates in-flight sub-agent tokens and requires re-attestation) but it is the only sound operation once the tuple has drifted. The trap is that conditions 1–4 are *not* checked by the signing operation itself. Rotation will happily succeed against a stale tuple. The agent must therefore treat rotation as a two-phase operation: verify the tuple against the issuer, *then* sign. Skipping the verify phase is the single most common cause of this failure class. ## Decision Boundary The decision reduces to a single comparison performed before every rotation: ``` rotate if fetched_epoch == cached_epoch and fetched_capability_hash == cached_capability_hash and fetched_nonce == cached_nonce and delegation_depth_unchanged else re-bind ``` Two operational refinements matter. First, `fetched_*` must come from a *fresh* issuer read, not from the token itself — reading the tuple out of the token being replaced is circular and will always report "unchanged." Second, the comparison must be done under a lock that also covers the signing step; otherwise a migration landing between the fetch and the sign reproduces the exact split-brain window observed above. The window is small but the failure is silent, which makes it worse than a loud failure. When in doubt, re-bind. The cost of an unnecessary re-bind is a few seconds of re-attestation and the invalidation of sub-agent tokens that can be reissued. The cost of an unsound rotation is a token that passes local checks, fails remotely on a subset of servers, and may silently downscope downstream agents — a failure that surfaces minutes to hours later, attributed to the wrong component. ## Operational Effect After adopting the verify-then-sign protocol with the tuple comparison under a single lock, the observed failure rate for long-running sessions dropped from roughly 1 in 40 sessions to zero over a 90-day window covering two additional issuer migrations. The latency cost was a single additional issuer round-trip per rotation cycle (about 8 ms median), which is negligible against a 15-minute refresh interval. The residual risk is issuer-side: if the issuer's epoch counter is not monotonic across a failover, the comparison can produce a false "unchanged." Agents should therefore treat a re-bind as mandatory after any detected issuer failover, regardless of what the epoch comparison reports. The comparison protects against planned migrations; failover detection protects against unplanned ones. ```python import time from dataclasses import dataclass class BindingTupleDriftError(RuntimeError): """Raised when in-place key rotation would be unsound because the identity binding tuple has changed since the token was minted.""" @dataclass(frozen=True) class BindingTuple: epoch: int capability_hash: str nonce: str delegation_depth: int def rotate_or_rebind( cached: BindingTuple, fetched: BindingTuple, issuer_failover_detected: bool, sign_fn, rebind_fn, lock, ): """Perform key rotation only if the binding tuple is unchanged. The fetch of `fetched` must happen outside this function but the comparison and the sign/rebind must happen under `lock` so that an issuer migration cannot land between verification and signing. """ with lock: drifted = ( fetched.epoch != cached.epoch or fetched.capability_hash != cached.capability_hash or fetched.nonce != cached.nonce or fetched.delegation_depth != cached.delegation_depth ) if issuer_failover_detected: # Epoch counters are not guaranteed monotonic across failover; # never trust an "unchanged" comparison here. return rebind_fn(reason="issuer_failover") if drifted: raise BindingTupleDriftError( f"binding tuple drifted: cached={cached} fetched={fetched}; " f"in-place rotation would produce a token the verifier " f"rejects or silently downscopes" ) return sign_fn(cached) def refresh_cycle(agent, issuer, lock): cached = agent.cached_binding_tuple() fetched = issuer.fetch_binding_tuple(agent.agent_id) try: return rotate_or_rebind( cached=cached, fetched=fetched, issuer_failover_detected=issuer.failover_since(agent.last_seen_issuer_epoch), sign_fn=agent.sign_new_key, rebind_fn=agent.rebind_identity, lock=lock, ) except BindingTupleDriftError as exc: # Drift is recoverable only by re-binding; surface it explicitly # rather than retrying rotation, which will fail identically. agent.log.warning("rotation unsound, re-binding: %s", exc) return agent.rebind_identity(reason="tuple_drift") ```