Przejdź do treści
ErmisAI

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.

#VendorFunctionData region as published
01ClerkAuthentication & account managementUnited States
02CapyDB (Postgres)Primary databaseEuropean Union - Hetzner (Helsinki, Finland)
03Upstash (Redis)Cache, rate limiting & queuesEuropean Union / United States (regional endpoint)
04PolarBilling & subscription managementUnited States
05Vercel (Hosting + Blob storage)Application hosting, edge delivery & object storageEuropean Union (arn1, Stockholm) + global edge; Blob storage in Vercel-managed regions
06Vercel AI Gateway + Azure OpenAIAI model inferenceEuropean Union / United States (Azure regions)
07ResendEmail deliveryUnited States
08SentryError & performance monitoringEuropean 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

SubprocessorData it receivesWhere that is in code
ClerkUser identity, sign-in and session handling, organization membershipMirrored 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 recordsrc/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 cachessrc/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
PolarCheckout, subscriptions, invoices, card processingMirrored into billing_plans, tenant_subscriptions, billing_invoices, billing_checkout_sessions, billing_portal_sessions (schema.ts:206-317). Card data never reaches ErmisAI
VercelServerless compute, routing, CDN, and object storagevercel.json; src/lib/storage/object-storage.ts
AI inferencePrompt content: source article text, the saved article body, chat messages, and system promptssrc/lib/ai/gateway.ts, src/lib/ai/azure.ts; mode selected by ERMIS_AI_PROVIDER_MODE
ResendRecipient addresses and rendered message bodiesTemplates in src/emails/ — welcome, team invite, upsell, erasure confirmation, waitlist invite, launch campaign
SentryError events and performance tracessrc/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

CategoryStoreNotes
Accounts, sessions, org membershipClerk (US), mirrored to Postgresapp_role is read from the live JWT, never the DB mirror
Newsroom settings, sources, tagsPostgres tenant_newsrooms
Drafts, articles, story snapshotsPostgres editorial_story_drafts
Chat transcriptsPostgres story_chat_session_payloads
Alert rules, alert history, notifications, delivery logsPostgres platform_state / platform_state_hashKeyed platform:<domain>:<scope>
API key materialPostgres tenant_integration_api_keysStored as sha256(rawKey) hex with a ermis_••••••••<last4> mask; the raw key is shown once
Outbound webhook secretsPostgres tenant_webhook_configsAES-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 recordsPostgres ai_usage_ledger, ai_usage_metering_outboxNo prompt or response content
Billing statePolar (US), mirrored to Postgres
Uploaded imagesPublic object store, key uploads/<userId>/<uuid>.<png|jpg>Public-read
Source iconsPublic object store, key source-icons/<host>Public-read
Clerk and Polar webhook bodiesPrivate archive store, archive/ prefixContains PII; see below
Email suppression tokensPostgres platform_state_hashHMAC-SHA256-base64url of the lowercased address; no plaintext address stored
Error and performance telemetrySentryIdentifiers 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/healthconfig 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 claimPinned in the repo?What supplies it
Compute in Stockholm (arn1)Yesvercel.json"regions": ["arn1"]
Postgres in Hetzner HelsinkiNoThe 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 GermanyNoThe 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"NoUPSTASH_REDIS_REST_URL only
Azure OpenAI regionNoAZURE_BASE_URL / AZURE_RESOURCE_NAME (src/lib/ai/azure.ts:48,76-77)
Blob "Vercel-managed regions"NoStore configuration
Clerk, Polar, Resend in the USNoProvider 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

DataHorizonMechanism
Clerk webhook rows + their archived blobs30 daysWEBHOOK_EVENT_RETENTION_DAYS = 30, opportunistic prune at most every 6 hours (clerk-user-repository.ts:17-18)
Polar webhook rows + their archived blobs30 daysSame constants (billing-repository.ts:106-107)
Per-tenant delivery counters35 daysTENANT_DELIVERY_COUNTER_RETENTION_DAYS = 35 (src/lib/platform/shared/delivery-log.ts:40)
AI envelope threshold firings90 daysTHRESHOLD_FIRING_RETENTION_DAYS = 90, daily cron (src/app/api/admin/state/cleanup/route.ts:24)
Persisted feed cacheexpires_at from ERMIS_FEED_CACHE_PERSIST_RETENTION_MS, default 3 hoursSwept by the same daily cron
Compose response cache (Redis)6 hourscache-middleware.ts:32
Email suppression tokensNever pruned, by designExplicitly exempted (state/cleanup/route.ts:18-25); they survive erasure as a legitimate-interest record and hold no plaintext address
Erasure request rowsRetained indefinitelypurged/cancelled rows are the audit trail
Everything else tenant-scopedUntil the tenant or user is purgedSee 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. recordInputs and recordOutputs are false at 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. enrichSpan attaches ermis.stage, ermis.surface, ermis.billing_scope, ermis.story_id and ermis.route_key. The tenant scope id passes through redactTenantScopeId(), 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.

  • /subprocessors is missing from the sitemap. src/app/sitemap.ts:6-19 lists 13 public paths; /security is among them and /subprocessors is not. The page is reachable, allow-listed in src/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 gateway mode 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 is openai/gpt-5-mini (src/lib/ai/models.ts:6). Before answering "which model providers process our data", read the deployment's ERMIS_AI_PROVIDER_MODE and 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 /dpa route, 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_KEY covers 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_CONFIRMED and ERMIS_AI_PRIVACY_DISCLOSURE_CONFIRMED (plus _AT) are read as env booleans and surfaced in GET /api/admin/ai/usageoperationalReadiness (src/app/api/admin/ai/usage/route.ts:52-99). Nothing checks them against a provider.
  • The non-claims on /security are 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".

Na tej stronie