Saltar para o conteúdo
ErmisAI

GDPR erasure runbook

The erasure request lifecycle, the grace period, the purge job, and how to verify a purge completed.

ErmisAI implements erasure as a grace-period soft-delete. A deletion event opens a pending row in data_erasure_requests with a scheduled_purge_at timestamp. Once that timestamp passes, a daily cron executes the irreversible purge automatically. Nobody has to remember to press a button, and a stray organization.deleted webhook cannot destroy a newsroom's data on the spot.

Everything below is HTTP and SQL. There is no admin screen.

There is no erasure UI. Nothing under src/app/(internal)/admin/ references erasure — the admin nav has Platform review, Tenants, Source catalog, Queues, Feature flags, AI config, Costs and Deliveries, and none of them touch this subsystem. The API and this page are the interface.

The code: src/lib/db/data-erasure-repository.ts (lifecycle and purge), src/app/api/admin/erasure/route.ts (operator API), src/app/api/admin/erasure/purge/route.ts (cron), src/lib/db/schema.ts:911-972 (table), docs/ops/gdpr-erasure-runbook.md (in-repo runbook).

Who can act

Both erasure endpoints gate on canAccessAdminSurface(appRole, 'tenants'), which resolves to ['super_admin'] and nothing else (src/lib/auth/platform-roles.ts:25-35). editor and ops cannot list, open, confirm or cancel a request. The gate is deliberate and commented as such in src/app/api/admin/erasure/route.ts:31.

appRole comes from the live Clerk JWT claim app_role, never the clerk_users.app_role snapshot, so a demotion takes effect on the next token refresh.

EndpointMethodAuth
/api/admin/erasureGET, POSTClerk session, super_admin
/api/admin/erasure/purgePOSTClerk session, super_admin
/api/admin/erasure/purgeGETAuthorization: Bearer ${CRON_SECRET}

The purge path is allow-listed past Clerk middleware in src/proxy.ts so the Vercel cron's bearer request reaches the handler; the handler self-protects. /api/admin/erasure is not allow-listed and carries its own gate as well.

How a request is opened

Three entry points, all of which land on createDataErasureRequest() (data-erasure-repository.ts:353).

Self-service. The user opens Settings → Account and presses "Delete account", confirms in the dialog with "Yes, delete my account", and the client calls DELETE /api/account with body { "confirm": true }. The route opens the user erasure request before calling clerk.users.deleteUser(userId) (src/app/api/account/route.ts:52-63), because webhook delivery is not guaranteed — a lost user.deleted event used to leave the data retained with no request row and no account left to complain from. The user is signed out immediately (clerk.signOut({ redirectUrl: '/' })). Rate limited on key account:delete: 5/hour per IP, 3/hour per user.

Clerk webhooks. user.deleted opens a user request with requestedBy: 'webhook:user.deleted'. organization.deleted opens a tenant request for org:<id> with requestedBy: 'webhook:organization.deleted', created before clearClerkOrganizationSnapshot so the owner emails and member ids are captured while the membership rows still exist (src/app/api/webhooks/clerk/route.ts:325-357). A failure to open the request emits clerk_webhook_erasure_request_failed telemetry rather than failing the webhook.

Operator. POST /api/admin/erasure with action: "request". This is the path for a DSAR that arrives by email rather than through the product.

The self-service route and the user.deleted webhook both open the same request. That is fine: the create path reads any existing unresolved row first and returns it, and two partial unique indexes (data_erasure_requests_pending_tenant_uq, data_erasure_requests_pending_user_uq, predicate status NOT IN ('purged','cancelled')) make the select-then-insert race-safe under concurrent webhook retries. At most one unresolved request can exist per subject.

Status lifecycle

data_erasure_status values, in declaration order: pending, failed, purged, cancelled, purging. purging is appended last on purpose — Postgres ALTER TYPE … ADD VALUE only appends, and the index predicates above had to be expressible using pre-existing values (schema.ts:615-632).

