Chio/Docs
LOGIN · JOIN

PlatformCluster Topology

Cluster

Leader & Failover

A deterministic pick over the peers one node can see, recomputed on every status read, and what fails when it cannot.

Where the failure drills live

This page owns how a leader is designated and how a mutating request reaches it. Two of the drills built on that mechanism are here: quorum loss under the minority-partition claim and stale-term rejection after a restart under the term fence. The other two belong to the pull round in Replication & Convergence: partition injection and heal at its peer-visit gate, late-joiner catch-up at its snapshot backstop. Nothing on this page contacts a peer, and that round produces every observation it reads. All four, run end to end against live processes, are walked in Cluster Recovery.

Not an election

The control plane’s own architecture record states the mechanism and its non-claim in the same breath:

crates/platform/chio-control-plane/ARCHITECTURE.mdtext
1. `cluster::consensus::compute_cluster_consensus_locked` recomputes the
   leader on every status check: the lowest-sorted URL among peers that are
   reachable, unpartitioned, and inside the lease TTL. This is a deterministic
   pick over the locally observed peer set, not a voted election - there is no
   term voting, log replication, or election RPC.

One correction to that record, because the code is the authority: the candidate set is not "peers", it is self plus qualifying peers. A node is always in its own set and its own health is never tested. Otherwise read it as a description of the whole subsystem, not a summary of one. There is no election timer, no candidacy message, no vote request, and no append-entries stream anywhere in crates/platform/chio-control-plane/src/trust_control/cluster/. The function is reached only from view helpers (cluster_consensus_view, cluster_authority_lease_view, the combined snapshot the internal status route uses, and budget_write_quorum_commit_view), so the leader exists only as the answer to a question somebody just asked.

It takes &mut ClusterRuntimeState because computing the view writes the term, the last leader, and the lease expiry back into memory. A term advance is therefore a side effect of a read. In a running cluster that read is never scarce: each clustered node pulls every peer’s GET /v1/internal/cluster/status once per sync round, answering that poll makes the polled node recompute, and so does every GET /health and every write that checks whether it should forward. The sync interval is not a guaranteed cadence in either direction: it is the sleep between rounds, a round visits peers serially with each call bounded by the control HTTP timeout, and a parked budget write can kick the next round early.


The state one node keeps

ClusterRuntimeState is built once at startup by build_cluster_state, and only when at least one --peer-url resolves to something other than this node’s own advertised URL. Without that, the node is unclustered and every mechanism below is inert. 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. Configuring peers alongside --authority-seed-file is rejected outright: a cluster wants the SQLite authority behind --authority-db, because that is where the term fence is persisted. With --authority-db set, a fence row found at startup is restored only when it still matches the authority’s current generation and rotation timestamp, and otherwise discarded with a warning, so a rotated key cannot resurrect an old term. A restored leader URL is kept only if it is this node or a configured peer.

FieldMeaning
self_url--advertise-url normalized, or http://{listen addr} when that flag is absent. Compared to peers byte for byte.
peersStatic map from the configured peer URLs, minus self_url, to a PeerSyncState. No enrollment, no membership change at runtime.
election_termLocal counter, incremented whenever the locally 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_urlThe previous computed leader, used only to detect that change.
term_started_atUnix seconds when the current leader was first computed. Cleared on quorum loss.
lease_expires_atRestamped to now + ttl on every computation that holds quorum, and set to None otherwise.
lease_ttl_msDerived from the sync interval at startup and never changed.

A peer joins the candidate set only when three independent predicates hold. All three are fed by the replication round, not by this function:

PredicateFieldWho sets it
Reachablehealth.is_reachable()True for Healthy only. Unknown, the value a freshly configured peer starts at, is not reachable. Raised by a successful cluster_status call, lowered by update_peer_failure on a transport or protocol error. That demotion does not clear last_contact_at, so a dead peer usually leaves the candidate set on health before its contact stamp goes stale.
UnpartitionedpartitionedSet by POST /v1/internal/cluster/partition, the injection route the cluster integration tests drive. A partitioned peer is skipped by the sync round entirely, so its contact stamp also goes stale.
Inside the TTLlast_contact_atStamped by the same success paths. None until the first successful peer status call.

