AI usage accounting, guardrails and metering
How every AI call is accounted, the ledger, spend caps, and what happens when a guardrail trips.
Every model call in ErmisAI passes through the same three-part path: an admission check before the call, a telemetry and accounting middleware around it, and a durable ai_usage_ledger row after it. Overage, if a tenant has it enabled, is derived from that ledger and pushed to Polar through an outbox.
This page covers the accounting side. For provider modes, stages, model resolution and prompts, see AI runtime.
All paths are relative to the app repository root (the directory is named hermesai; the package is ermisai).
What is billable
Three surfaces charge a tenant's monthly AI capacity. Nothing else does.
| Surface | Call site | billingScope | billable |
|---|---|---|---|
tenant_story_chat | src/app/api/stories/[storyId]/chat/route.ts:593 | tenant | true |
tenant_story_completion | src/app/api/stories/[storyId]/completion/route.ts:266 | tenant | true |
tenant_draft_compose | src/app/api/stories/[storyId]/draft/compose/route.ts:147 | tenant | true |
pipeline_cluster | src/lib/services/rss-aggregation.ts:2553 | platform | false |
pipeline_synthesis | src/lib/services/rss-aggregation.ts:2959 | platform | false |
unscoped | — (default when no invocation context is set) | — | false |
The surface list is the pgEnum ai_surface (src/lib/ai/invocation-context.ts:7-14, src/lib/db/schema.ts:120).
The RSS ingestion pipeline — clustering and synthesis — is the dominant AI spend on the platform, and it never touches a tenant envelope. Both stages run with tenantScopeId: 'platform:shared' and billingScope: 'platform'. They do count toward the platform-wide spend cap. Any statement of the form "every AI call accrues against your capacity" is wrong.
billable_cost_cents_usd is set to the resolved cost only when billable && billingScope === 'tenant'; otherwise it is written as 0 (src/lib/ai/usage-accounting.ts:525-528). That column is the sole input to envelope enforcement, overage metering, and the platform spend cap.
The invocation context
Every call site wraps its model call in runWithAiInvocationContext(...) (src/lib/ai/invocation-context.ts:60-78), an AsyncLocalStorage store carrying invocationId, tenantScopeId, userId, actorType, billingScope, billable, surface, stage, storyId, chatId, clusterId, workflowMode and routeKey.
The guardrail and the provider middleware both decide "is this tenant spend?" through one shared predicate, isBillableTenantInvocation (src/lib/ai/invocation-context.ts:49-57), so the reservation taken at admission and the release taken at the terminal event can never disagree.
A model call with no context still runs. The middleware emits an ai_model_invocation_warning carrying the literal warning code unscoped_invocation (src/lib/ai/providers.ts:542-548, :733-739), and the ledger row is recorded with surface: 'unscoped'. The admin panel surfaces the count as the Unscoped warnings metric card.
Admission: assertAiInvocationAllowed
src/lib/ai/usage-guardrails.ts:400-627. Runs before the model call, in exactly this order.
Operator kill switches (:413-439). aiPaused throws AiPausedError; otherwise the platform daily/monthly spend cap is evaluated and can throw AiPlatformSpendCapReachedError. Both write a blocked ledger row first.
These two apply to every invocation, including non-billable platform pipeline runs, and they apply even when aiGuardrailsMode is disabled. There is no observe/shadow variant — they always hard-block. Pipeline callers degrade to their deterministic fallbacks when this throws.
Mode and scope short-circuit (:441-446). If aiGuardrailsMode === 'disabled', or there is no tenantScopeId, or there is no userId, admission returns { wouldBlock: false } and nothing below runs.
Load policy and period snapshot (:448-459). getTenantAiUsagePolicy(tenantScopeId) resolves the plan limits and billing window; getTenantAiBillingPeriodSnapshot aggregates the ledger over that window.
Reserve in-flight spend (:466-483). For billable tenant invocations only, a fixed cost estimate is added to a Redis counter (INCRBY) before the projected total is read, so two concurrent admissions observe each other rather than both passing the same pre-reserve snapshot.
Default 25 USD cents, TTL 10 minutes, tunable via ERMIS_AI_INFLIGHT_RESERVE_CENTS (0 disables it) — src/lib/ai/usage-guardrails.ts:195-220. The TTL is deliberately longer than the 300 s maximum invocation duration so a reservation leaked by a mid-stream disconnect self-heals.
Compute projected spend (:490-492):
projectedSpendCents = round((summary.billableCostCentsUsd + inFlightReservedCents) * usdToEurRate)Envelope violations, first match wins (:494-513):
subscriptionBlocksAiAccess(policy.status)→AiSubscriptionBillingBlockedError!policy.envelopeConfigured→AiEnvelopeNotConfiguredErroraiMonthlyHardLimitCents !== null && projected >= hardLimit→AiMonthlyHardLimitReachedError!aiOverageEnabled && projected >= included && included > 0→AiOverageDisabledError
Rate and concurrency limits (:515-565), in order: tenant sliding-window requests per minute (ai:tenant:rpm), user sliding-window requests per 5 hours (ai:user:rp5h), user spend counter per 5 hours, and — for operation === 'stream' only — the concurrent-stream counter.
Decide. In enforce (:574-602): release the reservation this call made, write a blocked ledger row, roll back the stream slot, throw. In observe (:604-626): return { wouldBlock: true, mode, code } and let the call proceed.
Per-plan limits
Hardcoded per billing plan in src/lib/ai/usage-policy.ts:27-83. These are not configurable per tenant.
| Plan | Tenant req/min | Concurrent streams | User req / 5 h | User spend ¢ / 5 h |
|---|---|---|---|---|
individual_free | 20 | 1 | 120 | 400 |
individual_plus | 60 | 3 | 300 | 2 500 |
individual_pro | 120 | 8 | 800 | 12 000 |
business_plus | 60 | 3 | 300 | 2 500 |
business_pro | 120 | 8 | 800 | 12 000 |
enterprise | 240 | 20 | 2 000 | 50 000 |
internal_unlimited | 1 000 | 100 | 100 000 | 1 000 000 000 |
The rest of the policy is derived from the subscription (usage-policy.ts:147-200):
includedCentsMonthly = max(subscription.costEnvelopePerMonthCents, 0)— already standing-aware and override-aware from the billing DTO.aiMonthlyHardLimitCents = subscription.aiMonthlyHardLimitCents ?? (aiOverageEnabled ? null : includedCentsMonthly). With overage off — the default for every plan — the envelope is the hard limit.envelopeConfiguredis false only when the envelope is 0 and there is no explicit hard limit and overage is off. That is the enterprise "operator has not set an override yet" case.internal_unlimitedshort-circuits toaiOverageEnabled: true,aiMonthlyHardLimitCents: null, and an envelope floored at 1 000 000 000 cents (:159-174).
The billing window is recomputed, not read
resolveEffectiveBillingPeriod(currentPeriodEndIso, now) (usage-policy.ts:106-145) does not trust the stored current_period_end. It treats the anchor's day-of-month as the billing anniversary and walks in clamped calendar months to the single window where start <= now < end. An unparseable anchor falls back to a trailing 30 days.
This exists because the stored value is routinely outside the current window: free tenants get a one-shot now + 30 days bootstrap that no Polar webhook ever advances, paid tenants can miss a renewal webhook, and annual plans store an end up to a year ahead. Reading it literally put all new usage outside the window and silently disabled the envelope.
Guardrail errors
All defined in src/lib/ai/usage-errors.ts. Messages are hardcoded English and are not routed through next-intl, so a newsroom on a non-English locale sees the raw string.
| Code | HTTP | Message |
|---|---|---|
ai_usage_limit_reached | 429 | AI usage limit reached for the current window. |
ai_user_window_limit_reached | 429 | AI usage limit reached for the current user window. |
ai_concurrent_stream_limit_reached | 429 | Too many concurrent AI streams are active for this tenant. |
ai_platform_spend_cap_reached | 429 | The platform-wide daily / monthly AI spend limit has been reached. Please try again later. |
ai_overage_disabled | 402 | AI usage is above the included allowance and overage is disabled. |
ai_monthly_hard_limit_reached | 402 | AI monthly hard limit reached for the current billing period. |
ai_envelope_not_configured | 402 | AI capacity has not been configured for this workspace yet. Contact support to activate your contracted monthly envelope. |
ai_subscription_billing_blocked | 402 | AI usage is blocked because the subscription billing state is not active. |
ai_paused | 503 | AI features are temporarily paused. Please try again later. |
The user-spend variant of ai_user_window_limit_reached carries a different message on the same code: "AI usage spend limit reached for the current user window." (usage-guardrails.ts:543-547).
The three guardrail modes
aiGuardrailsMode is persisted admin runtime config, seeded from ERMIS_AI_GUARDRAILS_MODE (default enforce).
| Mode | Per-tenant checks | On violation | blocked ledger row |
|---|---|---|---|
enforce | run | throws | written |
observe | run | call proceeds; returns wouldBlock: true | not written |
disabled | skipped entirely | — | — |
In observe mode nothing is rejected and no blocked row is written — only enforce calls recordBlockedAiInvocation (usage-guardrails.ts:585-595). If you are measuring would-block rates in observe mode, the ledger is not where they land.
Observe mode also deliberately does not release the in-flight reservation or the stream slot: the provider middleware owns the single release at the terminal event. For a stream-limit violation the slot is force-reacquired with no limit (:604-620) so the counters stay symmetric with the middleware's unconditional decrement.
A disabled mode resolved inside a Vercel deployment emits exactly one loud stderr warning at config load (src/lib/platform/ai-runtime-config.ts:288-316). It is a warning, not an alert. Monthly limits, overage gating, standing blocks and every rate limit are bypassed.
The ledger
Table ai_usage_ledger, src/lib/db/schema.ts:785-834. One row per AI invocation.
| Group | Columns |
|---|---|
| Identity | id (client-supplied PK), invocation_id, request_id, generation_id |
| Attribution | tenant_scope_id, user_id, actor_type, billing_scope, billable, surface, stage, operation, route_key, workflow_mode, story_id, chat_id, cluster_id |
| Model | provider_mode, provider_id, model_id |
| Outcome | status (completed | failed | blocked | aborted), finish_reason, provider_status_code, error_code, duration_ms |
| Tokens | input_tokens, output_tokens, total_tokens, cached_input_tokens, cache_write_tokens, reasoning_tokens |
| Cost | actual_cost_cents_usd, estimated_cost_cents_usd, billable_cost_cents_usd, cost_source (actual | estimated | none) |
| Timestamps | created_at, finished_at |
Three indexes (schema.ts:828-832): (tenant_scope_id, finished_at), (user_id, finished_at), and (finished_at) alone — the last one exists because platform-wide aggregates filter on finished_at with an unconstrained leading column, which the composite indexes cannot serve.
Writes are gated on aiUsageLedgerEnabled in the runtime config. recordBlockedAiInvocation additionally returns null when there is no invocation context (usage-accounting.ts:537-556), so a blocked unscoped call produces no row.
Cost resolution
resolveActualOrEstimatedCost (src/lib/ai/usage-accounting.ts:415-459), in order:
- Gateway-reported actual USD cost from provider metadata →
cost_source: 'actual'. - Otherwise the estimate from the hand-maintained price cards →
cost_source: 'estimated', but only when it is greater than zero. - Otherwise zero →
cost_source: 'none'.
src/lib/ai/provider-pricing.ts carries price cards for 18 model ids. calculateEstimatedCostCents returns 0 when no card matches (provider-pricing.ts:165-170). A model that is not in the table and whose provider does not report an actual cost accrues zero spend: it does not consume the envelope, does not trigger the hard limit, and meters nothing. Adding a model to the catalog without adding a price card silently makes it free.
The estimator peels cache-read and cache-write tokens off the provider-reported input total before charging the base input rate, because the reported total is inclusive of both (provider-pricing.ts:175-177).
Durability: why billable writes block the response
persistAiUsageLedgerEntryWithDurability (src/lib/ai/providers.ts:452-514) splits on isBillableTenantInvocation:
- Non-billable — the ledger write is scheduled post-response via the accounting task scheduler.
- Billable tenant — the row is built once, in request context (capturing a stable id and
finishedAt), then inserted inline and awaited within the request or stream lifetime.
The reason is stated in the source: serverless can terminate a post-response task when the response ends, and this is the spend that drives the envelope, the hard limit, and Polar metering. Losing it is worse than adding latency. A failed inline write is logged loudly but never fails the user's generation — the content has already been produced.
On insert failure the already-built row (same id) is pushed to the Redis retry queue by enqueueFailedAiLedgerEntry, and the middleware emits an ai_model_invocation_warning with a durable_accounting_failed:* warning code.
The in-flight reservation is released in the finally of that path, through a synchronous once-claim (claimAiInFlightReservationRelease, invocation-context.ts:92-101) shared with the compose cache-hit path, so two terminal events cannot both decrement and consume a different call's reserved cents.
The ledger retry queue
src/lib/ai/ledger-retry-queue.ts. Upstash Redis lists — deliberately a different plane from the failing dependency, because putting the retry queue in the same Postgres that just rejected the write provides no resilience.
| Key | Purpose |
|---|---|
ermis:ai:ledger:retry:queue | FIFO queue of failed rows |
ermis:ai:ledger:retry:processing | claim list; reclaimed by the next run if a redrive crashes mid-flight |
ermis:ai:ledger:retry:dlq | entries past MAX_ATTEMPTS |
MAX_ATTEMPTS = 10, default redrive batch 100 (:23-24). Claims use LMOVE rather than RPOP so a crash after the claim leaves the entry recoverable. The contract is at-least-once, which is safe because the payload carries the stable client-supplied id: a re-insert is a primary-key no-op, never a second counted row.
purgeQueuedLedgerEntriesForTenantScope (:288) scrubs all three lists during GDPR erasure (src/lib/db/data-erasure-repository.ts:279), so a redrive cannot resurrect deleted usage.
Without Upstash configured, enqueueFailedAiLedgerEntry writes a stderr line and returns false — the row is gone. Redis is not optional for correct accounting in production.
Metering overage to Polar
After a committed billable ledger row, persistBuiltAiUsageLedgerEntry (usage-accounting.ts:621-641) calls enqueueAiMeteringForLedgerRow when aiMeteringEnabled is set. The call is isolated in a try/catch that logs and swallows: the ledger row is already committed, metering is delta-based and reconcilable from the ledger, and a post-commit throw would make the durable wrapper re-enqueue an already-persisted row.
src/lib/billing/ai-metering.ts:35-143. A row is only considered when it is billable, billingScope === 'tenant', status === 'completed', has a tenant scope, and has billableCostCentsUsd > 0.
Inside one transaction holding pg_advisory_xact_lock(hashtext(tenantScopeId)):
- Sum cumulative billable USD cents for the billing window, with a strict
(finishedAt, id)upper bound so two same-millisecond completions cannot both count each other. - Convert to EUR with
resolveUsdToEurRate()— the same rate the admission guardrail uses, so enforcement and billing agree on where the envelope ends. targetOverage = max(cumulativeSpendEur - includedCentsMonthly, 0).delta = max(targetOverage - alreadyQueuedOverage, 0). Zero or less: stop.- If
policy.aiOverageEnabledis false: stop and enqueue nothing. - Insert one row into
ai_usage_metering_outboxwithexternalEventId = ledgerRowId.
The advisory lock is transaction-scoped (required on a transaction-mode pooler) and auto-releases at commit. The threshold notification runs after the transaction so the lock is never held across notification I/O.
Overage is opt-in per tenant. ai_overage_enabled defaults to false and the only write path is PUT /api/admin/billing/ai-controls (super_admin only). With it off, spend past the envelope is never sent to Polar — the excess stays unbilled by design, because concurrent admitted calls can land past the envelope during the reservation race window and nothing should charge for that. The tenant is blocked, not billed.
The outbox and its drain
Table ai_usage_metering_outbox (src/lib/db/schema.ts:836-859): ledger_id (FK, cascade), tenant_scope_id, external_event_id (unique index), meter_name, quantity_cents, status, attempt_count, provider_event_id, last_error, processed_at.
flushAiMeteringOutbox (ai-metering.ts:146-198) drains it. The loop is deliberately serial — one Polar request at a time, and the processed/failed mark lands before the next row is claimed, so a crash re-delivers at most the in-flight row. With POLAR_ACCESS_TOKEN absent it no-ops with reason: 'polar_not_configured'.
The meter shape (ai-metering.ts:15-33):
{
"name": "ai_usage.cents.v1",
"externalCustomerId": "org:<clerkOrgId>",
"externalId": "<ledgerRowId>",
"metadata": { "quantity_cents": 137, "source": "ermisai-ai-ledger" }
}Polar deduplicates on externalId, which is why the ledger row id is used verbatim.
MAX_AI_METERING_ATTEMPTS = 5 (src/lib/db/ai-usage-repository.ts:171). listDeliverableAiMeteringOutboxRows filters on attemptCount < 5, so a row that fails five times is parked permanently as silently unbilled overage. Nothing retries it, and there is no alert for it beyond the cron's reportCronDrainFailures on that run. Recovery is an operator calling POST /api/admin/ai/metering/requeue, which resets the attempt counters (src/lib/db/ai-usage-repository.ts:237-247).
Crons and manual redrives
Two entries in vercel.json, both every two minutes:
| Schedule | Path | Drains |
|---|---|---|
*/2 * * * * | /api/admin/ai/metering/flush | metering outbox → Polar |
*/2 * * * * | /api/admin/ai/ledger/retry/flush | Redis ledger retry queue → Postgres |
Both routes carry two handlers. GET is the cron path, authorized by isAuthorizedCronRequest (the CRON_SECRET bearer check). POST is the human path, gated on canAccessAdminSurface(appRole, 'ai') — super_admin only.
Without CRON_SECRET set, the cron GET returns 401. The metering outbox then fills and no overage ever reaches Polar. This is a silent revenue failure: the app works normally, tenants are enforced correctly, and nothing surfaces it except the growing outbox.
The manual operator surface for all of this:
| Action | Route | Role surface |
|---|---|---|
| Flush metering backlog | POST /api/admin/ai/metering/flush | ai (super_admin) |
| Requeue dead metering rows | POST /api/admin/ai/metering/requeue | ai (super_admin) |
| Redrive ledger retry queue | POST /api/admin/ai/ledger/retry/flush | ai (super_admin) |
The Requeue dead metering rows button lives on /admin/costs, which ops operators can open — but its endpoint is gated on the ai surface, which ops cannot use. An ops operator sees the button and gets a 403.
Threshold notifications
src/lib/ai/usage-threshold-notifier.ts fires at 50%, 80% and 100% of the envelope, computed from the same EUR-converted cumulative spend the metering step produced. Each (billingPeriodKey, threshold) pair is recorded with an atomic HSETNX (addHashRecordIfAbsent) before the notification is emitted, so two concurrent billable completions cannot double-fire. A new billing period uses new hash fields, so the next period re-fires.
Titles and bodies are hardcoded English (:50-71), not next-intl:
- 50% —
50% of monthly AI capacity used - 80% —
80% of monthly AI capacity used - 100% —
Monthly AI capacity reached
At 80% and 100% an upsell email is also dispatched (usage-threshold-notifier.ts:118-128), but only to individual_free and individual_plus tenants whose subscription status is free or grants entitlements (src/lib/email/upsell-trigger.ts:12, :36-52). The dispatcher is fully failure-isolated so a send error never affects the metering path.
An envelope of 0 produces no thresholds at all (computeCrossedThresholds returns an empty list when envelopeCents <= 0).
Platform-wide spend cap
src/lib/ai/platform-spend.ts. Independent of any tenant envelope: a total ceiling on sum(billable_cost_cents_usd) across all tenants.
- Windows are UTC start-of-day and UTC start-of-month.
- Caps come from the runtime config (
platformDailySpendCapCents/platformMonthlySpendCapCents), seeded fromERMIS_PLATFORM_AI_DAILY_CAP_CENTS/ERMIS_PLATFORM_AI_MONTHLY_CAP_CENTS.nullmeans no cap. - Only a window whose cap is non-null is queried, so an all-
nullconfig costs zero ledger reads. - Results are cached 60 s per window, so the check adds at most one aggregate query per minute to the dispatch path.
- Exceeded when
spend >= cap.
The cap fails open. resolvePlatformAiSpendCapError wraps the whole snapshot read in a try/catch that returns null on any error (usage-guardrails.ts:374-397) — a ledger read failure must never take AI down. A degraded Postgres therefore disables the platform ceiling without any user-visible signal.
Note also that the caps are USD cents, matching the raw ledger, while every tenant envelope and hard limit is EUR cents. The two are not the same unit.
Currency
Plan prices, envelopes, overrides and hard limits are EUR cents. The ledger accrues USD cents (provider cost). There is exactly one conversion function, resolveUsdToEurRate() (src/lib/ai/usage-fx.ts:14-28), read from ERMIS_USD_TO_EUR_RATE, defaulting to 1.0. A non-finite or non-positive value also falls back to 1.
It is deliberately a dependency-free leaf module: both the guardrail and the metering layer need it, and importing it from either created a usage-accounting → ai-metering → usage-guardrails → usage-accounting cycle.
Three call sites use it, and because they all use the same one they agree with each other:
- The admission guardrail's
projectedSpendCents(usage-guardrails.ts:490-492). - The overage metering delta (
ai-metering.ts:100-102). - The tenant usage snapshot shown in the workspace (
src/lib/ai/tenant-usage-summary.ts:83-102).
They are consistent, but they are not currency-accurate until an operator sets a real rate.
Failure modes worth knowing
| Condition | Behaviour |
|---|---|
| Redis unavailable at admission | In-flight reservation is skipped and the call proceeds (usage-guardrails.ts:473-483). Sliding-window limits fall back to a per-process in-memory counter that is explicitly not cross-instance coherent (:66-90). |
| Redis unavailable at ledger-write failure | The row cannot be enqueued and is lost (ledger-retry-queue.ts:102-108). |
| Postgres unavailable at ledger write | Row goes to the Redis retry queue with its stable id; the cron redrives it once Postgres recovers. |
| Ledger read fails during the platform cap check | Fails open — AI proceeds with no ceiling. |
| Metering enqueue throws after ledger commit | Logged to stderr, swallowed; self-corrects on the next billable row because the delta is recomputed cumulatively. |
| Model has no price card and the provider reports no cost | cost_source: 'none', zero spend, zero metering. |
aiGuardrailsMode: 'disabled' in a deployment | One stderr warning at config load. All per-tenant checks bypassed; aiPaused and the platform cap still apply. |
Tenant subscription is past_due or canceled | AiSubscriptionBillingBlockedError (402) on every tenant-billable call, and the whole workspace reverts to Free-plan limits. |
| Enterprise tenant with no envelope override | AiEnvelopeNotConfiguredError (402) on the very first call — the catalog envelope is null, which resolves to 0. |
Operator surfaces
/admin/ai → AI usage operations (super_admin only, src/lib/auth/platform-roles.ts:32) has the buttons Refresh usage summary and Flush metering backlog, and the metric cards Tenant billable, Platform cost, Blocked requests, Failed requests, Pending metering, Failed metering, Unscoped warnings, Snapshot generated, plus Ledger anomalies and Provider operations readiness alerts. The Provider stage footprint section below it charts ledger-backed provider and stage costs.
/admin/costs (super_admin and ops) groups ai_usage_ledger by provider_id, stage and billing_scope. Two caveats: no date range is passed, so the figures are all-time totals; and they are USD, while the tenant envelopes on /admin/tenants are EUR.
/admin/tenants/[tenantId] (super_admin only) is where the three per-tenant billing controls live: the Overage enabled (metered billing beyond the included envelope) checkbox, Monthly hard limit (EUR, empty = plan default), and Envelope override (EUR/month, empty = plan envelope; REQUIRED for enterprise). updateTenantBillingAiControlsInDb (src/lib/db/billing-repository.ts:585) is the only write path for those three columns; undefined leaves a field unchanged, explicit null clears it.
Environment variables
| Variable | Default | Effect |
|---|---|---|
ERMIS_AI_GUARDRAILS_MODE | enforce | disabled | observe | enforce. Seed default only — persisted admin config wins once set. |
ERMIS_AI_METERING_ENABLED | true | Seed default for the Polar metering outbox. |
ERMIS_AI_PAUSED | false | Seed default for the global kill switch. |
ERMIS_PLATFORM_AI_DAILY_CAP_CENTS | unset (no cap) | USD cents. |
ERMIS_PLATFORM_AI_MONTHLY_CAP_CENTS | unset (no cap) | USD cents. |
ERMIS_AI_INFLIGHT_RESERVE_CENTS | 25 | USD cents held per in-flight billable call; 0 disables. |
ERMIS_USD_TO_EUR_RATE | 1 | Applied at all three USD→EUR boundaries. |
CRON_SECRET | — | Required by both drain crons. Unset means overage never reaches Polar. |
POLAR_ACCESS_TOKEN | — | Absent means the metering drain no-ops with polar_not_configured. |
Everything except ERMIS_USD_TO_EUR_RATE, ERMIS_AI_INFLIGHT_RESERVE_CENTS, CRON_SECRET and POLAR_ACCESS_TOKEN above is a seed default. The persisted admin runtime config in platform_state under platform:admin:ai-runtime-config is authoritative once written, and it is edited only through PUT /api/admin/ai/config. Changing the env var in Vercel will not move a value that has already been persisted.
AI runtime
Provider modes, the four stages, model resolution and prompt versioning.
Feature flags and runtime controls
Where aiPaused and the spend caps actually live, and why they have no UI.
API error and status reference
Every error code the guardrails can return, with statuses and messages.
Incident and recovery runbooks
Redriving the ledger queue, the metering outbox and dead rows.
Scheduled jobs and the queue subsystem
All five crons, CRON_SECRET, and what each drain does.
Billing and AI capacity
The tenant-facing view of the same envelope.