StatusMeaningNext
pendingRequest open, grace window running or elapsedpurging (claimed) or cancelled
purgingA purge is in flight and holds the claimpurged or failed
purgedTerminal. purge_report, purged_at, confirmed_by recorded
failedThe purge threw. last_purge_error recordedRetried on every cron tick; can be cancelled
cancelledTerminal. Operator stood the request down

failed is not a dead end. It stays in the cron's work list because a purge is an idempotent delete sweep, so it retries every night at 02:45 UTC. The status exists so a chronically failing request is triageable instead of sitting silently in pending with a climbing purge_attempt_count.

The reclaimable purging claim

Before running the purge, confirmDataErasureRequest() flips the row to purging and bumps purge_attempt_count, guarded by a conditional WHERE so a concurrent cancel or a second claimant loses (data-erasure-repository.ts:680-708). Without the claim, a cancel landing between the cron's read and the purge recorded cancelled while the data was already being destroyed.

If the function instance dies mid-purge, the row is stranded in purging. STALE_PURGING_CLAIM_AFTER_MS = 15 * 60 * 1000 (:64) makes such a claim reclaimable after 15 minutes — listDueDataErasureRequests() includes purging rows whose last_purge_attempt_at is older than that, and the next tick picks them up.

Cancel races the purge and loses. cancelDataErasureRequest() only accepts pending and failed (:480-500). Once the cron has flipped a row to purging, action: "cancel" returns 404 and the data is already going. If you need to stand a request down, do it well before 02:45 UTC.

Operator procedures

These routes authenticate with a Clerk session, so the practical way to drive them is from a signed-in super_admin browser tab on https://ermisai.com — open devtools and use fetch on the same origin. The cron GET is the only bearer-authenticated entry point.

List requests

// Newest first, capped at 200. Purged and cancelled rows are the audit trail
// and are retained indefinitely, which is why the listing is capped.
await (await fetch('/api/admin/erasure')).json()
await (await fetch('/api/admin/erasure?status=pending')).json()
await (await fetch('/api/admin/erasure?status=failed')).json()

Response: { "requests": [ … ] }. An unrecognised status value returns 400 "Invalid status filter"; valid values are the five enum values above.

Open a request

await (await fetch('/api/admin/erasure', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    action: 'request',
    subjectType: 'tenant',        // 'tenant' | 'user'
    tenantScopeId: 'org:org_…',   // required when subjectType is 'tenant'
    reason: 'DSAR 2026-07-23',    // optional, max 2000 chars
    graceDays: 14,                // optional integer, 0-90
  }),
})).json()

Returns 201 with { "request": { … } }. For subjectType: "user" pass userId instead of tenantScopeId; the wrong pairing returns 400 ("tenantScopeId is required for a tenant erasure request" / "userId is required for a user erasure request"), and the same invariant is enforced at the schema layer by the data_erasure_requests_subject_present_chk CHECK constraint.

graceDays is the only way to vary the window. DEFAULT_ERASURE_GRACE_DAYS = 14 is a source constant, not an env var — there is no deployment-level override. graceDays: 0 schedules the purge for the current instant, which the next cron tick executes.

Cancel

await (await fetch('/api/admin/erasure', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ action: 'cancel', id: '<request id>' }),
})).json()

404 "No active erasure request with that id" means the request is already purging, purged or cancelled.

Confirm early

Only needed for an expedited request. A request whose window has elapsed is purged automatically.

await (await fetch('/api/admin/erasure', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ action: 'confirm', id: '<request id>', force: true }),
})).json()

Without force, confirming inside the grace window returns 409:

{
  "code": "erasure_grace_window_active",
  "error": "Erasure grace window has not elapsed (scheduled for 2026-08-06T09:14:22.104Z). Pass force=true to override.",
  "message": "Erasure grace window has not elapsed (scheduled for 2026-08-06T09:14:22.104Z). Pass force=true to override."
}

