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.

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 --yes

It 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 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 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 the output schema (and don't put it in the returned object).
  • Inboundcrud.create / crud.update refuse an input that sets a .serverOnly() column, with ServerOnlyColumnWrite naming 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 with ctx.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 grants billing:read unlocks the column even when the scope is not listed directly on the subject.
  • admin:full bypasses it. A subject holding admin:full sees 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 EVERYONE

Enforcement 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