The role names are borrowed, the mechanism is not

The computation emits exactly three roles. leader means the pick equals self_url, follower means it does not, and candidate means there is no quorum and therefore no leader. A fourth string, standalone, never comes out of the computation at all: it is the literal an unclustered node reports under cluster on GET /health, and the same fallback the internal status route substitutes when no consensus view exists. A Chio candidate does not campaign, solicit, or time out into an election. It is the name for a node that currently cannot act.

How the pick is computed

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);
}

Four consequences follow directly from those lines. First, a node always counts itself: candidates starts with self_url and nothing tests local health, so reachable_nodes is never zero. Second, quorum_size is the majority of the configured cluster: two peers means a quorum of two, four peers means three. A two-node cluster has a quorum of two, so either node going quiet stops writes on both. It is computed from this node’s --peer-url list, so an asymmetric configuration gives nodes different quorum sizes and nothing detects the disagreement.

Third, candidates.sort() is a byte-wise sort of URL strings, not a numeric one, so http://node:10001 sorts ahead of http://node:9001. The only normalization applied to a peer URL is a trim and a trailing-slash strip, so a host written as an address on one node and as a DNS name on another is two different nodes to this function. Fourth, the term advances on any change of the computed leader, including a change to no leader at all: the unit test compute_cluster_consensus_tracks_role_quorum_and_election_terms walks a three-node state from no quorum, to leader at term 1, to lost quorum at term 2.

The lease is a local stamp, not a grant

Nothing issues the lease. The same computation that names a leader restamps lease_expires_at to now + lease_ttl_secs whenever quorum holds, and a lease is valid when quorum holds and that stamp has not passed. The TTL itself 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 leaseTtlMsStaleness window actually applied
500 (CLI default)15001s
20005000 (clamped at the 5s ceiling)5s
100500 (clamped at the 500ms floor)1s

The third column is not a rounding of the second. lease_ttl_secs truncates the millisecond TTL to whole seconds and floors the result at one, so a 1500ms lease tolerates one second of peer silence, not one and a half. Reported lease metadata stays in milliseconds. Tune the interval, read the window in seconds. The interval itself is floored at 50ms by the CLI before it reaches this function, and TrustServiceConfig::validate rejects a zero interval outright.


Where a write goes

rendering
A mutating request that lands on a follower. The follower forwards, the leader writes and re-reads its own store, and the follower returns the leader's response body unchanged.
sourcecrates/platform/chio-control-plane/src/trust_control/report_rendering.rs:261-355at fe56570

Every cluster-aware mutating handler opens the same way, before it touches a store:

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,
}

Ok(None) means "handle it here", returned when the node is unclustered, is itself the leader, or becomes the leader partway through a retry. Otherwise the helper checks quorum, then lease validity, then the leader URL, and forwards. It makes at most two attempts, and the second only when a recomputation after the failure names a different leader; a failure against a leader the node still computes as leader is returned to the caller rather than retried into the same wall. What makes that recomputation move at all is the failure itself: update_peer_failure demotes the leader to Unhealthy in this node’s own peer map before the next computation runs, which can drop it from the candidate set or drop the node below quorum entirely.

The leader then writes locally and verifies before it answers. respond_after_leader_visible_write runs a caller-supplied closure that re-reads the record it just wrote, and returns 500 with a message such as tool receipt was not visible on the leader after write when the read comes back empty. It is one re-read, not a poll: a write that is not immediately readable on the node that performed it is reported as a failure rather than waited out. Only after that check does json_response_with_leader_visibility add the cluster metadata: handledBy, leaderUrl, visibleAtLeader, and the clusterAuthority lease block. Because the follower returns the leader’s body verbatim, handledBy equals leaderUrl on a forwarded write, which is exactly what the integration suite asserts in assert_write_visibility_metadata.

