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.

PatternSignatureResponse
Bulk extractionHigh volume, low diversity, sequential identifiersAlert a human
EnumerationMany near-identical questions varying one tokenAlert, and tighten existence disclosure
A script someone wrotePerfectly regular timing, no session structureTalk to them; usually a real need
Runaway retryOne question, repeated, same sessionIdempotency 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.