Data residency and subprocessors
Where ErmisAI data physically lives, which subprocessors touch it, and how to keep the public disclosures accurate.
The customer-facing answer is published at https://ermisai.com/subprocessors. Its source file describes itself as "the single public source of truth" for sub-processors (src/app/[locale]/subprocessors/page.tsx:18-25), and both the privacy notice (§4 Service providers and infrastructure) and the GDPR page (§5 Transfers and subprocessors) point at it.
This page is the engineering backing for that disclosure: which store holds which category of data, what the repository actually proves about location, and what is deployment configuration you have to confirm elsewhere before you write it into a questionnaire.
The repository pins exactly one region. vercel.json declares "regions": ["arn1"] for compute. Every other region in the published list — Helsinki, Germany, "regional endpoint", "Azure regions" — comes from a connection string, a DSN or a provider dashboard, and is asserted nowhere in code. See What the code actually pins.
The published list
Eight entries, rendered in this order from a hard-coded array at src/app/[locale]/subprocessors/page.tsx:26-35. Each row shows three labelled fields: Function, Purpose, and Data region.
| # | Vendor | Function | Data region as published |
|---|---|---|---|
| 01 | Clerk | Authentication & account management | United States |
| 02 | CapyDB (Postgres) | Primary database | European Union - Hetzner (Helsinki, Finland) |
| 03 | Upstash (Redis) | Cache, rate limiting & queues | European Union / United States (regional endpoint) |
| 04 | Polar | Billing & subscription management | United States |
| 05 | Vercel (Hosting + Blob storage) | Application hosting, edge delivery & object storage | European Union (arn1, Stockholm) + global edge; Blob storage in Vercel-managed regions |
| 06 | Vercel AI Gateway + Azure OpenAI | AI model inference | European Union / United States (Azure regions) |
| 07 | Resend | Email delivery | United States |
| 08 | Sentry | Error & performance monitoring | European Union (Germany) |
Vendor names are proper nouns and are not translated. The function, purpose and region strings are message keys under legal.subprocessors.entries.<key> and exist in all ten locale files.
The /security page describes the same infrastructure by category, not by vendor — its five cards are keyed clerk, polar, database, upstash, vercel but the copy names none of them, and its intro says: "Specific provider names and DPA terms are available before contract." That sentence predates the /subprocessors page. The names are public now.
What each subprocessor actually holds
| Subprocessor | Data it receives | Where that is in code |
|---|---|---|
| Clerk | User identity, sign-in and session handling, organization membership | Mirrored into clerk_users (src/lib/db/schema.ts:346): primary_email, first_name, last_name, image_url, org_id, org_name, org_slug, org_role |
| CapyDB (Postgres) | Everything durable — the system of record | src/lib/db/schema.ts (25 tables) plus the runtime KV tables platform_state (schema.ts:870) and platform_state_hash (schema.ts:889) |
| Upstash (Redis) | Rate-limit counters, AI guardrail windows, the AI ledger retry queue, the feed build lock, and two short caches | src/lib/api/rate-limit.ts, src/lib/ai/usage-guardrails.ts, src/lib/ai/ledger-retry-queue.ts, src/lib/services/rss-aggregation.ts:853-920, src/lib/ai/cache-middleware.ts, src/lib/platform/newsroom-preferences.ts:360 |
| Polar | Checkout, subscriptions, invoices, card processing | Mirrored into billing_plans, tenant_subscriptions, billing_invoices, billing_checkout_sessions, billing_portal_sessions (schema.ts:206-317). Card data never reaches ErmisAI |
| Vercel | Serverless compute, routing, CDN, and object storage | vercel.json; src/lib/storage/object-storage.ts |
| AI inference | Prompt content: source article text, the saved article body, chat messages, and system prompts | src/lib/ai/gateway.ts, src/lib/ai/azure.ts; mode selected by ERMIS_AI_PROVIDER_MODE |
| Resend | Recipient addresses and rendered message bodies | Templates in src/emails/ — welcome, team invite, upsell, erasure confirmation, waitlist invite, launch campaign |
| Sentry | Error events and performance traces | src/instrumentation.ts (server/edge), src/instrumentation-client.ts (browser) |
Two details worth having straight before a review call:
Postgres holds the editorial content itself, not just metadata. editorial_story_drafts (schema.ts:530) stores the headline and body plus a story_snapshot jsonb column carrying the whole story, and story_chat_session_payloads.messages (schema.ts:502) stores complete chat transcripts. Stories, clusters and articles are not separate tables — they live inside those two.
Redis is short-lived, but it is not content-free. The /security page says of the cache layer: "No persistent sensitive data stored here." That is accurate about persistence. It is worth knowing that the compose cache stores generated model output for up to six hours (DEFAULT_TTL_SECONDS = 6 * 60 * 60, src/lib/ai/cache-middleware.ts:32; on by default — the disableComposeCache flag defaults to false), and the newsroom-profile cache holds profile data for 30 seconds. Nothing in Redis is application state; the ledger retry queue is deliberately there precisely because the failure it covers is "Postgres is unavailable".
Where each data category lives
| Category | Store | Notes |
|---|---|---|
| Accounts, sessions, org membership | Clerk (US), mirrored to Postgres | app_role is read from the live JWT, never the DB mirror |
| Newsroom settings, sources, tags | Postgres tenant_newsrooms | |
| Drafts, articles, story snapshots | Postgres editorial_story_drafts | |
| Chat transcripts | Postgres story_chat_session_payloads | |
| Alert rules, alert history, notifications, delivery logs | Postgres platform_state / platform_state_hash | Keyed platform:<domain>:<scope> |
| API key material | Postgres tenant_integration_api_keys | Stored as sha256(rawKey) hex with a ermis_••••••••<last4> mask; the raw key is shown once |
| Outbound webhook secrets | Postgres tenant_webhook_configs | AES-256-GCM at rest only when WEBHOOK_SECRET_ENCRYPTION_KEY is set; unset is a silent no-op that stores plaintext |
| AI usage and cost records | Postgres ai_usage_ledger, ai_usage_metering_outbox | No prompt or response content |
| Billing state | Polar (US), mirrored to Postgres | |
| Uploaded images | Public object store, key uploads/<userId>/<uuid>.<png|jpg> | Public-read |
| Source icons | Public object store, key source-icons/<host> | Public-read |
| Clerk and Polar webhook bodies | Private archive store, archive/ prefix | Contains PII; see below |
| Email suppression tokens | Postgres platform_state_hash | HMAC-SHA256-base64url of the lowercased address; no plaintext address stored |
| Error and performance telemetry | Sentry | Identifiers only; see Telemetry boundaries |
Object storage: two stores, not one
src/lib/storage/object-storage.ts supports supabase and vercel-blob, selected by OBJECT_STORAGE_PROVIDER or auto-detected (object-storage.ts:101-117). Production moved to Vercel Blob in July 2026 (README.md:137-150); the Supabase paths remain because deletion routes by each row's recorded provider, not the configured one.
Public objects go to the default store with a one-year cache-control (DEFAULT_PUBLIC_CACHE_CONTROL = '31536000'). Anyone holding an upload URL can read it. Object keys are derived server-side from the validated MIME type plus a random UUID — never from the client filename (src/app/api/files/upload/route.ts:59-63) — and normalizePath() drops ./.. segments so a caller-supplied path cannot traverse out of its prefix.
Private archives are different. Clerk and Polar webhook bodies carry emails, names and billing ids, so uploadPrivateArchiveObject() (object-storage.ts:281-319) writes them with access: 'private' and addRandomSuffix: true to a separate private Blob store reached via BLOB_ARCHIVE_READ_WRITE_TOKEN, and nulls the returned url so no fetchable location is ever persisted. The bucket column is stamped 'private-archive' so deletes route to the right store.
The two stores exist because Vercel Blob store access is fixed at store creation: the shared public store rejects private puts outright. That is not theoretical — it caused a webhook outage on 2026-07-22, documented in the module's own comments (object-storage.ts:37-42).
An unset BLOB_ARCHIVE_READ_WRITE_TOKEN creates PII that no retention job can reach. Without the token nothing is written to the archive store and the caller inlines the webhook body into its DB row with payload_storage_path = NULL. Both retention prunes require payload_storage_path IS NOT NULL (src/lib/db/clerk-user-repository.ts:86, src/lib/db/billing-repository.ts:205), so those rows are never pruned. The token is tracked as a launch-critical ops entry — check GET /api/health → config before quoting the 30-day horizon to anyone.
What the code actually pins
This is the section to read before answering a residency question in writing.
| Published claim | Pinned in the repo? | What supplies it |
|---|---|---|
Compute in Stockholm (arn1) | Yes | vercel.json → "regions": ["arn1"] |
| Postgres in Hetzner Helsinki | No | The client only recognises the hostname suffix .db.capydb.dev and the port convention — 6432 pooled, 5432 direct (src/lib/db/client.ts:32-48). Location is a CapyDB account fact |
| Sentry in Germany | No | The CSP ingest origin is derived from NEXT_PUBLIC_SENTRY_DSN at request time (src/proxy.ts:120-130). Region is whatever the DSN encodes |
| Upstash "regional endpoint" | No | UPSTASH_REDIS_REST_URL only |
| Azure OpenAI region | No | AZURE_BASE_URL / AZURE_RESOURCE_NAME (src/lib/ai/azure.ts:48,76-77) |
| Blob "Vercel-managed regions" | No | Store configuration |
| Clerk, Polar, Resend in the US | No | Provider account configuration |
So the honest formulation is: compute region is enforced by deployment configuration in the repository; every storage and processing region is a property of the provider account and is verified in the provider console, not in the code. Do not claim more than that from a code review.
Cross-border processing
Five of the eight entries carry a non-EU or mixed region: Clerk (US), Polar (US), Resend (US), Upstash (EU/US), and AI inference (EU/US). The published safeguard language is deliberately thin.
Privacy notice §9 Transfers, verbatim: "Some providers may process data outside the country where the customer is based. Where that happens, ErmisAI relies on the safeguards made available through the relevant provider terms and customer contract set."
GDPR page §5, verbatim: "Where those providers act as subprocessors or importers, the relevant provider terms and customer contract govern the transfer safeguards. A complete list of sub-processors is published on the Sub-processors page."
There is no transfer-impact assessment, no SCC artefact and no DPA document in the repository. The only DPA reference in shipped copy is that terms are "available before contract" or on request to the privacy contact.
Retention horizons
| Data | Horizon | Mechanism |
|---|---|---|
| Clerk webhook rows + their archived blobs | 30 days | WEBHOOK_EVENT_RETENTION_DAYS = 30, opportunistic prune at most every 6 hours (clerk-user-repository.ts:17-18) |
| Polar webhook rows + their archived blobs | 30 days | Same constants (billing-repository.ts:106-107) |
| Per-tenant delivery counters | 35 days | TENANT_DELIVERY_COUNTER_RETENTION_DAYS = 35 (src/lib/platform/shared/delivery-log.ts:40) |
| AI envelope threshold firings | 90 days | THRESHOLD_FIRING_RETENTION_DAYS = 90, daily cron (src/app/api/admin/state/cleanup/route.ts:24) |
| Persisted feed cache | expires_at from ERMIS_FEED_CACHE_PERSIST_RETENTION_MS, default 3 hours | Swept by the same daily cron |
| Compose response cache (Redis) | 6 hours | cache-middleware.ts:32 |
| Email suppression tokens | Never pruned, by design | Explicitly exempted (state/cleanup/route.ts:18-25); they survive erasure as a legitimate-interest record and hold no plaintext address |
| Erasure request rows | Retained indefinitely | purged/cancelled rows are the audit trail |
| Everything else tenant-scoped | Until the tenant or user is purged | See the GDPR erasure runbook |
The only job that deletes expired platform_state rows is /api/admin/state/cleanup, scheduled 15 3 * * * (03:15 UTC) in vercel.json. Reads already filter expired rows, so nothing looks wrong when this job is not running — the rows simply accumulate.
Provider backups are outside all of this. The erasure completion email says so to the data subject: "Residual copies in encrypted infrastructure backups expire on their regular rotation schedule, and records we must keep for legal or accounting obligations are retained only as long as the law requires."
Telemetry boundaries
Two controls make the AI-content answer defensible:
- Prompt and response content never reaches telemetry.
recordInputsandrecordOutputsarefalseat every AI call site — story chat (src/app/api/stories/[storyId]/chat/route.ts:687-688), completion (.../completion/route.ts:355-356), compose (src/lib/services/story-compose.ts:840-841), and both pipeline stages (src/lib/services/rss-aggregation.ts:2600-2601, 3014-3015). - Span attributes are identifiers only.
enrichSpanattachesermis.stage,ermis.surface,ermis.billing_scope,ermis.story_idandermis.route_key. The tenant scope id passes throughredactTenantScopeId(), which emits<scopeType>:<sha256-prefix>because personal scopes embed the Clerk user id (src/lib/telemetry/ai-runtime.ts:60-75).
Client-side Sentry starts only after the visitor accepts optional cookies (hasTelemetryConsent(), src/instrumentation-client.ts) and stops live on withdrawal. Server-side Sentry is unaffected by consent and is inert when no DSN is set. Production traces sample at 10%.
The published AI statement — privacy notice §5, verbatim: "Prompt and response content is not used to train models." — is a provider-terms claim, not something the application enforces. What the application enforces is the two controls above.
Keeping the disclosures accurate
Edit the vendor array. SUBPROCESSORS in src/app/[locale]/subprocessors/page.tsx:26-35. The name is the display string; the key selects the message entry.
Add category, purpose and region under legal.subprocessors.entries.<key> in all ten messages/*.json files. tests/locale-parity.vitest.ts fails CI if any locale drifts, so a change to messages/en.json alone will not merge.
Update the effective date. All six legal pages render one shared string, legal.shared.lastUpdated = "Last updated 25 June 2026". There is no per-page effective date — changing it moves the date on privacy, terms, cookies, GDPR, AI policy and sub-processors together.
Check whether the change also affects /security. Its five infrastructure cards are category-level and name no vendor, but the database and vercel cards do state regions in prose.
Gaps to state plainly
Anyone answering a questionnaire should know these before a reviewer finds them.
/subprocessorsis missing from the sitemap.src/app/sitemap.ts:6-19lists 13 public paths;/securityis among them and/subprocessorsis not. The page is reachable, allow-listed insrc/proxy.ts, and linked from the footer Legal group — it is just not in the generated sitemap.- The AI entry may under-name the upstream providers. In
gatewaymode the provider registry supports six upstream providers —openai,anthropic,google,xai,deepseek,minimax(src/lib/ai/provider-registry.ts:18-26) — while the published entry names "Vercel AI Gateway + Azure OpenAI". The default gateway model isopenai/gpt-5-mini(src/lib/ai/models.ts:6). Before answering "which model providers process our data", read the deployment'sERMIS_AI_PROVIDER_MODEand the persisted per-stage model configuration; env overrides the persisted value for provider mode (src/lib/platform/ai-runtime-config.ts:133-141). - There is no DPA artefact. No
/dparoute, no downloadable document, no self-serve request form. All legal copy routes the request to the privacy contact. - Secret encryption is optional and silently off without a key.
WEBHOOK_SECRET_ENCRYPTION_KEYcovers tenant outbound-webhook HMAC secrets and nothing else; unset,encryptSecret()returns plaintext (src/lib/security/secret-encryption.ts:55-60). Do not answer "secrets are encrypted at rest" without checking that the key is set. - DPA and spend-alert confirmations are operator attestations, not verified integrations.
ERMIS_<PROVIDER>_DPA_CONFIRMED,ERMIS_<PROVIDER>_SPEND_ALERTS_CONFIRMEDandERMIS_AI_PRIVACY_DISCLOSURE_CONFIRMED(plus_AT) are read as env booleans and surfaced inGET /api/admin/ai/usage→operationalReadiness(src/app/api/admin/ai/usage/route.ts:52-99). Nothing checks them against a provider. - The non-claims on
/securityare load-bearing and must not be upgraded: "No certification unless published.", "No public bug bounty.", "No uptime SLA unless contracted.", "No prevention of AI editorial errors.", "No substitute for publisher rights review."
Governing law
Terms §11: the agreement is governed by "the laws of Greece and applicable European Union law", and "The courts of Athens, Greece have exclusive jurisdiction". The operator's legal name is interpolated from NEXT_PUBLIC_ERMIS_LEGAL_NAME into the terms and privacy body copy; when it is unset the copy falls back to reading "operated by ErmisAI".
Security controls
CSP, headers, SSRF guard, rate limiting, and the controls the product does not claim.
GDPR erasure runbook
How data is actually deleted, and the four backstops a purge does not cover.
Database schema and runtime state
The 25 tables and two KV tables behind every row in the residency map.
Tenant scoping and isolation
How one customer's data is kept apart from another's.
Environment variable reference
Every variable named here, with its default and its failure mode.
Your account and your data
The same subject matter written for an end user.
