Chio/Docs
LOGIN · JOIN

Rate Limit Guards

Token buckets the kernel checks before dispatch cap how often an authorized caller acts, per grant or per agent.

Concept chio@5f63e6e

On this page

An agent can stay inside its grant and still call the same tool hundreds of times a minute. The velocity guards cap that rate: each call they govern draws one token from a bucket, and a call that finds less than a whole token is refused before dispatch.

Two guards share the arithmetic. VelocityGuard, registered as velocity, keeps a bucket per grant and can also meter declared spend. AgentVelocityGuard, registered as agent-velocity, keeps one bucket per agent and one per agent and capability. Both can sit in front of the same call, and either can refuse it.

Each key drains its own bucket and refills with time

A bucket starts full at its capacity. Whenever it is checked, it refills from the monotonic time elapsed since the last check, then admits the call only if one whole token remains.

Token bucketChoose a burst factor.
Tokens in one bucket over twelve seconds at burst_factor 1.0: 16 of 20 calls admitted0369024681012tokenstime, sc = 6 tokensrefills 1 token per secondcallsadmitted refused before dispatchTokens in one bucket over twelve seconds at burst_factor 1.0: 16 of 20 calls admitted036904812tokenstime, sc = 6 tokensadmitted refused before dispatch

Seven of the first ten calls are admitted

With burst_factor 1.0, the bucket holds 6 tokens and refills one per second. 16 of 20 calls are admitted; the four calls that found less than a whole token are refused before dispatch.

r=max⁡ ⁣(⌊1000 n / 1000 w⌋, 1) mt/msc=max⁡ ⁣(round⁡(n b), 1) tokensadmit(t)  ⟺  ℓ(t)≥1000\begin{array}{l} r = \max\!\left(\lfloor 1000\,n \,/\, 1000\,w \rfloor,\ 1\right)\ \text{mt/ms} \\ \htmlClass{cor-relation}{c} = \max\!\left(\operatorname{round}(n\,b),\ 1\right)\ \text{tokens} \\ \mathrm{admit}(t) \iff \htmlClass{cor-relation}{\ell(t)} \ge 1000 \end{array}
In Rust · velocity.rs:443
fn burst_capacity(per_window: u64, burst_factor: f64) -> u64 {
    ((per_window as f64 * burst_factor).round() as u64).max(1)
}
One bucket under an illustrative limit of 6 calls per 6-second window, computed in your browser with the guard's integer arithmetic; n is the per-window limit, w the window in seconds, b the burst factor, and mt/ms milli-tokens per millisecond. The spend bucket, which charges a grant's declared cost instead of one token, is left out.

The arithmetic is integer. One logical token is 1,000 milli-tokens (MT_PER_TOKEN), and the refill rate is milli-tokens per millisecond, rounded down and floored at one. That floor is one token per second, so a configured rate slower than that refills faster than asked; a slower or exact window ceiling belongs to the durable budget store.

Rounding also lowers fast rates. With 100 calls per minute and burst_factor = 1.5, capacity is 150 tokens (150,000 milli-tokens) and refill is one milli-token per millisecond: 60 tokens per minute instead of the requested 100. An idle bucket admits a burst of 150, and admission then follows the achieved rate.

VelocityGuard gives every grant its own limit

VelocityGuard keys its buckets by (capability_id, grant_index). Two grants inside one capability each get a bucket; two capabilities that carry the same ID share one. When either limit is configured, the guard needs the grant the kernel matched, and a missing matched_grant_index returns Err(KernelError::Internal), which the kernel refuses on.

Setting max_spend_per_window adds a second bucket that meters money. Each call draws the matched grant's max_cost_per_invocation.units, the declared ceiling for one call. Spend limiting applies when max_spend_per_window is Some, the kernel matched a grant, and that grant carries max_cost_per_invocation; any other combination returns an error and the call is refused.

The guard resolves both buckets under one state lock, in three phases, so a refusal from one limit leaves the other untouched:

  1. Reject impossible spend. A declared cost larger than the spend bucket's burst ceiling can never fit, so the guard refuses before creating any bucket, and the refused call takes no table slot.
  2. Reserve. The guard reserves a slot for every new bucket the call needs across both maps at once. A new key with both limits on needs two, so a call refused for lack of room cannot leave an unpaired invocation bucket in the last free slot.
  3. Peek, then commit. can_consume runs on both buckets before consume runs on either, so a call one limit refuses drains nothing from the other.

AgentVelocityGuard limits an agent across its capabilities

AgentVelocityGuard runs the same bucket over different keys. max_requests_per_agent keys a bucket on agent_id alone, and max_requests_per_session keys one on (agent_id, capability_id). The guard context exposes no session ID, so the capability stands in for the session: one agent holding two capabilities counts as two sessions.

