Chio/Docs
LOGIN · JOIN

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.

Concept chio@5f63e6e

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.

Replicated logChoose a view.
replicated on each nodeBbudget pool BnAnode AwriternBnode BpeernCnode Cpeerorigin A · budget mutation events55charge USD 2.0056settle USD 1.4057charge USD 0.6058charge USD 1.20writes 58ack 58ack 572 of 3 hold 58 · quorum 2 · committedBpool BnAnode AnBnode BnCnode Corigin A · budget mutation events55charge56settle57charge58charge585857write, acks2 of 3 hold 58 · quorum 2 · committed

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.

One origin’s log on three trust-control nodes: each row is an entry with its index, and a filled dot is a row that node holds. Indices, amounts, and capability ids are illustrative; tool receipts, child receipts, and lineage, whose sequences may contain gaps, are left out.

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.

  1. Forward. forward_post_to_leader checks 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.
  2. Stamp. A clustered money write calls current_budget_event_authority, which answers 503 when the lease is unavailable or expired and otherwise stamps authority_id (the leader URL), lease_id in the form {leader_url}#term-{epoch}, and lease_epoch. A transition on an existing hold takes its authority from resolve_budget_hold_authority: an injected joint authority store's fence first, then the authority recorded on the hold, then the current lease.
  3. Identify. When an optional eventId is absent, the handler mints cluster-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.
  4. Target. budget_write_token resolves (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.
  5. Wait. wait_for_budget_write_quorum_commit nudges the background loop once with request_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.

crates/platform/chio-control-plane/src/trust_control/budget_handlers.rs458-486rust
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
crates/platform/chio-control-plane/src/trust_control/cluster/deltas.rsrust
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.

  1. 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.
  2. Local subset. validate_local_snapshot_subset requires the snapshot to retain every local mutation event identically, cover every local abandoned sequence range, and prove every local usage row.
  3. Heads. A claimed covered_head must 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.

QuestionMechanismWhat to inspect
What was admitted?Local budget transaction and mutation journalHold, event identity, exposed and realized counters
Which peers witnessed it?Acknowledgment heads for the write's origin and sequencebudgetCommit, witness URLs, peer eligibility
What remains after a failure?Compensation, or the retained mutationThe 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

Budget routes

RouteStore callWaits for quorum
POST /v1/budgets/increment
try_increment
No
POST /v1/budgets/authorize-exposure
authorize_budget_hold with a holdId, otherwise try_charge_cost_with_ids_and_authority
Admissions only
POST /v1/budgets/capture-invocation
capture_invocation_reservations
Yes
POST /v1/budgets/release-exposure
reverse_budget_hold
Yes
POST /v1/budgets/reconcile-spend
reconcile_budget_hold with both amounts, otherwise release_budget_hold
Yes
GET /v1/budgets
list_usages
Read path
  • increment counts invocations, carries no money, and returns after one leader-visible re-read. try_increment takes no authority argument, so its event carries a null authority and no peer ever witnesses it.
  • authorize-exposure writes a denial's journal event and returns after leader visibility, with no budgetCommit.
  • capture-invocation types holdId and eventId as required strings and is the one money route that skips validate_budget_request_identity.
  • release-exposure performs the store's reverse, the pre-capture exit that also decrements invocation_count. It refuses unless the amount equals the hold's remaining exposure exactly, the invocation was debited, and the hold is uncaptured.
  • reconcile-spend answers 400 when exactly one of authorizedExposureUnits and realizedSpendUnits accompanies reductionUnits. Without reductionUnits, the JSON extractor rejects the body before the handler runs.
  • GET /v1/budgets answers from what this node has imported, with no forwarding and no quorum check. Its rows carry no replication seq.

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.

QuantitySource-test valueInterpretation
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_partition isolates 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_exposure stops 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_call mints ten thousand ids in a tight burst and checks that all are distinct.
  • budget_authorize_compensation_tracks_the_authorize_generation drives the authorize-60, hold-40 rollback and its fresh-hold retry.
  • respond_after_budget_write_quorum_commit in cluster/deltas.rs answers for handle_capture_invocation, handle_reverse_charge_cost, and handle_reduce_charge_cost; budget_authorize_compensation_event_id derives the reverse's event id.
  • crates/products/chio-cli/tests/trust_cluster.rs covers quorum refusal, compensation, denied-event replication, and snapshot catch-up; a unit test covers the witness arithmetic over simulated acknowledgment state. Snapshot checks live in validate_budget_snapshot_in_isolation, apply_import_batch, and chio-store-sqlite/src/budget_store/snapshot.rs; provenance in verify_budget_snapshot_anchor_provenance and budget_snapshot_anchor_authenticator in cluster/snapshots.rs. Replica repair is a separate path from the leader-forwarded handlers and updates the store on its own.

Next: follow a revocation across the same nodes

Budgets Across Nodes · Chio Docs