Chio/Docs
LOGIN · JOIN

PlatformDurable State

Node

Budget Store

Authorize, capture, release, reconcile as one process performs them, and what the store refuses to do on its own.

One node’s file, not the cluster’s

Everything on this page is answerable by a single node writing its own SQLite file. Getting one node’s budget decisions onto the other nodes under the same authority keypair is a different problem with a different failure model: Cluster · Budgets Across Nodes owns distribution, quorum acks, and the overrun bound. This page owns the baseline those are measured against.

One question, asked before every spend

A budget store answers one question for one process: may this grant spend this much right now, and what has it spent so far? Budget mutation is not Kernel content, and the kernel says so. The portable core's evaluate module lists what it deliberately does not do, and budget mutation is the second item, right after revocation:

crates/kernel/chio-kernel-core/src/evaluate.rsrust
//! What it does NOT do (fenced into `chio-kernel` proper):
//!
//! - Revocation membership lookup (stateful `RevocationStore`).
//! - Budget mutation (stateful `BudgetStore`).
//! - Delegation-chain ancestor inspection against the receipt store.

The reason is the same as for the revocation set, with one addition: a budget decision does not only read state, it writes it. A retry after a crash must not charge twice, and a commit whose fate is unknown must say so rather than guess. That needs a durable transaction, an idempotency key, and a fence. None of those belong in a pure function.


One row per grant, and the two columns a cap is compared against

TermDefined inRole
BudgetStorechio-kernel/src/budget_store.rsThe trait. Lifecycle methods, read methods, and three profile methods that describe the backend to its caller.
SqliteBudgetStorechio-store-sqlite/src/budget_store/The durable node-local backing: WAL, one writer behind a mutex. Three tables carry this page’s model; the same schema also creates replication, quota, and cumulative-approval tables that belong to Cluster.
InMemoryBudgetStorechio-kernel/src/budget_store/in_memory.rsA mutex over hash maps with the same journal. What the property tests drive.
BudgetUsageRecordchio-kernel/src/budget_store.rsOne row per (capability_id, grant_index): invocation_count, total_cost_exposed, total_cost_realized_spend, seq.
Holdbudget_authorization_holdsOne in-flight authorization: remaining_exposure_units, invocation_captured, and a disposition the store writes as open, released, reversed, or reconciled. A projection_kind column separates a legacy hold, which is the one this page describes, from a composite_v1 hold.
BudgetMutationRecordbudget_mutation_eventsThe journal. One row per attempted mutation, allowed or denied, carrying the after-state and a dense event_seq.
BudgetEventAuthoritychio-kernel/src/budget_store/model.rsauthority_id, lease_id, lease_epoch. Stamped on every mutation and re-checked on replay.

The two cost columns are the whole model. Exposure is money the node has committed to allow but has not yet been told was spent; realized spend is money a completed call reported. What a cap is compared against is their sum, and that sum is computed with a checked add so a wrap becomes a refusal rather than a wrong comparison:

crates/kernel/chio-kernel/src/budget_store.rsrust
fn checked_committed_cost_units(
    total_cost_exposed: u64,
    total_cost_realized_spend: u64,
) -> Result<u64, BudgetStoreError> {
    total_cost_exposed
        .checked_add(total_cost_realized_spend)
        .ok_or_else(|| {
            BudgetStoreError::Overflow(
                "total_cost_exposed + total_cost_realized_spend overflowed u64".to_string(),
            )
        })
}

Amounts are u64 minor units. MonetaryAmount (chio-core-types/src/capability/scope.rs) pairs those units with an ISO 4217 currency string, and ToolGrant carries max_invocations, max_cost_per_invocation, and max_total_cost, all optional. A grant with none of the three has no limit to enforce. Nothing below the grant carries a currency: every lifecycle method on the trait takes bare u64 units, and the store never compares one. docs/adr/ADR-0006-monetary-budget-semantics.md records why integers rather than floats or decimal strings. Read its "monotonic, no-refund" section as history: ADR-0016 supersedes it and makes the authorize-then-reconcile hold lifecycle normative, because the code refunds. ADR-0016 is itself still marked Proposed, and its receipt-side predicate is documented at Authoritative Spend.

