---
title: "Privacy and data rights"
description: "Declare retention per data label, export or erase a person across every driver with one call, and record consent per purpose."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "privacy-lifecycle"
canonical: "https://mainsdk.dev/docs/interfaces/privacy"
updated: "2026-10-28"
---

# Privacy and data rights

> Declare retention per data label, export or erase a person across every driver with one call, and record consent per purpose.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/privacy · Updated: 2026-10-28

`privacy` carries your data rules through the whole app: retention per data label, one call to export a person and one to erase them, and consent per purpose.

Reach for it as soon as you store personal data: GDPR and India's DPDP Act require both export and erasure.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/privacy-lifecycle.md
- Category: Security and privacy

Calls:

- `privacy.export(user)`
- `privacy.erase(user, { reason })`
- `privacy.consent(user).set({ purpose, granted })`
- `privacy.consent(user).get(purpose)`
- `privacy.hold(user, { until, reason })`
- `privacy.release(user)`
- `privacy.consent(user).signal({ tcf } | { gpp })`
- `privacy.register(name, erase, { export?, labels?, region? })`
- `privacy.retain()`
- `privacy.resume()`
- `privacy.pending()`
- `privacy.verify(record)`
- `privacy.guard(user, { purpose, send })`
- `privacy.registerRetainer(key, prune)`

In main.yaml:

```yaml
privacy:
  retention:
    personal: 365d
    child: 90d
    logs: 30d
    analytics: 13m
  erase: { confirm-within: 30d, retries: 3, retry-delay: 1s }
  consent:
    tcf: { analytics: [1], personalisation: [1, 3, 4] }
```

Guarantees, whatever the driver:

- Retention declared per label and enforced everywhere
- Erasure confirmed by every driver, with a signed record
- Backups erased by deleting the person's key
- Consent recorded per purpose, and a consent platform's TCF or GPP signal read with no second banner
- A driver that cannot confirm leaves a partial signed record and a pending erasure, never a failed request


## Drivers

Pick one in main.yaml with `privacy.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-PRIV-0001` | check | A data label in use has no retention rule (`privacy.retention.<label>`) |
| `MAIN-PRIV-0004` | check | The `privacy` section of main.yaml does not read: an unknown key, a retention that is not a duration, or retries out of range |
| `MAIN-PRIV-0002` | runtime |  |
| `MAIN-PRIV-0003` | runtime |  |
| `MAIN-PRIV-0005` | runtime |  |
| `MAIN-PRIV-0006` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Retention per data label | 1 | 0.3 | `privacy-lifecycle#retention` |
| Erase and export hooks in drivers | 1 | 0.3 | `privacy-lifecycle#driver-hooks` |
| Erase across every driver | 1 | 0.3 | `privacy-lifecycle#erase` |
| Signed erasure record and backup crypto-erasure | 1 | 0.3 | `privacy-lifecycle#erasure-record` |
| One export bundle | 1 | 0.3 | `privacy-lifecycle#export` |
| Consent records with TCF and GPP signals | 1 | 0.3 | `privacy-lifecycle#consent` |
| Generated privacy labels and data map | 1 | 0.3 | `privacy-lifecycle#labels-gen` |
| Legal hold | 1 | 0.3 | `privacy-lifecycle#hold` |
| Partial erasure that retries and stays audited | 1 | 0.3 | `privacy-lifecycle#partial` |
| Context | 1 | 0.3 | `privacy-lifecycle#wiring` |
| Industry packs for payments | 3 | 1.2 | `industry-packs` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| fleet | Certified compliance packs and evidence |


## Quick start

```ts title="lib/privacy.ts"
import { privacy } from "mainsdk";

declare const user: string;
const bundle = await privacy.export(user);
const record = await privacy.erase(user, { reason: "user request" });
console.log(Object.keys(bundle.files), record.status);
```

Both calls fan out to every registered module. The sections below show retention, erasure, consent and holds in full.

## Configuration

