Privacy and data rights
privacyDeclare retention per data label, export or erase a person across every driver with one call, and record consent per purpose.
privacy-lifecycleFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
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
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] }import { privacy } from "mainsdk";
await privacy.consent(user).set({ purpose: "analytics", granted: false });
const record = await privacy.erase(user, { reason: "user request" });
// signed record listing every driver and its confirmationapp/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" });
});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)
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
1 drivers. Select one to see its line in main.yaml.
privacy:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-PRIV-0001 | Checkmainsdk check, before deploy | A data label in use has no retention rule (privacy.retention.<label>) |
MAIN-PRIV-0004 | Checkmainsdk check, before deploy | 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 | RuntimeError responses and logs | See the spec |
MAIN-PRIV-0003 | RuntimeError responses and logs | See the spec |
MAIN-PRIV-0005 | RuntimeError responses and logs | See the spec |
MAIN-PRIV-0006 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Retention per data label | Phase 1 | 0.3 | privacy-lifecycle#retention |
| Erase and export hooks in drivers | Phase 1 | 0.3 | privacy-lifecycle#driver-hooks |
| Erase across every driver | Phase 1 | 0.3 | privacy-lifecycle#erase |
| Signed erasure record and backup crypto-erasure | Phase 1 | 0.3 | privacy-lifecycle#erasure-record |
| One export bundle | Phase 1 | 0.3 | privacy-lifecycle#export |
| Consent records with TCF and GPP signals | Phase 1 | 0.3 | privacy-lifecycle#consent |
| Generated privacy labels and data map | Phase 1 | 0.3 | privacy-lifecycle#labels-gen |
| Legal hold | Phase 1 | 0.3 | privacy-lifecycle#hold |
| Partial erasure that retries and stays audited | Phase 1 | 0.3 | privacy-lifecycle#partial |
| Context | Phase 1 | 0.3 | privacy-lifecycle#wiring |
| Industry packs for payments | Phase 3 | 1.2 | industry-packs |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Fleet | Certified compliance packs and evidence |
Quick start
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.
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 needsprivacy.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
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:
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.
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 consentsignal 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
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
mainsdk privacy labels --format app-store
mainsdk privacy labels --format play --manifest manifest.jsonThe 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.
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.
Testing
createTestApp() from @mainsdk/testing runs privacy on the in-memory store and a local keys driver, so you can test erasure, holds and consent offline.
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.registeruntilemailcarries 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.
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: per-user keys and crypto-erasure.
- Tenants: export and erase a whole tenant.
- Data and records: label your fields.
- Product analytics: tracking that respects consent.
Was this page helpful?