That metadata is not universal. Only responses built through that helper carry it, and two clustered write paths do not use it: /scim/v2/Users answers through scim_json_response, and /v1/capabilities/issue returns a plain IssueCapabilityResponse. Both forward correctly; neither reports handledBy, and neither runs a leader-visible re-read. Do not treat the absence of visibleAtLeader as evidence a write stayed local.

Four helpers, three different checks

HelperCallersAuth to the leaderLease checkedTerm forwardedLeader answers non-2xx
forward_post_to_leaderTool and child receipts, lineage, revocations, evidence import, federation-policy evaluation, budgets (legacy and structured), fiscal, underwriting, open-market issuance, passport issuance and challengesBearer service token on the public routeYesNoPassed through to the client verbatim; the leader is not demoted
forward_authority_post_to_leader/v1/authority, /v1/capabilities/issueCluster-peer keyed digest on the internal routeYesYes, taken from the leader’s own status when that status has quorum, names itself leader, and holds a valid lease; the local term otherwiseTreated as a forwarding failure: the leader is demoted and the caller sees a 503
forward_scim_post_to_leader/scim/v2/Users createBearer service tokenNoNoTreated as a forwarding failure
forward_scim_delete_to_leader/scim/v2/Users/{id} deleteBearer service tokenNoNoTreated as a forwarding failure

Read the last column twice. Only forward_post_to_leader maps a status error back into a response and returns it; the other three go through the shared client, which turns any non-2xx into an error, marks the leader unhealthy, and reports 503. A leader that is up and deliberately rejecting a request looks like a dead leader on those three paths. The SCIM pair also skips lease validity and re-wraps the leader’s JSON as a fresh 201 or 200 with SCIM error shaping rather than passing the response through. Budget writes go the other way: after the leader-visible write they park on wait_for_budget_write_quorum_commit until a majority of peers have acknowledged the event, under a wall-clock bound derived from the sync interval and peer count. A timeout, or quorum disappearing mid-wait, fails the write closed with 503 after it is already durable on the leader. That is Budgets Across Nodes.

What a write returns when it cannot proceed

ConditionStatusMessage
Fewer reachable nodes than the quorum size503cluster quorum is unavailable for trust-control writes
Quorum holds but no lease view exists503cluster authority lease is unavailable for trust-control writes
Quorum holds but the lease stamp has passed503cluster authority lease expired before trust-control write forwarding
Quorum and lease hold but the pick is None503cluster leader is unavailable for trust-control writes
Forwarding failed and the recomputation still names the leader that just failed503failed to forward control-plane write to leader: {error}
Both attempts failed against leaders that kept changing503failed to forward control-plane write to cluster leader
Forwarded authority term does not match the leader’s lease409cluster authority mutation term does not match the current lease
Write succeeded but the re-read did not find it500Per-handler, for example revocation was not visible on the leader after write

Those strings belong to forward_post_to_leader. The authority helper carries a parallel set with authority writes substituted for trust-control writes and failed to forward authority write to leader for the forwarding failure. Match on the status code, not on the sentence.


The one path that fences on term

Authority mutation is the exception to everything above. A forwarded authority write carries the term in x-chio-cluster-auth-term, which the peer keyed digest covers rather than merely accompanies. validate_authority_mutation_auth runs first, before anything is forwarded or written, and rejects a mismatch with 409. It engages only when a request arrives with cluster peer headers; a plain bearer request falls through to validate_service_auth and carries no term at all. Past that, enforce_authority_mutation_fence checks the term against a row persisted in the node’s own authority database: a term below the persisted one is stale, a term equal to the persisted one under a different leader URL is already fenced, and a fence row recorded against a different authority generation is refused rather than overwritten. All three map to 409. All three need --authority-db; with no authority database configured the fence step is a no-op and only the in-memory lease check applies.

trust_control_cluster_rejects_stale_authority_term_after_failover_and_restart exercises the whole path on three live processes: it kills the leader, waits for the survivors to converge on a higher term, restarts the killed node, replays an authority mutation carrying the pre-failover term, and asserts a 409 with an unchanged authority generation. It then posts the same mutation to a follower and asserts the generation advances by exactly one with handledBy naming the leader.