force: true skips the entire grace window with no second confirmation. The purge starts on that request. Treat it as the destructive operation it is.

Run the sweep by hand

await (await fetch('/api/admin/erasure/purge', { method: 'POST' })).json()

Same work the cron does. Response:

{ "due": 3, "purged": 2, "failed": 0, "results": [ { "id": "…", "subjectType": "tenant", "status": "purged" } ], "processedAt": "…" }

due can exceed purged + failed: a request that stopped being active between the list and the confirm (an operator confirmed or cancelled it concurrently) returns null and is counted in neither bucket.

The purge cron

Declared in vercel.json as /api/admin/erasure/purge on "45 2 * * *"daily at 02:45 UTC. The whole project runs in a single region, arn1.

listDueDataErasureRequests() selects pending and failed rows with scheduled_purge_at <= now, plus stale purging claims. The handler iterates them serially — each confirm runs an irreversible multi-table transaction, and serial execution keeps one heavy transaction on the 3-connection pool at a time (purge/route.ts:31-47). Failures go to Sentry via reportCronDrainFailures('gdpr-erasure-purge', failed).

Requests confirmed by the cron carry confirmed_by = 'cron:erasure-purge'. Requests confirmed by an operator carry that operator's Clerk user id.

What a purge deletes

Tenant subject

purgeTenantData() (data-erasure-repository.ts:103-292) deletes every tenant_scope_id-keyed row in one transaction, in FK-safe order, recording each table's delete count in purge_report:

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, still inside the transaction, the tenant's slice of the runtime KV store: platform_state and platform_state_hash rows whose key matches the scope id. Every KV key embeds the scope id (platform:<domain>:<scope>), which is exactly how the purge finds them.

After the transaction commits, two best-effort steps:

  • storage_objectsremovePublicObjectsByPrefix('uploads/<userId>/') for every member.
  • ledger_retry_entries — drains the Redis AI-ledger retry queue and DLQ for the scope, so the two-minute redrive cron cannot re-insert usage rows for an erased tenant.

Two hard safety rails are worth knowing about, because both protect against a whole-platform wipe:

  • An empty or whitespace scope id is refused outright (:118). Otherwise the KV delete becomes LIKE '%%' and matches every tenant.
  • escapeLikePattern() (:76) escapes \, % and _. Clerk scope ids look like org:org_2abc… and contain _, which LIKE treats as a single-character wildcard — unescaped, the KV purge would reach other tenants' keys.

User subject

purgeUserData() (:301) first purges the personal tenant scope user:<id> through purgeTenantData, so a solo customer's entire newsroom goes with the account. Those counts appear in the report under personal_tenant.* keys. Then, in a second transaction, it deletes all tenant_memberships rows for the user and the clerk_users row, and finally sweeps uploads/<userId>/.

Authorship inside org newsrooms is deliberately left intact. Content in a shared team newsroom belongs to that newsroom, not to the departing member. The product says this to users too: "Content in shared team newsrooms belongs to those newsrooms and is not affected; ask an owner if a team newsroom should be erased as well." To remove it, open a separate tenant request for that org:<id>.

The completion notice

On success — and only from the writer that actually recorded the purge, so a cron tick racing an operator confirm cannot double-send — ErmisAI emails the data subject (GDPR Art. 12(3)/19). Subject line: "Your data has been erased from ErmisAI". Template: src/emails/erasure-confirmation.tsx.

Recipients are snapshotted at request time (notification_targets jsonb) and captured again at purge time; the two sets are unioned and deduplicated by address. Request-time capture is load-bearing: Clerk hard-deletes the org membership rows immediately on organization.deleted, so a purge-time lookup 14 days later would find nobody to notify.

  • user subject → the account's clerk_users.primary_email, in the personal newsroom's ui_locale.
  • tenant subject → the email of every membership with tenantRole === 'owner', labelled with the newsroom's publicationName.

