Stratify
Legal and compliance

Retention & Deletion Schedule

docs/compliance/isms/privacy/PRIV-11-retention-deletion-schedule.md

Source updated 03. Aug. 2026

Retention & Deletion Schedule

1. Purpose and relationship to existing documents

docs/compliance/dsgvo-data-flow.md §2 already states a retention period per data category. This document formalizes that into a standalone schedule with a column §2 doesn't have: the actual deletion mechanism per table, pulled from the real, working code in apps/web/lib/admin/ and apps/web/app/(admin)/subscribers/actions.ts rather than described in the abstract. This satisfies GDPR Art. 5(1)(e) (storage limitation) by making the retention commitment auditable against real code, not just a stated intention.

2. Retention & deletion table

Data categoryTableRetention periodDeletion mechanismTrigger
Account (email, user_id, language)subscribersUntil account deletion + 30 days backupAnonymized in place, not hard-deleted — see §3eraseSubscriberData() call, DPO/compliance-triggered
Active mandate selectionuser_active_mandateSame as accountHard-deletederaseSubscriberData()
Onboarding profileonboarding_profileSame as accountHard-deletederaseSubscriberData()
Subscriber rolessubscriber_rolesSame as accountHard-deletederaseSubscriberData()
Web-push subscriptionweb_push_subscriptionsUntil revoke or endpoint HTTP 410Hard-deletederaseSubscriberData(), or natural expiry (410 response from push endpoint)
Expo push tokenexpo_push_tokensUntil revoke or DeviceNotRegisteredNot covered by eraseSubscriberData() today — gap, see §4Natural expiry only, currently
Subscription status, Stripe customer IDsubscribers.status, subscribers.stripe_customer_idSame as accountCleared as part of the subscribers row anonymization (§3)eraseSubscriberData()
Referral cookieBrowser cookie + subscribers.referred_by90 days (cookie) / account lifetime (attribution field)Browser-side cookie expiry; referred_by/referral_attributed_at cleared as part of subscribers row anonymizationCookie: automatic 90-day expiry. Attribution field: eraseSubscriberData()
Waitlist email + sourcewaitlist24 months from entryNot covered by eraseSubscriberData() (waitlist entries are pre-account) — gap, see §4Time-based only, currently
Signal-delivery logsignal_deliveries24 months (Phase A); flips to 7 years at FMA licence grantNot deleted by `eraseSubscriberData()` — deliberate, retained for compliance/audit purposes independent of subscriber erasureTime-based purge (not yet automated — see §4)
Audit log (chain-hashed)audit_log24 months (Phase A); flips to 7 years at FMA licence grantExplicitly not deleted on erasure, by design — see §5Time-based purge (not yet automated — see §4); append-only DB trigger prevents any row-level delete regardless
API request logapi_request_log24h rollingDaily purge — planned, not yet built per dsgvo-data-flow.md §2Time-based, automated purge pending
Supabase Auth userSupabase auth.usersSame as accountHard-deleted, optionally — only if deleteAuth: true is passed to eraseSubscriberData()eraseSubscriberData(), operator choice per request

3. Why the subscribers row is anonymized, not hard-deleted

eraseSubscriberData() (apps/web/app/(admin)/subscribers/actions.ts:85-160) hard-deletes four related tables outright (web_push_subscriptions, onboarding_profile, user_active_mandate, subscriber_roles), but handles the subscribers row itself differently: it rewrites the row in place —

  • emailerased+<user_id[:8]>@stratify.invalid
  • display_namenull
  • statusbanned
  • stripe_customer_idnull
  • referred_by, referral_attributed_atnull

This is the correct approach, not a shortcut: subscribers.user_id is a foreign-key referent for signal_deliveries and audit_log history that must remain queryable for compliance and audit-integrity purposes independent of any single subscriber's erasure request (see §5). Hard- deleting the row would either cascade-delete or orphan that history. Anonymizing in place preserves referential integrity while removing every personally identifying field the row held — which is what Art. 17 actually requires (the data subject's personal data is erased/rendered non-identifying), not literal row deletion. This satisfies Art. 17 even though it is not a literal DELETE.

4. Known gaps — stated honestly, not glossed over

  • `expo_push_tokens` is not touched by `eraseSubscriberData()`. It relies on natural expiry

(revoke or DeviceNotRegistered) rather than being included in the erasure action's hard-delete set. This is inconsistent with web_push_subscriptions, which is included — worth Antonios's review on whether this is an intentional omission or a real gap to close.

  • `waitlist` entries are not touched by `eraseSubscriberData()` — reasonable, since waitlist

entries precede account creation and aren't necessarily linked by user_id, but this means a waitlist-only erasure request (someone who joined the waitlist but never converted to a subscriber) has no automated path today and would need a separate manual process.

  • **Time-based purges for signal_deliveries, audit_log, and api_request_log are not yet

automated.** The 24-month (Phase A) retention period is a stated policy in dsgvo-data-flow.md §2, not an enforced, scheduled deletion job. This is a real, current gap between the documented retention period and what actually happens to rows past that age today.

5. The Art. 17 vs. Art. 5(1)(c)/30 tension — stated explicitly

audit_log is deliberately not deleted as part of subscriber erasure — this is by design, not an oversight, and the erasure event itself is written back into the very table it doesn't touch: eraseSubscriberData() calls recordAudit() with eventType: 'gdpr.erasure.completed' (actions.ts:144-155), preserving only priorEmailDomain (the domain portion of the pre-erasure email, not the full address) in the payload.

This is a genuine, deliberate tension between two obligations that pull in opposite directions:

  • Art. 17 (right to erasure) asks for the subject's personal data to be removed.
  • **Art. 5(1)(c) (data minimization) and Art. 30 (records of processing) / general accountability

obligations** ask for a durable, tamper-evident record that processing (including the erasure itself) actually happened — which is exactly what the append-only, chain-hashed audit_log exists to provide (facts.md, "Audit log"; apps/web/lib/audit/chain.ts).

The position taken here is defensible but not automatic, and it rests on one specific fact: the audit entries referencing the erased subscriber no longer contain identifying PII once the upstream `subscribers` row has been anonymized — the entries reference subject_id (a UUID) and, in the erasure event specifically, only an email domain, not any directly identifying field. A UUID alone is arguably still personal data under a broad reading (it remains linkable to the anonymized subscribers row), so this is a defensible position, not a closed question — it is stated here explicitly, with the reasoning shown, precisely so it gets Antonios's explicit sign-off rather than being assumed correct by default.

6. Sign-off

FieldValue
Retention periods sourced fromdsgvo-data-flow.md §2 (2026-05-19)
Deletion mechanisms verified againstapps/web/lib/admin/gdpr-export.ts, apps/web/app/(admin)/subscribers/actions.ts:85-160
Art. 17 / Art. 5(1)(c) tension (§5) reviewed bypending — DPO sign-off required
Gaps in §4 accepted or scheduled for closurepending — DPO/ISMS Owner decision

7. Review

Reviewed whenever the erasure code path changes, whenever a new personal-data table is added to the schema, and otherwise annually alongside the other Tier 3 privacy documents.