Answer an access or erasure request

You need: auth and controlplane composed, and a management client connected to the environment the person’s account lives in.

Two requests, one surface

Somebody writes in: send me everything you hold about me. A month later, somebody else: delete me. Both are a sentence in a privacy law, and both reach every table that names that person — their balance, their ratings, their support threads, and the rows your own code wrote.

You answer both from the management surface, against the person’s account id. Suspended and deleted accounts has the states and every route; this is the path through them.

Grant the two scopes

auth:users:export hands over everything the kit holds about one person. auth:users:delete deletes an account, restores it inside its window, and starts its purge. Reading a user grants neither, and neither grants the other.

The export is a single GET, so it is a read, and a connection made with the default grant holds it, unless it was made before the Worker served the route. The delete scope is a write and never is. --scope names the whole grant, so pass every scope the connection already holds beside the new one, or the update narrows it:

pithy dashboard connect --env prod --update --scope auth:users:read --scope auth:users:export --scope auth:users:delete

pithy dashboard status prints what a connection holds now.

Answer an access request

GET /auth/admin/users/:userId/export?reason=…

The reason is required, one sentence, and it goes on the audit row: the export is recorded as a disclosure, auth/admin_user_exported, with the operator, the reason and which parts answered. Never the data.

The answer is one JSON document: the subject, when it was read, and one entry per composed capability that holds rows about a person, in the order a purge would reach them. Each entry names its capability, says in that capability’s own sentence what a purge does with its rows, and then says what it answered:

stateMeans
exportedThe capability answered, and data is what it holds about the person
not_exportedIt erases rows about the person and does not say how to export them. No kit capability answers this; only one of your own can
unavailableIts rows would not read on this request. Nothing was established — it is not none. Ask again

One capability failing costs its own entry, never the document.

Some rows are kept, and the entry says why. disposition is erase or retain, and a retained entry carries a basis, one sentence written for the person. Four kit capabilities keep theirs: payments (kept for tax and accounting records), email (kept so your unsubscribe holds), audit (kept as the record of who did what) and the control plane’s legal holds (kept as the record that a legal hold stood), whose entry says when each hold on the person was placed and released, never who placed it or why. A retained entry is still exported: rows you keep are rows an access request is answered with.

No part carries a credential. No session token, provider token, push token, two-factor secret or key, from auth or from anyone else.

Answer an erasure request

DELETE /auth/admin/users/:userId   { mode?, reason, notify? }

Soft is the default. The account is shut at once: every session ends and a sign-in answers auth/account_deleted. Its data waits for the purge window — retention.purgeWindows.user in the Worker’s retention block when it names one, otherwise auth’s retention.purgeAfterDays, 30 unless you set it — and inside that window POST …/restore brings the account back. The person gets the deletion-scheduled mail, with the purge date, unless the call sends notify: false.

Hard closes the window now. Name mode: "hard" on purpose: nothing can restore the account, and the purge starts in the same call. A typo is a 400, never either mode. A soft-deleted account deleted again hard has its purge brought forward to now.

The purge is a Workflow, one step per capability. Each composed capability’s rows are erased in their own step, so a failure retries that capability alone, and auth’s own rows go last. A daily cron at 02:00 UTC starts it for every account past its window. The Workflow classes live in your Worker’s entry, five lines each; until they are bound, a deletion still shuts the account, a hard one reports its purge as pending, and nothing is erased. The purge has the two classes and the wrangler.jsonc entries.

A legal hold on the account stops its purge until it is released: the daily pass starts none, and one already running ends as held at its next step. If counsel has asked you to preserve somebody’s records, place the hold before you delete: retention and legal holds.

A retained step is recorded as retained. Payments, email, audit and the control plane take part in the purge as steps that erase nothing, and each writes purge_retained with its basis, so the account’s history never reads a kept step as an erased one. That history, pithy_auth_user_lifecycle, is the one auth row a purge leaves.

Prove it was erased

GET /control-plane/erasure/user/:userId

Behind erasure:read, a read, so a connection made with the default grant holds it, unless it was made before the Worker served the route. The erasure attestation answers from the purge’s own record: when the account was deleted, by which kind of actor and why, soft or hard, each step erased or kept with its reason, whether the purge is complete, in_progress or failed, and the audit event behind each step by id and chain position. A kept step reads retained, never erased. Hand an auditor that and an export of the chain, and pithy audit verify checks the events it cites.

For every erasure in a quarter, GET /control-plane/erasure?since=…&until=… counts them and names each failed or overdue one with the step it stopped at.

Your own tables

The kit answers for the kit. A table your own code writes, with a column that names a person, answers for nobody until you declare a contribution on your own app capability:

import type { PurgeContribution } from "@pithy-sh/core/src/capability/purge";

const purge: PurgeContribution = {
  subject: "user",
  summary: "Deletes the user's drafts.",
  erase: async ({ subject, env }) => {
    // Remove or anonymize this user's rows. Succeed when there is nothing left to remove.
  },
  export: async ({ subject, env }) => {
    // What you hold about this user, as one JSON object. No credentials.
    return { drafts: [] };
  },
};

defineCapability({ name: "app", requiredBindings: [], purge: [purge] });

erase must succeed when the rows are already gone, because a step is retried after a failure that may have come once they were. Leave out export and your entry reads not_exported, which is an honest answer and an incomplete one. To keep rows instead, declare retain: { basis: "…" } with an export and no erase. For tables that only need reading out and deleting by a column, personRowsPurge from @pithy-sh/core/src/capability/personRows writes the contribution for you.

What it does not do

There is no export for an organization. The subject of an export is one person’s account. An organization has a purge of its own, for the rows scoped to a tenant, and nothing hands those back as a document.

It signs nothing. The attestation cites the trail, and the trail’s hash chain is what makes it tamper-evident; there is no signature and no key. Nothing reaches a copy of the data outside the databases the Worker binds: a backup, an export somebody saved, a log line elsewhere.

Check it worked

  • An export lists every composed capability that holds rows about the person, with no not_exported entry unless it is one of yours
  • A soft deletion answers purge: "scheduled", and a sign-in as that account answers auth/account_deleted
  • A hard deletion answers purge: "started", not pending — pending means the purge Workflows are not bound
  • After the purge, GET /auth/admin/users/:userId answers 404 for the account’s id, and pithy_auth_user_lifecycle holds a purge_retained row for each retained capability
  • The attestation for the account answers state: "complete", with a retained step for each capability that kept its rows
ESC