Chio/Docs
LOGIN · JOIN

PlatformFan-Out & Fan-In

Swarm

Sub-Agent Budgets

One signed pool split across child tasks at fan-out, reclaimed at fan-in, and reconciled by a terminal receipt before the graph closes.

Three pages carry budgets, and they are not variants

Node · Budget Store owns one process mutating its own SQLite row under a durable transaction. Cluster · Budgets Across Nodes owns getting that row's decisions onto peers holding the same authority keypair. This page owns the case where there is no row.

Source

This page reflects spec/schemas/chio-swarm/v1/budget-pool.schema.json and spec/PROTOCOL.md section 6.4.2 in the chio repository. The Swarm Protocol reference carries the full set of artifacts and the verification order.


A budget that cannot be a row

The spender is a task the operator does not run, so the budget cannot be a row in a database the operator holds. It travels as an artifact inside the delegation bundle and is checked arithmetically by a pure function.

The pool itself is not signed

SwarmBudgetPool has six fields and none of them is a signature or an issuer. What binds it is indirect, and worth carrying through the rest of this page: the signed task graph pins the poolId and nothing else about the pool, the signed terminal receipt pins the aggregate columns per dimension, and the runtime pins the pool's canonical SHA-256 against the copy in its own verifier-owned store. Edit an allocation and you must also forge the terminal receipt to keep the rollup equal. There is no signature on the allocations to break.

Status: the verifier is shipped and is on the runtime admission path for swarm-bound requests. chio-runtime-core loads the stored bundle, requires the dispatch reference to name the exact pool bytes by canonical SHA-256, and then calls verify_swarm_authority_bundle before the child action runs. That path is opt-in per request. swarm_ref_from_request reads governedIntent.context.chioSwarm and returns nothing when the block is absent, so a request that does not carry the full seven-artifact reference is admitted without any budget check at all. Present the block and it must be complete: task graph, continuation token, route plan, delegation witness, join receipt, revocation epoch, and budget pool, each by id and by SHA-256.

Two vocabularies meet here and mixing them produces wrong code. The crate API says reserve and release: reserve_swarm_budget_fanout and release_swarm_budget_fanin are the two graph-shaped moments, split and reclaim. The house lifecycle is authorize, capture, release, reconcile, which is what the node and cluster budget stores implement against a mutable usage row. Only the word release is a member of both sets, and it names different work in each: here it is the fan-in that converts an allocation's remaining ceiling into released units, not a store transition with a journal event behind it.


Model

ObjectSchema idRole
SwarmBudgetPoolchio.swarm.budget-pool.v1One pool per graph. Carries poolId, graphId, a single currency string, totalUnits, and the allocations.
SwarmBudgetAllocationNested in the poolOne task's share, keyed by allocationId and bound to a taskId and a dimensionId.
SwarmGraphNode.budgetAllocationRefchio.swarm.task-graph.v1The signed graph's pointer from a task to its allocation. Option<String> in the type. Every node with a parentTaskId must carry a continuation token, and the token check then demands this ref, so it is mandatory for every child and absent on the root.
SwarmContinuationToken.budgetAllocationIdchio.swarm.continuation-token.v1The dispatch-time binding. Must agree with the graph node, resolve in the pool, name the token's own child task, and read Active.
SwarmTerminalBudgetRollupNested in chio.swarm.terminal-graph-receipt.v1One row per dimension in the closing receipt, plus a declared totalUnits the verifier recomputes.

An allocation carries six unit columns and a state. Both the Rust type and the JSON Schema are closed: deny_unknown_fields on the struct, additionalProperties: false and all ten fields required in spec/schemas/chio-swarm/v1/budget-pool.schema.json. 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,
}
crates/kernel/chio-swarm-authority/src/types.rs296-302rust
pub enum SwarmBudgetAllocationState {
    Reserved,
    Active,
    Consumed,
    Released,
    Reversed,
}

max_units is the allocation's ceiling. The other five columns are a partition of it, and the verifier enforces that reading exactly. dimension_id is the key the closing rollup groups by, and the verifier gives it no meaning past that: non-emptiness is the only rule it carries. There is no per-dimension ceiling either: totalUnits is compared against the sum of every allocation across all dimensions at once. The pool's single currency field is validated only for being non-empty, and nothing anywhere cross-checks it against dimension_id. A pool declaring USD with allocations in three unrelated dimensions verifies.


