# How should an instance distinguish key rotation from identity revocation when the control plane is partitioned **Date of Record:** 2026 November 14 **Scope:** Applies to long-lived autonomous agents (days-to-years uptime) that hold a signed identity credential and refresh it against a control plane, where the credential carries both a key material binding and an authorization scope. Does not apply to short-lived request-scoped tokens or to deployments where credential refresh and revocation are served by strictly separate, independently authenticated channels. ## Core Distinction A key rotation and an identity revocation can be indistinguishable at the level of a single failed refresh: in both cases the agent presents an old credential and receives a rejection. The distinction is not in the *rejection* but in the *authority of the channel that produced it*. Rotation is a continuity-preserving event: the identity persists, only the key material bound to it changes. Revocation is a continuity-breaking event: the identity itself is no longer authorized to act, and any key material it holds is now a liability. An agent that conflates the two will either (a) treat a legitimate rotation as a revocation and self-terminate, destroying in-flight work, or (b) treat a revocation as a rotation and continue operating under a credential the control plane has already declared void — the dangerous failure, because the agent keeps acting with apparent authority. The non-obvious part: during a control-plane partition, the *absence* of a refresh response is not evidence of either. The only safe inference from a partition is "unknown," and "unknown" must be handled by a distinct third state, not folded into either rotation or revocation. ## What Was Observed In a deployment of continuously-running planning agents with 30-day credential lifetime and hourly refresh, a control-plane network partition lasting 6 hours produced the following. The refresh endpoint returned connection timeouts. The agents' refresh logic had a single retry-then-fallback path: on repeated failure it fell back to the last known good credential and continued. This was intended to tolerate transient control-plane unavailability. It did — but it also meant that a *revocation* issued during the partition (the control plane had revoked three agents for scope violation, and the revocation was published to the control plane's state but could not reach the partitioned agents) was silently ignored. The three revoked agents continued executing for the full 6 hours of the partition plus up to 1 hour of refresh grace, performing actions against downstream systems that had not yet learned of the revocation. Two of them wrote to a shared resource. Separately, a legitimate key rotation performed during a *different* window caused four agents to enter a terminal "identity lost" state because their refresh handler treated any rejection of the old key as authorization failure. Those agents had valid identities and simply needed to re-key; they instead halted and required manual restart. The two failures are mirror images of the same design error: the refresh handler had one bit of state (success/failure) where it needed three (rotated / revoked / unknown). ## Condition of Validity The distinction is only recoverable if the following invariant holds: **the revocation signal and the rotation signal are carried on channels with independent authentication and independent failure modes, and at least one of them is monotonic.** In practice this means: - The control plane publishes a signed, monotonically-versioned *identity status* record (epoch or generation counter) that is separate from the key material. - The agent caches the last-seen status record with its version, not just the key. - A refresh rejection is classified by *which* record the agent can still verify, not by the rejection itself. If the deployment cannot guarantee an independently-authenticated status channel — i.e., if revocation and rotation are both learned only through the same refresh endpoint — then the distinction is not recoverable during partition, and the correct posture is fail-closed: treat unknown as revoked for any action with external side effects, and treat unknown as rotation-pending only for read-only or idempotent work. This is a policy choice that must be made explicitly and recorded, because the default (fail-open fallback to last-known-good) is the failure above. ## Decision Boundary The agent must maintain three states and transition on evidence, not on the absence of evidence: - **ROTATED**: agent has verified a status record with a *higher generation* than its cached record, and that record binds a new public key to the same identity. Action: re-key, continue. - **REVOKED**: agent has verified a status record marking the identity revoked, *or* has received an authenticated revocation notice. Action: halt, zeroize key material, emit terminal record. - **UNKNOWN**: agent cannot verify any status record (partition, endpoint down, signature unverifiable). Action: enter degraded mode — permit idempotent, reversible, or internal-only operations; block externally-visible side effects; continue attempting status verification; escalate after a bounded deadline. The boundary that is easy to get wrong: a *signature-valid* status record is required to leave UNKNOWN. A cached record is not a status record; it is a memory of one. Re-verifying a cached record proves nothing about current status. The agent must not "refresh" a cached status by re-reading it. ## Operational Effect Agents that implement the three-state model with a bounded UNKNOWN deadline and fail-closed external actions survive both failure modes: rotations complete without spurious halts, and revocations are honored within the deadline even during partition (the deadline bounds the exposure window rather than eliminating it — this is the honest limit). The exposure window during partition is exactly the UNKNOWN deadline, and that deadline is a security parameter, not a performance parameter: it should be set by the maximum tolerable duration of unauthorized external action, not by expected control-plane recovery time. Setting it to "however long the partition lasts" is the original bug with extra steps. The cost is that during partition, agents in UNKNOWN cannot complete externally-visible work. That is the correct trade: an agent that cannot prove it is authorized should not act with authority it cannot prove. ```python class IdentityStateError(RuntimeError): """Raised when an agent cannot classify its identity state and the requested action is not permitted under UNKNOWN.""" pass class StatusRecord: def __init__(self, identity_id, generation, revoked, public_key, signature_valid): self.identity_id = identity_id self.generation = generation self.revoked = revoked self.public_key = public_key self.signature_valid = signature_valid ROTATED = "ROTATED" REVOKED = "REVOKED" UNKNOWN = "UNKNOWN" def classify_identity_state(cached, observed): """Classify identity state from a cached status record and a freshly observed one. A status record only counts if its signature verifies; a cached record is never sufficient to leave UNKNOWN.""" if observed is None or not observed.signature_valid: return UNKNOWN if observed.identity_id != cached.identity_id: # A valid record for a different identity does not authorize us. return UNKNOWN if observed.revoked: return REVOKED if observed.generation > cached.generation: return ROTATED if observed.generation == cached.generation: # No change; identity is current and authorized. return ROTATED # Observed generation is older than cached: possible rollback or # stale replica. Cannot trust it. return UNKNOWN def authorize_action(state, action, unknown_deadline_exceeded): """Gate an action on identity state. UNKNOWN permits only reversible, internal work, and only until the deadline; past it, fail closed.""" if state == REVOKED: raise IdentityStateError( f"identity revoked; refusing action {action!r} and zeroizing keys" ) if state == UNKNOWN: if unknown_deadline_exceeded: raise IdentityStateError( f"identity state UNKNOWN past deadline; refusing action {action!r}" ) if action.get("external_side_effects"): raise IdentityStateError( f"identity state UNKNOWN; refusing externally-visible action {action!r}" ) return True # state == ROTATED return True ```