Skip to main content

Quotas

Project-scoped caps that block traffic once an aggregate limit is exceeded.

Overview​

A quota compares an aggregate to a limit and blocks when it is breached: cost control, not authorization. Usage metering answers "what did this cost?", Guardrails "may this one tool call execute?", a quota "has this scope exceeded its cap?".

Three metrics measure a flow (accumulates inside a window, empties when it rolls) and breach with 429 QUOTA_EXCEEDED. storage_bytes measures a stock (what the project holds right now) and breaches with 409 QUOTA_STORAGE_EXCEEDED (see Storage enforcement).

MetricEnforced
requestsKoa middleware after authentication; counts API-key-authenticated requests only and blocks the request that pushes the counter past the limit. JWT-user (interactive) requests are never counted or blocked, which removes the admin-lockout hazard
tokens, cost_usdPre-generation check: the current window's usage is aggregated from the usage meter and compared to the limit
storage_bytesThe corpus write paths

Which project a request counts against​

A requests quota is always project-scoped (see Scope × metric validity), so every counted request needs exactly one project:

KeyProject attributedWhen
Project-scopedThe key's bound projectBefore routing — no handler work is spent on a request the quota rejects
Unscoped (no bound project)The project the route resolved and authorizedAt the route's own permission check, before it writes anything

Unscoped attribution waits for authorization so a caller cannot burn an unrelated project's quota by naming its public id; a denied request increments nothing. One request counts once, however many permission checks the handler makes.

  • Background drives are exempt. Self-calls a durable run or a workflow-dispatched agent makes with a run-as token are not counted, even though the token names the starting API key; they continue a request already counted on arrival.
  • The control-plane key is exempt. A request made with an admin's API key that is bound to no project and carries no policies of its own is operator traffic: it is neither counted against the project it reads nor refused by that project's quota. A key bound to a project is counted whoever minted it, and so is an admin key with attached policies.
  • Residual exemption. A request resolving to no single project is not counted: an unscoped key listing across every project it can reach (no project_id filter, or several projects accessible). Pass a project_id, bind the key to a project, or use tokens/cost_usd quotas, which aggregate from the usage meter.
  • Capping one specific unscoped key is not possible: an api_key-scope scope_ref must name a key in the quota's project. Use a null-scope_ref api_key quota (or a project quota).

Counting scope mirrors API-request metering exactly.

See the Permissions Reference for the IAM action strings for this module.

Data Model​

FieldTypeDescription
idstringPublic identifier (e.g. quota_…)
project_idstringID of the owning project
scopestringproject | api_key | agent | actor
scope_refstringPublic id of the api key / agent / actor; null = all entities of that scope type (for actor, one budget per actor — see Actor scope)
metricstringrequests | tokens | cost_usd | storage_bytes
windowstringrolling_1m | rolling_1h | rolling_24h | calendar_month | current (storage_bytes only — see Storage enforcement)
limitnumberThe cap (> 0); bytes for storage_bytes
modestringenforce (block with 429, or 409 for storage_bytes) | monitor (observe and report, never block — see Monitor mode)
meter_typestringThe meter a cost_usd cap answers for (see Meter scope); null = every priced meter
on_unpricedstringblock | allow — what an enforce cost_usd quota does over a pricing blackout (see Unpriced usage). Defaults to block; null for metrics with no pricing dependency
current_usageobjectCurrent fixed-window usage for requests (window_key, count, resets_at); null for token/cost quotas (they aggregate the meter at check time), null for storage_bytes (a stored total has no window and no counter — read the footprint from the storage meter), and null in list responses
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Identity is (project_id, scope, scope_ref, metric, window, meter_type); a duplicate returns 409 QUOTA_CONFLICT. scope_ref must reference an api key / agent / actor in the same project at create time; it is a soft reference: when the entity is deleted the quota goes inert (not cascade-deleted), still visible and deletable.

Key Concepts​

Scope × metric validity​

A quota is accepted only for a scope the metric can be aggregated by; anything else is 400, never a silent no-op.

MetricValid scopes
requestsproject, api_key
tokensproject, agent, actor
cost_usdproject, agent, actor
storage_bytesproject

Each metric is scoped by what measures it: the request middleware sees API key and project, not agent or end user; the usage meter carries project, agent and end user, not API key; the storage snapshot measures a project's footprint only.

window is validated both ways: storage_bytes accepts only current; every other metric refuses it.

Token and cost enforcement​