Fan-out, then fan-in

rendering
Two transitions ship in chio-swarm-authority: fan-out mints Reserved, fan-in stamps Released. Active, Consumed, and Reversed are validated wherever they appear and are produced by no code in the workspace.
sourcecrates/kernel/chio-swarm-authority/src/verifier.rs:374-482at fe56570

Fan-out splits the pool. reserve_swarm_budget_fanout takes a request and returns a whole pool. It 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 then accumulates the requested units into total_reserved under checked_add. What stands between the loop and the returned pool is uniqueness and the ceiling:

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"));
}

Both identifier spaces are unique here: no two allocations may share an id, and no two may name the same task. Each emitted allocation is Reserved with max_units set equal to the units requested and every other column zero, so an allocation's ceiling is fixed at fan-out and no later transition can raise it. Fan-out never sees the task graph. It takes graph_id as a bare string and cannot tell whether a task id exists, so a pool minted here can still fail verification on swarm budget allocation task is unknown.

Fan-in reclaims what is left. release_swarm_budget_fanin takes a pool plus the completed task ids, requires that list to be non-empty and free of duplicates, and for every matching allocation computes what has already reached a final column, then hands the remainder back:

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)
}

The subtraction is the interesting part, and it is easy to read wrong. Fan-in does not sweep the reserved and active columns into released. It releases against the ceiling: whatever max_units has not already reached a final column. On an allocation that satisfies the conservation law those two readings coincide, because reserved plus active is exactly the gap. Fan-in never checks that law on its input. Every completed task id must have matched at least one allocation, or the call fails with swarm budget fanin task has no allocation.

Fan-in releases against the ceiling, and repairs a pool that does not conserve

swarm_authority_releases_budget_on_fanin pins this. It feeds a 2,500-unit ceiling holding 1,000 active and 400 consumed, a pool whose columns sum to 1,400 rather than 2,500 and which validate_budget_pool would reject. Fan-in accepts it and returns 400 consumed and 2,100 released, while the sibling allocation for an uncompleted task stays Active. Only 1,000 units were ever provisional; the other 1,100 are manufactured by the subtraction to make the allocation conserve. Separately, an allocation whose final columns already sum to its ceiling reclaims zero units and is still stamped Released, so a bulk close through fan-in erases the distinction between a fully consumed allocation and an abandoned one. The unit columns keep the truth; the state does not.

What the verifier checks

verify_swarm_authority_bundle validates the pool 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 revocation epoch does not. Four checks matter, in the order the function runs them. The arithmetic of the first two lives in its own module, 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.

One. The conservation law. Each allocation's five columns must sum to its ceiling exactly, under checked_add at every step. Nothing is clamped:

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(())
}

That second clause is the only place a state and a unit column are cross-checked. The other four states are labels the arithmetic does not constrain.

Two. The 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 must be non-empty. Every allocation must name a task that exists in the graph, no allocation id may repeat, and the sum of every ceiling, accumulated with checked_add, must not exceed totalUnits. The relation is an inequality, not an equality: the fixture at fixtures/proof-room/swarm-authority/valid-recursive-delegation/budget-pool.json allocates 2,500 units to each of three children against a 10,000-unit pool and leaves 2,500 unallocated.

Three. The terminal rollup. A bundle with no terminal receipt is rejected outright. The receipt's budgetPoolId must match the pool, each of its rollups must declare a totalUnits equal to the sum of its own five columns, no dimension may repeat, and the resulting map must compare equal to one the verifier builds itself by summing the live allocations per dimension. swarm_authority_stage0_rejects_terminal_budget_rollup_mismatch pins the map comparison specifically: it adds one unit to a rollup's active column and to that rollup's declared totalUnits, so the self-consistency check still passes, re-signs the receipt, and the bundle still fails with swarm terminal budget rollup mismatch. One unit of drift from the live allocations is enough. The rest of that receipt, including the three set equalities it must also satisfy before a graph counts as closed, is Join & Terminal Receipts.

The last comparison is unreachable while the first two hold

