Incident and recovery runbooks
What to do when the feed is empty, the database is unreachable, deliveries fail, or spend spikes.
Everything here assumes you have the repo, the Vercel project, and a platform role in the operations console. Paths are relative to the app repo root.
First move: read the health probe
GET /api/health is auth-free and allowlisted in src/proxy.ts. It is the fastest way to tell an
app fault from a dependency outage.
curl -s https://ermisai.com/api/health | jqThe response is { status, checks: { database, redis }, aiGuardrailsMode, controls, config }
(src/app/api/health/route.ts:133-166). Read it like this:
| Field | Meaning |
|---|---|
| HTTP status | 200 only when checks.database.status === 'ok'; 503 otherwise. Redis degradation never fails readiness. |
checks.database.migrated | false means platform_state is unreachable. The detail string is platform_state unreachable (run db:migrate before serving traffic): <err>. |
checks.redis.status | not_configured when no Upstash credentials are present — that is a configuration state, not an outage. |
controls | Live values of aiGuardrailsMode, aiPaused, signupsPaused, waitlistEnabled, platformDailySpendCapCents, platformMonthlySpendCapCents. This is the only read surface for those switches. |
config | The ten launch-critical environment keys and which are missing. config.ready: false never changes the HTTP status. |
Blast radius
Postgres is the dominant single point of failure and it is deliberately fail-closed. Production has
no in-memory fallback for shared state: isLocalStateFallbackAllowed() returns false whenever
VERCEL_ENV is set (src/lib/platform/shared/persistence.ts:24-34), so an unreachable or unmigrated
database 503s every authenticated surface at once — feed, editorial, compose, billing, newsroom
profile. A divergent in-memory store was judged worse than an outage
(docs/ops/disaster-recovery.md:3-9).
The public marketing site is unaffected. Those routes are CDN-cached with Set-Cookie stripped, so
they serve without touching Postgres or Clerk.
Two mitigations shorten the failure: the 10-second statement timeout means a saturated or locked
database returns 503 fast instead of hanging a function to its 300-second ceiling, and /api/health
distinguishes the two cases in one request.
ERMIS_FORCE_INMEMORY_STORE=true will not rescue a production incident. It is ignored whenever
VERCEL_ENV is set — production and preview (src/lib/platform/shared/persistence.ts:16-22).
Dependency degradation
| Dependency down | What you see | Behaviour |
|---|---|---|
| Postgres | Authenticated routes 503; /api/health returns 503 with database.status: error | Fail-closed everywhere. Marketing stays up. Follow the restore procedure below. |
| Redis (Upstash) | Nothing obvious | Rate limiting fails open (src/lib/api/rate-limit.ts:12-16). The feed build lock fails open, so warm instances duplicate whole-catalog AI builds. Guardrail windows fall back to a per-process counter map. The ledger retry queue is unavailable — a redrive returns errors: ['redis_not_configured']. The per-tenant spend guardrail stays fail-closed. |
| Polar | Checkout and portal 503 when POLAR_ACCESS_TOKEN is absent; plans drift | The metering outbox accumulates and drains when Polar recovers. Webhook drift needs an explicit resync — see below. |
| AI gateway or Azure | Feed goes stale; compose and chat surface typed errors | In production there is no heuristic synthesis fallback (shouldAllowAiPipelineFallback() is false when NODE_ENV === 'production', src/lib/services/rss-aggregation.ts:630-650). A cluster whose synthesis fails is skipped and the previous cache is re-served. You get fewer stories, not worse ones. |
The feed is empty or stale
Symptoms and what each one means
Stories feed unavailable— HTTP 503, codestories_feed_unavailable. The route's internal timeoutSTORIES_ROUTE_TIMEOUT_MS = 95_000fired (src/app/api/stories/route.ts:18-23). Almost always a cold synchronous whole-catalog build, not a hung database.No stories match your current filters.— the feed loaded and returned nothing. This string shows even with zero filters set.- Stories present but hours old — the scheduled ingestion is not running.
Checks, in order
CRON_SECRET.isAuthorizedCronRequestcomparesAuthorization: Bearer ${CRON_SECRET}withtimingSafeEqualand returns false when the variable is unset (src/lib/api/cron-auth.ts:46-65), so a missing secret can never open the endpoint — it 401s every cron instead. With no cron there is no scheduled tenant ingestion, no alert sweep, and no metering flush; freshness silently degrades to whatever browser traffic triggers./api/health→configlists it.- Source health.
/admin/sourcesshows per-sourceok/failed/empty parse/not polled, last polled time, 24-hour article count, and error rate. A source returning HTML instead of a feed readsempty parse. - Catalog reach. Only 13 of the 1,427 entries in
news-sources/catalog.v1.jsonare bothactiveand ingest-enabled, and those are the only sources the global build polls. If a sync deactivated rows, the feed narrows without any error. - Kill switches.
ERMIS_DISABLE_AI_PIPELINE=truedisables synthesis entirely;aiPausedblocks every AI invocation including the pipeline. - Pipeline diagnostics.
/admin/ai→ "News pipeline diagnostics" shows the last build's status, duration, article/cluster/story counts, and the last failure with its stage, model, and provider. It is an in-process, per-instance snapshot of the last global build; a cold instance renders "No diagnostics snapshot available yet." and a tenant-scope build never appears there at all.
Forcing a refresh
The tenant-facing control is the Refresh sources button on the feed, which calls
GET /api/stories?forceRefresh=true and rebuilds only that tenant's scope.
Operator-side there is no button. Post to the cron route by hand:
curl -s -X POST https://ermisai.com/api/admin/rss/refresh \
-H "Cookie: <your authenticated session>"POST requires the sources admin surface and, with the queue flag off, runs
listAggregatedStories({ forceRefresh: true }) inline — which refreshes the global catalog
scope, not tenant scopes. Tenant ingestion happens only on the cron GET path, which runs
ingestAllTenantScopedFeeds() serially under ERMIS_TENANT_INGESTION_TIME_BUDGET_MS (default
240000) with a rotating start offset, then sweeps alert rules
(src/app/api/admin/rss/refresh/route.ts).
Opening /admin/sources or /admin/queues on a cold instance can trigger a synchronous
whole-catalog fetch, cluster, and AI synthesis, because both read loadFeedCache() for the global
scope with no forceRefresh (src/lib/platform/admin/dashboards.ts:96-100, 149-154). That costs
money and can take a minute. Do not use those pages as a first diagnostic on a cold deployment.
Deliveries fail
Webhook failures
Every dispatch writes a log entry whether or not a packet left the process
(src/lib/platform/integrations/webhooks.ts:273-358). Read the code before assuming a network fault:
| Recorded code | Cause |
|---|---|
| 400 | Endpoint validation failed at dispatch time (SSRF guard). |
| 422 | The webhook is disabled, or the endpoint or secret is empty. No network call was made. |
| 502 | Network error. |
| 504 | The 10-second WEBHOOK_DELIVERY_TIMEOUT_MS elapsed. |
| Upstream status | The endpoint answered with a non-2xx. |
There is no retry queue, no backoff, and no dead-letter. A failed alert.triggered delivery is left
out of the dedup set so the next alert evaluation re-attempts it; a failed webhook.test is never
retried.
Two SSRF behaviours reject endpoints that look valid to the customer: validation is re-run at
dispatch with DNS resolution, and the socket is pinned to the address that passed. Split-horizon DNS,
a hostname resolving to any private or reserved address, and a load balancer that rotates addresses
mid-flight all fail. Saving the configuration does not run the DNS check
(requireResolvedPublicAddress: false, webhooks.ts:379-382), so a config can save cleanly and
every delivery still fail with 400.
A fleet-wide delivery cap drop
If many tenants report Story is currently queued. (HTTP 402, code story_queued) and the amber
queued for delivery (daily cap resets at midnight UTC) banner at once, suspect the billing
database rather than the plans.
The comment at src/lib/platform/local-platform-data.ts:1909-1913 says delivery capacity fails open
to unlimited, but the resolver it calls swallows every error and returns individual_free
(src/lib/platform/newsroom-preferences.ts:512-513), which maps to 20 deliveries per day. A billing
read failure therefore drops every tenant to the Free cap silently. Check
checks.database and the tenant_subscriptions reads before touching plan configuration.
What the delivery surfaces will and will not tell you
/admin/deliveries reads a bounded 500-entry cross-tenant recent-activity mirror, described in code
as "recent-N, not a ledger" (src/lib/platform/shared/delivery-log.ts:5-18). It silently drops older
entries. Exact per-tenant volume lives in the per-UTC-day counters retained for 35 days, and only
successful deliveries increment them.
There is no email delivery. ALERT_EMAIL_DELIVERY_ENABLED is a hard-coded false
(src/lib/platform/alerts/index.ts:25), so an EMAIL tile reading zero is correct, not a fault.
AI is blocked, or spend spikes
Reading the block
Guardrail errors are not localized, so the raw English message reaches the toast. The ones that indicate a platform-level condition rather than a tenant limit:
| Code | HTTP | Message |
|---|---|---|
ai_paused | 503 | AI features are temporarily paused. Please try again later. |
ai_platform_spend_cap_reached | 429 | The platform-wide daily AI spend limit has been reached. Please try again later. (or monthly) |
Both are evaluated first, before anything else, even when aiGuardrailsMode is disabled, and
they apply to platform-scoped pipeline runs as well as tenant calls
(src/lib/ai/usage-guardrails.ts:400-439). Every block writes a blocked row to ai_usage_ledger.
The platform spend read itself fails open: any error reading the aggregate returns null and the
call proceeds (usage-guardrails.ts:374-398).
The platform cap sums billable_cost_cents_usd, and that column is set only when
billable && billingScope === 'tenant' (src/lib/ai/usage-accounting.ts:524-527). The RSS pipeline
runs billingScope: 'platform' with billable: false, so pipeline cost never accumulates against
the platform cap even though the cap blocks pipeline invocations. If pipeline spend is what spiked,
the cap will not stop it — use aiPaused or ERMIS_DISABLE_AI_PIPELINE.
Throwing the brakes
aiPaused, signupsPaused, waitlistEnabled, and both platform spend caps are enforced but have
no control anywhere in the admin UI. AiConfigPanel.save() never sends them, so a hand-set value
survives a later panel save. Flip them with a direct PUT, gated on the ai surface (super_admin
only):
curl -s -X PUT https://ermisai.com/api/admin/ai/config \
-H "Content-Type: application/json" \
-H "Cookie: <your authenticated session>" \
-d '{"aiPaused": true}'The same body shape accepts platformDailySpendCapCents and platformMonthlySpendCapCents (integer
USD cents, or null to clear), aiGuardrailsMode (disabled | observe | enforce),
aiMeteringEnabled, signupsPaused, and waitlistEnabled
(src/lib/contracts/ai-runtime.ts:158-191). Confirm the result at /api/health → controls.
Guardrails mode matters during an incident: observe records would-block events without rejecting
requests, which is the right setting when you suspect the guardrail itself is the fault.
A deployed environment running with guardrails disabled emits a one-time stderr WARNING naming
/api/admin/ai/config and ERMIS_AI_GUARDRAILS_MODE.
Saving the /admin/ai panel always rewrites two fields the panel does not display:
enableProviderOptionsMatrix: true and chatAllowModelOverride: false
(AiConfigPanel.tsx:367-368). Anything you set for those by API is reset on the next panel save.
Billing state is stale
The Polar webhook acks HTTP 200 for every non-retryable processing failure by design; only
isRetryableDatabaseServiceError produces a 500. Unmapped products, a missing or foreign
customer.externalId, and malformed payloads are all acked. A tenant can be stuck on the wrong
plan indefinitely while Polar's delivery log is 100% green. Do not treat that log as a health
signal.
A past_due or canceled standing reverts the entire workspace to Free entitlements — zero
monitoring rules, the Free source cap, 20 deliveries per day, every paid capability off, and
tenant-billable AI hard-blocked. So "the customer says they lost everything" is usually a standing
problem, not a data problem.
Triage:
/admin/tenantsshows the tenant scope id, plan, monthly AI capacity, and billing standing. The envelope displayed is the one enforcement actually uses, so apast_duetenant shows the Free envelope, not its contracted one.GET /api/admin/billing/webhook-events?status=ignored&limit=50(andstatus=failed) lists what the receiver dropped. The same log is on/admin/costswithall | processed | ignored | failedfilters.- Repair with a resync — replaying the archived payload would just reproduce the original outcome:
curl -s -X POST https://ermisai.com/api/admin/billing/resync \
-H "Content-Type: application/json" \
-H "Cookie: <your authenticated session>" \
-d '{"tenantScopeId": "org:org_..."}'This pulls the live Polar subscription and upserts it through the same idempotent path the webhook
uses. A paid database row with no active Polar subscription is downgraded to canceled. It needs
the costs surface, so ops can run it. The UI equivalent is "Resync tenant from Polar" on
/admin/costs.
Restore procedure after data loss
Point-in-time recovery is recorded in the repo as unconfirmed. docs/ops/disaster-recovery.md:23
heads its backups section "Backups / PITR (CONFIRM before beta)" and asserts nothing about whether
PITR is actually enabled on the production plan. Verify it in the CapyDB dashboard before you need
it, and record the retention window for the on-call rotation.
Restore the CapyDB database from PITR or the latest backup to a known-good timestamp.
Re-run pnpm db:migrate against the restored database to reconcile schema drift. Migrations are
additive and idempotent, so re-running is safe. Use the direct or session URL
(MIGRATIONS_DATABASE_URL), never the pooled one.
Confirm GET /api/health returns 200 with checks.database.migrated: true.
Redrive the durable queues so nothing is lost across the gap — POST /api/admin/ai/ledger/retry/flush
then POST /api/admin/ai/metering/flush. Both are listed in the table below.
Re-verify the core journey: sign in, feed, compose, submit for review, approve.
Redrives and manual operations
None of these have a button except where noted. All are curl-or-runbook operations.
| Operation | Endpoint | Surface | What it fixes |
|---|---|---|---|
| Ledger redrive | POST /api/admin/ai/ledger/retry/flush | ai | Re-persists ledger rows whose inline Postgres insert failed. Returns {drained, succeeded, requeued, deadLettered, errors, processedAt}. Entries past 10 attempts move to ermis:ai:ledger:retry:dlq; a non-zero deadLettered is reported to Sentry. |
| Metering flush | POST /api/admin/ai/metering/flush | ai | Drains ai_usage_metering_outbox to Polar, serially. Returns {processed, failed, skipped, processedAt}, or a no-op with reason: 'polar_not_configured'. |
| Dead-metering requeue | POST /api/admin/ai/metering/requeue | ai | Resets attempt counters on rows past MAX_AI_METERING_ATTEMPTS = 5, which are otherwise parked as silently unbilled overage. Returns {requeued, processedAt}. |
| Polar resync | POST /api/admin/billing/resync | costs | Pulls live Polar state for one tenant scope. Body {tenantScopeId}. |
| Global RSS refresh | POST /api/admin/rss/refresh | sources | Inline whole-catalog rebuild. |
| Expired-state cleanup | POST /api/admin/state/cleanup | queues | Deletes expired platform_state rows and threshold-firing hash rows older than 90 days. |
Both flush routes also expose a GET variant scheduled every two minutes and authenticated with the
CRON_SECRET bearer. The requeue button on /admin/costs is a known cross-surface mismatch: the
page is ops-accessible but the endpoint requires the ai surface, so an ops operator sees the
button and gets a 403 toast.
Cron health
Five crons are declared in vercel.json, all pinned to region arn1:
| Path | Schedule |
|---|---|
/api/admin/ai/metering/flush | */2 * * * * |
/api/admin/ai/ledger/retry/flush | */2 * * * * |
/api/admin/rss/refresh | */10 * * * * |
/api/admin/erasure/purge | 45 2 * * * |
/api/admin/state/cleanup | 15 3 * * * |
Every GET self-authenticates with the bearer secret and every handler is wrapped in
runGuardedCronHandler, which converts a throw into a typed 500 routed to Sentry.
reportCronDrainFailures raises a Sentry error whenever a drain reports failures — that is what
turns a silent metering backlog into a page. Sentry's cron monitors ride on
_experimental.vercelCronsMonitoring in next.config.ts; the webpack.* Sentry options are no-ops
under Turbopack, so do not expect automaticVercelMonitors to do anything.
Smoke-test the bearer path:
curl -s -o /dev/null -w "%{http_code}" https://ermisai.com/api/admin/ai/metering/flush
# 401 without a bearer
curl -s -H "Authorization: Bearer $CRON_SECRET" https://ermisai.com/api/admin/ai/metering/flush
# 200Leading indicators of under-billing
Two backlogs mean tenant AI spend is real but invisible to envelope checks and to Polar:
- Metering outbox. Visible on
/admin/aiunder "AI usage operations" asPending metering(withOldest <datetime>or "No pending rows") andFailed metering. A growingFailed meteringcount is unbilled overage waiting for a requeue. - Ledger retry queue.
readAiLedgerRetryQueueDepth()exists atsrc/lib/ai/ledger-retry-queue.ts:322but has no call site — nothing in the app or the admin UI reads it. To check depth, inspect the Redis lists directly:ermis:ai:ledger:retry:queue,ermis:ai:ledger:retry:dlq,ermis:ai:ledger:retry:processing.
Screens that are not monitoring
Four admin surfaces read as telemetry and are not. Knowing this saves an hour on a bad night.
/admin/queues"Queue health" derives four cards from story phase counts and source health (src/lib/platform/admin/dashboards.ts:149-196).RetriesandDead letterare hard-coded0forcluster-queue,synthesis-queue, andreview-queue, and three cards share the samelagSeconds. Nothing in the codebase reads Vercel Queue depth or the<topic>-dlqmirror topics./admin/costsis an all-timeGROUP BYoverai_usage_ledgerwith no date range, in USD, while tenant envelopes on the adjacent screen are in EUR./admin/deliveriesis the 500-entry recent-N mirror described above.- The platform review queue returns
[]when the editorial-draft repository is unavailable (local-platform-data.ts:2733-2737). An empty queue can mean "database down", not "nothing pending".
Deploying to production
Build shape, the migration gate, and the post-deploy smoke tests.
Scheduled jobs and the queue subsystem
What each cron does and the honest state of the queue pipeline.
AI usage accounting, guardrails and metering
The ledger, the admission order, and how spend reaches Polar.
Feature flags and runtime controls
The two flag systems and the operator brakes with no UI.
Migrations and schema changes
The drizzle-kit layout and the rules that break migrations here.
GDPR erasure runbook
Grace window, purge scope, and the manual backstops.
