# Database

> Branch the live schema and REHEARSE your migration on it — apply the plan to a throwaway copy, flag every lossy operation, prove it converges, drop the branch. Plus the branch primitive itself (namespace snapshot on Postgres, Neon copy-on-write fast-path).



---

<!-- source: en/database/branching.md -->
## Data branching

_Branch the live schema and REHEARSE your migration on it — apply the plan to a throwaway copy, flag every lossy operation, prove it converges, drop the branch. Plus the branch primitive itself (namespace snapshot on Postgres, Neon copy-on-write fast-path)._

Data branching creates an **isolated copy of a schema** you can read, write and
migrate without touching the source. The headline use is not the branch — every
serverless-Postgres vendor sells one of those — it is what Voltro can do WITH a
branch that a vendor cannot: **rehearse your pending migration on it and tell you
what it would do.**

## `voltro db branch` — the migration rehearsal

```bash
voltro db branch --pr 128                 # branch the live schema, rehearse, report, drop it
voltro db branch --pr 128 --seed copy     # …with the parent's rows copied in
voltro db branch --pr 128 --keep          # leave the branch standing to poke at
voltro db branch --pr 128 --json          # machine-readable, for a PR comment
```

What it does, in order:

1. **Branches the LIVE schema** into a throwaway namespace (`br_pr128_<app>`).
2. **Checks fidelity** — plans the declared schema against the branch AND against
   the parent, and aborts if the two disagree. A branch that is not a faithful
   copy rehearses a different migration from the one you are about to run, and
   saying nothing about that would be worse than not rehearsing at all.
3. **Plans your migration** against the branch and classifies every operation.
4. **Executes it there**, including the destructive operations.
5. **Re-plans.** An empty re-plan is the verdict; a migration that applies and
   then re-proposes itself forever is not a migration.
6. **Drops the branch** (unless `--keep`), even when the apply failed.

```
branch rehearsal · br_pr128_shop · mechanism namespace
  branched 18 table(s), replayed 3 foreign key(s)
  plan: 2 operation(s), 1 lossy, 0 refused
    • add-column members [safe]
    ✗ drop-column members [lossy]

  ⚠ 1 operation(s) DESTROY DATA. They were executed on the branch (it is
    disposable) so they are rehearsed, but production refuses them until you set
    VOLTRO_DESTRUCTIVE_OK — naming the tables, not `1`.

  ✓ applied on the branch, and the re-plan is EMPTY (the migration converges).
  branch br_pr128_shop torn down.
```

### Lossy operations are executed, not skipped

Production refuses a `drop-column` until a human acknowledges it. A branch that
is about to be dropped has no such reason — and refusing there would mean the one
operation most likely to fail is the one operation never rehearsed. So the
rehearsal unblocks lossy operations on the branch, runs them, and leads the report
with every one of them. That report is the thing you paste into the PR.

Operations the planner refuses **anywhere** (`needs-rename-annotation`,
`multi-step`) are NOT executed — the plan is reported as `blocked` instead.

### Exit codes

| code | meaning |
|---|---|
| `0` | applied on the branch and converged, nothing lossy |
| `2` | a REVIEW signal: the plan destroys data, or the planner refuses part of it |
| `1` | the rehearsal could not answer — it failed, the branch was not faithful, or the migration did not converge |

`lossy` is deliberately not an error. A `drop-column` in a PR is a normal,
intentional thing; making it exit `1` trains people to pass `--force`, and the
next real failure goes with it.

### What `--seed` does and does not rehearse

`--seed empty` (the default) branches the SCHEMA only. That is enough for every
structural question and costs nothing. It does **not** rehearse anything
data-dependent: a `NOT NULL` meeting existing NULLs, a backfill meeting real
values, a unique constraint meeting duplicates, or the row-count threshold that
promotes an operation to `online-required`. Use `--seed copy` for those — it
copies every row, which is fast on a small database and slow on a large one.

## Mechanisms — what actually works

Be precise here, because the vendor landscape invites over-claiming. The branch
**plan** is dialect-agnostic. The shipped **executor** is not.

| mechanism | when | status |
|---|---|---|
| `namespace` | Postgres — a schema per branch | shipped, and what `voltro db branch` uses |
| `neon-cow` | Postgres on Neon, `seed: 'copy'` | plan shipped; **executed by the cloud control plane**, not the CLI |

