Chio/Docs
LOGIN · JOIN

Leader & Failover

Follow control-plane writes through a failover and learn which checks keep a stale leader from changing the authority.

Concept chio@5f63e6e

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.

Leader termsChoose a scenario.
term 1 · leaderterm 1 · follows Aterm 1 · follows AnAnode AnBnode BnCnode Cterm 1leaderterm 1follows Aterm 1follows AnAnode AnBnode BnCnode C

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.

Leader selection on three trust-control nodes over twelve sync rounds at the default 500 ms interval, so a lease lasts three rounds. The rounds and writes are illustrative; forwarding from followers to the leader is left out.

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
crates/platform/chio-control-plane/src/trust_control/cluster/consensus.rsrust
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.

  1. A node always counts itself. candidates starts with self_url and nothing tests local health, so reachable_nodes is never zero.
  2. 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-url list, so give every node the same peer set: an asymmetric configuration gives nodes different quorum sizes, and nothing detects the mismatch.
  3. The sort is byte-wise. http://node:10001 sorts ahead of http://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.
  4. 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.
  5. The term is per node. election_term advances 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
crates/platform/chio-control-plane/src/trust_control/receipt_handlers.rsrust
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:

  1. Stale. The term is below the persisted term.
  2. Already fenced. The term equals the persisted term under a different leader URL.
  3. 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.

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 for Healthy only; Unknown, where a freshly configured peer starts, is unreachable. A successful cluster_status call raises it, and update_peer_failure lowers it on a transport or protocol error. Demotion leaves last_contact_at in place, so a dead peer usually leaves on health before its contact stamp goes stale.
  • Unpartitioned (partitioned). Set by POST /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; None until 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:

crates/platform/chio-control-plane/src/trust_control/cluster/consensus.rsrust
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-sync-interval-msReported leaseTtlMsWhole-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

ConditionRequest pathReader-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

HelperCallersAuth to the leaderLease checkedTerm forwardedLeader answers non-2xx
forward_post_to_leader
Tool and child receipts, lineage, revocations, evidence import, federation-policy evaluation, both budget route families, fiscal, underwriting, open-market issuance, passport issuance and challenges
Bearer service token on the public route
Yes
No
Passed through verbatim; the leader stays healthy
forward_authority_post_to_leader
/v1/authority, /v1/capabilities/issue
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_post_to_leader
/scim/v2/Users create
Bearer service token
No
No
Forwarding failure
forward_scim_delete_to_leader
/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

ConditionStatusMessage
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 to role: candidate with 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, with handledBy naming 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 is Healthy.
  • 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.

Next: see how a lagging peer catches up