Usage caps — stop below the provider's wall
An LLM subscription ("forfait") meters two rolling windows, five hours and seven days, and refuses every call once one is exhausted. iterion already survives that refusal: the run parks and a durable retry resumes it when the window reopens (scheduling.md). What it could not do was stop before the wall — and the wall is rarely where an operator wants to be, because the same subscription usually pays for their own interactive work. A fleet of bots that drives it to 100% takes the human down with it.
A usage cap is a percentage the operator chooses, enforced from the provider's own telemetry.
# The recommended posture. Two variables is the whole configuration.
export ITERION_USAGE_CAP_5H_PCT=85
export ITERION_USAGE_CAP_WEEK_PCT=75Nothing is capped by default: an unset cap leaves runs bounded only by the provider, which is the historical behaviour.
The two postures
The windows fail differently, so they default to different postures.
| Window | Default mode | What it does |
|---|---|---|
| five-hour | soft | Never interrupts work in flight; no NEW run starts |
| weekly | hard | Stops the run where it stands, and starts nothing new |
A five-hour window refills soon, so killing a half-finished run to save minutes of quota trades a lot for a little. A weekly window that runs out on a Tuesday is a dead week, and the run that would have finished is worth less than the four days of headroom it would have eaten.
Either posture ends a capped run the same way: failed_resumable, with a durable retry armed for the instant the window reopens. A capped run is not a lost run; it is a run that waits. The cap reuses the provider-refusal path wholesale rather than inventing a recovery of its own.
A window an operator clears by hand reopens with no notice, so the wait is not only passive: a bounded hourly probe pulls a parked run forward to let the usage preflight answer early, free of its attempt budget and of the monthly run quota while that resume follows its pull inside the marker's TTL (one hour). Past that hour the pull is nobody's probe any more: the resume is metered like any launch and the park it causes charges an attempt — up to max_attempts of each per run — while the probes stay bounded by the budget, because the budget belongs to the wall and not to the arming (scheduling.md — ITERION_RETRY_PROBE_FLOOR, ITERION_RETRY_PROBE_BUDGET, 0 to disarm). The direct path remains iterion remote runs resume --usage-blocked.
That wall is the unit of a usage park for the four readers that key on it: the run owner's notification, the operator's incident, the pause notice on the pull request a parked run gates and the probe budget. One provider window produces one notification and one incident however many times the run re-parks on it (notifications.md); the pull request hears the park that OPENED the wall and every wall the provider MOVES, and a park on a wall no probe ever pulled (one closer than the probe floor, or a deployment with ITERION_RETRY_PROBE_BUDGET=0) can still comment once per charged park — at most max_attempts of them, the behaviour that shipped before the wall existed.
The run-completion callback a launch may ask for (callback_url, pkg/notify) keys on the wall too: a parked run is terminal for polling but not done, so the park delivers nothing, and the run delivers once at settlement — resumed and finished, or its retry retired (the sweeper's abandonment fires the callback the executing lanes never will). A row from before the wall was persisted keeps the per-transition behaviour, like every reader above.
Configuration
| Variable | Values | Default |
|---|---|---|
ITERION_USAGE_CAP_5H_PCT | 0–100 (0/unset = no cap) | unset |
ITERION_USAGE_CAP_5H_MODE | off | soft | hard | soft |
ITERION_USAGE_CAP_WEEK_PCT | 0–100 (0/unset = no cap) | unset |
ITERION_USAGE_CAP_WEEK_MODE | off | soft | hard | hard |
ITERION_USAGE_CAP | off disarms both caps | unset |
ITERION_USAGE_CAP_TRUST_WINDOW | a Go duration (3h, 90m) — how long a stored dated reading is believed, see A reading is trusted for a bounded time | 3h |
ITERION_USAGE_CAP_REFUSAL_REST_MAX | a Go duration, or off — the ceiling of the escalating rest an account-level refusal earns, see A repeatedly-refused credential rests longer | 6h |
A malformed value refuses to start rather than falling back to no cap: every wrong answer here fails open, and a guard silently disabled by a typo is the failure the feature exists to prevent. That includes the trust window, and it holds under ITERION_USAGE_CAP=off too: the window also bounds the credential-skip evidence, which the kill switch does not disarm.
There is deliberately no per-run flag and no DSL field. The cap protects a credential and the deployment that owns it, not a run — a bot able to lift the guard would not be a guard. ITERION_USAGE_CAP=off is the escape hatch, and it belongs to whoever runs the deployment.
Changing the caps at runtime (no restart)
The env vars above are defaults. On a cloud deployment the two percentages are also a platform-scoped runtime-settings record (Mongo platform_settings, same tier as the platform LLM credentials), mutable through the super-admin API — so retuning a cap in production is one call, not a kubectl set env on two deployments plus a rolling restart:
iterion remote admin caps # record + env + EFFECTIVE + source
iterion remote admin caps set --five-hour 80 --week 70
iterion remote admin caps set --clear-week # back to the env defaultThe raw surface is GET/PUT /api/admin/settings/usage-caps (super-admin, the same guard as the platform LLM-credential routes). The PUT has merge semantics: a field present with a number sets that override, present with null clears it, absent leaves it untouched — a call naming one window can never silently clear the other. Values must be integers 0–100 (0 = no cap); anything else — including an unknown field name — is rejected 400 with the reason. Every update lands in the platform audit log (platform.settings.usage_caps.updated) with old value, new value and the caller.
Propagation bound: ≤ 30 seconds. Both enforcement points — the server's launch-time pre-flight and the runner's claim pre-flight + mid-run guard — resolve the effective policy through a TTL-cached lookup (usagecap.Resolver, 30s TTL), re-read per evaluation. One DB record is the single source of truth for every replica of both deployments, which ends the class of divergence where one deployment rolled with a new env value and the other did not. A run already in flight picks a tightened cap up at its next reading, within the same bound. The pod that served the update is coherent immediately (its cache is invalidated in the handler).
Semantics that stay put:
- Env fallback. No record (or a cleared field) → the env value applies. A deployment that never touches the API behaves exactly as its env vars say.
- Only the percentages are runtime-mutable. The soft/hard modes and the
ITERION_USAGE_CAPkill switch stay env-only: they encode the deployment's enforcement posture, and a posture change should be witnessed by a deploy. In particular the kill switch wins — withITERION_USAGE_CAP=off, a DB percentage stays inert, so a runtime write can never re-arm a guard the operator explicitly disarmed. - Verification without DB access.
/healthz(and/readyz) echo the EFFECTIVE policy plus ausage_cap_sourcemarker (env,dbordb+env) — curl it after a change and watch the new number appear within the propagation bound. This is also the FIRST diagnostic for "a scheduled bot posted nothing this morning": every LLM-bearing run failing onusage cap: <window> at N% ≥ M%while zero-LLM runs (collectors) pass is the cap's signature, and/healthznames the ceiling in one curl. Grep theusage cap:substring, NOTrate_limited: a workflow whose every path reaches a model is refused before its first node by the runner pre-flight and carries the bare reason (norate_limitedprefix, no node id), while a workflow with a model-free path is let through and — under a hard cap — stops mid-run withrate_limited (<backend>): usage cap: …. Measured on the 2026-08-31 Vigie outage, where a 70% DB record was the whole story — and where the morning's first signal, the 04:00 docs-refresh run, was the pre-flight shape. - Don't wait for the silent morning: wire the operator webhook. With
ITERION_ALERTS_WEBHOOK_URLset on the server deployment, every run that parksfailed_resumable(usage cap, provider window — with the armed retry's reset ETA) or fails hard produces ONE message on the webhook (Mattermost/Slack{"text": ...}shape), deduped across replicas and backed by a 2-minute reconciliation sweep, with a/runs/<id>deep link. The five silent Monday digests would have been five messages at 06:0x instead of a manual discovery hours later. (This is the cloudalert.OpsDispatcher; the same env var also feeds the in-process alert Manager for local runs.) - Settings reads fail toward the last-known value (env defaults before the first successful read), retried once per TTL window: a settings-store blip changes nothing abruptly in either direction.
Which windows a cap governs
The provider reports six windows. five_hour is the 5h cap; seven_day, seven_day_opus, seven_day_sonnet and seven_day_overage_included are all governed by the weekly cap — a run refused on the per-model weekly sub-limit is refused, whatever the all-models number says. overage is not capped here: it is metered money, not subscription quota, and --max-cost-usd is what bounds money.
Where the numbers come from
Claude Code emits a rate_limit_event on its stream-json output whenever the provider's usage numbers move, carrying {status, rateLimitType, utilization, resetsAt}. utilization is a fraction (0..1); resetsAt is Unix seconds. That is the only place a subscription's remaining headroom is observable from outside the provider — the metered API returns anthropic-ratelimit-* headers, but a CLI-driven session hides them, and GET /api/oauth/usage requires a user:profile scope that a claude setup-token credential does not carry.
Consequences worth knowing:
- The cap is claude_code-shaped today. Other backends have no equivalent telemetry surface; a run on
claworpiis not capped. - One event names one window. A
rate_limit_eventcarries a singlerateLimitType, emitted when that window's numbers move — so a session refreshes the windows the CLI happens to report, not all of them, and a run refused before its first call (the pre-flights below) refreshes nothing at all. - A reading expires at its own reset instant. Past it the window has rolled over and the number describes a window that no longer exists, so a stale reading stops blocking by itself — no sweeper.
- A reading is also trusted for a bounded time after it was observed — the next section.
- Recording is not enforcing. Readings are collected whether or not a cap is configured. They are the only input the credential-tier skips have (a fair-usage refusal, a rejected credential —
frequency/auth/spend, which no operator cap governs), so a deployment that never asked for a ceiling would otherwise send every run into the same wall. A run carries the observing guard as soon as there is a ledger to publish to; with no cap configured it blocks nothing.
A reading is trusted for a bounded time
The premise "a window's utilization cannot drop before its reset" is false for this provider: it has reset every window early, out of cycle. On 2026-09-04 the seven-day windows of the deployment's forfaits went to 0% at the provider while the ledger still held 93–99% readings taken hours earlier, each carrying a reset instant three to four days out. Every credential walk skipped both forfaits on them, and every claude_code run was refused at admission — including the revi/review merge gate of two PRs, which then read as "review in progress" forever. The lock was self-sustaining: the only writer of a fresh reading is a live session, and the refusal is what prevented one. Nothing could recover on its own before the recorded reset, four days later.
So a reading is authoritative only for ITERION_USAGE_CAP_TRUST_WINDOW (default 3h) after it was observed, whatever its reset instant says. Past that it is suggestive, not binding: the walk lets the credential through, the pre-flight admits the run, and the run's own session re-measures the window within one call. If the wall is really still there, that costs one call and one park — the ordinary wait, with the retry armed for the reset — which is the price of never locking a credential out through a reset the ledger cannot see. Readings with no reset instant (a relayed refusal, a dead credential) are not bounded by the trust window at all — it exists because a dated window can roll over early, which an account-level refusal cannot do — and keep their own 1h staleness bound instead, escalating with the streak (next section).
The same bound governs both consumers of the ledger — the cap pre-flights and the credential-skip evidence — because both read the same readings and must forget a pre-reset one at the same moment.
The launch walk asks the provider instead of guessing. When a forfait's stored readings are stale-but-suggestive — past the trust window, window not rolled over, and saying "closed" when they were taken — the cloud publisher re-measures the credential at Anthropic's OAuth usage endpoint with the forfait's own token (pkg/backend/forfait.FetchWindows), records every window it reports under the same key the runner meters, and decides on that: the forfait is skipped when the wall is real and granted when the window reset early, with no pod spent either way. Best effort and bounded (5s): a credential the endpoint refuses (a claude setup-token lacks the user:profile scope and gets 403), a network error or a malformed body each cost one Info line and fall back to trusting the credential — the trust-window behaviour. Fresh readings and low stale readings never trigger a round trip.
A repeatedly-refused credential rests longer
Three refusals carry no reset instant, because the provider relays them as text rather than as window telemetry: auth (the credential itself was rejected), frequency (a fair-usage limit on the request rate) and spend (the account's own money ceiling). Nothing but the 1h staleness bound ever expires them — so a credential the provider has frozen for days was asked again every hour, forever: one pod and one parked run each time, on a condition only a human can end.
The rest now escalates with the streak. The ledger counts how many times in a row a credential was refused on a window (the store does it, not the caller: one pod sees one refusal, and several pods write the same ledger), and the bound doubles per refusal — 1h, 2h, 4h — capped at ITERION_USAGE_CAP_REFUSAL_REST_MAX (default 6h, so a frozen account is probed four times a day instead of twenty-four). Set it to off for the old flat 1h re-probe.
It stays a rest, not a lock, on three counts: the ceiling is bounded, so a re-probe always happens; the streak resets to zero the moment the credential serves a call; and rotating the credential opens a fresh meter key anyway (see Rotating a credential resets its meter). To cut the wait short after fixing an account in place — same token, same fingerprint — clear its readings by hand, below.
Forgetting a credential's readings by hand
An operator who knows a reset happened does not have to wait out the trust window, and should not raise the global cap to unstick one fingerprint (that lifts the guard for every tenant and every bot). The scalpel clears one credential's readings, under every key it was metered with, and leaves the caps alone:
iterion remote admin usage-readings clear e4ecd2283afb305f
# → {"fingerprint":"e4ecd2283afb305f","deleted":2}The fingerprint is the credential's audit identity — shown on the key and connection views, and in the server's SKIPPED … fp= / AT ITS CEILING lines. Raw surface: DELETE /api/admin/usage-readings/{fingerprint} (super-admin). Every call lands in the platform audit log (platform.usage_readings.cleared, with the count). Afterwards the credential reads "nothing learned yet": the next run is admitted and re-measures.
What it looks like when it fires
- a
usage_capevent on the run's timeline (window,percent,cap,mode,stopped,resets_at) — the only thing that distinguishes "the provider refused us" from "we stopped ourselves"; - a warn line in
run.log:usage cap: seven_day window at 76% ≥ 75% (week, hard), resets 2026-08-18T21:00:00Z; - the run's own error, carrying the same sentence;
- then the ordinary wait:
run_retry_scheduledwithretry_afterat the window's reopening, andrun_auto_resumedwhen it fires.
Two shapes, three situations — and the number is what tells them apart
The warn line comes in two shapes, and the shape alone does not say who refused. Reading it as if it did is what wastes the time this page exists to save.
usage cap: provider rejected on the seven_day window (week cap 85%, hard), resets …The provider refused, and said so without a number. evaluate picks this wording only when the reading's status is rejected AND it carries no utilization figure. The (week cap 85%, hard) in parentheses names the policy in force, not the reason — which is what makes it easy to misread. Touching the cap changes nothing: the call never got as far as the cap.
usage cap: seven_day window at 76% ≥ 75% (week, hard), resets …This one is ambiguous, and the percentage resolves it.
- Below 100% — we stopped ourselves. The provider is still serving and iterion refused because its own telemetry crossed the operator's percentage. Raising the cap (
iterion remote admin caps set --week …) lets work through immediately. - At or above 100% — the provider refused, in the same words. A reading can be rejected and still carry a number, and it then renders in this shape; the Anthropic forfait probe marks every window at ≥100% rejected while keeping the figure. A rejected reading is blocked at any cap — the guard reads
if !rejected && pct < cap, so even a cap of 100% refuses it. Raise nothing; the reset instant is the only lever.
So the rule to carry is not "which sentence is it" but "is the reading rejected", and the two things that answer it are the missing number and the percentage at or above 100%.
Two consequences worth having in mind before touching anything:
- A refusal can appear while the provider's own dashboard shows headroom on the other window. The weekly and five-hour walls are independent, and a run refused on one says nothing about the other.
iterion remote admin usage-readings clearwill happily forget a refusal too, and it buys nothing: the next run is admitted and then refused at the call instead of at admission. That command earns its keep on the opposite shape — a dated reading trusted past a reset the ledger could not see (see A reading is trusted for a bounded time). Reach for it when the provider's dashboard disagrees with iterion, not when the provider itself is saying no.
Measured on 2026-09-08: fifteen review runs refused over an hour on a deployment whose caps had just been lowered — the caps were not the cause, and every one of the fifteen resumed and delivered by itself once the window reopened.
What a cap does NOT stop
A run is only refused in advance when it could not possibly avoid spending. The cap governs a model subscription, and it blocks at launch only if EVERY path from the workflow's entry to a terminal passes through something that can call a model — an agent, a judge, an llm router, a model-answered human node, an agent recovery rung, a subbot, a supervisor.
If any model-free path exists, the run starts; under a HARD cap the mid-run guard stops it at the actual call on a claude_code route, while a soft cap lets it finish (see below). That costs a pod and a clone in the worst case, and it is the price of not refusing work that would never have been billed.
The distinction is not cosmetic. A zero-LLM run is often the half of a bot that gathers — and gathered material is not recoverable by retrying later. Vigie's collect mode polls feeds into a queue; a feed serves a short window and does not remember what nobody fetched, so every refused collect is material permanently gone, while the digest half it feeds waits on a queue that stays empty. Between 2026-08-17 and 2026-08-18 that is exactly what happened.
Why "every path" and not "contains a model node". A two-mode bot carries both halves in ONE .bot: Vigie's collect polls feeds with tool nodes, digest synthesises with an agent, and a router picks between them from a field the plan node produces at RUNTIME — not from a var, so no launch-time analysis can predict it. A predicate asking merely "does this graph contain an agent?" answers yes for both halves and refuses the collect half too. That is exactly the defect that silenced the production veille, and shipping the weaker predicate first did not fix it.
The predicate is ir.Workflow.AlwaysReachesLLM, treating a model-calling node as a wall: an execution that reaches a terminal around every wall proves a model-free path exists — a fan-out's branches all run, so one model branch walls the fan-out. It stays conservative in the direction that matters — an unwalkable graph, a missing entry, a dangling edge or a supervisor all answer "true", keeping today's refusal rather than opening the gate. (UsesLLM still exists for the plain "does this graph contain one?" question; the two deliberately disagree on a two-mode bot, which is the whole point.)
Both pre-flights apply it: the cloud runner's (which has the compiled workflow in hand) and the local launch path's (which compiles only when the cap is blocking, so the common case pays nothing). The mid-run guard stays armed in both cases, so a workflow that turns out to spend anyway is still stopped at the call — under a HARD cap, on a claude_code route: the guard reads usage readings, and only claude_code sessions report them (pi only on a provider refusal, claw never). A soft cap stops nothing in flight, nor does any cap on another backend: a two-mode run admitted for its model-free path that takes its model path spends that credential to the end of the run. That is the price of never refusing the collect half, accepted on purpose; the per-route rule below (a reachable soft-capped route parks the run) applies only to runs with no model-free path.
And only when it could spend the wire the cap meters. The readings come from the claude_code delegate's session telemetry and nowhere else, keyed by the credential each session spent. A run whose every route is pinned off that wire — both LLM nodes on claw + openai/…, a codex bot — cannot spend the capped subscription, and parking it for the anthropic weekly reset strands it for nothing: a fully pinned two-node rite froze for five days that way while its single-node sibling on the identical pin ran (#668). The cloud runner's pre-flight asks model.AnthropicWireReachable under the launch's own overrides, and lets the run through when the answer is no. Every uncertainty answers "reachable" and keeps the guard armed: an empty, auto or claude_code backend, claw/pi with a provider it cannot resolve, any anthropic/zai hint on any backend, a model-answering node with no LLMFields.
Primary routes only. A fallbacks: route or a run-level --fallback stage onto the wire does not arm the pre-flight: the primary can carry the whole run without ever touching the wire, and the rescue route fires only on a failure the mid-run guard and the delegate's own usage-window classification already refuse at dispatch. Refusing such a run in advance would park work that could not possibly spend the capped subscription — the one thing the pre-flight promises not to do. The credential the rescue route needs is still sealed into the run: the wants derivation widens on the chain, it is only this guard that ignores it.
Per route, and per path (cloud runner). Each primary anthropic-wire route is judged on the credential IT spends, not on the run's default — a run can hold a Claude forfait on the wire beside a z.ai key a shared tier sealed for its GLM routes, and a closed forfait says nothing about those routes. The route is read the way the executor will run it (model.AnthropicWireRoutes: the node's backend under the launch's overrides, the FIRST element of its provider chain), and its credential is asked of the delegate itself: a claude_code route through delegate.AnthropicRouteSource — the composition the session will run, under the hint model.RouteProviderHint hands it (a GLM id goes to z.ai) — so the pre-flight reads the ledger the session will write; claw and pi routes as the spend ledger books them; kimi and grok routes not at all (their own config pays), an opencode route on the pod's ambient meter. A route the walk cannot read (a {{vars.…}} backend, hint or model) keeps the run's default credential; a route the delegate refuses before spawning (a facade hint with no key) spends nothing and is not the cap's to park; a store read that fails is headroom for that credential alone.
The run then parks on the paths it can take (ir.Workflow.AlwaysReachesVia, over those walls and over THIS run's own transitions — a fan-out's branches all run, so a capped branch is on every execution):
- a hard-capped claude_code route parks the run only when every execution from the entry to a terminal crosses a capped route; one some path avoids does not — a hard cap stops a call in flight, so the mid-run guard stops the run there if it goes that way;
- a soft-capped route parks the run as soon as the run may reach it at all (
ir.Workflow.CanReachVia): a soft cap refuses new work and stops nothing in flight, so a run let through to find out would spend it uninterrupted; - so does a hard-capped route on any backend but claude_code — claw, pi, opencode, or one resolved at dispatch: the mid-run guard reads the usage readings only claude_code sessions report, so nothing would stop that call in flight;
- a supervisor is a route every execution takes, and nothing stops it in flight: it calls its model in process for the whole run (claw, no readings). Its model is the one its evaluator resolves on the runner — its pin, else
ITERION_DEFAULT_SUPERVISOR_MODEL, else the provider its watched nodes run on, under the run's credentials — and the credential that model spends parks the run when capped, soft or hard, whatever the nodes spend. Supervisors the run will not spawn (--supervisors off,ITERION_SUPERVISORS=0) are not judged; one that resolves no model spends nothing; and neither does one whose every WATCHED node this run's launch excluded — a coordinator evaluates only while a watched node is active (supervise.Coordinator.armed), so its model is never called. That last reading is what lets a bi-model bot give each family its own supervisor:review-pr'spacerwatches the claude reviewer andpacer_gptthe gpt one, and a mono run is metered for the family it takes alone. An EMPTYwatches:is the whole run and is always judged.
The paths are this run's, not the graph's. A launch that pins a family has already excluded branches the graph still carries, and a capped route on one of them can spend nothing: review-pr launched with --var mono_family=gpt takes topology -> reviewer_gpt, never the unconditional topology -> reviewer_claude beside it. So both walks read this run's topology through ir.Workflow.LaunchVarEdgeFilter, over the run's effective vars — the workflow's declared defaults under the launch's own values, the two layers resolveVars stacks. An edge is dropped only where the pre-flight answers the way the engine will at the crossing: its when reads nothing but vars.… and evaluates false, or it is the fallback a matching vars guard pre-empts (the engine takes a guardless edge only when no guard matched). It is sound because the value is the same at both readings — a run's vars are built once from its inputs and never written again, and a var whose text the environment answers is left undecided — and because the same expression decides at the crossing.
The vars decide, not the message's shape. A launch carries them; a REDELIVERED launch still does (the runner stamps Resume on it when the run already has resumable state, which this very park creates); a resume PUBLICATION carries none, and the pre-flight then reads the run document's own inputs, which nothing rewrites after the launch persisted them. That is what makes the retry honour the instant it was armed for: a woken resume walks the same topology the launch did. With neither — no store, or a document that cannot be read — the walk keeps the whole-graph answer and says so in the log.
Everything a launch does not settle keeps the whole-graph answer: a predicate reading a node's output or mixing one in, the Condition field form, a var nothing declares or supplies, a value the run may read remapped under its workspace, a fan-out or llm router's branches (taken whatever their predicate says), a round-robin's cycled edges (it reads no predicate at all), a var whose text the environment answers (the settings overlay is repointable while a run waits, so two readings may differ), and the exit beside an iteration back-edge — a loop or foreach edge whose when holds is still declined at its cap or its fuel, and the exit is the crossing then. The pre-flight that decides whether a cap applies at all (AlwaysReachesLLM) is deliberately NOT narrowed: it refuses rather than admits, so a launch excluding a model-free branch must not make it refuse.
A node whose launch excludes every exit is a node's own end, not a cap wall. Both walks consume the same annotation in ir.Workflow.walkable: CanReachVia crosses no excluded exit, so a soft cap behind the node parks nothing; AlwaysReachesVia counts the node's death as an end without a wall, so a hard cap beside it cannot take the blame (#1828, #2468). The node itself still executes, so a cap ON it counts before its death.
A bounded-iteration fallback (loop or foreach) does not supply an exit once spent, even though C012 accepts it as a fallback. The walk still reaches the iteration body while it has budget; after exhaustion the node's own end keeps the prune valid. This prevents a retained back-edge from manufacturing an endless cycle, discarding the entire prune and restoring an impossible capped route. Unknown guards and malformed reachable targets stay conservative. A prune with neither a reachable terminal nor a node's own end is still discarded: an unannotated cycle proves no path around a wall.
The retry is armed for the earliest reopening after which the run could start: on alternative branches the first hard-capped route to reopen is enough, in sequence the run needs the last. The decision does not depend on the order the routes were read in.
A bot whose family a launch pins — the deployment half
The prune only stops a cap from parking a run for routes the run cannot take; it changes nothing about routes it does take. So a bot whose launch pins a family owes the OTHER routes of that family too — not only its reviewers. review-pr carries four per family: three on edges the same launch-vars prune decides, and the pacer supervisor by its WATCH set — a supervisor has no edge, and a coordinator arms only while one of its watched nodes is active:
- the reviewer —
reviewer_gpton claw, behindvars.mono_family == 'gpt', pre-empting the unconditional claude edge; - its glance twin —
reviewer_gpt_glance, behind that guard ANDed withvars.review_tier == 'glance': a route of its own, which is why the pre-flight's witness names all three claude NODES it prunes (reviewer_claude,reviewer_claude_glance,converge) and not two; - the merge step —
converge_gpt, claw +openai/gpt-6-sol, behind the same guard. On every path that reviews anything, which is why its claude twin used to park a mono-GPT run under a SOFT cap (a HARD one parks nothing there: the bot's empty-diff short-circuit reachesdonewithout it, and the mid-run guard stops the call if the run goes that way); - the pacer supervisor —
pacer_gpt,openai/gpt-6-sol, watchingreviewer_gptalone, so the claudepacerbeside it arms on no execution of this run and is not metered.
A mono-GPT review therefore starts under a spent Anthropic window, soft cap or hard, with no dial set at all (TestUsageCapPreflight_MonoGPTOnTheReviewPrBot, pkg/runner). The dials stay the escape hatch in both directions: pointing the merge twin's three (ITERION_VIBE_{BACKEND,MODEL,PROVIDER}_EMIT_GPT) back at claude_code + claude-opus-5-5 + anthropic parks the run again, which is the operator's explicit choice honoured rather than replaced. What this verdict reads is the backend and the model — a claw route is metered on the credential its model resolves — so a leftover anthropic hint on a claw node does not park the run; clearing it is the node's own instruction, for the credential walk (the bot's own dials).
Cloud
Every pod sees only its own session, so readings are shared through the usage_windows collection (one document per credential and window, newest wins). A claimed run consults it before cloning a repo or starting a container and parks for free when there is no headroom — otherwise each pod would rediscover the ceiling by spending against it.
The ledger is keyed per credential: a tenant that brought its own subscription is never blocked by what another tenant spent, and runs falling back to the deployment's own credential share one meter, which is correct — they really are one subscription.
Rotating a credential resets its meter — on purpose
The key names the CREDENTIAL, not the slot it sits in:
claude_code|tenant:<id> # legacy, still valid, expires in place
claude_code|tenant:<id>|fp:aaaa1111 # the meter of ONE credentialfp: is the audit fingerprint of the credential the run actually spends, stamped when a human connects it and preserved across the automatic token refreshes (which rewrite the tokens of the same subscription). Without it, posting a fresh token over an exhausted one inherited the old account's reading — legitimately fresh until its own reset instant, up to seven days out — and parked every run of a credential with a full week available. That happened.
So: connect a new credential and the pre-flight finds nothing, fails open, and republishes the new account's real readings at the first call. The old readings expire in place under their orphaned key (the collection has a 14-day TTL), and nothing needs migrating. This applies to every tier — a tenant's forfait, a pool donor's lent subscription, and the deployment's own platform forfait, where the meter is fleet-wide.
Two boundaries worth knowing:
- Re-connecting the SAME Anthropic subscription also opens a fresh meter. A
credentials.jsoncarries no account id, so iterion cannot tell "the same subscription again" from "a different one" (Codex'sauth.jsondoes carryaccount_id, and is metered per account). The reflex of re-pasting credentials when a token looks broken therefore forgets what was measured. It fails open — one run rediscovers the wall and republishes it — and the mid-run guard is the backstop. - A local CLI/studio run is metered per machine, not per credential. Rotating your own credential inside a long-lived
iterion studioprocess keeps reading the replaced account's window until it resets; restart the process to clear it.
The pre-flight fails open on every uncertainty (no ledger, an unreadable ledger, nothing measured yet, a rolled-over reading). A cap exists to protect a subscription from a fleet, not to strand the fleet on a bookkeeping outage; the in-run guard still stands behind it, so failing open costs one call.
A local CLI or studio run keeps the same policy on a process-local ledger, and a second run in the same process starts already knowing what the first one measured. There the launch is refused outright (429, with the reason and the reopening instant) rather than parked: the operator is present, so an immediate answer beats a queued one.
Emergency brake
The cap governs iterion. To stop everything on a cloud deployment, including runs already claimed, freeze the runner instead — queued work piles up in NATS and resumes intact on unfreeze:
kubectl -n iterion annotate scaledobject iterion-runner \
autoscaling.keda.sh/paused-replicas="0" --overwrite # freeze
kubectl -n iterion annotate scaledobject iterion-runner \
autoscaling.keda.sh/paused-replicas- # thawIn-flight runs survive the freeze: a scaled-down pod SIGTERMs into the lame-duck drain (ErrRunInterrupted → failed_resumable, auto-resumed when capacity returns), and even a SIGKILL leaves the run to the orphan sweeper, which flips it to failed_resumable within minutes. Cancel first only when you do NOT want the run to come back after the thaw — a cancel is terminal until an explicit resume.