PropertyVelocityGuardAgentVelocityGuard
Guard namevelocityagent-velocity
Bucket key(capability_id, grant_index)agent_id and (agent_id, capability_id)
GrainPer grant within a capabilityPer agent and per session, across capabilities
Spend limitYes, from max_cost_per_invocationNone
Default window60 s60 s
Default burst factor1.01.0
At bucket_capPrune refilled buckets, refuse new keysPrune refilled buckets, refuse new keys

Both count in integer milli-tokens over monotonic Instant time, and both return an error the kernel refuses on when their state lock is poisoned.

A full bucket table refuses newcomers and keeps live limits

Each guard holds both of its bucket maps behind one mutex, so checking the combined count and inserting a bucket happen as one step, and no evaluation reads a stale size of the sibling map.

bucket_cap bounds that combined count. It defaults to chio_kernel::MemoryBudgetConfig::defaults().velocity_bucket_cap, so lowering the process memory budget tightens the table. When a new key would pass the cap, the guard calls prune_refilled to reclaim buckets that have refilled to capacity, which are equivalent to new ones. If only buckets holding live state remain, the new key is refused.

Evicting a live bucket would let a caller reset a depleted limit by presenting a new capability ID, so the depleted bucket stays and the newcomer is refused. The bound and the pruning both come from the guard itself.

Read more in Fail-Closed Semantics.

Two guards in series cap billing and abuse separately

An embedding host can place both guards in one pipeline. The agent-grain buckets bound abuse across capabilities, and the grant-grain buckets bound what one grant may bill. Their state is independent, so a call that clears one can still be refused by the other.

src/main.rs · embedding the kernelrust
use chio_guards::velocity::VelocityConfig;
use chio_guards::{AgentVelocityConfig, AgentVelocityGuard, VelocityGuard};

let mut pipeline = chio_guards::GuardPipeline::new();

// Agent-grain throttle (cross-capability, broad)
pipeline.add(Box::new(AgentVelocityGuard::new(AgentVelocityConfig {
    max_requests_per_agent: Some(600),
    max_requests_per_session: Some(120),
    window_secs: 60,
    burst_factor: 1.0,
})));

// Capability-grain throttle (per-grant, narrow)
pipeline.add(Box::new(VelocityGuard::new(VelocityConfig {
    max_invocations_per_window: Some(60),
    max_spend_per_window: Some(1_000),
    window_secs: 60,
    burst_factor: 1.5,
})));

// Install on the existing mutable kernel.
kernel.add_guard(Box::new(pipeline));

The spend bucket charges each grant's declared per-call ceiling and refills by the arithmetic above; it never reconciles actual charges. An enforceable aggregate spending budget lives in the durable budget store, whose hold reserves the worst case before dispatch, and a family cap bounds what sibling grants spend together. Byte volume across a session is the data-flow guard's job.

Read more in Budgets.

Velocity guard configuration and source

VelocityGuard structs

Source crates/guards/chio-guards/src/velocity.rs; guard name velocity. The guard holds its config, a bucket cap, and both bucket maps behind a single mutex.

crates/guards/chio-guards/src/velocity.rs123-132rust
pub struct VelocityConfig {
    /// Maximum invocations per window. None means unlimited.
    pub max_invocations_per_window: Option<u32>,
    /// Maximum spend (monetary units) per window. None means unlimited.
    pub max_spend_per_window: Option<u64>,
    /// Window duration in seconds.
    pub window_secs: u64,
    /// Burst factor (1.0 = no burst above steady rate).
    pub burst_factor: f64,
}
crates/guards/chio-guards/src/velocity.rs153-163rust
pub struct VelocityGuard {
    /// Both bucket maps live behind ONE mutex so the combined-cap check and the
    /// insert are atomic across both maps: no evaluate() ever samples a stale
    /// sibling-map size, so the combined `invocation + spend` bucket count stays
    /// bounded by `bucket_cap` even under concurrent evaluate() calls for
    /// distinct capability ids. A single lock also removes any cross-map lock
    /// ordering, so there is no deadlock.
    state: Mutex<VelocityState>,
    config: VelocityConfig,
    bucket_cap: usize,
}
crates/guards/chio-guards/src/velocity.rs166-169rust
struct VelocityState {
    invocation_buckets: HashMap<(String, usize), TokenBucket>,
    spend_buckets: HashMap<(String, usize), TokenBucket>,
}

VelocityGuard defaults

KnobTypeDefaultPurpose
max_invocations_per_window
Option<u32>
None
Input to capacity and refill, subject to integer quantization and the minimum refill rate. None disables this limit.
max_spend_per_window
Option<u64>
None
Monetary units spendable per window. Requires max_cost_per_invocation on the matched grant.
window_secs
u64
60
Window length, floored at 1 second internally.
burst_factor
f64
1.0
Capacity multiplier. 1.0 admits no burst above the steady rate.

Capacity and refill