Two ways the notice silently does not arrive:

  • No targets resolved. The purge proceeds regardless and writes [data-erasure] no notification targets resolved for request <id> (<subjectType>); completion notice will be skipped to stderr.
  • The address is on the hard-bounce suppression list. sendEmail() suppresses hard-bounced addresses for every category including transactional, and returns { id: null, skipped: true, reason: 'hard_bounced' }.

Verifying a purge completed

Read the request row. GET /api/admin/erasure?status=purged, or directly:

select status, purged_at, confirmed_by, purge_attempt_count, last_purge_error, purge_report
from data_erasure_requests
where id = '<request id>';

Read purge_report. It is a flat { "<table>": <count> } map. Check storage_objects against how many members had uploads. ledger_retry_entries: -1 means the Redis drain failed and the entries may still be queued — re-check after the next redrive cycle rather than treating the purge as failed.

Spot-check the tables. For a tenant scope, every one of the 16 tables above should return zero:

select count(*) from editorial_story_drafts where tenant_scope_id = 'org:org_…';
select count(*) from ai_usage_ledger      where tenant_scope_id = 'org:org_…';
select count(*) from platform_state       where key like '%org:org\_…%';

Confirm the notice went out. The send carries Resend tags type=erasure_confirmation and locale=<ui locale> (src/lib/email/send.ts:212-215), which is how you identify it among the other transactional sends.

Work the manual backstops below. The purge report being clean does not mean every copy is gone.

Failure handling

A purge that throws flips the row to failed, records last_purge_error (truncated to 2000 characters) and last_purge_attempt_at, and rethrows so the cron's failure accounting still pages. If even recording the failure fails — usually because Postgres is the thing that is down — that is written to stderr and the original error still propagates.

Investigate a failed request the same day. The data is still present until a purge succeeds, which means the Art. 17 clock is still running.

SymptomCauseAction
Sentry alert gdpr-erasure-purgeOne or more purges threwGET /api/admin/erasure?status=failed, read last_purge_error
purge_attempt_count climbing nightlySame error recurringFix the cause; do not wait out the retries
Row stuck in purgingInstance died mid-purgeSelf-heals after 15 minutes; the next tick reclaims it
Cancel returns 404Row already purging or terminalToo late — the purge is running or done
due > 0 but purged = 0 and failed = 0 every tickRequests keep losing the claimCheck for a second sweep running concurrently
No cron activity at allCRON_SECRET unset or rotated in one environmentGET /api/healthconfig.missing

Manual backstops

Four things the code cannot reach. All four are real gaps, not theoretical ones.

Configuration traps

An unset BLOB_ARCHIVE_READ_WRITE_TOKEN creates PII that no retention job can ever reach. Without the token, uploadPrivateArchiveObject() writes nothing and the caller inlines the webhook body into the DB row with payload_storage_path = NULL. Both retention prunes require payload_storage_path IS NOT NULL (clerk-user-repository.ts:86, billing-repository.ts:205), so those rows are never pruned and grow unbounded. Check GET /api/healthconfig before trusting the 30-day horizon; the token is tracked as a launch-critical ops entry.

Two more that change what erasure means for your deployment:

  • Billing rows are deleted, not anonymized. A tenant purge drops billing_invoices, tenant_subscriptions, billing_checkout_sessions and billing_portal_sessions outright. If a financial or tax retention obligation applies in your jurisdiction, change purgeTenantData to strip PII and keep the amounts before relying on this for a regulated entity.
  • Provider backups are untouched. The purge deletes rows and objects; it does not reach into managed-provider backups. The completion email tells the subject this explicitly: "Residual copies in encrypted infrastructure backups expire on their regular rotation schedule."

What ErmisAI does not offer

There is no self-service access, export, rectification or portability path. purgeUserData is deletion only, and the only user-facing control is the "Delete account" button in Settings → Account. Every other data-subject right is handled by email through the contact published on the GDPR and privacy pages. If you are answering a DSAR that is not a deletion request, none of this subsystem applies.

Nesta página