Skip to content
Like what we’re building? Star on GitHub

Quotas and limits ​

Audience. Anyone choosing platform-default values, deciding what to set on a paying org, or debugging "why did this run get denied". Both the operator-set platform defaults and the per-org overrides documented here come from real fields on real records — not aspirational settings.

Iterion enforces six distinct limits at run launch and one at the webhook intake, plus one authorization rung in front of them, and can bias which workload meets them first (see Budget floors). They live behind a single decision function (pkg/server/launch_gate.go:gateLaunch) called by every surface that launches a NEW workflow from a request, and by the daemons that launch on their own schedule: the HTTP launch and resume, the WebSocket answer that resumes a run, the custom-event emit, the operator's launch-now on the pipeline board, the inbound webhooks, the retry sweeper's automatic resumes, the scheduled launches, the trigger spine and the board dispatcher. The table in Which surfaces are gated names them all.

Two families re-enter the engine outside it: POST /api/runs/{id}/fork and POST /api/runs/{id}/rewind create a run from an EXISTING one, and a resume driven by an existing run's own machinery (/answer-human, /interactions/{iid}/answer, /host-event, the assistant mission and watch coordinators) re-enters the engine on a run that already exists. Both are authorized by the run-by-id ladder (ADR-103) — a member of the run's team — and neither is metered; that is a known follow-up, not a property of the gate.

The launch-admission order ​

gateLaunch returns the first failing check. The authorization rung comes first, before any counter is read: launching a run spends the org's budget and executes code in its workspace, so it takes at least the member role of the team. A viewer is read-only and the orthogonal config_editor capability is not a ladder rung (ADR-078), so both are refused member_role_required (403). The principals that launch with no human role are exempt by their identity kind — an inbound webhook delivery, which authenticates itself with the config's bearer token or an HMAC body signature, and the daemons of this process (board dispatcher, schedule tick, trigger spine, retry sweeper). The rung is fail-closed: an identity carrying neither a member role nor one of those kinds is refused.

