Chio/Docs
LOGIN · JOIN

Invocation limits

Bound repeated tool use across one capability or an entire delegation family, including concurrent calls and interrupted execution.

Concept chio@5f63e6e

On this page

One finite allowance can cover every use of a task's authority, however many agents the task spreads across. A free tool can still send thousands of messages, consume a code host's rate limit, or probe the orders database, so an aggregate invocation budget counts uses of authority directly.

Chio distinguishes a capability-local allowance from a delegation-family allowance. The family allowance follows the root authority, so splitting work among agents leaves the number of permitted invocations unchanged. In the security-swarm plan drawn here, six workers share one family allowance, and with four uses the third graph is refused.

One family, three graphsChoose a scenario.
budget_share_bps of 10,000family invocation allowanceκroota1triage-417a2fixer-417a3triage-418a4fixer-418a5triage-419a6fixer-419invocation budget exhaustedmjobs · 4issue-417issue-418issue-419sharesallowanceκa1a2a3a4a5a6mjobs · 4issue-417issue-418issue-419

a1triage-417a2fixer-417a3triage-418a4fixer-418a5triage-419a6fixer-419

The shared allowance stops the third graph

With a family allowance of four, the first four sends are admitted and triage-419 and fixer-419 are refused with “invocation budget exhausted for capability”. Separate graphs get no private copy of the allowance. An exact retry of the first send returns its original response and receipt, so the mailbox holds four messages. Secure a Swarm.

The security-swarm example’s plan: six direct children of one root across three graphs, each with a 1,600 basis-point share and one send on the jobs mailbox fixed by the plan, with widths drawn to the shares. Host confinement and the Linux matrix are left out.Secure a Swarm

A family allowance follows the root grant

An allowance counts against one of two owners. Under Capability, uses count against the selected capability's own allowance, for one independently bounded grant that owns the task. Under DelegationFamily, they count against the authenticated root family's allowance, so a parent and its descendants share one finite task budget.

A planner may start with one worker, find several independent questions, and create more workers. If each received a fresh copy of the full allowance, parallelizing the task would change its total authority; the family allowance fixes a task-wide limit while the application varies how the work is distributed. The root binding is authority-bearing data that an adapter keeps when it serializes a delegated capability, and verification needs the root identity and the commitments that tie the allowance to the signed root scope.

The shared ceiling decides whether another use is allowed, and scheduling decides which work gets it, so one worker can consume capacity a sibling hoped to use, within its own constraints. Protected allocations or fairness are application requirements stated beside the security limit. Invocation limits also sit beside two other controls:

  • Invocation limits count authorized uses. A free call can exhaust one while spending nothing.
  • Monetary budgets bound exposure in the selected currency or accounting unit.
  • Rate limits bound activity over time. A burst can violate one while total uses remain.

An outside buyer's agent may need all three against the pricing service: a fixed number of quote calls, its USD 5 allowance at USD 0.01 per quote, and a burst limit that protects the service. Their recovery questions differ. One capture can include several accounting participants, each with its own committed identity, and restoring one store or refunding one counter without reconciling the combined operation leaves the participants disagreeing. Dispatch commitments explains why the native path captures its required participants together.

A reservation holds capacity, and a capture keeps it

Suppose a parent delegates analysis to workers A and B with a shared allowance of three invocations, a number small enough to make concurrency visible. Both workers refer to the same authenticated family owner. The trace is illustrative accounting:

EventCaptured usesLive reservationsAvailable for a new reservation
Task starts003
A reserves its first operation012
B reserves its first operation021
A captures and dispatches111
B is refused before capture102
A's reply is lost102
Recovery examines A's original operation102

Admission reserves the allowance before the effect boundary and carries the original reservation into capture, so no other worker can spend that capacity while the reservation is live. A refusal before capture returns only its own reservation. A captured use whose outcome is unknown stays charged because the effect may have started: the lost reply leaves capacity where it was, and recovery retains the captured use until the original operation is reconciled.

Retrying the same effect as a second operation risks duplicate execution and spends more allowance; the original identity and its recovery contract recover a retained result. At the last available use, the shared authority lets at most one competing reservation acquire the unit. Reading a count and then decrementing it in separate uncoordinated steps would let both through, so the decision and the mutation run under the durable authority's concurrency contract instead of separate in-memory counters.

Read more in Unknown outcomes, which covers how a captured operation is reconciled.

Two workers race for the last use, and exactly one wins

The Authority application includes two independently scoped workers and the registered repository reader. From the prepared Execution example directory and fresh state, read once as each worker. Then race prepares a distinct last-use operation for each worker and releases two threads through one barrier into the same serving runtime, and the winner's operation is repeated from a new process:

Terminalbash
authority init --repository repository --state .limits --json > initial.json
authority read src/discount/rules.py --as reviewer --operation first --state .limits
authority read src/refund/rules.py --as tester --operation first --state .limits
authority inspect --state .limits --json > before.json
jq '{family, grant_usage}' before.json

