Chapter 123 · Guardrails and security
Rate limiting and abuse
Spend, not requests, is the thing to limit when one question can cost two hundred times another. Four limits that compose, the anomaly that matters at 200 users, and why the cost ceiling is a security control.
8 min read
Rate limiting an agent is different from rate limiting an API for one reason: the cost of a request varies by two orders of magnitude, and the caller chooses which kind they send.
// Atlas, measured over a quarter.
const COST = {
p50: { tokens: 4_400, tools: 0, cost: 0.0084, seconds: 1.9 },
p99: { tokens: 96_000, tools: 9, cost: 0.1840, seconds: 31 },
max: { tokens: 410_000, tools: 24, cost: 0.9100, seconds: 118 },
}A hundred and eight times the median at the top. A limit of "60 requests per minute" permits anything from fifty cents to fifty dollars a minute, which means it is not a limit on anything anyone cares about.
Limit spend, not requests
// The budget is denominated in money and time, and it composes across
// four scopes. Chapter 81 set per-run limits; these are the scopes above
// a single run.
interface Budget {
perRun: { tokens: 120_000; toolCalls: 25; seconds: 180; cost: 0.50 }
perUser: { costPerHour: 3.00; costPerDay: 15.00 }
perTeam: { costPerDay: 120.00 }
global: { costPerHour: 40.00; concurrentRuns: 30 }
}// Checked before a run starts and re-checked before each expensive step,
// because a run that begins inside budget can leave it.
export async function reserve(ctx: CallerContext, estimate: number): Promise<Reservation> {
const spent = await spendIn(ctx.userId, '1 hour')
if (spent + estimate > BUDGET.perUser.costPerHour) {
await audit.record('budget.exceeded', { scope: 'user', ctx, spent, estimate })
throw new BudgetExceeded('user', resetAt(ctx.userId))
}
return hold(ctx, estimate)
}The global ceiling is the one that makes this a security control rather than a cost control. Without it, a loop bug, a retry storm or a deliberate abuse turns into an unbounded bill on someone else's infrastructure — and chapter 90 catalogued enough loop bugs to make that concrete.
A spend ceiling is the only control that bounds a failure nobody anticipated. It does not need to know what went wrong.
Degrading rather than refusing
// A hard stop at the limit is correct for an attack and wrong for the
// person who happens to be having a busy Tuesday. Three tiers.
function tierFor(spent: number, limit: number): Tier {
if (spent < 0.7 * limit) return 'full'
if (spent < limit) return 'reduced' // fewer steps, no subagents, cheaper model
return 'blocked'
}const REDUCED = {
maxSteps: 4, // chapter 80's termination, tightened
subagents: false, // chapter 84's fan-out is the expensive path
model: 'the cheaper tier',
banner: 'Answers are abbreviated — you are near your hourly limit.',
}Atlas's reduced tier serves 0.4% of requests and has produced no complaints, which is the expected result: the people who reach it are running genuine bulk work and the banner tells them why. Going straight from full to blocked produced four complaints in the first week it existed.
What abuse looks like at 200 users
Not a botnet. The realistic patterns are mundane and all three were observed.
| Pattern | Signature | Response |
|---|---|---|
| Bulk extraction | High volume, low diversity, sequential identifiers | Alert a human |
| Enumeration | Many near-identical questions varying one token | Alert, and tighten existence disclosure |
| A script someone wrote | Perfectly regular timing, no session structure | Talk to them; usually a real need |
| Runaway retry | One question, repeated, same session | Idempotency and a circuit breaker |
-- Sequential-identifier detection. Cheap, and it caught the only real
-- bulk-extraction attempt Atlas has seen -- which turned out to be
-- someone building a spreadsheet, with no ill intent and no approval.
select user_id,
count(*) as questions,
count(distinct regexp_replace(question, '\d+', 'N', 'g')) as shapes,
round(count(*)::numeric / nullif(count(distinct regexp_replace(question, '\d+', 'N', 'g')), 0), 1) as repeat_ratio
from question_log
where asked_at > now() - interval '1 hour'
group by 1
having count(*) > 20 and
count(*)::numeric / nullif(count(distinct regexp_replace(question, '\d+', 'N', 'g')), 0) > 8
order by repeat_ratio desc;The repeat ratio — questions divided by distinct question shapes — is the single most useful signal, because it separates someone working hard from someone iterating a template. Normal heavy users sit between 1.2 and 2.5. The spreadsheet builder was at 47.
// And the response that fits a 200-person company: a message to a human,
// not an automated block. At this scale the right handling of unusual
// behaviour is usually a conversation, and an automated block on a
// colleague doing legitimate work costs more trust than it protects.
if (anomaly.repeatRatio > 8) await notifyOwner({ user, anomaly, action: 'none taken' })The third-party limits you do not control
// The provider's rate limit is a shared resource across every caller in
// the system, including the eval suite. Chapter 114 found 12 concurrent
// was optimal; a nightly eval run at that concurrency competes with
// production traffic.
const POOLS = {
interactive: { share: 0.70, priority: 1 }, // real users
background: { share: 0.20, priority: 2 }, // ingestion, summarisation
evaluation: { share: 0.10, priority: 3 }, // the suite
}Partitioning the provider quota by workload is a twenty-minute change that prevents the specific incident where a scheduled job makes the product unavailable. Atlas hit that once: a re-embedding job after a corpus migration consumed the quota and users saw rate-limit errors for forty minutes, which read as an outage.
Limits as an incident signal
// Budget events are security telemetry, not just billing telemetry.
const WATCH = [
{ on: 'global hourly spend >2x the trailing median', means: 'a loop bug or an attack' },
{ on: 'one user exceeding their daily budget twice in a week', means: 'a need, or a script' },
{ on: 'per-run token limit hit >1% of runs', means: 'chapter 80 termination regression' },
{ on: 'concurrent runs at the ceiling', means: 'capacity, or a stuck-run leak' },
]The third is the one that is easy to dismiss as a tuning issue. A rising share of runs hitting the token ceiling means runs are getting longer, and the usual cause is a control-flow change that stopped terminating cleanly — the limit is doing its job and masking the regression while it does.
Key points
- Cost per request varies 108× between the median and the maximum. A requests-per-minute limit permits anything from fifty cents to fifty dollars a minute.
- Denominate limits in money and time, across four scopes: run, user, team, global.
- The global ceiling is a security control. It bounds a failure nobody anticipated without knowing what went wrong.
- Degrade before blocking: fewer steps, no subagents, a cheaper model, and a banner saying why. Atlas's reduced tier serves 0.4% with no complaints; blocking outright produced four in a week.
- Abuse at 200 users is bulk extraction, enumeration, someone's script, and runaway retries. None is a botnet.
- The repeat ratio — questions over distinct question shapes — separates a hard worker from a template iterator. Normal is 1.2 to 2.5; the one real case was 47.
- Respond with a message to a human. An automated block on a colleague doing legitimate work costs more than it protects.
- Partition the provider quota across interactive, background and evaluation. A re-embedding job once consumed it and read as a forty-minute outage.
- Treat budget events as security telemetry. A rising share of runs hitting the token ceiling is usually a termination regression being masked.
Next: audit logs: what to record for every run — the record every control in this section assumed existed.