PlatformCluster Topology
Cluster
Cluster Overview
Many nodes, one authority keypair. What the rung buys, the four claims it deliberately does not make, and where it degrades quietly.
The other half of node health
Four claims, before anything else
A cluster in Chio is many nodes under one authority: one signing keypair, one admin service token, one shared-secret peer credential, one trust domain. It is a deterministic leader plus a repair loop. It is not a consensus protocol, and four claims fix the ceiling before the mechanism is worth reading. The first three are rows of docs/standards/CHIO_BOUNDED_OPERATIONAL_PROFILE.md, the profile the release is allowed to claim. The fourth has no row there because there is nothing to bound.
| Plane | Class | Claimed | Explicitly not claimed |
|---|---|---|---|
| Trust-control reads and writes | leader-local | Deterministic leader selection, single-writer local truth, eventual repair, bounded visibility over local SQLite state. | Consensus, quorum commit, stale-leader fencing, or a globally linearizable control-plane view. |
| Clustered monetary budgets | leader-local | Bounded provisional authorized exposure with a documented overrun bound. | Any realized-spend <= budget guarantee under split brain. |
| Receipt and checkpoint plane | local-only | Signed local audit evidence, immutable local checkpoints, inclusion proofs over locally checkpointed claim-log batches. | Public transparency-log semantics, cross-node append-only coverage, strong non-repudiation. |
| Policy | not replicated | Nothing. A cluster distributes no policy at all. | That two nodes under one key enforce the same rules. Nothing makes them agree. |
The last row is the one operators misread. The internal replication surface is exactly 10 routes, and this is all of them:
| Route | Method | Handler |
|---|---|---|
/v1/internal/admission-authority | POST | handle_admission_authority |
/v1/internal/authority/snapshot | GET | handle_internal_authority_snapshot |
/v1/internal/budgets/delta | GET | handle_internal_budgets_delta |
/v1/internal/cluster/partition | POST | handle_internal_cluster_partition |
/v1/internal/cluster/snapshot | GET | handle_internal_cluster_snapshot |
/v1/internal/cluster/status | GET | handle_internal_cluster_status |
/v1/internal/lineage/delta | GET | handle_internal_lineage_delta |
/v1/internal/receipts/children/delta | GET | handle_internal_child_receipts_delta |
/v1/internal/receipts/tools/delta | GET | handle_internal_tool_receipts_delta |
/v1/internal/revocations/delta | GET | handle_internal_revocations_delta |
No policy delta, no guard module distribution, no chio.yaml sync. Policy is enforced by whoever holds the authority, never shipped to a member: configure_capability_authority refuses a reputation or runtime-assurance issuance policy on the --control-url path and tells you to run chio trust serve --policy instead. Nothing checks that the file two nodes loaded is the same file: the health body reports issuancePolicyConfigured and runtimeAssurancePolicyConfigured as booleans, never a digest, and load_policy sets both to None outright on the plain Chio-YAML path, so only a HushSpec policy file populates them at all. Policy Rollout works that row through in full: every negative verified against the code, and what an operator does in place of the mechanism.
Peer-URL clustering is the legacy coordinator, and it costs you the hold lifecycle
the legacy cluster coordinator in four separate refusals. The newer joint budget-and-revocation authority (--session-db) cannot run alongside it: TrustServiceConfig::validate rejects the combination at boot. The consequence is concrete. The versioned authorize/capture/release/reconcile hold routes under /v1/budgets/holds/ require an injected joint authority, so on a peer-URL cluster they answer 503: authorize reports that the structured budget authority was never provisioned, and the later transitions report that the versioned rich budget lifecycle is unavailable with the legacy cluster coordinator. Clustering leaves you the older exposure routes under /v1/budgets/, not the full lifecycle.The cluster model
The filing test for this rung: does answering the question require another node’s disk under the same key? If one process can answer it by reading its own, it is Node. If it needs a signature from a party under a different key, it is Swarm. Four objects carry everything in between.
| Object | What it is | Where it lives |
|---|---|---|
| Authority keypair | The single issuer identity every node validates against. Replicated as an authority snapshot carrying the current public key, generation, rotation timestamp, and trusted key history, applied from every peer each round. | --authority-db. Peers configured alongside --authority-seed-file are rejected at boot. |
| Admin service token | One bearer secret for administrative access. Tenant read tokens are separate, confined to one tenant’s receipts, and rejected by validate if equal to it. | CHIO_TRUST_SERVICE_TOKEN, compared with subtle::ConstantTimeEq. |
| Peer credential | Not a separate secret. A SHA-256 digest over the RFC 8785 canonical JSON encoding of scheme, serviceToken, nodeId, endpoint, issuedAt, and term, under scheme chio.cluster.peer.v1. | validate_cluster_peer_auth in report_validation.rs. |
| Cluster runtime state | Self URL, peer map, election term, lease expiry. An Option: None is the ordinary case, not a fault. | build_cluster_state returns Ok(None) when the peer list is empty. |
The peer credential is derived from the admin service token, which is what makes the trust domain singular rather than merely coordinated. Membership is the admin token plus a node id that normalizes to an entry in peer_urls. The node id is allowlisted first, the digest is compared in constant time, and only then are timestamps outside a 60-second skew window rejected. Invalid-signature failures are counted per node-id-and-endpoint pair under an unverified: bucket, 8 in 60 seconds, answered 429 with Retry-After: 60; skew failures after a valid digest are counted separately under the node id. Two consequences follow from the digest being derived from a shared secret rather than a per-node key. A peer cannot prove authorship of anything to a third party, and any holder of the admin token can mint valid headers for any allowlisted node id, including /v1/internal/cluster/partition, which is a live route in the shipped router and will partition a running cluster on request.
Two ways to cross into this rung
Add peer URLs. Repeated --peer-url on chio trust serve, with --advertise-url naming this node. URLs are normalized once at boot; loopback and private hosts are refused without --allow-local-peer-urls, and the node’s own URL is filtered out of its own peer set. A peer list that normalizes down to nothing is still a standalone node.
Borrow remote stores. --control-url with a required --control-token swaps a kernel’s local stores for HTTP-backed adapters: RemoteReceiptStore, RemoteRevocationStore, RemoteBudgetStore, RemoteCapabilityAuthority. Each store is local or borrowed, never both: every configure_* function errors on a database path and a control URL together. The flag takes a comma-separated endpoint list, and every request carries a 15-second client timeout.
A borrowing node is in the domain but is not a peer
--control-url node receives no deltas, holds no peer state, casts no witness for a budget write, and never appears in peer_urls. Its failure modes are a client’s, covered in failure domain 3.How a clustered write lands
- Leader selection, recomputed per status check.
compute_cluster_consensus_lockedbuilds a candidate list of this node plus every peer that is healthy, unpartitioned, and contacted inside the lease TTL, sorts it, and takes the first. Quorum size ispeers.len().div_ceil(2) + 1, so a two-node cluster needs both. Without quorum there is no leader and the role iscandidate. A change of leader increments the election term. There is no term voting, no log replication, no election message of any kind. - The lease. TTL is three sync intervals clamped to the range 500 ms through 5 s, with a lease id of
{leader_url}#term-{epoch}. The freshness test truncates that TTL to whole seconds with a floor of one, so at the default--cluster-sync-interval-ms 500the lease is 1.5 s but a peer drops out of the candidate set after one second without contact. The term is persisted in the authority store and adopted on boot only when the recorded generation and rotation timestamp still match the live authority; a rotated key discards the fence rather than carrying a lease across it. - Forwarding, which refuses early.
forward_post_to_leaderanswers 503 before it opens a connection if quorum is missing, the lease is unavailable, the lease expired, or no leader is named. It makes at most two attempts, and the second only against a leader that changed; re-posting to the same failed URL is treated as terminal. - Budget writes wait on witnesses. A peer counts toward a budget write only when its contiguous acknowledgment head for that write’s origin has reached the write’s
event_seq, and the peer is reachable, unpartitioned, and not pending a forced snapshot. The wait is bounded by a timeout that scales with peer count and is capped at 300 s; on expiry the handler rolls the local exposure back and answers 503, except on a replayed or already-captured write, where the admission is retained and only the 503 is returned. - Repair, pulled not pushed.
run_cluster_sync_loopvisits peers serially. Budgets are pulled first because they are quorum-critical, then tool receipts, child receipts, and lineage share the remaining round budget; revocations run on an independent budget so budget churn cannot starve revocation propagation. Each of those two lanes gets its own cap of 64 pages, 200,000 records, and 20 seconds of wall clock, and any single peer response body is refused past 64 MiB. - Contract violations demote. A page that rewinds past the cursor, repeats a sequence, breaks contiguity on a dense stream, or ships budget usage records with no mutation events drops the peer to unhealthy and force-resyncs it from a full snapshot. A delta window too large to page at all is a separate outcome: it flags the snapshot without demoting, because that is backlog rather than misbehavior.
Adding a peer lowers the reported budget guarantee
budget_authority_guarantee_level returns single_node_atomic when state.cluster is None and advisory_posthoc once peers exist. It reads nothing else: the commit index it is handed is unused, so the level is a function of clustered-or-not and nothing about the write. That is the honest direction of travel. Clustering buys availability and costs enforcement strength on exactly one plane; see Authoritative Spend for what the stronger level actually asserts.Three failure domains node health cannot report
The first two live in the trust-control service and are visible only in the body of its own GET /health, never in its status code. The third lives in a kernel that borrowed its stores, one process removed from the sidecar routes GET /chio/live and GET /chio/health, which keep answering 200 while that node’s own receipt table is writable. Per-node probes will not show you any of them.
1. A leaderless cluster reports ok: true
The trust-control GET /health route is unauthenticated and returns "ok": true unconditionally. The authority and federation-registry snapshots degrade to available: false inside the body rather than failing the request, deliberately: a broken certification registry file should not hide the cluster verdict. The consequence is that quorum loss never changes the status code. It changes cluster.hasQuorum, cluster.role, and cluster.leaderUrl, and the write paths that forward to a leader start answering 503 with a message naming quorum, the lease, or the missing leader. That set is a subset of the mutating routes, not all of them: roughly a third of the POST routes call forward_post_to_leader, and the rest, credit bond issuance among them, keep writing to the local SQLite file and returning 200 with no quorum gate at all.
hasQuorum is false on a healthy standalone node
role: "standalone", hasQuorum: false, quorumSize: 1, reachableNodes: 1. Alerting on hasQuorum == false alone pages you for every correctly running single node. Gate the alert on the top-level clustered flag first.2. A reachable peer that cannot witness
Reachability and eligibility are different properties. PeerSyncState::default() sets health: Unknown and force_snapshot: true, and is_reachable() matches only Healthy. A freshly booted clustered node therefore has one reachable node, no quorum, role candidate, and every leader-forwarded write refused until the first sync round reaches a peer. The same flag outlives recovery: any peer whose cluster_status call failed, not only one that violated the wire contract, is marked unhealthy and force-snapshot, and a bare reachability probe flips it back to healthy without clearing that flag. Only a full clean round does. The cluster then reports healthy peers while budget writes time out on the quorum wait, because the witness test excludes forced-snapshot peers by design. The distinguishing fields, forceSnapshot, snapshotAppliedCount, and deltaRecordsSinceSnapshot, appear only on the authenticated /v1/internal/cluster/status endpoint, not in the public health body.
3. A borrowing node whose control plane stalls
A --control-url node has moved its receipt writes, revocation lookups, and capability issuance onto the network. Its sidecar readiness probe does not follow: it opens a transaction against the sidecar’s own local SQLite receipt tables and rolls it back, and it reports healthy without probing anything at all when no such store is attached. Two mechanisms keep the borrowing node fail-closed instead of merely slow.
Bounded remote writes. BoundedReceiptWriter runs blocking control-plane writes on a fixed pool of 2 worker threads behind a depth-2 queue. Once every worker is parked and the queue is full, further submissions return ReceiptStoreError::Timeout immediately rather than spawning a thread per write. A write that outlives the caller’s budget still completes, with its result discarded, because the caller already failed closed. Only the budgeted overrides route through that pool: the plain append_chio_receipt, append_child_receipt, and record_capability_snapshot calls, and every revocation lookup and receipt point-load, block on the client’s own 15-second timeout with no budget at all.
A denial rather than a panic. AuthorityKeyCache refreshes against the service on a 2-second TTL, and from_status rejects any status with no current key, so a primed cache always carries one. If that invariant is ever violated, the code must neither abort the process nor return a key an attacker could influence:
/// Fail-closed substitute for a missing current authority key.
fn deny_sentinel_public_key() -> PublicKey {
tracing::error!(
"remote capability authority cache missing current key; \
returning a non-trusting sentinel so admission fails closed"
);
Keypair::generate().public_key()
}The returned key is freshly generated and its private half is discarded on the spot, so it can never validate a real capability and a caller that folds it into a trust set gains no usable issuer. An unprimed cache produces a guaranteed-wrong denial, a verdict an operator can read in a receipt, instead of a crash or a silent admission. The error log line is the signal to alert on; nothing here changes a health status code.
Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | Every internal cluster endpoint authenticates the caller: allowlisted node id, constant-time digest comparison, 60-second skew window, rate limiting on repeated failure. | trust_control/report_validation.rs |
| Shipped | Replication enforces strict contiguity on the dense budget mutation-event stream, and forward progress plus within-page monotonicity on the gap-tolerant receipt and lineage streams. A violating peer is demoted, force-resynced, and cannot witness a budget write until it has caught up. | cluster/pull_budget.rs, cluster/deltas.rs |
| Shipped | A borrowing node fails closed on a stalled control plane for budgeted writes: bounded writer pool, and an unprimed authority cache returns a non-trusting sentinel. | service_runtime/remote_stores.rs, service_runtime/remote_authority.rs |
| Bounded | Clustered budget authorization is advisory_posthoc with a documented overrun bound. See the claim block below. | docs/adr/ADR-0006-monetary-budget-semantics.md |
| Not claimed | Consensus. Leadership is deterministic selection over the reachable, unpartitioned, freshly contacted candidate set, plus a repair loop. | compute_cluster_consensus_locked sorts candidates and takes the first. |
| Not detectable | A mid-stream skip by a lying peer on a gap-tolerant stream. Those sequence columns legitimately gap under retention deletes, so the client cannot distinguish a hole from a skip. The periodic full snapshot is the only backstop, and it bounds rather than prevents. | require_forward_progress doc comment in cluster/pull_budget.rs |
| Not replicated | Policy, guard modules, and node configuration. Two nodes under one authority key can enforce different pipelines and neither will notice. | The internal endpoint set in service_types/paths.rs carries no policy route. |
| Unsupported | The versioned authorize/capture/release/reconcile hold lifecycle while peer-URL clustering is on. It requires the joint authority, which validate refuses to run alongside peers. | budget_handlers/structured.rs, service_types/config.rs |
| Out of scope | Multi-datacenter consensus, byzantine quorum rotation, HSM-backed signing, and dynamic user login or identity-provider federation are named non-goals of the current plan. | docs/operations/HA_CONTROL_AUTH_PLAN.md |
Claim: the budget overrun bound
| Field | Value |
|---|---|
| Status | Shipped and documented, stated as a bound rather than a guarantee. |
| Claim | Under split brain each active node may independently approve one invocation at the per-invocation cap before the merge propagates, so overrun <= max_cost_per_invocation * node_count. |
| Subject | One monetary grant enforced across N nodes, each holding an independent SQLite budget store merged last-writer-wins on sequence. |
| Evidence | ADR-0006, and the same bound carried as a safety comment on BudgetStore::try_charge_cost. |
| Limit | No hard stop in a clustered deployment, only a soft bound with a node-count multiplier. Size max_total_cost with that headroom: a two-node cluster with a $5.00 per-invocation cap can overrun by $10.00. The named test, concurrent_charge_overrun_bound, is a documentation test rather than a proof: it opens two independent stores, charges each once, and asserts their summed exposure falls inside max_cost_per_invocation * node_count. It runs no replication merge, and the combined-spend assertion compares two identical expressions. |
| Note | try_charge_cost takes no currency argument at all. Every comparison is over raw integer minor units; currency agreement is enforced upstream at grant attenuation, never converted here. |
Next Steps
- Leader & Failover · the deterministic pick, the lease, and what a write does when there is no leader
- Replication & Convergence · the pull loop, the round budgets, and what costs a peer its witness standing
- Cluster Peer Auth · the digest, the allowlist, and the skew window in full
- Budgets Across Nodes · quorum witnessing, compensating rollback, and the overrun bound as arithmetic
- Authority & Rotation · one keypair per cluster, its snapshot, and the custody it refuses to copy
- Health & Readiness · the counterpart page, and what one process can answer about itself
- Trust Control Plane · the operational depth: topology, running a node, failover, key rotation
- Swarm Overview · the next rung, where authority descends to parties you do not run