Each guard carries its own private TokenBucket with the same fields and arithmetic; velocity.rs holds this one and agent_velocity.rs declares a second copy. evaluate sizes both buckets through burst_capacity and derives the refill rate from the window.

crates/guards/chio-guards/src/velocity.rs32-38rust
struct TokenBucket {
    capacity_mt: u64,
    tokens_mt: u64,
    /// Milli-tokens added per millisecond of elapsed time.
    refill_rate_mpm: u64,
    last_refill: Instant,
}
crates/guards/chio-guards/src/velocity.rs443-445rust
fn burst_capacity(per_window: u64, burst_factor: f64) -> u64 {
    ((per_window as f64 * burst_factor).round() as u64).max(1)
}
crates/guards/chio-guards/src/velocity.rsrust
let window_ms = window_secs.saturating_mul(1_000).max(1);
let refill_rate_mpm = (max_per_window.saturating_mul(MT_PER_TOKEN))
    .checked_div(window_ms)
    .unwrap_or(1)
    .max(1);

Planned spend units

The spend bucket's cost per call comes from planned_spend_units, which reads grant.max_cost_per_invocation.units from the matched grant.

crates/guards/chio-guards/src/velocity.rs447-468rust
fn planned_spend_units(ctx: &GuardContext) -> Result<u64, KernelError> {
    let grant_index = ctx.matched_grant_index.ok_or_else(|| {
        KernelError::Internal(
            "velocity guard spend limiting requires matched_grant_index".to_string(),
        )
    })?;
    let grant = ctx.scope.grants.get(grant_index).ok_or_else(|| {
        KernelError::Internal(format!(
            "velocity guard could not resolve grant index {grant_index}"
        ))
    })?;
    grant
        .max_cost_per_invocation
        .as_ref()
        .map(|amount| amount.units)
        .ok_or_else(|| {
            KernelError::Internal(
                "velocity guard spend limiting requires max_cost_per_invocation on the matched grant"
                    .to_string(),
            )
        })
}

VelocityGuard key

crates/guards/chio-guards/src/velocity.rsrust
let grant_index = if self.config.max_invocations_per_window.is_some()
    || self.config.max_spend_per_window.is_some()
{
    ctx.matched_grant_index.ok_or_else(|| {
        KernelError::Internal(
            "velocity guard rate limiting requires matched_grant_index".to_string(),
        )
    })?
} else {
    0
};
let key = (ctx.request.capability.id.clone(), grant_index);

The grant index defaults to 0 only when neither max_invocations_per_window nor max_spend_per_window is set, when the guard limits nothing. With either limit configured, a missing matched_grant_index is a fail-closed Err(KernelError::Internal).

VelocityGuard results

  • An empty bucket returns Ok(GuardDecision::deny(Vec::new())). The evaluate signature is Result<GuardDecision, KernelError>, and a rate-limited call is a routine refusal.
  • A poisoned state lock returns Err(KernelError::Internal("velocity guard state lock poisoned")). Both maps share the lock, so one message covers either map. The kernel refuses on it.
  • A missing grant index while either limit is on returns Err, and the kernel refuses on it.
  • A new key is admitted only while the combined bucket count stays under bucket_cap; past it, prune_refilled runs first and the key is refused if only live buckets remain.

AgentVelocityGuard structs

Source crates/guards/chio-guards/src/agent_velocity.rs; guard name agent-velocity. Both maps sit behind Mutex<AgentVelocityState>, and bucket_cap defaults to velocity_bucket_cap from the process memory budget.

crates/guards/chio-guards/src/agent_velocity.rsrust
#[derive(Clone, Debug)]
pub struct AgentVelocityConfig {
    pub max_requests_per_agent: Option<u32>,
    pub max_requests_per_session: Option<u32>,
    pub window_secs: u64,
    pub burst_factor: f64,
}

pub struct AgentVelocityGuard {
    state: Mutex<AgentVelocityState>,
    config: AgentVelocityConfig,
    bucket_cap: usize,
}

struct AgentVelocityState {
    agent_buckets: HashMap<String, TokenBucket>,
    session_buckets: HashMap<(String, String), TokenBucket>,
}

AgentVelocityGuard defaults

KnobTypeDefaultPurpose
max_requests_per_agent
Option<u32>
None
Per-agent ceiling, keyed by agent_id.
max_requests_per_session
Option<u32>
None
Per-session ceiling, keyed by (agent_id, capability_id).
window_secs
u64
60
Window length, floored at 1.
burst_factor
f64
1.0
Capacity multiplier.

AgentVelocityGuard keys

crates/guards/chio-guards/src/agent_velocity.rsrust
let agent_id = ctx.agent_id.clone();
let cap_id = ctx.request.capability.id.clone();
// ...
let session_key = (agent_id.clone(), cap_id);

The per-agent bucket is keyed on agent_id alone (agent_velocity.rs:292) and the per-session bucket on the pair.

Next: bound what a session reads and runs

Rate Limit Guards · Chio Docs