# Data retention policy

**Doc type:** Internal policy. Backs the public retention schedule in [`/privacy.html`](../privacy.html) § 5.
**Last reviewed:** 2026-05-26
**Owner:** Controller (founder).

## Principle

We retain personal data only for as long as necessary to fulfil the purpose for which it was collected, unless a legal obligation requires longer retention. When a retention period expires, data is either erased or anonymised (aggregation-only).

## Retention schedule

| Data category | Storage | Retention period | Legal basis for the period | Action at expiry |
|---|---|---|---|---|
| Learner account (email, name, password hash, MFA secret) | `users` (Supabase EU) | Active account + 5 years post-deletion | Code du Travail Art. L6353-9 (training-records evidence) | Hard delete row; replaced by aggregate ledger entry |
| Learner session JWT id | `sessions` (Supabase EU) | 24 h (TTL) | Operational | Auto-delete via TTL job |
| Quiz / lab / final progress rows | `progress` (Supabase EU) | 5 years post-completion | Qualiopi indicator 32 (audit trail) | PII fields nulled; aggregate row retained |
| Certificate (PDF + metadata) | Supabase Storage + `progress` row | 10 years | Qualiopi auditor look-back (CACES-style precedent) | Hard delete after 10 years |
| Enterprise customer record | `customers` | Active relationship + 10 years post-termination | Code de commerce (commercial records) | Hard delete |
| Manager record (email, display name, token hash) | `managers` | Until revoked + 5 years | Same as learner account | Hard delete |
| Cohort codes (issued, redeemed, unused) | `codes` | 5 years post-revocation | Audit trail | Hard delete |
| Devis row (pending, po_received, invoice_sent) | `quotes` | 3 years if never paid | Commercial records (lower for never-paid) | Hard delete |
| Devis row (provisioned, paid) | `quotes` | 10 years post-paid_at | Code de commerce Art. L123-22 | Hard delete |
| Devis PDF / PO PDF | Supabase Storage (`devis-pdfs`, `po-uploads`) | Same as parent quote row | Linked to invoice retention | Object deleted from bucket |
| Stripe event log | `stripe_events` | **10 years** | Financial audit trail; NEVER deleted by RGPD erasure | Hard delete only at 10 y |
| Transactional email log (Resend) | Resend platform | 30 days | Resend default; operational | Auto-pruned by Resend |
| Email suppression (bounce / complaint) | `email_suppression` | Indefinite | Delivery hygiene + legitimate interest (Art. 6(1)(f)) | None — suppression is the purpose |
| Rate-limit buckets | `rate_limits` | 30 days | Operational | Daily cron prune (`expires_at < now()`) |
| Server access logs (Netlify) | Netlify | 90 days | Operational | Netlify auto-pruned |
| Support thread (inbox) | EU mailbox | 3 years | Operational + warranty period | Manual archive then delete |
| Tux bot conversation logs | Supabase EU | 90 days raw, then anonymised aggregate | Improvement of bot grounding | Email + identifier nulled; question kept |
| Marketing site analytics (Plausible) | Plausible EU | 24 months aggregate | Legitimate interest, low impact | Plausible auto-pruned |

## Interaction between retention and RGPD erasure (Art. 17)

An erasure request **cannot override** a competing legal obligation. The handling matrix:

| Table | Erasure action | Why |
|---|---|---|
| `users` | Email + name + password_hash + mfa_secret nulled; row kept with a tombstone marker until 5 y elapsed | Training-records obligation requires *proof of attendance*, not the identifiable PII |
| `progress` | learner_email + learner_name + learner_id nulled; score + module + type + timestamp retained | Qualiopi indicator 32 audit trail |
| `quotes` | contact_email + contact_name nulled; SIRET + company_name + financial figures retained | Code de commerce financial audit |
| `stripe_events` | **No erasure** | 10 y financial retention overrides |
| `codes` | redeemer_email nulled if assigned; status flipped to `revoked` | Same as learner account |
| `email_suppression` | Email retained (it IS the suppression list — purpose is erasure-protection) | Legitimate interest |

This policy is enforced by `netlify/functions/gdpr-erase.mjs`.

## Purge jobs

| Job | Frequency | Removes |
|---|---|---|
| `rate-limits-prune` | Daily 03:00 UTC | `rate_limits` rows where `expires_at < now()` |
| `sessions-prune` | Hourly | `sessions` rows where `expires_at < now()` AND `revoked_at IS NULL` |
| `bot-logs-anonymise` | Daily 04:00 UTC | Nulls `email` on tux conversations older than 90 days |
| `audit-retention-sweep` | Quarterly | Surfaces all rows past their retention horizon for operator review before deletion |

All scheduled jobs run as Netlify Functions on a cron schedule (`netlify.toml` `[[scheduled.functions]]`) and authenticate to Supabase via service-role JWT.

## Operator's quarterly review

Every quarter the controller reviews:
- New processing activities introduced (update [`registre-des-traitements.md`](registre-des-traitements.md))
- Sub-processor changes (update [`sub-processors.md`](sub-processors.md))
- Retention exceptions (e.g. ongoing litigation → litigation hold overrides scheduled deletion)
- Sample audit: pick 5 random data-subject requests from the past 90 days, verify the response was complete + within time limit

## Litigation hold

If a dispute is foreseen or active, the controller may issue a *litigation hold* freezing all otherwise-scheduled deletions in the affected scope. Hold is documented in writing and lifted explicitly. Holds do NOT extend retention beyond what is strictly necessary for the dispute.

## Changelog

| Date | Change |
|---|---|
| 2026-05-26 | Initial version. Aligned with Supabase EU cutover. Stripe events 10 y financial retention codified. |
