Invocation limits
Bound repeated tool use across one capability or an entire delegation family, including concurrent calls and interrupted execution.
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.
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.
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:
| Event | Captured uses | Live reservations | Available for a new reservation |
|---|---|---|---|
| Task starts | 0 | 0 | 3 |
| A reserves its first operation | 0 | 1 | 2 |
| B reserves its first operation | 0 | 2 | 1 |
| A captures and dispatches | 1 | 1 | 1 |
| B is refused before capture | 1 | 0 | 2 |
| A's reply is lost | 1 | 0 | 2 |
| Recovery examines A's original operation | 1 | 0 | 2 |
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:
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{
"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:
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:
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:
python3 run.py
python3 inspect.pybudget: 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_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:
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
pub struct AggregateInvocationBudget {
pub scope: AggregateInvocationScope,
pub max_invocations: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root_binding: Option<AggregateBudgetRootBinding>,
}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:
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.