validate_terminal_budget_rollups ends by summing the rollups and rejecting if the total exceeds the pool. Given the equality it just proved, that total is the sum of every allocation's five columns, which the conservation law makes the sum of every ceiling, which the pool check already bounded. It is defense in depth against a future refactor that decouples the two, not an independent constraint. Read it that way before you rely on it.

Four. The dispatch binding. For every continuation token, validate_continuation_budget requires the graph node's budgetAllocationRef to be present and to equal 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 denies the hop with swarm budget allocation is not active: {allocation_id}, which is what swarm_authority_stage0_rejects_released_budget_allocation asserts. This is the only check that treats a budget as a gate rather than a bound, and it reaches every child task: any node carrying a parentTaskId must also carry a continuation token, so no parented task escapes it. The root task, which has neither, is never gated this way. The reference itself is spelled two ways at every level: swarm_ref_from_request accepts budgetLease, budgetLeaseId, and budgetLeaseSha256 as alternatives to the budgetPool spellings.

Two digests over one pool, and they are not interchangeable

The runtime pins the stored pool with canonical_sha256 over the wire object, which is what verify_ref_matches compares the request's budgetPoolSha256 against. A cognition-market pool_sha256 is a different 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 the rule and its consequence: that 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. Two digests, one pool, and only one of them is the admission comparison.

The oversubscription negative

The ceiling has a counterexample. The negative descriptor names the claim it breaks, the valid pool it mutates, and the failure code the proof room must produce:

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"
}

That failure code is not a hand-written label. Proof-room negative codes are proof-room.negative. plus the slugified rejection text, so swarm budget allocations exceed pool total becomes swarm-budget-allocations-exceed-pool-total. The executable binding is the recursive-runtime-swarm proof-room bundle, which carries the case in its manifest and replays it: a bundle whose negative resolves to any other code fails with proof-room.negative-case.failure-mismatch.

The descriptor is not a note beside that mechanism. It is a second one. chio proof doctor --scenario proof-package walks the fixture catalog, and for every entry of kind negative-transaction-passport it opens <family>/negatives/<name>.json, reads expected_failure_code out of it, and hands that string to the check that must see the bundle fail. A descriptor missing from that directory is not skipped: the check reports missing expected failure metadata and the run fails. What no crate parses is the descriptor's schema field: chio.swarm-authority.negative-fixture.v1 has no occurrence outside the 11 fixture files themselves, because the struct the doctor deserializes into reads only expected_failure_code and an optional verifier context. The replay path in the proof room reads a different family, chio.proof-room.negative-fixture.v1.

The sibling directory the descriptor names holds the materialized bundle. Two 8,000-unit ceilings against a 10,000-unit pool, and the arithmetic is the whole of it:

swarm-authority · budget-exceeds-pooltranscript
$ source scripts/proof-room-quickstart-env.sh
$ chio proof verify \
  fixtures/proof-room/swarm-authority/budget-allocations-exceed-pool/transaction-passport.json
error [urn:chio:error:cli:other]: proof verify: swarm budget allocations exceed pool total
context: {"domain":"cli","severity":"error","stability":"deprecated","string_code":"CHIO-CLI-OTHER"}
suggested fix: Preserve the original message and migrate the call site to a specific registry code when touched.
exit 1

The message names no allocation, because no single allocation is at fault. The check is a sum against a ceiling, so the smallest honest thing it can report is that the sum lost.

FieldValue
StatusShipped, and pinned from three directions: a unit test, a conformance property, and a replayed proof-room negative.
ClaimA bundle whose allocation ceilings sum above the pool total is rejected, and the rejection is attributed to claim.swarm.budget_pool_bound.
SubjectOne SwarmAuthorityBundle and the caller-supplied trusted witness keys. Nothing outside the bundle is consulted.
Evidenceswarm_authority_stage0_rejects_budget_allocations_exceeding_pool drops totalUnits to 100 and asserts swarm budget allocations exceed pool total; the materialized fixture directory carries two 8,000-unit allocations against a 10,000-unit pool; r_t03_recursive_swarm_conformance_rejects_generated_malformed_cases reaches the same rejection from case 1 of the six 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 path as negative-fixture evidence for the claim.
LimitThe check compares declared units to declared units. It bounds what a graph is authorized to spend, and observes no settlement of any kind.