tokens and cost_usd quotas are checked before a generation starts, aggregating the current window from the usage meter: cost_usd sums the priced event cost, tokens sums the billable token components (uncached input + output + cached; the non-billable reasoning_tokens detail is excluded). At or over the limit, the generation is blocked with 429 QUOTA_EXCEEDED and nothing is metered for it. Session and conversation generate answer that 429 only with wait=true; a background call is accepted before the check.

Storage enforcement​

storage_bytes caps what a project holds: files, document chunks (text and vector), memories and the evaluations corpus, all metered. No other metric bounds them.

Flow metricsstorage_bytes
windowa fixed windowcurrent
Aggregateevents inside the windowthe project's footprint
Breach429 QUOTA_EXCEEDED409 QUOTA_STORAGE_EXCEEDED
Retry-Afterseconds until the window resetsnot sent
What clears itthe window rollingdeleting stored content
current_usagea counter (requests) or nullnull

As with QUOTA_UNENFORCEABLE, waiting is no remedy, so 409 and no Retry-After. meta carries current_bytes beside limit, no resets_at:

{
"error": {
"code": "QUOTA_STORAGE_EXCEEDED",
"message": "Storage quota quota_V1StGXR8Z5jdHi6B exceeded: the project stores more than its 5000000000-byte limit.",
"meta": {
"quota_id": "quota_V1StGXR8Z5jdHi6B",
"metric": "storage_bytes",
"limit": 5000000000,
"current_bytes": 5241041920
}
}
}

Where the cap acts​

Enforced on the caller-facing corpus writes, all creates, so a refusal leaves nothing half-written:

PathDelta counted
POST /api/v1/files/upload, /upload/base64, /upload/{token}the uploaded bytes
POST /api/v1/filesthe declared size — metadata-only, but it is what the meter sums for the row
POST /api/v1/documentsthe content bytes
POST /api/v1/documents/ingest, POST /api/v1/documents/{document_id}/ingestnone — the source file is already stored and measured; what ingestion adds is chunk text and vectors, produced after the response
POST /api/v1/memoriesthe content bytes
POST /api/v1/datasets/{dataset_id}/items, /items/from-generationthe serialized input, expected_output and metadata

The file, document, memory and dataset_item formation resources are held to the same cap.

Writes driven from inside a generation or run are exempt, since a refusal would leave the turn or run half persisted: conversation messages (each a Document with chunks and embeddings), memories from the write_memory tool, and eval_results rows. The cap bounds deliberate ingest; monitor-mode data says whether that is enough.

Measured against the last snapshot​

The footprint is the newest storage event plus the request's own delta, never a live scan (which joins every chunk row through documents and files). The cap accepts up to a day of staleness; a burst inside one day spends against a figure that has not heard of it.

  • A never-metered project is measured on its delta alone: an upload larger than the whole cap is refused; everything smaller is admitted until the first snapshot.
  • The cap is approximate on the way up. Chunks and vectors weigh several times the source text (chunk_count and gb_day) and are not in the delta. Size the cap for the footprint you will hold, not the byte the refusal fires on.

A capped-out project is wedged until a human removes something. Ship a cap in monitor mode first.

Recovering​

Delete content (files, documents, memories, dataset items); deletes are never refused and the next snapshot clears the breach. Raising the cap with PATCH /api/v1/quotas/{quota_id} or switching to monitor clears it immediately. A re-ingest is refused too: it can grow the corpus.

Unpriced usage​

Usage events carry cost_usd: null when no price-book row covers them and contribute 0 to a cost_usd aggregate (tokens sums component quantities, always recorded). A window that metered usage and priced none of it aggregates to 0 and can never fire; an enforce quota then follows on_unpriced:

  • block (default): new generations are refused with 409 QUOTA_UNENFORCEABLE.
  • allow: they pass; availability over containment, recorded on the quota.
{
"error": {
"code": "QUOTA_UNENFORCEABLE",
"message": "Cost quota qta_... cannot be enforced: the current window metered usage but priced none of it.",
"meta": {
"quota_id": "qta_...",
"metric": "cost_usd",
"limit": 25,
"window": "calendar_month",
"unpriced_rows": [
{ "provider": "openai", "model": "gpt-4o", "component": "input_tokens" },
{ "provider": "openai", "model": "gpt-4o", "component": "output_tokens" }
]
}
}
}

