Chio/Docs
LOGIN · JOIN

Sub-agent budgets

One spending ceiling divides into per-child allocations, and the verifier keeps every allocation and the closing rollup inside it.

Concept chio@5f63e6e

On this page

A planner can split one spending ceiling across the children it delegates to. Each child carries its own allocation, and the kernel refuses a swarm-bound request whose allocations outgrow the pool or whose child holds no active share.

The running example is the signed recursive-delegation fixture: a pool of 10,000 usd_minor units, with 2,500 units allocated to each of three children and 2,500 left unallocated.

Every child’s allocation fits under one pool ceiling

The signed task graph names the pool, each child’s node names its allocation, and the allocation ceilings together may fill the pool but never exceed it.

Signed task graphChoose a view.
depth 0depth 13 of maxFanout 3maxDepth 2joinpool · 10,000 usd_minor7,500 allocated · 2,500 unallocatednext task: task-rootκtask-roota1task-child-a2,500a2task-child-b2,500a3task-child-c2,500ρjoin-child-resultsmaxDepth 2 · maxFanout 3pool 10,0007,500 of 10,000κtask-roota12,500a22,500a32,500ρjoin-child-resultsnext task: task-root

a1task-child-aa2task-child-ba3task-child-c

Each child’s allocation fits inside the pool

The pool holds 10,000 usd_minor units. Each child holds 2,500, so 7,500 is allocated and 2,500 stays unallocated. The verifier checks that every allocation’s counters sum to its maxUnits and that the ceilings sum to at most the pool’s total, so no child is wider than its parent. Sub-Agent Budgets.

The signed task graph and budget pool from the valid-recursive-delegation fixture, with widths drawn to the pool’s units; the overdrawn pool is the budget-allocations-exceed-pool fixture and the two-hop chain is the verifier’s source test. Route plans, continuations, and the revocation epoch appear by name only.

The fixture allocates 7,500 of its 10,000 units. Its terminal receipt rolls the allocations up by dimension and records 7,500 active units and zero consumed: the rollup accounts for declared authority, and unallocated capacity stays out of it. The overdrawn fixture gives two children 8,000 units each against the same pool, and the verifier refuses that bundle whole.

Three bindings hold the pool in place. The graph signs the pool id, the terminal receipt signs aggregate unit columns per dimension, and the runtime admission hook compares the request’s pool digest with the exact pool in its store.

Five counters partition each allocation’s ceiling

An allocation names one task, one dimension, a ceiling in max_units, and a state. Its reserved, active, consumed, released, and reversed units always sum to the ceiling.

crates/kernel/chio-swarm-authority/src/types.rs296-302rust
pub enum SwarmBudgetAllocationState {
    Reserved,
    Active,
    Consumed,
    Released,
    Reversed,
}
  1. Reserved. Fan-out creates each allocation with its whole ceiling in reserved_units.
  2. Active. The host activates an allocation before its child runs. A continuation can name only an Active allocation, and an active allocation must hold active units.
  3. Consumed and Reversed. The host moves units into these columns as the child’s work is charged or reversed.
  4. Released. Fan-in returns whatever has not reached a final column and marks the allocation released.

The pool and allocation are closed objects: every field is required and unknown fields are refused. The dimension is a grouping key for the terminal rollup, and the pool total bounds the sum of every ceiling across all dimensions at once.

Read more in Swarm Protocol, which defines every swarm artifact.

Fan-out splits the pool and fan-in returns what is left

reserve_swarm_budget_fanout takes a pool total and the requested allocations and returns a whole pool. It accumulates the requests under checked_add, then checks uniqueness and the ceiling before it returns anything.

crates/kernel/chio-swarm-authority/src/verifier.rs419-423rust
require_unique_strings(&allocation_ids, "swarm budget allocation")?;
require_unique_strings(&task_ids, "swarm budget task")?;
if total_reserved > request.total_units {
    return Err(rejected("swarm budget fanout exceeds pool total"));
}

