Data classification (.sensitive / .safe)

Classify columns as .sensitive(class) or .safe() so the export masker can replace PII with realistic, referentially-consistent fakes at the source. Fail-closed — an unclassified column blocks a masking export.

Two column modifiers that carry data-sensitivity metadata into the schema — nothing at runtime, nothing in the DDL. They exist for one job: letting voltro data export --profile dev replace personal data with realistic, referentially-consistent fakes at the source, so a copy of prod that lands in dev/stage never contains real user data.

  • .sensitive(class) — this column holds personal/sensitive data of a known CLASS (email, phone, secret, …). The class picks a format-preserving fake.
  • .safe() — this column was reviewed and holds NO PII; copy it verbatim.

They pair: under a masking profile every exported column must be one or the other (or the primary key / a foreign key, which are implicitly safe), or the export refuses. That is the whole point — see why fail-closed.

.sensitive(class) — mark a column as PII

import { id, text, table } from '@voltro/database'

export const users = table('users', {
  id:    id(),
  email: text().sensitive('email'),
  name:  text().sensitive('fullName'),
})

What it does: nothing at runtime and nothing in the emitted DDL. It is pure metadata — the export masker reads it (from the DECLARED schema) and picks a format-preserving fake for the column.

Why you use it: lower environments must never contain real user data — for GDPR, for least privilege, and to shrink the blast radius of a leaked dev dump. Classifying the PII columns lets the framework substitute realistic, referentially-consistent fakes automatically at the source, so real values never reach the bundle, transit, or a developer's machine.

Known classes

Each known class maps to a format-preserving fake (a fake email is a valid email, a fake phone looks like a phone). The class is also what a masking policy keys its per-class overrides on.

Class Fake
email valid-looking address (ada.lovelace4823@example.com)
fullName First Last
firstName a first name
lastName a last name
username a handle
phone a phone number
address a street address
company a company name
url a URL
ip an IPv4 address
creditCard a Luhn-valid 16-digit number
date date-shifted (see below)
secret nulled
freeText redacted to [redacted]

date, secret, and freeText are the non-fake defaults: date shifts by a seed-derived offset (relative intervals and ordering survive), secret is set to null, freeText becomes [redacted]. Every default is overridable per class or per column in the masking policy.

Custom classes. The known set is only what gives editor autocomplete — any string is accepted. Use a custom class (e.g. .sensitive('iban')) and give it a transform in the policy's classes (an unmapped custom class falls back to redact).

account: text().sensitive('iban'),   // give 'iban' an action in the policy's `classes`

.safe() — mark a column reviewed-safe

import { id, integer, text, table } from '@voltro/database'

export const posts = table('posts', {
  id:     id(),
  title:  text().safe(),                       // public — copy verbatim
  status: text().oneOf(['draft', 'live']).safe(),
  views:  integer().safe(),
})

What it does: metadata only — it copies verbatim under a masking profile.

Why it exists: masking is fail-closed. Under a masking profile a column that is neither .sensitive() nor .safe() blocks the export until it is consciously classified — so adding a column later can never silently leak PII to dev. .safe() is the explicit "I looked, it's fine" acknowledgement (a public title, a status enum, a counter).

Primary keys and foreign keys are implicitly safe

You do not annotate every id. id() (a primary key) and reference() (a foreign key) columns are opaque identifiers — keeping them verbatim is exactly what makes joins survive the copy — so they are treated as safe by default under a masking profile. No .safe() needed.

An explicit .sensitive() still overrides this for the rare case where the key itself is PII (a natural key like an email-as-id):

// A table keyed by a natural PII value — classify it explicitly.
subscription: table('subscription', {
  email: text().sensitive('email'),   // this IS the PK, and it IS PII → fake it
  plan:  text().safe(),
})

Interaction with .encrypted()

This is the subtle trap. .encrypted() protects data at rest (the stored value is opaque ciphertext), but the runtime decrypts on read — so by the time the export streams the row, the column is back to plaintext. Encryption at rest gives you nothing at export time.

The framework therefore treats an .encrypted() column as .sensitive('secret') automatically (so it is nulled under a masking profile) — unless you classify it otherwise:

ssn:   text().encrypted(),                 // ⇒ implicitly sensitive('secret') → nulled on export
phone: text().encrypted().sensitive('phone'), // explicit class wins → fake phone instead of null
review: text().encrypted().safe(),         // encrypted at rest, but reviewed-safe → copied verbatim

Precedence, exactly:

  1. An explicit .sensitive(class) wins — the column is faked by that class.
  2. Otherwise .encrypted() (without .safe()) implies sensitive('secret') → the column is nulled.
  3. .safe() on an encrypted column suppresses the implied secret and copies it verbatim — say this only when you have genuinely reviewed the plaintext.

.serverOnly() — never to a client

A THIRD, independent axis. .sensitive() / .safe() are about data export masking; .encrypted() is about storage at rest; .serverOnly() is about wire exposure — a column marked .serverOnly() is read normally by server code but must NEVER be serialized to a client:

keyHash: text().serverOnly(),   // an auth middleware verifies it; a client never sees it

The three are orthogonal — a column can carry any combination:

keyHash:      text().serverOnly(),               // a hash you never ship (not secret at rest — it IS the digest)
recoveryNote: text().encrypted(),                // encrypted at rest, but the owner may read it → not serverOnly
apiToken:     text().encrypted().serverOnly(),    // secret at rest AND never to a client

Enforcement: the crud.* read helpers strip .serverOnly() columns from every returned row automatically — you declare the exposure policy once at the schema and can't forget it on a handler. For a hand-written query, omit the column from the output schema (and don't put it in the returned object) — and voltro dev warns at boot, naming the query and column, if a wire-reachable query's output declares a .serverOnly() column of its source table, so a secret can't ship on the wire by omission-mistake.

Distinct from .encrypted() on purpose: encryption at rest says nothing about who may receive the plaintext — a private note you decrypt for its owner is a valid case, so treating "encrypted" as "never to a client" would be wrong. State the exposure policy explicitly.

Worked example

import { id, text, table } from '@voltro/database'

export const users = table('users', {
  id:     id(),                                   // PK → implicitly safe
  email:  text().sensitive('email'),              // → fake email
  name:   text().sensitive('fullName'),           // → fake name
  ssn:    text().encrypted(),                      // → implicitly sensitive('secret') → nulled
  status: text().oneOf(['active', 'banned']).safe(), // reviewed → copied verbatim
  bio:    text(),                                  // UNCLASSIFIED → blocks a masking export
})

Under a masking profile this table exports fine except bio: it is neither .sensitive() nor .safe(), so the export refuses and names it. Classify it (.sensitive('freeText') if it may contain PII, .safe() if it can't) — or override it in the policy's columns — and the export proceeds.

Classification is invisible everywhere else: it does not change the column's SQL type, its nullability, or any query. It is read only at export time.

Why fail-closed

Fail-open masking is worse than no masking. If a newly-added, unclassified column were copied verbatim by default, the copy would look masked — giving false confidence — while leaking real PII into dev on the very next schema change. The failure is silent and the blast radius grows over time.

Fail-closed flips that: the export stops and names the unreviewed column, forcing a conscious .sensitive() / .safe() decision before any data moves. The cost is a one-line annotation per new column; the payoff is that a PII leak to a lower environment can't happen by omission. Preview the exact set that would block with --dry-run before a real run.

See also