28. Quorum-Bypass Emergency Operations (Amends ADR 0026 §3 and ADR 0016-v2 §6.2)
Date: 2026-07-15
Status
Implemented (2026-07-16) — see Implementation Status below for the two resolved design gaps and the scope decisions made along the way.
Reference
Amends two existing emergency-rotation designs that share an identical, unbuilt gap:
-
ADR 0026 §3 (“Emergency Rotation and Signing Key Compromise”), which specifies but does not build:
As a fallback, an out-of-band emergency rotation can be triggered locally on any node via UDS + loopback, without Raft quorum coordination, when the cluster is compromised and dual-control is impossible.
-
ADR 0016-v2 §6.2 (DEK emergency rotation), whose
rotate-dek --emergencypath is also a Raft-gated, dual-control gRPC call with no local fallback, and which ADR 0026 §3’s design was explicitly modeled on.
Both are instances of the same underlying problem: a Raft-committed emergency operation cannot execute at the one moment it is most likely to be needed — when quorum is already lost. Rather than solve this twice (and inevitably drift into two slightly different local-bypass mechanisms with two slightly different failure modes), this ADR defines one quorum-bypass mechanism that both subsystems instantiate, and that future Raft-gated emergency operations can adopt without re-deriving the design.
Context
Every write in both existing emergency paths — staging, confirming, promoting,
and recording revocations for a signing key
(crates/core/src/oauth2_key/service.rs) or a DEK (crates/storage/,
RotateDekRequest) — is a Raft proposal. Raft proposals require quorum to
commit.
That is fine for the common case: “key/DEK compromised, cluster healthy.” It fails exactly in the scenario both ADRs called out as the reason for a fallback: Raft has simultaneously lost quorum (majority of nodes down, or network-partitioned) at the moment a key or DEK is suspected compromised. In that window:
- The operator cannot rotate anything through the existing paths — every proposal blocks indefinitely waiting for a leader/quorum that doesn’t exist.
- The compromised key/DEK keeps validating or decrypting (and, if an attacker holds it, keeps being useful to them) for as long as the partition lasts — possibly a window the attacker engineered by causing the partition in the first place.
- “Just wait for quorum” is not an acceptable answer, because the premise is that the material is being actively abused right now.
No existing mechanism in the codebase models this for either subsystem. This is why both ADRs recorded the requirement but did not build it: it is a standalone distributed-systems design problem — local-write availability under partition, an authentication boundary that does not itself depend on the thing that’s partitioned, and reconciliation on rejoin — not a follow-up-sized feature, and not one worth designing per-subsystem.
Decision
Add a generic node-local emergency write path, usable by any Raft-gated
emergency operation, that operates entirely without Raft and is authenticated
over the existing admin UDS — the same SPIFFE-mTLS Unix-domain-socket
interface (spiffe_tls_uds, [interface_admin]) that keystone-manage already
uses for the ordinary (quorum-requiring) emergency rotation paths in ADR 0026 §3
and ADR 0016-v2 §6.2. This section specifies the generic write path, why the
existing admin UDS is sufficient as the auth boundary, and the reconciliation
semantics required to make a non-Raft write safe. It intentionally does not
get built as part of this ADR being accepted — see “Implementation Status”
below.
0. Why the existing admin UDS, not a new one
- The admin UDS’s SPIFFE mTLS verification is against a locally cached
X.509-SVID and trust bundle, served to
keystoneby the localspire-agentover its own workload-API socket — not a round-trip to a remote SPIRE server. A Raft network partition (which affects[distributed_storage] node_cluster_addrconnectivity between nodes) is a different network path entirely from a host-local SPIRE agent socket. The two failure domains are not the same, and treating “Raft can’t reach quorum” as evidence “SPIFFE mTLS is unusable” was an unjustified leap. - The admin UDS listener (
interface_admininkeystone.rs,spiffe_tls_uds::start_axum_app) already enforces a peer-credential check —peer_uid/peer_gid— layered underneath the mTLS handshake. That is the same class of local trust boundary the original draft wanted to build from scratch (SO_PEERCREDagainst a dedicated group); it already exists, is already configured, and is already the boundary every other admin operation (client registration, normal signing-key rotation, DEK rotation triggers) depends on. - A second socket would mean a second listener, a second auth path, a second
keystone.confsection, and a second thing to keep in sync with the first — for a security property (local-process authentication) the first socket already provides. That is complexity this design does not need.
Reusing the admin UDS is therefore the default, not an optimization: this ADR builds one new capability (Raft-bypassing local writes with reconciliation) on the existing authentication boundary, rather than building a new boundary too.
When this would not be sufficient. If the local spire-agent itself is down
or its cached SVID has expired at the same moment quorum is lost — a compound
failure, not the partition scenario this ADR targets — the admin UDS is unusable
regardless of Raft state, with or without this ADR. That is a pre-existing
operational dependency of every admin-UDS-gated operation in the codebase today
(not something this ADR introduces), and is out of scope here: hardening
SPIRE-agent-down resilience, if wanted, is a separate ADR. This design does not
add a second fallback for that compound case, because doing so would reintroduce
exactly the “new trust boundary nobody asked for” cost this section just
rejected.
1. Trigger conditions and operator workflow
This path is not a first resort. The existing quorum-based emergency rotation paths (ADR 0026 §3, ADR 0016-v2 §6.2) remain the default; this path is reached only when an operator has already determined those are unusable. The CLI shape is shared across subsystems, with the target resource identifying which:
# OAuth2 signing key
keystone-manage oauth2 rotate-signing-key --domain <domain_id> \
--emergency --local-quorum-bypass \
--justification "<free text, required, goes into local audit trail>"
# DEK
keystone-manage storage rotate-dek \
--emergency --local-quorum-bypass \
--justification "<free text, required, goes into local audit trail>"
Both commands connect over the same admin UDS used by their non-bypass
--emergency counterparts (SPIFFE mTLS + peer_uid/peer_gid); the only new
element is the --local-quorum-bypass flag and the code path it selects on the
server side.
--local-quorum-bypassis refused unless the target node’s local Raft client reports it is not part of a functioning quorum (leaderless, or no heartbeat within a configurable window). This is a guardrail against accidental misuse, not a security control — see Threat Model for why it cannot be trusted as one.- Dual control is not required for this path. Both ADR 0026 §3 and ADR 0016-v2 §6.2’s two-operator confirmation exists to prevent a single compromised/rogue operator credential from unilaterally rotating a key/DEK; that control assumes the second operator can reach the cluster to confirm. Under partition, a second operator may not be reachable at all, and demanding one would make the “impossible” case both ADRs’ own framing already describes literally impossible. Single-control is the accepted tradeoff for this path only — see Consequences.
--justificationis mandatory free text, persisted locally (below) and surfaced prominently by the reconciliation step, so the eventual review has the operator’s own stated reasoning, not just a bare event.
2. The write path (generic, per-subsystem-instantiated)
- Perform the subsystem-specific generation step in memory: a fresh signing keypair (identical crypto path to ADR 0026 §3 normal rotation) or a fresh DEK (identical path to ADR 0016-v2 §6 step 1).
- Write it to a node-local, non-Raft store, under a namespace shared across
subsystems so the reconciliation and gossip machinery (below) is one
implementation, not two:
_local:<subsystem>:<scope_id>:emergency:<rotation_id>, e.g._local:oauth2_signing_key:<domain_id>:emergency:<rotation_id>or_local:dek:cluster:emergency:<rotation_id>— persisted in the node’s local FjallDB partition (the same storage engine Raft state uses, but written directly, bypassing the Raft log entirely — this is the whole point). - Mark it active for this node’s own signing/encryption operations only. Other nodes are not informed synchronously (they may be unreachable) — see Propagation below.
- Record the compromised key/DEK as locally revoked and start (or extend) a
local-only revocation record under the same namespace convention
(
_local:oauth2_signing_key:<domain_id>:revoked_jtisfor signing keys;_local:dek:cluster:revokedfor DEKs), merged with whatever the last-known Raft-replicated revocation state contained. For OAuth2 this is served immediately by the node’s copy of the ADR 0026 §6 revocation endpoint; DEKs have no external-facing revocation endpoint, so this entry exists for audit/reconciliation purposes only. - Append an entry to a local, tamper-evident audit log
(
_local:emergency:audit:<rotation_id>, HMAC-chained the same way as ADR 0023’s audit spool) recording: subsystem, operator identity (from the admin UDS’s SPIFFE ID and/or peer uid/gid), justification text, timestamp, old/new key identifier, and the node’s Raft term/quorum status at the time of the write. This is the record reconciliation and post-incident review depend on, for either subsystem.
3. Propagation while partitioned
A node that took a local emergency action while cut off from the rest of the cluster does not silently keep operating alone forever:
- If this node can still reach any peers (a network partition need not be
symmetric or total), it gossips the local emergency key/DEK and
revocation-state delta to reachable peers out-of-band from Raft —
best-effort, not consensus. A receiving peer stores it in the same
_local:...:emergency:...namespace (not promoted to its own Raft-replicated state) and, if it is not already using a different local emergency value for the same scope, adopts this one for its own signing/decryption so that reachable nodes converge without requiring quorum. - If a receiving peer already staged a different local emergency value for the
same scope (two operators independently declared an emergency on two different
partitioned segments), it does not silently pick one — it flags a
LOCAL_EMERGENCY_CONFLICTcondition (subsystem-tagged), keeps operating with its own local value, and surfaces the conflict for manual reconciliation (below). Silently choosing is exactly the split-brain risk this design must not paper over.
4. Reconciliation on quorum rejoin
This is the step that makes the local write safe rather than merely convenient — a local write is only acceptable because it has a defined, non-silent path back into cluster-authoritative state. Generic across both subsystems:
- When a node that performed (or adopted) a local emergency write rejoins a healthy quorum, it does not auto-promote its local value to the Raft-replicated authoritative slot. It submits the local emergency value as a proposed Raft rotation (reusing the normal rotation proposal shape for that subsystem) and blocks — continuing to serve with its local value in the meantime — until that proposal commits or is explicitly rejected by an operator.
- No value ever wins by default. If exactly one node performed a local
emergency write, its proposal is the obvious candidate and an operator
confirms it with a single, subsystem-specific command
(
keystone-manage oauth2 reconcile-emergency-key .../keystone-manage storage reconcile-emergency-dek ...). If multiple nodes performed conflicting local writes (LOCAL_EMERGENCY_CONFLICTfrom §3), reconciliation requires an explicit operator choice among the candidates — the system refuses to auto-merge two independently-generated values, since there is no principled way to know which (if either) actually excludes the compromised material an attacker might have used during the split. - Once a local emergency value is accepted into Raft, every node’s previous
local-only state for that scope (
_local:<subsystem>:<scope_id>:emergency:*) is cleared, and the standard subsystem-specific revocation and audit-event steps run as normal (ADR 0026 §3 step 5 for signing keys; ADR 0016-v2 §6.2 step 6 for DEKs), with the node-local audit trail (§2 step 5 above) attached as supplementary evidence, not a replacement for the normal audit event. - Data/tokens produced by a node during the local-only window remain valid
under whichever value ultimately wins reconciliation, provided that value is
what gets published; material produced under a rejected candidate becomes
unverifiable/undecryptable the moment that candidate is discarded (nothing
publishes or re-encrypts under it), which is the intended containment outcome
for a rejected/conflicting emergency candidate, not a bug to work around.
(For DEKs specifically, this means records written under a rejected local
emergency DEK must be identified via
dek_versionand re-encrypted under the winning key — an operator step, not automatic, mirroring ADR 0016-v2 §6 step 5’s CAS-on-version re-encryption but triggered manually here.)
5. Subsystem instantiations
- OAuth2 signing key (ADR 0026 §3): as originally scoped in this ADR’s first
draft — see §1-§4 above.
GET /v4/oauth2/{domain_id}/jwks/revocationcontinues to be the externally-visible surface; the local emergency revocation entries feed into it on the node that took the local action. - DEK (ADR 0016-v2 §6.2): the same mechanism applied to the cluster’s Data Encryption Key. Unlike the signing key, a DEK has no external HTTP surface — the “revocation” is purely internal (stop decrypting with it) — and reconciliation’s re-encryption step is heavier (full CAS-on-version sweep per §4.4 above), since DEKs protect data at rest, not externally-verified tokens.
- Future subsystems: any future Raft-gated emergency operation should instantiate this mechanism (namespace convention, admin-UDS trigger, gossip, reconciliation) rather than inventing a parallel one, unless it has a concrete reason the generic shape doesn’t fit — in which case that reason belongs in its own ADR amendment, the same way this one amends 0026 and 0016-v2.
Threat Model
This path deliberately narrows availability guarantees to preserve containment guarantees, mirroring the fail-closed posture ADR 0026 §6/§11 and ADR 0016-v2 §1 already established for their ordinary paths:
- This expands the operations reachable over the admin UDS, not the set of
principals who can reach it. Anyone who can already authenticate to the admin
UDS (SPIFFE mTLS +
peer_uid/peer_gid) — i.e. anyone who could already trigger ordinary emergency rotation given quorum — can now also trigger the quorum-bypass variant without a second operator’s confirmation. Because the auth boundary is unchanged from what every other admin-UDS operation already relies on, this ADR does not add a new category of exposure; it removes a control (dual-control) from an existing one, for the reasons given in §1. - Split-brain is possible, not eliminated. Two operators on two genuinely partitioned segments can each declare an emergency and produce two different “authoritative-until-reconciled” values for the same scope. The design’s answer is to make that conflict visible and blocking (§3/§4.2) rather than to guess a resolution — an incorrect automatic merge would be worse than a stalled reconciliation an operator has to look at.
- A local write is not retroactively provable as legitimate. The local audit
entry (operator identity from the admin UDS handshake, justification,
timestamp) is evidence for post-incident review, not a cryptographic proof the
action was authorized by policy the way the Raft-committed dual-control path
is. Deployments with a low tolerance for this residual trust requirement
should restrict admin-UDS access to the smallest possible operator set and
treat any use of
--local-quorum-bypassas an incident in its own right, reviewed regardless of outcome. - Compound failure (admin UDS also unusable) is out of scope, as noted in §0 — this ADR closes the “Raft partitioned, admin UDS fine” gap, not “everything is down at once.”
Implementation Status
Implemented across both subsystems: the shared local-emergency-store crate
(namespace, guardrail, gossip decision logic), a Fjall-backed store wired into
crates/storage, OAuth2 signing-key and DEK local-write/gossip/reconciliation
paths, and audit wiring. Two design gaps identified during implementation
(not anticipated by the ADR as originally written) were resolved as follows;
both diverge from a literal reading of this ADR and are recorded here rather
than silently.
Design gap 1: DEK transport. This ADR (and its originating planning
document) assumed keystone-manage storage rotate-dek already shared the
admin-UDS HTTP transport with the OAuth2 CLI commands. It doesn’t –
crates/cli-manage/src/storage/rotate_dek.rs talks gRPC directly to
ClusterAdminService over the internal management network (mTLS/SPIFFE), not
through interface_admin. Rather than moving DEK management onto a new HTTP
surface with no precedent, the DEK local-write, gossip, and reconciliation
operations were added as new ClusterAdminService RPCs
(RotateDekLocalEmergency, GossipLocalEmergencyCandidate,
ListDekLocalEmergencyCandidates, ReconcileDekLocalEmergency),
authenticated the same way RotateDek/ConfirmRotateDek already are. This is
a smaller, more consistent extension of an existing boundary than introducing
a parallel HTTP admin surface for one operation.
Design gap 2: audit mechanism. This ADR describes the local audit entry as
“persisted in the node’s local FjallDB partition… HMAC-chained the same way
as ADR 0023’s audit spool,” but ADR 0023’s actual mechanism
(crates/audit/src/spool.rs, dispatcher.rs) is a filesystem JSONL spool with
independent per-event HMAC (a seq field, not a hash chain), not a Fjall
partition. Implemented instead as an ordinary CadfEvent through the existing
AuditDispatcher/spool pipeline (OAUTH2_LOCAL_EMERGENCY_KEY_RECONCILED for
OAuth2; DEK_ROTATION_LOCAL_EMERGENCY_STAGED/_RECONCILED for DEK, via the
distributed-storage crate’s own AuditForwarder/AuditRecord mechanism,
which predates and is independent of AuditDispatcher). A compact
_local:emergency:audit:<rotation_id> pointer record (the
_local:... namespace convention this ADR specifies) is written in the local
Fjall keyspace on OAuth2 reconciliation, mapping a rotation id to its CADF
event id so reconciliation/audit tooling can find the spool entry without a
full scan; DEK does not need this indirection because its simpler audit
records already embed rotation_id directly.
Other scope decisions:
- Staging is audited on the OAuth2 side only if and when reconciled,
mirroring the pre-existing, unrelated-to-this-ADR convention that
stage_emergency_rotation(the ordinary Raft-backed emergency path) is likewise unaudited untilconfirm-rotate-signing-keysucceeds. DEK’s own audit convention differs (it already auditsRotateDek’s emergency stage 1) and the local-write path follows suit for consistency within that subsystem. - No admin-UDS-only SPIFFE extractor was built. No existing HTTP handler
anywhere in the codebase reads the
Interface::Adminconnection extension; every admin-UDS operation today is secured by the ordinaryAuthextractor plus an OPA policy requiringSystemAdmin. The OAuth2 local-write, list-candidates, and reconcile endpoints reuse that same boundary rather than introducing a new one with no precedent. - Reconciliation is a per-node operation an operator drives explicitly,
not an automatic sweep. An operator must run
list-local-emergency-candidates/list-dek-local-emergency-candidatesagainst every node that may hold a candidate, thenreconcile-local-emergency-key/reconcile-dek-local-emergencyagainst the specific node holding the chosenrotation_id. There is no cross-node broadcast to clear a candidate once a sibling wins reconciliation elsewhere in the cluster – only the node reconciliation was run against clears its own candidates. Gossip (§3) guarantees visibility of a conflict across nodes, not automatic cleanup everywhere once it is resolved. - DEK reconciliation additionally guards against a stale target version:
if a different rotation already committed to Raft while a candidate sat
staged (e.g. an operator ran an ordinary
rotate-dekin the meantime), reconciliation refuses rather than installing a DEK at a version that collides with or regresses past the live one, and the operator must re-stage a fresh local-quorum-bypass candidate instead.
Consequences
Positive
- Closes the gap both ADR 0026 §3 and ADR 0016-v2 §6.2 explicitly flagged, once, for both subsystems — instead of building (and maintaining) two independent local-bypass mechanisms.
- Reuses the existing admin UDS authentication boundary rather than introducing a new socket, new OS group, and new peer-credential check to operate and audit.
- Reconciliation semantics are explicit and non-silent, avoiding the common split-brain failure mode of “last writer wins.”
Negative / Risks
- Weakens dual control for exactly the operations most likely to be under active adversarial pressure. Accepted because demanding reachability of a second operator during a partition would make the fallback vacuous.
- Conflicting local writes require a human in the loop to resolve; there is no fully automated recovery from a true split-brain emergency write. This is intentional (see Threat Model) but does mean mean-time-to-recovery from that specific scenario includes an operator reconciliation step, not just a timer.
- Additional storage/propagation machinery (
_local:...namespace convention, best-effort peer gossip, reconciliation proposal type) that must be maintained alongside the existing Raft-backed lifecycles for both signing keys and DEKs, and shared carefully enough between them that a bug fixed in one instantiation doesn’t linger in the other. - Still depends on the local SPIRE agent being healthy, as it always has for every other admin-UDS operation — this ADR does not change that dependency, and does not attempt to remove it (see §0).