Allocation ids and task ids are both unique at fan-out, and a request that asks for more than the pool fails with swarm budget fanout exceeds pool total. When children complete, release_swarm_budget_fanin releases each completed task’s allocation against its ceiling: every unit that has not reached consumed, released, or reversed returns to the pool.

The unit columns carry the accounting. A fully consumed allocation passed to fan-in reclaims zero units and is still stamped Released, so read the columns, not the state, to tell a finished child from an abandoned one.

The verifier checks four relationships before any child runs

verify_swarm_authority_bundle checks the pool after the route plans and joins and before the revocation epoch, terminal receipts, and continuation tokens. Every sum uses checked arithmetic, and an overflow refuses the bundle.

  1. Conservation. Each allocation’s five columns sum to its ceiling exactly, or the bundle fails with swarm budget allocation unit rollup mismatch.
  2. Pool ceiling. The pool belongs to this graph, its id equals the graph’s signed budgetPoolRef, every allocation names a task in the graph, and the ceilings sum to at most totalUnits.
  3. Terminal rollup. The signed terminal receipt’s per-dimension rollup equals the verifier’s own sum of the allocations, unit for unit.
  4. Dispatch binding. Each continuation’s budgetAllocationId equals its graph node’s budgetAllocationRef, names the token’s own child, and reads Active.

The pool carries no signature of its own. The graph authenticates its id and the terminal receipt its per-dimension aggregates, so moving units between allocations can leave the rollup unchanged; the runtime’s canonical SHA-256 comparison against the stored pool pins its exact contents. Every node with a parent carries a continuation, so the dispatch binding reaches every delegated child.

The prepared example reserves, charges, and releases a pool

The downloadable Rust project reserves 40 units for each of two tasks from a 100-unit pool, records a 15-unit charge against the first, and releases the rest at fan-in.

Worked Rust example: Reserve allocations and release unused units

Reserve allocations and release unused units

Reserve 40 units for each of two tasks from a 100-unit pool. The host records a 15-unit charge against the first allocation, then fan-in releases its remaining 25 units.

Use the Rust example setup, or download the complete source project. The files below belong to that project; shared constructors and configuration are included in the download.

Run it

bash
cargo run --locked --bin budget -- 100

Implementation

budget.rsrust
use chio_swarm_authority::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let total: u64 = std::env::args()
        .nth(1)
        .unwrap_or_else(|| "100".into())
        .parse()?;
    let request = SwarmBudgetFanoutReservationRequest {
        pool_id: "analysis-budget".into(),
        graph_id: "analysis-graph".into(),
        currency: "USD".into(),
        total_units: total,
        allocations: ["read", "summarize"]
            .into_iter()
            .map(|task| SwarmBudgetFanoutAllocationRequest {
                allocation_id: format!("allocation-{task}"),
                task_id: task.into(),
                dimension_id: "usd_minor".into(),
                reserved_units: 40,
            })
            .collect(),
    };
    let mut pool = reserve_swarm_budget_fanout(request.clone())?;
    assert_eq!(
        pool.allocations
            .iter()
            .map(|a| a.reserved_units)
            .sum::<u64>(),
        80
    );
    println!(
        "reserved: {} + {} of {total}",
        pool.allocations[0].reserved_units, pool.allocations[1].reserved_units
    );
    // The host owns this transition and must persist it atomically in its ledger.
    // The fanout helper itself only produces Reserved allocations.
    let first = &mut pool.allocations[0];
    first.state = SwarmBudgetAllocationState::Active;
    first.reserved_units = 0;
    first.active_units = 40;
    // After observing a 15-unit charge, record consumption before releasing slack.
    first.active_units = 25;
    first.consumed_units = 15;
    let released = release_swarm_budget_fanin(SwarmBudgetFanInReleaseRequest {
        pool,
        completed_task_ids: vec!["read".into()],
    })?;
    assert_eq!(released.allocations[0].consumed_units, 15);
    assert_eq!(released.allocations[0].released_units, 25);
    assert_eq!(released.allocations[1].reserved_units, 40);
    println!(
        "read: consumed {}, released {}; summarize: reserved {}",
        released.allocations[0].consumed_units,
        released.allocations[0].released_units,
        released.allocations[1].reserved_units
    );
    let mut too_small = request;
    too_small.total_units = 79;
    println!(
        "over-allocation: {}",
        reserve_swarm_budget_fanout(too_small).expect_err("80 cannot fit in 79")
    );
    Ok(())
}