Verdict rules:

  • A blackout is at least 3 metered events in the window, none priced. One or two all-unpriced events pass (the exception below still files), so a fresh window's first event landing on one unpriced model does not stop a mostly-priced project at every boundary.
  • A partly priced window never refuses (that would block the generation that lands the first priced event, making the cap unrecoverable) but is always reported: a priced model A beside an unpriced model B reads A's spend alone, so a $100 cap passes while $530 is spent.
  • unpriced_rows lists the distinct (provider, model, component) the window metered and no price row covered, capped at 10, so no trip through the rollup is needed. Zero-quantity components and non-billable details are left out. Rows are per component, not per event, which exposes a partly priced event: a model priced for input but not output tokens carries a real cost_usd that understates itself; output_tokens in unpriced_rows shows it.
  • The verdict reads the llm_tokens meter alone, while the guarded aggregate sums every priced meter the meter scope admits. Platform meters such as compute_execution are priced by the operator from a soat SKU; counting them would let unpriced platform events refuse the first priced AI generation, or a priced platform event mask a genuine AI blackout. A quota naming a platform meter therefore has no verdict: never a blackout, never the exception below. An llm_tokens-scoped quota is held to the verdict.
  • Embeddings are held out of the verdict: their rate is deployment configuration outside every price book tier, so counting them unpriced would refuse a cap no one in the project can satisfy, and counting them priced (an unset rate meters at 0) would report the window measurable. Embedding spend still counts towards the aggregate and never appears in unpriced_rows.
  • No Retry-After, and no quota.exceeded webhook: no limit was reached. Configure the price book for the models in use, or set on_unpriced to allow.
  • A quota reads a window the meter already wrote, so the generations that opened it ran. To refuse an unpriced model before it runs, set require_priced_model on the project.
  • monitor mode never blocks, whatever on_unpriced says; it gets the exception below and nothing else.
  • on_unpriced is storable on cost_usd quotas only; elsewhere the write is 400.

Whatever the posture, a cost_usd check that finds AI usage no price row covered files a quota_unpriced exception (severity warning) carrying the quota, its limit, the window's llm_tokens event count, how many were unpriced, and the same unpriced_rows, from the first unpriced row, so the triage item precedes any refusal. Blackout and partly-priced windows file the same item, deduped on the quota; occurrence_count is the number of generations that ran under it. An empty window files and refuses nothing.

A generation in flight is never killed, so a budget may overshoot by at most one generation. A project quota aggregates the whole project; an agent quota with a scope_ref only that agent; an actor quota only the end user behind the generation (see Actor scope). The check reads the meter, not a separate counter, so quotas and usage never disagree.

Meter scope​

A cost_usd quota sums every priced meter by default, so a newly priced platform meter would consume a cap set to bound model spend. meter_type names the meter a cap answers for:

{
"scope": "project",
"metric": "cost_usd",
"window": "calendar_month",
"limit": 200,
"meter_type": "llm_tokens"
}

meter_type is part of the quota's identity: an AI cap, a storage cap and an unscoped cap over the same scope and window are three distinct budgets. Immutable; replace the quota to change it.

Actor scope​

An actor quota caps one end user (Actors) regardless of agent: "no single user costs me more than $5/month".

The end user is derived from the generation's session, never a separate argument, as in usage attribution, so the actor enforced against is the actor billed. A generation with no session (a direct API call, a trigger, an orchestration node) or a session without actor_id matches no actor quota; cap that traffic with a project or agent quota.

scope_ref: null means one budget per actor​

For actor scope a null scope_ref is one budget per actor, not a pooled total: "every end user gets 100k tokens a month", and one user exhausting theirs blocks nobody else:

soat create-quota --project-id proj_ABC --scope actor \
--metric tokens --window calendar_month --limit 100000

Set a scope_ref to cap one named actor instead (a larger allowance, or throttling an abusive user).

note

This differs from agent scope, where a null scope_ref aggregates the whole project — a pooled all-actors total would duplicate a project quota.

Webhook granularity​

The quota.exceeded webhook fires once per window per quota, not per actor: the first actor to breach fires it, later ones in that window are silent. Every actor is still blocked independently. To see who hit their cap, query the meter with group_by=actor (see Usage).

Windows and counters​

For requests, rolling windows are fixed windows keyed by the truncated timestamp (2026-07-07T12:31Z for rolling_1m); calendar_month keys are YYYY-MM. Each (quota, window) is one row incremented with one atomic UPDATE … RETURNING, so counters are correct across replicas. Every request reaching the middleware increments it, including requests subsequently rejected.

  • Within a window the limit is exact at any concurrency: increment and comparison are one statement.
  • Across a boundary the count resets, not decays: a rolling_1m cap of 60 admits 60 requests at 12:00:59 and 60 more at 12:01:00. Size the window for the burst you will absorb, or use a longer window with a proportionally larger limit.

