Finding 8: the GDPR deletion gap
This is the last in-scope finding of the audit, and the one inexperienced engineers almost always get half right. Your goal: document the incomplete account deletion in src/lib/account/delete-account.ts as findings/008-gdpr-deletion.md, the eighth and final entry in the eight-category floor.
When a user clicks “delete my account,” the seeded handler deletes the user row and nothing else: no graph walk, no calls to the external services the user’s data reached, no anonymization of the audit trail. The app returns success, sends the confirmation email, and leaves personal data scattered across half the schema and every third party it ever touched.
Your mission
Section titled “Your mission”Open src/lib/account/delete-account.ts and read it against the retention catalog from the security-baseline chapter’s deletion lesson, then grep every table that references the user’s id and every external service that holds their data, and confirm the handler touches none of them. The healthy version already ships at trigger/delete-user.ts, so the gap is the difference between the two.
The trap is the partial answer. The reflex is to notice that membership rows survive, name member, and stop. The discipline is to name every place the data could have leaked — the data-graph tables, but also the external services a DELETE against your own database can never reach: Stripe holds a Customer, Resend a contact and a suppression entry, PostHog a person profile, R2 the user’s stored objects. The second miss: audit-log rows are anonymized, never hard-deleted. The right to erasure and the immutable, append-only audit trail are in tension, and anonymization — keep the row, scrub the actor — is how both survive.
Frame the consequence in legal terms. Personal data persisting past a granted erasure request is not the risk of a breach; it is the breach itself, the failure Article 17 of the GDPR exists to prevent. The confirmation email is a lie, an exposure of its own: it removes the user’s chance to escalate while their data is still everywhere. Stay read-only: document the defect, never patch it.
findings/008-gdpr-deletion.md has all four template sections — Rule, Location, Consequence, Fix — filled with real content.src/lib/account/delete-account.ts with a line range and the grep/read command(s) that surfaced it.member, invitation, invoice_notes, exports, the Better Auth session and account rows, audit_logs) and the external services (Stripe, Resend, PostHog, R2) — read against the retention catalog.deleteUser job, block sign-in for the in-progress account, anonymize the audit_logs actor columns, fire the Stripe / Resend / PostHog / R2 deletes, and remove the user row last.Coding time
Section titled “Coding time”Write findings/008-gdpr-deletion.md against the template and the brief above before you open the solution — the value is in catching the externals and the anonymize tension yourself. Once you have walked the graph and written all four sections, expand the reference below.
Reference solution and walkthrough
Start at the seeded handler. It reads in one glance, which is why the gap is easy to miss: no clever logic to inspect, only an absence.
import 'server-only';
import { eq } from 'drizzle-orm';
import { db } from '@/db';import { user as users } from '@/db/schema/auth';
// The "delete my account" handler the settings page calls.export const deleteAccount = async (userId: string): Promise<void> => { // SEEDED #8: one-row delete. Everything else the retention // catalog names is left behind. await db.delete(users).where(eq(users.id, userId));};The seeded one-statement handler. The whole body is a single DELETE against the users table: no transaction, no graph walk, no external call, no anonymization, no route through the async deletion job. The target ships this bug on purpose; finding 8 names the gap, it does not patch it.
export const deleteUser = schemaTask({ id: 'delete-user', schema: z.strictObject({ userId: z.string().min(1) }), run: async ({ userId }) => { await db.transaction(async (tx) => { // Walk the data graph: every table holding this user's PII. await tx.delete(invitation).where(eq(invitation.inviterId, userId)); await tx.delete(invoiceNotes).where(eq(invoiceNotes.authorId, userId)); await tx.delete(exports).where(eq(exports.requestedBy, userId)); await tx.delete(member).where(eq(member.userId, userId));
// Anonymize — do NOT hard-delete — the audit trail. await tx .update(auditLogs) .set({ actorUserId: null, actorIp: null, actorUserAgent: null }) .where(eq(auditLogs.actorUserId, userId));
// External deletes named, not wired (no third party in the pipeline): // Stripe Customer · Resend contact/suppression · PostHog person · R2 objects
await tx.delete(users).where(eq(users.id, userId)); // users row LAST }); },});The healthy async job: what a complete erasure actually walks. Inside one db.transaction, it clears the retention catalog (invitation, invoiceNotes, exports, member), anonymizes the auditLogs actor columns rather than deleting the rows, names the four external deletes, then removes the users row last. The fix cites this job by name; the seeded handler never enqueues it.
Read the two tabs together and the finding writes itself: the healthy job is the checklist of everything the seeded handler skips. Here is the completed findings/008-gdpr-deletion.md.
# Finding 008 — Account deletion leaves the user's PII behind
**Category:** GDPR deletion (security baseline).**Severity:** critical — a successful "delete my account" request leaves personal data live across half the schema and every external service, and the only proof it ran was a one-row `DELETE`. PII persisting past a granted erasure request is a direct Article 17 breach, and the user was told it was done.
## Rule
A GDPR erasure request runs as an async deletion job that walks the full retention catalog: every table holding the subject's PII or references is cleared, every external service the PII reached is told to delete it, and the audit trail is *anonymized — not hard-deleted* so the immutable record survives without naming the person (chapter 081, lesson 4 — Account deletion and the retention catalog; the three deletion shapes are hard-delete the row, anonymize the row, and cascade-delete the children, and "anonymize don't delete" is the rule for the append-only audit log specifically).
## Location
`src/lib/account/delete-account.ts`:
- `deleteAccount(userId)` — lines 21–25. The whole body is one statement: `await db.delete(users).where(eq(users.id, userId))`. There is no transaction, no graph walk, no external call, no anonymization, and no route through the async deletion job.
How it surfaced — read the deletion handler against the retention catalog, then grep for every table and service that holds this user's data and confirm the handler touches none of them.
```# 1. The deletion entry point and what it actually deletes.rg -n "delete\(" src/lib/account/delete-account.ts# 2. The retention catalog — every table that references user.id.rg -n "references\(\(\) => user(s)?\.id" src/db/schema.ts src/db/schema/auth.ts src/db/audit.ts# 3. The healthy shape that already exists, for the fix to name.rg -n "schemaTask|delete-user" trigger/delete-user.ts```
Grep 2 names the data graph the handler skips. The subject's `user.id` is referenced by:
- `member` (org membership rows — cascade on `user.id`, but the deletion is *not* the same as a foreign-key cascade because the user row is the only thing being deleted here, and the order/anonymization still has to be deliberate),- `invitation` (`inviterId` — invitations this user sent),- `invoice_notes` (`authorId` — free-text the user typed, real PII),- `exports` (`requestedBy` — export-run history tied to the user),- `session` / `account` (Better Auth credential + session rows, cascade on `user.id`),- `audit_logs` (`actorUserId` — the append-only trail; this is the one row set that must be *kept and anonymized*, never deleted).
External services the same PII reached, none of which a `DELETE users` touches: Stripe (the org's Customer), Resend (the contact / suppression entry), PostHog (the person profile), and R2 (the user's stored objects). The discipline here is to name every place the data could have leaked to, not only the obvious tables — the externals are where an auditor finds the gap a SQL-only deletion misses.
The healthy reference already ships in the repo at `trigger/delete-user.ts` (the `deleteUser` `schemaTask`, id `'delete-user'`): it walks `invitation`, `invoiceNotes`, `exports`, `member`, anonymizes `audit_logs`, names the four external deletes, then removes the `users` row last, all inside one `db.transaction`. The seeded handler does not import or enqueue it.
## Consequence
A user clicks "delete my account", the request returns success, and the app deletes exactly one row. Their invoice notes, the invitations they sent, their export history, and their session and credential rows stay live, and their actor id stays stamped on every audit-log row they ever generated. Their Stripe Customer, Resend contact, PostHog profile, and R2 objects are never told anything. In legal terms this is a failure to honour an Article 17 erasure request: personal data persists after the controller confirmed it was erased, which is the breach itself, not a risk of one. The confirmation the user received — "your account and data have been deleted" — is false, and that false confirmation is its own exposure, because it removes the user's chance to escalate while their data is still everywhere.
## Fix
Route the request through the async deletion job, not an inline `DELETE`. The `deleteAccount` handler's job is to mark the account `deletion_in_progress` (so sign-in is blocked for an account mid-deletion and the user can't re-authenticate against a half-deleted graph) and enqueue the `deleteUser` Trigger.dev `schemaTask` already present at `trigger/delete-user.ts`; the job owns the actual erasure. Inside one `db.transaction`, the job walks the retention catalog — delete `invitation`, `invoice_notes`, `exports`, `member`, and the Better-Auth `session` / `account` rows — and **anonymizes** the `audit_logs` rows rather than deleting them: the append-only trail must survive for compliance, so the row stays and only the actor is scrubbed. The deletion/audit-trail tension resolves exactly here — anonymization is how both the right-to-erasure and the immutable audit record hold at once.
```ts// inside the deleteUser job's db.transaction, the audit-trail anonymize step:await tx .update(auditLogs) .set({ actorUserId: null, actorIp: null, actorUserAgent: null }) .where(eq(auditLogs.actorUserId, userId));```
After the in-database graph, the job fires the external deletes it cannot do in SQL — Stripe Customer, Resend contact/suppression, PostHog person, R2 objects — then removes the `users` row last so a partial failure leaves a recoverable, still-anonymizing state rather than orphaned children. It closes by writing `account.deletion-completed` through `logAudit` as an `ExplicitAuditEvent` with `actorUserId: null` (the job has no session, so the actor is the system, not the deleted user). The fix is structural — the async job, the catalog walk, and the anonymize step — never a wider `DELETE`.Three decisions in that finding are the parts the partial answer never reaches.
The full enumeration is the whole point. No automated check can tell a finding that names member apart from one that names every seam, so the Location section does it by hand: grep 2 surfaces every column that references user.id, each listed with one clause on why it holds PII. Then it crosses the boundary the data graph cannot. A DELETE against your own Postgres reaches none of Stripe, Resend, PostHog, or R2; an erasure request that ignores them has only tidied the local database. Naming the four externals is the line between a junior answer and a complete one.
Anonymize the audit log, don’t delete it. The append-only trail is in tension with the right to erasure: hard-delete the rows and you break the trail, keep the user’s name on them and you breach erasure. Anonymization resolves both — null out the actor columns (actorUserId, actorIp, actorUserAgent) and the history survives while the person disappears from it.
Delete the users row last. Delete it first and fail partway through the external deletes, and you are left with orphaned children and no anchor to retry against. Clear the children and anonymize the trail first, remove the users row last, and a partial failure is recoverable: the worst case is a re-run, not a corruption.
Severity is critical: personal data surviving a granted erasure request is a direct regulatory breach with a false confirmation on top.
The full legal text the Consequence section cites — the erasure obligation, its grounds, and its exceptions.
Regulator guidance on the Article 19 duty to notify every recipient — the discipline behind naming all four external services.
Moment of truth
Section titled “Moment of truth”Run the lesson’s gate:
pnpm test:lesson 9The suite reads your committed findings/008-gdpr-deletion.md, asserts the finding’s shape — file present, four sections filled, the Rule naming the async deletion job and the anonymize-not-hard-delete pattern and citing the security-baseline chapter’s lesson 4, the Location naming delete-account.ts with a line range and a grep command — and probes the source to confirm deleteAccount still deletes only the users row. That last probe passes only if you documented the defect instead of patching it: “fix” the handler and it fails on purpose.
The gate cannot judge the parts that make this finding land. Confirm those by hand:
member. This is the partial-answer trap; the complete list is the finding.With finding 8 written, the eight-category floor is complete. The next lesson commits the whole findings/ directory, scores it clause-by-clause against the answer key, and reaches for the two bonus findings that take the report from 8/8 toward 10/10.