---
title: "@octabits-io/data-lifecycle"
summary: "What happens to a scope's data over time, made checkable: every scoped table classified for purge (delete, cascade, retain with a reason), every encrypted or credential-shaped column classified before an export can sweep it, the database's own foreign keys asked whether each cascade claim is real and what would block the root delete, a static audit that every scoped table is under row-level security, and a batched retention runner over your policies."
install: "pnpm add @octabits-io/data-lifecycle"
availability: "Available from November 2026"
version: "0.4.0"
license: "MIT"
related: []
canonical_url: "https://octabits.io/platform/data-lifecycle"
---

# `@octabits-io/data-lifecycle`

What happens to a scope's data over time, made checkable. A scope is whatever
your rows belong to — a tenant, an organisation, a workspace, an account —
and every table carrying the scope column is a table that must do something
definite when the scope is purged, exported, isolated or simply ages. This
package holds the checks that keep those answers honest, as pure functions
with **no dependency at all**, and the runner that acts on the one answer
that is a calendar.

```ts
import { checkTableDispositions, createRetentionRunner } from '@octabits-io/data-lifecycle';
import { describeDatabase, introspectForeignKeys } from '@octabits-io/data-lifecycle/postgres';
import { describeDrizzleSchema, restrictTo } from '@octabits-io/data-lifecycle/drizzle';
```

| Import | Depends on | What it is |
|---|---|---|
| `@octabits-io/data-lifecycle` | nothing | The checks over a structural `SchemaDescription`, and the retention runner. Runs anywhere. |
| `./postgres` | nothing (structural executor) | `introspectForeignKeys`, `introspectScopedTables`, `describeDatabase`, `introspectRowSecurity`, `introspectCurrentRole`, and the live isolation audits `auditScopeCoverage` and `auditAppRole`, over any `query(text, params)` — a `pg` Pool, PGlite, a postgres.js wrapper. |
| `./drizzle` | `drizzle-orm` (optional peer) | `describeDrizzleSchema`, the `derive*` helpers, introspection through a Drizzle instance, `restrictTo` for retention runners. |

## When not to use it

