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 verbatimPrecedence, exactly:
- An explicit
.sensitive(class)wins — the column is faked by that class. - Otherwise
.encrypted()(without.safe()) impliessensitive('secret')→ the column is nulled. .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 itThe 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 clientEnforcement: 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
voltro data— masking, profiles, subsetting — the export command, the masking policy,--dry-run, and the audit manifest.- Column types —
.encrypted()and the other column modifiers.