Say how long each kind of data is kept. `m` is months here, so `13m` is thirteen months.

```yaml title="main.yaml"
privacy:
  retention:
    personal: 365d      # fields labelled personal
    sensitive: 365d
    child: 90d
    phi: 7y
    logs: 30d           # plain log records
    analytics: 13m      # analytics events
  erase: { confirm-within: 30d, retries: 3, retry-delay: 1s }
  consent:
    tcf: { analytics: [1], personalisation: [1, 3, 4] }   # the TCF purposes each purpose needs
```

`privacy.retain()` deletes what has outlived a rule: database rows by the shortest retention among their labels, plain log records older than `logs`, and any store you register with `privacy.registerRetainer`. People under a legal hold are skipped, and the audit log is never trimmed. Call it from a scheduled job. `mainsdk check` fails with `MAIN-PRIV-0001` when a label in use has no rule.

## Erase and export a person

```ts title="app/api/account/erase/route.ts"
import { privacy } from "mainsdk";

export async function POST(req: Request) {
  const { user } = (await req.json()) as { user: string };
  const record = await privacy.erase(user, { reason: "user request" });
  return Response.json({ status: record.status, verified: privacy.verify(record) });
}
```

`privacy.erase` calls the erase hook of every registered module, waits for each to confirm and retries the ones that fail. When every one has confirmed it writes an erasure record signed by the project key (ed25519, through `keys`), and with `keys.per-user: true` it destroys the person's key, which erases the copies in backups. The record holds a keyed pseudonym of the person, never their id.

The app registers `db`, `engagement`, `feeds` and shared `context` for the sections `main.yaml` has. Register anything else yourself, with the labels and region it holds:

```ts
import { privacy } from "mainsdk";

declare function deleteTickets(user: string): Promise<void>;   // your own support-desk code
declare function listTickets(user: string): Promise<unknown[]>;
declare const user: string;

privacy.register("support-desk", (user) => deleteTickets(user), {
  export: (user) => listTickets(user),
  labels: ["personal"],
  region: "eu",
});
const bundle = await privacy.export(user);   // { manifest, files: { "db.json": …, "privacy.json": … } }
```

A driver that never confirms does not fail the call. The record is `partial` (still signed), the driver is listed `pending` with an error code, the person's key is kept, and `privacy.pending()` lists the gap, marked overdue after `confirm-within`. A job calls `privacy.resume()` to retry.

## Consent

Consent is per purpose and opt-in: `get` is `false` until the person, or your consent platform, says yes.

```ts
import { privacy } from "mainsdk";

declare const user: string;
declare const tcString: string;                          // from your consent banner
declare function track(event: string): Promise<void>;   // your own analytics call
declare const event: string;

await privacy.consent(user).set({ purpose: "analytics", granted: true });
const granted = await privacy.consent(user).get("analytics");
await privacy.consent(user).signal({ tcf: tcString });   // or { gpp: gppString }
await privacy.guard(user, { purpose: "analytics", send: () => track(event) });   // runs only with consent
```

`signal` reads an IAB TCF v2 string, or the TCF section of a GPP string, in process, so you need no second banner. It sets the purposes `privacy.consent.tcf` maps and leaves the others. Other GPP sections are ignored. Shared context and engagement read their consent from here: context under `context:<purpose>`, engagement's send time under `personalisation`.

## Legal hold

```ts
import { privacy } from "mainsdk";

declare const user: string;

await privacy.hold(user, { until: "2027-06-30", reason: "case 42" });   // erase refuses with MAIN-PRIV-0003
await privacy.release(user);
```

## Generate privacy labels

```text
mainsdk privacy labels --format app-store
mainsdk privacy labels --format play --manifest manifest.json
```

