Skip to content

Data subject requests — the procedure the schema cannot perform

docs/adr/20260201-org-scoped-tenancy.md records the fact this runbook exists for:

DSR completeness is now procedure-backed, not schema-backed… residual rows can no longer be enumerated back to the subject.

Before the org re-scope, "delete user X" and "what belongs to X" were the same query: every owner_id hung off one root and removing the auth.users row cascaded the lot. After it, the data controller for a note is the organization, so the cascade deliberately stops — and what stops with it is the ability to answer an erasure request by running one statement.

tools/data-flow.json is the reviewed inventory of exactly what survives and why; tools/check-data-flow.mjs reds when the schema grows a surviving row nobody reviewed. This file is the other half: what a human does when a request arrives.

This runbook is a starting point, not legal advice. Your controller/processor relationships, retention obligations and lawful bases are yours. What the harness guarantees is that the inventory is complete and current — no column carrying personal data survives a deletion without appearing in tools/data-flow.json with a written reason.

What the account-deletion path already does

Section titled “What the account-deletion path already does”

A user deleting their own account (mobile: the session.deleteAccount command; the delete-account Edge Function behind it) performs, in this order:

  1. Sweeps the caller's personal org — a single-seat organization nobody else can join. Deleting it cascades its memberships, invitations and notes.
  2. Verifies the sweep — error and row count. Any mismatch returns 500 and deleteUser is not called.
  3. Deletes the auth.users row, which cascades public.profiles and every public.memberships seat.

The order is load-bearing and the reason is worth knowing before you touch this path: public.orgs.created_by is ON DELETE SET NULL, so deleting the identity first would null the very column the sweep filters on. The personal org would become permanently unsweepable and — with the auth user gone — unauthenticatable, so no retry could even begin. One misordering is not a retryable failure; it is unrecoverable orphaned tenant data.

What it does NOT do, and what you do instead

Section titled “What it does NOT do, and what you do instead”

Run node tools/check-data-flow.mjs for the current list. As shipped:

What survives Why What the procedure is
notes rows in a team org (title, body, and the now-NULL owner_id) The org is the data controller. An employee leaving must not delete the company's content. Erasure: refer the subject to the org, which is the controller for that content. If you are the controller (B2C, or the org is defunct), delete by org_id with a reviewed one-off migration.
orgs.created_by → NULL, invitations.invited_by → NULL Attribution, not ownership. The workspace and its pending seats outlive their creator. Nothing further — the link is already severed, which is the erasure.
invitations.email for a pending invitation The invitee may have no account at all, so there is no auth.users row to delete and no foreign key that could reach it. Nothing reaps expired rows. Delete by (org_id, email). This is the one request that can arrive from someone who has never used the product.
orgs.slug of a personal org, only after a failed sweep The slug embeds the subject's own auth id (personal-<uid>). Normally unreachable because the sweep runs first and is verified. If a 500 was returned by delete-account, the org still exists and created_by is intact — retry the deletion. Do not delete the auth user by hand first.
audit.events.actor_id and the rest of the trail A trail that deletes its own evidence is not a trail. GDPR Art. 17(3) covers this: retention for legal claims and for the controller's own security obligations. Do not delete audit rows to satisfy a request. The bound on this data is the retention window: audit.drop_partitions_older_than() removes whole monthly partitions. Record the request and the refusal, and tell the subject the window.

tools/data-flow.json export.projection is the reviewed answer to "what is my data", and the gate closes it against the schema in both directions — a projected column no migration creates reds, and a subject-data table that is neither projected nor excluded reds.

The delivered surface is system.exportMyData (export.surface names packages/api/src/routers/system.ts) — a tRPC query on authedProcedure that runs as the subject, under RLS. Every projected row is readable under their own policies, so no elevated credential is involved anywhere in the path, and that is deliberate: an export that needs service_role is an export that can return somebody else's rows if the filter is wrong. It is a procedure, not a screen (see PARITY.md): the subject triggers it from any authenticated client, or support walks them through it — it is never fulfilled by an operator acting as someone else.

To fulfil a request:

  1. Authenticate as the subject (their session; never a service credential).
  2. Call system.exportMyData with no cursor. Each page carries the profile, the full seat list, and one org's slice of the notes they authored, newest first.
  3. Follow notes.nextCursor until it is null — the walk visits each org the subject holds a seat in, one org at a time, and a page may carry fewer than limit notes (an export is a batch walk, not a screen). Concatenate the notes.items arrays; the final page's profile + memberships are current.
  4. Deliver the collected JSON to the subject.

Three properties worth knowing when someone asks why the numbers look the way they do:

  • Authored-only is application logic, in the query. RLS admits every org-mate's notes to the caller; the procedure filters owner_id = <the subject> because exporting the whole org's content into one member's personal archive is the same over-export the excluded[] reasons forbid. Archived notes are included — an archive flag does not make a note less the subject's.
  • Memberships are bounded at EXPORT_MEMBERSHIPS_LIMIT (200) seats, ordered by org id — an unconditional LIMIT like every read in the repo. A subject somehow past it needs a manual query, and you will know, because the memberships array arrives exactly at the cap.
  • Installs seeded before 0.7.0 predate the procedure: your packages/ tree is seeded (yours, never overwritten by update), and your own tools/data-flow.json still records your state. The data-flow gate now enforces the export.surface.target date, so your two legitimate moves are to ship your own surface (the template's packages/api/src/routers/system.ts + packages/api/src/export.ts + @app/notes listAuthoredNotes are the worked pattern, and npx next-expo-supabase-agent-harness@latest update --refresh-seeded tools/data-flow.json pulls the reviewed exemplar of the policy file) or to re-review the target to a release you mean, in a reviewed diff.

The exclusions are as much a part of the answer as the projection, and each carries its reason in the same file. The one worth understanding: an audit trail is data the controller observed, not data the subject provided, and Art. 20 covers the latter. That is the distinction the exclusion rests on — not convenience.

Adding a table that carries personal data puts you in exactly one of four states, and the gate will tell you which:

  • ERASED — a chain of ON DELETE CASCADE reaches it from auth.users. Nothing to do.
  • SEVERED — ON DELETE SET NULL. The row survives. Record why in tools/data-flow.json severed[].
  • RETAINED — no delete reaches it. Record why in retained[], with a procedure pointing at the file that answers the request. The gate requires that file to exist.
  • BLOCKING — ON DELETE RESTRICT, NO ACTION, or no ON DELETE clause at all, which PostgreSQL treats as NO ACTION. The account deletion fails. This is a hard red with no quiet escape: an account that cannot be deleted is a GDPR Art. 17 failure and an Apple 5.1.1(v) review rejection from the same line, and the omitted-clause spelling is the one that looks like every other column definition.

Then re-run node tools/check-data-flow.mjs and pnpm validate.