Skip to content

feat(gdpr): la file de suppression, inerte — le schéma sans l'exécuteur#282

Merged
thierryvm merged 9 commits into
mainfrom
feat/3b-a-deletion-queue
Jul 27, 2026
Merged

feat(gdpr): la file de suppression, inerte — le schéma sans l'exécuteur#282
thierryvm merged 9 commits into
mainfrom
feat/3b-a-deletion-queue

Conversation

@thierryvm

@thierryvm thierryvm commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Ce que fait cette PR, et ce qu'elle ne fait surtout pas

/app/settings/deletion-status affiche à l'utilisateur une date de suppression et un
décompte de jours. Rien ne l'exécute : executeDeletion() n'a aucun appelant depuis
avril. Cinq comptes réels en production, dont trois appartiennent à des tiers. Ce n'est
pas une omission — c'est une affirmation inexacte faite à la personne concernée
(art. 12(1)) sur un droit garanti par l'art. 17.

Cette PR pose ce sur quoi l'exécuteur reposera, et rien d'autre.

Invariant tenu : executeDeletion n'acquiert aucun appelant. Rien ne peut
supprimer un compte après ce merge. La route de cron part en PR-B, dont la revue ne
portera alors que sur une seule question : est-ce que ça peut partir quand il ne faut
pas ?

Décisions : ADR-024.
Plan : step-3b. Trois tours de plan-reviewer
avant écriture, un quatrième de confirmation pendant.

Le fait qui contraint toute la conception

Deux conceptions ont été écrites puis abandonnées, chacune tuée par une mesure :

  1. Fonction SECURITY DEFINER atomiqueFORCE ROW LEVEL SECURITY s'applique au
    propriétaire de la table
    . Possédée par postgres, elle écrirait zéro ligne sans
    lever
    si le postgres hébergé n'a pas BYPASSRLS. Invérifiable.
  2. La même en SECURITY INVOKER appelée par service_role — mesuré : service_role
    n'a aucun privilège sur auth.users.

L'atomicité SQL de bout en bout est donc impossible par toute conception. C'est la
frontière entre PostgREST et GoTrue, pas une limite de notre code. La garantie devient
l'idempotence et la reprise, et aucun privilège nouveau n'est demandé — c'est ce
qui rend cette conception défendable là où les deux précédentes reposaient sur une
hypothèse invérifiable.

Migration

Élément Pourquoi
Statut processing + colonne claimed_at le verrou de reprise a besoin d'une date de réclamation, pas de demande
Collapse des pending en double, puis index unique partiel couvre les deux statuts actifs, sinon la remise en file violerait sa propre contrainte
claim_pending_deletions() SECURITY INVOKER, ne touche que public, revoke from public avant grant (advisor 0028)
deletion_self_insert supprimée pas durcie — voir plus bas
purge_audit_log_older_than_12_months() en SECURITY INVOKER même défaut que la conception 1, sur une fonction jamais appelée depuis avril

Le verrou teste claimed_at, jamais requested_at. Une ligne n'étant réclamable
qu'à scheduled_for <= now(), soit 14 jours après sa demande, un test sur requested_at
serait toujours vrai : chaque exécution remettrait en file les lignes qu'une autre
traite, et le même compte serait supprimé deux fois. Invariant écrit dans la
migration : le seuil de reprise (1 h) doit rester supérieur au maxDuration (60 s) de la
route qui arrivera en PR-B.

deletion_self_insert est supprimée plutôt que durcie parce que la mesure le permet :
deletion_requests n'est écrit qu'en un seul endroit du code, via service_role, qui
contourne la RLS. Aucune insertion client n'existe. La politique accordait une
capacité que le produit n'utilise pas — et armée, c'était une auto-suppression immédiate,
alors que la grâce de 14 jours est précisément la mitigation contre le vol de session.

L'extraction qui rend le chemin destructeur testable

src/lib/gdpr/deletion.ts importe @/lib/supabase/admin, qui porte import 'server-only'
— lequel lève inconditionnellement. Vitest l'aliase, Playwright non. La seule
instruction irréversible du système n'était donc prouvée nulle part.

Mesuré sur ce dépôt, avant d'écrire quoi que ce soit — import jetable, puis contrôle
négatif :

  • @/lib/gdpr/deletion-core : 3 cas listés, module chargé ;
  • @/lib/gdpr/deletion : échec à la collecte, server-only/index.js:1.

D'où deletion-core.ts, sans le marqueur, recevant le client en paramètre. Le garde-fou
n'est pas affaibli : npm run lint:use-server passe, et un test lit le graphe d'imports
du module pour refuser toute réintroduction — y compris via @/lib/security/audit-log,
qui ré-exporte depuis admin.ts et suffirait à tout casser.

Trois silences remplacés par trois réponses honnêtes

  • Une demande en double renvoie l'échéance existante, pas une nouvelle — annoncer
    une date que la file n'honorera pas est le défaut même qu'on corrige.
  • Une annulation dit quand rien n'a été annulé. cancelDeletion renvoyait void : un
    filtre ne touchant aucune ligne était indiscernable d'un succès, et l'action écrivait
    quand même une ligne d'audit affirmant une annulation qui n'avait pas eu lieu.
  • processing a enfin une branche d'affichage. Il tombait jusqu'ici dans « Complétée »,
    en rouge.

Le trou trouvé par la revue de plan

settings/page.tsx filtrait sur status='pending' seul. Pendant processing,
deletion valait donc null, la zone de danger réaffichait le formulaire de demande
et retirait le seul lien vers l'écran de statut — exactement quand l'effacement était
devenu irréversible
. Corrigé, plus une chaîne dédiée qui ne promet plus « tu peux
annuler à tout moment ».

Preuves