Guarantees and limits

StatusClaimEvidence
ShippedEvery budget accumulation rejects on overflow rather than wrapping, at fan-out, at fan-in, in the conservation check, and in the terminal rollup.checked_add in reserve_swarm_budget_fanout and the terminal rollup, and both checked_add and the crate's one checked_sub in release_swarm_budget_fanin; the conservation and pool-ceiling arithmetic in verifier/budget_accounting.rs; stated as an invariant in the crate's ARCHITECTURE.md
Shippedclaim.swarm.budget_pool_bound is emitted unconditionally on a verified bundle, unlike the route, join, continuation, and witness claims, which are gated on their evidence being non-empty. The task-graph, revocation-epoch, and terminal-receipt claims are unconditional for the same reason: the artifacts behind them are mandatory.The claim assembly in verify_swarm_authority_bundle
ShippedA dispatch cannot silently point at a different pool: the runtime compares the reference's canonical SHA-256 against the stored pool before verification runs.verify_swarm_reference_hashes in chio-runtime-core/src/admission_hook/swarm_authority.rs
Not shippedIssuance. The fan-out and fan-in helpers have no production caller in the workspace, and the crate says so rather than implying otherwise. A caller outside this workspace would not show up in that check.crates/kernel/chio-swarm-authority/ARCHITECTURE.md, invariants section, corroborated by a workspace grep finding callers only under tests/
Not implementedThree of the five states. No code in crates/ transitions an allocation to Active, Consumed, or Reversed; they appear only in fixtures and tests. The verifier validates them wherever a producer supplies them.Every reference to SwarmBudgetAllocationState in the workspace
Weaker than it looksVerification does not require unique task ids, only unique allocation ids. A pool carrying two allocations for one task verifies, provided the ceilings still fit. Only fan-out refuses to build one.validate_budget_pool versus reserve_swarm_budget_fanout
Weaker than it looksAllocations need not cover the graph. The root task has no parent, therefore no continuation token, therefore no allocation and no gate; the valid fixture is exactly that shape. The terminal receipt's completed set must still equal every task in the graph, root included.valid-recursive-delegation/task-graph.json; validate_terminal_graph_receipt_refs
Weaker than it looksThe whole path is opt-in per request. Absent a chioSwarm block on the governed intent, no bundle is loaded, no pool is verified, and admission proceeds. This is enforcement for requests that declare themselves swarm-bound, not an ambient ceiling on every agent.swarm_ref_from_request in chio-runtime-core/src/admission_hook/swarm_ref.rs
Enforced one layer downA ceiling on invocations across a delegation family, rather than units inside one graph. That is AggregateInvocationBudget on a CapabilityToken, checked behind a negotiated feature gate, with an AggregateBudgetDelegationMarker { root_binding_digest, max_invocations } riding every attenuation step. Nothing at the swarm rung aggregates invocations: a swarm witness hop carries that marker on its scopeSubsetProof and the attenuation check the hop runs never reads the field, so the marker binds on the capability path and is inert on this one.chio-core-types/src/capability/aggregate_invocation.rs:39-73; verify_aggregate_invocation_budget, called from chio-kernel-core/src/capability_verify.rs and chio-kernel/src/kernel/admission_coordinator.rs; validate_attenuation_proof, which reads cumulative_approval and not aggregate_budget
UnsupportedCross-bundle budget reuse. The runtime consumes single-use continuation token ids and nothing else. No store dedupes a poolId or an allocationId, so two separately stored bundles sharing one pool each verify on their own terms and spend it twice.consume_swarm_continuation and chio_swarm_continuation_replay; Swarm Overview

Next Steps

  • Swarm Authority · the verifier the pool is checked inside, and where an unpinned key stops everything
  • Node · Budget Store · the same problem for one process: a mutable usage row, idempotency keys, and a real hold state machine
  • Cluster · Budgets Across Nodes · what distribution under one keypair costs, including the overrun bound stated as arithmetic
  • Swarm Overview · the other five mechanisms in the bundle, and the six limits the rung carries
  • Authoritative Spend · where declared units stop and settlement begins
  • Proof Room · how a negative descriptor becomes a failure code a third party can replay