The same rung guards the surfaces a machine principal CONSUMES. The board's Ready column is one: the dispatcher drains it under a principal the gate exempts, so a card written ready is a launch its author asked the platform to perform. A trigger subscription is another: the spine evaluates it against every matching event and launches under the same kind of exempt principal, so a stored subscription is a standing launch order. A team's memory space is the third: the engine reads its documents back into every agent run that opens the space, so a document written there is instruction the platform will follow. The rung is therefore taken at the mount, by every state-changing method of:

  • the three board registrars — registerPipelineBoardRoutes (/api/v1/pipeline-board/…), registerCloudBoardRoutes (the native.BoardAPI under /api/v1/native/…) and registerBoardForgeRoutes (the per-card push / pull-request / merge endpoints under the same /api/v1/native/ prefix);
  • registerTriggerRoutes — /api/v1/triggers and /api/v1/bots/{name}/triggers/from-invocation;
  • registerMemoryRoutes — the seven /api/memory/* routes, where the write, the delete and the import take it (a global-space mutation then takes platform authority on top, requireMemoryWriteAuth).

Each answers the same member_role_required (403), with a detail naming what the caller was changing; GET/HEAD stay readable by a viewer. The self-hosted single-store mount of /api/v1/native is untouched: it runs with no identity store, so there is no role to read — the same arm gateLaunch itself opens with.

Then the six limits, in this exact order:

  1. Org status — team EffectiveStatus() ∈ {active}. Suspended and read-only orgs short-circuit here.
  2. Per-repository quota — when the launch names a repository and a quota covers it, its month-to-date consumption (read off pkg/credusage's repository dimension) must be under the ceiling. See Budget floors.
  3. Concurrency — count(active runs for tenant) < MaxConcurrentRuns (CountActiveRunsByTenant). Active = queued or running.
  4. Launch rate — token-bucket LaunchRatePerMin per team, rate = perMin/60 per second, burst = perMin.
  5. Monthly run quota — AllowRun() atomically increments the month's org_usage counter and refuses when the new total exceeds MonthlyRunQuota. This is also the metering step — a successful run consumes one slot at this point.
  6. Monthly cost cap — MonthlyUsage.CostUSD < MonthlyCostCapUSD, decided off the same post-increment document as step 5, in one round trip. The run quota is tested first, so an org over both caps is answered monthly_run_quota_exceeded.

Step 2 binds only when the launch names a repository, which every automated lane does at the gate. POST /api/runs is the exception in placement, not in effect: it learns its repository from a connection read that must sit behind the suspend check, so it re-runs step 2 — that step alone, since step 5 has already metered — once repoProjectPath resolves, before the forge reachability probe and the managed-secret mint. The super-admin exemption below travels with it: it lives inside the repo check rather than only in the gate's prologue, so the one surface that re-runs a step on its own cannot answer a super-admin differently from the five that do not.

Steps 3 and 6 are additionally lowered by any capacity reservation that does not name this launch's bot — the floor described below — and refuse outright when those reserves take the whole cap (see When the reserves swallow the whole cap, which is the case a lowered ceiling cannot express). A reservation never creates a limit that is not configured, so a deployment with no concurrency or cost cap is unaffected by one — including a wiring whose run store cannot count active runs, where step 3 is inert and the slot reserve is inert with it.

Super-admins bypass the whole gate (they explicitly opt out of org scoping). Local mode (no identity store) has no gate. The gate fail-opens on a Mongo / store error so a transient blip doesn't wedge every launch — quotas are an operator policy, not a hard security boundary. The one nuance: when AllowRun errors at step 5 the launch still proceeds unmetered (logged WARN) instead of being denied; the denial path is only the deliberate "this would exceed the cap" case.

Which surfaces are gated ​

Every launch of a new workflow passes gateLaunch with the identity of whoever is launching and meters one monthly run at step 5. The slot goes back only when the refusal PROVES nothing started (a sealing failure, a bot that does not compile — no document, no queue message). When the error says a run may exist or a landed message may be claimed (a publish that reported failure after landing), the slot STAYS — refunding the unit of a real run is the under-count the gate exists to prevent (#1725, runview.RunMayHaveStarted):

SurfaceIdentity on the ctxGatedMetered
POST /api/runs, the studio, iterion remote runs launch, the MCP remote_runs_launchthe caller'syesyes, rolled back on a refused launch
POST /api/runs/{id}/resume, the WS answer that resumes a runthe caller'syesyes
Inbound webhooks, direct launch (insertAndLaunchWebhook) — including the merge-gate auto-fix and relaunch lanes, which reuse that tailthe token's synthetic webhook identityyesyes — refunded when the refusal proves nothing started (and for the idempotency loser); kept on a landed-but-failed publish
Inbound webhooks, board mode (the command creates a card; the dispatcher launches it)the token'spre-check only, at card creationno — a card is not a run; the pre-check's slot is handed back at once and the dispatcher meters the launch when it claims the card
Board dispatcher (processBoardCard)board-dispatcher on the card's teamyesyes — refunded when the refusal proves nothing started; kept on a landed-but-failed publish
Retry sweeper (automatic resume of a failed_resumable run)the run's owneryesyes — refunded when the resume proves nothing started; kept on a landed-but-failed publish
POST /api/v1/pipeline-board/tasks/{id}/launch (the operator's launch-now, bypassing the admission queue's ranking)the caller'syesyes, rolled back on every refusal that leaves no run
POST /api/v1/triggers/emit (custom event)the caller'spre-check only, per requestno — an emit is one EVENT and fans out to 0..N launches; the pre-check's slot is handed back at once and the spine meters each launch it performs
Trigger spine direct launches (serviceLauncher: mode: direct board triggers, run-completion chains, the emit fan-out, the local schedule source)trigger-spine on the subscription's teamyesyes — refunded when the refusal proves nothing started; kept on a landed-but-failed publish
cloudsched scheduled launches (launchScheduledBot)cloud-scheduler on the schedule's teamyesyes, rolled back on a refused launch
POST /api/runs/{id}/fork and /rewind (a run created from an existing one), and the resumes an existing run's own machinery drives (/answer-human, /interactions/{iid}/answer, /host-event, the assistant mission and watch coordinators)the caller's, or the run's ownno — authorized by the run-by-id ladder (ADR-103), which takes a member of the run's teamno — known follow-up
Local mode (iterion studio / iterion dispatch with no identity store, the pipelines admission)—no gate exists—

On the board dispatcher a denial is a launch refusal of the dispatcher's transient class, not a verdict on the card (dispatcher.md): the card returns to its column under the machine provenance launch_refused, its ledger reads the rule that refused it — launch gate: concurrency_cap_exceeded: org has 3 active runs (cap 3) … — and the next attempt waits out the backoff, so an org at its cap retries its ready cards on the backoff schedule, not on every 5s tick. A cap that does not free within the attempt cap files the card blocked under launch_given_up with the rule on it, and the pipeline board shows it in its Needs attention lane.

On the two surfaces that have no request to answer, a denial is recorded where an operator reads it, never skipped in silence:

  • a trigger subscription carries last_error + last_error_at (GET /api/v1/triggers, iterion remote triggers list), raised by the refusal and cleared by the next launch that goes through;
  • a schedule carries the same two fields (GET /api/teams/{id}/schedules, iterion remote schedules list), plus the tick-audit row the ticker already wrote.

Both are targeted field writes on both store twins, so an operator editing the row between the match and the record does not lose the edit.

Limits, fields and platform defaults ​

Every limit has three knobs: an override field (on the Org or the Team document — see the table), a platform env var (the default applied when the override field is zero), and a public denial reason token. Zero means "no limit" everywhere — the safe default for existing deployments. The first two rows are the exception: having a workspace at all, and the member rung inside it, are authorization rather than limits — no field, no env var; they are cleared by belonging to a team and by the caller's role in it.

LimitOverride fieldPlatform env varDenial reasonHTTP
No workspace: the caller names no team, or one the identity store no longer hasn/a — the caller's teamn/a — not configurableno_workspace403
Below the member rung of the teamn/a — the caller's rolen/a — not configurablemember_role_required403
Org suspended / read-onlyStatusn/a — admin actionorg_suspended403
Concurrent active runsMaxConcurrentRunsITERION_ORG_DEFAULT_MAX_CONCURRENT_RUNSconcurrency_cap_exceeded429 (Retry-After: 30)
Launches per minuteLaunchRatePerMinITERION_ORG_DEFAULT_LAUNCH_RATE_PER_MINlaunch_rate_limited429
Monthly LLM cost cap (USD)MonthlyCostCapUSDITERION_ORG_DEFAULT_MONTHLY_COST_CAP_USDmonthly_cost_cap_exceeded402
Monthly run quotaMonthlyRunQuotaITERION_ORG_DEFAULT_MONTHLY_RUN_QUOTAmonthly_run_quota_exceeded402
Per-repository quotabudget_floor platform settings (repo_quotas)n/a — runtime-mutable, no env defaultrepo_quota_exceeded402

Status, MonthlyCostCapUSD, and MonthlyRunQuota are Org-document fields (org-wide, super-admin managed — pkg/identity.Org); the org run/cost counters sum every team in the org. MaxConcurrentRuns and LaunchRatePerMin are Team-document fields (per-workspace executor caps — pkg/identity.Team), and Team.Status is now writable through iterion remote teams status (org admin) — it was read by the gate below and settable by nothing.

A limit this table does NOT carry: whose credential funds the run. That is a separate question with its own two gates — the org's CredentialAudience (which teams may spend the org's shared keys) and the platform tier's platform_credentials audience (which tenants may draw on the deployment's). Both are documented in cloud-llm-credentials.md; neither denies a launch, they decide what the run is handed. A third, per-credential limit lives on the key itself: max_concurrent_runs (byok.md) is a hard cap held by an atomic reservation at launch and resume, and a full key parks a launch (CRED_SLOT_WAIT, asked again until a slot frees or its max_wait passes) rather than denying it — an operator's resume on a full key answers 429 (credential_at_capacity), whichever surface resumes it.

The override-field semantics are pinned in pkg/server/launch_gate.go:orValue (the tenant override wins when > 0; else platform default; zero = unlimited). The denial reason tokens are stable strings — clients (the studio, SDKs, CI scripts) switch on them. The HTTP status codes follow the standard "402 = paying issue (resets next month), 429 = retry later" convention.

The env vars are read at boot by cmd/iterion/server.go:orgLimitDefaultsFromEnv. Invalid / negative / unset values fold back to zero (unlimited).

The denial envelope ​

Every denial returns the same JSON shape (pkg/server/launch_gate.go:writeLaunchDenial):

jsonc
{
  "error":    "monthly_cost_cap_exceeded",         // stable token
  "detail":   "monthly LLM cost cap ($80.00) reached",
  "reset_at": "2026-07-01T00:00:00Z"               // monthly quotas only
}

Plus a header on rate denials:

Retry-After: 31

The envelope also carries the authorization refusal: a caller below the member rung reads "error": "member_role_required" with a 403 and no reset_at — nothing resets, the role does. Clients that switch on the token therefore see one shape for "you may not launch here" and for "you may, but not right now".

Forge webhooks see the same envelope when the launch-admission gate fires — the inbound handler writes a launch_error delivery row and calls writeLaunchDenial so a forge integration can react identically to a UI-driven launch.

What gets metered ​

CounterWhen it bumpsWhere
org_usage.runsAt launch admission (step 5 above)pkg/orgusage/orgusage.go:AllowRun
org_usage.cost_usd + tokensAt the end of each runner execution attempt, from that attempt's accumulated LLM eventspkg/runner/loop_spend.go:recordOrgSpend calls orgusage.AddSpend
webhook_quotas.count — one _id per org-month, plus one per webhook-month when a per-webhook cap appliesAt webhook admission, after the rate limitpkg/webhooks/store.go:Counter

The run counter includes every launch: REST POST /api/runs, POST /api/runs/{id}/resume (a resume re-enters the engine and spends like a launch), and inbound webhook deliveries. A re-published DLQ message does not double-count — it picks up the existing run row.

Cost metering is "floor, not invoice":

  • claw (in-process LLM) is priced through pkg/backend/cost and reports cost_usd per call.
  • Every CLI delegate (claude_code, Codex, pi, Kimi, and Grok) contributes its aggregate token total when the CLI reports usage. The cloud runner's delegate event carries no input/output split, so that total is reported as aggregate_tokens — its own field, leaving input_tokens and output_tokens for splits that were actually measured. A true total is the sum of the three, and a per-direction ratio is only meaningful on a row whose aggregate is zero. Zero in all three means not observed, never nothing spent.
  • A CLI delegate's cost_usd is added to org_usage.cost_usd — the delegate_finished figure flows through metricsEmitter.RunTotals into recordOrgSpend. claw is the one exclusion, and deliberately: being in-process it emits both a priced llm_step_finished per step and a delegation total, so counting both would charge every claw run twice and trip an org's monthly cap at half its budget (pkg/runner/loop_metrics.go:240-260, loop_spend.go).
  • It is still a floor, not an invoice: a delegate that reports no cost contributes none. Treat the monthly USD cap as a trend signal rather than a billing ledger.
  • A forfait run does NOT report $0 — and reading cost_usd as money spent is the misreading this bullet exists to prevent. claude_code prints total_cost_usd on every call whatever pays for it: on a subscription it is the price those same calls WOULD have cost metered, cache creation billed at 1.25× and cache reads at 0.1× included. Measured 2026-09-03 on a cloud runner holding a forfait: claude -p "reply pong" — three input tokens, five output — reported $0.0402, because it created 5 751 cache tokens and read 17 120. Nothing was charged; the plan is flat. So an org showing cost_usd_this_month: 1991 on forfait-served runs has spent that in equivalent API price, not in money: the only real money on a subscription is the extra-usage overage, which the provider's own console is the sole authority on.
  • And the bucket is the ORG, not the credential: recordOrgSpend charges msg.OrgID whatever tier served the run (team forfait, credential pool, platform keys, BYOK). The figure answers "what did this org consume", never "what did this key cost" — which is what the per-credential ledger below answers.

Per-credential usage — what did THIS key cost ​

pkg/credusage is the second bucket, fed from the same attempt beside recordOrgSpend. It keys on {fingerprint, provider, tier, tenant} × month and answers the question the org counter structurally cannot.

Two properties carry it:

  • Split by backend. A run can spend a claude_code forfait on its implementer and a platform codex key on its plan review, while RunTotals() is one number that belongs to neither. The spend is taken per (backend, model) ROUTE, and the MODEL is what names the provider (a claw node can be pointed anywhere). A route iterion cannot attribute — a bare model id on a multi-provider backend, a provider the run holds no credential for — is charged to nobody: no figure beats a wrong one.
  • Nature, in the API. Every amount is typed metered (real money on an invoice: a BYOK or lent API key) or estimate (a subscription — see the total_cost_usd bullet above). The same line credpool.CredentialSource.Metered() draws, asserted equal by TestCredentialNature_AgreesWithCredpoolMetered. The list responses keep metered_usd and estimated_usd apart for that reason: summing them reproduces exactly the misreading the ledger exists to remove.

The tier (team | pool | platform) is part of the meter identity, not a label: the same key lent through the pool and used by its owner are two different economic facts.

Where a route's model comes from. The runner's metrics emitter names each node's route from three events, in order: delegate_started.declared_model (the node's spec, provider included — every backend emits it), then each llm_request (the routing spec the call was resolved from), then delegate_finished when the delegation ends.

  • A claw llm_request carries the routing spec (openai/gpt-5.6-sol) and, when the request carried a different id, that wire id as wire_model; such a request names its own route — a fallback element's included.
  • delegate_finished.route_model is the spec of the chain element that SERVED the node, named by the executor that ran the chain: the delegation is keyed on it, refined by effective_model only when the backend reports another model (an ANTHROPIC_MODEL override), never re-routed by the reported id's spelling — OpenRouter reports anthropic/claude-sonnet-4.5 for the model it served.
  • A fallback chain books the routes it abandoned after they spent on the one delegate_finished / delegate_error that ends it, and itemises them in folded_spend (each slice's backend, route_model, provider, fingerprint, tokens, cost_usd): the meter keys each slice on its own route and the rest of the figure on the event's. A claw slice whose loop the meter already priced step by step is not counted again, whichever route ended the chain.
  • Without either fact (an older runner, a backend reporting a bare id), the route is rebuilt from the declaration: a report equal to the declared model's wire id keeps the declared route; a report whose prefix is a provider iterion routes or funds on names that route; a report that is the declared model up to its snapshot alias keeps the declared route; any other id is kept as reported.

claw inside a sandbox. The LLM loop runs in iterion __claw-runner in the container, and the runner relays its per-step llm_request / llm_step_finished to the launcher over the IPC (sandbox.md), so a sandboxed claw node is metered from its steps exactly like an in-process one — and the delegate_finished total, a summary of those steps, is not counted again (cost or tokens). When a run's container carries an older runner that relays nothing, that total is the only observation: it is booked on the route and, when the event carries no cost_usd, priced from the table at the model's input rate — a floor, since one aggregate count cannot be split into input and output — or left unpriced when no source knows the model. Zero is unknown, never free.

A route iterion cannot attribute is logged at warn by the runner, once per route per attempt (no credential iterion can name): the decline is definitive for that attempt, so it has to be visible.

sh
# This team's credentials, this month
iterion remote usage --by-credential
# GET /api/teams/{id}/credentials/usage

# A past month, on either route
iterion remote usage --by-credential --month 2026-08
iterion remote api GET "/api/admin/credentials/usage?month=2026-08"

# The platform tier across every tenant it served (super-admin)
iterion remote api GET /api/admin/credentials/usage
# ?tier=team|pool|platform — or ?fingerprint=<fp> for one credential,
# whose rows live under each tenant that drew on it.

The admin route answers for ONE tier, and the default is platform. A team forfait's spend is metered on a team row and is therefore absent from the unfiltered listing — by design, since no tenant view can show the platform tier and that is the question this route exists for. Every response now carries a scope object naming what was applied (tier, fingerprint, repo, team_id), because a listing that does not say what it left out reads as "everything", and a credential missing from "everything" reads as a credential that spent nothing:

json
{ "month": "2026-09", "scope": { "tier": "platform" }, "credentials": [ … ] }

Both routes take ?month=YYYY-MM, and refuse a value they cannot parse (400) rather than fall back to the current month — the response is labelled with a month, so serving another one under that label is a wrong answer, not a partial one. Two readings of this endpoint made without either property were what opened #1087 against a counter that was recording normally: a ?month= the server ignored returned the current month twice, and the platform-tier default hid the team row the run had actually charged.

Metering is best effort throughout, like the org bucket: a missing counter, an unattributable route or a store failure leave the observation on the floor rather than turn a finished run into a failed one.

The repository dimension ​

The meter also keys on the repository a run targeted — repo_id, the forge slug (owner/repo, group/sub/project) the launch surfaces already stamp on the run as ProjectPath and the studio already groups runs by, not a second identity derived from a clone URL. It is what makes "one busy repository is eating the shared subscription" a question with an answer.

sh
# What this team spent on one repository this month
iterion remote usage --by-credential --repo SocialGouv/iterion

# The same repository across every tenant and credential (super-admin)
iterion remote api GET "/api/admin/credentials/usage?repo=SocialGouv/iterion"
# ?fingerprint= and ?repo= are REFUSED together (400): one credential across
# repositories and one repository across credentials are different questions,
# and answering whichever the code checked first returns a figure nobody
# asked for.

Four properties are worth knowing, because each is a way to misread the numbers:

  • Every listing that predates the dimension sums the repositories back together, so --by-credential without --repo reports exactly what it always did. The alternative — one row per repository — would have made a credential appear several times with no total anywhere.
  • A row with no repository is not "all repositories". Local runs, CLI runs and non-webhook cloud launches target none, and neither does any row written before this existed. --repo therefore never reaches them: a repository's bill must not quietly include the deployment's unattributed runs. The document id omits an empty repo entirely, which is what lets those rows keep accumulating instead of restarting from zero at the deploy.
  • A repository's spend spans tenants. Two teams can serve one repo with their own credentials, so the admin view sums both; the team view returns only that team's share.
  • The slug's case does not split a bill. A forge resolves case-variant slugs to one repository — GitHub serves O/Hungry and o/hungry identically — so the reads fold the case instead of keying on a spelling: the per-repo quota match, and every --repo / ?repo= listing, answer for every spelling a run was stamped under. A URL's .git suffix — any case, doubled, trailing slashes — folds to a fixed point at the derivation (forge.CanonicalRepoSlug). The rows keep the spelling their run arrived with; the reads count them together.
  • A run whose repository cannot be read meters without one. Same rule as the route attribution: charged to the credential, attributed to nobody — never guessed.

This is the accounting subject the per-repo quota is enforced against — see Budget floors below.

Budget floors — holding capacity back from unreserved work ​

Everything above this line is a ceiling: it answers "how far may this go?". This answers a different one — "which workload should stop FIRST?" — by lowering, for every bot a reservation does not name, the ceilings those gates already enforce.

A preference, not a guarantee — and the gap is worth knowing before you rely on it. Nothing is allocated: admission compares a total against a lowered ceiling and acquires nothing, so work admitted below it can still consume the band a reservation names, bounded only by the combined outstanding spend of every admitted attempt. Two replicas can both admit into the last slot. The reserve acts at admission only: a run in flight is never re-judged, and the runner's own guard (pkg/runner/usage_cap.go) reads the deployment-wide policy, not the lowered one.

A real guarantee needs durable allocation — reserve at admission, settle against actual spend when the attempt ends, reconcile on crash — tracked on #950 and not built. Its design is ADR-155: what this deployment enforces is what is documented above, not what that record decides.

The difference is not academic. On 2026-09-08 campaign bots and the PR reviewer shared one Anthropic subscription; when its five-hour window closed at 06:14Z, eight runs parked in six minutes and fourteen within the hour. No cap had been exceeded and no quota breached — every individual run stayed under its own ceiling all the way down. Review simply had nothing held for it.

sh
iterion remote admin budget-floor                                     # show
iterion remote admin budget-floor reserve --bot review-pr --five-hour 20
iterion remote admin budget-floor quota --repo owner/repo --monthly-usd 50
iterion remote admin budget-floor rm --bot review-pr

Each of those subcommands is a GET → edit one entry → PUT of the WHOLE policy, so the write is conditional: the record's updated_at travels back as a compare token and a lost race is a 409, not one admin's reservation silently deleted by the other's stale document. The CLI answers it by re-reading and replaying the same edit; a raw iterion remote api PUT /api/admin/settings/budget-floor must send the updated_at it read (omit it only for a deployment's very first write).

The workload is a bot id ​

review-pr — the thing that actually spends, already on every run and already the vocabulary a credential-pool pledge uses for its allow-list. No indirection, no new concept. The cost, stated because it is silent: renaming or replacing the bot leaves the reservation pointing at an id nothing launches, and it then protects nothing. Re-point it by hand.

The reserve holds a share of the provider's WINDOW by default ​

On a subscription the provider bills nothing per call — which is exactly why credusage types those dollars estimate. Reserving "$X for the reviewer" on a forfait reserves a fiction: the run that dies does so because the five-hour window is spent, not because a figure was reached. So the default axis is the window, and enforcement needed no new gate — the credential walk already skips a forfait whose window is closed and falls through to the next tier; the reserve simply lowers the ceiling that skip is judged against, per bot.

5h window utilisation
0%                     50%       60%        70%   80%       100%
|-----------------------|---------|----------|-----|---------|
      everyone      unreserved  feature-dev  review-pr   provider
                     stops       stops        stops       wall

The window reserve needs a window cap to lower. It subtracts from the operator's own usage cap and never creates one — a floor may hold work back, it may not invent a ceiling, nor re-arm a guard the ITERION_USAGE_CAP kill switch disarmed. So a deployment that set no ITERION_USAGE_CAP_* (see usage-caps.md) stores a window reservation that holds nothing: the 80% in the diagram is the cap, and without one there is no band to divide. budget-floor says so in a warnings field, on the read and on the write that sets the reserve — the order "configure the floor, then arm the cap" is legitimate, so it is a warning and not a refusal. The cap has to sit strictly below the provider's own wall for any of this to have room.

Two other axes are available where they are the honest answer, each enforced at the gate that already caps it: --monthly-usd (real money on a metered key) comes off the org's cost cap, and --concurrent-runs off the team's concurrency cap — the dial that keeps the reviewer answering a PR while a campaign runs. Any subset may be set — and reserve (like quota) EDITS one entry: an axis you do not name keeps the value it has, so adding --concurrent-runs to a bot that already holds a window band keeps that band. Name an axis with 0 to clear it, or rm for the whole entry.

--concurrent-runs is enforced conservatively, and the difference is worth knowing before you set it: the gate counts a tenant's active runs, not each bot's, so what it enforces is "unreserved work may not push the TOTAL past cap − reserved". The reserved workload therefore always finds its slots free, and one case over-refuses — while the holder is spending its own reserve, an unreserved launch is denied although the fleet is under its cap (cap 3, reserve 2, the reviewer running 2: the third slot stays unused until one of them ends). Reserve the smallest number that keeps the reviewer moving; a large slot reserve quiets everything else while the holder runs.

Composition: protected from the others, never from itself ​

A workload's ceiling is the cap minus the reserves of every OTHER workload. With review-pr at 20 and feature-dev at 10 under an 80% cap: ordinary work stops at 50, review-pr may reach 70, feature-dev 60.

Summing every reservation instead would refuse a workload on its own band — the reservation would make its holder stop earlier than before it existed, the exact opposite of a floor, and green under any test that only checks unreserved work.

Which cap — and read this before setting the two countable axes on a multi-tenant deployment. The cap subtracted from is the one that already enforces the axis, and only the window's is deployment-wide. So a --five-hour / --week reserve is held once for the fleet, while --monthly-usd (the launching org's cost cap) and --concurrent-runs (the launching team's) are applied to each tenant's cap independently: reserve --monthly-usd 50 on a deployment with twenty orgs and every one of them holds $50 back from its own cap, not $50 between them. There is no fleet-wide dollar or slot cap to subtract from, so this is the only available reading — but it makes those two a decision about every tenant, and the corollary is the next section: a tenant whose own cap is at or below the reserve has nothing left for unreserved work and is refused there for the rest of the month, even if it never runs the reserved bot. Size them against the smallest cap on the deployment.

Two things a reservation can never do ​

  • Create a cap. A window, cost cap or concurrency cap that is not configured is returned untouched. Subtracting a reserve from zero yields a positive limit out of nothing, and a deployment that never set a cap would ACQUIRE one — every run refused because somebody wrote a reservation. A floor may hold work back; it may not invent a ceiling.
  • Let its holder overspend. The reserved workload never passes the deployment's own caps. A reservation holds capacity back from others; it is not a way around the wall.

When the reserves swallow the whole cap ​

Reserving 50% of the window under a 50% deployment cap — or lowering the cap after the reservations were written — leaves unreserved work nothing, and that is a refusal, never a lowered ceiling. It has to be said out loud because the two are one keystroke apart in code and opposite in effect: MaxPercent 0 means "this window is not enforced" to pkg/usagecap, so a ceiling clamped at zero would hand every unreserved bot an uncapped credential — amplifying the starvation the reserve exists to prevent. So the walk refuses the credential for the unreserved bot and falls through to the next tier, saying so in the skip log ("the five_hour window is entirely reserved for other workloads"); the org's monthly cost cap does the same on its own axis, denying with monthly_cost_cap_exceeded. Policy.Validate cannot catch this at write time — it knows the 100% window, never the deployment's own cap — so it is not refused, it is reported: budget-floor returns it in the response's warnings, beside the opposite shape (a reserved window this deployment does not cap at all). Both are advisory, because both are legitimate in transit — "hold the whole window now, widen the cap next" is an order to do things in. The window is also the axis this can reach without anyone touching the floor: lowering the usage cap under reserves that were already written produces it, and the next read of the settings says so.

The per-repo quota ​

A ceiling inside the shared budget, refused with its own reason (repo_quota_exceeded) because the operator's next move differs: raise that repository's quota, not the org's. Three forms — --monthly-usd (default), --route-spends-per-month, and --reserve-share N --share-of-bot <bot>, which slices a reservation so that raising the reservation raises every repository taking a share of it.

A share of a window-only (or concurrency-only) reserve is refused at configuration time, not resolved to zero: slicing a live five-hour window between repositories would need real-time arbitration across replicas, and a locally computed share is a number two pods disagree about. Only --monthly-usd can be sliced into a repository ceiling, and the refusal names the way out.

It is read off the repository dimension above — the meter the runs actually write — and it is deployment-wide, not per team: the repository dimension spans tenants, so the quota bounds what that repository consumed whoever ran it, including rows left under a previous team after the repo moved. Two teams running the same slug therefore share one quota.

Read the figure back with the platform view, iterion remote admin → GET /api/admin/credentials/usage?repo=X, whose audience matches the policy's own. The team-scoped iterion remote usage --by-credential --repo X narrows to one tenant and will read lower than the number that refused the launch — it is answering a different question, not disagreeing.

--route-spends-per-month does not count runs, and is named so nobody reads it as if it did. The ledger increments once per AddSpend, and the runner calls AddSpend once per (credential, backend, model) route an attempt charged: an agent on opus with a judge on haiku spends two, a fallbacks: crossing spends two, and a resumed run spends again. Cap attempts with the org-level monthly run quota; this axis caps metered activity in one repository.

What is not covered ​

The gate is told what a launch is by an explicit subject (the bot, and the repository when there is one), and that matters in the direction people do not expect: a launch judged as ordinary work faces the ceiling its OWN reservation lowered, so a missing subject makes a reservation refuse the very workload it protects. Every automated surface names its bot — the webhook launches, the trigger spine, the cloud scheduler, the board dispatcher, the retry sweeper — and so does POST /api/runs (from the request's bot_id).

A resume names its bot too — POST /api/runs/{id}/resume and the WS answer that resumes a run both read it off the RUN, which is the only place it is written. The run is loaded before the gate but its error is answered after, so an unreadable run costs the subject and never the admission: the lookup is not tenant-filtered, and answering its 404 first would turn the suspend check into a run-existence probe.

Two cases legitimately carry no bot, and are judged as ordinary, uncapped work:

  • POST /api/v1/triggers/emit: one event fanning out to 0..N launches, each gated with its own subject. This pre-check bounds the event, not any of them.
  • A plain .bot upload, which names no bot at all.

The repository is passed wherever one exists, the automated lanes and the hand-driven ones alike — a quota with a bypass on the door an operator or a CI loop uses is not a quota. On POST /api/runs it is resolved a few checks after the admission, because naming the repository costs a connection read and that must sit behind the suspend check, not in front of it; the repo half of the gate is re-run on the line that resolves it, ahead of the forge reachability probe and the managed-secret mint, so an over-quota repository costs neither. Only the repo half — the monthly arm meters, and the launch has already charged its run slot by then.

The board dispatcher is inside the perimeter too (#2371, D33): the card carries the forge slug beside its clone URL (the webhook payload's own project path, stamped at carding; the stamp is honored only where no clone URL contradicts it, and a URL that is present is canonicalised back to the slug against the card's integration), and processBoardCard both stamps it on the run — so the meter charges the card's spend to its repository — and passes it to the gate. The sanctioned consequence: a board card whose repository sits at its ceiling is refused like any other launch, where it previously launched uncounted.

Reading usage ​

Both views share the same JSON shape (pkg/server/admin_orgs_routes.go:orgUsageView):

jsonc
{
  "org": { "id": "…", "name": "…", "status": "active", … },
  "members": 12,
  "effective_memory_quota_bytes": 1073741824,
  "monthly_run_quota":            1000,
  "runs_this_month":              347,
  "cost_usd_this_month":          18.91,
  "input_tokens_this_month":      4123890,
  "output_tokens_this_month":      921334,
  "aggregate_tokens_this_month":  2210544,
  "monthly_cost_cap_usd":         80.0,
  "max_concurrent_runs":          5,
  "active_runs":                  2,
  "webhook_calls_this_month":     410,
  "memory_used_bytes":            73801234,
  "api_key_count":                3,
  "generic_secret_count":         2,
  "bot_binding_count":            4,
  "webhook_count":                3
}

Two routes serve it:

  • GET /api/admin/orgs/{id}/usage — super-admin only, any org.
  • GET /api/orgs/{id}/usage — any member of the org (self-serve mirror).

The "effective" values resolve the team override against the platform default before returning, so the UI shows the real ceiling the gate would apply.

Webhook call quota — the separate axis ​

Inbound webhook deliveries have their own quota separate from the run launch counter (pkg/webhooks/store.go:Counter). It rejects the request before the launch gate fires — so a flood of "filtered" deliveries (label edits on a noisy MR) still counts toward the org's webhook budget, but never against the cost cap or run quota.

Reset semantics, audit and denial format match the run quota — only the quota dimension differs.

Memory quota — pointer ​

Memory + knowledge spaces have their own aggregate quota (MemoryQuotaBytes on the Org document) plus per-visibility sub-caps. The counter the write path enforces is keyed per team, so each team of the org carries the whole ceiling rather than a share of one. The launch gate does not evaluate it — memory writes go through a separate CAS check inside the memory store. See memory-and-knowledge.md for the full contract.

Changing the org override via PATCH /api/admin/orgs/{id} { "memory_quota_bytes": … } propagates into the enforced counter via SetTenantQuota on the cloud Mongo memory store (pkg/server/admin_orgs_routes.go:tenantMemoryQuotaSetter), once per team of the org — the field alone is not enough, the counter has to be told.

Prometheus metrics ​

Every denial / throttle event bumps a counter on the shared registry (pkg/cloud/metrics/metrics.go). No tenant label is ever attached — cardinality discipline; per-org accounting lives in the Mongo counters above.

MetricLabelsMeaning
iterion_launch_denied_totalreason (denial token, member_role_required included)The refusals of the admission gate that it renders on an HTTP response (writeLaunchDenial) — the rung it takes as well as the six limits; a refusal the WebSocket answer envelope or a daemon records on the socket, the card, the subscription or the schedule is not in this series. The same rung taken at a board / trigger mount answers the same envelope and is deliberately not counted here: a refused board write is not a refused launch. The two launch routes that sit behind such a mount — POST /api/v1/pipeline-board/tasks/{id}/launch and POST /api/v1/triggers/emit — refuse a caller below the rung at the mount, before the gate runs, so those refusals are not counted either: the series moves only when the gate itself refuses on an HTTP response; the alert below excludes the authorization reason
iterion_webhook_throttled_totalprovider, reason (rate_limited / quota_exceeded)Inbound deliveries throttled before processing
iterion_webhook_deliveries_totalprovider, statusEvery inbound delivery's terminal status
iterion_auth_logins_totalresult (success / invalid / locked / password_change_required / error)Login attempts
iterion_auth_password_resets_totalstep (requested / confirmed)Self-service reset flow
iterion_dlq_depth—Runs parked on the DLQ (the orphan / max-deliver bridge)
iterion_runs_orphan_recovered_total—The orphan sweeper's flips to failed_resumable
iterion_runs_usage_window_blocked_total—Runs stopped by an exhausted provider quota window
iterion_runs_retry_scheduled_total—Durable automatic retries armed for a provider reset
iterion_runs_retry_resumed_totalresult (enqueued / abandoned / failed)Retry-sweeper outcomes for due runs; enqueued includes the resumes a pull-forward probe caused, countable against iterion_runs_retry_probes_total
iterion_runs_retry_pending—Due-retry rows observed in the latest bounded sweep (sampled gauge); probe-pulled rows are due rows, so a parked fleet keeps a floor under it
iterion_runs_retry_sweeps_total—Retry-sweeper passes; flat at zero in cloud means the sweeper is not running, not merely idle
iterion_bot_override_shadows_newerslug1 while a platform bot override is strictly older than the bundle this deployment would otherwise serve — the override wins by design, so the newer baked bundle does not run until it is re-pushed or dropped (platform-bots.md). Sampled every five minutes — twelve readings inside the hour its alert waits — with every row rewritten per pass and the vanished slugs retired after those writes, so a dropped override loses its series rather than paging forever, while no scrape reads an empty inventory for one that is still shadowing
iterion_bot_shadow_check_unavailable—1 when a leg of that check could not be read (the catalog walk, or the platform rows), including a deployment with no bot root on disk at all. The per-slug gauges then HOLD their last values: a check that could not run must not publish the same zero as a clean inventory
iterion_bot_override_unorderable_versionslug1 when the override and the bundle below it cannot be ordered (Manifest.version is free-form, so a suffixed pin parses as nothing; and a manifest is optional on both sides, so either may declare no version), and no staleness verdict is made either way. Never the clean 0 of a row with nothing to report: the override still outranks what sits below it. Informational: no alert rule reads this series, because a page on a version iterion cannot order is one nobody can clear
iterion_runs_retry_probes_totalresult (pulled / still_blocked / stood_down)Pull-forward probes of a not-yet-due wait, by what the probe FOUND: pulled is the pod spent, still_blocked the preflight refusing again, stood_down a pull whose resume never reached the runner — pulled minus the other two found the window reopened, which is how a wasted pod is told from a useful one. A probe spends no attempt and no monthly run-quota unit, unless the due pass reaches its resume more than the marker's TTL (1 h) after the pull: that resume IS metered and the park it causes charges an attempt, up to max_attempts of each per run — while the probes themselves stay bounded by budget per provider wall, because the ledger belongs to the wall (scheduling.md)

The starter alert pack (charts/iterion/templates/prometheus-rule.yaml) fires:

  • IterionLaunchDeniesSpiking at sum(rate(iterion_launch_denied_total{reason!="member_role_required"}[10m])) > 0.5 — the authorization reason is excluded, so a viewer retrying a launch is not a quota page.
  • IterionWebhookThrottling at increase(iterion_webhook_throttled_total[1h]) > 50.
  • IterionDLQNotEmpty when iterion_dlq_depth > 0 for 10 minutes.
  • IterionRunnerHeartbeatErrors on increase(iterion_runner_heartbeat_errors_total[5m]) > 3.
  • IterionOrphanRunsRecovered on increase(iterion_runs_orphan_recovered_total[30m]) > 0.
  • IterionBotOverrideShadowsNewer at max by (slug) (iterion_bot_override_shadows_newer) > 0 for an hour — aggregated across replicas because each pod measures its OWN baked image against the shared platform rows, so an un-aggregated expression is one page per pod.
  • IterionBotShadowCheckUnavailable when max(iterion_bot_shadow_check_unavailable) == 1 for 30 minutes: while it fires, no override can be certified current.

The thresholds are deliberately conservative starting points — tune them per deployment.