- **To delete, export or anonymise.** Nothing here issues a `DELETE`, reads a
  row for export, or rewrites one. The checks hold your manifests to the schema;
  the purge, the export and the retention runners are code you write and this The access and erasure requests themselves — sources per category, erasure steps per source, the coverage check between them, the cover sheet, the honest outcome and the request log — are [`@octabits-io/data-subject`](https://octabits.io/platform/data-subject.md); its steps and this package's dispositions describe the same tables from two sides.
  package calls or verifies.
- **As the isolation runtime.** `auditRlsCoverage` is a static read of Drizzle
  schema source and migration SQL — it looks for `pgTable(` and the
  `ENABLE` / `FORCE ROW LEVEL SECURITY` and `CREATE POLICY` statements, not at
  `pg_policies`. It reports what your source says, not what a live database
  enforces, and it sets no GUC: per-request scoping is
  `@octabits-io/rls/drizzle` or `@octabits-io/rls/prisma`, and the policies are
  yours.
  `auditScopeCoverage` is the live counterpart: it reads `pg_class` and
  `pg_policy` after your migrations ran, and still sets nothing.
- **A schema without a scope column.** Every check runs over the list of scoped
  tables you hand it. A single-scope application has no scope to purge or
  isolate; what is left for it is the retention runner and, over whatever table
  list it passes, the export classification.
- **Scheduling.** `createRetentionRunner` is the sweep; the clock is yours — a
  cron, a pg-boss schedule, a Kubernetes CronJob.
- **A data-subject-request workflow.** No request queue, no deadline tracking,
  no data map for a person to browse. This is the CI test that says every scoped
  table has an answer; the process around it is a product of yours.

## The five questions, and the check for each

**Purge.** When the scope goes, every scoped table is either deleted
explicitly, cascaded from something that is, or retained on purpose with a
reason. `checkTableDispositions` holds a manifest to that: nothing
unclassified, nothing stale, the purge order and the `purge` dispositions the
same set, every cascade pointing at something itself removed, every retain
justified. `checkCascadeDispositions` then asks the database's foreign keys
whether each cascade claim is real — a wrong claim is silent in unit tests
and either retains a purged scope's rows or aborts the purge — and
`findBlockingTables` asks, independent of the manifest, what would block
`DELETE FROM <root>`.

**Export.** Before a data-subject export can sweep every scoped table, every
encrypted column (`bytea`) and every credential-shaped one (`*token*`,
`*secret*`, `*hash*`, … as a string type) is classified: decrypt with a named
regime, omit with a reason, or emit plain with a reason — so "nobody looked"
and "somebody checked" stay distinguishable. `sensitiveColumns` derives the
set; `checkExportDispositions` holds the manifest to it, and a `mustOmit` list
names the columns that may never be emitted in any form.

**Personal data at rest.** A column named like a person's data — `email`,
`phone`, `name`, `first_name`, `street`, `date_of_birth`, `iban`, `tax_id`, …
(`DEFAULT_PERSONAL_DATA_COLUMN_PATTERNS`, matched in snake_case on either the
property or the SQL name, and extendable with your domain's words) — holds
ciphertext, or a blind index over it, or is declared plain with the reason it
may stay readable. `personalDataColumns` derives the set over every table,
global ones included; `checkPersonalDataColumns({ personalDataColumns,
protectedColumns, plain })` reports a plaintext column nobody declared
(`personal_data_plaintext`), a declaration without a reason
(`personal_data_plain_unjustified`) and one that no longer fits
(`personal_data_plain_stale`). Which columns are protected is the host's to
say — a column type that refuses plaintext, a declared set; the check only
holds the two lists against the schema, so adding a `phone` column fails CI
until somebody encrypts it or says why it need not be:

```ts
import { checkPersonalDataColumns, personalDataColumns } from '@octabits-io/data-lifecycle';

expect(checkPersonalDataColumns({
  personalDataColumns: personalDataColumns(describeDrizzleSchema(schema)),
  protectedColumns: ['contact.nameEncrypted', 'contact.emailEncrypted', 'contact.emailIndex'],
  plain: { 'organization.name': "a company's name, not a person's" },
})).toEqual([]);
```

**Isolation.** `auditRlsCoverage` reads your schema source and migration SQL
and reports every scoped table without row-level security enabled, forced
*and* policied — enabled without forced is bypassed for the owner, enabled
without a policy denies every row — against a pruned allowlist, with renames
modelled because Postgres carries RLS through `ALTER TABLE … RENAME TO`.
Static; no database. A scope column pattern that matches no table is itself a
finding, `rls_scope_pattern_unmatched` — a pattern with the wrong quotes would
otherwise audit nothing and pass.

The live counterpart asks the question the static one cannot: is there a table
that should be scoped and was never given the scope column?
`auditScopeCoverage(db, { scopeColumn, global })` describes every table of the
migrated database and reports each one that neither carries the scope column
under row-level security enabled, forced and policied, nor is declared global
with a reason (`scope_missing`, `rls_uncovered`, `rls_half_applied`, and
`scope_global_stale` for an entry that no longer fits). `auditAppRole(db)` checks
the role the application's connection runs as: a superuser or a `BYPASSRLS` role
ignores every policy, so the first two checks would pass with isolation off
(`role_bypasses_rls`). Both run in a test against PGlite in milliseconds, which is
how a host turns "every table is isolated" into a check a coding agent cannot
skip:

```ts
import { auditAppRole, auditScopeCoverage } from '@octabits-io/data-lifecycle/postgres';

expect(await auditScopeCoverage(owner, {
  scopeColumn: 'org_id',
  global: { user: 'sign-in identities, shared across organisations' },
})).toEqual([]);
expect(await auditAppRole(app)).toEqual([]);
```

`checkScopeCoverage` and `checkAppRole` are the same checks as pure functions
over a description you build yourself.

**Age.** `createRetentionRunner({ policies, runners, dateProvider, logger })`
is the batched, per-policy, failure-isolated sweep for storage limitation
(GDPR Art. 5(1)(e)): each `RetentionPolicy` names its window and the reason
for it, its runner affects at most `limit` rows per call, the loop stops on a
short batch or loudly at a ceiling, and the summary says what was affected,
what was truncated and what failed. `run({ scopeValues })` restricts a sweep
to given scopes; the runner receives them and builds its own predicate.

Every check returns `LifecycleFinding[]` — `{ code, subject, message }` — and
a test asserts the list empty. Adding a scoped table or a `*_token` column
then fails CI until somebody says what should happen to it.

## Where the schema comes from

The checks read a `SchemaDescription`: tables with a name, a SQL name and
columns with their SQL types. Three ways to get one:

```ts
// Drizzle: by export name, columns by property name
const tables = describeDrizzleSchema(schema);
const scoped = deriveScopedTables(schema, 'orgId');

// Any database, any ORM: the live schema, names = SQL names
const tables = await describeDatabase(pool);           // pg, PGlite, postgres.js
const scoped = scopedTables(tables, 'org_id');

// By hand, for a schema in whatever you use
const tables: SchemaDescription = [{ name: 'invoice', sqlName: 'invoice', columns: [{ name: 'org_id', sqlName: 'org_id', sqlType: 'text' }] }];
```

A Prisma or Kysely codebase takes the second path and runs every check
against the migrated database, which is also the only description that can
never drift from what is deployed.

## A manifest test

```ts
import { checkTableDispositions, checkCascadeDispositions, findBlockingTables } from '@octabits-io/data-lifecycle';
import { deriveScopedTables, sqlNameOf, introspectForeignKeys } from '@octabits-io/data-lifecycle/drizzle';
import * as schema from './schema';
import { DISPOSITIONS, PURGE_ORDER } from './purge-manifest';

const scoped = deriveScopedTables(schema, 'orgId');

test('every org-scoped table has a disposition', () => {
  expect(checkTableDispositions({ scopedTables: scoped, dispositions: DISPOSITIONS, purgeOrder: PURGE_ORDER, root: 'org' })).toEqual([]);
});

test('every cascade claim is backed by the database', async () => {
  const foreignKeys = await introspectForeignKeys(db);
  const nameOf = (n: typeof scoped[number]) => sqlNameOf(schema, n);
  expect(checkCascadeDispositions({ scopedTables: scoped, dispositions: DISPOSITIONS, sqlNameOf: nameOf, foreignKeys })).toEqual([]);
  expect(findBlockingTables({ scopedSqlTables: scoped.map(nameOf), purgedSqlTables: PURGE_ORDER.map(nameOf), rootSqlTable: 'org', foreignKeys })).toEqual([]);
});
```

## A retention runner

```ts
const retention = createRetentionRunner({
  policies: [{ key: 'mail_log', table: 'mail_log', action: 'delete', days: 90, rationale: 'delivery evidence; 90 days covers every dispute window in the terms' }],
  runners: {
    mail_log: async ({ cutoff, limit, scopeValues }) => {
      const rows = await db.delete(t.mailLog)
        .where(and(restrictTo(t.mailLog.orgId, scopeValues), lt(t.mailLog.createdAt, cutoff)))
        .returning({ id: t.mailLog.id });   // with a LIMIT via a subquery on your driver
      return rows.length;
    },
  },
  dateProvider: { now: () => new Date() },
  logger,
});
await retention.run();                          // the platform-wide sweep, from a cron
await retention.run({ scopeValues: ['org-7'] }); // one scope, on demand
```

## What stays yours

The manifests — which table is purged in what order, which column is
decrypted with which key, which tables are allowlisted out of RLS and why,
which retention windows apply — and the purge itself. This package never
issues a `DELETE`; it holds the lists to the schema and the schema to the
database.

## Stability

0.x: patches never break, minors say so in the changelog. The finding codes
(`table_unclassified`, `cascade_no_fk`, `rls_half_applied`, …) are part of
the surface — a test that matches on them keeps working across patches.

## License

MIT