Three trait methods describe the backend rather than mutate it, and the defaults are what an unclustered node reports: budget_guarantee_level() is SingleNodeAtomic, budget_authority_profile() is AuthoritativeHoldEvent, and budget_metering_profile() is MaxCostPreauthorizeThenReconcileActual. SqliteBudgetStore overrides none of the three. The control plane's RemoteBudgetStore overrides exactly one, budget_guarantee_level, and reports AdvisoryPosthoc.

Read the other two narrowly. BudgetAuthorityProfile and BudgetMeteringProfile are single-variant enums today and carry no discriminating information yet. Only BudgetGuaranteeLevel has alternatives: SingleNodeAtomic, HaLinearizable, PartitionEscrowed, AdvisoryPosthoc.


The lifecycle in order

One mediated call produces one hold. The kernel derives its identity before it touches the store, so every later transition is addressable and replayable: budget-hold:{request_id}:{capability_id}:{grant_index}. Event ids hang off it: {hold_id}:authorize, {hold_id}:capture-invocation:{commit_index}, {hold_id}:reconcile, and, for both reverse and cancel-before-dispatch, the same id built off the authorize event rather than the hold: {hold_id}:authorize:rollback:{commit_index} (kernel/validation.rs, kernel/mod.rs). Two other hold shapes exist and are not covered here: a nonce preflight prefixes nonce-preflight-, and under durable admission the id comes from the admission record instead. On a standalone node the authority is kernel:{public key hex} with lease id single-node and epoch 1.

rendering
The disposition values the SQLite budget store writes for one hold, and the trait method that writes each. Capture sets a flag on the row rather than a disposition, so it leaves a hold open unless the exposure was already spent down to zero.
sourcecrates/platform/chio-store-sqlite/src/budget_store/model.rs:4-30crates/platform/chio-store-sqlite/src/budget_store/trait_impl.rsat fe56570

Every edge is a literal in the SQLite store. create_hold inserts at HoldDisposition::Open with invocation_captured = 0; release writes Released only when the remainder reaches zero and otherwise leaves the row open; reverse and cancel-after-capture both write Reversed; reconcile writes Reconciled. Capture is the one transition that is not a disposition at all. Its statement sets invocation_captured = 1 and touches disposition through a CASE that fires only when remaining_exposure_units = 0, which is why the reaper finds captured-but-unreconciled work with WHERE disposition = 'open' AND reserved_until IS NOT NULL. Neither reaper adds an edge: both drive the same four methods, so an expired reserved hold lands on Reconciled by capturing and then reconciling at its worst case.

A fifth disposition is declared and never written

BudgetHoldDispositionView, the kernel-side projection the trait returns, has five variants: Open, Released, Reversed, Reconciled, and Expired. The SQLite backend cannot produce the fifth. Its own HoldDisposition enum has four variants, its parse returns None for any other string, and the function that projects a stored row into the trait's view maps those four and has no arm that yields Expired. A grep of crates/ finds the variant declared and given an as_str arm of "expired", and constructed nowhere. An expired reserved hold reads back as Reconciled, not as Expired.
TransitionJournal kindinvocation_counttotal_cost_exposedtotal_cost_realized_spend
authorizeAuthorizeExposure+1+ requestedunchanged
captureCaptureInvocationunchangedunchangedunchanged
reverseReverseExposure−1− exposureunchanged
cancel after captureCancelCapturedBeforeDispatch−1− exposureunchanged
releaseReleaseExposureunchanged− amountunchanged
reconcileReconcileSpendunchanged− exposure+ realized

Two per-event lanes ride on every journal row

The counters above are the hold's arithmetic. Beside them each journal row carries four more columns that record where the mutation moved a state machine: invocation_state_before, invocation_state_after, monetary_state_before, and monetary_state_after. append_mutation_event derives all four through appended_event_lifecycle and writes them on every event, denials included. They are two machines, not one, and neither is the hold's disposition: disposition is the hold-level summary, and these are per-event stamps.

LaneEnumVariants, in declaration order
InvocationBudgetInvocationStateAbsent, Authorized, Captured, Reversed, Denied
MonetaryBudgetMonetaryStateNone, Exposed, Released, Reconciled, Captured, Reversed