Open src/bin/budget.rs

Captured result and refusal

Captured executable outputtext
reserved: 40 + 40 of 100
read: consumed 15, released 25; summarize: reserved 40
over-allocation: swarm budget fanout exceeds pool total

Active and consumption transitions here are explicit host code. Persist them atomically in your own ledger; the library's fan-out helper returns Reserved allocations and does not debit an external account.

Exit 0 indicates a successful example run. Expected refusals shown in the output are checked by the program; invalid command inputs exit nonzero. Keys, digests, timestamps and run-directory names vary.

You should see both reservations fit, the first allocation end with 15 units consumed and 25 released while the second stays reserved, and an over-allocation refused with swarm budget fanout exceeds pool total.

An oversubscribed pool is refused before any child is admitted

The negative fixture declares two 8,000-unit allocations against a 10,000-unit pool. Each allocation conserves its own ceiling; their sum exceeds the shared one.

swarm-authority · budget-exceeds-pooltranscript
$ chio proof verify ./swarm/negatives/budget-allocations-exceed-pool \
  --trust ./swarm/trust.toml
REFUSED   budget-allocations-exceed-pool  swarm budget allocations exceed pool total
trust     ./swarm/trust.toml (fixture keys)
exit 1

The offline verifier refuses the whole bundle with swarm budget allocations exceed pool total, and the refusal is attributed to claim.swarm.budget_pool_bound. The message names no allocation, because the check compares a sum with a ceiling and no single allocation is at fault.

The proof room replays the same case as a negative fixture, and a replay that resolves to any other failure code fails. A test, a conformance property, and that replayed negative all reach this refusal.

The pool declares authority; the hold ledger charges spend

The pool bounds what a graph is authorized to spend. Spend itself is charged against the kernel hold ledger, the one consumption authority, and a restart never replenishes a pool. The bundle verifier is stateless and checks each bundle on its own.

These checks apply to requests whose governed intent carries context.chioSwarm; every other request still goes through capability, policy, and budget admission. A ceiling on invocations across a whole delegation family is enforced on the capability path, as Invocation limits describes.

Read more in Authoritative Spend, which follows declared units to settled spend.

Budget objects and checks

Wire objects

ObjectSchema idCarries
SwarmBudgetPool
chio.swarm.budget-pool.v1
poolId, graphId, currency, totalUnits, allocations
SwarmBudgetAllocation
Nested in the pool
allocationId, taskId, dimensionId, state, units
SwarmGraphNode.budgetAllocationRef
chio.swarm.task-graph.v1
The signed pointer from a task to its allocation
SwarmContinuationToken.budgetAllocationId
chio.swarm.continuation-token.v1
The dispatch-time binding
SwarmTerminalBudgetRollup
Nested in chio.swarm.terminal-graph-receipt.v1
One row per dimension, plus a declared totalUnits

There is one pool per graph. budgetAllocationRef is Option<String> in the type, and the continuation check makes it mandatory for every parented task. A root may also reference a continuation and an allocation, for example when it is the next task of a join. The token’s budgetAllocationId must agree with the graph node, resolve in the pool, name the token’s own child task, and read Active. The verifier recomputes each rollup’s declared totalUnits.

Allocation fields

An allocation carries six unit columns and a state. The Rust type uses deny_unknown_fields, and spec/schemas/chio-swarm/v1/budget-pool.schema.json sets additionalProperties: false and requires all ten fields, so there is no partial allocation.