- **MySQL, MariaDB, SQLite and SQL Server are not supported by
  `voltro db branch`.** `makeNamespaceBranchExecutor` emits `CREATE SCHEMA`,
  `CREATE TABLE … (LIKE … INCLUDING ALL)` and `"`-quoted identifiers, none of
  which those engines accept. The command refuses with that reason rather than
  sending Postgres syntax at them. Writing your own `BranchExecutor` for another
  dialect is the supported path — the plan is already portable.
- **Neon copy-on-write is a call to Neon's branch API** and needs a Neon token,
  which the CLI does not hold. `voltro db branch` names the mechanism and refuses,
  pointing at `--prefer namespace`. The cloud control plane executes it.
- **There is no Supabase mechanism and no template-database mechanism.** Neither
  exists in the codebase.

## The plan/execute split

Branching is a **plan** (what to do) + an **executor** (do it), mirroring the
migration engine. The plan is pure and inspectable; the executor performs the I/O.

```ts
import { planBranch, resolveBranchMechanism } from '@voltro/database'

const mechanism = resolveBranchMechanism({ seed: 'copy', dbUrl: connectionString })
const steps = planBranch({
  mechanism,
  branchId: 'br_pr128_shop',
  tableNames,
  seed: 'copy',
  parentNamespace: 'public',
})
```

## Provision + tear down

`provisionBranch` / `teardownBranch` run a plan through an injected
`BranchExecutor`. Injected so the lifecycle is unit-testable with a recording
executor — no live database:

```ts
import { provisionBranch, teardownBranch, makeNamespaceBranchExecutor } from '@voltro/database'

const result = await provisionBranch(
  {
    appSlug: 'shop', prNumber: 128, seed: 'copy', tableNames,
    parentNamespace: 'public',
    foreignKeys,       // see below — LIKE does not copy these
    indexNames,        // see below — LIKE renames these
    dbUrl: connectionString,
  },
  makeNamespaceBranchExecutor({ run: (sql) => client.query(sql) }),
)

await teardownBranch(result.branchId, result.mechanism, executor)
```

`admitBranch` enforces the storage-cost caps (a TTL and a maximum number of live
branches) before a new one is provisioned.

### Two things `LIKE … INCLUDING ALL` does not carry

Both were found by pointing the rehearsal at a real Postgres and watching a
converged schema propose work. They are properties of Postgres, not of Voltro's
emission, and a branch missing either is not a copy:

- **Foreign keys are not copied.** There is no `INCLUDING` clause that copies
  them. A branch without them accepts writes production rejects.
- **Index names are re-derived** from the table and columns. Measured on pg 17:
  `byApiKeyTenant` came back as `_voltro_api_keys_tenantId_idx`, and
  `_voltro_idempotency_scope_key_uq` as `…_scope_key_idx`. The migration planner
  compares indexes by name, so every custom-named index reads as a different
  index.

`provisionBranch` replays both (`foreignKeys` + `indexNames`, sourced from the
parent's introspected snapshot). `voltro db branch` does it for you, and refuses
to report anything about your migration if the branch still differs from the
parent.

## Branch-per-PR in CI

```yaml
- run: voltro db branch --pr ${{ github.event.number }} --json > rehearsal.json
  continue-on-error: true      # exit 2 is a review signal, not a build failure
- run: node scripts/comment-rehearsal.mjs rehearsal.json
```

The `--json` report carries `operations`, `lossy`, `blocked`, `residual`,
`converged` and `infidelity` — everything a PR comment needs, already classified.



---

<!-- source: en/database/sensitivity.md -->
## 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](#why-fail-closed).

## `.sensitive(class)` — mark a column as PII

```ts
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](/docs/cli/data#masking-prod-dev-stage-safely) 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](/docs/cli/data#masking-prod-dev-stage-safely).

**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`).

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

## `.safe()` — mark a column reviewed-safe

```ts
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):

```ts
// 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()`](/docs/database/columns) 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:

```ts
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:

```ts
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.

```sh
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:

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

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

```ts
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](/docs/data/crud) 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).
- **Inbound** — `crud.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:

```ts
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:

```ts
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`](/docs/data/rest-routes) 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

```ts
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`](/docs/cli/data#dry-run-preview-without-writing)
before a real run.

## See also

- [`voltro data` — masking, profiles, subsetting](/docs/cli/data) — the
  export command, the masking policy, `--dry-run`, and the audit manifest.
- [Column types](/docs/database/columns) — `.encrypted()` and the other
  column modifiers.