Guarantees and limits

StatusClaimEvidence
ShippedThe leader is the lowest-sorted URL among self plus reachable, unpartitioned, fresh peers, and is None without quorum.cluster/consensus.rs
Proved by testA minority partition drops to role: candidate with a null leader and returns 503 on a budget write, while the majority keeps writing; healing the partition reconverges on the same leader and the minority catches up from a snapshot.trust_control_cluster_requires_quorum_and_heals_after_partition
Proved by testKilling the leader promotes the next-lowest node and advances the authority term; a replayed pre-failover term is rejected 409 with no generation change.trust_control_cluster_rejects_stale_authority_term_after_failover_and_restart
Proved by testA peer whose last contact is older than the staleness window leaves the candidate set even while its recorded health is Healthy.compute_cluster_consensus_drops_stale_peers_after_authority_lease_timeout, a unit test
Not claimedConsensus, quorum commit, or stale-leader fencing for trust-control writes as a class. The guarantee class is leader-local: deterministic leader selection, single-writer local truth, eventual repair. The term fence above is narrower than that non-claim, covering authority mutation only.docs/standards/CHIO_BOUNDED_OPERATIONAL_PROFILE.md
UnsupportedMembership change at runtime. There is no enrollment protocol, no join token, and no leave path: the peer map is built once from configuration, and adding a node means restarting every node that must accept it.build_cluster_state is the only non-test peers.insert in crates/; the allowlist check in validate_cluster_peer_auth
UnsupportedLeader stickiness. Leadership returns to the lowest-sorted node the moment it is reachable and fresh again, so a flapping lowest node moves leadership on every flap.candidates.first() with no incumbency term
Not wiredNothing forwards a handler that does not call a forwarding helper. handle_publish_certification, for one, writes its file-backed registry on whichever node received the request, and no delta stream replicates it. The replicated streams are budgets, tool receipts, child receipts, lineage, revocations, and the authority snapshot.certification_handlers.rs; peer_pullers and sync_peer in cluster/deltas.rs
UnsupportedAgreement on the term. election_term is a per-node counter over a per-node observation history; nothing in the protocol reconciles two nodes’ terms. The authority path works around this by reading the leader’s term off the leader’s own status before forwarding.compute_cluster_consensus_locked; the status refresh in forward_authority_post_to_leader

Claim: a minority partition cannot accept writes

FieldValue
StatusShipped, bounded.
ClaimA node that cannot see a majority of its configured cluster computes no leader and fails every cluster-aware mutating request with 503 rather than writing locally.
SubjectOne clustered trust-control node, per request, over its own observations.
Evidencehas_quorum gating leader_url in consensus.rs; the 503 assertion on the isolated node in trust_control_cluster_requires_quorum_and_heals_after_partition.
LimitMajority quorums intersect, but each node computes over its own candidate set, and each computes quorum_size from its own configuration. Under an asymmetric partition, where B can see both A and C while A and C cannot see each other, A and C can each hold quorum and name different leaders for a window. No in-tree test constructs that case; this is read off the code, not observed. Nothing in this mechanism prevents it. Two narrower mechanisms bound the damage: the persisted term fence refuses a stale or already-fenced authority mutation, and a budget write fails closed unless a majority of peers durably acknowledges the event. Neither is a split-brain safety proof, and the bounded operational profile declines the realized-spend claim for clustered budgets explicitly. Everything else on the write path is single-writer by observation, not by proof.

Next Steps

  • Replication & Convergence · the pull round that produces every peer observation this page reads, the partition gate, and the snapshot backstop a late joiner heals through
  • Authority & Rotation · the one keypair the term fence protects, and how a follower takes over
  • Budgets Across Nodes · quorum-commit witnessing, and the overrun bound stated as arithmetic
  • Health & Readiness · what one node can answer about itself before any of this applies