Falsification — 7 mutations du code, 3 du schéma. Chacune rouge sur le test qui la
garde, vertes après restauration. Détail complet dans docs/prs/PR-3B-A-report.md.

Chemin destructeur exercé de bout en bout contre un vrai Postgres avec la migration
appliquée — 5 cas, comptes de lignes exacts sur chaque table fille, et audit_log qui
conserve ses lignes avec ip_address et user_agent à NULL. Cette dernière assertion
est celle qui compte : user_id seul ne prouverait rien, on delete set null l'efface
comme effet de bord de la cascade.

Planchers e2e mesurés, jamais annoncés : job authentifié 25 → 30 (+5 dans un
seul projet, iPhone 14 filtrant sur **/mobile-ios/**) ; job public inchangé à 215
(+15 sautés, +0 passé). Liste de quarantaine inchangée à 6.

Ce qui reste vrai après ce merge, et qu'il faut nommer

auth.audit_log_entries conserve l'email en clair et l'IP, n'a aucune clé étrangère
vers auth.users, et survit à l'effacement. service_role ne peut même pas la lire.
C'est un manquement art. 17 mesuré — issue #278. Il existe à partir du jour où le cron
s'arme, pas avant
: donc pas avec cette PR, mais avec PR-B.

status='completed' et completed_at sont inatteignables : la ligne de demande
cascade avec le compte. La branche « Complétée » de l'écran de statut est du code mort,
laissée en place pour une PR d'hygiène séparée.

Production — fait

  • Les trois lectures sont revenues (@Thierry). N° 1 : 0 ligne. N° 2 : 0 ligne.
    N° 3 : concluante — et sa première mesure ne prouvait rien, elle rendait les
    chiffres de la veille ; c'est la reconnexion réelle qui a produit auth.login et
    auth.logout. Le NO-GO de PR-B est levé.
  • supabase db push --linked — migration appliquée, local = remote. Collapse des
    doublons : 0 ligne
    , conformément à la lecture n° 2.
  • npm run supabase:types confirme les deux ajouts au caractère près.

Un défaut trouvé après coup, et fermé

En relisant les privilèges réels en base plutôt que le texte de la migration :

claim_pending_deletions :: postgres=X | anon=X | authenticated=X | service_role=X
POST /rest/v1/rpc/claim_pending_deletions  avec la clé ANON  →  HTTP 200

revoke execute … from public ne retire que le grant du pseudo-rôle PUBLIC. Il ne
retire pas
les grants explicites que les privilèges par défaut de Supabase accordent à
anon et authenticated. Deux choses qui se lisent pareil. Et mon commentaire de
migration affirmait que le revoke suffisait — une garantie annoncée qui n'existait
pas
.

Impact mesuré : nulSECURITY INVOKER + FORCE RLS = zéro ligne touchée, [] en
réponse. Fermé quand même (20260727000002, appliquée en prod) : un endpoint non
authentifié qui émet deux UPDATE sur une table RGPD ne doit pas dépendre de politiques
inchangées pour rester inoffensif. Après : anon42501/401, service_role200.

Au passage, purge_audit_log_older_than_12_months() est observée pour la première fois
depuis avril
: 2 lignes semées à −13 et −14 mois, la fonction rend 2, il en reste 0. Elle
supprime réellement. Elle reste sans appelant — PR-B l'arme.

Agents QA — dont deux qui n'ont pas tourné

i18n-auditor PASS_WITH_NOTES, gdpr-compliance-auditor COMPLIANT_WITH_NOTES
(3 défauts réels chez moi, corrigés — dont un commentaire qui promettait une exhaustivité
de statuts inexistante, status étant typé string).

rls-flow-tester et test-quality-auditor se sont arrêtés sur la limite de session.
Ce n'est pas un vert par défaut. J'ai repris à la main les points de rls-flow-tester
c'est ce qui a sorti le défaut de grants ci-dessus — mais une vérification faite par
l'auteur du code n'a pas la valeur d'une vérification indépendante.

Détail complet, y compris un incident de process (un agent QA doté de Bash a modifié mon
arbre de travail et sa mutation s'est retrouvée dans un commit) : docs/prs/PR-3B-A-report.md.

🤖 Generated with Claude Code

`/app/settings/deletion-status` affiche une date de suppression et un décompte
de jours. Rien ne l'exécute : `executeDeletion()` n'a aucun appelant depuis
avril. Ce n'est pas une omission, c'est une affirmation inexacte faite à la
personne concernée (art. 12(1)) sur un droit garanti par l'art. 17.

Cette PR pose ce sur quoi l'exécuteur reposera, et RIEN d'autre. Invariant tenu :
`executeDeletion` n'acquiert aucun appelant. Rien ne peut supprimer un compte
après ce merge — la route de cron part en PR-B, dont la revue ne portera alors
que sur une question : est-ce que ça peut partir quand il ne faut pas ?

Décisions : ADR-024. Deux conceptions ont été écrites puis abandonnées, chacune
tuée par une mesure — `FORCE ROW LEVEL SECURITY` s'applique au propriétaire de
la table, et `service_role` n'a aucun privilège sur le schéma `auth`.
L'atomicité SQL de bout en bout est donc impossible par toute conception : c'est
la frontière entre PostgREST et GoTrue. La garantie devient l'idempotence et la
reprise, et aucun privilège nouveau n'est demandé.

Ce que contient la migration :

- statut `processing` + colonne `claimed_at` ;
- collapse défensif des demandes `pending` en double, puis index unique partiel
  couvrant LES DEUX statuts actifs — sinon la remise en file violerait sa propre
  contrainte ;
- `claim_pending_deletions()`, `SECURITY INVOKER`, dont le verrou de reprise
  teste `claimed_at` et JAMAIS `requested_at`. Une ligne n'étant réclamable que
  14 jours après sa demande, un test sur `requested_at` serait toujours vrai :
  chaque exécution volerait le lot d'une exécution vivante, et le même compte
  serait supprimé deux fois ;
- `deletion_self_insert` SUPPRIMÉE plutôt que durcie. Aucune insertion client
  n'existe (mesuré) ; la politique accordait une capacité que le produit
  n'utilise pas, et armée c'était une auto-suppression immédiate ;
- `purge_audit_log_older_than_12_months()` passe en `SECURITY INVOKER` : en
  `DEFINER` sur une table `FORCE RLS` elle pouvait ne rien supprimer sans lever.

Côté code, l'orchestration destructrice sort de l'enveloppe `server-only`
(`deletion-core.ts`). Mesuré ici même : Playwright charge `deletion-core`, et
lève sur `deletion.ts` à `server-only/index.js:1`. C'est ce qui rend la seule
instruction irréversible du système enfin exécutable de bout en bout en CI.

Trois réponses honnêtes remplacent trois silences : une demande en double
renvoie l'échéance EXISTANTE et non une nouvelle ; une annulation dit quand
rien n'a été annulé, et n'écrit plus de ligne d'audit affirmant le contraire ;
`processing` a enfin sa branche d'affichage — il tombait jusqu'ici dans
« Complétée », en rouge.

Le trou trouvé par la revue de plan : la page settings filtrait sur
`status='pending'` seul, donc pendant `processing` elle réaffichait le
formulaire de demande et retirait le seul lien vers l'écran de statut —
exactement quand l'effacement devenait irréversible.

Falsification : 7 mutations du code et 3 du schéma, chacune rouge sur le test
qui la garde, vertes après restauration. Planchers e2e mesurés, jamais estimés.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @thierryvm, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ankora Ready Ready Preview, Comment Jul 27, 2026 4:47pm

@github-actions github-actions Bot added status:review-needed Ready for review type:feat New user-facing feature labels Jul 27, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Guide du/de la relecteur·rice

Implémente le schéma de base de données, la logique cœur et les tests pour une file d’attente de suppression de compte RGPD reprenable, sans encore raccorder l’exécuteur à un appelant, tout en resserrant les sémantiques de requête/annulation et les messages d’interface afin que le produit ne fasse plus de déclarations approximatives sur l’état de la suppression.

Diagramme de séquence pour le nouveau flux d’annulation et les issues honnêtes

sequenceDiagram
  actor User
  participant SettingsClient
  participant cancelAccountDeletionAction
  participant cancelDeletion
  participant Supabase as deletion_requests
  participant Audit as logAuditEvent

  User->>SettingsClient: click cancel account deletion
  SettingsClient->>cancelAccountDeletionAction: cancelAccountDeletionAction()
  cancelAccountDeletionAction->>cancelAccountDeletionAction: rateLimit('mutation', user)
  cancelAccountDeletionAction->>cancelDeletion: cancelDeletion(user.id)

  cancelDeletion->>Supabase: update deletion_requests
  activate Supabase
  Supabase-->>cancelDeletion: { data, error }
  deactivate Supabase

  alt error
    cancelDeletion-->>cancelAccountDeletionAction: throw Error
    cancelAccountDeletionAction-->>SettingsClient: { ok:false, errorCode:errors.settings.deletionCancelFailed }
  else success
    cancelDeletion-->>cancelAccountDeletionAction: CancelDeletionResult
    alt result.cancelled == true
      cancelAccountDeletionAction->>Audit: logAuditEvent(GDPR_DELETION_CANCELLED)
      Audit-->>cancelAccountDeletionAction: ok
      cancelAccountDeletionAction-->>SettingsClient: { ok:true }
    else result.cancelled == false and reason == in_progress
      cancelAccountDeletionAction-->>SettingsClient: { ok:false, errorCode:errors.settings.deletionCancelTooLate }
    else result.cancelled == false and reason == none
      cancelAccountDeletionAction-->>SettingsClient: { ok:true }
    end
  end
Loading

Modifications au niveau des fichiers

Changement Détails Fichiers
Introduire un module de cœur de suppression agnostique au serveur et adapter le wrapper serveur ainsi que les tests pour l’utiliser, permettant des tests de bout en bout du chemin destructif sans affaiblir les garde-fous spécifiques au serveur.
  • Extraire l’orchestration de la suppression dans deletion-core.ts qui prend un client Supabase, en fournissant pseudonymiseAuditLog, executeDeletionWith et claimPendingDeletionsWith avec des sémantiques explicites d’idempotence et de gestion des erreurs.
  • Mettre à jour deletion.ts pour déléguer aux fonctions du cœur, injecter des clients privilégiés, centraliser la ligne de log d’audit, ajouter un point d’entrée claimPendingDeletions, et introduire un CancelDeletionResult typé pour cancelDeletion.
  • Refactoriser les tests unitaires de suppression pour utiliser un client Supabase factice programmable, couvrir la gestion des requêtes dupliquées, les sémantiques d’annulation affinées, et le nouveau wrapper claimPendingDeletions.
  • Ajouter des tests dédiés à deletion-core qui exercent la pseudonymisation, les cas limites GoTrue, le mapping de la RPC claim_pending_deletions, et une protection garantissant que deletion-core.ts n’importe jamais de modules réservés au serveur ou privilégiés.
src/lib/gdpr/deletion-core.ts
src/lib/gdpr/deletion.ts
src/lib/gdpr/__tests__/deletion-core.test.ts
src/lib/gdpr/__tests__/deletion.test.ts
Ajouter un schéma de file d’attente de suppression reprenable et une RPC dans la base de données, appliquer l’unicité et des contraintes RLS côté client sur deletion_requests, et ajuster les types générés en conséquence.
  • Modifier deletion_requests pour ajouter un statut de traitement et un timestamp claimed_at, et assouplir/redéfinir la contrainte CHECK sur le statut pour inclure les quatre états.
  • Regrouper les requêtes en attente dupliquées par utilisateur en annulant les plus anciennes, puis imposer au plus une requête active (en attente ou en traitement) via un index partiellement unique.
  • Créer la fonction claim_pending_deletions(batch_size) avec SECURITY INVOKER qui remet en file les lignes en traitement périmées via claimed_at, revendique de façon atomique les lignes en attente arrivées à échéance avec for update skip locked, et n’est exécutable que par service_role.
  • Resserrer les politiques RLS côté client sur deletion_requests en supprimant la politique d’auto-insertion et en limitant les mises à jour à la transition pending→cancelled, en gardant les chemins d’insert/update effectivement réservés au rôle service_role.
  • Changer purge_audit_log_older_than_12_months en SECURITY INVOKER appelé par service_role, en conservant le comportement tout en évitant les problèmes FORCE RLS + SECURITY DEFINER, et régénérer les types Supabase pour inclure claimed_at et les définitions de claim_pending_deletions.
supabase/migrations/20260727000001_deletion_queue.sql
src/lib/supabase/types.ts
Aligner les actions backend et l’UI frontend avec les nouvelles sémantiques et statuts de la file d’attente afin que le comportement visible par l’utilisateur reflète réellement ce que le système fera.
  • Mettre à jour cancelAccountDeletionAction pour utiliser CancelDeletionResult, distinguer entre annulation réussie, annulation trop tardive sur une suppression en cours, et cas sans effet, en n’émettant un événement d’audit qu’en cas de réelle annulation et en renvoyant des codes d’erreur spécifiques dans les autres cas.
  • Étendre DeletionStatusPage pour traiter processing comme un état en cours distinct avec son propre libellé, sa couleur et son texte descriptif, sans offrir de bouton d’annulation une fois qu’une exécution possède la requête.
  • Ajuster la zone de danger de la page de paramètres pour considérer pending et processing comme des états de suppression actifs lors de la décision d’afficher le formulaire et le lien, en évitant de réafficher le formulaire de demande quand la suppression est déjà irréversible, et adapter le texte pour ne pas promettre la possibilité d’annuler pendant processing.
  • Ajouter des clés de messages localisés pour le nouveau texte lié à processing dans les langues prises en charge (en, fr-BE, nl-BE, de, es-ES, etc.).
src/lib/actions/settings.ts
src/lib/actions/__tests__/settings-deletion.test.ts
src/app/[locale]/app/settings/deletion-status/page.tsx
src/app/[locale]/app/settings/page.tsx
src/app/[locale]/app/settings/SettingsClient.tsx
messages/en.json
messages/fr-BE.json
messages/nl-BE.json
messages/de-DE.json
messages/es-ES.json
Ajouter une couverture de bout en bout pour la file d’attente de suppression et mettre à jour la documentation du projet et les attentes CI pour refléter les nouveaux tests.
  • Introduire un test e2e Playwright qui exerce la file d’attente de suppression et deletion-core contre une instance locale réelle de Supabase, en couvrant l’effacement réussi et la pseudonymisation de l’audit, les réexécutions idempotentes, les sémantiques de revendication (arrivées à échéance vs non arrivées à échéance vs traitements périmés), l’unicité des requêtes actives, et le comportement RLS pour les clients authentifiés.
  • Garantir que le test e2e refuse de s’exécuter contre des URLs Supabase non locales et exige des variables d’environnement appropriées, afin d’éviter toute exécution accidentelle contre des bases de données de type production.
  • Mettre à jour la documentation de relecture de plan dans CLAUDE.md avec les nouveaux paliers de jobs authentifiés Playwright et la justification des nombres modifiés.
  • Ajouter une entrée de projet authenticated-specs (et le câblage associé) afin que le nouveau test e2e ne s’exécute que dans l’ensemble de projets Playwright prévu.
e2e/gdpr-deletion-queue.spec.ts
e2e/authenticated-specs.json
CLAUDE.md
docs/plans/step-3b-deletion-queue.md
docs/prs/PR-3B-A-report.md

Conseils et commandes

Interagir avec Sourcery

  • Déclencher une nouvelle revue : Commentez @sourcery-ai review sur la pull request.
  • Poursuivre les discussions : Répondez directement aux commentaires de revue de Sourcery.
  • Générer une issue GitHub à partir d’un commentaire de revue : Demandez à Sourcery de créer une issue à partir d’un commentaire de revue en y répondant. Vous pouvez aussi répondre à un commentaire de revue avec @sourcery-ai issue pour créer une issue à partir de celui-ci.
  • Générer un titre de pull request : Écrivez @sourcery-ai n’importe où dans le titre de la pull request pour générer un titre à tout moment. Vous pouvez aussi commenter @sourcery-ai title sur la pull request pour (re)générer le titre à tout moment.
  • Générer un résumé de pull request : Écrivez @sourcery-ai summary n’importe où dans le corps de la pull request pour générer un résumé de PR à tout moment exactement à l’endroit souhaité. Vous pouvez aussi commenter @sourcery-ai summary sur la pull request pour (re)générer le résumé à tout moment.
  • Générer le guide du/de la relecteur·rice : Commentez @sourcery-ai guide sur la pull request pour (re)générer le guide du/de la relecteur·rice à tout moment.
  • Résoudre tous les commentaires Sourcery : Commentez @sourcery-ai resolve sur la pull request pour résoudre tous les commentaires Sourcery. Utile si vous avez déjà traité tous les commentaires et ne souhaitez plus les voir.
  • Ignorer toutes les revues Sourcery : Commentez @sourcery-ai dismiss sur la pull request pour ignorer toutes les revues Sourcery existantes. Particulièrement utile si vous voulez repartir de zéro avec une nouvelle revue – n’oubliez pas de commenter @sourcery-ai review pour déclencher une nouvelle revue !

Personnaliser votre expérience

Accédez à votre dashboard pour :

  • Activer ou désactiver des fonctionnalités de revue telles que le résumé de pull request généré par Sourcery, le guide du/de la relecteur·rice, et d’autres.
  • Changer la langue de revue.
  • Ajouter, supprimer ou modifier des instructions de revue personnalisées.
  • Ajuster d’autres paramètres de revue.

Obtenir de l’aide

Original review guide in English

Reviewer's Guide

Implements the database schema, core logic, and tests for a resumable GDPR account deletion queue, without yet wiring the executor to any caller, while tightening request/cancel semantics and UI messaging so the product no longer makes inexact statements about deletion status.

Sequence diagram for updated cancellation flow and honest outcomes

sequenceDiagram
  actor User
  participant SettingsClient
  participant cancelAccountDeletionAction
  participant cancelDeletion
  participant Supabase as deletion_requests
  participant Audit as logAuditEvent

  User->>SettingsClient: click cancel account deletion
  SettingsClient->>cancelAccountDeletionAction: cancelAccountDeletionAction()
  cancelAccountDeletionAction->>cancelAccountDeletionAction: rateLimit('mutation', user)
  cancelAccountDeletionAction->>cancelDeletion: cancelDeletion(user.id)

  cancelDeletion->>Supabase: update deletion_requests
  activate Supabase
  Supabase-->>cancelDeletion: { data, error }
  deactivate Supabase

  alt error
    cancelDeletion-->>cancelAccountDeletionAction: throw Error
    cancelAccountDeletionAction-->>SettingsClient: { ok:false, errorCode:errors.settings.deletionCancelFailed }
  else success
    cancelDeletion-->>cancelAccountDeletionAction: CancelDeletionResult
    alt result.cancelled == true
      cancelAccountDeletionAction->>Audit: logAuditEvent(GDPR_DELETION_CANCELLED)
      Audit-->>cancelAccountDeletionAction: ok
      cancelAccountDeletionAction-->>SettingsClient: { ok:true }
    else result.cancelled == false and reason == in_progress
      cancelAccountDeletionAction-->>SettingsClient: { ok:false, errorCode:errors.settings.deletionCancelTooLate }
    else result.cancelled == false and reason == none
      cancelAccountDeletionAction-->>SettingsClient: { ok:true }
    end
  end
Loading

File-Level Changes

Change Details Files
Introduce a server-agnostic deletion core module and adjust the server wrapper and tests to use it, enabling end-to-end testing of the destructive path without weakening server-only guards.
  • Extract deletion orchestration into deletion-core.ts that takes a Supabase client, providing pseudonymiseAuditLog, executeDeletionWith, and claimPendingDeletionsWith with explicit idempotency and error-handling semantics.
  • Update deletion.ts to delegate to the core functions, inject privileged clients, centralize the audit log line, add claimPendingDeletions entry point, and introduce a typed CancelDeletionResult for cancelDeletion.
  • Refactor deletion unit tests to use a programmable fake Supabase client, cover duplicate-request handling, refined cancellation semantics, and the new claimPendingDeletions wrapper.
  • Add dedicated deletion-core tests that exercise pseudonymisation, GoTrue edge cases, claim_pending_deletions RPC mapping, and a guard ensuring deletion-core.ts never imports server-only or privileged modules.
src/lib/gdpr/deletion-core.ts
src/lib/gdpr/deletion.ts
src/lib/gdpr/__tests__/deletion-core.test.ts
src/lib/gdpr/__tests__/deletion.test.ts
Add a resumable deletion queue schema and RPC in the database, enforce uniqueness and client RLS constraints on deletion_requests, and adjust generated types accordingly.
  • Alter deletion_requests to add a processing status and claimed_at timestamp, and relax/redefine the status CHECK constraint to include all four states.
  • Collapse duplicate pending requests per user by cancelling older ones, then enforce at most one active (pending or processing) request via a partial unique index.
  • Create claim_pending_deletions(batch_size) SECURITY INVOKER function that requeues stale processing rows by claimed_at, atomically claims due pending rows with for update skip locked, and is executable only by service_role.
  • Tighten client RLS policies on deletion_requests by dropping the self-insert policy and narrowing updates to the pending→cancelled transition, keeping insert/update paths effectively service-role only.
  • Change purge_audit_log_older_than_12_months to SECURITY INVOKER called by service_role, maintaining behavior but avoiding FORCE RLS + SECURITY DEFINER issues, and regenerate Supabase types to include claimed_at and claim_pending_deletions definitions.
supabase/migrations/20260727000001_deletion_queue.sql
src/lib/supabase/types.ts
Align backend actions and frontend UI with the new queue semantics and statuses so user-visible behaviour matches what the system will actually do.
  • Update cancelAccountDeletionAction to use CancelDeletionResult, distinguish between successful cancellation, too-late in-progress cancellations, and no-op cases, emitting an audit event only on real cancellation and returning specific error codes otherwise.
  • Extend DeletionStatusPage to treat processing as a distinct in-flight state with its own label, color, and body text, without offering a cancel button once a run owns the request.
  • Adjust the settings page danger zone to treat both pending and processing as active deletion states when deciding whether to display the form and link, avoiding re-showing the request form when deletion is already irreversible, and adjust the copy to avoid promising cancellability during processing.
  • Add localized message keys for the new processing copy across supported locales (en, fr-BE, nl-BE, de, es-ES, etc.).
src/lib/actions/settings.ts
src/lib/actions/__tests__/settings-deletion.test.ts
src/app/[locale]/app/settings/deletion-status/page.tsx
src/app/[locale]/app/settings/page.tsx
src/app/[locale]/app/settings/SettingsClient.tsx
messages/en.json
messages/fr-BE.json
messages/nl-BE.json
messages/de-DE.json
messages/es-ES.json
Add end-to-end coverage for the deletion queue and update project documentation and CI expectations to reflect the new tests.
  • Introduce a Playwright e2e spec that exercises the deletion queue and deletion-core against a real local Supabase instance, covering successful erasure and audit pseudonymisation, idempotent re-runs, claim semantics (due vs not-due vs stale processing), uniqueness of active requests, and RLS behaviour for authenticated clients.
  • Ensure the e2e spec refuses to run against non-local Supabase URLs and requires appropriate env vars, preventing accidental execution against production-like databases.
  • Update CLAUDE.md plan reviewer documentation with new Playwright authenticated job floors and rationale for the changed numbers.
  • Add an authenticated-specs project entry (and related wiring) so the new e2e spec runs only in the intended Playwright project set.
e2e/gdpr-deletion-queue.spec.ts
e2e/authenticated-specs.json
CLAUDE.md
docs/plans/step-3b-deletion-queue.md
docs/prs/PR-3B-A-report.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

thierryvm and others added 4 commits July 27, 2026 15:54
…erge pour une preuve

Rapport PR-3B-A : les trois lectures production, les 10 mutations de
falsification, les planchers e2e mesurés deux fois, et l'écart art. 17 de
l'issue #278 énoncé sans détour — une personne qui exerce son droit à
l'effacement verra son adresse e-mail rester en base, en clair, tant que
`auth.audit_log_entries` n'est pas traitée.

Registre de conformité (§6.5) : la vérification d'efficacité du correctif #273
en production est faite, datée, et racontée telle qu'elle s'est passée. Le
premier relevé rendait des chiffres identiques à ceux de la veille — il ne
distinguait pas « le correctif est cassé » de « personne ne s'est connecté ».
C'est la reconnexion réelle qui a produit la preuve.

Sa clôture affirmait « contresigné par le merge de la PR #273 ». Un merge ne
démontre que le départ d'un code, jamais qu'une écriture atterrit. Corrigé.

de-DE : le pronom `Sie` reprenait `die Löschung` et était correct, mais en tête
de phrase il ressemble à une fuite de vouvoiement pour un relecteur ou un grep
de registre. Reformulé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Résidu d'une édition in-place pendant les mutations de test, committé par
inadvertance. Aucun impact fonctionnel — mais un fichier source dupliqué dans
le dépôt est exactement le genre de chose qu'on retrouve six mois plus tard en
se demandant lequel fait foi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test-quality-auditor`, qui dispose de Bash, a injecté un import dynamique de
`@/lib/supabase/admin` dans `deletion-core.ts` pour éprouver le test de
frontière de module — et la ligne a été committée en 43b8ec8 parce que je
committais depuis le même répertoire de travail que celui où l'agent opérait.

Cause : la règle « un seul agent par répertoire de travail, worktree sinon »
n'a pas été appliquée. L'expérience de l'agent était légitime ; sa présence
dans un commit ne l'est pas.

Sa conclusion, elle, est retenue : le test de frontière lit les imports de
premier niveau et n'aurait PAS attrapé cet import dynamique. Consigné.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… garanties annoncées qui n'existaient pas

Trois défauts, tous trouvés en lisant l'état réel plutôt que le texte qui le
décrit.

1. GRANTS — `revoke execute … from public` ne retire QUE le grant du pseudo-rôle
   PUBLIC. Les privilèges par défaut de Supabase accordent en plus un grant
   EXPLICITE à `anon` et `authenticated` sur toute nouvelle fonction de `public`.
   Deux choses différentes qui se lisent pareil. Mesuré :

     claim_pending_deletions :: postgres=X | anon=X | authenticated=X | service_role=X
     POST rpc/claim_pending_deletions avec la clé ANON  ->  HTTP 200

   Impact aujourd'hui : nul. SECURITY INVOKER + FORCE RLS = zéro ligne touchée,
   `[]` en réponse. Fermé quand même : moindre privilège, et un endpoint non
   authentifié qui émet deux UPDATE sur une table RGPD ne doit pas dépendre de
   politiques inchangées pour rester inoffensif.

   Après : anon -> 42501/401, service_role -> 200 avec 5 lignes. Les deux sens.

2. Le commentaire de `deletion-status/page.tsx` affirmait que lister les statuts
   ferait apparaître un futur statut comme cas manquant. Faux : `status` est
   typé `string` et une chaîne de ternaires n'a aucun contrôle d'exhaustivité.
   Un cinquième statut se serait affiché « Complétée », en rouge — annoncer un
   compte effacé alors qu'il ne l'est pas. Table de correspondance + repli
   neutre `statusUnknown` (5 locales).

3. `ARCHITECTURE.md` décrivait deux e-mails qui n'existent pas, et deux
   affirmations que cette PR venait de rendre fausses. Réécrit sur le code.

Plus : le commentaire de migration disait « 14 jours » quand le code en écrit 30.

La purge de rétention est enfin observée : 2 lignes semées à -13 et -14 mois, la
fonction rend 2, il en reste 0. Première fois depuis avril qu'on la voit faire
quelque chose. Elle reste sans appelant — PR-B l'arme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 passed / 190 skipped au job public, 30 passed à l'authentifié — relevés
dans les logs des jobs, après avoir été mesurés en local. Le public passe de
175 à 190 sautés : les 15 attendus, pas un de plus.

Le critère 1 du DoD est vert sur 312e441 et PAS ENCORE sur le dernier commit.
Écrit tel quel plutôt que coché d'avance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n agent vers un commit

MESURE (demandée avant merge). Le motif `revoke … from public` sans nommer
`anon, authenticated` ne concerne pas que `claim_pending_deletions`. Lu en base,
pas dans les migrations : 4 des 9 fonctions de `public` sont exécutables par
`anon`, dont DEUX en SECURITY DEFINER — donc « la RLS filtre » ne serait pas une
réponse valable, elles s'exécutent avec les droits du propriétaire.

Impact vérifié en appelant réellement avec la clé anon :

  assert_rls_coverage()      -> [] HTTP 200   aucune divulgation (INVOKER)
  is_workspace_member(uuid)  -> false          aucun oracle (auth.uid() est NULL)
  is_workspace_editor(uuid)  -> false          idem
  touch_updated_at()         -> PGRST202       inatteignable par REST

Aucune exploitation possible aujourd'hui. Le résultat est rassurant et c'est
pour ça qu'il faut nommer ce qui le produit : l'innocuité tient au CORPS de
chaque fonction, pas au privilège. Une future fonction DEFINER qui n'interroge
pas `auth.uid()` hériterait du même grant sans que personne ne le voie.

Correctifs en PR DÉDIÉE — ces grants datent d'avril et mai, les corriger dans
une PR dont l'invariant est de ne rien armer serait l'extension de scope que le
plan interdit. Ordre : assert_rls_coverage (anon+authenticated),
is_workspace_member/editor (anon SEULEMENT — `authenticated` est requis par les
politiques RLS), touch_updated_at.

Le MCP Supabase ne pouvait pas servir : il ne voit que `goldteam`, le PREMIER
compte. L'utiliser aurait interrogé la mauvaise base avec l'air d'être vrai.

PROCESS. `test-quality-auditor.md:73` disait déjà « Never modify code — only
report ». Il a désobéi. Répéter l'instruction ne sert donc à rien. Ce qui manque
est que sa désobéissance n'ait aucun chemin vers l'historique : jamais de
`git add -A` après le passage d'un agent doté de Bash, lecture obligatoire de
`git diff --cached --stat` avant chaque commit, et toute mutation de
falsification hors de l'arbre de travail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… passait la CI

Trouvé par `test-quality-auditor` relancé proprement. La table
STATUS_PRESENTATION qui empêche `processing` de s'afficher « Complétée », le
`.in('status', [...])` de settings/page.tsx, et la branche `processing` de
DangerZone : aucune n'était gardée, ni en unitaire ni en e2e.

Ce sont précisément les corrections d'un défaut art. 12(1) — dire à quelqu'un
quelque chose de faux sur un acte irréversible. Le rapport se félicitait de 10
falsifications tout en laissant sans garde la correction que le commit lui-même
appelle « le trou trouvé par la revue de plan ».

Un sixième cas e2e parcourt le trajet complet : connexion réelle, /app/settings
pendant `processing` (lien « Voir le statut » présent, formulaire de demande
absent, promesse « annuler à tout moment » absente), puis l'écran de statut
(« En cours » visible, « Complétée » absent, aucun bouton d'annulation).
Falsifié : `.in(...)` -> `.eq('status','pending')` rend `element(s) not found`
sur le lien. 1 failed, restauré 6 passed.

Deux autres trous fermés :
- Le durcissement des grants n'avait aucun test. Une migration réaccordant
  `execute … to anon` serait passée en silence — la faute même que
  20260727000001 avait commise. Falsifié en réaccordant en base.
- La charge utile de la pseudonymisation n'était jamais vérifiée en unitaire :
  le faux client jetait `_values`. Retirer `ip_address: null` ne laissait rouge
  que le job Supabase réel, pas la suite de chaque push.

Plus la branche `23505` sans ligne retrouvée, et une fragilité que le test a
lui-même révélée : `claim_pending_deletions` étant GLOBALE, l'égalité exacte
échouait sur une base locale portant des lignes dues d'autres essais. Les
assertions sont scopées aux utilisateurs semés — identique sur une base
éphémère, robuste ailleurs.

Plancher authentifié mesuré : 30 -> 31. Un plancher qui monte parce qu'un trou a
été trouvé est le seul mouvement sain de ce tableau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t mesurée

`rls-flow-tester` (verdict PASS) a fabriqué le mode de panne au lieu de le
raisonner : rôle dédié sans BYPASSRLS, propriétaire d'une fonction de purge,
7 lignes semées dans audit_log (FORCE RLS).

  DEFINER, propriétaire SANS bypassrls -> rows_deleted = 0, survivants = 7
  INVOKER                              -> rows_deleted = 7, survivants = 0

Zéro ligne supprimée, aucune erreur, retour « succès ». Le rejet de la
conception 1 et la décision D4 reposent désormais sur une mesure reproductible.

Plus inquiétant : `postgres` porte rolbypassrls = t sur une pile locale. La
version DEFINER d'avril aurait donc passé un test local avec un compteur
correct. Ce défaut n'a pas survécu trois mois faute de test — il a survécu
parce qu'un test local vert n'aurait rien prouvé.

Deux imprécisions relevées dans mes propres migrations, corrigées dans l'ADR et
le rapport PLUTÔT que dans les fichiers : schema_migrations porte une colonne
`statements`, donc éditer un fichier déjà appliqué le ferait diverger de ce qui
a tourné. Sur une migration qui touche à la suppression de comptes,
l'immuabilité vaut mieux qu'un commentaire plus juste.

  1. ...000002:21-24 « both UPDATE statements match zero rows » — vrai pour
     anon, FAUX pour un authenticated propriétaire d'une ligne échue, où
     l'appel lève 42501/403 sur le with check (mesuré 5/5). La conclusion
     tient, le mécanisme non — et la correction renforce l'argument.
  2. ...000001:51-61 l'écrasement teste `requested_at >` STRICT : deux pending
     au même instant se protègent et le create unique index échoue. Bruyant,
     jamais silencieux.

Cause profonde du défaut de grants, décomposée par l'agent : le revoke de mai
sur is_workspace_member/editor a retiré le grant NOMINATIF à anon, mais le
grant à PUBLIC (grantee 0) est resté et anon en hérite. La migration de mai n'a
rien changé. La PR dédiée doit nommer `public` dans chaque revoke, sinon elle
répète la faute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thierryvm
thierryvm merged commit d386aae into main Jul 27, 2026
10 checks passed
@thierryvm
thierryvm deleted the feat/3b-a-deletion-queue branch July 27, 2026 17:02
thierryvm added a commit that referenced this pull request Jul 27, 2026
…andoff (#283)

Deux fichiers de documentation qui devaient partir avec
[#282](#282) et sont arrivés
trop tard : la PR a été mergée (`d386aae`) pendant que j'écrivais le
handoff, GitHub a supprimé la branche, et mon push l'a recréée. Ces deux
fichiers vivaient donc sur une branche morte, hors de `main`. Aucun
code, aucune migration.

## 1. Le handoff canonique de la session


`docs/handoffs/2026-07-27-1900-etape-3b-pr-a-file-suppression-inerte.md`,
9 sections selon le template. Double redondance : ce fichier **et** le
miroir dans le vault Obsidian — slug `ankora` **lu** dans
`10_Projects/*/_index.md`, jamais inféré.

## 2. Le fait que l'historique Git ne dira pas

Ajouté au rapport de PR à la demande de @Thierry, et c'est le point qui
justifie à lui seul cette PR :

> **La production portait déjà les deux migrations avant le merge.** Le
comportement de la base a changé **au moment du `db push`, pas au moment
du merge**.

Concrètement, et indépendamment de `main` : `deletion_self_insert`
**supprimée**, `deletion_self_update` resserrée en `pending →
cancelled`, `claim_pending_deletions` fermée à `anon` et
`authenticated`, `purge_audit_log_older_than_12_months()` passée en
`SECURITY INVOKER`.

**Pourquoi c'est sûr, et pourquoi ce n'est pas une supposition** :
`deletion_requests` n'est écrit qu'en **un seul endroit** du code —
`src/lib/gdpr/deletion.ts`, via `createServiceRoleClient()`, qui
contourne la RLS. **Aucune insertion client n'existe.** La politique
retirée accordait une capacité que rien n'utilisait.

Et si #282 n'avait **pas** été mergée : la migration serait **restée**.
Un revert de PR sur GitHub ne défait pas un `db push`. Elle est additive
et ses politiques sont plus restrictives qu'avant, donc sans danger —
mais présente. Revenir en arrière pour de bon demanderait une migration
inverse écrite exprès.

## 3. Mesure avant prescription

@Thierry a refusé qu'une future PR d'hygiène propose un `revoke` sur la
foi de la documentation. `touch_updated_at()` est une fonction de
trigger branchée sur **6 tables** (`accounts`, `charges`, `commitments`,
`users`, `workspace_settings`, `workspaces`) : la révoquer à l'aveugle
pouvait casser **chaque écriture de l'application**.

Mesuré en trois temps sur la pile locale, en rôle `authenticated` avec
claims JWT :

```
updated_at remis à 2020-01-01, UPDATE  →  2026-07-27 17:02:40.346726   (avant revoke)
revoke execute … from public, anon, authenticated
updated_at remis à 2020-01-01, UPDATE  →  2026-07-27 17:02:40.368922   (APRÈS revoke)
```

**`updated_at` bouge toujours.** Le privilège `EXECUTE` d'une fonction
de trigger est vérifié **à la création du trigger**, pas à chaque
déclenchement. Le `revoke` ne casse rien. Grant local restauré après la
mesure.

`assert_rls_coverage()` : **aucun appelant** dans le dépôt — le «
Callable from CI » de `20260417000002:36` décrit une intention jamais
réalisée.

Les deux révocations de la future PR grants sont donc **prouvées**, pas
documentées. Cette PR-là part **après PR-B**, décision @Thierry : ces
fonctions sont invoquées par toutes les policies RLS, et ce risque ne
doit pas courir en parallèle de l'armement d'une suppression de comptes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

## Summary by Sourcery

Documenter l’état de la base de données de production et le calendrier
de déploiement pour les migrations de la file de suppression, et ajouter
le rapport de passation pour PR-3B-A.

Documentation :
- Étendre le rapport PR-3B-A avec une description explicite de la
manière dont les migrations de la file de suppression affectent déjà la
production indépendamment de la fusion du code, et pourquoi cela est
sans risque.
- Ajouter un document de passation détaillé pour l’étape 3B / PR-A
couvrant l’état de la branche, le statut de déploiement, les décisions
prises, les actions de suivi et les garde-fous pour les travaux futurs
sur la file de suppression et les révocations de privilèges.

<details>
<summary>Original summary in English</summary>

## Summary by Sourcery

Document the production database state and deployment timing for the
deletion queue migrations, and add the handoff report for PR-3B-A.

Documentation:
- Extend the PR-3B-A report with an explicit description of how the
deletion queue migrations already affect production independently of the
code merge, and why this is safe.
- Add a detailed handoff document for step 3B / PR-A covering branch
state, deployment status, decisions taken, follow-up actions, and
guardrails for future work on the deletion queue and privilege
revocations.

</details>

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:review-needed Ready for review type:feat New user-facing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant