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 category | Table | Retention period | Deletion mechanism | Trigger |
|---|---|---|---|---|
| Account (email, user_id, language) | subscribers | Until account deletion + 30 days backup | Anonymized in place, not hard-deleted — see §3 | eraseSubscriberData() call, DPO/compliance-triggered |
| Active mandate selection | user_active_mandate | Same as account | Hard-deleted | eraseSubscriberData() |
| Onboarding profile | onboarding_profile | Same as account | Hard-deleted | eraseSubscriberData() |
| Subscriber roles | subscriber_roles | Same as account | Hard-deleted | eraseSubscriberData() |
| Web-push subscription | web_push_subscriptions | Until revoke or endpoint HTTP 410 | Hard-deleted | eraseSubscriberData(), or natural expiry (410 response from push endpoint) |
| Expo push token | expo_push_tokens | Until revoke or DeviceNotRegistered | Not covered by eraseSubscriberData() today — gap, see §4 | Natural expiry only, currently |
| Subscription status, Stripe customer ID | subscribers.status, subscribers.stripe_customer_id | Same as account | Cleared as part of the subscribers row anonymization (§3) | eraseSubscriberData() |
| Referral cookie | Browser cookie + subscribers.referred_by | 90 days (cookie) / account lifetime (attribution field) | Browser-side cookie expiry; referred_by/referral_attributed_at cleared as part of subscribers row anonymization | Cookie: automatic 90-day expiry. Attribution field: eraseSubscriberData() |
| Waitlist email + source | waitlist | 24 months from entry | Not covered by eraseSubscriberData() (waitlist entries are pre-account) — gap, see §4 | Time-based only, currently |
| Signal-delivery log | signal_deliveries | 24 months (Phase A); flips to 7 years at FMA licence grant | Not deleted by `eraseSubscriberData()` — deliberate, retained for compliance/audit purposes independent of subscriber erasure | Time-based purge (not yet automated — see §4) |
| Audit log (chain-hashed) | audit_log | 24 months (Phase A); flips to 7 years at FMA licence grant | Explicitly not deleted on erasure, by design — see §5 | Time-based purge (not yet automated — see §4); append-only DB trigger prevents any row-level delete regardless |
| API request log | api_request_log | 24h rolling | Daily purge — planned, not yet built per dsgvo-data-flow.md §2 | Time-based, automated purge pending |
| Supabase Auth user | Supabase auth.users | Same as account | Hard-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 —
email→erased+<user_id[:8]>@stratify.invaliddisplay_name→nullstatus→bannedstripe_customer_id→nullreferred_by,referral_attributed_at→null
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, andapi_request_logare 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
| Field | Value |
|---|---|
| Retention periods sourced from | dsgvo-data-flow.md §2 (2026-05-19) |
| Deletion mechanisms verified against | apps/web/lib/admin/gdpr-export.ts, apps/web/app/(admin)/subscribers/actions.ts:85-160 |
| Art. 17 / Art. 5(1)(c) tension (§5) reviewed by | pending — DPO sign-off required |
| Gaps in §4 accepted or scheduled for closure | pending — 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.