Database schema and runtime state
The durable relational schema, the platform_state key-value layer, and the rule for deciding which to use.
ErmisAI persistence is a hybrid. Twenty-five Drizzle-defined Postgres tables hold durable relational
data. Two of those tables — platform_state and platform_state_hash — are generic key-value
stores that hold everything mutable that never earned a schema. Redis holds no application state at
all.
Both halves live in the same Postgres database, so "runtime state" here does not mean "somewhere else". It means "a jsonb blob under a string key instead of columns".
The rule for deciding which side
If the data has a stable shape, is queried by anything other than its own key, or must survive a
schema review — give it a table. If it is a per-tenant or per-user blob that is only ever read
whole by key, put it in platform_state.
In practice the split falls out like this:
| Signal | Side |
|---|---|
| Foreign keys, indexes, or cross-row aggregation | Relational table |
Filtered or sorted by a column (editorial_state, status, updated_at) | Relational table |
| Money, identity, or audit data | Relational table |
| Read and written as one whole object per tenant or per user | platform_state |
| Needs atomic insert-if-absent on a field (idempotency receipts) | platform_state_hash |
| TTL'd cache that must survive a cold start | platform_state with expires_at |
| Rate-limit counters, in-flight reservations, a cross-instance lock | Redis |
Durable relational: Postgres and Drizzle
src/lib/db/schema.ts is the single schema source of truth — 25 pgTable definitions and 22
pgEnum declarations (18 of them in the block at src/lib/db/schema.ts:98-133, the rest declared
next to the tables that use them). One repository module per domain sits beside it in src/lib/db/.
There are no parallel Supabase SQL migrations; Supabase is only ever a possible host.
Billing
| Table | Line | Holds |
|---|---|---|
billing_plans | schema.ts:206 | Plan catalog mirror: price mode (fixed, base_plus_seat_addon, custom), monthly price, per-seat add-on, monitoring-rule count, included seats, cost envelope cents, polar_product_id |
tenant_subscriptions | schema.ts:232 | One row per tenant scope (tenant_scope_id unique): plan FK (onDelete: 'restrict'), Polar customer/subscription ids, seat quantity, status, current_period_end, polar_modified_at, ai_overage_enabled, ai_monthly_hard_limit_cents, cost_envelope_per_month_cents_override |
billing_invoices | schema.ts:266 | Invoice mirror |
billing_portal_sessions | schema.ts:283 | Polar portal sessions, provider session id uniquely indexed |
billing_checkout_sessions | schema.ts:300 | Polar checkout sessions, same uniqueness |
billing_webhook_events | schema.ts:318 | Idempotency ledger for Polar webhooks, unique on (provider, provider_event_id); carries the payload-archive columns |
polar_modified_at is an event-ordering guard: a late webhook cannot resurrect a canceled
subscription (schema.ts:246-248).
Identity
| Table | Line | Holds |
|---|---|---|
clerk_users | schema.ts:346 | Webhook-maintained mirror of Clerk users: email, name, image, role, app_role (default unassigned), persona, org id/name/slug/role, metadata, deleted_at |
tenant_memberships | schema.ts:376 | Composite PK (tenant_scope_id, clerk_user_id), tenant_role enum (owner, admin, member), title, desk_slugs |
clerk_webhook_events | schema.ts:401 | Same idempotency plus payload-archive shape as billing |
clerk_users.app_role is written but never used for authorization — the live JWT claim wins. See
Tenant scoping and isolation.
Source catalog
| Table | Line | Holds |
|---|---|---|
source_catalog_entries | schema.ts:426 | The feed registry: feed URL (unique), type, tier (wire, tier1, tier2, tier3), region, language, categories, catalog_origin (default global), ingest_enabled, active, resolved icon URL, last-fetch telemetry |
source_catalog_dataset_versions | schema.ts:462 | Version, checksum, and source count of each catalog dataset ingest |
Editorial
| Table | Line | Holds |
|---|---|---|
story_chat_sessions | schema.ts:474 | PK (tenant_scope_id, clerk_user_id, story_id, chat_id), active_stream_id |
story_chat_session_payloads | schema.ts:502 | Same PK, messages jsonb — split off so metadata reads do not drag the transcript |
editorial_story_drafts | schema.ts:530 | The editorial system of record. PK (tenant_scope_id, story_id) |
editor_queue_items | schema.ts:641 | Personal editor queue. PK (tenant_scope_id, user_id, story_id), pinned, status queued/submitted/removed — no physical deletes |
tenant_newsrooms | schema.ts:667 | Newsroom profile per tenant scope: publication and desk name, vertical, selected source ids and countries, tags, custom categories, ui_locale, content_locale, display_style, custom_sources, completed_at |
Integrations
| Table | Line | Holds |
|---|---|---|
tenant_integration_api_keys | schema.ts:690 | key_hash uniquely indexed, masked_value, revoked_at, last_used_at |
tenant_wordpress_connections | schema.ts:716 | Unique (tenant_scope_id, site_url) and unique key_id; plugin and WordPress versions |
tenant_webhook_configs | schema.ts:750 | One row per tenant: endpoint, secret, enabled |
tenant_webhook_logs | schema.ts:769 | Delivery status, response code, timestamp |
AI accounting
| Table | Line | Holds |
|---|---|---|
ai_usage_ledger | schema.ts:785 | One row per AI invocation: invocation/request/generation ids, tenant and user, actor type, billing scope (tenant/platform), billable, surface, stage, operation, provider mode/id/model id, status, token counters, duration, actual/estimated/billable cost cents with cost_source |
ai_usage_metering_outbox | schema.ts:836 | Durable outbox for Polar overage metering; FK to the ledger with onDelete: 'cascade', unique external_event_id, status pending/processed/failed, attempt_count |
Compliance
data_erasure_requests (schema.ts:911) drives GDPR erasure through a grace window. Subject type
is tenant or user; status is pending, failed, purged, cancelled, or purging.
notification_targets and member_user_ids are captured at request time, because Clerk
hard-deletes the org membership snapshot immediately and a purge-time lookup two weeks later would
find nothing (schema.ts:936-944). A CHECK constraint requires the matching subject id, and two
partial unique indexes make "at most one unresolved request per subject" race-safe
(schema.ts:950-970).
Stories, clusters and articles are not tables
There is no stories table, no clusters table, no articles table. The only editorial table is
editorial_story_drafts, and it embeds the story it was composed from as a story_snapshot jsonb
column. Aggregated stories live in the feed cache (platform:story-feed-cache:v1:<scopeKey>, TTL'd)
and in process memory. Anything that needs to query across stories queries drafts or rebuilds the
feed.
Schema-enforced editorial invariants
editorial_story_drafts does not trust application code for two things.
Optimistic concurrency. version integer NOT NULL DEFAULT 0 (schema.ts:544-547) is bumped on
every persisted write, and the transition mutators pass the version they read as a compare-and-set
guard. When a caller supplies expectedVersion — the version the client loaded into its edit
session — the lock spans the whole session, so a save built on stale content is rejected even after
minutes (src/lib/platform/drafts/tenant-draft-store.ts:481-491). A conflict is a precondition
failure, not an infrastructure fault: it is re-thrown rather than masked by the fallback path
(tenant-draft-store.ts:493-497) and surfaces to the editor as
"Someone else updated this draft. The latest version was loaded - review and retry."
State/timestamp consistency. Three CHECK constraints (schema.ts:596-607):
editorial_state != 'in_review' OR submitted_for_review_at IS NOT NULL
editorial_state != 'published' OR published_at IS NOT NULL
editorial_state != 'rejected' OR reviewed_at IS NOT NULLA row that violates one is contradictory audit data, and the CMS and WordPress export gates read
published_at. The migration that added them backfills legacy rows from updated_at first
(drizzle/20260717002329_editorial-state-timestamp-checks/).
One deliberate oddity: editorial_story_drafts.content_locale and tenant_newsrooms.content_locale
both default to the literal 'en', not to the env-derived platform default. A column DEFAULT baked
from ERMIS_CONTENT_LOCALE would make the generated schema drift with whichever environment
generated it (schema.ts:566-569). Application code always writes contentLocale explicitly.
The database client
src/lib/db/client.ts is plain postgres (postgres-js) plus drizzle-orm/postgres-js. No vendor
transport.
- Connection string resolution order is
SUPABASE_DATABASE_URL→DATABASE_URL(client.ts:66-69). Both must use thepostgres:orpostgresql:protocol. If the value containsREDIS_URL=orUPSTASH_REDIS_the error adds "Check for a missing newline in your env file" (client.ts:82-89). - Pool size is
POSTGRES_POOL_MAX, default 3 per instance, withconnect_timeout: 10,idle_timeout: 20,prepare: false(client.ts:19,:135-149). - The client is memoized on
globalThis.__ermisDbClientonly outside production (client.ts:154-162).
Direct Postgres hosts are refused in production, fail-closed. In NODE_ENV=production, a URL
that is a direct host throws at client construction instead of falling through to the next
candidate env var (client.ts:97-104). Direct means Supabase db.<ref>.supabase.co on any port
other than 6543, or CapyDB *.db.capydb.dev on any port other than 6432. The message is
"<NAME> points at a direct Postgres host. Use the pooled/transaction connection string for the app runtime in production." A partial misconfiguration is itself a fault — using a direct host
from serverless exhausts connections.
Statement timeout
POSTGRES_STATEMENT_TIMEOUT_MS defaults to 10000 ms and is sent as a wire startup parameter —
but only for non-pooled URLs. isCapyDbPooledUrl (*.db.capydb.dev on port 6432) omits it entirely
(client.ts:58-63, :146-148).
The reason is an incident. CapyDB's PgBouncer pooler hard-rejected statement_timeout with
08P01 unsupported startup parameter, which took down every production Postgres connection on
2026-07-22. CapyDB then added it to ignore_startup_parameters — so it is now silently dropped
rather than rejected. Sending it through the pooler achieves nothing either way.
Pooled connections get their timeout from a role-level default instead, installed by
drizzle/20260722020726_statement_timeout_role_default/:
ALTER ROLE <current_user> IN DATABASE <current_database> SET statement_timeout = '10s';Tuning POSTGRES_STATEMENT_TIMEOUT_MS in production does nothing. The pooled runtime uses the
10 s role default. Changing the effective pooled timeout requires a new migration. Long-running
DDL inherits the same 10 s cap, so any migration with slow DDL must start with
SET statement_timeout = 0;.
Runtime state: platform_state and platform_state_hash
Two generic tables, both in schema.ts:
| Table | Shape | Line |
|---|---|---|
platform_state | key text PRIMARY KEY → value jsonb, nullable expires_at, updated_at | schema.ts:870 |
platform_state_hash | PK (key, field) → value jsonb, updated_at | schema.ts:889 |
They replace an earlier Upstash blob keyspace. The doc comment states the tradeoff: these access
patterns are low-frequency or fronted by an in-process cache, so Postgres latency matches Redis
while gaining durability and removing a hard Redis dependency (schema.ts:861-868). The composite
primary key on the hash table is what gives back the atomic insert-if-absent that Redis HSETNX
provided (schema.ts:883-887).
The store API
The factory is createPlatformStateStore (src/lib/platform/shared/runtime-state.ts:60). Exactly
one instance is built, in src/lib/platform/shared/state.ts:25-37, and it exports:
| Function | Semantics |
|---|---|
readState(key, seedFactory, schema?) | Select where key matches and (expires_at IS NULL or expires_at > now()); on miss, seed with INSERT … ON CONFLICT DO NOTHING, and if the insert lost the race re-read with the same expiry filter so an expired row cannot be resurrected |
readStateIfPresent(key, schema?) | Same read, no seeding; null on miss |
writeState(key, value, schema?) | INSERT … ON CONFLICT (key) DO UPDATE, always writing expires_at: null |
mutateState(key, seed, mutator, schema?) | Transaction with SELECT … FOR UPDATE on the row, then upsert |
listStateKeysByPrefix(prefix) | LIKE '<escaped>%' with \, % and _ escaped, expired rows filtered out |
addHashRecordIfAbsent(key, field, record, schema) | INSERT … ON CONFLICT DO NOTHING plus read-back, returning { record, created } |
hasHashField(key, field) | Existence check |
listHashRecords(key, schema) | All fields under a key |
The Drizzle context is dynamically imported and lazily cached, so the in-memory path never
instantiates a DB client and a missing DATABASE_URL only matters once the store is actually used
(runtime-state.ts:42-58).
Unlike the Redis store it replaced, this store does not latch a disabled flag. Postgres is the
system of record, so a transient error surfaces in production or falls back per call in dev
(runtime-state.ts:70-73).
mutateState's mutator must be synchronous. It runs inside SELECT … FOR UPDATE. An async
mutator would hold a row lock across arbitrary awaits and exhaust a 3-connection pool
(runtime-state.ts:537-544). The signature enforces it — mutator: (current: T) => T — but a
fire-and-forget promise inside the body would compile.
Key naming
buildScopedPlatformKey(domain, id) produces platform:<domain>:<normalized-id>
(runtime-state.ts:18-20). Ids are trimmed and lowercased; null or undefined becomes
'anonymous' (runtime-state.ts:10-12). The id is almost always a tenant scope id —
user:<clerkUserId> or org:<clerkOrgId> — which is also how the GDPR purge finds a tenant's KV
slice.
A handful of keys use dedicated builders for composite ids: buildEditorQueueStateKey,
buildStoryNotesStateKey, buildStoryChatSessionsKey, buildWordPressConnectSessionsStateKey,
buildWordPressConnectionsStateKey (runtime-state.ts:22-39).
Validation
Every payload is zod-validated on read and on write.
resolveLocalPlatformStateSchemaByKey (src/lib/platform/shared/state-schemas.ts:274-382) matches a
key against an ordered list of exact-match and prefix matchers and returns the schema.
- A failing read treats the value as absent and writes one line to stderr:
[local-platform-data] invalid postgres state for <key>: …(runtime-state.ts:112-114). - A failing write or mutate throws
[local-platform-data] write validation failed for <key>(runtime-state.ts:513); the hash variant throws[local-platform-data] hash write validation failed for <key>(runtime-state.ts:377).
Unmatched keys are unvalidated. The matcher list falls through to z.unknown()
(state-schemas.ts:381), so a typo'd key family gets no schema enforcement at all and no warning.
Adding a key family means adding a matcher.
Known key families
| Key or prefix | Payload | Defined in |
|---|---|---|
platform:editorial:article-drafts (and :<tenant>) | Article draft records | shared/state.ts:11 |
platform:admin:delivery-logs | Global recent-N delivery mirror | shared/state.ts:12 |
platform:admin:review-status-events | Review status change events | shared/state.ts:13 |
platform:admin:delivery-failed-events | Delivery failure events | shared/state.ts:14 |
platform:admin:feature-flags | Global flag snapshot | feature-flags.ts:16 |
platform:admin:feature-flag-overrides:<tenant> | Per-tenant flag overrides | feature-flags.ts:17 |
platform:admin:ai-runtime-config | Admin-managed AI runtime config | ai-runtime-config.ts:63 |
platform:alerts-rules:<tenant> | Alert rules | alerts/index.ts:313 |
platform:alerts-history:<tenant> | Alert history | alerts/index.ts:427 |
platform:alerts-delivery-claims:<tenant> | Atomic delivery claims | alerts/index.ts:444 |
platform:monitoring-sources:<tenant> | Monitored sources | alerts/index.ts:233 |
platform:notifications:<user> | Notification inbox | state-schemas.ts:328 |
platform:team-members:<user> | Team member snapshots (KV fallback) | team/index.ts:83 |
platform:editor-queue:<tenant>:<user> | Editor queue fallback | runtime-state.ts:22 |
platform:story-notes:<tenant>:<storyId> | Story notes | runtime-state.ts:26 |
platform:story-delivery-state:<tenant> | Per-UTC-day released/delivered/queued story ids | local-platform-data.ts:1905 |
platform:story-chat-sessions:<tenant>:<story>:<user> | Chat sessions fallback | runtime-state.ts:38 |
platform:integrations-api-keys:global | API key records — one global array | integrations/keys-and-wordpress.ts:65 |
platform:integrations-webhook-config:<tenant> / -logs:<tenant> | Webhook config and logs fallback | state-schemas.ts:344-350 |
platform:delivery-logs:<tenant> / platform:delivery-counters:<tenant> | Per-tenant log and per-UTC-day counters | shared/delivery-log.ts:66-72 |
platform:newsroom-profile:<scope> | Newsroom profile fallback copy | newsroom-preferences.ts:406 |
platform:story-feed-cache:v1:<scopeKey> | Persisted feed cache — the only TTL'd key | rss-aggregation.ts:696 |
platform:email-suppressions:marketing / :bounces (hash) | Unsubscribe and hard-bounce tokens | src/lib/email/suppression.ts:8-9 |
platform:ai-envelope-threshold-firings:<tenant> (hash) | At-most-once threshold firing receipts | ai/usage-threshold-notifier.ts:33 |
The platform: prefix is not proof of a platform_state row. platform:cache:newsroom-profile:<scope>
(30 s TTL) and platform:cache:newsroom-source-catalog (5 min TTL) live in Redis, not in the
table (src/lib/platform/newsroom-preferences.ts:187-188, read at :360).
Expiry and cleanup
writeState and mutateState always write expires_at: null. Exactly one writer sets an expiry:
the persisted feed cache, using ERMIS_FEED_CACHE_PERSIST_RETENTION_MS
(src/lib/services/rss-aggregation.ts:794-802).
Reads filter expired rows, but nothing deleted them, so abandoned scope keys — old multi-KB
per-source-set feed caches — accumulated indefinitely. src/app/api/admin/state/cleanup/route.ts
runs daily at 15 3 * * * and deletes:
platform_staterows with a non-nullexpires_atin the past;platform_state_hashrows underplatform:ai-envelope-threshold-firings:%older than 90 days (THRESHOLD_FIRING_RETENTION_DAYS).
It returns { deleted, deletedHashRecords, processedAt }. GET requires CRON_SECRET; POST requires
an operator session with queues admin access.
Email-suppression hash rows are deliberately never pruned — the route prunes only explicitly listed
key families, never the whole hash store (cleanup/route.ts:18-25).
Array caps
KV arrays are bounded, which means they are not counting sources:
| Constant | Value | Where |
|---|---|---|
STORY_DELIVERY_STATE_LIMIT | 5000 | local-platform-data.ts:79 |
ARTICLE_DRAFT_STATE_LIMIT | 5000 | local-platform-data.ts:81 |
STORY_CHAT_SESSION_LIMIT | 24 | local-platform-data.ts:80 |
TENANT_STORY_NOTE_LIMIT | 80 | local-platform-data.ts:82 |
DELIVERY_LOG_LIMIT, TENANT_DELIVERY_LOG_LIMIT, DELIVERY_FAILED_EVENT_LIMIT, REVIEW_STATUS_EVENT_LIMIT | 500 each | shared/delivery-log.ts:35-38 |
TENANT_DELIVERY_COUNTER_RETENTION_DAYS | 35 | shared/delivery-log.ts:40 |
The delivery-log module documents the failure this caused. Delivery records used to live in one
global 500-entry array written read-then-write: concurrent appends lost entries, and once
platform-wide volume passed 500 entries per month the tenant usage page silently undercounted. The
fix was to separate the three concerns — a bounded per-tenant raw log for dedup and inspection,
exact per-UTC-day counters as the usage page's source of truth, and a bounded global mirror for the
admin dashboard — and to route every write through mutateState
(src/lib/platform/shared/delivery-log.ts:1-18).
Read-then-writeState on a shared array is a lost-update bug. Use mutateState. If you need an
exact count, add a counter — do not count a bounded array.
Hybrid domains: which side is authoritative
Several domains have a table and a KV key, selected at runtime by a repository-availability gate. The table is primary; the KV key is a mirror or fallback.
| Domain | Primary | Fallback key | Selector |
|---|---|---|---|
| Editorial drafts | editorial_story_drafts | platform:editorial:article-drafts:<tenant> | src/lib/platform/drafts/tenant-draft-store.ts |
| Story chat | story_chat_sessions (+ payloads) | platform:story-chat-sessions:… | local-platform-data.ts:135, used from :2203 |
| Editor queue | editor_queue_items | platform:editor-queue:* | local-platform-data.ts:168, used from :1404 |
| Newsroom profile | tenant_newsrooms | platform:newsroom-profile:<scope> | newsroom-preferences.ts:1004-1008 |
| Integrations (keys, WordPress, webhooks) | tenant_integration_api_keys, tenant_wordpress_connections, tenant_webhook_configs, tenant_webhook_logs | platform:integrations-* | shared/integrations-gateway.ts:62-81 |
Two behaviours are worth knowing before you touch these:
- Successful DB writes are still mirrored to KV. The draft upsert writes the DB under a
compare-and-set, then reflects the post-write version into the KV copy so the cache and the DB do
not disagree by one (
tenant-draft-store.ts:506-522). The newsroom profile does the same. - The integrations gateway returns
{ ok: false }rather than throwing.runWithIntegrationsRepositoryhands back a discriminated result and the caller uses the KV records (integrations-gateway.ts:62-81).
Availability is not permanent. createRepositoryAvailability
(src/lib/runtime/repository-availability.ts:20-67) disables a repository for a 60 second
cooldown (DEFAULT_REPOSITORY_DISABLE_COOLDOWN_MS = 60_000), logs the first disable, and re-logs
once per cooldown window so an ongoing outage stays visible instead of being swallowed.
Production has no in-memory fallback
src/lib/platform/shared/persistence.ts decides this in two functions:
export function isInMemoryStoreForced() {
if (process.env.VERCEL_ENV) {
return false
}
return process.env.ERMIS_FORCE_INMEMORY_STORE === 'true'
}
export function isLocalStateFallbackAllowed() {
if (isInMemoryStoreForced()) {
return true
}
if (process.env.VERCEL_ENV) {
return false
}
return process.env.NODE_ENV !== 'production'
}When fallback is not allowed, a failure throws
[local-platform-data] <surface> is required in production after <stage>: <error>
(persistence.ts:36-46).
An unreachable or unmigrated Postgres 503s every authenticated surface. This is deliberate,
not a gap. There is no degraded read-only mode for tenant state. GET /api/health reports
checks.database.migrated: false with the detail
"platform_state unreachable (run db:migrate before serving traffic): …" and returns HTTP 503
(src/app/api/health/route.ts:53-77, :144). Redis degradation is reported but never fails
readiness.
ERMIS_FORCE_INMEMORY_STORE=true is a local-only switch. The presence of VERCEL_ENV — production
or preview — wins over the flag, so a stray value copied into a deployment environment can never
route tenant state to memory.
The Redis boundary
src/lib/redis/client.ts:17-39 returns a cached singleton, or null when
UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN are absent or construction fails (logged
[redis] Upstash client disabled: …). Every consumer must tolerate null.
That is the complete list of consumers:
| Consumer | Keys | Failure mode |
|---|---|---|
Edge rate limiting (src/lib/api/rate-limit.ts) | ermis:rl:<scope>:<key> sliding windows | Fails open |
AI usage guardrail windows (src/lib/ai/usage-guardrails.ts:29-105) | ermis:ai:tenant:rpm, ermis:ai:user:rp5h | Falls back to per-process counters; the per-tenant spend guardrail stays fail-closed |
AI ledger retry queue (src/lib/ai/ledger-retry-queue.ts) | Lists ermis:ai:ledger:retry:queue, …:dlq, …:processing | Deliberately not in Postgres — the failure it covers is "Postgres unavailable" |
Compose-stage response cache (src/lib/ai/cache-middleware.ts) | ermis:ai-cache:<stage>:…, 6 h default TTL, 128 KB max payload, wrapGenerate only | In-memory LRU of 256 entries; fails open |
Feed build lock (src/lib/services/rss-aggregation.ts:853-920) | ermis:feed:build-lock:<scopeKey>, SET NX PX, 7 min TTL | Fails open with a bypass token so builds always proceed |
Newsroom profile and source-catalog caches (src/lib/platform/newsroom-preferences.ts:360) | platform:cache:*, 30 s and 5 min TTL | Cache miss |
Health probe (src/app/api/health/route.ts:80-96) | PING | Reported, non-fatal |
The ledger retry queue claims work with LMOVE into a processing list rather than RPOP, so a
crash strands at most one entry, and a reclaim loop drains the processing list at the start of each
redrive (ledger-retry-queue.ts:166-189). Redrive is safe to race because ledger persistence is
idempotent — a stable client-supplied row id makes re-insert a PK no-op. MAX_ATTEMPTS is 10.
The feed build lock TTL is 7 minutes because a 4-minute TTL expired while minute-five builds were
still running and a concurrent trigger started a duplicate whole-catalog AI build. It must exceed
the 300 s consumer maxDuration.
REDIS_URL / KV_URL are unrelated to Upstash here — they are required only when
NEXT_PUBLIC_ERMIS_CHAT_STREAM_MODE=resume for resumable chat streams.
Object storage
src/lib/storage/object-storage.ts supports two providers: 'supabase' and 'vercel-blob'.
resolvePreferredProvider() (object-storage.ts:101-117) takes an explicit OBJECT_STORAGE_PROVIDER
first; otherwise Supabase if SUPABASE_URL/NEXT_PUBLIC_SUPABASE_URL plus
SUPABASE_SERVICE_ROLE_KEY are set; otherwise Vercel Blob if BLOB_READ_WRITE_TOKEN or
BLOB_STORE_ID (the OIDC path) is set; otherwise null.
Path normalization is security-relevant. Leading slashes are stripped and empty, . and ..
segments dropped, so a caller-supplied path cannot traverse out of its prefix in a shared public
bucket; an empty result throws "Invalid object storage path" (object-storage.ts:67-83).
Public objects
uploadPublicObject defaults to cache-control: 31536000. Two callers:
src/app/api/files/upload/route.ts:68— keyuploads/<userId>/<uuid>.<png|jpg>, derived server-side and never from the client filename (route.ts:59-63).src/lib/sources/source-icon-resolver.ts:451— keysource-icons/<host>.
getPublicObjectUrl(path) builds <url>/storage/v1/object/public/<bucket>/<path> on Supabase.
Vercel Blob has no path-to-URL helper, so it needs BLOB_PUBLIC_BASE_URL and returns null without
it (object-storage.ts:251-279).
The private archive split
Webhook payload archives carry PII — Clerk and Polar webhook bodies contain emails, names and
billing ids — so they use a true access: 'private' ACL.
Vercel Blob store access is fixed at store creation. The shared public store rejects private
puts outright, which is what broke the Clerk and Polar webhook archive path on 2026-07-22. Private
archives therefore live on a separate private store, reached through
BLOB_ARCHIVE_READ_WRITE_TOKEN. An in-place put({ access: 'private' }) against the public store
fails; converting requires creating a new private store and moving the objects with
scripts/storage/fix-archive-blob-access.ts.
uploadPrivateArchiveObject (object-storage.ts:281-319) prefixes the path with archive/, sets
addRandomSuffix: true, stamps bucket with the marker 'private-archive', and nulls the
returned url so no fetchable location is ever persisted. Deletion works from the pathname alone.
Without the archive token the archive is not written at all and the function returns null; the
caller then inlines the payload in its DB row (object-storage.ts:301-305). That is still
non-public, but see the retention trap below.
deletePrivateArchiveObject(locator) (object-storage.ts:337-381) routes by the recorded
provider and bucket, not the currently configured one, so post-cutover deletes still find
pre-cutover objects. bucket === 'private-archive' means the private store and requires the archive
token — it throws a descriptive error if the token is missing. bucket === null means a pre-split
row on the shared public store.
removePublicObjectsByPrefix(prefix) (object-storage.ts:470-492) is what GDPR erasure uses to
purge uploads/<userId>/. The Supabase path walks the folder tree with 1000-item pagination and
removes in batches; the Blob path pages list() and deletes each page before fetching the next
cursor.
Payload archive and retention
src/lib/storage/payload-archive.ts computes a SHA-256 checksum, a preview capped at 2048
characters, and a path archives/<domain>/<YYYY>/<MM>/<DD>/<uuid>.json. If the upload returns
null, the record keeps payload inline and leaves every storage column null
(payload-archive.ts:53-63). Callers are src/lib/db/clerk-user-repository.ts:781
(domain clerk-webhooks) and src/lib/db/billing-repository.ts:926 (domain billing-webhooks).
Both repositories prune webhook rows older than 30 days (WEBHOOK_EVENT_RETENTION_DAYS) at most
every 6 hours (WEBHOOK_EVENT_PRUNE_INTERVAL_MS) and delete the corresponding blobs.
Rows with inline payloads are never pruned. Both prune queries require
payload_storage_path IS NOT NULL (clerk-user-repository.ts:79-88,
billing-repository.ts:200-208). So when BLOB_ARCHIVE_READ_WRITE_TOKEN is unset and payloads
inline into the payload column, those PII-bearing rows grow without bound and no retention job
can reach them. BLOB_ARCHIVE_READ_WRITE_TOKEN is tracked as a launch-critical variable for
exactly this reason (src/lib/platform/launch-readiness.ts:31-41), reported under config in
GET /api/health.
Where a tenant's data actually lives
Useful when answering "what is stored for this workspace". The GDPR purge is the authoritative
enumeration — one transaction deletes, in order: ai_usage_metering_outbox, ai_usage_ledger,
editor_queue_items, editorial_story_drafts, story_chat_session_payloads, story_chat_sessions,
tenant_webhook_logs, tenant_webhook_configs, tenant_wordpress_connections,
tenant_integration_api_keys, tenant_newsrooms, billing_checkout_sessions,
billing_portal_sessions, billing_invoices, tenant_subscriptions, tenant_memberships, then
the tenant's slice of platform_state and platform_state_hash matched by
LIKE '%<escaped scope id>%' (purgeTenantData, src/lib/db/data-erasure-repository.ts:103).
After commit it purges uploads/<userId>/ and drains the Redis ledger retry queue for the scope.
Per-table counts land in data_erasure_requests.purge_report.
That LIKE '%<scope>%' match is only possible because every KV key embeds the scope id. A key that
does not carry the scope id is invisible to erasure. The function refuses a blank scope id outright,
because a blank one would turn the scoped delete into LIKE '%%' and wipe every tenant's state
(data-erasure-repository.ts:115-120).
Secrets at rest
src/lib/security/secret-encryption.ts wraps tenant outbound-webhook secrets in an AES-256-GCM
envelope, enc:v1:<iv>:<authTag>:<ciphertext>, keyed by WEBHOOK_SECRET_ENCRYPTION_KEY (32 bytes,
base64 or hex).
When the key is unset, encryptSecret is a no-op returning plaintext and decryptSecret passes
through anything without the enc:v1: prefix. That makes the feature switchable on later with no
migration — and means an unset key stores webhook secrets in the clear, silently.
Migration layout
Migrations use the drizzle-kit v1 rc layout: one directory per migration containing
migration.sql and snapshot.json. There is deliberately no drizzle/meta/ directory and no
_journal.json; applied state lives in the drizzle.__drizzle_migrations table in the database.
Nineteen migration directories exist, from 20260507070419_fearless_praxagora (initial schema) to
20260722020726_statement_timeout_role_default.
Full rules — the production migrate-then-build gate, enum ordering, and the direct-versus-pooled URL split — are in Migrations and schema changes.
Migrations and schema changes
The drizzle-kit v1 layout, the production migrate gate, and the rules that break migrations here.
Tenant scoping and isolation
How tenant scope ids work and how isolation is actually enforced.
Scheduled jobs and the queue subsystem
The crons that prune state, drain outboxes and refresh the feed.
GDPR erasure runbook
What a purge deletes, and the backstops it cannot reach.
Environment variable reference
Every persistence variable, its default, and what breaks when it is unset.
Local development setup
Running with or without Postgres, and the pins that matter.