Precedence​

When several quotas match, every enforce quota is checked and any breach blocks (fail closed). The most specific scope (actor > agent > api_key > project) is reported in the error body; a more specific quota never loosens a broader one.

Breach contract​

A flow breach returns HTTP 429 with a Retry-After header (seconds until the window resets) and the standard error body; a storage_bytes breach returns 409 with no Retry-After (see Storage enforcement):

{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Quota exceeded for api_key key_V1StGXR8Z5jdHi6B.",
"meta": {
"quota_id": "quota_V1StGXR8Z5jdHi6B",
"metric": "requests",
"limit": 600,
"window": "rolling_1m",
"resets_at": "2026-07-07T12:32:00Z"
}
}
}

quota.exceeded webhook​

Every breach fires quota.exceeded once per window, for enforce and monitor quotas alike; the fire state is one stored window key, re-fired only when the window rolls (no hysteresis). data carries quota_id, project_id, scope, scope_ref, metric, meter_type, window, window_key, limit, observed_value, and mode, so a consumer can tell which meter breached without a fetch. Subscribe a webhook to quota.exceeded (or a wildcard).

A storage_bytes quota's window_key is the snapshot's own UTC day: one report per fresh measurement while a project stays over cap.

Monitor mode​

mode: monitor observes without blocking: a breach fires quota.exceeded and lets the request, generation or corpus write through. Dry-run a cap, then flip mode to enforce via PATCH; the next breaching request is blocked. enforce quotas fire the same webhook beside the 429.

A monitor breach leaves no trace on the request, so it also writes an audit-log entry once per window: action: quotas:MonitorBreach (principal_type/principal_id null), the quota as resource, detail.kind quota_monitor_breach with metric, meter scope, window, limit and observed value. enforce breaches need none: the audit log records the 429.

Formation resource​

A quota formation resource (QuotaResourceProperties) takes scope, scope_ref, metric, window, limit, mode, on_unpriced, meter_type, validated by the same function as the REST route. scope_ref may be a { "ref": … } to an actor resource in the same template. Only limit and mode update through the lifecycle. Unknown fields are 400.

scope, scope_ref, metric, window, and meter_type are immutable (they key the window counters). Declaring a different value fails the operation: the formation is left status: "failed" with the field named in the operation error, and the quota keeps every previous value (limit and mode are never applied piecemeal). Restating a current value is fine; scope, metric, and window are required on create, so templates carry them on every update. To change one, replace the resource.

Omitting the nullable scope_ref means "not supplied", not clearing it; an explicit null disagreeing with the stored ref is a change. For actor scope, null is one budget per actor, a ref one named actor.

Self-modification footgun​

Quota mutations are ordinary IAM actions. Do not grant quotas:UpdateQuota / quotas:DeleteQuota to an autonomous API-key principal whose spend the quota caps. An admin JWT (never blocked by request quotas) can always raise or remove a quota.

Who may act on a quota​

Every route that acts on one quota is authorized against that quota's SRN, srn:<project_id>:quota:<quota_id>, not against the project. A policy may therefore name the quotas it covers:

{
"statement": [
{
"effect": "Allow",
"action": ["quotas:GetQuota", "quotas:UpdateQuota"],
"resource": ["srn:proj_V1StGXR8Z5jdHi6B:quota:quota_V1StGXR8Z5jdHi6B"]
}
]
}

Refusals keep the shapes IAM defines: a read the caller may not perform is 404 (a quota it may not see does not announce itself), a write is 403, and a credential scoped to another project is 403 API_KEY_PROJECT_SCOPE. A write on a resource in a project none of the caller's policies name is 404 too — the same answer their read would get, so a refusal never confirms existence across a tenant boundary.

Listing quotas stays project-scoped: GET /api/v1/quotas asks whether the caller may list quotas in a project at all, so a policy that names individual quotas grants no listing.

Examples​

Create a quota​

soat create-quota --project-id proj_ABC --scope api_key --scope-ref key_ABC \
--metric requests --window rolling_1m --limit 600

Cap the stored corpus​

In monitor mode first: it reports which projects would breach and refuses nothing.

soat create-quota --project-id proj_ABC --scope project \
--metric storage_bytes --window current --limit 5000000000 --mode monitor

Get a quota with current usage​

soat get-quota --quota-id quota_ABC