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.
Which stores decrypt — every one the framework hands you
The codec is applied by the store, so "which store" is the whole question. All three of these decrypt on read and encrypt on write:
| store | where you get it |
|---|---|
ctx.store |
inside a handler |
| the boot store | an auth strategy, a plugin HTTP route, bindDataStore |
| a transaction view | inside store.transactional(...) |
The boot store is deliberately NOT the request-scoped one — it has no resolved Subject, so it carries no tenant scope, no soft-delete filter, no audit stamping and no row filter. It does carry the storage codec, because that needs no Subject: it is how a declared column is spelled on disk versus in JS.
That split is worth knowing because getting it wrong is silent. Ciphertext is a
string. It compares, concatenates, renders and logs without complaint, so a
value read through a store that skipped the codec fails somewhere else entirely
— a team sent enc:v1:… upstream as a bearer token, got a 401, and spent a day
inside their auth code. If a value that should be plaintext arrives as
enc:v1:…, the question is which store produced it, not whether the column is
declared correctly.
A store you construct yourself from a driver has no codec. If you need one — a migration script, a maintenance task — wrap it:
import { wrapStoreWithBootCodec } from '@voltro/runtime'
const store = wrapStoreWithBootCodec(rawDriverStore, 'postgres')Encrypting a column that already has rows
.encrypted() encrypts on write. Adding it to a populated column converts
nothing that is already stored — those rows stay plaintext until something
rewrites them, which for a credential column may be never.
voltro db encrypt-column integrations.webhookSecret --dry-run
voltro db encrypt-column integrations.webhookSecret employees.meilisearchKey --yesIt reads FIELD_ENCRYPTION_KEY (or --key-env NAME) and must be the same key
your app runs with — the one you pass to
governancePlugin({ fieldEncryption: { key } }).
Order does not matter. A read returns a non-ciphertext value unchanged, so
the column may hold a mix while you deploy: run the command before or after the
release that adds .encrypted(), and run it again afterwards to catch anything
written in between. It skips what is already encrypted, which also means an
interrupted run is resumed by running it again.
Five things it refuses to do, each of them a way a hand-written UPDATE goes
wrong quietly:
| It checks | Because |
|---|---|
| already-ciphertext values are skipped | double encryption cannot be undone without the key history |
| the value decrypts back before the write | a broken cipher otherwise fails on the first read, when the plaintext is gone |
| the key matches what the column already holds | a different key round-trips fine; resuming with one leaves a column readable with neither key alone |
| the column is wide enough | ciphertext is 49 + 4×ceil(bytes/3) characters — a 64-char key needs 137, and a varchar(100) fails partway through |
--yes is present |
it rewrites a column in place |
The width figure is in bytes, not characters: 'ä'.repeat(10) is 10
characters and 20 bytes, and encrypts to 77.
No value — plaintext or ciphertext — is ever printed. The report is counts.
.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 runs in both directions, because "never crosses the wire" is not a one-way claim:
- Outbound — 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 theoutputschema (and don't put it in the returned object). - Inbound —
crud.create/crud.updaterefuse an input that sets a.serverOnly()column, withServerOnlyColumnWritenaming it, and write nothing. A column the client may not read must not be one the client can set: accepting it is mass assignment. It is refused rather than silently stripped because a stripped field makes an attack indistinguishable from a no-op. When the server needs to write one, do it from the handler withctx.store.insert/ctx.store.update— the refusal is on the generated path, which is the one fed straight from client input.
A hand-written output is the case crud.* cannot cover, so an audit checks it:
a wire-reachable query whose source table carries a .serverOnly() column that
its output declares. What that costs is different per command, on purpose:
| Command | On a leak |
|---|---|
voltro serve |
the boot fails |
voltro doctor |
exits non-zero — put it in CI |
voltro dev |
warns, naming the query and column |
Dev only warns because a refused boot between two keystrokes is worse than the
bug; production is the opposite, so that is where the gate is. Do not read a
green voltro dev boot as a clean audit — the warning sits in the boot log
among everything else. voltro doctor is the check to automate.
VOLTRO_SERVER_ONLY moves the line in both directions: strict makes voltro dev fail too, warn downgrades voltro serve to a warning, off silences it
entirely. The downgrades are documented rather than hidden because the
alternative to a stated escape hatch is deleting the marker — and a check whose
only way out is to disable it gets disabled.
Gate CI on the audit having RUN, not on its silence
voltro doctor can only run this audit if it can load your descriptors. When it
cannot, it says so instead of claiming a pass:
• serverOnly: NOT CHECKED — the app's descriptors could not be loaded (not a pass)
reason: Transform failed with 1 error:
src/queries/broken.query.ts:2:5: ERROR: Expected ";" but found "is"
voltro doctor --json carries the same answer as serverOnly: { checked, reason?, leaks? }.
Assert on checked — an app that leans on .serverOnly() should treat a
persistent skip as a failure, because a skipped audit and a clean one look
identical from the outside.
(voltro check does not run this audit. It has a live-api mode that has no
access to your table definitions, and a rule that fires in one of its two modes
would be worse than one that fires in neither.)
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.
.readableBy(...scopes) — visible only to scoped subjects
The graded middle of the same wire-exposure axis as .serverOnly() — not a
fourth axis. A plain column is visible to everyone who can read the row;
.serverOnly() hides it from every client; .readableBy(...) sits between them —
the column reaches a subject only if it holds the scope:
import { id, integer, text, table } from '@voltro/database'
export const invoices = table('invoices', {
id: id(),
number: text(), // visible to everyone
amountCents: integer().readableBy('billing:read'), // only billing-scoped subjects
taxNote: text().readableBy('billing:read', 'admin:pii'), // ANY of the two scopes
})What it does: a column marked .readableBy(...scopes) is stripped from the
wire OUTPUT for any subject holding NONE of the listed scopes, and present for
one holding at least one of them. Like .serverOnly() it changes nothing at
rest and nothing in the DDL — it is a wire concern only, so a server-internal
read (ctx.store.query) still sees the value; the strip applies only on the way
out to a client.
Scope semantics:
- ≥ 1 scope, OR-matched. A subject sees the column iff it holds at least one
of the listed scopes —
.readableBy('a', 'b')means "a OR b", not both. - Effective scopes. The check is the framework's
hasEffectiveScope— the subject's RAW scopes ∪ its rbac role-derived scopes — so a role that grantsbilling:readunlocks the column even when the scope is not listed directly on the subject. admin:fullbypasses it. A subject holdingadmin:fullsees every.readableBy(...)column, exactly as it satisfies every guard.- At least one scope is required.
.readableBy()with no scope would mean "readable by nobody" — that is.serverOnly()— so both the empty call and a blank scope string are rejected at declaration.
.serverOnly() wins when both are present. .serverOnly() is the all-hidden
end of the axis, so a column carrying both is hidden from EVERYONE — admin:full
included. .readableBy() only ever narrows an otherwise-visible column; it can
never re-expose a .serverOnly() one:
amountCents: integer().readableBy('billing:read'), // scoped subjects see it; admin:full does too
secretKey: text().serverOnly().readableBy('billing:read'), // serverOnly wins → hidden from EVERYONEEnforcement shares the SAME chokepoint as .serverOnly() — the Dispatcher's read
boundary — but resolves per subject: it applies to a query's initial snapshot
AND every reactive subscription delta, and to the one-shot
publicApi REST GET, in both boot paths (voltro dev,
voltro serve). A table that declares no .readableBy(...) column pays nothing —
the strip collapses to the subject-independent .serverOnly() set and the
dispatcher's read/diff memo is still shared across all subscribers of a change.
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.