crates/kernel/chio-swarm-authority/src/types.rs281-292rust
pub struct SwarmBudgetAllocation {
    pub allocation_id: String,
    pub task_id: String,
    pub dimension_id: String,
    pub state: SwarmBudgetAllocationState,
    pub max_units: u64,
    pub reserved_units: u64,
    pub active_units: u64,
    pub consumed_units: u64,
    pub released_units: u64,
    pub reversed_units: u64,
}

max_units is the ceiling and the other five columns partition it. The only rule on dimension_id is that it is non-empty; it groups the closing rollup. The pool has no per-dimension ceiling: totalUnits bounds the sum of every allocation across all dimensions. The pool’s single currency must be non-empty and is not cross-checked against any dimension_id, so a pool declaring USD with allocations in three unrelated dimensions verifies.

Fan-out rules

reserve_swarm_budget_fanout refuses an empty allocation list; an empty pool id, graph id, currency, allocation id, task id, or dimension id; and any allocation asking for zero units. It accumulates total_reserved under checked_add, requires unique allocation ids and unique task ids, and refuses a total above the pool with swarm budget fanout exceeds pool total.

Each emitted allocation is Reserved, with max_units and reserved_units both equal to the requested units and the other columns at zero. Fan-out takes graph_id as a bare string and never reads the task graph, so a pool it builds can still fail verification with swarm budget allocation task is unknown.

Fan-in rules

release_swarm_budget_fanin takes a pool and the completed task ids, which must be non-empty and free of duplicates. For each matching allocation it sums the final columns, releases the remainder of the ceiling, zeroes reserved and active, and sets the state to Released. A completed task id that matches no allocation fails with swarm budget fanin task has no allocation.

crates/kernel/chio-swarm-authority/src/verifier.rs435-482rust
pub fn release_swarm_budget_fanin(
    request: SwarmBudgetFanInReleaseRequest,
) -> Result<SwarmBudgetPool, SwarmAuthorityError> {
    if request.completed_task_ids.is_empty() {
        return Err(rejected("swarm budget fanin completed tasks missing"));
    }
    require_unique_strings(&request.completed_task_ids, "swarm budget fanin task")?;
    let completed_task_ids = request
        .completed_task_ids
        .iter()
        .map(String::as_str)
        .collect::<BTreeSet<_>>();
    let mut pool = request.pool;
    let mut released_task_ids = BTreeSet::new();

    for allocation in &mut pool.allocations {
        if !completed_task_ids.contains(allocation.task_id.as_str()) {
            continue;
        }
        let already_final = allocation
            .consumed_units
            .checked_add(allocation.released_units)
            .and_then(|units| units.checked_add(allocation.reversed_units))
            .ok_or_else(|| rejected("swarm budget fanin release overflow"))?;
        let releasable = allocation
            .max_units
            .checked_sub(already_final)
            .ok_or_else(|| {
                rejected(format!(
                    "swarm budget fanin release exceeds allocation: {}",
                    allocation.allocation_id
                ))
            })?;
        allocation.reserved_units = 0;
        allocation.active_units = 0;
        allocation.released_units = allocation
            .released_units
            .checked_add(releasable)
            .ok_or_else(|| rejected("swarm budget fanin release overflow"))?;
        allocation.state = SwarmBudgetAllocationState::Released;
        released_task_ids.insert(allocation.task_id.as_str());
    }

    if released_task_ids != completed_task_ids {
        return Err(rejected("swarm budget fanin task has no allocation"));
    }
    Ok(pool)
}

Fan-in releases against the ceiling and does not check conservation on its input. On a conserving allocation the two readings coincide, because reserved plus active is exactly the gap. swarm_authority_releases_budget_on_fanin pins the difference: it feeds a 2,500-unit ceiling holding 1,000 active and 400 consumed, a pool validate_budget_pool would refuse, and fan-in returns 400 consumed and 2,100 released while the sibling allocation for an uncompleted task stays Active.

Verification order and conservation