Both live in chio-kernel/src/budget_store/model.rs. The legal moves are a closed table rather than a convention. On the write path appended_event_lifecycle derives both lanes from the mutation kind, the allow bit, and the exposure amount, so an author never picks them. On the import and replay path validate_legacy_event_lifecycle enumerates, per mutation kind, which before and after pair each lane may carry, and anything outside that table is budget mutation `X` has an invalid legacy lifecycle projection. Authorize moves the invocation lane from Absent to Authorized, or to Denied on a refusal; capture moves it to Captured; reverse and cancel-after-capture both move it to Reversed; release and reconcile leave it where it was. The monetary lane is where the amounts show: Exposed on a non-zero authorize, Released once the remainder is given back, Reconciled after a settle, and None throughout when the authorized exposure was zero.

Four mutation kinds never reach a legacy hold. validate_supported_import_record refuses an imported record carrying ReserveInvocation, AuthorizeCumulativeApproval, ReverseInvocation, or CaptureSpend with budget mutation `X` uses state unsupported by the sqlite budget store, and the lifecycle table marks the same four invalid. The last of those is why BudgetMonetaryState::Captured never appears on a node-local hold: it is the only kind that produces it. Those four, and the quota and cumulative-approval fields that travel with them, belong to the composite path.

Authorize takes the worst case, not the price. The metering profile name says so, and the kernel is literal about where the worst case comes from: the pre-execution debit is grant.max_cost_per_invocation in minor units, and 0 when the grant does not set that field. The three limit checks run inside one IMMEDIATE transaction, in a fixed order: invocation count, then per-invocation cap, then the total. An overflow while computing the total is raised as an error instead of being compared:

crates/platform/chio-store-sqlite/src/budget_store/authorization.rsrust
if request.max_invocations.is_some_and(|max| invocation_count >= max)
    || request
        .max_cost_per_invocation
        .is_some_and(|max| request.requested_exposure_units > max)
{
    return Ok(false);
}
let committed = checked_committed_cost_units(exposed, realized)?;
let requested = committed
    .checked_add(request.requested_exposure_units)
    .ok_or_else(|| {
        BudgetStoreError::Overflow(
            "committed cost + requested exposure overflowed u64".to_string(),
        )
    })?;
Ok(request
    .max_total_cost_units
    .is_none_or(|max| requested <= max))

A grant with only max_total_cost exposes nothing

Because the debit is read off max_cost_per_invocation, a grant that sets only max_total_cost authorizes an exposure of zero. Reconcile then clamps realized spend to the authorized exposure (realized_spend_units: realized.min(charge.cost_charged) in reconcile_budget_charge), so both cost columns stay at zero and the aggregate cap is never approached. max_invocations still counts. If you want a monetary cap enforced at all, set the per-invocation cap. That same clamp is why a tool cannot spend past its cap after the fact: a server reporting more than the authorized exposure produces a cost_overrun, the receipt gets settlement_status failed, and the hold reconciles at the exposure. The cost columns are an upper bound on admitted spend, not a record of what a tool claims to have charged.

A refusal is durable. The denial path still allocates a sequence number and appends a journal event with allowed = Some(false) and a commit index, while writing no usage row at all: denied_authorize_hold_reports_durable_commit_metadata asserts exactly that, one event and get_usage still None. A deny receipt can therefore cite a budget commit index, not just a boolean. That test drives InMemoryBudgetStore; the SQLite path allocates the sequence and appends the event in the same code path but is covered by its own tests.

Capture is the point of no return for the invocation slot. Before it, a pre-dispatch failure calls reverse_budget_hold, which decrements invocation_count and removes the exposure. After it, reverse is refused by the store, and the only pre-dispatch exit is cancel_captured_before_dispatch, a distinct transition with its own journal kind. Reverse is also all-or-nothing: the same guard rejects any amount that is not the hold's exact remaining_exposure_units, with budget hold `X` does not match reverse amount. Partial give-back is what release is for.

