Rate Limit Guards
Token buckets the kernel checks before dispatch cap how often an authorized caller acts, per grant or per agent.
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.
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.
velocity.rs:443fn burst_capacity(per_window: u64, burst_factor: f64) -> u64 {
((per_window as f64 * burst_factor).round() as u64).max(1)
}
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:
- 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.
- 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.
- Peek, then commit.
can_consumeruns on both buckets beforeconsumeruns 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.
| Property | VelocityGuard | AgentVelocityGuard |
|---|---|---|
| Guard name | velocity | agent-velocity |
| Bucket key | (capability_id, grant_index) | agent_id and (agent_id, capability_id) |
| Grain | Per grant within a capability | Per agent and per session, across capabilities |
| Spend limit | Yes, from max_cost_per_invocation | None |
| Default window | 60 s | 60 s |
| Default burst factor | 1.0 | 1.0 |
At bucket_cap | Prune refilled buckets, refuse new keys | Prune 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.
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
Search for burst_factor, bucket_cap, or grant_index.
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.
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,
}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,
}struct VelocityState {
invocation_buckets: HashMap<(String, usize), TokenBucket>,
spend_buckets: HashMap<(String, usize), TokenBucket>,
}VelocityGuard defaults
| Knob | Type | Default | Purpose |
|---|---|---|---|
max_ | Option<u32> | None | Input to capacity and refill, subject to integer quantization and the minimum refill rate. None disables this limit. |
max_ | Option<u64> | None | Monetary units spendable per window. Requires max_ 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.
struct TokenBucket {
capacity_mt: u64,
tokens_mt: u64,
/// Milli-tokens added per millisecond of elapsed time.
refill_rate_mpm: u64,
last_refill: Instant,
}fn burst_capacity(per_window: u64, burst_factor: f64) -> u64 {
((per_window as f64 * burst_factor).round() as u64).max(1)
}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.
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
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())). Theevaluatesignature isResult<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_refilledruns 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.
#[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
| Knob | Type | Default | Purpose |
|---|---|---|---|
max_ | Option<u32> | None | Per-agent ceiling, keyed by agent_id. |
max_ | 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
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.