The pool check runs after routes and joins and before the revocation epoch, terminal receipts, and continuation tokens. The last two take the allocation index the pool check returns. The arithmetic lives in crates/kernel/chio-swarm-authority/src/verifier/budget_accounting.rs, which validate_budget_pool calls on its first line; the bindings to the graph stay in verifier.rs.

crates/kernel/chio-swarm-authority/src/verifier/budget_accounting.rs50-73rust
fn validate_budget_allocation_units(
    allocation: &SwarmBudgetAllocation,
) -> Result<(), SwarmAuthorityError> {
    let units = allocation
        .reserved_units
        .checked_add(allocation.active_units)
        .and_then(|units| units.checked_add(allocation.consumed_units))
        .and_then(|units| units.checked_add(allocation.released_units))
        .and_then(|units| units.checked_add(allocation.reversed_units))
        .ok_or_else(|| rejected("swarm budget allocation unit overflow"))?;
    if units != allocation.max_units {
        return Err(rejected(format!(
            "swarm budget allocation unit rollup mismatch: {}",
            allocation.allocation_id
        )));
    }
    if allocation.state == SwarmBudgetAllocationState::Active && allocation.active_units == 0 {
        return Err(rejected(format!(
            "swarm budget allocation has no active units: {}",
            allocation.allocation_id
        )));
    }
    Ok(())
}

The Active clause, which refuses an active allocation with zero active units as swarm budget allocation has no active units, is the one place the arithmetic check reads a state. Continuation validation separately requires the referenced allocation to be Active.

Pool ceiling

The pool’s schema constant must match, its graph id must equal the task graph’s, and its poolId must equal the graph’s signed budgetPoolRef. Pool id, currency, and every allocation id, task id, and dimension id are non-empty. Every allocation names a task in the graph, allocation ids are unique, and the ceilings, summed under checked_add, are at most totalUnits. The relation is an inequality: ./swarm/proof-room-bundle/budget-pool.json allocates 2,500 units to each of three children against 10,000 and leaves 2,500 unallocated.

Verification requires unique allocation ids and accepts two allocations for one task when the ceilings still fit; fan-out is the step that refuses a repeated task.

Terminal rollup

A bundle with no terminal receipt is refused. The receipt’s budgetPoolId must match the pool, each rollup’s declared totalUnits must equal the sum of its own five columns, no dimension may repeat, and the resulting map must equal the one the verifier builds by summing the live allocations per dimension. swarm_authority_stage0_rejects_terminal_budget_rollup_mismatch adds one unit to a rollup’s active column and to its declared total, re-signs the receipt, and the bundle still fails with swarm terminal budget rollup mismatch.

validate_terminal_budget_rollups ends by checking that the rollups sum to at most the pool. While the equality and the pool ceiling hold, that comparison is already implied, so it stands as defense in depth. The receipt’s other closure rules are on Join & Terminal Receipts, including its completed set, which equals every graph task, the root included.

Dispatch binding

validate_continuation_budget requires the graph node’s budgetAllocationRef to be present and equal to the token’s budgetAllocationId, that id to resolve in the allocation index, the allocation’s task to equal the token’s child task, and the state to be Active. A released allocation refuses the hop with swarm budget allocation is not active: {allocation_id}, as swarm_authority_stage0_rejects_released_budget_allocation asserts.

A root without a continuation is outside this gate, and the valid fixture leaves its root unallocated. A root that is the child of a join-origin continuation satisfies the same allocation binding. swarm_ref_from_request (chio-runtime-core/src/admission_hook/swarm_ref.rs), which loads a bundle only for a request carrying chioSwarm, accepts budgetLease, budgetLeaseId, and budgetLeaseSha256 as alternatives to the budgetPool spellings.

Pool digests

The runtime pins the stored pool with canonical_sha256 over the wire object, and verify_ref_matches compares the request’s budgetPoolSha256 against it in verify_swarm_reference_hashes (chio-runtime-core/src/admission_hook/swarm_authority.rs) before verification runs. A Cognition Market pool_sha256 is a second digest over the same pool: swarm_budget_pool_sha256 in chio-swarm-authority/src/finding_pool.rs hashes the RFC 8785 canonical bytes of a chio.swarm.budget-pool-digest-projection.v1 preimage in which totalUnits and every unit column is the shortest unsigned base-10 string.