Reconcile is fenced on three conditions, each of which is an explicit Invariant error rather than a silent clamp: the hold must have been captured (invocation was not captured before reconciliation), the reconciled exposure must equal the hold's remaining exposure exactly, and realized spend must not exceed exposed cost. The unused remainder is released by construction, which is why a reconciled hold can hold both a committed and a released amount under one terminal disposition.

An unknown dispatch outcome is retained, not reversed

If a tool server returns an error or the future is dropped after the invoke method has been polled, the node does not reverse the hold. Reversing would reopen spending capacity for a call that may have already committed a side effect. There is no retain mutation in the journal, so the exposure stays outstanding and keeps counting against the cap. The signed receipt is the only record: its budget_authority.hold_id names the stuck hold, and mark_runtime_admission_reservations_retained_fail_closed copies any live reservation ids across as retained_destructive_lease_id, retained_treaty_continuation_id, and retained_swarm_continuation_id. Resolution is an operator action. The empty journal cell is deliberate and is stated as such in docs/formal/plan/FV-B3-budget-conservation-law.md.

Idempotency, fencing, and unknown outcomes

The store opens with journal_mode = WAL, synchronous = FULL, busy_timeout = 5000, and foreign_keys = ON, stamps its schema revision under the budget key, and refuses a database already provisioned to a serving owner with BudgetStoreError::Fenced before it reads anything. Writes take TransactionBehavior::Immediate and verify the serving-owner fence and authority anchor inside begin_write; reads use a deferred transaction and the same fence.

One arithmetic limit is easy to miss. The trait speaks u64, but SQLite stores signed 64-bit integers, so every amount and sequence number goes through budget_u64_to_sqlite on the way down. A value above i64::MAX is refused with Overflow (budget field `X` exceeds SQLite INTEGER range), not truncated. The usable range on this backend is half of what the types say.

Every lifecycle call carries an event_id, and existing_event_allowed makes a replay of that id return the recorded outcome instead of applying the mutation twice. It is strict about what counts as the same mutation: capability, grant index, kind, hold id, exposure, realized spend, and all three caps must match, or the call fails with budget event_id `X` was reused for a different mutation. A separate check compares the persisted BudgetEventAuthority against the one presented, so a retry under a different lease is rejected rather than absorbed.

BudgetStoreError::OutcomeUnknown exists so a failed commit is not reported as a false negative the caller would be tempted to retry blindly. Know where it actually fires: commit_joint_transaction produces it, and that method requires a serving owner. The unbound node-local path this page describes commits with a plain transaction.commit()?, so a commit failure there arrives as BudgetStoreError::Sqlite. The distinction is available on the structured path, not the plain one.

A serving owner switches the store to a different code path

Everything above describes SqliteBudgetStore::open, which is what a node opens for itself. When the store is instead built with open_alongside a serving owner, which is what the control plane's durable-admission authority database does, the unbound lifecycle mutations described here are refused outright: joint sqlite authority rejects legacy {operation} mutation. A composite path with its own quota, approval, and admission-binding tables takes over. That path is Cluster content and this page makes no claims about it.

The trait refuses to fake what a backend cannot do

Most rich methods have defaults that return Invariant errors with text like budget store does not support truthful rich release projections. The _with_ids shims error when handed a hold or event id they cannot honor rather than dropping it. And try_charge_cost_with_ids_and_authority has no default at all, with the reason written in the trait: the method is required "so a backend upgrade cannot compile successfully and fail only on live calls."

Two reapers clean up what a crashed or abandoning caller leaves behind. reap_orphaned_holds settles holds against a map of realized amounts. reap_expired_reserved_holds handles holds that were explicitly marked reserved with a deadline, and its choice is worth stating plainly: an expired, unreconciled hold is settled at its worst case, forfeiting the full reserved amount to realized spend. In the two-phase flow the only evidence a spend occurred is the caller's reconcile, so releasing at zero would under-count real spend and fail open against a cumulative cap. Both reapers are idempotent, and only touch holds that are still open.

Three boundaries on that. The only thing that marks a hold reserved is the allow path minting an execution nonce, and the deadline is that nonce's expires_at; a hold with no nonce has a NULL reserved_until and is never swept. The trait default for both reapers is a no-op returning zero, so a backend that has not implemented them reports success and does nothing. And nothing in the kernel puts them on a timer: reap_expired_reserved_budget_holds says so itself, "the sidecar drives this on a timer (startup wiring is a later task); this method is the reachable primitive." The only caller on a clock today is the chio-api-protect proxy, every 30 seconds, and only with a mediation kernel configured. A node embedding the kernel directly calls the primitive itself or not at all.


