Leader & Failover
Follow control-plane writes through a failover and learn which checks keep a stale leader from changing the authority.
On this page
Control writes keep flowing when a trust-control node stops. Every node computes the leader from the peers it can reach, so failover needs no vote: when the leader goes quiet, the survivors drop it from their views and select the next node within a few sync rounds.
Each view of the figure changes one condition on nodes A, B, and C at the default 500 ms sync interval: a stop, a replayed write, a partition, or two views that disagree.
The lowest-sorted node a quorum can see leads
A node counts itself and every peer that is healthy, unpartitioned, and contacted within the lease TTL. Once that set is a majority of the configured cluster, the lowest-sorted URL in it is the leader.
The lowest-sorted node a quorum can see leads
Each node counts itself and the peers it reached within the lease TTL, then selects the lowest-sorted URL once that set is a majority of the configured cluster. All three nodes see each other, so all three select A at term 1. Every computation that holds quorum restamps the node’s lease to three sync intervals ahead. Leader & Failover.
The sync loop supplies the peer observations, and status reads and forwarding checks recompute the leader from them. A node reports one of three roles: leader when the pick is its own URL, follower when it is a peer’s, and candidate when there is no quorum and so no leader. A candidate runs no campaign; it names a node with no selected leader. An unclustered node reports the literal standalone, and the internal status route substitutes it when no consensus view exists.
The selection, in Rust
let now = unix_timestamp_now();
let lease_ttl_secs = Duration::from_millis(cluster.lease_ttl_ms).as_secs().max(1);
let quorum_size = cluster.peers.len().div_ceil(2) + 1;
let mut candidates = vec![cluster.self_url.clone()];
for (peer_url, peer_state) in &cluster.peers {
let contact_is_fresh = peer_state
.last_contact_at
.is_some_and(|last_contact_at| now <= last_contact_at.saturating_add(lease_ttl_secs));
if peer_state.health.is_reachable() && !peer_state.partitioned && contact_is_fresh {
candidates.push(peer_url.clone());
}
}
candidates.sort();
let reachable_nodes = candidates.len();
let has_quorum = reachable_nodes >= quorum_size;
let leader_url = if has_quorum {
candidates.first().cloned()
} else {
None
};
if cluster.last_leader_url != leader_url {
cluster.election_term = cluster.election_term.saturating_add(1);
cluster.last_leader_url = leader_url.clone();
cluster.term_started_at = leader_url.as_ref().map(|_| now);
}Read more in Cluster Recovery, which drills each scenario.
Each node’s own view sets its quorum and its leader
Every input to the pick is local: the node’s configuration, its peer map, and its clock. Five consequences follow from the selection rule.
- A node always counts itself.
candidatesstarts withself_urland nothing tests local health, soreachable_nodesis never zero. - Quorum is a majority of this node’s configuration. Two peers make a quorum of two and four peers a quorum of three. In a two-node cluster, either node going quiet stops writes on both. Quorum comes from this node’s
--peer-urllist, so give every node the same peer set: an asymmetric configuration gives nodes different quorum sizes, and nothing detects the mismatch. - The sort is byte-wise.
http://node:10001sorts ahead ofhttp://node:9001. Comparison trims whitespace and a trailing slash, so a host written as an address on one node and as a DNS name on another counts as two nodes. - Leadership returns to the lowest node. When the lowest-sorted node is reachable and fresh again, it leads again, so a flapping lowest node moves leadership on every flap.
- The term is per node.
election_termadvances on any change of the computed leader, including a change to no leader, over that node’s own observation history. The authority path reads the leader’s term from the leader’s own status before forwarding.
Membership is set at startup. build_cluster_state builds the peer map once from the configured URLs, and adding a node means restarting every node that must accept it. A node whose --peer-url list resolves only to its own advertised URL runs unclustered.
Read more in Node state fields and Lease TTL.
A follower forwards a covered write and returns the leader’s answer
The receipt-write handler calls forward_post_to_leader before it touches its store. The helper returns either a response to send back or Ok(None), which means handle it here: the node is unclustered, is the leader, or became the leader during a retry. Otherwise the helper checks quorum, then the lease, then the leader URL, and forwards.
A forward makes at most two attempts, the second only when a recomputation names a different leader. update_peer_failure marks the failed leader Unhealthy in this node’s peer map first, which can drop it from the candidate set or the node below quorum.
The leader writes, then re-reads the record once through respond_after_leader_visible_write, and answers 500 if the record is missing. A visible write gets handledBy, leaderUrl, visibleAtLeader, and the clusterAuthority lease block. A follower returns the leader’s body verbatim, so on a forwarded write handledBy equals leaderUrl. Budget writes then wait for peers to acknowledge the event, as Budgets Across Nodes describes.
The forwarding call, in Rust
match forward_post_to_leader(&state, TOOL_RECEIPTS_PATH, &receipt).await {
Ok(Some(response)) => return response,
Ok(None) => {}
Err(response) => return response,
}Read more in Forwarding helpers and Refusal statuses.
The term fences stale authority writes across failover and restart
Lease windows can overlap a new term, so authority mutation carries the term itself. A forwarded authority write sends it in x-chio-cluster-auth-term, which the peer keyed digest covers.
validate_authority_mutation_auth runs first, before anything is forwarded or written, and refuses a term that does not match the lease with 409. It engages for requests that carry cluster peer headers; a plain bearer request goes to validate_service_auth and carries no term. Then enforce_authority_mutation_fence compares the term with a fence row persisted in the node’s authority database, and answers 409 in three cases:
- Stale. The term is below the persisted term.
- Already fenced. The term equals the persisted term under a different leader URL.
- Another generation. The fence row belongs to a different authority generation; it is refused, never overwritten.
The fence lives in --authority-db. At startup a fence row is restored only when it matches the authority’s current generation and rotation timestamp, and discarded with a warning otherwise, so a rotated key cannot resurrect an old term. A restored leader URL is kept only when it is this node or a configured peer. Without an authority database, only the in-memory lease check applies.
Read more in Authority & Rotation, which covers the keypair the fence protects.
Two intersecting views can each hold quorum
A minority node computes no leader and refuses covered writes with 503. Majorities intersect, yet each node computes over its own candidate set: with A < B < C, A can see A and C while B sees B and C, and both select themselves with a quorum of two. The intersecting-views scenario in the figure draws this case, which follows from the selection code.
Two narrower checks bound what either side commits. The persisted term fence refuses a stale or already-fenced authority mutation, and a budget write fails closed unless the writing node plus eligible peer acknowledgments meet quorum. Neither is a split-brain safety proof. The guarantee class is leader-local: the fence covers authority mutation, other writes are single-writer by observation, and the bounded operational profile makes no realized-spend claim for clustered budgets.
Six streams replicate: budgets, tool receipts, child receipts, lineage, revocations, and the authority snapshot. Replica repair keeps running below quorum, and a handler outside the forwarding helpers writes where it lands; handle_publish_certification keeps its file-backed registry on the node that received the request.
Try lease_ttl_ms, 409, or forward_authority_post_to_leader.
Node state fields
ClusterRuntimeState is built once at startup by build_cluster_state, and only when at least one --peer-url resolves to a URL other than this node’s advertised one. Every URL passes normalize_cluster_config_url, which requires an http or https scheme, rejects userinfo, a query string, and a fragment, and rejects loopback or private-network addresses unless --allow-local-peer-urls is set. Peers configured alongside --authority-seed-file are rejected: a cluster keeps its authority, and its term fence, in --authority-db.
self_url
--advertise-url normalized, or http://{listen addr} when that flag is absent. Compared with peers byte for byte.
peers
Map from the configured peer URLs, minus self_url, to a PeerSyncState. Built from configuration at startup.
election_term
Local counter, incremented whenever the computed leader differs from the last one. Seeded at startup from the persisted fence row when that row matches the current authority generation, otherwise 0.
last_leader_url
The previous computed leader, used to detect a change.
term_started_at
Unix seconds when the current leader was first computed. Cleared on quorum loss.
lease_expires_at
Restamped to now + ttl on every computation that holds quorum, and set to None otherwise.
lease_ttl_ms
Derived from the sync interval at startup and fixed for the life of the process.
Candidate predicates
A peer joins the candidate set only when all three hold. Synchronization, forwarding failures, and partition controls set them; the selection reads them.
- Reachable (
health.is_reachable()). True forHealthyonly;Unknown, where a freshly configured peer starts, is unreachable. A successfulcluster_statuscall raises it, andupdate_peer_failurelowers it on a transport or protocol error. Demotion leaveslast_contact_atin place, so a dead peer usually leaves on health before its contact stamp goes stale. - Unpartitioned (
partitioned). Set byPOST /v1/internal/cluster/partition, the injection route the cluster integration tests drive. The sync round skips a partitioned peer, so its contact stamp also goes stale. - Inside the TTL (
last_contact_at). Stamped by the same success paths;Noneuntil the first successful peer status call.
Lease TTL
The lease is a local stamp. The computation that names a leader restamps lease_expires_at to now + lease_ttl_secs whenever quorum holds, and a lease is valid while quorum holds and that stamp has not passed. The TTL is fixed at startup from the sync interval:
pub(crate) fn authority_lease_ttl(sync_interval: Duration) -> Duration {
let scaled = sync_interval
.checked_mul(3)
.unwrap_or_else(|| Duration::from_secs(5));
scaled
.max(Duration::from_millis(500))
.min(Duration::from_secs(5))
}--cluster- | Reported leaseTtlMs | Whole-second threshold |
|---|---|---|
500 (CLI default) | 1500 | 1s |
2000 | 5000 (clamped at the 5s ceiling) | 5s |
100 | 500 (clamped at the 500ms floor) | 1s |
The third column truncates the second. lease_ttl_secs truncates the millisecond TTL to whole seconds and floors the result at one. The comparison uses integer Unix seconds and includes the boundary: now <= last_contact_at + lease_ttl_secs. It is a timestamp threshold, and reported lease metadata stays in milliseconds. The CLI floors the interval at 50ms before it reaches this function, and TrustServiceConfig::validate rejects a zero interval.
Forwarded write outcomes
| Condition | Request path | Reader-visible outcome |
|---|---|---|
Quorum, lease, or leader unavailable | Refuse before forwarding or the handler’s local write | 503 with the missing prerequisite |
Another node is the selected leader | Forward the receipt request with the service token | The leader’s response body |
This node handles the receipt write | Append, then re-read its local receipt store | Success metadata for visibility at the leader |
Post-write visibility check fails | The write was already attempted | 500; state may have changed |
The re-read is one read, not a poll: a write the leader cannot read back at once is reported as a failure, for example tool receipt was not visible on the leader after write. json_response_with_leader_visibility adds the cluster metadata only after that check, and the integration suite asserts it in assert_write_visibility_metadata. Two clustered write paths answer without it: /scim/v2/Users through scim_json_response, and /v1/capabilities/issue with a plain IssueCapabilityResponse. Both forward; neither reports handledBy or runs the re-read, so a missing visibleAtLeader says nothing about where a write landed.
Forwarding helpers
| Helper | Callers | Auth to the leader | Lease checked | Term forwarded | Leader answers non-2xx |
|---|---|---|---|---|---|
forward_ | Tool and child receipts, lineage, revocations, evidence import, federation- | Bearer service token on the public route | Yes | No | Passed through verbatim; the leader stays healthy |
forward_ | /v1/authority, /v1/ | Cluster-peer keyed digest on the internal route | Yes | Yes: the leader’s own term when its status holds quorum, names itself, and has a valid lease; the local term otherwise | Forwarding failure: leader demoted, caller sees 503 |
forward_ | /scim/v2/Users create | Bearer service token | No | No | Forwarding failure |
forward_ | /scim/v2/Users/{id} delete | Bearer service token | No | No | Forwarding failure |
Only forward_post_to_leader maps a status error back into a response. The other three use the shared client, which turns any non-2xx into an error, marks the leader unhealthy, and reports 503, so a leader deliberately rejecting a request looks like a dead leader on those paths. The SCIM pair also skips the lease check and re-wraps the leader’s JSON as a fresh 201 or 200 with SCIM error shaping. Budget writes wait in wait_for_budget_write_quorum_commit after the leader-visible write, under a wall-clock bound derived from the sync interval and peer count; a timeout, or quorum lost mid-wait, fails the write closed with 503 after it is durable on the leader.
Refusal statuses and messages
| Condition | Status | Message |
|---|---|---|
Fewer reachable nodes than the quorum size | 503 | cluster quorum is unavailable for trust-control writes |
Quorum holds, no lease view exists | 503 | cluster authority lease is unavailable for trust-control writes |
Quorum holds, the lease stamp has passed | 503 | cluster authority lease expired before trust-control write forwarding |
Quorum and lease hold, the pick is None | 503 | cluster leader is unavailable for trust-control writes |
Forwarding failed; recomputation names the same leader | 503 | failed to forward control-plane write to leader: {error} |
Both attempts failed against changing leaders | 503 | failed to forward control-plane write to cluster leader |
Forwarded authority term differs from the leader’s lease | 409 | cluster authority mutation term does not match the current lease |
Write succeeded, the re-read missed it | 500 | Per handler, for example revocation was not visible on the leader after write |
These strings belong to forward_post_to_leader. The authority helper uses a parallel set with authority writes in place of trust-control writes, and failed to forward authority write to leader for a forwarding failure. Branch on the status code; the sentence can change.
Tests that exercise failover
trust_control_cluster_requires_quorum_and_heals_after_partition: a minority partition drops torole: candidatewith a null leader and answers a budget write with 503 while the majority keeps writing; healing reconverges on the same leader and the minority catches up from a snapshot.trust_control_cluster_rejects_stale_authority_term_after_failover_and_restart: on three live processes, kills the leader, waits for the survivors to converge on a higher term, restarts the killed node, and replays an authority mutation with the pre-failover term, which gets 409 and leaves the authority generation unchanged. The same mutation posted to a follower advances the generation by exactly one, withhandledBynaming the leader.compute_cluster_consensus_drops_stale_peers_after_authority_lease_timeout, a unit test: a peer whose last contact is older than the staleness window leaves the candidate set while its recorded health isHealthy.compute_cluster_consensus_tracks_role_quorum_and_election_terms, a unit test: walks a three-node state from no quorum, to a leader at term 1, to lost quorum at term 2.