---
title: "@octabits-io/data-subject"
summary: "Data-subject rights as a contract your application implements per table: sources that collect what you hold about a person (Art."
install: "pnpm add @octabits-io/data-subject"
availability: "Available from November 2026"
version: "0.2.1"
license: "MIT"
related:
  - name: "@octabits-io/result"
    url: "https://octabits.io/platform/result.md"
canonical_url: "https://octabits.io/platform/data-subject"
---

# `@octabits-io/data-subject`

A person asks what you hold about them, or asks you to erase it. What you hold
is spread over tables only your application knows, so this package owns none
of the queries. It owns the protocol around them: a **source** per category of
data that collects the person's rows (Art. 15 access, Art. 20 portability), an
**erasure step** per source that anonymises, deletes, or retains on a stated
legal basis (Art. 17), a **coverage check** that fails the build when a source
has no step, an **export bundle** with a cover sheet — the categories and
their counts, the withholdings, and the Art. 15(1) statements the controller
provides (`disclosures`: purposes, recipients, retention, rights, source,
automated decisions) — an
**erasure runner** whose outcome is honest — `partial` whenever anything stays
standing — and the **request log** that is your Art. 5(2) accountability
evidence, as a contract with an in-memory reference, a plain-SQL store over a
structural executor, and a Drizzle adapter. One dependency, `@octabits-io/result`.

```bash
npm i @octabits-io/data-subject          # bring the executor or the ORM you already have
```

```ts
import { checkErasureCoverage, collectExport, runErasure, type DataSubjectSource, type ErasureStep } from '@octabits-io/data-subject';
import { createPostgresDataSubjectRequestStore } from '@octabits-io/data-subject/postgres';

// Your context: whatever the queries need — the subject, a transaction, the key that decrypts.
interface Ctx { subject: { id: string; emailIndex: Buffer }; tx: Tx }

const sources: DataSubjectSource<Ctx>[] = [
  { key: 'profile', label: 'Customer master record', collect: ({ subject, tx }) => tx.query(sql`SELECT … WHERE id = ${subject.id}`) },
  { key: 'orders',  label: 'Orders',                 collect: ({ subject, tx }) => tx.query(sql`SELECT … WHERE customer_id = ${subject.id}`) },
  { key: 'invoices', label: 'Invoices',              collect: ({ subject, tx }) => tx.query(sql`SELECT … WHERE customer_id = ${subject.id}`) },
];
const steps: ErasureStep<Ctx>[] = [
  { key: 'profile',  mode: 'anonymize', run: ({ subject, tx }) => tx.update(sql`UPDATE customer SET name = '[erased]', email = NULL WHERE id = ${subject.id}`) },
  { key: 'orders',   mode: 'delete',    run: ({ subject, tx }) => tx.delete(sql`DELETE FROM "order" WHERE customer_id = ${subject.id}`) },
  { key: 'invoices', mode: 'retain',    retentionBasis: 'Invoices are kept ten years under § 147 AO.' },
];

// in a test beside your schema — a source the erasure forgot fails here, not in front of the authority
expect(checkErasureCoverage({ sources, steps })).toEqual([]);

// Art. 15 / Art. 20
const bundle = await collectExport({ sources, context, controller: 'Example GmbH', subject: { kind: 'customer', id, displayName } });

// Art. 17
const report = await runErasure({
  steps,
  blockers: await findBlockers(subject),                       // an open contract, an unsettled payment → refused
  inTransaction: (work) => db.transaction((tx) => work({ subject, tx })),
  afterCommit: async () => deleteAttachmentBlobs(subject),     // what cannot join the transaction; anything left → partial
});

// Art. 5(2): the log
const requests = createPostgresDataSubjectRequestStore({ db: pool, scope: { column: 'org_id', value: orgId } });
await requests.record({ subjectKind: 'customer', subjectId: id, subjectRef: emailIndexBase64, requestKind: 'erasure', ...report.value, actorId });
```

| Subpath | Deps | Contents |
|---|---|---|
| `.` | `@octabits-io/result` | `DataSubjectSource` / `ErasureStep` and `checkErasureCoverage`; `collectExport` and the `ExportBundle` / `ExportCoverSheet`; `runErasure` and the `ErasureReport`; the `DataSubjectRequestStore` contract, `createInMemoryDataSubjectRequestStore()`, and the page and subject-filter semantics every store shares |
| `./postgres` | `@octabits-io/result` (structural executor) | `createPostgresDataSubjectRequestStore({ db, table?, scope? })` and `dataSubjectRequestDdl({ table?, scope? })` over any `query(text, params) → { rows }` — a `pg` Pool or Client, PGlite, a postgres.js wrapper |
| `./drizzle` | `drizzle-orm` (optional peer) | `dataSubjectRequestColumns` to spread into your `pgTable` beside your scope column, and `createDrizzleDataSubjectRequestStore({ db, table, scope? })` |

A boundary lint keeps the root and `./postgres` on `@octabits-io/result` alone and
`drizzle-orm` confined to `./drizzle`, and the whole package type-checks with no
Node typings.

## What the package decides, and what you do

**The subject is yours.** Resolving "this e-mail address" to a customer row,
an owner row, or a blind index that reaches people who never had a row, is a
query against your schema, so it is your code. The contexts carry whatever it
produced; the log keeps only an opaque `subjectRef` — a blind index, encoded —
that survives the erasure it records, so the same person's next request can be
matched to it. Never the identifier itself.

**A category is a source.** One `key`, one `label` for the cover sheet, one
`collect`. Collectors return rows already decrypted and shaped for the person
who will read them; a collector that throws fails the whole export, because a
bundle with a category silently missing is worse than none.

**Every source has an erasure step, and the check says so.** `anonymize`
overwrites, `delete` removes, `retain` keeps rows and states the legal basis
that will appear in the outcome. `checkErasureCoverage` finds the source with
no step, the step with no source, the retention without a basis, and the
mutating step without a `run`. It is a pure function over your two lists — put
it in a test.

**The outcome cannot flatter you.** `runErasure` refuses on blockers before any
step runs. Otherwise the mutating steps run in order inside your transaction
(a throw rolls it back and comes back as an error naming the step), then the
work that cannot join a transaction — deleting blobs — runs after commit, never
before: a rollback then leaves the attachments intact, whereas deleting first
destroys them for an erasure that never happened. A retained step (with its basis as
the note, or the lack of one), anything `afterCommit` reports as left standing,
and an `afterCommit` that throws — the rows are gone by then, so the report
stands and says what did not happen — make the outcome `partial` with a note
saying why. `completed` means nothing about the person remains. A step that
throws is not a report but an error value; the log's word for it is `failed`,
so a request that did not go through is evidence too.

**The log is the evidence.** One row per request: the subject's kind, id and
ref, the request's kind, the outcome and its note, the per-source counts, who
performed it, when. Scoping is the store's construction-time concern:
`scope: { column, value }` stamps and filters; `scope: { column }` alone stamps
from the entry and reads across scopes, for a system-mode caller; no `scope`
in a single-scope deployment. `list` is newest first, 50 a page, 200 at most,
filtered by subject kind and id or ref.

## Where it comes from

Extracted from a multi-tenant booking platform's Art. 15 / Art. 17 service, in
production, where the sources are Drizzle queries over encrypted columns and
the steps crypto-shred access tokens, anonymise guest names to a placeholder,
and retain invoices and guest registrations inside their statutory windows.
That service resolves subjects by blind index, decides blockers from future
bookings and unsettled payments, and deletes message attachments after commit
— every one of those is the host's code, and every one of them fits the
contract above without a change to it.