Guarantees and limits

ClaimStatusEvidence and boundary
Limit checks and the state change they authorize are one atomic transaction. No partial state is observable.Shippedauthorize_budget_hold_atomic runs the checks, the usage upsert, the hold insert, and the journal append inside one IMMEDIATE transaction. Scoped to one process against one file.
Overflow denies rather than mis-compares.Shipped, and proved for the scalar modelchecked_add at both call sites; the Kani harness verify_budget_checked_add_no_overflow (compiled only under cfg(kani)) proves fail-closed no-partial-commit and overflow-before-cap ordering for a standalone model of that arithmetic, not for the store.
A mutation replayed under the same event id applies once.Shippedexisting_event_allowed plus validate_replay_authority. Idempotency is keyed on the event id the kernel derives, so a caller that invents a fresh id per retry gets a second mutation, correctly.
The store enforces currency, not just units.Unsupported in the store; handled above it, and only with an oracle configuredEvery lifecycle method takes bare u64 units. Currency lives on MonetaryAmount in the grant and on the reserved-hold row. Conversion is the kernel's job, not the store's: resolve_cross_currency_cost converts a reported cost whose currency differs from the grant's through a PriceOracle. That oracle is None unless an operator calls set_price_oracle, and with no oracle the conversion fails closed: the hold reconciles at the full authorized exposure and the receipt records oracle_conversion.status = failed. ADR-0006's Negative consequence that a foreign-currency cost is "compared as raw units" predates that path; the crate is authoritative.
The cap is a hard stop across several nodes.Unsupported at this rungTwo nodes reading their own replicas can each admit one invocation at the per-invocation cap before a merge propagates, so the overrun is bounded by max_cost_per_invocation × node_count. The bound is a SAFETY comment on the trait's try_charge_cost. concurrent_charge_overrun_bound illustrates it rather than proves it: it opens two separate database files, charges each once through the legacy try_charge_cost, and asserts the arithmetic. No replication merge runs in that test. It is a Cluster concern; the law on this page is scoped to SingleNodeAtomic for exactly this reason.
FieldValue
StatusThe ranking check is shipped. The truthfulness it depends on is a contract on the issuer, not an enforced property. ADR-0016 is status Proposed.
ClaimA consumer can pin an operator floor and reject any receipt whose reported guarantee level ranks below it.
SubjectThe label a receipt carries, and the floor a consumer checks it against.
EvidenceADR-0016 decision 2 makes the BudgetGuaranteeLevel taxonomy normative and requires it to be truthful: no ha_linearizable without a quorum store. receipt_meets_guarantee_floor ranks the four levels and fails closed twice over, on an unrecognized floor and on an unrecognized claimed level, so a typo cannot rank as the weakest level and slip through.
LimitThe predicate ranks a label; it does not verify it. r4_receipt_claiming_ha_linearizable_fails_single_node_operator_floor ends by editing guarantee_level to ha_linearizable, re-signing with the same key, and asserting the forged receipt now passes the linearizable floor. What the test pins is that an honest single-node receipt fails that floor. A dishonest one does not. The only thing standing between the two is signer admission plus the backend reporting itself honestly, which is why a store reporting a level it cannot back is a correctness bug, not a policy choice.

One more limit worth stating outright. The kernel serializes its own budget access behind a process Mutex in with_budget_store, recovering from poisoning rather than failing; with_revocation_store takes no such lock. That Mutex orders one process's mutations. It says nothing about a second process opening the same file, which is what the serving-owner fence is for.


Next Steps

  • Cluster · Budgets Across Nodes · distribution, write quorum acks, and the overrun bound this page defers
  • Node State · the substrate: authority database, own-file stores, schema stamping, the serving-owner fence
  • Revocation Store · the first entry on the same exclusion list
  • Economy · what sets the numbers this store enforces
  • Formal Assurance · how a four-lane law is registered, and what "linkage not established" means