Budgets Across Nodes
Share one budget across trust-control nodes, read the quorum commit on each admission, and know what a failed witness leaves behind.
On this page
One budget backs calls served on several trust-control nodes. A monetary admission returns to its caller only after a quorum of those nodes holds the event that admitted it.
The cluster replicates the budget mutation journal that Budget Store keeps on one node. Exposure and realized spend stay separate counters, every mutation has a stable event identity, and each admission's response names the nodes that hold it.
A monetary admission returns after a quorum holds its own event
The writing node counts itself. A peer counts once its acknowledgment head for the write's origin reaches the write's sequence.
A budget write commits when a quorum holds its own origin’s event
Node A writes event 58 to pool B’s mutation log and counts itself. Node B has acknowledged origin A through 58 and counts; node C has acknowledged only through 57 and waits. Two of three meet the quorum of two, so the write reports quorumCommitted. A head is the last event before the first gap, and only an acknowledgment for the write’s own origin counts. Budgets Across Nodes.
In the three-node write drawn here, node A and node B hold event 58 and meet the quorum of two, while node C, acknowledged through 57, waits. A head is the last event before the first gap, so a peer missing a preceding event cannot witness a later one. Only the write's own origin counts: a peer that is ahead on another origin, or that awaits a forced snapshot, stays outside the witness set.
Admissions and the monetary transitions that follow them wait for this witness. Invocation-only increments and denied admissions return after one leader-visible re-read, and a denial still writes its journal event.
Read more in Replication & Convergence, which defines the eligible witness set.
Each write is forwarded, stamped, identified, and witnessed in order
Every mutating budget route runs the same five steps after its service-token check.
- Forward.
forward_post_to_leaderchecks quorum, then lease validity, then the leader URL, and answers 503 before opening a connection if any is missing. It makes at most two attempts, the second only against a leader that changed. A node without local quorum cannot mutate budgets. - Stamp. A clustered money write calls
current_budget_event_authority, which answers 503 when the lease is unavailable or expired and otherwise stampsauthority_id(the leader URL),lease_idin the form{leader_url}#term-{epoch}, andlease_epoch. A transition on an existing hold takes its authority fromresolve_budget_hold_authority: an injected joint authority store's fence first, then the authority recorded on the hold, then the current lease. - Identify. When an optional
eventIdis absent, the handler mintscluster-budget-write-{pid}-{nanos}-{counter}from a process-local counter, so the witness waits on this write's own sequence. Waiting on the authority's highest sequence would let a concurrent write to another capability raise the target and roll back a write that had reached quorum. - Target.
budget_write_tokenresolves(origin_id, event_seq, budget_term)from the stored event, with the event's own origin and lease epoch. A retry after leadership moved waits on the origin peers advertise the event under. Only a missing row falls back to the authority's highest sequence, which can over-target but never under-target. - Wait.
wait_for_budget_write_quorum_commitnudges the background loop once withrequest_sync, then parks on a watch channel and fetches from no peer itself. It answers 503 when quorum disappears, on timeout, or when the progress channel closes while the node is clustered. The timeout is the larger of a sync-interval term clamped to 5 to 30 seconds and a term that scales with the peer count, capped at 300 seconds.
A successful wait adds the budgetCommit block to the response: budgetSeq and commitIndex (both the write's own event sequence), quorumCommitted, quorumSize, committedNodes, witnessUrls, and the authority and term that wrote it.
let budget_commit = match wait_for_budget_write_quorum_commit(&state, write).await {
Ok(budget_commit) => budget_commit,
Err(_) => {
if already_captured || replayed_event {
return plain_http_error(
StatusCode::SERVICE_UNAVAILABLE,
"budget authorization replay could not confirm quorum commit; admission retained",
);
}
let rollback_result = rollback_budget_authorize_exposure(
&state,
&payload,
authority.as_ref(),
commit_index,
);
return match rollback_result {
Ok(()) => plain_http_error(
StatusCode::SERVICE_UNAVAILABLE,
&format!(
"budget authorize became leader-visible at commit index {commit_index} but failed quorum commit; local exposure rollback succeeded"
),
),
Err(error) => budget_internal_error(
&error,
"budget authorize quorum failure and rollback failed; admission retained",
),
};
}
};Read more in Leader & Failover, which covers forwarding and the lease.
An admission that misses its quorum is reversed by a new event
When the witness wait fails on authorize-exposure, the node appends a reverse under an event id derived from the generation it cancels. A later authorize that raised the usage sequence in between stays intact.
The compensation event id in Rust
fn budget_authorize_compensation_event_id(
payload: &TryChargeCostRequest,
authorize_event_seq: u64,
) -> String {
if let Some(event_id) = payload.event_id.as_deref() {
return format!("{event_id}:rollback:{authorize_event_seq}");
}
if let Some(hold_id) = payload.hold_id.as_deref() {
return format!("{hold_id}:rollback:{authorize_event_seq}");
}
format!(
"rollback:{}:{}:{}",
payload.capability_id, payload.grant_index, authorize_event_seq
)
}When an authorize of 60 is followed by an unrelated hold of 40 and the first is rolled back, the usage row lands at one invocation and 40 units exposed. The rolled-back hold cannot reopen under its original id, and a retry under a fresh hold id receives a strictly higher commit index, so a caller retrying a compensated authorize opens a new hold.
Compensation covers newly admitted exposure only. Capture, release, and reconcile answer the same 503 through respond_after_budget_write_quorum_commit and keep their local mutation. A replayed event id or an already-captured hold answers 503 with admission retained, because a reverse there could remove exposure another request relies on or reopen a slot whose call may have taken effect.
The reverse, written by rollback_budget_authorize_exposure, is a local write that waits for no peer. If it fails, the response is 500, or 503 for a fenced store, with budget authorize quorum failure and rollback failed; admission retained. After any of these responses, read the operation and the hold state before retrying, since the counters may already have moved.
A lagging node imports history without forgetting or over-claiming
A node that falls behind installs another node's budget snapshot only after three checks on the import path.
- Replay. The snapshot is replayed into a throwaway in-memory store and compared with what it claims, so a payload without its own complete final usage projection never reaches the database.
- Local subset.
validate_local_snapshot_subsetrequires the snapshot to retain every local mutation event identically, cover every local abandoned sequence range, and prove every local usage row. - Heads. A claimed
covered_headmust equal the head the imported records prove, and each per-origin acknowledgment head must match the same way.
A leader started with --authority-db whose snapshot carries a usage-history anchor attaches a budgetAnchorProvenance chain. The importer refuses one that comes from a node it does not call leader, names another leader or term, mismatches the anchor set, or rewinds or forks the chain it has persisted for that leader.
The chain is a chio.budget-snapshot-anchor-provenance.v1 record with one signed commitment per link, each binding a commit sequence, the previous chain digest, the anchor-set digest, the leader URL, and the election term. Each commitment is checked against a key carried in its own body, which proves the chain's continuity. The cluster authenticates the chain with a constant-time HMAC of cluster_authenticator under the cluster service token, so provenance is as strong as that shared secret. A snapshot without the chain goes through import_budget_snapshot with the three checks.
A clustered plane labels its budget guarantee advisory_posthoc
Three records answer what happened to one clustered budget write.
| Question | Mechanism | What to inspect |
|---|---|---|
| What was admitted? | Local budget transaction and mutation journal | Hold, event identity, exposed and realized counters |
| Which peers witnessed it? | Acknowledgment heads for the write's origin and sequence | budgetCommit, witness URLs, peer eligibility |
| What remains after a failure? | Compensation, or the retained mutation | The response outcome and the current hold state |
Receipts carry the guarantee level of the store that admitted the spend, and a consumer can pin a floor. budget_authority_guarantee_level returns advisory_posthoc whenever a cluster is configured, and a kernel that reaches budgets through --control-url holds a RemoteBudgetStore that reports AdvisoryPosthoc in every case. quorumCommitted: true states that one event reached a quorum; linearizable spend across nodes is outside what it claims.
The release profile treats clustered monetary budgets as provisional authorized exposure. Across a partition, the documented bound is overrun <= max_cost_per_invocation × node_count, which assumes each replica admits at most one invocation at the per-call cap before the merge catches up; the exposure bound works it through.
Read more in Authoritative Spend, which states what a receipt must carry before a spend counts as authorized.
Budget routes and records
Try authorize-exposure, reductionUnits, or 150 units.
Budget routes
| Route | Store call | Waits for quorum |
|---|---|---|
POST /v1/ | try_increment | No |
POST /v1/ | authorize_ with a holdId, otherwise try_ | Admissions only |
POST /v1/ | capture_ | Yes |
POST /v1/ | reverse_ | Yes |
POST /v1/ | reconcile_ with both amounts, otherwise release_ | Yes |
GET /v1/budgets | list_usages | Read path |
incrementcounts invocations, carries no money, and returns after one leader-visible re-read.try_incrementtakes no authority argument, so its event carries a null authority and no peer ever witnesses it.authorize-exposurewrites a denial's journal event and returns after leader visibility, with nobudgetCommit.capture-invocationtypesholdIdandeventIdas required strings and is the one money route that skipsvalidate_budget_request_identity.release-exposureperforms the store's reverse, the pre-capture exit that also decrementsinvocation_count. It refuses unless the amount equals the hold's remaining exposure exactly, the invocation was debited, and the hold is uncaptured.reconcile-spendanswers 400 when exactly one ofauthorizedExposureUnitsandrealizedSpendUnitsaccompaniesreductionUnits. WithoutreductionUnits, the JSON extractor rejects the body before the handler runs.GET /v1/budgetsanswers from what this node has imported, with no forwarding and no quorum check. Its rows carry no replicationseq.
Route names and store verbs differ: release-exposure reverses, and reconcile-spend releases or reconciles depending on the fields present. authorize-exposure, release-exposure, and reconcile-spend also accept a form without holdId, which lands on the matching _with_ids_and_authority ledger call; capture-invocation has no such form. Requests carry exposureUnits, maxExposurePerInvocation, and maxTotalExposureUnits, responses carry totalExposureCharged and totalRealizedSpend, and the test budget_wrappers_use_split_budget_routes pins reconcile's three fields byte for byte.
Request identity
On the three routes with optional identifiers, validate_budget_request_identity runs after the service-token check and before forwarding. A holdId without a non-empty eventId is a 400, and so is any empty identifier. A store fenced to another serving owner answers 503, and the compared epochs stay out of the response body.
Remote budget store and versioned holds
A kernel's RemoteBudgetStore refuses authority-fenced charging (remote budget store does not support authority-fenced charging and its siblings) and, through reject_unsupported_remote_hard_authority, aggregate family invocation quotas and cumulative-approval reservations.
The versioned /v1/budgets/holds/* lifecycle runs against an injected joint budget and revocation authority. /holds/authorize, /holds/authorize-cumulative, and /budgets/cumulative-approval/lookup require it (structured remote budget authority requires an injected, already-provisioned joint budget and revocation authority). These routes are leader-forwarded, with no budgetCommit and no witness wait. The handlers live in budget_handlers/structured.rs behind structured_budget_store and structured_lifecycle_budget_store.
Exposure bound
The cluster design record states overrun <= max_cost_per_invocation × node_count under the assumption that each independent replica admits at most one invocation at the per-call cap before the merge catches up. The bound holds under that admission window and is stated for it alone.
| Quantity | Source-test value | Interpretation |
|---|---|---|
Configured total | 150 units | Each independent store sees this ceiling |
Per-invocation cap | 100 units | Each store admits one invocation |
Combined exposure | 200 units | 100 admitted on each of two stores |
Exposure beyond the configured total | 50 units | 200 combined minus the intended 150 |
The source test concurrent_charge_overrun_bound opens two stores, charges each once, and sums their exposure, with no replication, merge, partition, or external payment; its combined_spend variables name exposure arithmetic. The rank order a floor check uses is guarantee_level_rank in receipt/authoritative_spend.rs.
Source tests
trust_control_cluster_requires_quorum_and_heals_after_partitionisolates one node of three, asserts 503 on/v1/budgets/increment, writes the same request through the majority, heals the partition, and waits for the isolated node to report the usage row it never wrote.trust_control_cluster_failed_quorum_does_not_leave_orphaned_exposurestops the only peer of a two-node cluster with SIGSTOP, asserts the 503, and waits for the usage row to return to zero invocations and zero exposure.generated_budget_event_id_is_unique_per_callmints ten thousand ids in a tight burst and checks that all are distinct.budget_authorize_compensation_tracks_the_authorize_generationdrives the authorize-60, hold-40 rollback and its fresh-hold retry.respond_after_budget_write_quorum_commitincluster/deltas.rsanswers forhandle_capture_invocation,handle_reverse_charge_cost, andhandle_reduce_charge_cost;budget_authorize_compensation_event_idderives the reverse's event id.crates/products/chio-cli/tests/trust_cluster.rscovers quorum refusal, compensation, denied-event replication, and snapshot catch-up; a unit test covers the witness arithmetic over simulated acknowledgment state. Snapshot checks live invalidate_budget_snapshot_in_isolation,apply_import_batch, andchio-store-sqlite/src/budget_store/snapshot.rs; provenance inverify_budget_snapshot_anchor_provenanceandbudget_snapshot_anchor_authenticatorincluster/snapshots.rs. Replica repair is a separate path from the leader-forwarded handlers and updates the store on its own.