The labels come from your labelled `db.fields`, the third-party drivers in `main.yaml` and your retention rules. A manifest from `mainsdk discover --out` adds the hosts, system facts and programs the code uses. See [the CLI reference](https://mainsdk.dev/docs/reference/cli).

## What you get

- Retention per data label, applied to database rows, plain logs and any store you register.
- One export bundle and one signed erasure record across every registered module, with a partial record when a driver does not confirm.
- Crypto-erasure of backups by destroying the person's key.
- Consent per purpose, read from TCF or GPP strings, and legal holds that stop erasure.
- App Store and Play privacy labels generated from your labels and drivers.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-PRIV-0001` | Check: a data label in use has no retention rule | Add the label under `privacy.retention` |
| `MAIN-PRIV-0002` | A call got a missing or malformed argument | Pass a user id, a reason, a lowercase purpose, a future date |
| `MAIN-PRIV-0003` | The person is under a legal hold | Release the hold, or wait for its end |
| `MAIN-PRIV-0004` | Check: the `privacy` section does not read | Fix the key the message names |
| `MAIN-PRIV-0005` | A consent string is not TCF v2 or GPP | Pass the string your consent platform exposes |
| `MAIN-PRIV-0006` | Logged: an erasure is partial | Fix the driver, then `privacy.resume()` |
| `MAIN-KEYS-0004` | Reading data after the person's key was destroyed | Expected after erasure |

## Swapping drivers

`privacy` has no driver of its own. It works through the drivers of the interfaces that hold data, which register their erase and export hooks with `privacy.register`. Changing a driver in `main.yaml` keeps your `privacy` calls the same, and a driver that never confirms leaves a partial record until `privacy.resume()` retries it. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

## Testing

`createTestApp()` from [`@mainsdk/testing`](https://mainsdk.dev/docs/interfaces/testing) runs `privacy` on the in-memory store and a local keys driver, so you can test erasure, holds and consent offline.

```ts title="app/account/privacy.test.ts"
import assert from "node:assert/strict";
import { it } from "node:test";
import { createTestApp } from "@mainsdk/testing";
import { keys, privacy } from "mainsdk";

it("erases a person and makes their sealed data unreadable", async () => {
  createTestApp({
    yaml: "schema: 1\nproject: t\nkeys:\n  per-user: true\nprivacy:\n  erase: { retry-delay: 0 }\n",
  });
  const sealed = await keys.encrypt("note", { owner: "u1", region: "eu", label: "personal" });
  privacy.register("notes", () => 1);
  const record = await privacy.erase("u1", { reason: "test" });
  assert.equal(record.status, "complete");
  assert.equal(privacy.verify(record), true);
  await assert.rejects(keys.decrypt(sealed, { purpose: "test" }), { code: "MAIN-KEYS-0004" });
});
```

## Security and privacy

- Erasing a person's key erases their encrypted data everywhere, backups included.
- Records, store keys and audit entries name a person by a keyed pseudonym, never their id. Every erase, export and hold is in the audit log.
- The email suppression list keeps a hash so an erased person is still never emailed; its hook registers with `privacy.register` until `email` carries its own.

### What Cloud adds

On the Fleet plan, certified compliance packs and evidence. Retention, erasure, export, consent and generated labels in your app are free. See [Free in-app and Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud).

## FAQ

**What if a third-party driver can't erase in time?** The record is partial and signed, `privacy.pending()` shows the gap as overdue after `confirm-within`, and `privacy.resume()` retries until the driver confirms.

**Do I need my own consent banner?** Not if you run a consent platform: `privacy.consent(user).signal()` reads its TCF or GPP string directly.

**How are backups erased?** By deleting the person's key through `keys`, so their rows in backups can no longer be decrypted. That is built.

**Is there a ready-made privacy centre UI?** No. You build the screens; `privacy` does the work.

## Next

- [Encryption keys](https://mainsdk.dev/docs/interfaces/keys): per-user keys and crypto-erasure.
- [Tenants](https://mainsdk.dev/docs/interfaces/tenant): export and erase a whole tenant.
- [Data and records](https://mainsdk.dev/docs/interfaces/db): label your fields.
- [Product analytics](https://mainsdk.dev/docs/interfaces/analytics): tracking that respects consent.