authority race --state .limits --json > race.json
jq '[.[] | {process, verdict: .decision.verdict}]' race.json
authority inspect --state .limits --json > after.json
jq '{family, grant_usage}' after.json
wc -l .limits/resource-reads.jsonl

winner=$(jq -r '.[] | select(.decision.verdict == "allow") | .process' race.json)
path=$(jq -r '.[] | select(.decision.verdict == "allow") | .output.path' race.json)
authority read "$path" --as "$winner" --operation last-use --state .limits --json > replay.json
jq -S '.[] | select(.decision.verdict == "allow") | .receipt' race.json > original.receipt.json
jq -S . "$(jq -r .receipt replay.json)" > replay.receipt.json
cmp original.receipt.json replay.receipt.json
authority inspect --state .limits --json > reopened.json
jq '.family | {captured, reserved, limit}' reopened.json
wc -l .limits/resource-reads.jsonl
Recorded race and family usage
{
  "callers": [
    {
      "process": "reviewer",
      "verdict": "deny"
    },
    {
      "process": "tester",
      "verdict": "allow"
    }
  ],
  "family": {
    "limit": 3,
    "captured": 3,
    "reserved": 0
  }
}

You should see one caller receive the file and the other refused; either worker can win. Before the race the family had captured two of three uses and each child one of two, with no live reservation. After it the family holds three captured uses and zero reserved, and the connector journal holds three reads: the first two plus the winner's.

The winner's replay returns the same guarded result and signed receipt, so cmp finds no difference, and the reopened family still reports three captured uses, zero reservations, and three reads. A new command process creates no capacity. The race command retains its first observations and refuses a rerun, and the loser's refusal is no completed read: presenting it again may produce another refusal receipt.

The loser still has an unused local allowance, and raising that child's limit would change nothing, because its signed family ceiling is spent; provision no replacement family to clear an old operation's accounting. The run exercises two threads in one serving host.

The inspector and the race

The family owner comes from verified signed authority, never from the process ID or a counter the caller supplies. The inspector resolves it through the budget-store API:

security-execution/src/authority.rsrust
let verified =
    verify_aggregate_invocation_budget(&parent, &[issuer.public_key()], None)?
        .context("family root")?;
let family_key = BudgetQuotaKey {
    profile: BudgetQuotaProfile::AggregateFamilyInvocation,
    owner_id: verified.owner_id.clone(),
    grant_index: None,
};
let budget = authority.budget_store();
let family_usage = budget.get_invocation_quota_usage(&family_key)?;
let family = json!({"owner_id":verified.owner_id,"limit":verified.max_invocations,
    "captured":family_usage.as_ref().map_or(0,|u|u.captured_invocations),
    "reserved":family_usage.as_ref().map_or(0,|u|u.reserved_invocations)});

The complete race command keeps the host ownership lock while the two threads contend inside its durable authority:

security-execution/src/authority.rsrust
let barrier = std::sync::Barrier::new(2);
let handle = tokio::runtime::Handle::current();
let results = std::thread::scope(|threads| {
    let workers = calls
        .into_iter()
        .map(|(id, original)| {
            let (barrier, runtime, handle) = (&barrier, &runtime, &handle);
            threads.spawn(move || -> Result<_> {
                barrier.wait();
                let response = handle
                    .block_on(runtime.invoke_known_only(id, "last-use", &original))?;
                let output = match response.output {
                    Some(chio_kernel::ToolCallOutput::Value(value)) => Some(value),
                    None => None,
                    _ => anyhow::bail!("unexpected streaming result"),
                };
                Ok(json!({"process":id, "decision":response.receipt.decision,
                "output":output, "receipt":response.receipt}))
            })
        })
        .collect::<Vec<_>>();
    workers
        .into_iter()
        .map(|worker| {
            worker
                .join()
                .map_err(|_| anyhow::anyhow!("caller thread panicked"))?
        })
        .collect::<Result<Vec<_>>>()
})?;

A deployment's acceptance tests keep the allowance small, add a pre-capture refusal, a restart that preserves captured uses, a crash after capture that recovery neither refunds nor resends, and two competing recovery workers, then compare the authority's retained state with an independently recorded effect count; successful responses alone cannot reveal an extra effect whose reply was lost.

A family of four refuses the fifth and sixth sends before dispatch

The swarm example in the figure runs three two-worker graphs under one family. Each of the six direct children holds a 1,600-basis-point share and permission to send its job to the jobs mailbox, but the family allowance of four cannot cover six sends. The runner sends the first worker's exact operation twice, then continues through the remaining children, and repeats the plan in a reference host with six uses:

Terminalbash
python3 run.py
python3 inspect.py
Output
budget: triage-417=allow, fixer-417=allow, triage-418=allow, fixer-418=allow, triage-419=deny, fixer-419=deny
budget: exact retry retained original; mailbox contains 4 messages across the family
reference: all 6 planned sends allowed; mailbox contains 6 messages

You should see the graphs for issues 417 and 418 consume the allowance and both issue 419 workers refused with invocation budget exhausted for capability. The four-message count comes from the stopped SQLite mailbox, independent of the allow receipts, and the repeated triage-417 operation returns its first result without a fifth message or an extra family use. These calls run in sequence, so they show shared accounting and exact retry; the race above covers concurrency.

The reference host runs the same six-call plan independently with six family uses, and all six sends complete. That changes the initial allowance of a separately authorized test family and replenishes nothing in a live task; --reset applies only to the disposable fixture. Giving each child four independent uses would permit up to twenty-four sends and express a different policy. Sibling budget-share admission bounds how authority is allocated, and the retained pool bounds combined use.

How the runner sets and checks the allowance

The swarm runner initializes the signed shared allowance with:

run.pypython
run_chio("init", "--config", str(ROOT / "host-config.json"),
         "--state", str(self.state), "--aggregate-invocations", "4",
         "--swarm-plan", str(ROOT / "tasks.json"))

The budget scenario checks that the retry returns the original result and that four distinct operations are accepted across the family:

run.pypython
def scenario_budget(sock_root):
    family_budget = 4
    swarm = Swarm("budget", sock_root, family_budget=family_budget)
    swarm.host.start()
    try:
        results = []
        exact_retry = None
        for index, process in enumerate(swarm.calls):
            result = swarm.run_planned(process)
            results.append((process, result))
            if index == 0:
                exact_retry = swarm.run_planned(process)
    finally:
        swarm.host.stop()
    allowed = [process for process, result in results if sent_sequence(result)]
    exhausted = [process for process, result in results
                 if refused(result, "invocation budget exhausted for capability")]
    if (len(allowed) != family_budget or exhausted != ["triage-419", "fixer-419"]
            or exact_retry != results[0][1]):
        raise ScenarioFailure(f"the family budget did not stop the third graph: {results}")
    retain_results(swarm, dict(results))
    (swarm.dir / "exact-retry.json").write_text(json.dumps(exact_retry, indent=2) + "\n")
    print("DENY budget: shared family budget exhausted across graphs")

Exhaustion is a state; a broken binding is a fault

The kernel rejects a missing family binding, a root commitment that disagrees with the chain, a child that expands its parent's limits, and a continuation that tries to acquire a fresh hold for an already captured operation. Repeated admission after a timeout keeps the original operation identity.

Diagnose the two failures separately. Exhausted capacity is an operational state: check whether the task consumed its intended limit or live reservations still hold capacity, and request a newly authorized task if the work must continue. A binding mismatch is a contract failure that needs investigation: compare the signed chain, the root capability and scope commitments, the subject, and the expiry, because a larger maximum repairs nothing.

After a restart, inspect the current serving owner and the original reservation or capture. A second process that cannot resume the live operation never takes over its ownership or compensates its hold, and legitimate pending work stays intact when the kernel rejects an invalid continuation.

Signed allowance and verified accounting types
crates/core/chio-core-types/src/capability/aggregate_invocation.rs40–45rust
pub struct AggregateInvocationBudget {
    pub scope: AggregateInvocationScope,
    pub max_invocations: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub root_binding: Option<AggregateBudgetRootBinding>,
}
crates/core/chio-core-types/src/capability/aggregate_invocation.rs49–58rust
pub struct AggregateBudgetRootBindingBody {
    pub schema: String,
    pub root_capability_id: String,
    pub root_capability_hash: String,
    pub root_issuer: PublicKey,
    pub root_subject: PublicKey,
    pub max_invocations: u32,
    pub root_expires_at: u64,
    pub root_scope_hash: ScopeHash,
}

The root capability ID and hash identify the original grant, and the issuer and subject fix the authority relationship. The scope hash keeps the same numeric allowance from being grafted onto a different permission set, expiry bounds the family's lifetime, and the maximum invocation count is committed with those identities.

After aggregate authority is verified, accounting consumes a normalized budget identity, which matters when several delegated capabilities share one family:

crates/core/chio-core-types/src/capability/aggregate_invocation.rs93–98rust
pub struct VerifiedAggregateInvocationBudget {
    pub scope: AggregateInvocationScope,
    pub owner_id: String,
    pub max_invocations: u32,
    pub root_binding_digest: Option<String>,
}

owner_id names the accounting owner, scope distinguishes an individual capability from a delegation family, and root_binding_digest retains the family relationship established during verification. An adapter keeps the verified owner; replacing it with a local request identifier would create a fresh counter for every attempt. Both children in the worked run share one root binding although their grant and request IDs differ.

Next: follow an interrupted call