spec/PROTOCOL.md states that this preimage is not the wire serialization of chio.swarm.budget-pool.v1, and an implementation must not hash schema-valid integer-valued pool JSON directly when producing or checking it. Only the canonical wire digest is the admission comparison.

Negative descriptor

fixtures/proof-room/swarm-authority/negatives/budget-allocations-exceed-pool.jsonjson
{
  "schema": "chio.swarm-authority.negative-fixture.v1",
  "id": "budget-allocations-exceed-pool",
  "claim_ref": "claim.swarm.budget_pool_bound",
  "base_fixture": "fixtures/proof-room/swarm-authority/valid-recursive-delegation/budget-pool.json",
  "case": "BudgetAllocationsExceedPool",
  "expected_failure_code": "proof-room.negative.swarm-budget-allocations-exceed-pool-total"
}

Proof-room negative codes are proof-room.negative. plus the slugified refusal text, so swarm budget allocations exceed pool total becomes swarm-budget-allocations-exceed-pool-total. The recursive-runtime-swarm proof-room bundle carries the case in its manifest and replays it; a negative that resolves to another code fails with proof-room.negative-case.failure-mismatch.

chio proof doctor --scenario proof-package walks the fixture catalog. For every entry of kind negative-transaction-passport it opens <family>/negatives/<name>.json, reads expected_failure_code, and hands it to the check that must see the bundle fail; a missing descriptor reports missing expected failure metadata and fails the run. The doctor reads only expected_failure_code and an optional verifier context, so the chio.swarm-authority.negative-fixture.v1 schema tag appears only in the 11 fixture files. The proof-room replay path reads chio.proof-room.negative-fixture.v1.

Claim and test evidence

claim.swarm.budget_pool_bound covers one SwarmAuthorityBundle and the caller-supplied trusted witness keys. swarm_authority_stage0_rejects_budget_allocations_exceeding_pool drops totalUnits to 100 and asserts the refusal. r_t03_recursive_swarm_conformance_rejects_generated_malformed_cases reaches it from case 1 of the six cases it samples over 48 proptest runs, which drops totalUnits to 1. expected_claim_evidence_refs_are_manifested asserts that spec/registries/proof-manifest.v1.json lists the descriptor as negative-fixture evidence for the claim.

A verified bundle always carries claim.swarm.budget_pool_bound, as it does the task-graph, revocation-epoch, and terminal-receipt claims, because those artifacts are mandatory. The route, join, continuation, and witness claims appear when their evidence is non-empty. Every budget sum uses checked_add, and fan-in uses the crate’s one checked_sub, as the crate’s ARCHITECTURE.md states.

Family invocation ceilings

A ceiling on invocations across a delegation family is AggregateInvocationBudget on a CapabilityToken, checked behind a negotiated feature gate, with an AggregateBudgetDelegationMarker { root_binding_digest, max_invocations } on every attenuation step (chio-core-types/src/capability/aggregate_invocation.rs; verify_aggregate_invocation_budget, called from chio-kernel-core/src/capability_verify.rs and chio-kernel/src/kernel/admission_coordinator.rs). The swarm bundle verifier aggregates no invocations. A swarm witness hop carries the marker on its scopeSubsetProof, and the hop’s attenuation check, validate_attenuation_proof, reads cumulative_approval and leaves aggregate_budget unread, so the marker binds on the capability path and has no effect in the bundle verifier.

Wire contract

The pool follows spec/schemas/chio-swarm/v1/budget-pool.schema.json and spec/PROTOCOL.md section 6.4.2 in the public Chio repository. Durable usage rows and holds are on Budget Store and Budgets Across Nodes; negative replay is on Proof Room.

Next: verify the whole bundle