# CLI

> The voltro CLI — every command, grouped by purpose, with the flags that actually matter.



---

<!-- source: en/cli/overview.md -->
## Overview

_The voltro CLI — every command, grouped by purpose, with the flags that actually matter._

The Voltro CLI is the single entry point for scaffolding, dev, build, and run. It also seeds the framework agent guide into every project — written under BOTH `AGENTS.md` and `CLAUDE.md` — so AI coding agents have your framework conventions on tap.

## Command quick-reference

The dispatcher routes `voltro <command> [args]` to the matching subcommand and passes the rest through. **`voltro help` is the authority** — it prints the registry itself, so it cannot drift from what your installed CLI dispatches. The table below is every command that registry exposes, grouped by purpose:

| Group | Commands |
|---|---|
| [Scaffolding](/docs/cli/scaffolding) | [`init`](/docs/cli/scaffolding#voltro-init) (initialise the current directory as a workspace root — no apps), `create-project`, `add-app`, `list-templates`, `new` (scaffold one primitive — `query` / `mutation` / `action` / `workflow` / `page`) |
| Packages | `package` (`create` / `publishable` / `private` / `status`), `create-package` |
| [Dev](/docs/cli/dev) | `dev`, `codegen`, `typecheck` (`tsc --noEmit` with the app's own TypeScript), `agents-md`, [`env`](/docs/cli/env) (`check` / `sync` / `types` / `turbo`), [`generate`](/docs/cli/scaffolding) (AI app-builder), `dashboard` (serve the DevTools dashboard standalone; `--port`, `VOLTRO_DASHBOARD_APPS`) |
| [Build & run](/docs/cli/build-and-start) | `build`, `start`, `serve` |
| Deploy | `deploy` (`plan` — auto-detect the target tier per app + function), [`serverless`](/docs/deployment/serverless-functions) (`list` / `dev` / `serve` / `build` / `deploy`), [`static`](/docs/deployment/static-sites) (`hosts` / `deploy`), `dormancy` (single-node scale-to-zero: fronts the app, stops it when idle, wakes on the next request; `--idle-grace-ms` / `--tick-ms`) |
| [Database](/docs/cli/migrate) | `migrate`, `db` (`plan` / `apply` / `plans` / `drift` / `squash` / `restore-snapshot` / `migrate` / `rollback` / `status` / `seed`), [`evolve`](/docs/database/migrations/rename-and-drop) (schema-evolution copilot — propose a codemod + branch-verified backfill for a rename / retype / split / drop of an existing column or table) |
| [Update](/docs/cli/update) | `update` (`--to` / `--dry-run` / `--force` / `--exact`) — bump every `@voltro/*`, install, run the codemods that adapt your source to the new version |
| [Data transfer](/docs/cli/data) | `data` (`export` / `import` / `unpack` / `inspect` / `backup` / `restore`) — directory + single-file `.vbundle` bundles, streaming assets, masking, at-rest encryption |
| Ops / infra | `cache` (`status` / `flush` / `invalidate`), `add` (`redis`), `baseline` (`list` / `status` / `set`), `schedule` (`run <name>` — fire a registered schedule on demand; `--process` / `--trigger manual\|external` / `--url`), `schedule-manifest`, [`storage`](/docs/plugins/storage) (`doctor` / `cors`) |
| AI / data | `embeddings backfill <table> --text <field> --vector <field>` — (re)embed rows the `vectorEmbedding()` mixin missed (pre-existing rows / a model change); `--dry-run` to preview; [`eval`](/docs/ai/agents#evaluating-recorded-runs-voltro-eval) — replay recorded agent runs against golden cases + judge, exit 1 on regression (a deploy gate; `--json` / `--branch` / `--threshold`) |
| Integrate | [`webhooks`](/docs/plugins/webhooks#voltro-webhooks-consumer--the-package-your-subscribers-install) (`consumer` / `events`) — generate the ZERO-dependency Standard-Webhooks verification package your subscribers install, from your own declared events (`--out` / `--name`); list the events a subscriber can register for (`--json`) |
| [Inspect & debug](/docs/cli/inspect) | `inspect`, `logs`, `traces`, `workflows`, `cluster`, `check` |
| [Health & surface](/docs/cli/build-and-start) | [`doctor`](/docs/cli/build-and-start) — serve preflight + the hand-roll detector (names the shipped primitive at the spot you're rebuilding it); [`capabilities`](/docs/cli/build-and-start) (`--json`) — the export surface read from your installed `@voltro/*`, so it can be verified instead of recalled; `info` (`--json`) — CLI / node / package-manager / dialect + every installed `@voltro/*` version, flagging lockstep skew (exits 1 on skew) |
| Harness | `test`, `e2e` |
| Cloud | `cloud` (`login` / `whoami` / `projects` / `env` / `import`); `login` is a top-level alias of `cloud login` |
| Secrets | `secret` (`generate [purpose]` — the right var+format per secret; `generate` alone → a generic secret; `list`) |
| Meta | `agents-md`, `telemetry` (reports that Voltro collects none — no phone-home, nothing to opt out of), `version`, `help` |

**Two commands are hidden from `voltro help` on purpose** and are not in the
table: `prune-runtime <deploy-dir>` (trims a deployed web tree's `node_modules`
to the reachable runtime set — the standalone Dockerfiles run it for you) and
`_apply-codemods` (the re-exec target `voltro update` uses to run the target
version's codemods). Both are dispatchable; neither is something you invoke
directly.

Each command takes an optional path argument (the app directory) — defaults to `.` when run from inside an app. `voltro init` initialises the current directory as a workspace root — `pnpm-workspace.yaml`, a root `package.json` with `dev`/`build`/`test`/`typecheck`, a `.gitignore` and `git init`, idempotently and without scaffolding any apps (`voltro create-project <name>` does that, and bootstraps the same root when there isn't one). `voltro secret generate [purpose]` prints a cryptographically strong secret — with a purpose (`data-transfer`, `session`, `bundle-key`, `field-encryption`, `storage`, `inspect`) it emits the correct env var + length/format as a paste-ready `NAME=value` (`voltro secret list` shows them all); with no purpose, a generic base64url secret. `voltro telemetry` reports that Voltro collects none. `voltro deploy` shows the deploy paths — self-host via a [baseline](/docs/deployment/self-hosting) + CI, managed via the control-plane client `voltro cloud` (managed cloud deploy is coming soon — see [Voltro Cloud](/docs/deployment/voltro-cloud)), individual [serverless functions](/docs/deployment/serverless-functions) (`voltro serverless` → self-hosted Node, or Cloudflare / Scaleway), or a [static site](/docs/deployment/static-sites) (`voltro static` → Cloudflare Pages / S3 / Netlify).

## Help + version

`voltro help` prints the command list; `voltro version` prints the CLI version. Both are also reachable as bare flags — `voltro --help` / `-h` and `voltro --version` / `-v` — handled by the dispatcher before it looks for a subcommand.

**Per-command help** is the useful one. `voltro <command> --help` (equivalently `voltro help <command>`) prints that command's usage line, its flags, the environment variables that change what it does, and worked examples:

```bash
voltro dev --help
```

```text
voltro dev — Start the local development server (auto-discovers routes / mutations).

Usage
  voltro dev [app]

Examples
  voltro dev
      boot the app in the current directory
  voltro dev apps/acme/api
      boot a specific app

Environment
  PORT                     override the port from app.config.ts
  WATCH=0                  run the server directly, with no file-watching supervisor
  WATCH_POLL=1             poll instead of using native FS events (bind-mounts: k8s hostPath / Docker)
  …
```

`--help` never runs the command. A command with subcommands of its own (`db`, `cloud`, `workflows`, `inspect`, `update`, …) prints its own richer page instead.

## Startup

The CLI loads a command's implementation only when you dispatch it, so `voltro version`, `voltro info` and `voltro new` do not pay for the dev server, the build toolchain or the runtime. The same applies to the `voltro dev` supervisor's respawned child on every save.

The CLI's first line is its own — no Node warnings ahead of it.

## Common per-command flags

There is no universal global-flag layer; flags are per-command. The ones that recur:

| Flag | Where it applies |
|---|---|
| `--format pretty\|json` | `logs`, `traces`, `inspect`, `cluster`, `workflows` — machine-readable output. |
| `--no-color` | `logs`, `traces` — strip ANSI codes when piping to a file. |
| `--process <name>` | `logs`, `traces`, `inspect`, `cluster` — narrow to one running process. |
| `--force` | `agents-md` — overwrite existing files. |

Commands like `build` / `start` / `migrate` / `codegen` parse no flags at all — only an optional path. Check each command's page for its real surface.

## Common env vars

The framework reads more than a hundred distinct `VOLTRO_*` / `DB_*` / `PG_*`
variables across its packages, so this is **not** the full set and could not
usefully be — it is the ones you reach for. Each subsystem's page carries its own; the complete transport-security
and connection lists live in [Production hardening](/docs/deployment/production-hardening)
and [Security](/docs/security/overview).

| Var | Effect |
|---|---|
| `NODE_ENV` | `production` / `development` / `test`. Affects defaults across many commands. |
| `DB_DIALECT` | `postgres` *(default)* / `mysql` / `mariadb` / `mssql` / `sqlite` / `turso` / `memory`. Picks the SQL backend. |
| `STORE` | `memory` / `postgres` — alternate data-store selector (resolution order: `DB_DIALECT` → `STORE` → `app.config.ts`). |
| `WATCH` | `0` / `1`. Toggle filesystem watch in `voltro dev`. |
| `VOLTRO_DASHBOARD` | `off` to disable the auto-launched dashboard. |
| `VOLTRO_LOG_FORMAT` | `pretty` / `json`. Force the logger's output format. |
| `VOLTRO_LOG_LEVEL` | `trace` / `debug` / `info` / `warn` / `error` / `fatal`. |
| `VOLTRO_INSPECT` | `off` to disable the `/_voltro/inspect/*` HTTP endpoints. |
| `VOLTRO_INSPECT_TOKEN` | Bearer for the inspect surface. **Fail-closed:** unset → every request is `401`. `voltro dev` mints one per project; `voltro serve` / `voltro start` mint nothing, so a public deploy is closed by default (set it explicitly to open the surface). |
| `VOLTRO_INSPECT_ALLOWED_HOSTS` | Extra `Host` names allowed to reach the **dev** inspect surface, past its DNS-rebinding guard (comma/space-separated). Loopback names + IP literals are always allowed; any other domain name is refused unless listed here — the api counterpart of vite's `allowedHosts`. |
| `DB_URL` | Database connection string (falls back to `DB_PRIMARY_URL`; or the discrete `DB_*` / `PG_*` fields). |
| `DB_ACQUIRE_TIMEOUT_MS` | How long a request may wait for a free pooled connection before failing (default `10000`; `0` restores the driver's own unbounded wait). Read on every command and every dialect that can bound it. `DB_ACQUIRE_QUEUE_LIMIT` is its mysql/mariadb counterpart and is deliberately **unset** by default. |
| `VOLTRO_SESSION_SECRET` | Session-cookie signing secret (`@voltro/plugin-auth`). Rotate with zero downtime: move the old value to `VOLTRO_SESSION_SECRET_PREVIOUS` for one session lifetime — cookies signed with either secret keep verifying, and previous-key cookies are re-issued under the new one. |
| `VOLTRO_DATA_TRANSFER_SECRET` | Gates the prod data-transfer endpoints (`POST /_voltro/admin/{export,import}`); ≥16 chars or the routes don't mount. |
| `VOLTRO_BUNDLE_KEY` | Passphrase for `.vbundle` export encryption (`voltro data export --encrypt`). A DEDICATED key, not the transfer secret. |
| `VOLTRO_FIELD_ENCRYPTION_KEY` | Key for `.encrypted()` columns (`governancePlugin({ fieldEncryption: true })`) — a passphrase or a raw 64-hex AES-256 key. |
| `VOLTRO_STORAGE_SECRET` | Signs storage grant tokens (private files); falls back to the session secret if unset. |
| `AI_PROVIDER` / `AI_MODEL` / `AI_API_KEY` | Per-provider AI config. |

### Transport security — the overrides on by default since 0.34.0

The api listener's cross-site check, proxy policy and security headers are **on
by default** and configured in `app.config.ts`'s `security` block. Each has an
env override, resolved identically under `dev` and `serve`, with the explicit
config value always winning:

| Var | Effect |
|---|---|
| `VOLTRO_ORIGIN_GUARD` | `off` disables the cross-site origin check. Anything else leaves the default `same-origin`. |
| `VOLTRO_ALLOWED_ORIGINS` | Comma-separated origins allowed past that check — what a web app on a different origin than the api needs. |
| `VOLTRO_TRUSTED_PROXIES` | Which hops may set `x-forwarded-for` / `x-forwarded-proto`: a comma-separated CIDR/IP list, `private`, `*` (trust the leftmost token), or a hop **count** (`2`). **Unset means the header is ignored entirely** and the socket address wins — set it if you run behind an ingress AND rate-limit or audit per IP. |
| `VOLTRO_SECURITY_HEADERS` | `off` / `default` / `strict` — the whole header bundle's mode. |
| `VOLTRO_CSP` | Content-Security-Policy for non-HTML api responses. `off` drops just this one. |
| `VOLTRO_CSP_HTML` | CSP for HTML responses (under `strict` it defaults to the same policy as `VOLTRO_CSP`). `off` drops just this one. |
| `VOLTRO_HSTS` | `Strict-Transport-Security` value. `off` drops just this one. |
| `VOLTRO_MAX_RPC_BODY_BYTES` | Cap on the buffered `POST /rpc` JSON body (default 8 MiB) — an oversized body is refused `413` and never buffered past the cap. File uploads ride plugin routes with their own limits. |
| `VOLTRO_MAX_BODY_BYTES` | Cap on every OTHER body read — plugin HTTP routes, REST routes, incoming webhooks (default 8 MiB, matching the rpc cap). The config-file spelling is `http.maxBodyBytes` in `app.config.ts`; per-route overrides (`defineRestRoute({ maxBodyBytes })`, a webhook handler's `maxBodyBytes`) win over both. Oversize is `413` for `Content-Length` and chunked alike. |

Response compression for the buffered non-rpc surfaces (and `voltro start`'s
HTML) is configured in the same `http:` block — `http.compression.{enabled,minBytes}`
(default on, 1 KiB threshold; `POST /rpc` is never compressed). Details + the
BREACH reasoning: [Security → compression](/docs/security/overview#response-compression--and-where-breach-sits).

Generate any of the secret vars above with `voltro secret generate <purpose>` (see [`secret`](#command-quick-reference)) — it picks the right length and format. A lower environment's secrets must always differ from production's.

## Workflow patterns

### "I'm starting a new project"

```bash
mkdir acme && cd acme
pnpx voltro create-project acme --api api-backend --web frontend-landing
pnpm install
pnpm dev   # pnpm -r --parallel dev — voltro dev in every app at once
```

### "I want to add a docs site to my existing project"

```bash
voltro add-app docs --template frontend-docs --to acme
pnpm install   # picks up the new app's deps
pnpm dev       # the new app joins the parallel boot automatically
```

### "I changed my schema and want to apply it"

```bash
voltro db plan                 # diff declared schema vs live, color-coded
# review the plan
voltro db apply                # execute it (dev)
# `voltro migrate` is an alias of `db apply` — same differ, shorter name (CI / ops)
```

### "I want to encrypt a column that already has rows in it"

Turning on `.encrypted()` needs two things: the cipher, and a migration of the
rows that are already there. The second has a command — it is easy to miss,
because nothing about a schema change suggests a data pass is owed.

```bash
voltro db scan-credentials              # find plaintext secrets, column by column
voltro db encrypt-column users.apiToken --dry-run
voltro db encrypt-column users.apiToken --yes
```

`--key-env` names the key variable if it is not the default; the key must be the
SAME one the app runs with, or the rows come back undecryptable. `voltro db`
with no subcommand prints the full list.

### "I want a clean rebuild"

```bash
voltro codegen                 # rewrite the generated rpc group + .framework/*
voltro build                   # vite build + SSG pre-render
voltro start                   # production server
```

### "Scaffold a new primitive the right way"

`voltro new <kind> <name>` writes the correct file convention(s) so you don't
learn the descriptor/executor split or the browser-safe boundary from a boot-time
error. It refuses to overwrite an existing file unless you pass `--force`.

```bash
voltro new query notes.list          # notes.list.query.ts + notes.list.query.server.ts
voltro new mutation notes.create     # descriptor + .server executor pair
voltro new action notes.touch        # descriptor + .server executor pair
voltro new workflow orders.fulfill   # .workflow.tsx descriptor + .workflow.server.tsx executor
voltro new page about                # src-pages page.tsx under the name path
voltro new query billing.summary --dir queries   # write into a subdirectory
```

The descriptor half imports only `@voltro/protocol` (or `@voltro/workflow/define`)
+ `effect` — browser-safe by construction; the server graph lives in the paired
`.server` file. Fill in the `TODO`s, then `voltro dev` discovers it.

### "Type-check before I commit"

```bash
voltro typecheck                     # tsc --noEmit against ./tsconfig.json
voltro typecheck apps/api            # a specific app
voltro typecheck --project tsconfig.build.json
```

It runs the **app's own** TypeScript (a `tsc --noEmit`), so "green" means tests
AND types. Any flag it doesn't own passes straight through to `tsc`.

### "What versions am I actually running?"

```bash
voltro info                          # CLI, node, package manager, dialect + @voltro/* versions
voltro info --json                   # machine-readable; exits 1 on version skew
```

`@voltro/*` ship in lockstep, so a mismatch (e.g. `@voltro/database` a minor
behind `@voltro/runtime`) means an untested graph. `voltro info` flags it and
`voltro update` realigns everything.

### "Something's wrong — inspect what's running"

```bash
voltro inspect rpc             # discovered procedures + workflows
voltro inspect routes          # web page tree (web apps)
voltro inspect metrics         # request rates, p95 latencies
voltro logs --tail 100         # recent structured logs
voltro traces --errors         # traces with an errored span
voltro workflows list          # recent workflow runs
```

### "Did my edit break a binding?"

`voltro check` runs blast-radius checks over your app's typed graph — dangling
`source` / `target` tables, scopes no role grants, unguarded mutations, broken
route bindings, orphan tables — at edit time instead of at runtime. `--json`
emits LLM-shaped diagnostics so a coding agent can fix-and-repeat; `--diff`
previews the blast radius of a removal BEFORE you apply it.

It prefers a running api (its manifest is ground truth, including live table
introspection). **With none reachable it assembles the same graph from source**,
so it works as a pre-commit hook or a CI gate without a second terminal —
`--offline` forces that path.

```bash
voltro check                          # running api if there is one, else from source
voltro check --offline                # never contact a server — the CI form
voltro check --url https://api.example.com   # a DEPLOYED app
voltro check --json                   # { ok, diagnostics: [{ rule, node, breaks, fix }] }
voltro check --diff removeTable:todos # what a proposed removal would break, before applying
```

`rbac/unknown-scope` needs a declared scope vocabulary to compare against —
`rbacPlugin({ roles })` publishes one automatically. It catches a guard
requiring a scope no role grants, which makes that procedure permanently and
silently uncallable. (With a custom `resolvePermissions` the vocabulary isn't
exhaustive, so the rule stays quiet rather than flagging correct code.)

`rbac/unenforced-scope` is the same registry read in the other direction: a
scope a role GRANTS that no handler ever guards on. That direction has no
artefact to inspect — you cannot grep for an authorization check that was never
written, which is exactly why it survives review. One app modelled
`api-keys:write` in its role catalogue, complete and reviewed, and no handler
checked it: any member could mint a shared credential, and nothing failed.
Tests pass when an authorization check is missing.

It is a **warning**, because three innocent explanations exist: a plugin route
enforces it internally (the graph cannot see inside a plugin), a REST route
carries its own guards, or it is a UI-affordance scope that `useCan` reads to
hide a button and no server check backs on purpose. All three are fine. Not
knowing which is not.

`rbac/unguarded-mutation` flags a mutation that declares neither a guard nor an
`openAccess:` reason. It skips **`internal: true`** procedures: those are in no
rpc group and on no route, so "any caller who can reach the rpc surface" names a
surface that does not exist — and neither remedy applies either, since a guard
would protect nothing and `openAccess` is refused outright on an internal
descriptor. This is the same `isWireReachable` predicate the boot access gate and
all three rpc-group assemblies use, so `check` and `voltro doctor` cannot answer
the question differently.

A manifest that does not carry the field at all — an api older than it — is read
as *reachable*, not as internal. The rule stays loud rather than going quiet on
the apps least able to notice.

#### Declared vs OBSERVED — reconciled against reality

A query's `source` and a mutation's `targets` are not documentation: the
framework routes optimistic patches and decides which subscriptions a write
invalidates from them. A wrong declaration is a live, user-visible bug that
nothing type-checks — the mutation succeeds, the write lands, and the wrong list
fails to update.

`voltro dev` records what each procedure ACTUALLY touched, into
`app.graph.observed.generated.json` (gitignored automatically). When that file is present,
`check` diffs it against the declarations:

```
observed: 12/34 procedures exercised (35%)
  1 declared/observed mismatch among the 12 that ran
  mutation(orders.place) (api/orders/place.mutation.ts)
    writes 'inventory' (update) but declares no target for it
    subscriptions on that table are not invalidated by this mutation
    fix: add { table: 'inventory', op: 'update' } to this procedure's targets
  20 never ran — no observation exists, so nothing is claimed about them
  2 ran with no table access recorded — indistinguishable from touching nothing, so nothing is claimed about them either
```

Four things about that output are deliberate:

- **Coverage comes first.** Three findings at 8% coverage and three at 95% are
  different claims. Hiding the denominator is how a check starts overstating
  what it knows.
- **Every result line carries the count it is a result about.** `1 mismatch
  among the 12 that ran` — never a bare verdict. A sentence that still reads as
  a conclusion once it is cut out of this block will eventually be cut out of
  it, and quoted as a clean bill of health for a surface nobody measured.
- **The counts partition the declared set — they add up to the total, always.**
  There are two ways to say nothing about a procedure, and both get a line:
  it never ran, or it ran and no table access was recorded for it. A procedure
  that appeared in no line at all would be indistinguishable from a defect in
  `check` itself, so the two kinds of blindness are named separately and never
  folded into the findings.
- **Observed diagnostics never fail the build.** An observation is evidence
  about the runs that happened, not a proof about the ones that didn't, and
  `check`'s exit code gates CI. They are always warnings.

A fifth thing is not visible in that output and matters more than any of the
four: **the recorder only knows what ran.** A boot is not a run. An idle dev
instance that started, served nothing and stopped produces

```
observed: 0/34 procedures exercised (0%)
  nothing was compared — a declaration is only checked against a procedure that RAN
  34 never ran — no observation exists, so nothing is claimed about them
```

Note what that is NOT: it is not "no mismatches found". At zero coverage there
is no result to report, so the section reports the absence of the comparison
instead. The honest answer is also a useless one — it says nothing about any of
the surface. If you want this as a CI gate, the recording pass has
to be a run that actually *calls* the procedures. `voltro e2e` is one: it spawns
`voltro dev` for the api, which turns recording on, and then drives the specs. A
harness of your own that boots the app in its own process needs
`VOLTRO_OBSERVE_GRAPH=1` set before anything is imported. Either way, booting the
app and then running `check` measures nothing, and the report will not pretend
otherwise.

It is still worth wiring up at low coverage, because the findings are per
procedure and do not need company. One deployment had exactly one procedure
observed, and that one produced a real defect: a mutation upserting
`push_subscriptions` with no declared target while two queries read that table as
their `source`, so a registered device never appeared in any running subscription.

No file → the section is skipped silently. This is not derived by parsing your
handlers: a static pass over code that reaches the store through shared helpers,
behind conditionals, has a long tail of both false positives and false negatives
— and a check that is *sometimes* wrong is one people stop reading.

#### Declared vs LIVE — against a running server

When `check` runs against a live api (a local `voltro dev`, or a deployed one
via `--url`), it also diffs the tags your source declares — plugin routes
included — against the server's actual rpc registry:

```
declared vs live: 2 of 214 source-declared tags are NOT registered on the server
  ⚠ presence.heartbeat — a generated client calls this and gets "Unknown request tag"
  ⚠ presence.list — a generated client calls this and gets "Unknown request tag"
  fix: if the server runs older code, redeploy; if it is current, plugin route registration was dropped — check the boot line "plugin routes registered"
```

Only the source→live direction is a finding — a tag the server carries that your
source does not declare is normal runtime synthesis (agents, undo, approvals).
This exists because exactly that gap has shipped once: every
plugin-contributed procedure dead under `voltro serve`, with nothing anywhere
saying so — the only evidence was a `Defect` frame in the browser console of
whoever happened to look. (That registration defect is fixed; the diff is the
runtime backstop for the next one, e.g. a stale deploy.) The server now also
logs every `Defect` frame it sends (`rpc defect sent to client`), so an unknown
tag is an operator-visible event rather than a client-only one.

The HTTP surface is reachable directly too — e.g. `curl -s localhost:4000/_voltro/inspect/rpc | jq` (there is no `/_voltro/inspect/queries` endpoint; it's `rpc` for procedures, `routes` for the web page tree, `subscriptions` for active subscribers).

## Where to read next

- [Scaffolding](/docs/cli/scaffolding) — start + grow a project
- [Dev](/docs/cli/dev) — what happens during `voltro dev`
- [Build & start](/docs/cli/build-and-start) — production paths
- [Migrate](/docs/cli/migrate) — schema changes end-to-end
- [Update](/docs/cli/update) — upgrade the framework + run codemods
- [Inspect & test](/docs/cli/inspect) — debugging + harness



---

<!-- source: en/cli/scaffolding.md -->
## Scaffolding

_init, create-project, add-app, list-templates — boot new code with the framework's conventions baked in._

The scaffolder generates new projects + new apps from templates. Each template is a dogfooded reference; what you scaffold is the same shape the Voltro Cloud team uses.

## `voltro init`

```bash
voltro init          # takes no arguments — it initialises the CURRENT directory
```

Turns the directory you are standing in into a **Voltro workspace root**, and
scaffolds no apps at all. That is the whole distinction from `create-project`:
`init` prepares the root, `create-project` fills it. They share one
implementation (`ensureWorkspaceRoot`), so a greenfield `create-project` needs
no separate `init` — it bootstraps the same root when there isn't one.

What it writes, all of it **idempotent and additive**:

| File | Behaviour |
|---|---|
| `pnpm-workspace.yaml` | Written only when the walk up finds no workspace at all. |
| `package.json` (root) | Created when missing — `private`, `type: 'module'`, node/pnpm engines, the detected `packageManager`, the four scripts, `typescript` + `@types/node`. When it exists, only the **missing** keys are filled in; a script or a version range you already declared is never rewritten. |
| `tsconfig.base.json` | Written when missing. Every app + package tsconfig the framework generates extends this exact path, so `tsc` fails before it reads your code without it. |
| `.gitignore` | Created when missing; otherwise only the entries it does not already cover are appended. Includes `.env.local` — where `voltro dev` mints per-project secrets, and which must never be committed. |
| `git init` | Only when nothing at or above the directory is already a git working tree. |

The four root scripts are the workspace fan-outs:

```json
{ "dev": "pnpm -r --parallel dev", "build": "pnpm -r build",
  "test": "pnpm -r test", "typecheck": "pnpm -r typecheck" }
```

Two refusals, both deliberate:

- **A positional argument is an error** (`init takes no arguments — it
  initialises the current directory`), with a hint pointing at
  `voltro create-project <name>`. `voltro init acme` reads like "make me a
  project called acme", and it is not that command.
- **It will not nest a second root inside an existing pnpm workspace.** If an
  ancestor already has a `pnpm-workspace.yaml`, it names that root and tells you
  to run `create-project` from there instead.

A second run on an already-initialised root prints `is already a Voltro
workspace root — nothing to do.`

## `create-project`

```bash
voltro create-project <name> [flags]
```

Bootstraps a new project under `apps/<name>/` with selected templates.

| Flag | Default | Notes |
|---|---|---|
| `--api <templateId>` | prompts, then `api-backend` | API template. Use `none` for web-only projects. Also accepts `--api=<id>`. |
| `--web <templateId>` | prompts, then `frontend-blank` | Web template. Use `none` for api-only. Also accepts `--web=<id>`. |
| `--cache=redis` | off | Wire the Redis cache backend at scaffold time — sets `cache: 'redis'` in `app.config.ts` and injects the `redis` service + `CACHE_*` env into the active baseline's infra. |
| `--baseline=<bare\|compose\|helm>` | prompts (or skip) | Deploy baseline to scaffold (`bare` / `compose` / `helm`). Without it, the interactive prompt lists the available ids. |
| `--port-range <start>-<end>` | `5190-5199` | Port range for web apps in this project. Persisted in `project.json`. Also accepts `:` / `..` separators. |
| `--no-input` | false | Skip prompts; suitable for CI / scripted scaffolding. |
| `--no-register` | false | Do not contact the Voltro Cloud control plane. See [registration](#project-registration) below. |

What it does:

1. Validates the name (camelCase or kebab-case, no `_`, no leading digits).
2. Picks the next free port from `--port-range`.
3. Renders templates into `apps/<name>/api/` + `apps/<name>/web/`, substituting `{{appName}}`, `{{projectName}}`, `{{port}}` placeholders.
4. Writes `apps/<name>/project.json` recording the project's port range + app list.
5. Updates `pnpm-workspace.yaml` to include the new project's apps.
6. Seeds the **agent guide** for each app (see below).

After scaffolding:

```bash
cd <repo-root>
pnpm install
pnpm dev
```

### Project registration

`create-project` and `add-app` finish by registering the project with the Voltro Cloud control plane. This is how **self-hosted** use is counted, and the Terms of Service ask for projects and apps to be registered. It is worth knowing exactly when it happens and what it involves, because it is part of your first command.

**If you are not logged in, nothing is sent.** No network call is attempted at all, and the scaffolder says so:

```text
→ cloud registration: skipped — not logged in, so nothing was sent from this machine.
  Registration is how SELF-HOSTED use is counted; the Voltro Cloud Terms of Service ask
  for projects and apps to be registered once you have an account.
  Register later:   voltro cloud login   then   voltro cloud scan
  Never ask again:  scaffold with --no-register
```

**If you are logged in**, it prints the destination and the contents before the call:

```text
→ registering project 'acme' with https://cloud.voltro.dev (self-hosted usage tracking, ToS-governed)
  Sends: the project slug, and per app its name, kind, framework version and the NAMES of
         declared primitives (queries, mutations, tables, plugins, …) plus a page count.
  Never sends: source code, row data, environment values or secrets.
  Skip with --no-register.
```

Pass `--no-register` to skip it entirely — appropriate for offline work, CI, or while evaluating. You can register later with `voltro cloud login` followed by `voltro cloud scan`.

This is the only network call the CLI makes on its own behalf; `voltro telemetry` reports the rest of the picture (the framework collects nothing).

### The seeded agent guide

`create-project` / `add-app` (and `voltro dev` on first boot) seed a guide that
teaches AI coding agents the framework's conventions. It's generated, not a
monolith:

- **Root `AGENTS.md` + `CLAUDE.md`** — a slim always-loaded core (mental model,
  the primitive rubric, file conventions, the browser/server boundary, naming,
  anti-patterns) plus an **index** listing the workspace's installed plugins and
  linking the deep, on-demand topic docs.
- **Nested `AGENTS.md` + `CLAUDE.md` per app area** (`api/`, `api/database/`,
  `web/`) — type-specific notes an agent loads only when working there; a
  scaffolded app's file points at its template's doc.
- **`.claude/skills/*`** — Claude Code skills for the common how-tos.

Existing files are never overwritten; `voltro agents-md --force` refreshes them.
Protect a hand-maintained file from `--force` with a `voltro:agents-md:keep`
HTML comment at the top.

## `add-app`

```bash
voltro add-app <appName> --template <templateId> [--to <projectName>]
```

Adds another app to an existing project. Works for any kind — `api`, `web`, or
`serverless`. A `web` app gets the next free port from the project's range; a
`serverless` app has no server, so it gets no port (run it with `voltro
serverless`).

| Flag | Default | Notes |
|---|---|---|
| `--template <templateId>` | *(required)* | Template to scaffold. `voltro list-templates` for the catalogue. |
| `--to <projectName>` | auto-detected | Target project. Required when >1 project exists. |

What it does:

1. Reads the target project's `project.json` for the port range.
2. Picks the next free port from the range (rejects if the range is exhausted).
3. Renders the template into `apps/<project>/<appName>/`.
4. Updates `project.json` to record the new app.

Example:

```bash
voltro add-app docs --template frontend-docs --to acme
voltro add-app admin --template frontend-blank --to acme
```

## `list-templates`

```bash
voltro list-templates
```

Prints the catalogue. Templates come in three **kinds** — `api`, `web`, and
`serverless` (a bundle of [`*.serverless.ts`](/docs/deployment/serverless-functions)
functions deployed on their own):

```
id                    kind        summary
--------------------  ----------  --------------------------------------------
api-backend           api         Minimal Voltro backend (schema + query + mutation).
api-backend-mail      api         + @voltro/plugin-mail + a React-Email template.
api-backend-storage   api         + @voltro/plugin-storage (public + private objects).
api-backend-mariadb   api         MariaDB binlog CDC + storage, tenant-aware.
api-durable           api         Durable + reactive: workflow, trigger, cron, subscriber, aggregate.
api-ai                api         RAG agent: vectorEmbedding + search tool + model loop.
api-data-advanced     api         Advanced schema: relations, FTS, dbEnum, encrypted, caching.
api-auth              api         Real user auth: plugin-auth sessions + cookie strategy.
api-rest              api         Public REST API: defineRestRoute + plugin-openapi (Swagger).
api-saas              api         SaaS bundle: billing + notifications + analytics + presence.
api-observability     api         Metrics + errors + tracing + a @voltro/testing unit test.
api-webhooks          api         First-class webhooks: signature-verified incoming + outgoing emit.
frontend-blank        web         Empty React + layout shell.
frontend-app          web         Fullstack reactive loop — wired to an api (useSubscription + useMutation).
frontend-landing      web         Static marketing page — zero JS on the wire.
frontend-static-blog  web         SSG blog: getStaticPaths + islands + per-post meta.
frontend-spa          web         Pure client-rendered SPA (no backend, no SSR).
frontend-ssr          web         Server-rendered pages: ssr + isr (revalidate, swr).
frontend-contact      web         Static page + a serverless email form.
frontend-docs         web         Catch-all docs site with i18n.
changelog             web         Release-notes site (MDX + RSS).
edge-functions        serverless  A library of *.serverless.ts functions (8 types).
```

See the [App templates catalogue](/docs/reference/templates) for what each
demonstrates + a picks-for table. For programmatic use, add `--json`:

```bash
voltro list-templates --json
```

## `generate` (AI app-builder)

`voltro generate "<prompt>"` turns a natural-language prompt into framework
artifacts — queries, mutations, tables — **constrained to what your app can
actually express**. It reads the committed capability manifest
(`app.manifest.generated.json`, emitted by `voltro dev`) as the grammar, asks the
model for an app graph + the files that realise it, and gates every candidate
through the real `voltro check` before anything is written. A structurally-invalid
proposal is re-prompted with its diagnostics (the errors-as-LLM-API loop), never
written.

```bash
voltro generate "add a comments table with a list + create"          # dry-run: prints the proposal
voltro generate "add a comments table with a list + create" --write  # applies the accepted artifacts
```

**Dry-run by default** — artifacts hit disk only with `--write`, and only ever a
proposal that passed `voltro check`. Generation is also capped (file count + total
bytes). Run `voltro dev` once first so the manifest exists.

> Requires a model provider (`AI_PROVIDER` / `AI_MODEL` + the provider key, same
> as [agents](/docs/ai/agents)). The cloud dashboard exposes the same builder for
> proposal review (`apps.generateAppGraph`), behind the `aiBuilder` flag (off by
> default → typed `FlagDisabled`).

## Template tokens

Templates contain `{{token}}` placeholders that the scaffolder substitutes:

| Token | Example value |
|---|---|
| `{{appName}}` | `dashboard` |
| `{{capAppName}}` | `Dashboard` |
| `{{projectName}}` | `acme` |
| `{{capProjectName}}` | `Acme` |
| `{{port}}` | `5191` |

Apply across both file content AND file/directory names — a template file named `{{appName}}.entity.ts` or a dir `pages/{{appNameSnake}}/` renders to your project's names. When writing your own template, use these freely; the substitution is global.

## Writing a custom template

Drop a directory into `voltro-templates/apps/<your-id>/`:

```
voltro-templates/apps/my-template/
├── template.json
├── package.json
├── app.config.ts
├── src/
│   ├── pages/
│   │   └── index.tsx
│   └── …
└── tsconfig.json
```

`template.json` declares the metadata:

```json
{
  "id":      "my-template",
  "kind":    "web",
  "summary": "My custom template — does X.",
  "tags":    ["marketing", "minimal"]
}
```

`kind` is one of `api` / `web` / `serverless`. A `serverless` template ships a
`functions/` dir of `*.serverless.ts` files (no `app.config.ts`, no pages) and
is added with `voltro add-app`. Now `voltro list-templates` shows it; `voltro
create-project --web my-template` (or `--api`) uses it, and any kind is added
with `voltro add-app <name> --template my-template`.

For private templates (in your own repo), set `VOLTRO_TEMPLATES_DIR=/path/to/my/templates` and the CLI resolves templates from there instead of the default location. Point it at the templates **root** — the CLI appends `apps/`, so your templates live at `/path/to/my/templates/apps/<template-id>/`. When the variable is set it is authoritative: the CLI does not fall back to the bundled templates.

## Idempotency

The scaffolder refuses to overwrite an existing directory:

```bash
voltro create-project acme   # fails if apps/acme/ exists
```

To force, delete the directory first. The framework intentionally doesn't have a `--force` flag — accidental data loss is the kind of thing that happens once + ruins your day.

## Anti-patterns

- **Scaffolding into a non-Voltro repo.** The scaffolder writes to `apps/<project>/` + expects a `pnpm-workspace.yaml`. Without one, `pnpm install` doesn't link workspace packages.
- **Renaming the scaffolded app directory afterwards.** `project.json` records the path; rename invalidates discovery. Either re-scaffold with the right name, or update `project.json` + every cross-package import by hand.
- **Editing the templates dir directly to "fix" a scaffolded app.** Templates are starting points. After scaffolding, the app is yours — edit IT, not the template (unless the template itself has a bug).

## See also

- [App templates](/docs/reference/templates) — the catalogue with picks-for table
- [Dev](/docs/cli/dev) — what `voltro dev` does with the scaffolded app



---

<!-- source: en/cli/dev.md -->
## Dev

_voltro dev, codegen, agents-md — what runs during local development and the env flags that shape it._

`voltro dev` is the day-to-day command. It runs different machinery for api vs web apps but the contract is the same: edit a file, the right thing happens.

### Running `voltro dev` in a container

If your dev pod runs as root with the host workspace bind-mounted, everything
the framework generates would otherwise land `root:root` inside your own tree —
and on the host `voltro build` then fails on its own output:

```
EACCES: permission denied, open '…/apps/display/.framework/index.html'
```

`voltro dev` and `voltro build` hand their generated output (`.framework`,
`.env.local`, every `*.generated.*`) to whoever owns the app root, and warn
loudly when they cannot. Only generated state — the framework never takes
ownership of a file you wrote.

The cleaner fix is on your side and worth doing anyway: start the container as
the workspace owner, `docker run --user $(id -u):$(id -g)`. Then nothing needs
handing over at all.

### What the boot tells you

Three checks run at boot and print one line each when they have something to say
— never fatal, and silent when the answer is fine:

- **A reactive table with no change trigger** (postgres). The schema fingerprint
  covers columns, not triggers, so a restored dump or a hand-run `DROP TRIGGER`
  leaves the schema "up to date" and the subscription silently not reaching other
  instances. The reverse is reported too: a `.nonReactive()` table still carrying
  a trigger pays `REPLICA IDENTITY FULL` and a NOTIFY per write for nothing.
- **A `.nonReactive()` table that a query reads.** That subscription will never
  fire — first snapshot, then silence forever.
- **`apiKeys: true` with no `apikeys:issue:*` scope declared.** The capability is
  on and reachable by nobody; every issue request fails its guard.

`voltro doctor` adds a fourth, over your source: its **authz scan** asks of every
executor — queries included — whether it references an access check at all, and
lists the ones that reference none. It learns your own `require*` / `assert*`
guard names, so it does not report the call sites of guards you already wrote.
See [the authz scan](./build-and-start.md).

### When the database is not reachable

A refused, unresolvable or rejected database connection is reported as a condition with a fix, not as a framework crash:

```text
voltro: the database is not reachable at 127.0.0.1:5432 (ECONNREFUSED).

  No database is configured — none of DB_URL / DB_HOST / PG_HOST is set in the
  environment or in a loaded `.env`, so the framework used its local dev default.

  Either start one:      pnpm db:up          (if your project ships a compose file)
  or point at your own:  DB_URL=postgres://user:pass@host:5432/dbname
                         — put it in `.env` next to app.config.ts, not just in one shell.

  Re-run with --debug for the full stack.
```

When a variable **is** set, the message names it and says the address resolved and nothing answered there — which is a different problem from "is postgres running", and points you at the container, VPN or firewall instead. A server that answers and rejects you (wrong password, missing database) is reported as its own case, because the fix is different again.

Pass `--debug` (or set `VOLTRO_DEBUG=1`) to get the full Effect stack instead.

## `voltro dev <appDir>`

```bash
voltro dev .                        # current dir
voltro dev apps/acme/api            # explicit path
```

### What it does for an api app

1. Reads `app.config.ts`. Bails if not `type: 'api'`.
2. Walks `queries/`, `mutations/`, `workflows/`, etc. for the discovery patterns.
3. Generates `.framework/rpcGroup.generated.ts` exporting a typed client.
4. Starts the RPC over WebSocket server on `:4000` (override with `PORT`, or set `port:` in `app.config.ts`).
5. Watches every discovery-matching file. On save:
   - File added/removed → regen the discovery → restart the api process.
   - File modified → reload the module → fire `hmr update` to connected clients.
6. Resolves `STORE=memory` (in-memory store) or `STORE=postgres` (real DB).
7. Auto-launches the inspect dashboard on `:5179` (unless `VOLTRO_DASHBOARD=off`).

### What it does for a web app

1. Reads `app.config.ts`. Bails if not `type: 'web'`.
2. Walks `src/pages/` for queries + special files.
3. Generates `.framework/main.tsx`, `.framework/app.tsx`, `.framework/routeTable.ts`, `index.html`.
4. Starts Vite with the framework's plugin chain (React, Tailwind v4, page discovery, inspect, dashboard registry).
5. Binds to the configured port from `app.config.ts.port` (strict — fails on conflict).
6. Watches `src/`. On save, Vite HMR fires:
   - A page / layout **component** change → React Fast Refresh patches the live
     component in place; client state survives (see below).
   - A page-local **value export** (`const COLUMNS = [...]`) change → also a hot
     update, even in the same save as the JSX (see below).
   - A **server-read export** — `loader`, `renderMode`, `meta`, … — change → a
     full page reload, on purpose (see below).
   - CSS changes → swap stylesheets in place.
   - New page file → regen the entry files → reload the route tree.

`renderMode: 'ssr'` pages compile on demand the first time each route is hit. To
keep a burst of cold pages — several browser tabs, or a health-check sweep across
many routes — from spiking memory, `voltro dev` compiles at most **4** of them at
once and collapses duplicate concurrent requests for the same route into a single
compile. Already-compiled (warm) pages are never throttled, so a hot app stays
fully concurrent. Tune the cap with `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY` (below) —
drop it on a low-memory box, raise it on a big machine.

### A failed server render fails the request

If the server render throws, `voltro dev` answers **500** with the error and its
stack, marks the response `x-voltro-rendered-by: ssr-dev-failed`, and logs it at
`error`. It does **not** fall back to a client-rendered shell.

That is deliberate, and it is the same outcome `voltro start` produces in
production. A fallback would hand you a page that renders in the browser and a
500 in production from the identical code — and because an empty `<div
id="root">` is what a client-only app looks like, the usual conclusion is "the
framework does not server-render", not "my page threw". The failure is loud so
the cause is the thing you see.

The practical consequence: anything that only misbehaves under
`renderToPipeableStream` — a component that suspends with no `<Suspense>`
boundary above it, a loader that throws, a hydration-unsafe value — surfaces in
`voltro dev` at the moment you hit the route.

**Suspending is fine here, and does not need a boundary you add.** A component
that suspends during a streamed server render — a lazily-loaded translation
catalog, a `React.lazy` component, `react-i18next` with `useSuspense: true` —
renders normally: `renderToPipeableStream` treats the root as an implicit
boundary, so a suspend delays the shell flush instead of failing. Measured, not
assumed; a regression test pins it.

Do **not** add a blanket `<Suspense>` at the root to "fix" a suspend. It makes
things worse in a way that is hard to see: React downgrades an errored boundary
to client rendering, so a page that THROWS starts answering 200 with
`<template data-msg="Switched to client rendering">` instead of failing. You lose
the hard failure above and gain nothing — the suspend already worked. Mount
boundaries where you want a *fallback* (`loading.tsx` per route, `<Await>` for
deferred loader values), not to make suspending legal.

Two places where a suspend genuinely is not supported, both by React rather than
by choice: `renderToString`, which backs the static prerender
(`renderMode: 'static'`), emits the fallback instead of waiting — so a suspending
component in a prerendered page needs its own boundary or a resolved value; and
the client render after hydration, which follows React's own rules.


Under `VOLTRO_LOG_LEVEL=debug` each cold compile logs its own duration, so a slow
first paint can be attributed to a specific module:

```
[voltro:dev:web] ssr cold-compile start id=/app/src/pages/layout.tsx
[voltro:dev:web] ssr cold-compile start id=/app/src/pages/(main)/layout.tsx
[voltro:dev:web] ssr cold-compile end 3743ms id=/app/src/pages/layout.tsx
```

Read the `ms` on the **end** line rather than subtracting timestamps: compiles run
concurrently up to the cap, so the start and end lines interleave and adjacent
lines usually belong to different modules. The duration is measured inside the
concurrency permit, so it is that module's own compile cost and not time spent
queued behind the cap. A compile that threw says `FAILED` instead of `end`.

### Fast Refresh: what hot-updates and what reloads

Editing a **page or layout component** applies as a hot update — the React tree
stays mounted, so form input, scroll position, open dialogs and every `useState`
survive. So does editing a **page-local value** the page happens to export — a
`const COLUMNS = [...]` you change together with the table that renders it. The
module re-evaluates, the component renders the new value, and your client state
is untouched.

A full page reload happens for exactly one class of edit: an export the
**server** already read to produce the page in front of you.

| Export | What the server does with it |
| --- | --- |
| `loader` | runs it (SSR / prerender), and the router caches the result per route + params |
| `renderMode`, `dynamic` | picks the render strategy for the route |
| `meta` | renders it into `<head>` |
| `getStaticPaths` | enumerates which paths get prerendered |
| `revalidate`, `staleWhileRevalidate` | sets the ISR cache window |
| `cacheInvalidatesOn` | wires the page into the ISR cache invalidator |
| `interactive` | decides how much client JS is shipped |
| `tenantAware` | forms part of the server-side cache key |

That reload is deliberate, not a gap. The HTML you are looking at was produced
from the OLD value, so hot-swapping the export would leave stale output on
screen with nothing to signal it. A reload re-runs SSR with the new value, and
the console line names the export and the server step that consumed it.

The mechanism, in case you hit an edge: React Fast Refresh only accepts a module
whose exports are all components, and a page exporting `loader` beside its
component fails that test. `voltro dev` registers each route module's
non-component exports with the React plugin's ignore hook (so Fast Refresh
judges only the components), then makes the reload call itself by comparing the
server-read exports' VALUES across the update — a function by its source text,
anything else by its JSON form — so a JSX-only edit, which recreates the
`loader` function object, is correctly read as "unchanged".

One residual caveat: **adding or removing** a non-component export still
reloads once, whatever it is. Fast Refresh sees an export that was not on the
ignore list yet and refuses the boundary; the next edit to that page hot-updates
normally.

### The in-page devtools overlay

The generated web entry auto-mounts the `@voltro/devtools` overlay — a floating button that expands into live panels (subscriptions, mutations, indexes, webhooks, traces, routes, runtimes, logs; **Alt+V** toggles it). The component AND its stylesheet load dynamically under `import.meta.env.DEV` only; production builds strip the import entirely, so it needs zero code and ships zero bytes to prod. Opt out per app in `app.config.ts`:

```ts
// app.config.ts (web app)
export default {
  type: 'web' as const,
  name: 'web',
  disableDevtools: true,   // no overlay import, no mount, no stylesheet
}
```

#### Inspect token

The overlay's webhooks / traces / indexes panels poll each api's `/_voltro/inspect/*` endpoints, and that surface is **fail-closed everywhere**: with no `VOLTRO_INSPECT_TOKEN` configured, nobody is authorised — `voltro dev` included.

**Under `voltro dev` you configure nothing.** The dev server mints a token per project and its proxy attaches the `Authorization: Bearer` header server-side, on the `/_voltro/api/<name>` route the panels fetch through. The token stays in the dev server's process; the browser never holds it.

That is deliberate rather than convenient. A token compiled into the client bundle is a live credential published to everyone who loads the page, so there is no env-var channel for it — `voltro dev` and `voltro build` set vite's `envPrefix` to a sentinel precisely so nothing leaks through `import.meta.env`.

For an api the dev proxy does not front — a `voltro start` deploy with `VOLTRO_INSPECT_TOKEN` set, say — pass the token explicitly, and note that whatever you pass ships in the bundle:

```tsx
import { VoltroDevtools } from '@voltro/devtools'

<VoltroDevtools inspectToken={myToken} />
```

Without the prop the overlay sends no `Authorization` header of its own, which is correct: under `voltro dev` the proxy has already added one. (The indexes panel's live SSE stream can't carry a header at all; against an api reached without the proxy it falls back to token-carrying HTTP polling.)

#### Overriding the overlay's labels

Every user-facing label the overlay renders (tab labels, empty states, the FAB tooltip, …) routes through an overridable strings seam — the same pattern as `@voltro/ui`'s `UiStringsProvider`. Localize or rebrand by passing `strings` (deep-merged onto the English defaults — supply only what you change):

```tsx
import { VoltroDevtools } from '@voltro/devtools'

<VoltroDevtools
  strings={{
    tabs: { subscriptions: 'Abos', logs: 'Protokolle' },
    shell: { openLabel: 'Voltro Devtools öffnen' },
  }}
/>
```

A `<DevtoolsStringsProvider strings={…}>` mounted above the overlay works too; nested providers compose.

### Common flags + env vars

| Var / flag | Notes |
|---|---|
| `STORE=memory` | In-memory data store (default). Restart = state gone. |
| `STORE=postgres` | Real Postgres via `DB_URL` (or the discrete `DB_*` / `PG_*` fields). Survives restarts. `DB_DIALECT` picks the SQL backend. |
| `WATCH=0` | Disable filesystem watch. Useful under a parent watcher (Docker volume, devcontainer). |
| `VOLTRO_DASHBOARD=off` | Don't auto-launch the dashboard. |
| `VOLTRO_INSPECT=off` | Don't expose `/_voltro/inspect/*` endpoints. |
| `PORT=4001` | Override the listen port (api or web). See [Which port an app binds](#which-port-an-app-binds) for the full order. |
| `VOLTRO_DASHBOARD_PORT=5180` | Override the auto-launched dashboard port (default `5179`). |
| `VOLTRO_LOG_LEVEL=debug` | Verbose framework logs. |
| `VOLTRO_DEV_SSR_COMPILE_CONCURRENCY=4` | Max `ssr` pages compiled on demand at once (default `4`). Lower it (`1`/`2`) on a low-memory box if a burst of first-time `ssr` page loads spikes memory; raise it on a big machine. Warm (already-compiled) pages are never throttled. |

## Multi-app dev

The `dev` script the scaffolder writes at the workspace root is plain pnpm — no task runner to install:

```bash
pnpm dev   # ↳ pnpm -r --parallel dev — `voltro dev` in every app at once
```

`pnpm -r` selects by "has a `dev` script", so an app that owns its own dev loop (an Expo `mobile-app`, an `edge-functions` bundle) opts out simply by not defining one. To run a single app: `pnpm --filter @acme/api dev`.

If you prefer a task runner for its caching and per-app output panes, adding one is a normal workspace change — nothing in the framework depends on it.

## `voltro codegen <appDir>`

```bash
voltro codegen apps/acme/api   # regenerate the codegen for one app
voltro codegen .               # current dir
```

Regenerates `rpcGroup.generated.ts` (+ the web `.framework/*` entry) from the discovered descriptors. It takes only an optional app-directory path — no flags. You rarely need this; `voltro dev` does it on every save. Useful for:

- **CI environments** where you want the typed client baked into a tarball before tests run.
- **Editor LSP confused** after a discovery pattern changed and the generated file went out of sync.

### Staleness is detected, not assumed

The generated file carries a `source-fingerprint` of the descriptor tree, so the other commands can tell whether it still matches your code:

- **`voltro build`** regenerates it when it is stale. A CI build from a clean checkout never ran `voltro dev`, and it is a file the build can produce itself.
- **`voltro test`** REFUSES and tells you to run `voltro codegen`:

  ```text
  voltro test: …/rpcGroup.generated.ts is stale — a descriptor changed since the group was generated.
    The tests would run against the previously generated procedure group, pass, and prove nothing
    about the descriptors you just edited.
    Run `voltro codegen` (or boot `voltro dev` once) and try again.
  ```

  It refuses rather than regenerating because regenerating means importing your app's modules and config as a side effect of asking to run tests, and silently rewriting a checked-in source file is worse than stopping.

The check reads bytes only — no app module is imported — so it costs milliseconds. A generated file written by an older framework version carries no stamp and reads as stale; run `voltro codegen` once.

## `voltro agents-md`

```bash
voltro agents-md          # seed AGENTS.md if it doesn't exist
voltro agents-md --force  # overwrite existing file
```

Seeds the framework agent guide into the repo root under **both** filenames — `AGENTS.md` (the universal convention) and `CLAUDE.md` (project-pinned Claude Code setups) — written atomically from one template so they can't drift. The file teaches AI coding agents (Claude Code, Cursor, GitHub Copilot Chat) Voltro's conventions — file suffixes, schema DSL, query shape, layout contract, anti-patterns.

When the framework's template gains new sections (new file convention, plugin shape change), run `voltro agents-md --force` to pull them in — `--force` overwrites BOTH files. The CLI doesn't auto-overwrite on boot, so apps that customised their guide keep their changes until they ask for a refresh.

## File watch internals

For api apps, Voltro applies its own discovery walker on every save. The patterns that trigger a re-discovery (and the matching `.server.ts` executors):

- `**/*.query.ts`, `**/*.mutation.ts`, `**/*.action.ts`, `**/*.stream.ts` (+ their `.server.ts` siblings)
- `**/*.workflow.tsx`, `**/*.trigger.tsx`
- `**/*.cron.tsx`
- `**/*.webhook.tsx`
- `**/*.subscribe.ts`
- `**/*.aggregate.ts`
- `**/*.agent.tsx`
- `**/*.email.tsx`
- `**/*.seed.ts`, `**/*.startup.tsx`
- `**/*.entity.ts`, `**/*.schema.ts`, `schema.ts`
- `app.config.ts`

`*.tool.tsx` files are not discovered on their own — a tool is imported by the agent that uses it, so it's picked up through the agent file. Hidden dirs, `node_modules`, `dist`, and `.framework` are skipped.

Those names are matched as whole **path segments**, so a directory called `distribution/` or a file called `distTools.ts` is watched normally.

For web apps, Vite's built-in HMR handles the watch.

### Workspace packages are watched too

If your api depends on a workspace package (`"@acme/shared": "workspace:*"`), that package's `src/` is watched as well — editing `packages/shared/src/x.ts` restarts the api, exactly as editing a file inside the api would. The dependency set is resolved once at boot from the api's `package.json`, so adding a dependency needs a restart (it needs an install anyway).

Only real workspace packages are watched. A published npm dependency resolves inside `node_modules` and is skipped, so an app outside a monorepo watches nothing extra.

An edit in a dependency is logged with its package directory, not just the filename:

```
file changed — restarting   file=shared/src/x.ts
```

## Restart triggers

API apps restart (full process kill) on a change to **any source file**
under the api project dir — every `.ts` / `.tsx` / `.mts` / `.cts` /
`.js` / `.jsx` / `.mjs` / `.cjs` / `.json`, excluding `*.generated.*`
(the codegen rewrites those every boot, so watching them would
self-respawn forever). Concretely that includes:

- `app.config.ts` / `package.json` change
- A primitive descriptor / executor, or a new/removed primitive file
- **A shared `lib/` / `services/` helper** that a descriptor or executor
  imports — editing one respawns the api, because the whole module graph
  is re-imported on restart (it is NOT enough to watch only the
  convention files)
- A change to any `.env` / `.env.local` file the process loaded at boot

The restart is a full re-exec — there is no in-process hot-reload of a
handler body; editing a query's executor respawns the child (debounced
80ms, so a burst of saves collapses into one restart).

### Reading the restart timing

The completion line is printed when the api **can serve a request** — not when the replacement process was spawned:

```text
file changed — restarting        file=notes.list.query.ts
restart complete — api ready     ms=1840
```

The first boot reports the same measurement as `dev server ready`. If a restart never prints its completion line, the child did not come up — look for the crash above it, which the supervisor logs before it goes back to waiting for the next save.

That number is the whole wait: the process start, the module graph, discovery, codegen, the store connection, the boot schema diff and plugin boot. It is the number to quote if the inner loop feels slow.

### How the old process is stopped

SIGTERM first. The child runs its teardown — plugin `onDeactivate`, the
CDC detach, the scheduler and workflow runtime, the connection pool, and
every `ctx.onShutdown(cb)` a `*.startup.ts` registered — and then exits.
That is normally tens of milliseconds and you never see it.

It gets **1.5 seconds**, then SIGKILL, and the escalation says so:

```text
child ignored SIGTERM — escalated to SIGKILL   pid=41207
```

Read that as "something in this app's shutdown does not complete" — a
pool draining against a database that is already gone, a plugin
`onDeactivate` waiting on a dead socket. The restart still happens; it
just costs the full grace window every time, and whatever teardown had
not finished was cut off. Worth fixing at the source rather than living
with, because the same hang is a slow — then failed — shutdown in
production.

Neither timeout is optional: a stop that can wait forever is a dev
server that stops restarting entirely, with the old process still
holding the port and your browser's websocket still attached to code you
edited minutes ago.

## When the dev server stops

A restart replaces the child; the supervisor keeps watching. When the dev
server exits **on its own** — an aborted boot, or you stopping it — what
happens next depends on whether anyone is there to react.

**In a terminal**, a crashed boot is something you are about to fix, so
the supervisor keeps watching and tells you:

```text
dev server crashed — waiting for a file change   exitCode=1
```

Fix the cause and save. The watcher restarts the server exactly as it
would for any other edit; you don't retype the command.

**Piped, backgrounded or in CI** — anywhere stdout is not a TTY — nobody
is going to fix anything, so `voltro dev` exits with the child's code:

```text
dev server exited — supervisor stopping   exitCode=1
```

That half matters because the supervisor used to keep watching in *both*
cases: a shell that had long since closed still had a `voltro dev` behind
it holding a watcher, and a CI job that had "finished" kept its runner
busy. A failed boot is now a failed command wherever no one is looking.

A **clean** exit always stops the supervisor, watched or not — a dev
server ending on purpose is not something to wait out.

| Env | Effect |
|---|---|
| `VOLTRO_DEV_KEEP_ALIVE=1` | Wait for a fix even without a TTY — a CI runner with a TTY allocated, or a wrapper that pipes output while you watch it. |
| `VOLTRO_DEV_KEEP_ALIVE=0` | Exit on a crash even in a terminal. |

Neither can keep a clean exit alive; that would turn a deliberate
shutdown into a hang.

The `.env` trigger applies to **both api and web** apps — `process.env` is
parsed once at boot, so editing a `.env` (or re-pulling secrets, e.g.
`doppler secrets download > .env`) needs a full re-exec. `voltro dev` watches
the env-file chain (app dir → ancestors) and hard-restarts, logging
`.env changed (<file>) — hard-restarting…`. The api releases its port and the
web closes Vite before the replacement spawns, so the restart can't hit a
port-in-use race.

## Which port an app binds

Every command that starts an app listener — `voltro dev`, `voltro serve`,
`voltro start`, `voltro dormancy` — resolves the port the same way, in this
order:

1. `VOLTRO_DASHBOARD_PORT`, and only in the auto-launched dashboard process.
2. `PORT` from the environment.
3. `--port <n>` (`voltro serve`, `voltro dormancy`).
4. `port:` in the app's `app.config.ts`.
5. `4000` for an api app, `5173` for a web app.

`PORT` deliberately outranks `--port`: every host that assigns a port — a
container platform, a PaaS, a Kubernetes Deployment — assigns it through `PORT`,
and a `--port` baked into an image's start command must not override the port the
host actually routed to.

A value that is not a port in `1..65535` (`PORT=`, `PORT=8080x`) is **ignored**
with a warning naming the variable, and the next source wins. It is not passed to
`listen()`: `Number('8080x')` is `NaN`, node reads that as "any free port", and
the app would come up healthy at an address nobody can guess.

`voltro dev` does not take `--port`. Its file-watching supervisor respawns the
app as `dev <app>` and drops flags, so a `--port` would silently stop applying at
the first file change; set `PORT` for a one-off, or `port:` to keep it.

## Multiple instances on one machine

Run two api apps + two web apps in parallel? Every app reads the same `PORT` env, so prefer setting each app's `port:` in its own `app.config.ts` and `--cwd`-ing into each — that avoids one shared `PORT` clobbering them all. The dashboard auto-launches once on `:5179`; later instances see it's already up and skip it.

```bash
voltro dev apps/acme/api &     # port from apps/acme/api/app.config.ts
voltro dev apps/orbit/api &    # port from apps/orbit/api/app.config.ts
voltro dev apps/acme/web &
voltro dev apps/acme/docs &
voltro dev apps/orbit/web &
```

If you must override per-process from the shell, set `PORT` inline on each one (`PORT=4001 voltro dev apps/acme/api`) — but the config-file port is the cleaner path. `pnpm dev` at the workspace root handles all of this for you.

## Anti-patterns

- **Running `voltro dev` against a `voltro start` build directory.** The dev server expects source files; pointing it at `dist/` confuses it. Use `voltro start` for that.
- **Ignoring `app.config.ts` changes.** They require a process restart (the discovery walker re-reads them at boot only). Save, watch the process die + come back up.
- **Reading "the page reloaded" as "HMR is broken".** A page/layout component edit hot-updates and keeps client state; a `loader` edit reloads *by design* — it also runs server-side, and the rendered page came from the old one. If a pure component edit reloads, look for a non-component export on that route module that changed in the same save.
- **Expecting a `.env` edit to hot-reload.** It can't — `process.env` is read once at boot. `voltro dev` hard-restarts the server on a `.env` change (you'll see `.env changed … — hard-restarting`); wait for the process to come back before testing, rather than assuming the new value is already live.
- **Disabling the dashboard "to save resources".** It's a few MB of RAM + the inspect endpoints fail gracefully. Keep it on; it's the best debugging tool you have.



---

<!-- source: en/cli/build-and-start.md -->
## Build & start

_voltro build and voltro start — production builds, SSG pre-render, the SSR bundle, ISR cache._

`voltro build` produces a production artefact; `voltro start` serves it. Two commands, clean separation.

## `voltro build <appDir>`

```bash
voltro build apps/acme/web
voltro build .                  # current dir
```

What it does for a web app:

1. **vite build** against `.framework/` — produces `dist/` with chunked client bundle.
2. **Pre-renders static pages** — every page with `renderMode: 'static'` is rendered to HTML once + lands at `dist/<path>/index.html`.
3. **Pre-builds the SSR bundle** — every page module compiled to `dist/server/ssrEntry.js` so `voltro start` doesn't need a Vite middleware loader at runtime.
4. **Copies `public/`** into `dist/`.

For api apps, `voltro build` precompiles the whole handler closure — every procedure, workflow, subscriber, reaction, aggregate, agent, webhook, cron, startup, `app.config`, and their shared `database`/`lib` deps — into a single esbuild bundle at `.framework/dist-api/apiEntry.js` (the framework + npm deps stay external). `voltro serve` loads that bundle automatically at boot and resolves every handler from it, so production never transpiles TypeScript at runtime. Without a build, `voltro serve` still loads each source module on demand (via the `tsx` loader) exactly as `voltro dev` does — the build is an optimisation, not a requirement.

`voltro build` takes a single optional app directory and parses no flags — the SSR bundle is always attempted, and the SSG pre-render always runs for `static`-mode pages.

### Output layout

```
apps/acme/web/.framework/dist/
├── index.html                          # SPA shell fallback
├── about/index.html                    # pre-rendered static page
├── blog/first-post/index.html          # SSG via getStaticPaths
├── assets/
│   ├── index-abc.js                    # main client bundle
│   ├── index-abc.css
│   └── island-LikeButton-def.js        # per-island chunks (interactive: 'islands' pages)
└── server/
    └── ssrEntry.js                     # SSR bundle for voltro start
```

## Unresolvable optional peers

The SSR bundle inlines everything it reaches (`ssr: { noExternal: true }`), which is what lets a production web image ship without a framework dependency tree. A package that cannot be **resolved at all** is externalised instead of failing the build — almost always an uninstalled optional native peer reached through a library's Node entry point:

```
Rolldown failed to resolve import "canvas" from ".../konva/lib/index-node.js"
```

`konva`'s `main` is its Node build, which requires the optional `canvas`; its `browser` field points at one that does not. An app that never renders to a canvas server-side has nothing to install, and there is no app-side workaround: making the import dynamic does not help (the bundler must still resolve it to form the chunk), and `renderMode: 'spa'` does not either — the generated router imports every page statically, so the module is in the SSR graph whatever the render mode.

Every specifier externalised this way is named on the success line:

```
[voltro:build] SSR bundle ready { path: 'dist/server/ssrEntry.js', externalizedOptionalPeers: 'canvas' }
```

Read that list. Externalising is right for an optional peer you never use, and wrong for a dependency you forgot to install — it turns a build failure into a runtime one, and only you can tell the two apart. Framework packages (`@voltro/*`, `@effect/*`, `effect`) are never externalised.

## `voltro start <appDir>`

```bash
voltro start apps/acme/web              # serves the build output
PORT=8080 voltro start apps/acme/web
```

What it does:

1. Reads `app.config.ts.port` (or `PORT` env var) for the listen port.
2. Walks `dist/` to discover pre-rendered HTML files.
3. Loads the SSR bundle from `dist/server/ssrEntry.js`. Falls back to Vite middleware mode if absent.
4. Starts an `http.Server` that:
   - Serves pre-rendered HTML for matched URLs.
   - Serves static assets from `dist/assets/`, `dist/_voltro/`.
   - Renders SSR pages per request via the SSR bundle.
   - Reads / writes ISR cache for `renderMode: 'isr'` pages.

### Flags + env vars

| Flag / env | Notes |
|---|---|
| `PORT=8080` | Override the listen port. |
| `SSR_CACHE=postgres` | Use the Postgres-backed ISR cache. Requires the web process to also have a database in its environment — `DB_URL` (what the templates set), `DB_PRIMARY_URL`, `DB_HOST` or `PG_HOST`. Without one, `voltro start` **aborts** on `NODE_ENV=production`/`staging` and warns loudly elsewhere; it no longer falls back to the in-memory cache in silence. Default is in-memory. |
| `VOLTRO_INSPECT=off` | Disable the inspect HTTP endpoints in production. |
| `VOLTRO_INSPECT_TOKEN=…` | Bearer token guard on the inspect endpoints. |

### Per-request routing logic

For a request to `/foo`:

```text
1. dist/foo/index.html exists? → serve it.
2. URL matches a static asset? → serve from disk.
3. URL matches a registered query?
     - renderMode 'ssr' → render fresh via SSR bundle.
     - renderMode 'isr' →
         - cache HIT (fresh) → serve cached.
         - cache HIT (stale) + staleWhileRevalidate → serve cached + bg refresh.
         - cache MISS → render, store, serve.
     - renderMode 'static' (no pre-render found) → serve SPA shell.
4. None of the above → 404 via not-found.tsx.
```

The response includes a `x-voltro-rendered-by` header (`prerender` / `ssr` / `isr`) + cache state.

## ISR cache backends

```bash
SSR_CACHE=memory voltro start         # default — per-process, doesn't survive restart
# Postgres-backed cache — the web process needs a database in its env. Any of
# the usual variables works; DB_URL is what the templates set.
SSR_CACHE=postgres DB_URL=postgres://… voltro start
SSR_CACHE=postgres PG_HOST=… PG_PORT=… PG_USER=… PG_PASSWORD=… PG_DATABASE=… voltro start
```

For multi-instance + horizontal scale → Postgres. The cache table is auto-created on first boot.

> **This used to recognise `PG_HOST` and nothing else.** An app configured the
> documented way — `SSR_CACHE=postgres` plus `DB_URL` — silently got the
> per-process memory cache, announced as `isr cache backend: memory
> (per-process)`: an info line that reads like the default rather than like a
> refusal. Both the cache and the CDC invalidator go through the same connection
> resolver as everything else now, so `DB_URL` / `DB_PRIMARY_URL` / `DB_HOST` /
> `PG_HOST` all work — and `PG_SSL` comes with them. Asking for the postgres
> cache and getting memory is now a boot failure in production, not a log line.

## Tenant-aware ISR

Pages with `tenantAware: true` get separate cache entries per tenant. The cache key becomes `<pathname>|tenant=<tenantId>`. See [Render modes](/docs/routing/render-modes).

## CDC invalidation

For pages with `cacheInvalidatesOn: ['table', …]`, `voltro start` reads Postgres logical replication. Writes to listed tables invalidate every matching cache entry. Requires `SSR_CACHE=postgres` + `wal_level=logical`.

If routes declare `cacheInvalidatesOn` and the web process has no database in
its environment, boot now WARNS and names those routes — they fall back to plain
`revalidate` staleness. That gap used to be reported at `debug`, which is
invisible at the default level and indistinguishable from live invalidation
working.

## Graceful shutdown

`voltro start` handles SIGTERM:

1. Stop accepting new connections.
2. Wait up to `SHUTDOWN_GRACE_MS` (default 30s) for in-flight requests to finish.
3. Close active WebSocket connections (the client auto-reconnects).
4. Exit.

For container orchestrators, set `terminationGracePeriodSeconds: 60` to match.

## Health checks

`voltro start` exposes two unauthenticated probe routes:

- `GET /internal/liveness` → `200 ok` — the process is up at all (restart the pod if it stops answering).
- `GET /internal/readiness` → `200 ready` once boot completes, `503 not-ready` before (pulls the pod from Service endpoints until ready).

Point the Kubernetes liveness probe at `/internal/liveness` and the readiness probe at `/internal/readiness`.

## Multi-instance + sticky sessions

For WebSocket connections to land on the same backend (required for in-process subscription state):

- Reverse proxy: `lb_policy ip_hash` (Caddy) / `ip_hash` (nginx).
- Cross-instance subscription invalidation is built in on Postgres (LISTEN/NOTIFY) and MySQL/MariaDB (binlog CDC); on any other dialect add [`@voltro/plugin-broadcast`](/docs/plugins/broadcast) (Redis/NATS). With that in place a reconnect may land on any replica and still sees every change — sticky sessions then only keep one live connection pinned, they are not a correctness requirement.

## `voltro build api --target <swift|kotlin>` — native SDK generation

Generate a fully native mobile client from the same API bindings the TypeScript client is generated from. No hand-written models, no drift: the SDK is derived from your app's **capability manifest** — the exact procedure descriptors + JSON Schemas the framework already assembles from source — so every type stays in lockstep with the server.

```bash
voltro build api --target swift    apps/acme/api    # → apps/acme/api/sdk/swift  (Swift Package)
voltro build api --target kotlin   apps/acme/api    # → apps/acme/api/sdk/kotlin (Kotlin Multiplatform)
voltro build api --target swift --out ./ios/Sdk --name AcmeClient .
```

Flags:

- `--target swift | kotlin` — the language to emit. Required.
- `--out <dir>` — output directory. Default: `<appDir>/sdk/<target>`.
- `--name <PackageName>` — the Swift package / Kotlin module name (PascalCase). Default `VoltroClient`.
- `--kotlin-package <dotted>` — Kotlin source package. Default `com.voltro.client`.

What each package contains:

| Piece | Swift | Kotlin |
|---|---|---|
| Type-safe models | `Codable` structs + `String` enums | `@Serializable` data classes + enum classes |
| One-shot client (query / mutation / action) | `async throws` methods over `URLSession` | `suspend` methods over Ktor |
| Subscription client (streams) | `AsyncThrowingStream` over `URLSessionWebSocketTask` | `Flow` over Ktor WebSockets |
| Auth + tenant context | `AuthContext` (bearer + `x-tenant` headers) | `AuthContext` |
| Push registration | `PushRegistration` stub | `PushRegistration` stub |

Type mapping is faithful: `string → String`, `integer → Int`, `number → Double/Double`, `boolean → Bool/Boolean`, arrays → `[T]` / `List<T>`, nested objects → their own named type, string-literal unions → an enum, and an **optional field** (one absent from the schema's `required` set, or a `NullOr`) becomes a Swift `Optional` / Kotlin nullable with a `= nil` / `= null` default.

**Scope — this is the SDK code generator, not a native runtime.** Deliberately out of scope (they need a native runtime or managed infra, not generated client code): native module bindings (camera, biometrics), the APNs/FCM push **sender** (per-tenant Apple/Firebase credentials, provisioned server-side), and the managed OTA / EAS build pipeline. The generated source is verified at the generator level (golden-string tests over the emitted Swift + Kotlin). Compiling it with `swiftc` / Gradle is the remaining step in your own mobile CI — the framework harness has no Swift/Kotlin toolchain.

## `voltro doctor` — preflight a production serve

Production `voltro serve` for an **API** app boots ONLY from the precompiled serve
bundle and is **fatal if it's missing** — a hand-rolled Dockerfile that runs
`voltro serve` without a prior `voltro build` breaks at deploy. `voltro doctor`
(or `voltro serve --preflight`) catches that at BUILD time instead of cold-start:

```bash
voltro doctor .                 # check the serve bundle exists; print the fix if not
voltro serve --preflight .      # same check, then exit — never boots
```

It exits **1** when the serve bundle is missing on an API app (so it fails a CI /
Docker step) and prints the exact remedy: add a `voltro build .` step before
`voltro serve .`. Drop it into your image build right after `voltro build` to
guarantee the artefact is present before the image ships.

### Relations a query loads but does not declare

An eager-loaded relation is part of the RESULT, so its table has to be in `source:` or the view stops updating when it changes:

```
✗  1 query loads a relation it does not declare:
   tasks.getById: eager-loads `subTasks` from 'tasks' but does not declare
   'task_sub_tasks' in `source:` — the view will not update when 'task_sub_tasks' changes.
```

This is the failure that looks like a broken feature and is not: the write lands, a reload shows it, and every test of the write path is green. The name in `source:` is spelled right and the table exists, so neither the typed `source:` nor the boot audit has anything to say.

The rule has no exception list, deliberately. It resolves `.with({ … })` keys through the relation registry, so the missing table is a fact rather than an inference — and a **many-to-many** is reported twice when needed, because the junction table is where a link add/remove actually writes.

It reads what it can read literally: a computed `.with()` key yields nothing rather than a guess.

### The access-decision gate

Before the authz scan below — which is a heuristic over executor SOURCE — doctor
runs the same **gate the boot runs**: every wire-exposed procedure must declare
`guards:` or `openAccess:`. It is not advisory and not a ratchet, because a green
answer here means the app starts:

```
access decisions · security.defaultDeny ON
  ✗ no access decision                       3
  ✓ openAccess, declared on purpose          2
      pricing.current — public pricing page, reads no caller data
      status.ping — health probe

  ✗ invoices.list  (query)
      src/api/invoices.query.ts
```

Every undecided procedure is listed — never a prefix — and the same set is in
`voltro doctor --json` under `accessDecisions` for a CI gate:

```bash
voltro doctor --json | jq '.accessDecisions.undecided[] | {tag, kind, file}'
```

An app that sets `security: { defaultDeny: false }` still gets the list, marked
advisory, and doctor does not fail on it. Detail:
[Authorization](/docs/authentication/authorization).

### The authz scan

`voltro doctor` answers one mechanical question over every executor: **does it
reference an access check at all?**

```
authz scan · 586 executor(s)
  ✗ no access check                          21
  ⚠ inline ownership check, no named guard   24   (informational)
  ✓ guards: on the descriptor                 0
  ✓ calls a guard from the vocabulary       320
  – accepted as recorded debt               221   (voltro-authz-allowlist.txt)

  ✗ teams.deleteSubTeam  — deletes teams with nothing constraining WHICH row
      api/teams/deleteSubTeam.mutation.server.ts
```

It scans **queries and streams too**, not only writes: an executor that takes an
id and returns the row is the same hole as one that writes it. The exploitable
shape is "acts on a row the client named, without comparing anything on that row
to the caller", and a read has it.

**It learns your guard names.** An exported `require*` / `assert*` from your own
source counts as a guard, so `requireTeamAccess()` is recognised without any
configuration. Without that the scan would report every call site of your own
guards, which is the failure mode that makes a check ignorable.

It reads your **whole source tree** for those names, not just the
convention-named files — guards live in `lib/access.ts`, not in `*.mutation.ts`.
The line above the counts tells you what it found, and it is worth reading before
you trust the numbers:

```txt
  guard vocabulary: 17 from your source (requireTeamAccess, assertInquiryAccess, …)
```

If it instead says `framework names only — no exported require*/assert* found in
this app` while you know you export some, the counts below it are not meaningful:
every call site of your own guards is being reported as unguarded. Check that
they are `export`ed and that the name starts with `require` / `assert` followed
by a capital.

**An inline ownership check is informational.** `row.userId !== subject.id → new
AccessDeniedError({})` is correct code — it is listed so you can see where the
rule lives in a handler rather than on a descriptor, and it never fails the run.

Findings are ordered by blast radius: a `delete` outranks an `insert`, and a
target table that is `tenant()`-scoped or referenced by other tables outranks one
that is neither.

#### The ratchet — how to adopt this on an existing app

A first run on a large app reports hundreds of handlers, and nobody triages
hundreds of findings. So record them once and fail only on what comes after:

```bash
voltro doctor --write-authz-allowlist    # writes voltro-authz-allowlist.txt
voltro doctor                            # exits 1 on anything NEW
```

The file is **debt, not approval** — every line is a handler nobody has confirmed
is safe. It is keyed by rpc tag rather than path, so moving a file can neither
re-open a hole nor hide one, and it is consulted **last**: an executor that gains
a real guard is reported as guarded whether or not its line is still there. The
list can only shrink unless someone adds to it deliberately.

**Two kinds of line, because "debt" and "reviewed" are different claims.** A bare
tag is debt. A tag with `reviewed=<why>` says a human read the executor and found
it genuinely open — constrained by something the scanner cannot see:

```txt
teams.deleteSubTeam
inquiries.publicFeed  reviewed=public by design; returns only published rows
```

The reason is required: `reviewed=` with no why is the claim without the
evidence, and doctor refuses it. A bare tag is always available and is the honest
alternative. The two are counted and printed apart, and
`--write-authz-allowlist` is **additive**: it keeps the existing file verbatim —
entries, comments, grouping, order — and appends only tags it does not already
contain. It cannot remove a line. Removing one is your edit, or it happens on its
own when an executor gains a guard and its line stops mattering.

#### Reading the whole list

The human view prints the 20 most severe unchecked executors. The complete scan
— every finding, the counts, the inferred guard vocabulary, and the debt/reviewed
split — is in `voltro doctor --json` under `authz`:

```bash
voltro doctor --json | jq '.authz.unchecked[] | {tag, why, path}'
```

Nothing is truncated there. If you are triaging, work from the JSON.

#### Before you hand-roll another check

If your checks are imperative because a scope cannot express "may this subject
act on THIS row", that is what `guards: [{ action, resourceType, resource }]` is
for — and an app whose relationships live in its own tables (a `teamMembers` row,
say) registers its own tuple source instead of copying data into a framework
table. See [Authorization](../authentication/authorization.md).

### The predicate-column check

`eq` / `isNull` / `inSet` are free functions, so the column name arrives as a bare
`string` and the builder cannot relate it to the table the predicate is attached
to:

```ts
database.teamAppointments.where(isNull('deletedAt'))
//                               ^ teamAppointments has no softDelete() mixin,
//                                 so no `deletedAt` column exists. tsc: OK.
```

That type-checks — review and CI pass — and then fails at runtime as a bare SQL
error. `voltro doctor` checks every literal predicate column against the table's
declared columns and names both:

```
✗  predicate columns: 1 filter on a column that does not exist (214 checked)
  api/appointments/rollforward.query.server.ts:31  'teamAppointments' has no column 'deletedAt'
    columns: id, teamId, startsAt, createdAt, updatedAt
  This type-checks today and fails at runtime as a bare SQL error.
```

Matching is on the AST, never on text, so a column name in a comment or an
unrelated string cannot trip it. A call site whose table cannot be resolved is
skipped silently — an unresolvable receiver is usually not a table at all.

The type-level fix (binding the predicate to the row, `where(c => isNull(c.x))`)
is the right end state and is planned separately. This check is the half that
works **retroactively**: it finds the bug in code that already exists, which a
type change never will.

The detector also flags **raw `fetch()` in server files**. The SSRF guard the
framework ships lives in the `HttpClient` handlers `yield*` — so it protects
exactly the apps that already adopted it, and misses the ones that never did.
Those are usually the same apps that secured least elsewhere, which is why the
absence is worth naming out loud rather than assuming the default did its job.

The rule follows the IMPORT GRAPH, not the filename. Server-convention files
(`*.server.ts`, `*.cron.ts`, `*.subscribe.ts`, …) are the starting points, and
any file reachable from them and from **nothing else** counts as server code
too. That matters: keyed on filenames alone the rule caught 9 of 39 outbound
calls on the app that reported it — the other 30 sat in `lib/*.ts` helpers
(payments, an AI provider, TTS) imported only from server executors. A
`lib/payments-mollie.ts` is not client code, and no file extension can say so.

A helper a page ALSO imports stays unflagged, and that is the property keeping
this rule useful: `fetch` is unremarkable in a browser component, and flagging it
there would make the rule noise that gets scrolled past — taking the real
findings with it. Relative imports and your tsconfig `paths` aliases are both
followed.

The detector also flags an **executor that never names its own descriptor**.
Pairing is by FILENAME, which is right — and it means a `*.server.ts` can be a
complete, correct executor with no reference at all to the contract it
implements. Those are exactly the files where a hand-written input drifts from
the wire: in one reported codebase, six executors declared `boardPurpose: string`
where their own descriptor said `Schema.Literal(...)`, discarding the contract at
the executor boundary. Fix by importing the descriptor and typing the input as
`ExecutorInput<typeof descriptor>`. Only a SIBLING import clears the finding —
an executor importing nothing but `@voltro/*` and `node:*` has still not named
its contract.


### The `workflows.start` audit

`voltro doctor` also diffs every `workflows.start(name, payload)` call site
against the workflows the app actually registers:

```
✗  workflow starts: 13/14 call sites checked
  api/crons/weekly.cron.ts:22  'sprint.report' payload is missing: teamId
  api/crons/weekly.cron.ts:22  'sprint.report' payload has unknown field(s): scheduledAt
    accepted: sinceIso, teamId
  1 UNCHECKED (not verified — not a pass):
    api/crons/digest.cron.ts:8 — payload spreads a value
```

**Why this exists even though `workflows.start` validates at runtime.** Runtime
validation fires on the next firing — which for a daily cron is hours, for a
weekly one is a week, and for a quarterly one is a quarter. And
`voltro inspect schedules --failing` cannot see a job that has *never fired*,
because its roll-up is built from recorded runs. A weekly workflow broken by a
refactor is invisible to both until it next runs.

**`UNCHECKED` is never folded into a pass.** A payload built with a spread or a
computed key can contribute any name, so its key set is unknowable here. Those
call sites are counted and listed rather than passed silently — `0 issues` must
not be readable as "all verified". The workflow **name** is checked regardless,
since a rename or a deletion is decidable whatever the payload looks like.

The required keys come from the live `payloadSchema`, the same source the runtime
validation reads, so the two cannot disagree about what a payload needs.

### The junction-FK check

A link / junction table (`projectMembers`, `todoTagAssignments`) exists to connect two aggregates, so its columns are almost all foreign keys. Declared with `reference(() => projects)` the framework knows the edge — it enforces integrity, auto-indexes the FK, and can walk the reference graph. Declared as a bare `text()` id column the *same* edge is invisible: no FK, no auto-index, and nothing that walks references can follow it. Nothing type-checks the difference.

`voltro doctor` flags a junction table with an id-shaped column that is a plain scalar and not a `reference()`:

```
junction FKs: 2 junction tables with an id column that is a plain text() and not a reference()
  'todoListMembers': 'todoListId', 'userId' (part of a composite primary key)
  'todoTagAssignments': 'todoId', 'tagId' (this table is nothing but link columns)
  Declare each as reference(() => <table>): the FK is enforced, the column is auto-indexed,
  and the relationship becomes walkable (a plain text() id column is an invisible edge).
```

It will **not** fire on any `*Id` text column — a `tenantId`, a `traceId`, an external-system reference are all legitimate plain-scalar shapes. It fires only when the table's OWN structure independently says "link table", and it names which signal tripped it so the finding is auditable rather than a bare accusation:

| Signal | What it means |
|---|---|
| `part of a composite primary key` | the suspect column is a member of an explicit `primaryKey([...])` — the PK structure alone proves the row is a link |
| `sits beside a wired reference() on this table` | a real `reference()` on a same-shaped sibling column, while this one is a bare scalar |
| `this table is nothing but link columns` | the whole table is id-shaped columns + bookkeeping (a pure link table) |

The audit reads the tables' **real declared `ColumnType`s** — the same materialised column definitions the migrator emits DDL from — never source text. So a `reference` is told apart from a plain scalar by its declared type, not a name regex, and a column name that only appears in a comment cannot trip it.

### Event delivery + scale

Two events with identical route / subscriber / buffer numbers can mean **opposite** things about a missing message — `each` counts a drop as a loss and tells the subscriber, `latest` supersedes the pending value and says nothing — and that mode is invisible once the app is running. So `voltro doctor` lists every declared event's delivery mode:

```
event delivery: 4 declared events — the mode decides what a MISSING message means
  'games.started': each
  'player.moved': latest
  each   — every delivery matters; a slow subscriber loses the oldest and is TOLD how many (the default).
  latest — a newer delivery supersedes a pending one; a slow subscriber gets the current value, told nothing.
```

It also **warns** on two shapes that will not scale the way the declaration reads — advisory, never blocking:

| Warning | Why |
|---|---|
| **routing key has 3+ fields** | every key field is a routing address, and the count of distinct routes is the *product* of the fields' value spaces. Check each is an ADDRESS the delivery is decided by (`arenaId`), not a discriminator the handler reads (`gameType`) — the latter belongs in the payload, not the key. |
| **`webhook:` on a per-frame event** | a webhook block on a name like `player.moved` / `cursor.moved` / `*.frameRendered` becomes N HTTP deliveries per second *per subscribed target*. The webhook rate limit **defers** the excess as pending rows rather than failing, so the symptom is a growing table. Publish a coarser event (a summary / state change) for the outside world. |

The field count comes from the same schema-property reader the runtime validation uses, so it cannot disagree with the key the event actually routes on. Both findings appear in `voltro doctor --json` under `eventDelivery`.

### The hand-roll detector

`voltro doctor` also scans your source for shapes the framework already has a
primitive for, and names the primitive at the spot the hand-roll lives. This is
**advisory and never blocking** — it prints, it does not fail your build.

```bash
voltro doctor .
```

```text
•  Shipped primitives you may be hand-rolling:
   [server]
   hand-written not-found branch on rows[0] — 12 file(s): queries/team.get.ts, …
     → .one() — fails with the typed NoRowFound on zero rows AND on more than one
   [client]
   per-field useState + a submit flag (hand-rolled form) — 4 file(s): src/create-dialog.tsx, …
     → useFormBinding — fields + validation from the mutation input Schema
```

It covers both halves of the stack:

| Scope | It notices | Reach for |
|---|---|---|
| server | `if (!rows[0]) throw …` | `.one()` / `.first()` |
| server | 3+ sequential `store.query` in one handler | `relations()` + `.with()` — or `Effect.all` |
| server | `Effect.promise(() => ctx.store.…)` | `yield* EffectStore` |
| server | `requireScope(...)` at the top of an executor | `guards:` on the descriptor |
| server | a `token` / `secret` / `password` column with no encryption | `.encrypted()` |
| server | a notify / webhook helper called at a mutation's tail | `defineSubscriber` / `defineReaction` |
| server | `hasMore` + `limit + 1` | `paginateById` |
| server | `.getTime()` / `.toISOString()` mapping a row on the way out | `timestampMs` / `timestampMsOrNull` from `@voltro/database/wire` in the descriptor's `output` struct |
| client | per-field `useState` + a submit flag | `useFormBinding` |
| client | a table with local sort/filter state | `useDataTable` |
| client | `FileReader` / `readAsDataURL` | `useUpload` |
| client | `setTimeout` debounce in a `useEffect` | `useDebounced` |
| client | `useMemo` fanning in several subscriptions | `useDerived` |
| client | a local Next.js compat shim | the native `@voltro/web` exports |
| client | a hand-rolled presence heartbeat | `@voltro/plugin-presence` |
| client | `data === undefined` / `!data` on a subscription result | branch on `loading` (and `idle`, if you pass `skip`) |

The subscription rule **resolves the binding** rather than matching text, and
that distinction is the reason this scanner parses at all. A deployment migrating
these call sites wrote a regex codemod for the same job, and it rewrote a
`summary === undefined` check inside a child component where `summary` was a
PROP. Their compiler happened to catch it, because that name was out of scope
there; had the names matched, a silent behaviour change would have shipped. Text
cannot tell you which declaration an identifier refers to — so a rule about
identifiers has no business being written in text.

The rules are deliberately conservative — a detector that cries wolf trains you
to ignore it. A column that already carries `.encrypted()`, or a handler that
already uses `.one()`, stays silent.

Two of them are worth spelling out, because their advice is not one-line:

**The credential-column rule skips names that aren't credentials.** A name ending
in `Id` / `_id`, a name ending in `Hash` / `_hash`, and a name beginning with
`vault` are all left alone:

| Column | Why it's skipped |
|---|---|
| `jiraSecretId`, `token_id` | an IDENTIFIER of a secret held elsewhere, not the secret |
| `apiKeyHash`, `password_hash` | the hash IS the protection — encrypting it is nonsense, and it breaks the column as a unique lookup key |
| `vaultToken` | a HANDLE into a secret store, naming a secret held elsewhere |

The suffix tests use a camelCase / underscore boundary on purpose: a blind
`/id$/i` would also swallow `apiKeyValid`, while `tokenIdentifier` — which ends
in neither — must still fire.

**The sequential-reads rule names TWO levers, and the criterion for choosing.**
Both shapes chain later reads off earlier results, so no text-level heuristic can
split them — you make the call:

- The reads are a **parent → child walk on ONE key** → declare `relations()` in a
  `*.relations.ts` and collapse them into `.with({ … })`: one JSON-aggregate
  query, on every dialect.
- The reads **collect ids from SEVERAL sources** (JSON-array references, a
  junction carrying extra columns, JS-side sorting) → keep the assembly and run
  the independent LEADING reads under `Effect.all`. Same queries, same results,
  only concurrent — zero parity risk.

The second case is the common one. Measured on a real 74-hit codebase, about two
handlers were clean full-parity `relations()` conversions and the other ~72 were
multi-source assemblies where `.with()` covers only part of the work or subtly
changes behaviour. Prescribing `relations()` for all of them would be wrong ~97%
of the time — and advice that is usually wrong trains you to ignore the finding.

The human view shows the first three file paths per finding and says how many it
withheld. Those paths are the actionable part — a count you cannot turn back
into a work list tells you the size of the problem, not how to fix it — and the
matching rule lives inside the CLI, so you cannot re-derive the list with your
own grep. `--json` prints the complete scan, nothing elided, with no preflight
output mixed in:

### Unimported `@voltro/*` dependencies

A declared framework dependency nobody imports still gets installed, walked on
every `voltro update`, and read as evidence the package is in use — its
breaking-change notes included. The usual origin is a migration: the app moves
off a framework package to a third-party one, and the `package.json` entry
stays. `voltro doctor` checks every `@voltro/*` in `dependencies` and
`devDependencies` for an import site:

```
unimported deps · 8 @voltro/* package(s) declared, 214 file(s) scanned
  ⚠ @voltro/i18n — declared in dependencies, imported nowhere
      a dependency nobody imports still gets installed, updated, and read as
      evidence the package is in use — its breaking-change notes included.
      Remove it, or if it IS imported through an assembled specifier the scan
      cannot see, keep it and ignore this line — the rule is advisory.
  · (2 loaded by the framework itself: @voltro/cli, @voltro/sql-postgres)
```

Scoped to `@voltro/*` deliberately: for third-party packages the same question
has a long tail of legitimate no-import shapes, and a rule that is sometimes
wrong is one people stop reading. Three states are distinguished, and each is
printed:

- **Exempt, by name** — packages the framework loads on your declaration
  (`@voltro/cli` is the binary; `@voltro/devtools` is mounted by `voltro dev`;
  the `@voltro/sql-*` dialect drivers are loaded from your config). An
  exemption you cannot see is a finding you cannot question.
- **Not measurable yet** — `@voltro/client` / `@voltro/web` are normally
  imported by *generated* code. On a tree where codegen has never run, their
  absence is a missing measurement, not a dead dependency; the section says so
  and tells you to run `voltro dev` once.
- **Unimported** — advisory, never fatal. A mention in a comment or an error
  string does not count as an import (a commented-out import is exactly the
  residue this looks for), and an import assembled at runtime from string
  pieces is invisible to the scan — the finding text says both.

The full report is in `voltro doctor --json` under `unimportedDeps`
(`null` when there is no `package.json` to read — "could not check" and
"checked, clean" never print the same).

### Duplicate package instances

`voltro doctor` also reports any identity-sensitive package resolved at more than
one version — `effect`, `@effect/*`, `@voltro/*`, react/react-dom:

```
•  1 package(s) resolved at more than one version:
     effect — 3.18.4, 3.21.0
       node_modules/effect
       ../../node_modules/effect
```

This is worth its own check because of how it PRESENTS. Effect's types are
nominal, so a `Schema` built by one copy is not the type the other expects, and
the errors land in the GENERATED `rpcGroup.generated.ts` — a file you cannot edit
and did not write:

```
Property '[TypeId]' is missing in type … Schema<any, any, unknown>
Type 'typeof Never' is not assignable to type 'All'
Argument of type 'Rpc<…, Stream<…>, …>' is not assignable to 'Any'
```

Read cold, that says "the framework emits bad types". It says nothing about the
dependency tree, which is where the problem is. And the RUNTIME usually stays
green — two instances only diverge where identity matters — so the app boots,
serves and passes its tests while `tsc` is red.

Fix it in the install, not the code: align the version across the workspace (a
root `pnpm.overrides` / `resolutions` entry for `effect` is the blunt
instrument), then reinstall. Do NOT add `@ts-nocheck` to the generated file — it
is exactly where a genuine mistake in your own descriptors surfaces.

```bash
voltro doctor . --json          # the complete scan: every file path, machine-readable
```

```json
{
  "root": "/app/api",
  "scannedFiles": 214,
  "scannedDirs": ["queries", "mutations", "database"],
  "findings": [
    {
      "id": "row-not-found",
      "scope": "server",
      "smell": "hand-written not-found branch on rows[0]",
      "use": ".one() — fails with the typed NoRowFound on zero rows AND on more than one",
      "files": ["queries/team.get.ts", "queries/user.get.ts", "…"]
    }
  ],
  "spaCandidates": []
}
```

That is the form to hand an agent, or to pipe into a script that works the list
file by file.

### `renderMode:'spa'` candidates

`voltro doctor` also flags web pages that could adopt `renderMode: 'spa'` without
losing their server-rendered shell. A page under a layout renders that LAYOUT
chain on the server — nav, sidebar, auth gate, via the layout's own loader — even
when the page itself is `'spa'`. So a page whose BODY needs no SSR can skip its
per-page SSR compile while the shell still server-renders. Like the hand-roll
detector, this is **advisory and never blocking**.

A page is listed when ALL of these hold:

- it is a **page file** — not `layout.tsx` / `loading.tsx` / `error.tsx` /
  `not-found.tsx`;
- it exports **no `loader`** (so `'spa'` loses nothing the page contributed
  server-side);
- its `renderMode` is **`'ssr'` or unset/default** — not a page that already
  opted into a non-SSR render (`'spa'` / `'static'` / `'isr'`, or any other
  explicit mode);
- a **`layout.tsx` sits somewhere in its directory chain** — root, an ancestor, or
  the page's own dir. This is the load-bearing condition: only then does a layout
  still SSR the shell. A page with no layout would, as `'spa'`, ship no server
  HTML at all — so it is never flagged.

```text
•  renderMode:'spa' candidates (2 pages — loader-free, under a layout, currently ssr/default):
   src/pages/dashboard/page.tsx  (/dashboard) — default renderMode
   src/pages/admin/settings/page.tsx  (/admin/settings) — renderMode:'ssr'
     → renderMode:'spa' skips this page's SSR compile while its layout shell still renders server-side — adopt it if the page BODY does not need SSR (internal/authenticated pages); keep 'ssr' if the page content needs SEO or server first-paint.
```

Adopt `'spa'` for internal or authenticated pages whose content needs no SEO or
server first-paint; keep `'ssr'` (or the `'static'` default) when it does. There
is deliberately **no codemod** to flip pages automatically — dropping a page
body's server render is a per-page product decision, not a mechanically-safe
transform. Every candidate (with its `file`, `pattern`, and `currentMode`) is
also in `voltro doctor --json` under a `spaCandidates` array; the human view
above caps at ten pages and points to `--json` for the rest.

## `voltro capabilities` — what the framework actually exports

Asked "what does this framework export", a language model will produce a
confident answer whether or not it knows. This command replaces that guess with
a reading of the `.d.ts` files in your own `node_modules`:

```bash
voltro capabilities              # human summary, grouped by package
voltro capabilities --json       # the full machine-readable surface
```

```text
voltro capabilities — 3856 exported symbols across 36 packages
  @voltro/runtime@0.38.0 — 12 primitives, 5 hooks, 100 components, 360 values, 388 types
    defineAggregate, defineConnection, defineCostBudget, defineEventTrigger, defineExecutor, …
  @voltro/database@0.38.0 — 3 primitives, 33 components, 333 values, 223 types
    defineMigration *, defineMixin, defineSeed *

  * 9 primitive(s)/hook(s) appear nowhere in this project's agent guide:
      @voltro/database: defineMigration
      @voltro/plugin-flags: defineFlag
      …
```

The count is the packages **installed in that project**, not everything the
framework publishes — a leaner app reports fewer.

Every symbol reported was read out of an installed package a moment ago, so an
agent can **verify** the surface instead of recalling it. The `--json` form is
stable and locale-independent — the same tree produces byte-identical output on
every machine, so you can diff it across upgrades.

Symbols marked `*` ship but appear nowhere in this project's seeded `AGENTS.md`
/ `CLAUDE.md`. Refresh the guide with `voltro agents-md --force`, or read that
package's README.

## Anti-patterns

- **Running `voltro start` against a directory without `dist/`.** It exits 1 with a clear `no built dist found — run voltro build first` (checked against `.framework/dist/index.html` before any heavy work). Run `voltro build` first.
- **`voltro start` in dev to "test prod".** Use `voltro build && voltro start`. The dev server has different behaviour; serving dev artefacts via start is undefined.
- **Skipping the SSR bundle build.** Middleware mode is slower (cold-start cost on every render). For production deploys with `renderMode: 'ssr'` pages, build the bundle.

## See also

- [Render modes](/docs/routing/render-modes) — which mode produces which output
- [Voltro Cloud](/docs/deployment/voltro-cloud) — managed `voltro start` + autoscale (coming soon)
- [Self-hosting](/docs/deployment/self-hosting) — Docker + reverse proxy patterns



---

<!-- source: en/cli/migrate.md -->
## Migrate

_voltro migrate — apply the declared schema through the declarative differ (an alias of voltro db apply)._

`voltro migrate` applies your declared schema (`*.entity.ts` / `*.schema.ts` / `schema.ts`) to the configured database. It is an **alias of [`voltro db apply`](#the-declarative-workflow)**: it diffs the declared schema against the live database and emits the ALTERs, so a changed column or a new index actually lands.

> Before 0.11.4 this command was a create-only apply (`CREATE TABLE IF NOT EXISTS`, no diffing), which meant a column or type change reported success having applied **nothing**. If you need that bootstrap-only behaviour for a brand-new database, it is now `voltro migrate --create-only`.

```bash
voltro migrate            # apply the discovered schema to the configured store
voltro migrate apps/api   # explicit app directory (defaults to cwd)
```

`voltro migrate` forwards its arguments to `voltro db apply`, so the same flags apply; `--create-only` selects the bootstrap-only emitter instead. The diff / plan / apply / drift / squash workflow lives under `voltro db` (see below).

`--create-only` creates the SAME set of tables every other command declares: your
entities, the feature-mix framework tables, the agent-thread tables when the app
has an `*.agent.tsx`, and every plugin's `extendSchema.tables` — plus each
plugin's `extendSchema.migrations` afterwards. Before 0.34.0 it assembled that
set itself and got the last two wrong, so bootstrapping a fresh database the
documented way produced one with no plugin tables at all.

For the deep dive on the schema DSL + day-to-day patterns, see [Database / Migrations](/docs/database/migrations).

## How discovery works

The migrator walks the project root (skipping `node_modules`, `dist`, `.framework`, etc.) for:

- `*.entity.ts` / `*.schema.ts` / `schema.ts` — your table descriptors.
- Feature files (`*.workflow.tsx`, `*.cron.tsx`, `*.webhook.tsx`) — their presence decides which framework-internal `_voltro_*` tables get created (workflow-run tables, schedule ledgers, etc.). **`_voltro_traces` is the exception — env-gated, not feature-gated:** it's created under `voltro dev` (durable trace history on by default) but NOT under `voltro serve` / `voltro start` (prod default off → set `VOLTRO_TRACING_PERSIST=interesting` to opt in). See [Distributed tracing](/docs/observability/distributed-tracing).

The core `actors` table is injected automatically when you didn't declare your own, and `tenants` is registered when present, so the audit / soft-delete / tenant mixins resolve their FK targets.

## `voltro dev` already auto-migrates

On a SQL-backed store, `voltro dev` AUTO-APPLIES the discovered schema before any handler boots — so a standalone `voltro migrate` is rarely needed in the inner loop. It's the CI / ops-pipeline entry point.

Opt out of the dev auto-migrate when you ship schema through a separate reviewed pipeline:

```bash
VOLTRO_AUTO_MIGRATE=0 voltro dev .
```

The boot log then says `auto-migrate: skipped (VOLTRO_AUTO_MIGRATE=0)`, and you run `voltro migrate` (or `voltro db apply`) explicitly.

## Store + dialect selection

The dialect is resolved from `DB_DIALECT` (default `postgres`); the connection comes from `DB_URL` / `DB_PRIMARY_URL`, falling back to the discrete `DB_*` / `PG_*` fields (`DB_HOST` / `PG_HOST`, `DB_PORT` / `PG_PORT`, etc.).

```bash
DB_DIALECT=postgres DB_URL=postgres://app:app@localhost:5432/app voltro migrate
```

Migrating against `DB_DIALECT=memory` is rejected — there's nothing to migrate. In dev with the memory store, the in-memory store learns its shape from the schema declaration directly; switch to a SQL dialect (`postgres`, `sqlite`, `mysql`, `mariadb`, `mssql`, `turso`) to test the real migration path.

## The declarative diff workflow — `voltro db`

The plan / apply / drift / squash machinery — diffing the declared schema against a live database, generating reviewable DDL, applying a pre-reviewed plan in production — lives under `voltro db`:

```bash
voltro db plan                            # diff declared schema vs live, color-coded
voltro db plan --json                     # machine-readable for CI / PR comments
voltro db plan --sql                      # raw DDL preview
voltro db plan --against <url>            # diff vs a REMOTE env via /_voltro/inspect/migrations
voltro db apply                           # execute the plan (refuses NODE_ENV=production — and an UNSET NODE_ENV resolves to production)
voltro db apply --plan plan.json          # prod: apply a pre-reviewed plan from CI/CD
voltro db plans [--limit 20]              # plan history from _voltro_migration_plans
voltro db branch --pr <n>                 # REHEARSE the plan on a throwaway branch of the live schema
voltro db drift                           # alert if live diverged from the latest applied fingerprint
voltro db squash --before <iso-date>      # consolidate history into one snapshot
voltro db restore-snapshot <plan-id>      # restore soft-dropped columns from a plan
```

> **Every `voltro db …` / `voltro migrate` invocation declares its
> environment.** An unset `NODE_ENV` resolves to `production` for these
> commands — the same way it does for `voltro serve` and `voltro start` — so a
> bare `voltro db apply` with no `NODE_ENV` refuses (exit 3) rather than
> applying an un-reviewed diff. Locally: `NODE_ENV=development voltro db apply`,
> or put `NODE_ENV=development` in your `.env` (a declared value always wins).
> `voltro dev` declares `development` for itself and needs nothing.
>
> The second reason it matters is not the refusal: `_voltro_traces` and
> `_voltro_undo_log` are created only outside production, so a migration command
> that resolved the environment differently from the serving process **declared
> a different schema** — and the declared set is what the fingerprint hashes.

A separate file-based migration surface (the offline escape hatch) lives alongside it:

```bash
voltro db migrate                       # apply pending migration files
voltro db rollback [--to <id>]          # undo migrations
voltro db status                        # list applied / pending migrations
voltro db seed                          # run boot-lifecycle seeds against the configured store
voltro db seed --id <name>              # run one seed by id
voltro db seed --store memory           # explicit override; default = the app's configured store
```

`voltro db seed` defaults to the same store the app runs on (`DB_DIALECT` → `STORE` → `app.config.ts` `store:` → `postgres`) — seeding to memory was a silent data-loss footgun, so you opt into it explicitly.

## Production safety

For production deploys:

1. **Always apply schema BEFORE app version bumps.** App N+1 expects schema N+1; app N should still tolerate schema N+1.
2. **Review the plan first.** `voltro db plan --json` in CI, apply the reviewed plan with `voltro db apply --plan plan.json`.
3. **Check for drift.** `voltro db drift` flags a live database that diverged from the last applied fingerprint.
4. **Verify backups before destructive changes.** Restoring is the actually-tested rollback path.

For zero-downtime deploys with breaking schema changes:

- Add new columns / tables → apply first, then deploy.
- Remove columns → deploy code that doesn't read them, then apply the drop.
- Rename → add new column + dual-write, deploy, drop old column later.

The framework doesn't enforce these — that's an SRE responsibility. See [Migrations](/docs/database/migrations) for the playbook.

## Rehearsing a migration before it reaches production — `voltro db branch`

`voltro db plan` tells you what the diff IS. `voltro db branch` tells you what it
DOES: it branches the live schema into a throwaway namespace, applies the plan
there (destructive operations included — the branch is disposable, so the
operation most likely to fail is the one that actually gets rehearsed), re-plans
to prove the migration converges, and drops the branch.

```bash
voltro db branch --pr 128 --json > rehearsal.json   # exit 2 = the plan destroys data
```

Postgres only, and it says so on the other dialects rather than emitting Postgres
syntax at them. Full behaviour, exit codes and the `--seed` trade-off:
[Data branching](/docs/database/branching).

## See also

- [Database / Migrations](/docs/database/migrations) — the schema DSL deep dive
- [Data branching](/docs/database/branching) — `voltro db branch` + the branch primitive
- [Self-hosting](/docs/deployment/self-hosting) — production migration patterns



---

<!-- source: en/cli/inspect.md -->
## Inspect & test

_The HTTP inspect surface, the dashboard, voltro logs / voltro traces, voltro test, voltro e2e — the debugging + harness tools._

When something's wrong, these are the tools. Live inspection of a running app happens over an HTTP surface (and the dashboard that consumes it), not a dedicated CLI verb. The shell-facing debugging commands are `voltro logs` and `voltro traces`; the harness commands are `voltro test` and `voltro e2e`.

## `voltro inspect`

`voltro inspect <subcommand>` is the ergonomic CLI wrapper over the HTTP surface below. It discovers every running api via `~/.voltro/runtime-registry.json`, fans the matching GET/POST out to each, and renders the merged result — no `curl` + `jq` needed.

```bash
voltro inspect app                              # manifest meta (kind, name, store, …)
voltro inspect routes                           # web page tree (web apps only)
voltro inspect rpc                              # procedures + workflow descriptors (api only)
voltro inspect metrics                          # rolling rpc latency buckets
voltro inspect cache                            # web data-cache stats (web apps only)
voltro inspect schedules                        # cron registrations + coordination mode
voltro inspect schedules --failing              # only broken crons — exits 1 if any (see below)
voltro inspect aggregates                       # materialised aggregate views
voltro inspect invoke --tag users.list --input '{}'   # call a procedure over HTTP
```

Flags on every subcommand: `--process <name>` narrows to one api; `--format pretty|json` (default `pretty`). `invoke` additionally takes `--tag <procedureTag>` and `--input <json>`. With no subcommand it prints the endpoint map + the live processes it can reach.

### `schedules --failing` — is any cron actually broken?

A schedule fires unattended: there is no user watching it fail. The plain
`schedules` listing answers *which crons exist and when they fire next* — never
whether they **work**. `--failing` rolls each schedule's recent runs
(`/_voltro/inspect/schedules/runs`) into a verdict and prints only the broken
ones:

```bash
$ voltro inspect schedules --failing
# schedules @api
  sprint.report  0 2 * * *  FAILING x87
    every recorded run failed (last 20)
    Workflow "sprint.report" was started with an invalid payload. missing required field(s): teamId
```

It **exits 1 when anything is failing**, so it works as a post-deploy gate and
not only as something someone remembers to run:

```bash
voltro inspect schedules --failing || echo "broken cron — do not promote"
```

A trailing success ends a streak (a recovered job is not reported), and
`skipped` / `missed` runs are ignored — those are coordination outcomes (another
pod took the tick, the process was down), not handler verdicts. A schedule that
has never run is not "failing".

Pair it with `voltro logs --level error`: a failing handler now logs at **error**
level, so the two surfaces agree.

## `voltro schedule run <name>` — fire one job, now

```sh
voltro schedule run nightly-reconcile
voltro schedule run nightly-reconcile --process billing --format json
voltro schedule run nightly-reconcile --url https://api.example.com   # a deployed app
```

For the normal case: a nightly job that corrects business data, and you want to
run it once and watch. It reports the run id, and `voltro inspect schedules`
shows the outcome.

A run id of `null` is not a failure and is reported as its own outcome: the run
was **coordinated away** — another replica holds the lock, or the previous run is
still going and this schedule's `onOverlap` is `'skip'`. Printing "ok" there
would claim work that never started.

`--trigger external` records the run as externally triggered instead of manual,
for schedules that are normally fired by an outside scheduler.

This works against `voltro serve` as well as `voltro dev`. It did not before —
production mounted no inspect surface at all, which also meant the post-deploy
gate below could only ever be run against a dev server.

## Targeting a deployed app

Every command in this family resolves its target from the local runtime registry — the apps running on *this* machine. Pass `--url` to point one at a **deployed** app instead:

```bash
voltro inspect app     --url https://api.example.com --token "$TOKEN"
voltro logs   --tail 100 --url https://api.example.com
voltro traces --errors   --url https://api.example.com
voltro check             --url https://api.example.com
```

`--token` (or `VOLTRO_INSPECT_TOKEN`) supplies the bearer; `VOLTRO_INSPECT_URL` sets a default target so you can drop the flag. Works for `inspect`, `logs`, `traces`, `workflows`, `cluster` and `check`.

## `voltro probe access` — is a declared guard actually enforced?

`voltro check` reads an app's manifest and reports a procedure with **no access
decision**. It cannot tell you whether the decisions that ARE declared are
enforced. `voltro probe access` asks the running app:

```bash
voltro probe access                 # every live api
voltro probe access --url https://api.example.com --strict
```

It calls every procedure that declares a guard with **no credentials at all**
and reports any that answer anyway:

```
api: probed 14 guarded procedure(s) with NO credentials
  ✗ orders.export — ANSWERED an unauthenticated call
  ? billing.invoice — answered 'ParseError' — not an access refusal, so the guard was not reached
  ✓ 12 refused · 1 inconclusive · 1 admitted
```

Three verdicts, and the third is what keeps the command honest:

| Verdict | Meaning |
|---|---|
| `refused` | The call came back as an access refusal. The declaration is enforced. |
| `admitted` | The call SUCCEEDED without credentials. **This is the finding.** |
| `inconclusive` | The call failed for a reason that is not an access refusal — usually payload validation running before the guard. **Not a pass.** |

Exit code is non-zero on any `admitted`. `--strict` also fails on
`inconclusive`, which is what you want in CI: "I could not tell" should block.

**What it deliberately does not do.** It probes ANONYMOUSLY, so it cannot tell
`orders:read` from `orders:write` — it answers exactly one question, and the
alternative (minting a subject per guard) would put credential minting into a
command that can be pointed at production. Procedures declared `openAccess:` are
skipped; probing them would report every deliberately-public route as a finding
and bury the real ones.

## Securing the local surface

`voltro dev` mints a per-project `VOLTRO_INSPECT_TOKEN` into `.env.local`, so the inspect surface is authenticated from the first boot — the dev server listens on every interface, and without a token anyone on the same network could read your rows, schema and logs. You don't have to wire it anywhere: the CLI picks the token up from the runtime registry (so the commands work from any directory), and the dashboard's server-side proxy supplies it for same-machine targets. Setting `VOLTRO_INSPECT_TOKEN` yourself always wins.

## The inspect HTTP surface

Every `voltro dev` / `voltro start` instance exposes an introspection surface
under `/_voltro/inspect/*`.

**It is not read-only.** The core endpoints are reads, but installed plugins
mount their own — and some are POSTs that DO things: `plugin-governance` mounts
`/erase` (an irreversible GDPR right-to-be-forgotten deletion) and `/export` (a
full personal-data dump); `plugin-storage` mounts `/share` and `/revoke`.

**So a mutating method needs a second credential.** `VOLTRO_INSPECT_WRITE_TOKEN`,
sent as the `x-voltro-inspect-write` header ON TOP of the bearer — an additional
factor, not an alternative: the read token still has to be correct. GET / HEAD /
OPTIONS are unaffected. Unset, those endpoints are refused.

```sh
curl -H "authorization: Bearer $VOLTRO_INSPECT_TOKEN" \
     -H "x-voltro-inspect-write: $VOLTRO_INSPECT_WRITE_TOKEN" \
     -X POST http://localhost:4000/_voltro/inspect/plugins/governance/erase
```

`voltro dev` mints it per project like the read token, and the dashboard proxy
injects it for loopback targets, so the dev loop is unchanged. **Nothing mints it
for `serve` / `start`** — in production a destructive endpoint should take a
deliberate act to enable. Use a DIFFERENT value from the read token; reusing it
gives the split no meaning.

A plugin mounting a non-GET inspect endpoint must also declare the
`inspect:write` permission, and the boot audit refuses it otherwise. That governs
what a PLUGIN may mount; the write credential governs who may call it. The [Voltro Dashboard](/docs/observability/overview) consumes it to render the route sitemap, RPC list, subscription panel, workflow runs, and metrics. You can also hit the endpoints directly with `curl` (the `voltro inspect` subcommands above are the thin wrapper over exactly these).

```bash
PORT=4000   # from the app's app.config.ts

curl -s localhost:$PORT/_voltro/inspect/routes  | jq    # web: page tree + render-mode flags
curl -s localhost:$PORT/_voltro/inspect/rpc     | jq    # api: every query / mutation / action / workflow
curl -s localhost:$PORT/_voltro/inspect/metrics | jq    # rolling per-tag latency + invocation count
```

There is no `/_voltro/inspect/queries` endpoint. The registered GET surface is `app`, `routes` (web-only), `cache` (web-only), `rpc` (api-only), `metrics`, `subscriptions` (api-only), `checks` (api-only) and `agent/tools` (api-only) — `rpc` is the procedure list, `routes` is the web page tree.

### Invariant checks + the agent-tool surface

```bash
curl -s localhost:$PORT/_voltro/inspect/checks      | jq   # browser-safety, procedure-access, convergence, serverOnly
curl -s localhost:$PORT/_voltro/inspect/agent/tools | jq   # the exposeAsTool procedures an agent may run
```

`checks` runs the framework's own invariant checks and answers `pass | fail | unavailable` per check — `unavailable` means THIS process cannot answer it (a deployed `voltro serve` has no source tree to walk) and is never a pass. `agent/tools` lists the policy-admitted agent tools, and its sibling `POST /_voltro/inspect/agent/call` executes one; both are off until `agents: { mcp: true }`, and the call additionally needs the write credential plus an app credential on `x-voltro-agent-authorization`. See the [MCP server](/docs/cli/mcp) page for the full gate list.

What the surface reads:

- The endpoints serve in-process state from the running instance — no separate daemon.
- The default target is `http://localhost:<port>` based on the cwd's `app.config.ts`.
- GET responses are JSON; pipe into `jq`.

### Routes & RPC

```bash
curl -s localhost:$PORT/_voltro/inspect/routes | jq    # web: page tree + render-mode flags
curl -s localhost:$PORT/_voltro/inspect/rpc    | jq    # api: query / mutation / action / workflow list
```

### Subscriptions panel

```bash
curl -s localhost:$PORT/_voltro/inspect/subscriptions | jq
```

Shows every active subscriber with:

- Query name + input
- Subject (who's subscribed)
- Read set (which tables / rows are tracked)
- Frame buffer depth (backpressure indicator)
- Connection age

For "why isn't this updating?" — check the read set. If your mutation writes to a table not in the read set, the subscription doesn't invalidate.

### Workflow runs — `voltro workflows`

`voltro workflows` is the primary surface for inspecting + operating runs:

```bash
voltro workflows list                          # recent runs
voltro workflows show <runId>                  # one run's steps + events
voltro workflows retry <runId>
voltro workflows cancel <runId>
voltro workflows suspend <runId>
voltro workflows resume <runId>
voltro workflows signal <runId> --name approval        # inject a named signal
voltro workflows update <runId> --name …
voltro workflows children <parentExecutionId>
voltro workflows flow                          # the admission queue + ledger
voltro workflows pause|unpause <workflowName>  # stop/restart admission fleet-wide
voltro workflows cancel-many --reason "…"      # DRY RUN until --commit
voltro workflows replay-many --mode redrive    # DRY RUN until --commit
voltro workflows inferences                    # offloaded model calls in flight
```

`inferences` shows what nothing else can: a run parked on an offloaded model call reads `suspended` in the run list with no step row yet, so during a slow provider — the moment you would look — the run list has nothing to say.

Underneath, workflow state lives in the `_voltro_workflow_runs` + `_voltro_workflow_run_steps` tables and is surfaced live by the dashboard's Workflows panel. The same data is reachable over HTTP:

```bash
curl -s localhost:$PORT/_voltro/inspect/workflows/runs | jq            # recent runs
curl -s "localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/steps" | jq   # step-by-step
curl -s "localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/events" | jq  # the run's event log
curl -s "localhost:$PORT/_voltro/inspect/workflows/stats?hours=24" | jq       # bucketed run activity (the dashboard chart)
```

The runs endpoint filters **server-side**, so a triage query over a large run history costs one narrow page instead of the whole table:

```bash
# multi-status + tag search + source + id-prefix + time range — all composable
curl -s "localhost:$PORT/_voltro/inspect/workflows/runs?statuses=failed,cancelled&q=orders&source=workflow-rpc&idPrefix=wfrun_&from=2026-08-01T00:00:00Z&to=2026-08-08T00:00:00Z" | jq
```

`statuses` is a comma list; `q` is a case-insensitive tag substring; `idPrefix` matches the run id **or** the execution id (you never have to know which kind your log line carried); `from`/`to` bound `startedAt`. The dashboard's filter bar sends exactly these params.

`/workflows/stats` returns ~48 buckets over a trailing window (`hours`, default 24, max 168; optional `tag`), each with `started` / `succeeded` / `failed` / `cancelled` counts, plus per-workflow totals. When the window held more runs than the scan cap, the response says `truncated: true` — the chart renders that as a warning, because a silently-truncated chart shows throughput dropping at exactly the moment it spiked.

Each run row carries ID, name, status (running / succeeded / failed / dead), step count, last completed step, and duration. The per-run action endpoint matches `…/workflows/runs/<runId>/<action>` for `cancel` / `retry` / `suspend` / `resume` / `signal` — the `voltro workflows` subcommands and the dashboard's run-detail buttons both POST to these:

```bash
curl -s -X POST localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/retry
curl -s -X POST localhost:$PORT/_voltro/inspect/workflows/runs/<runId>/cancel
```

For dead-letter triage, filter runs by `status`, or open the dashboard's Workflows tab. See [Workflows / Debugging](/docs/workflows/debugging).

## `voltro logs`

The fastest way to see what a running instance is doing. Buffers the last 2000 server-side log records plus every browser console line the dev console bridge forwarded.

```bash
voltro logs                                # last 100 from every running process
voltro logs --tail 50 --level error        # only errors, last 50
voltro logs --since 30s                    # last 30 seconds
voltro logs --filter 'notes.summarise'     # message substring
voltro logs --trace <traceId>              # the WHOLE causal chain for one request
voltro logs --format json | jq '.records'  # machine-parseable
```

Run this BEFORE grepping source — the buffer carries the real error, stack, and rpc tag. The full flag set (`--scope`, `--source`, `--process`, `--no-color`, …) is documented in [Traces & logs from the shell](/docs/observability/cli) — the canonical reference for both commands.

## `voltro traces`

Mirror of `voltro logs` for the distributed-trace buffer.

```bash
voltro traces                              # 20 newest traces, pretty
voltro traces --errors                     # only traces containing an errored span
voltro traces --id <traceId>               # one trace, span waterfall
voltro traces --errors --format json | jq '.traces[]'
```

Workflow: `voltro traces --errors --format json` to find a failure, then `voltro logs --trace <id> --format json` for the full chain (frontend → api → api, in order). The full flag set (`--min-duration`, `--status`, `--process`, …) lives in [Traces & logs from the shell](/docs/observability/cli).

## `voltro cluster`

`voltro cluster status` gives a clustering snapshot of every running api — one row per instance with its `replicaId`, runner address, dialect, CDC flavour, coordination mode, and `server_id`, plus a flag for any SQL runner stuck on `localhost` (a common misconfiguration that silently breaks cross-instance work). Use it to confirm a multi-instance deployment actually formed a cluster rather than N isolated nodes.

```bash
voltro cluster status                 # pretty table across every running api
voltro cluster status --json          # machine-readable (also --format json)
voltro cluster status --process api   # narrow to one named process
```

Like the rest of the inspect family it reads the live `/_voltro/inspect/*` surface, so an api has to be running.

## `voltro test`

```bash
voltro test
voltro test path/to/file.test.ts
voltro test --filter notes
```

Runs Vitest with the framework's preset:

- Auto-loaded global setup (test context, mock providers).
- `STORE=memory` by default for fast isolation.
- Plays well with `@voltro/testing` helpers (mock stores).

### Every vitest flag is forwarded

Anything the command does not interpret itself goes straight to vitest — parsed
by **vitest's own CLI parser**, not a list this wrapper maintains:

```bash
voltro test --coverage
voltro test --reporter=junit --outputFile=reports/junit.xml
voltro test --coverage --reporter=junit --outputFile=reports/junit.xml
```

That covers coverage numbers and a JUnit report for a merge-request widget, which
is what most pipelines want beyond the exit code.

**`--coverage` needs a provider package.** vitest ships coverage providers as
*optional* peer dependencies, so nothing installs one for you. Every app
scaffolded by `voltro create-project` / `voltro add-app` already declares
`@vitest/coverage-v8` beside vitest; an older project adds it once:

```bash
pnpm add -D @vitest/coverage-v8      # or --coverage.provider=istanbul → @vitest/coverage-istanbul
```

`voltro test` checks for it *before* booting vitest and refuses with that
install command, because vitest's own failure (`Cannot find dependency
'@vitest/coverage-v8'`) names neither the flag nor the fix.

The framework keeps three decisions for itself and they win over a forwarded
flag: the **root** (a positional that is an existing directory, which vitest
would otherwise read as a filter), `--watch`, and `passWithNoTests` — an explicit
filter that matches no file is an *error*, which vitest cannot decide because it
does not know which positional was treated as a root.

An unrecognised flag is ignored rather than fatal, and a flag vitest cannot parse
at all degrades to "run without the extra flags" with a warning instead of taking
the run down.

The actual test runner is Vitest; this command is a thin wrapper that injects the framework's config. You can run vitest directly if you prefer:

```bash
pnpm vitest
```

## `voltro e2e`

```bash
voltro e2e            # current dir
voltro e2e apps/web   # explicit web app directory
```

`voltro e2e` takes only an optional path to the web app; it parses no other flags. Boots:

1. `voltro dev` for the api app.
2. `voltro dev` for the web app.
3. Runs every file matching `e2e/**/*.spec.ts`, one process each, as a **plain tsx script** (`node --import tsx <file>`).
4. Tear down: stops the boot processes.

**There is no test runner and no browser driver here.** A spec is an ordinary TypeScript program: it runs top to bottom, and a non-zero exit code (an uncaught throw, `process.exit(1)`, a failed `node:assert`) is a failed file. The framework ships no `describe`/`it`, no `page` fixture, no reporter, no sharding, and no browser — because what `voltro e2e` actually contributes is the *lifecycle*, and the lifecycle is the same whichever driver you pick.

Two environment variables are handed to every spec:

| Variable | Value |
|---|---|
| `WEB_URL` | `http://localhost:<webPort>` — the booted web app |
| `API_URL` | `http://localhost:<apiPort>` — the booted api app |

A spec that only needs the API is just `fetch` plus `node:assert`:

```ts
// apps/web/e2e/signup.spec.ts
import assert from 'node:assert/strict'

const res = await fetch(`${process.env.API_URL}/v1/signup`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email: 'a@b.com', password: 'correct horse battery staple' }),
})

assert.equal(res.status, 200)
console.log('✓ signup accepted')
```

To drive a real browser, bring your own driver and launch it inside the spec — the framework does not choose one for you, and does not install one:

```ts
// apps/web/e2e/signup-browser.spec.ts
import assert from 'node:assert/strict'
import { chromium } from 'playwright-core'   // your dependency, not the framework's

const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(`${process.env.WEB_URL}/signup`)
await page.fill('[name=email]', 'a@b.com')
await page.fill('[name=password]', 'correct horse battery staple')
await page.click('button[type=submit]')
await page.waitForURL(/\/dashboard/)
assert.ok(page.url().includes('/dashboard'))
await browser.close()
```

Configure the boot in `app.config.ts`:

```ts
export default {
  type: 'web' as const,
  name: 'web',
  e2e: {
    apiDir: '../api',            // relative to the web app root
    pattern: 'e2e/**/*.spec.ts',
    apiPort: 4000,
    webPort: 5173,
  },
}
```

Anything below the browser — a handler, a guard, a REST route, an `Idempotency-Key` replay, the `x-tenant` header — is faster and more precise from a [request-level test](/docs/testing/unit-testing#request-level-testing-maketestapp), which needs no booted process at all. Reach for `voltro e2e` when the thing under test *is* the two processes talking to each other.

## Securing the inspect surface

The surface is **fail-closed**: with no `VOLTRO_INSPECT_TOKEN` configured, every endpoint answers `401`. The absence of a secret is not consent — a check you were configured not to perform is a refusal, not a pass.

That is why `voltro dev` mints one for you (above) and why nothing mints outside dev: in production a missing secret must stay a boot-time decision rather than an invented value. So a production `voltro start` serves nothing here until you set the token yourself:

```bash
VOLTRO_INSPECT_TOKEN=$(openssl rand -hex 32)
voltro start apps/api
```

Then every request must carry the token:

```bash
curl -s -H "Authorization: Bearer $VOLTRO_INSPECT_TOKEN" \
  localhost:4000/_voltro/inspect/rpc | jq
```

Or disable the surface entirely: `VOLTRO_INSPECT=off`. The dashboard then can't introspect the production instance — that's intentional.

## Anti-patterns

- **Assuming the surface is open because you didn't configure it.** It is the reverse: no token means `401`, not "everyone". If a production dashboard suddenly stops introspecting, the missing `VOLTRO_INSPECT_TOKEN` is the first thing to check — not a network problem.
- **`voltro test` against `STORE=postgres` by default.** Slower + flaky (test isolation harder). Use postgres only for integration tests that NEED it.
- **Skipping `voltro e2e` because "it's slow".** It catches integration bugs that unit tests miss. Run it in CI on every PR; locally for changes that touch queries.

## See also

- [Workflows / Debugging](/docs/workflows/debugging) — the dashboard's workflow panel
- [Self-hosting](/docs/deployment/self-hosting) — production observability setup



---

<!-- source: en/cli/data.md -->
## Data (export / import / backup / restore)

_voltro data — portable, resumable export/import of your app's data + assets (typed-NDJSON, content-addressed blobs), plus native backup/restore via the vendor tools._

`voltro data` moves your app's data and assets in and out. It has **two families**, because backup and portability are different jobs with different right answers:

- **Logical** — `export` / `import`: a portable, resumable, dialect-agnostic bundle. Use it for data takeout (GDPR), seeding staging from prod, or migrating across SQL dialects.
- **Native** — `backup` / `restore`: orchestrates the vendor tools (`pg_dump`, `mysqldump`, …) for a lossless, point-in-time same-dialect backup. Use it for disaster recovery.

Both stream — a table or a blob is never fully held in memory — and both survive interruptions.

## Logical export

```bash
voltro data export ./backup-2026-07-01
voltro data export ./out --tenant org_abc            # one tenant + its FK closure
voltro data export ./out --tables users,posts         # an explicit set
voltro data export ./out --exclude cluster_locks       # everything else
voltro data export ./out --assets                      # include stored blobs
voltro data export ./out --compression gzip            # zstd (default) | gzip | none
voltro data export ./out --consistency snapshot        # point-in-time (see below)
voltro data export ./out --target api --api-url https://api.example.com --token $SECRET  # export FROM a live instance (in-process)
voltro data export --target api --api-url https://api.example.com --token $SECRET --bundle-key backups/2026-07-01.vbundle  # instance exports straight to storage (scale)
```

The output is a **directory bundle**, not a zip — each table is an independent file, so an interrupted run resumes cleanly and per-table compression stays effective (a single zip would fight both). Over `--target api` this same bundle is streamed as one framed `.vbundle` archive (a zero-dependency concatenation, not a zip) and unpacked back into the directory on the other end.

```
backup-2026-07-01/
  README.md                  # human summary: date, source host, dialect, scope,
                             # table/row counts, and a LOUD real-vs-masked banner
  manifest.json              # format version, source dialect + schema fingerprint,
                             # per-table column types + row counts + checksums
  data/
    users.ndjson.zst         # one typed-encoded row per line, framed-compressed
    posts.ndjson.zst
  assets/
    index.ndjson             # key → sha256 → size → content-type
    9f86d081…                # blobs, content-addressed by sha256 (auto-deduped)
  .ledger.json               # resume checkpoint (which tables/assets are done)
```

`README.md` is a human-readable sidecar written on every export — provenance (when, from which host, which dialect, scope) plus a prominent **data-sensitivity banner**: it says outright whether the bundle holds real (unmasked) data or was masked at the source (and which columns). It's ignored on import (the importer only reads files listed in `manifest.json`) and never contains a connection string or credentials. It rides inside the `.vbundle` archive too.

### Single-file bundles (`.vbundle`) — one artifact, any size

Give the export/import a path ending in **`.vbundle`** and you get (or read) **one self-contained file** instead of a directory — everything (tables + blobs + manifest + README) in a single artifact you can copy A→B:

```bash
voltro data export ./snapshot.vbundle --assets            # one file, blobs included
voltro data import ./snapshot.vbundle --assets            # stream it back in
voltro data inspect ./snapshot.vbundle                    # peek at the metadata WITHOUT extracting
voltro data unpack ./snapshot.vbundle ./out               # explode a file to a dir to look inside
```

`voltro data inspect` prints the bundle's `README.md` (date, source host, dialect, scope, table/row counts, and the real-vs-masked banner) — read via an **early-stop peek** that stops after the metadata, so it stays fast even on a 200 GB bundle (add `--json` for the machine-readable `manifest.json`, `--passphrase` for an encrypted bundle). `voltro data unpack` fully extracts a file to a directory (blobs materialised under `assets/`), decrypting with `--passphrase` when needed.

It's built to work at **any size (200 GB+) without needing that much scratch space**. The pipeline **streams end-to-end**: on export, blobs are pulled from object storage straight into the file (nothing staged locally — peak local disk is just the tables); on import, blobs stream straight from the file to the destination's object storage (never unpacked to a temp dir first). The file is a framed archive (`.vbundle`, a zero-dependency streamable format — not a zip, whose central-directory-at-the-end design would force downloading the whole thing before reading entry one). Blobs are named by storage key with a per-blob sha footer, so **integrity is per-entry** and a resumed transfer **skips blobs already present** at the destination.

### Encryption at rest (`--encrypt`)

A `.vbundle` is plaintext by default. For a copy that must be confidential at rest (a backup in a bucket, a file on a laptop/CI, an **unmasked** dump — which contains your `.encrypted()` columns as plaintext), encrypt it:

```bash
voltro data export ./backup.vbundle --assets --encrypt --passphrase "$BUNDLE_KEY"
voltro data import ./backup.vbundle --assets --passphrase "$BUNDLE_KEY"
voltro data unpack ./backup.vbundle ./inspect --passphrase "$BUNDLE_KEY"
```

- **A dedicated key, NOT the transfer secret.** The passphrase comes from `--passphrase` or `VOLTRO_BUNDLE_KEY` and is independent of `VOLTRO_DATA_TRANSFER_SECRET` (auth ≠ encryption; different instances have different transfer secrets; a local export has none). It's stretched with **scrypt** (salt + params in the file header).
- **Streaming, authenticated AES-256-GCM.** The bundle is encrypted in chunks (the age/Tink STREAM construction), so it stays streaming + resumable at 200 GB, and it's **tamper- and truncation-evident**: a wrong passphrase, a flipped byte, or a dropped final chunk all fail decryption loudly — never a silent partial import.
- **Complementary to masking.** Masking makes the *data* safe for a lower environment (strips PII); encryption makes the *artifact* confidential. A DR backup wants encryption (full real data); a prod→dev copy wants masking (and can add encryption too).

### Why NDJSON, and why it's exact

Rows are written as newline-delimited JSON — streamable, resumable by line, and human-inspectable. A naïve `JSON.stringify` would corrupt data, so a **typed codec** (driven by the column types in the manifest) handles the values JSON can't:

- `bigint` → preserved exactly (never truncated to a float)
- `NaN` / `Infinity` → preserved (JSON would turn them into `null`)
- `bytes` → base64; `timestamp`/`date` → ISO-8601 → `Date` on import
- `json` / `array` / `vector` → structured, verbatim

### Scope

| Flag | Selects |
|---|---|
| *(none)* | Every table the app has DATA in (the default) — see the note below on the handful that describe a deployment rather than filling it. |
| `--tenant <id>` | Every `tenant()`-scoped table filtered to that tenant, **plus** the FK closure in BOTH directions: (1) the transitive **FK-parent** closure of those rows — closure-pulled shared tables (a global `users` / reference table) are **row-subset to the ids the tenant's rows actually reference**, never exported in full; and (2) the **child** closure — rows that *reference* the tenant's rows (the comments on the tenant's projects) come along too, each scoped to the ids that actually point into the tenant set. The child walk is anchored on the `tenant()` tables, so a row that references **only a shared parent** (a global `users` another tenant also references) is **not** pulled — that would be a cross-tenant leak. A `--tenant` bundle therefore carries the tenant's parents AND children and **no other tenant's rows** — that's what makes it safe as a GDPR / per-tenant takeout. |
| `--tables a,b` | An explicit set (you own referential integrity; the importer's deferred-FK resolution covers load-order dangles, see below). |
| `--exclude a,b` | Everything EXCEPT these. The scope stays `all` and records what was left out — so the deployment-describing tables are still filtered, and `replace` still accepts the bundle. Works on both targets (the instance resolves it against its own live list). Cannot be combined with `--tables` or `--tenant`; a name that does not exist is refused, because an exclusion that excludes nothing leaves the run looking like it worked. |

#### What `all` deliberately leaves out

A number of framework tables hold rows **about a deployment** rather than an app's data. The test is: would a row from elsewhere make this target *act* — send, run, admit, refuse, skip a delivery — or assert something untrue about its own history? That covers its migration ledger, file-migration and seed records, CDC offsets and change log, schedule claims, wakeups and firing history, workflow watermarks / pending starts / admissions / pauses / delivered events, its outbox and delivery attempts, its idempotency keys, its storage grants, its spend and usage accounting, and its own traces and undo log. They are dropped from `all`, skipped on import, and never emptied by a `replace`, and the run says which ones and why.

Every framework table is classified one way or the other, and a new one fails the build until somebody decides — the classification used to live in two places, the module and a hand-kept copy in its test, and the two disagreed about exactly the tables that later caused trouble.

The reason is worth one paragraph, because it cost a real environment ninety minutes. A `scope: all` bundle carried `_voltro_migration_plans`, `replace` wrote it, and the target's next boot refused:

```
auto-migrate: SCHEMA FINGERPRINT MISMATCH — declared=6e2c61081a9ed80c  live=28af9a54414f22f1
```

The refusal was right. That fingerprint is computed over the **declared table set**, so the imported row was not out of date, it was **foreign**: it stated a schema decision made somewhere else. (The declared set could also differ per environment then, because `NODE_ENV` decided two of the tables. It no longer does — but that removes one *way* for two deployments to differ, not the reason a foreign ledger row is wrong.) Two of the others would have made the target *act* — a pending workflow start runs a workflow somebody queued elsewhere, a pause silently stops one here.

`all` is the only scope filtered. **Name one of these in `--tables` and you get it** — an explicit name is an expectation, and this command refuses to drop those silently.

**Every table needs a single-column primary key.** The export is keyset-paginated, so it orders by one column and resumes from the last value on the next page. That column comes from the real primary key — a declared `id()` where there is one, otherwise the table's actual PK, whatever it is called.

A table with a **composite** primary key, or none at all, is **refused by name** rather than exported. Ordering by one column of a composite key splits equal values across page boundaries, which drops or duplicates rows into a bundle that reports success — and a short backup is discovered at the restore. Leave such a table out with `--exclude`.

Tenant-scope details:

- **A tenant scope without a tenant id refuses loudly** (`ScopeError`): pass `--tenant <id>` (CLI) or `scope.tenantId` (API/profile). It never falls back to an unfiltered export.
- **Which tables count as tenant-scoped** comes from the `tenant()` mixin metadata when the CLI / admin endpoint can read the declared schema (authoritative — a table can carry a `tenantId` column *without* being tenant-scoped, e.g. a global `users` table's active-org pointer). Without that metadata the exporter falls back to a documented heuristic: any table with a `tenantId` column.
- A **cross-tenant reference** (a tenant-A row pointing at a tenant-B row) is never followed — the bundle stays tenant-clean and the reference dangles; importing such a bundle reports it loudly (`RowsRefusedError`) unless the target already has the row.

### Consistency: `live` vs `snapshot`

- **`live`** (default) — each table is read in short keyset-paginated chunks. Resilient and easy on the database, but the tables are read at slightly different instants (a concurrent write can leave a child whose parent you already passed; the importer's deferred-FK resolution handles the dangling reference — see below).
- **`snapshot`** — every table is read inside **one transaction pinned to a single MVCC snapshot** (per-dialect isolation prelude), so the whole export is a consistent instant. The trade: that transaction is held open for the export's duration.

## Logical import

```bash
voltro data import ./backup-2026-07-01
voltro data import ./out --mode append --on-conflict skip   # insert-only
voltro data import ./out --mode replace --atomic            # full refresh, all-or-nothing
voltro data import ./out --target api --api-url https://api.example.com --token $SECRET  # upload to a live instance
voltro data import --target api --api-url https://api.example.com --token $SECRET --bundle-key backups/2026-07-01.vbundle  # instance pulls from storage (scale)
voltro data import ./out --assets            # also restore blobs
voltro data import ./out --no-verify         # skip checksum/row-count verification
voltro data import ./out --force             # import despite schema drift AND cross-dialect warnings
voltro data import ./out --tables users,teams   # load only these tables out of the bundle
voltro data import ./out --dry-run              # report what would move; write nothing
```

**`--dry-run` and `--tables` work on BOTH targets**, including `--target api`. On the api
path they travel as `x-import-dry-run` / `x-import-tables`, and the response echoes
`{ "dryRun": true, "wrote": false }` so a preview is never mistaken for a write.

A dry run reaches every verdict a real run reaches — schema fit, cross-dialect portability,
mode legality, the table selection — and stops at the first line that would write. It does
NOT read per-table checksums (those stream during the load) and cannot see a conflict that
depends on the target's current rows; the command says both out loud, because a preview
over-read is worse than no preview.

`--tables` names tables the BUNDLE carries. A name it does not carry is refused, listing
what it does — a silently-ignored table name is how a run scoped to one table writes the
whole bundle.

### Every `import` option, in one place

| Option | Default | What it does |
|---|---|---|
| `--mode upsert\|append\|replace` | `upsert` | How rows are written. See the table below. |
| `--on-conflict skip\|fail` | `skip` | `append` only: what to do when the primary key already exists. |
| `--atomic` | **on for `replace`**, off otherwise | Wrap the whole table phase in ONE transaction — readers see the import all-or-nothing. |
| `--no-atomic` | — | Opt out of that. See the trade below. |
| `--tables a,b` | every table | Import only these tables from the bundle. |
| `--dry-run` | off | Run every pre-flight and report what WOULD move; write nothing. |
| `--force` | off | Proceed despite schema drift AND cross-dialect warnings. |
| `--no-verify` | off | Skip per-table checksum verification (direct target only). |
| `--allow-live` | off | Override the refusal to write directly into a database a live instance is serving (direct target only). |
| `--assets` | off | Restore blobs as well as rows. |
| `--passphrase <s>` | — | Decrypt an `--encrypt`ed bundle (direct target only; over the api the CLI decrypts before sending). |
| `--target direct\|api` | `direct` | Write straight to the database, or through a running instance's admin endpoint. |
| `--api-url <url>` / `--token <secret>` | — | Required by `--target api`. The token must equal the instance's data-transfer secret. |
| `--bundle-key <key>` | — | `--target api`: have the instance PULL the archive from object storage instead of uploading it. |
| `--chunk-size <mb>` | 16 | `--target api`: bytes per upload chunk. Chunking engages automatically above one chunk. |
| `--timeout <seconds>` | none | `--target api`: give up waiting for the instance. Default is to wait as long as the import takes. |
| `--json` | off | Machine-readable result. |

A flag this subcommand does not read is refused, not ignored — see below.

### A flag this command does not read is an ERROR

Every `voltro data` subcommand declares the flags it reads, per target, and refuses anything
else instead of ignoring it:

```text
✗ voltro data import: --no-verify is not read with --target api (it is a --target direct
  flag). Remove it, or change --target.
✗ voltro data export: --mode is not a `export` flag (it belongs to `voltro data import`).
✗ voltro data import: unknown flag --drynrun. Run `voltro data --help` for the flags this
  command reads.
```

The refusal happens before anything boots, so a mistyped flag costs you a message rather
than a run. On a command whose job is moving data into a live system, silence is the wrong
default: an accepted-and-ignored flag turns a typo into a no-op whose only evidence of
working is that nothing complained.

Import is **integrity-checked** (each table's checksum + row count verified as it decodes; each asset re-hashed against its content address), applies tables in **FK-parent-first order**, and a resumed run skips already-applied tables via the ledger.

### Postgres targets bulk-load via COPY

On a **postgres** target, the direct import switches to `COPY … FROM STDIN`
wherever plain-INSERT semantics provably hold: `--mode replace` (the tables were
just truncated) and the default `upsert` into a table that is **empty** at
import time — the fresh-target shape every cross-dialect migration
(mysql → postgres, sqlite → postgres, …) lands in. Measured on a 7-column table
(text/int/bool/jsonb/timestamptz) with 50 000 rows against a local postgres:
row-by-row **12.8 s (~3.9 k rows/s)** vs COPY **0.59 s (~84.6 k rows/s)** —
**21.7× faster**. Your factor depends on row width and network latency;
COPY's advantage grows with per-row round-trip cost.

Everything else keeps the per-row `DataStore` writes: `upsert` into a non-empty
table (COPY cannot upsert), `append` (per-row conflict handling), `--atomic`
(the COPY connection would sit outside the transaction), and every other
dialect. A refused COPY batch (an FK the deferred pass repairs later, a value
COPY text can't carry) is atomic — nothing landed — so the importer replays
exactly that batch through the per-row path and continues; semantics are
identical, only the speed differs.

### Schema-drift pre-flight

The question the pre-flight asks is **"will the rows this bundle carries fit this target?"** — not "are these two schemas identical". It compares the **intersection**: for every table the bundle carries, the columns and types must exist on the target, and a column the target REQUIRES (NOT NULL, no default) that the bundle carries no value for is refused too. On a problem it **refuses, fail-closed, before any row lands**:

```text
✗ schema drift: refusing before the table phase.
    table 'items' is in the bundle but MISSING from the target — its rows have nowhere to go
    orders.total: type 'integer' (bundle) vs 'text' (target)
    users.region: the target requires it (NOT NULL, no default) and the bundle carries no
      value — every row of this table would fail
```

Every line is something that would break the load. Tables and columns the target has and
the bundle does not are **untouched by definition** and are never reported — that is the
normal shape of any cross-environment seed, and refusing on it would make `--force` the
routine way to run an import and take the protection with it.

Both fingerprints are still reported (they are what you paste when asking for help), and an
identical pair is a fast path that skips the comparison. But **differing fingerprints are
not on their own a refusal**: a bundle's fingerprint covers its SOURCE schema regardless of
export scope, so a one-table export out of a 75-table database carries the 75-table
fingerprint.

**`--force`** downgrades the refusal to a **loud warning** and proceeds. Over the
`--target api` path the same check runs **on the instance** against its declared schema and
returns **`409` schema drift** with the fingerprints + diff; `--force` sends
`x-import-force: 1` to override.

The pre-flight only runs when the importer has a target schema (the CLI introspects it; the API endpoint uses the instance's declared schema). Importing into a fresh/empty database with no comparable schema simply skips the check.

### `replace` writes down what it is about to destroy

Before the first delete, a `replace` exports the target's **current** rows for exactly the tables it is going to empty, as an ordinary bundle beside yours:

```
rollback capture: 240172 row(s) across 75 table(s) → ./out.rollback-2026-08-21T09-10-11-000Z
  If this run does not finish, restore with:  voltro data import ./out.rollback-… --mode replace
```

It is on disk **before** anything is destroyed, so it does not depend on a transaction, or on the process being alive to roll one back. That distinction is the whole reason it exists: a deployment lost 240 172 rows to a `replace` whose api pod disappeared nine minutes in, and recovered from an export they had taken twenty minutes earlier out of habit. This is that habit, made into the tool's behaviour.

**It is fail-closed.** A capture that cannot be taken stops the import before it starts, and the target is untouched. A safety net you believe in and do not have is worse than none — the belief is what stops you taking your own export.

`--no-rollback` turns it off, and `--rollback-dir <path>` puts it somewhere else. It is only taken for `replace`: `upsert` and `append` do not destroy, so there is no moment where the old state has silently become unreachable.

**Over `--target api` it goes to the instance's object storage**, because there the process that would roll a transaction back IS the instance — a capture in the pod's filesystem would go away with the failure it exists for. Name a key:

```bash
voltro data import ./out --target api --api-url <url> --token $SECRET --mode replace --rollback-key backups/before.vbundle
```

The instance writes the capture there **before the first delete**, and refuses the run (409) if it has no storage configured — asking for a capture and being served without one is the answer that removes your own precaution while looking like agreement. A `replace` that names no key still runs, and says what it did not keep.

### `replace` loads somewhere else first

The target keeps its rows until the load stands. `replace` creates a staging
table per table, loads the bundle into those, and then swaps the CONTENT across
in one short transaction of server-side SQL. It says so when it does:

```
staging 114 table(s) before the swap — the target keeps its rows until the load stands.
  The destructive step is one server-side transaction at the end, not the whole load.
```

The difference is what a dead process costs. Loading straight into the target
holds the destructive transaction open for the whole load — minutes, for a large
bundle — and the target only survives because the database rolls that
transaction back. With staging, a process that dies during the load leaves the
target exactly as it was, because nothing has been deleted yet.

Not every run can take it, and a run that cannot **says why** rather than quietly
taking the slower path:

- a store the framework cannot send DDL to (the in-memory store).
- a **write recorder** on any table in the set — `rowHistoryPlugin({ timing:
  'in-transaction' })` and friends. A recorder is keyed by table name, so a
  staged write would find none and the recorder would silently not run. Its
  promise is "if the change committed, the entry is there", so the run keeps the
  path that can keep it.

**`--no-atomic` stages too, and that is where it changes the most.** The flag
exists for resumability on a large bundle, and it used to be the mode with the
worst failure: the target emptied and partially refilled, in neither state.
Staged, the ledger keeps its exact meaning — a recorded table is one fully
loaded, it just lands in staging — while the target stays untouched until the
swap. Resumable and all-or-nothing at once, which the two could not be before.

A resumed run continues from what it already staged rather than reloading it,
and a run whose swap has not happened keeps its staged rows and says so:

```
the staged rows are KEPT so a re-run can continue from them rather than reload.
  If you are not going to re-run this bundle, drop them: voltro data clear-staging --yes
```

**A failed swap names every offending row, not the first.** Staging carries no
foreign keys — a staged row whose parent has not been staged yet must not be
refused — so a dangling reference surfaces at the swap, where the database
reports one constraint. The importer then asks staging the same question and
lists every row that fails it:

```
the swap could not run: 2 row(s) in the bundle reference a row the bundle does not carry.
    tasks.t_41: ownerId = "u_9" — no such row in users
    tasks.t_88: ownerId = "u_12" — no such row in users
  The target is UNCHANGED — the swap runs in one transaction and none of it committed.
```

Staging tables from a run that died mid-load are collected by the next
`replace` over the same tables. One over a DIFFERENT set leaves them, and
nothing else removes them:

```bash
voltro data clear-staging --yes
```

Deliberately a command and not a boot sweep: a booting process cannot tell a
leftover from a staging table another replica is loading into right now, and
deleting the second would destroy an import in flight.

A **cycle** in the bundle's foreign keys is detected before the load, not after
it. The swap inserts parents first, so two tables referencing each other cannot
both be satisfied by a bulk copy on postgres, sqlite or SQL Server — `SET
CONSTRAINTS ALL DEFERRED` does not help, because postgres only defers a
constraint declared `DEFERRABLE`. Such a run says so and takes the row-by-row
path, whose deferred-FK pass exists for exactly that shape. A table referencing
ITSELF is not a cycle: one statement carries the whole table.

### A `replace` does not write per-row history

Write recorders — `rowHistoryPlugin({ timing: 'in-transaction' })` and anything
else registered through the same seam — are **suspended for a `replace`**. A
replace sets a state; it does not change rows, so a per-row history entry would
describe something that did not happen. On a large bundle that is not a detail:
one import wrote 242 950 history rows for a deployment, doubling the write load
of their most expensive run.

The reasoning is not the cost. This path already writes through the raw store —
no tenant scoping, no row filter, no `audit()` stamping — and the recorder fired
anyway because it sits one layer below. Suspending it makes the layers agree.

`upsert` and `append` still record: those CHANGE existing state, which is what a
recorder is for. Every suspended run says so, and the suspension is scoped to the
run rather than the process, so requests served alongside it keep recording.

Instead of per-row history, the operation records **itself**. One row in
`_voltro_data_transfers` per run — in either direction: the mode, the transport, the bundle, the source
deployment's schema fingerprint, the counts — and the failure, for the run you
are usually looking for. A trail that only records successes goes quiet exactly
when it is needed.

It is best-effort, unlike the interrupted-replace marker: that one is a safety
interlock and a run which cannot write it must not proceed, while this is
history. A target whose schema is not migrated yet still imports, and says the
trace could not be written.

### Every request fits a budget

Some environments cap a single request: a job runner that kills a client after
ten minutes, an ingress with a 30-second ceiling, a CI step with a deadline. A
whole transfer may still take an hour — it just has to do so as a series of short
requests, each individually abandonable and individually retryable.

The rule the endpoints follow:

> **A request that carries bytes never runs a transfer. A request that starts a
> transfer never carries bytes.**

```bash
# Nothing here may take longer than 30 seconds per request.
voltro data import ./bundle --target api --api-url https://app.example \
  --mode replace --max-request-seconds 30

# Or: start it and walk away. The outcome is NOT known when this returns.
voltro data import ./bundle --target api --api-url https://app.example --detach
```

Both staging areas — the uploaded bundle on its way in, the produced one on its
way out — sit in the system temp directory by default and move with
`VOLTRO_IMPORT_UPLOAD_DIR` / `VOLTRO_EXPORT_ARTIFACT_DIR`. Worth setting on a
container: a bundle is the size your database is, and a default `/tmp` is
frequently a small tmpfs.

`--max-request-seconds` is DECLARED rather than probed. The thing that kills a
request is a policy on your side, and only you know it — in the one environment
this was measured against, the ingress would happily hold a connection for an
hour and the caller's own toolchain killed the client at ten minutes.

**An import is three steps.** The bundle goes up (in 16 MiB chunks when it is
big, resumable, one plain request when it is small); `import/start` begins the
run and answers as soon as the run is recorded; then the client watches the
record. `start` is idempotent — retrying it under a budget returns the run that
is already going rather than beginning a second destructive one.

**An export is three steps too.** Ask, wait for the record, then fetch the bytes
in ranges from `GET /_voltro/admin/export/download`. The archive used to arrive
in the response body, which made the request last as long as reading your whole
database.

**`--detach` returns once the run has started** and says so plainly: exit `0`
there means "it started", not "it worked". Attached, the exit code comes from the
run's record — `0` finished, `1` failed, `2` still going when you stopped
watching. Ctrl-C loses the watching and never the run.

### Watching a transfer you cannot see

Over `--target api` the run happens INSIDE the instance. Everything it decides
— whether it stages, whether recorders are suspended, how far it has got — is
printed in the pod's log, and someone reaching for `--target api` is by
construction someone who cannot reach the database directly and usually cannot
read that log either. The run is also deliberately decoupled from the caller:
killing the client does not stop it, which is what keeps a dead client from
leaving a half-emptied target. Both properties are right, and together they used
to mean an operator could neither see the run nor learn how it ended.

Two things close that, and neither is a streamed response on the upload
connection. That connection is the thing an operator is most likely to lose: a
client killed by a job timeout while the import runs on inside the instance is
the ordinary case, and a stream would go quiet at exactly the moment somebody
needs to know what happened. A poll can be run from a different machine than
the one that started the import.

**Ask before you upload.** A `replace` over the api asks the instance what it is
going to do, before a byte of the bundle goes up. The instance answers from the
same function the run itself calls, so the answer cannot drift from the run:

```
$ voltro data import ./bundle --target api --api-url https://app.example --mode replace
api replace: the instance WILL stage — 104 table(s) load into copies and the
  target keeps its rows until one short swap at the end. Interrupting the load leaves the target intact.
  importing — 41200 row(s) across 18 table(s) (staged; the target still holds its own rows)
  done — 228866 row(s) across 104 table(s)
```

With `--bundle-key` the client never holds the bundle, so it sends the key and
the instance reads the table list out of the archive itself — the caller about to
have an instance empty its own database is the last one who should be told to
check a log they cannot read.

If it will not stage, the line says so and names the reason — a bundle table the
target does not have, a foreign-key cycle, a dialect this build cannot stage on.
That is the difference between "interrupting this is safe" and "interrupting
this empties the target", which is exactly the decision an operator is making
while they watch.

**Read the run from anywhere.** `_voltro_data_transfers` carries one row per run
in EITHER direction: opened before the first table, advanced every couple of
seconds as tables land, closed with the outcome — so polling it IS the progress
feed:

```
$ voltro data transfers --target api --api-url https://app.example
1 import(s) IN FLIGHT — re-run this to watch the counters move
  2026-08-23T09:04:01.000Z · replace — started and never reported finishing · via api · staged (target untouched until the swap) · from /tmp/b
  2026-08-23T08:00:00.000Z · export all — 228866 row(s) across 104 table(s) · via api · from backups/nightly.vbundle
```

Same data over `GET /_voltro/admin/transfers?limit=20`, behind the same
data-transfer secret (the row names bundles and schema fingerprints). It works
from a different machine than the one that started the import, and through
anything that forwards a GET.

The `staged` part is a separate claim from the preflight's, and both are needed.
The preflight says what the instance WILL do; this says what it did. Without it
the two could only be closed by reading the pod's log, which is the one place a
`--target api` caller cannot reach.

### An interrupted `replace` cannot be silent

The capture only helps if somebody knows to reach for it. A half-replaced database is indistinguishable from an empty one **from the inside** — every table exists, every constraint holds, every query returns nothing without erroring — so a run that emptied a target and disappeared can be served over for hours before anyone asks the right question.

So a `replace` writes one row before the first delete and removes it after the last insert. Finding it at boot is a **refusal**, not a warning:

```
refusing to start: a destructive import did not finish.
  - a `replace` over api began emptying 114 table(s) 12 minute(s) ago and never reported finishing.
    The target's previous rows were captured first:
      ./out.rollback-2026-08-21T09-10-11-000Z
    Restore them with:  voltro data import ./out.rollback-… --mode replace
```

The row lives in the same transaction as the emptying, so it is present **exactly when the emptying is**: a run that rolls back cleanly takes the marker with it, and a boot over a database nothing happened to is not refused. A completed `replace` clears its own marker and any older one — so the recovery import both restores the data and silences the alarm, in one command.

Nothing expires. A half-replaced database does not become whole with time, so clearing it is a decision:

```bash
voltro data clear-replace-marker --yes
```

The devtools import panel takes the same two precautions as the command line — a capture before the first delete, and the marker — because a button is easier to press than a command is to type.

### Deferred-FK resolution

A row whose write fails on a foreign-key constraint — a forward reference from a `live`-consistency export, a genuine FK **cycle** between tables (which the exporter orders by breaking the closing edge), or an intra-table self-reference to a later row — does **not** fail the import. It is held and resolved after every table has streamed:

1. **Retry to a fixpoint** — forward references resolve once the later tables landed.
2. **FK-shedding** — rows still stuck are written with their FK-bearing columns set to `NULL` (possible wherever those columns are nullable), which breaks row cycles on every dialect without session-level constraint toggles.
3. **Patch pass** — shed rows are re-written with the full bundle row, restoring the FK values.

Anything still unresolvable — the parent row exists in **neither the bundle nor the target**, a `NOT NULL` FK cycle, or a row the target refuses for a reason of its own — fails with a typed `RowsRefusedError`. Held rows are the exception set, not the data set: memory is bounded by how many rows dangle at load time.

#### Reading a refusal

The error carries three things, and they answer different questions:

| Field | What it is |
|---|---|
| `totalCount` / `primaryCount` | how many rows were refused, and how many of those are the actual failures. A row is **derived** when one of its reference columns points at another row that also failed — it could not have landed whatever it contained, so it says nothing about itself. In an FK-dense bundle these dominate. |
| `byTable` | **complete** per-table counts (`refused`, `primary`), worst first. |
| `rows` | up to 20 refused rows — primary ones first — each with `table`, `id`, `reason`. |

`rows` is **capped and `byTable` is not**, and that distinction is worth one sentence: tallying the tables in the printed list answers "how big is the cap", not "which tables failed". The CLI prints the `byTable` line above the list and says so when the list is truncated:

```
import refused 35 row(s), of which 35 are the actual failures — the rest could not land because a row they reference did not.
  by table: vacations 26, weeklyUpdates 9
  vacations v1  not-null constraint userId: the column requires a value [ER_BAD_NULL_ERROR/1048]
  …
  (the list above is capped at 20 of 35 — the counts by table are complete)
```

A refused import ends with a **non-zero exit status and that report** — not with a stack trace. A refusal is a condition with a named cause, not a framework defect, so it is not dressed as one. The report is the same on both transports: over `--target api` the refusal crosses as a 500 whose message embeds it, and the CLI renders that rather than printing the body raw.

**`RowsRefusedError` keeps its tag in every mode, `--atomic` included.** That is worth stating because it is the mode `replace` uses by default: the whole table phase runs in one transaction, and rolling that transaction back needs a rejection — but the rejection carries the typed error, not a rendering of it. So `Effect.catchTag('RowsRefusedError', …)` works on the default path, which is the one most likely to raise one.

One trap if you consume `runImport` yourself: what `Effect.runPromise` **rejects** with is a `FiberFailure`, and `err._tag` on one of those reads `undefined` however good the error inside is. Use `Effect.catchTag` on the effect, or `asImportError(err)` (exported from `@voltro/data-transfer`) on the rejection — reading the tag off the caught value takes the "not my error" branch every time.

A row's `reason` is stated as precisely as the driver allows, in three tiers:

1. **the rule, in your schema's vocabulary** — `unique constraint PRIMARY: a row with this value already exists [ER_DUP_ENTRY/1062]`, `foreign key teams_ibfk_1: the referenced row does not exist (import it first, or check the bundle's table order) [ER_NO_REFERENCED_ROW_2/1452]`. The driver's own code is appended, because that is what you grep a log for;
2. **the driver's own message + code**, for anything it refused that is not one of the five integrity rules — `Data too long for column 'v' at row 1 [ER_DATA_TOO_LONG/1406]`;
3. **that there was nothing**, when no driver detail is reachable at all: `the database refused the write and the driver gave no detail` — plus `(via …)`, the chain of wrappers, whenever that chain has more than one layer to name. Read this tier as "not a constraint": a guard, a row filter, or a failure whose words did not survive. When it fires, the run also **logs the full rendering** of the first three such rows (table, primary key, and the whole error as it rendered) and counts the rest. That log line is never returned over the wire — it carries our stack frames, and on some engines a driver's sentence carries row data — so on `--target api` you read it in the instance's log.

Unlike the rpc wire, this string DOES include the driver's own words. The audience is the difference: it is read only by whoever ran `voltro data import` — the holder of the data-transfer secret, who supplied the rows and can export the whole target anyway.

### Write modes (`--mode`)

| Mode | Behaviour | Use it for | Conflict |
|---|---|---|---|
| `upsert` (default) | INSERT-or-UPDATE by primary key | sync / idempotent re-import | overwrites per row |
| `append` | INSERT only | additive data (event log, new seed) | `--on-conflict skip` (default) or `fail` |
| `replace` | capture the target, empty it, then INSERT | full refresh — target ends up **exactly** the bundle | — |

`replace` **refuses a partial bundle** (a subset / tenant / table scope): emptying would delete rows the bundle never carried. Re-export with full scope, or use `upsert`. (`append --on-conflict fail` throws on a duplicate primary key only where the store enforces the constraint — every SQL dialect does; the in-memory dev store overwrites.)

#### How `replace` empties the target, and what it refuses

The bundle's tables are emptied **as one unit, in one transaction, with referential integrity suspended for the duration** — not table by table. Both halves matter:

- **All or nothing — the emptying AND the load.** `--mode replace` runs the whole table phase in ONE transaction by default, so a run that cannot finish leaves the target exactly as it found it. That default is a correction: the guarantee used to cover only the emptying, and a replace that died partway through the *load* left the target emptied of its old rows and holding part of the new ones. There is no useful state for a replace to stop in, which is why it is the default rather than a flag you have to know about.

  One transaction also closes a window that is easy to miss. Between the delete and the load the target is empty, and if the database is being served, the application writes into that gap — a row it creates on demand is then a primary-key conflict against the same row arriving from the bundle. With one transaction the concurrent writer waits instead of racing.

  `--no-atomic` opts out, and the trade is real: every write to those tables waits for the load, so on a bundle that takes minutes, so does the wait. On postgres it also re-enables the bulk `COPY` loader, which cannot join a transaction it does not own — an atomic run says so once rather than being quietly slower.
- **No ordering can replace the suspension.** MySQL, MariaDB and SQL Server check a foreign key as each *row* is deleted, so a table that references **itself** cannot be emptied at all by ordering tables — the conflict is between two rows of one of them. `createdBy → actors` on the `actors` table is exactly that shape, and it is what an audit mixin on an actor table produces. (Postgres needs no suspension: a multi-table `TRUNCATE` covers the whole set at once.)

What it will **not** do is reach outside the bundle. If a table the bundle does **not** carry holds rows that reference one it does, `replace` refuses **before deleting anything** and names them:

```
replace cannot empty this target: 1 table(s) OUTSIDE the bundle hold rows that reference
tables INSIDE it. Emptying the bundle's tables would leave those rows pointing at nothing,
and the bundle cannot restore them.
  webhookDeliveries (4021 rows): createdBy → actors, updatedBy → actors
Nothing has been deleted. Either re-export with a scope that includes those tables, or use
--mode upsert if those rows are meant to survive.
```

An **empty** table outside the bundle blocks nothing — a schema always carries tables an environment has never written to.

One consequence of emptying in bulk: the delete itself emits **no change events** (one per row is not affordable at bundle scale). Over `--target api` that is covered — an import through the instance's own process asks every live subscription to re-read once it lands, the same coarse refresh the framework uses after a broadcast gap. Over `--target direct` there is no live instance to tell, which is the whole reason that path is guarded.

#### Which mode for which job

| You want | Mode | Why |
|---|---|---|
| Keep a staging database in step with a production export, re-runnable | `upsert` | Idempotent per row: run it as often as you like, the result is the same. |
| Add an event log / new seed data without touching what is there | `append --on-conflict skip` | Insert-only, and a row that already exists is left alone rather than overwritten. |
| Catch a duplicate instead of silently skipping it | `append --on-conflict fail` | The import stops and names the row. Use when a duplicate means the bundle is wrong. |
| Make a target become **exactly** a bundle — replace an environment | `replace` (full-scope bundle) | Empties the bundle's tables first, so rows the bundle does not carry are gone. |
| The same, against a database that is being served right now | `replace --atomic` | One transaction: readers see the old state until commit, the new one after. Never a half-loaded table. |
| The same, against a live instance, honouring app invariants | `replace --target api` | Runs in the instance's process: validation, mixins, field encryption, hooks, and live subscriptions re-read afterwards. |

Two things `replace` will refuse, both before writing anything: a **partial bundle** (subset / tenant / table scope), because emptying would delete rows the bundle never carried; and a target where a table **outside** the bundle holds rows referencing one inside it, because those rows cannot be put back. Both refusals name what to do instead.

#### Recipe: replace a dev environment from your local database

```sh
# 1. Full-scope export of the source, locally.
voltro data export ./dev-refresh

# 2. Preview against the target first — every pre-flight runs, nothing is written.
voltro data import ./dev-refresh --target api \
  --api-url https://dev.example --token "$VOLTRO_DATA_TRANSFER_SECRET" \
  --mode replace --dry-run

# 3. Do it. A big bundle chunks itself; a failed run can be re-run and resumes.
voltro data import ./dev-refresh --target api \
  --api-url https://dev.example --token "$VOLTRO_DATA_TRANSFER_SECRET" \
  --mode replace --atomic
```

If step 3 dies halfway — a dropped connection, a laptop closing — run **exactly the same command again**. The upload continues where it stopped, and the import is a single all-or-nothing step, so the target is either the old state or the new one.

### Importing into a LIVE instance

A plain import connects **straight to the database** (not through the running app), so it is an uncoordinated concurrent writer — readers can see partial state, the reactive layer either storms (CDC dialects) or goes stale (others), and rows race with live writes. So `import` / `restore` **refuse by default when a live instance is detected** (via the local runtime registry, or an explicit `--api-url` probe). Two ways forward:

| Target | How | Guarantees | Use for |
|---|---|---|---|
| **direct** (default) | writes straight to the DB | none while live — **guarded**; pass `--allow-live` to override | a stopped target, a replica, a dev/staging DB not serving traffic |
| **direct + `--atomic`** (the default for `replace`) | wraps empty+load in ONE transaction | MVCC readers see the import **all-or-nothing** (old until commit, new after) — the live-safe `replace` | replacing a live target's data with no partial-state window |
| **in-process (`--target api`)** | `voltro data import <dir> --target api --api-url <url> --token <secret>` **uploads** the packed bundle (or, with `--bundle-key`, has the instance **pull it from storage**) to the instance's secret-gated admin endpoint, which imports it **in its own process** through its store | full app pipeline — validation, mixins, **field encryption**, hooks — AND automatic reactivity (in-process writes emit change events, so subscriptions update; no separate resync) | a live merge (incl. PROD) that must honour app invariants |

`--atomic` is what `replace` does by default (see above): one transaction, so live reads never see a half-loaded table. (It holds a write transaction for the load duration — writes to those tables block, readers don't. A shadow-table rename would shorten that lock window, but for a full replace concurrent writes are discarded on swap anyway, so it isn't the default.)

### The `--target api` data-transfer endpoints (prod-safe)

`--target api` moves data in/out of a **running production** instance without direct DB access. It does NOT reuse the dev data-viewer surface — two dedicated endpoints, `POST /_voltro/admin/export` and `POST /_voltro/admin/import`, both built to be safe on prod and both gated by the **same** secret:

- **Secure by default — no secret, no endpoints.** The instance mounts BOTH routes **only** when a data-transfer secret is configured: `VOLTRO_DATA_TRANSFER_SECRET=<≥16 chars>` (or `serveApi({ dataTransferSecret })`). Unset (or shorter than 16 chars) → the routes return `404`. There is no "on" switch that leaves them open.
- **Secret gate, constant-time.** Every request must present that secret as a **Bearer token** (`Authorization: Bearer <secret>`), compared in constant time over SHA-256 digests (neither length nor content leaks via timing). The gate is a framework-owned secret **independent of app RBAC** — enabling data transfer can never accidentally ride on a user role. The CLI sends it via `--token` / `VOLTRO_DATA_TRANSFER_SECRET` (it must equal the server's secret). Wrong/absent token → `401`.
- **One secret, both directions.** The same secret gates export (read) and import (write). If you need to grant export without import (e.g. a backup job that must never overwrite), split it into a per-operation credential deliberately — the default is one credential for the whole surface.

#### A big bundle goes up in chunks, and resumes (`--chunk-size`)

A bundle bigger than one chunk is uploaded as a **series of short requests** instead of one long one, and the switch is automatic: the packer's stream is buffered one chunk ahead, so a bundle that fits inside that buffer is sent exactly as before — one request, no protocol — and a bigger one chunks itself. **You never have to know in advance which table is the big one.**

Each chunk is its own request, so a proxy body cap or an ingress read timeout has nothing large to choke on. The **import still runs once**, at the end, over the whole bundle — same modes, same deferred-FK repair, same all-or-nothing emptying. Only the transport changes.

**Resume is real.** The upload id is derived from the bundle itself, so re-running the same command after a failure asks the instance how far it got and continues from there:

```
api import: resuming a chunked upload the instance already holds { bytes: 50331648 }
api import: uploaded in chunks { chunks: 7, bytes: 62914560, resumedFrom: 50331648 }
```

That is safe because `packBundle` over a bundle directory is byte-identical across runs, and because each chunk carries the bundle's **key** (the hash of its manifest). Uploading a *different* bundle under the same id is refused rather than spliced into the partial one, and a chunk that does not start exactly where the instance left off is refused with the offset it does expect.

`--chunk-size <mb>` overrides the default of 16 MiB. Unfinished uploads are discarded by the instance after 24 hours.

##### The chunk protocol, for when you are reading proxy logs

Worth knowing if an ingress sits in the way, because these requests are what it will show you. All of them are `POST` to the same `/_voltro/admin/import` path, gated by the same Bearer secret:

| Request | Headers | Answer |
|---|---|---|
| probe | `x-import-upload-id`, `x-import-probe: 1`, empty body | `200 {uploadId, bytes}` — how much the instance already holds |
| chunk | `x-import-upload-id`, `x-import-upload-key`, `x-import-chunk-offset` | `202 {uploadId, bytes}` — accepted, nothing imported yet |
| final chunk | the same plus `x-import-chunk-final: 1` | `200` with the import's own result — this is the long one |

A `409` means the instance refused the chunk and says which of three things happened: `offset-mismatch` (with the `expectedOffset` to continue from), `key-mismatch` (a different bundle under this upload id — use a new id), or `bad-id`. None of them is retryable by simply repeating the request, which is why each one names the fix.

Only the FINAL request runs the import, so only that one is long. If it is the request your proxy times out on, that is the one to raise `proxy_read_timeout` for — or use `--bundle-key` and have the instance pull the archive from object storage instead of receiving it.

#### How long an `--target api` call may take (`--timeout`)

Both api-target calls **wait as long as the instance needs**. There is no default deadline, and that is deliberate: the response arrives only when the *import* (or export) has finished, so any fixed bound is really a bound on the size of your database. A full bundle of a grown database routinely takes longer than five minutes to apply.

Pass `--timeout <seconds>` when you want one. If it is hit, the message says what it means — the upload finished long ago and **the instance is very probably still importing**:

```
voltro data import ./out --target api --api-url https://api.example --token $SECRET --timeout 900
```

Note the asymmetry when a deadline *is* hit: re-running an `upsert` after the first run has finished is safe (it is idempotent); re-running a **`replace`** while the first is still mid-flight would empty the target under it. Check the instance's log before deciding — the message says so too.

An instance that is **not answering at all** is a different message, and it names the instance rather than a database:

```
voltro: api import: the instance at api.example.com is not answering (ETIMEDOUT).
  That address came from --api-url (or VOLTRO_API_URL) — it is the running instance, not the database.
  Nothing was sent, so nothing was imported. …
```

Worth one line because the alternative was measured: a connect failure carries an address, a port and an errno, which is the same shape a database driver's carries — so without this the api host was reported as an unreachable *database*, attributed to `DB_URL`, a variable not in play on that run. Every word after the address was wrong, and the address being right is what made it convincing.

**Import** (`POST /_voltro/admin/import`) applies a bundle **in the instance's own process** through its store — full app pipeline (validation, mixins, **field encryption**, hooks) AND automatic reactivity. Two transports, neither needs a server-readable path:
  - **upload** (default) — the CLI **packs the bundle and uploads the bytes**; the server unpacks to its own temp dir, imports in-process, cleans up. Buffered → small/moderate bundles.
  - **storage-pull** (`--bundle-key <key>`) — the CLI sends only `{ "bundleKey": "<key>" }`; the instance **streams that archive from its configured object storage** (S3 / Azure / MinIO / filesystem, resolved from the storage env — `STORAGE_PROVIDER`, `S3_*`, `AZURE_*`, …), no body buffering → arbitrarily large bundles.

**Export** (`POST /_voltro/admin/export`) reads the instance's data **in-process** (so field-decryption + hooks apply) and packs a bundle. ⚠️ This is a **data-exfiltration surface** — it can read *all* prod data — which is exactly why it sits behind the same off-by-default secret. It is also the right place for **source-side masking on a live box**: name a **server-side** profile and its scope/subset/masking apply *before any byte leaves the instance* (fail-closed — see the prod→dev section below). Two transports:
  - **download** (default) — the response body **is** the packed bundle; the CLI writes it to `<outDir>`. Buffered → small/moderate exports.
  - **storage-push** (`--bundle-key <key>`) — the instance **exports straight to its object storage** under that key and returns `{ bundleKey, tables, rows }`, no response buffering → the scale path. Then pull it elsewhere (e.g. `import --bundle-key`).

The masking policy for a `--target api` export is named by `--profile <name>` and loaded **on the server** (`data-profiles/<name>.ts`) — version-controlled on the instance, never supplied by the client. No profile → a raw export (the secret-holder is trusted to read prod).

**Assets over the API — full parity with the file path.** With `--assets`, an API export **streams the instance's blobs from its own object storage into the bundle** (single-pass, nothing staged); an API import **streams the bundle's blobs straight to the destination instance's storage** as the archive arrives. So `voltro data export prod.vbundle --assets` (from a running instance via `--target api`) → `voltro data import prod.vbundle --target api --api-url <dev>` moves data *and* blobs at any size. (Uploading a *directory* bundle that has materialised assets over `--target api` transfers data only — pack a single-file `.vbundle` for assets; the CLI warns if you try.)

- **Audited** — every export/import logs the transport, table + row counts, and (export) how many columns were masked.

For a live **merge** (import) that must run app invariants + drive reactivity, this in-process path needs no quiesce/resync — in-process writes are reactive automatically.

### Cross-dialect

You can import a bundle into a **different** dialect than it came from — but only for the framework's portable DSL types. A cross-dialect import runs a **portability lint** first:

- **Blocked** (refused unless `--force`): `raw()` columns (verbatim source-dialect SQL), and `vector` columns targeting a dialect with no vector type.
- **Warned** (imported, represented differently): `array` / `interval` on a non-postgres target.
- **Portable everywhere**: text, integer, real, boolean, timestamp, date, json, bytes, reference, enum, id.

The lint **refuses loudly** rather than silently coercing — a blocked import tells you exactly which columns are the problem.

## Reliability: chunking, retry, resume

A production export can run for hours over millions of rows and gigabytes of blobs. It is built so a dropped connection, a pool blip, or an outright crash never means starting over — and so a huge table never blows up memory or knocks the source database over.

### Chunking — flat memory, gentle on the DB

Every table is read with a **keyset cursor**, not `OFFSET`: `WHERE pk > :last ORDER BY pk LIMIT n` (default **1000 rows/page**, tune with `chunkSize`). Consequences:

- **Flat memory** — one bounded page is in memory at a time, regardless of table size (a 20 M-row table streams in 1000-row pages).
- **O(1) per page** on the pk index — `OFFSET n` re-scans and skips `n` rows every page (O(n²) over a full walk); keyset reads each row exactly once.
- **Backpressure** — a page is fetched only when the sink (encode → compress → disk) is ready to take it, so a slow disk throttles the DB reads instead of overrunning memory or hammering the server.

`live` consistency keeps each read short (no long-held transaction → vacuum-friendly); `snapshot` trades that for one pinned transaction held for the export's duration (see above).

### Retry — a blip doesn't kill the run

Each page read is retried on a transient failure (dropped connection, pooled-backend hiccup) with **exponential backoff + jitter**, up to **5 attempts** by default (`retryTimes`). The retry is **per page**, and the keyset cursor is preserved — so a reconnected page resumes at exactly the row it stopped on, never re-emitting or skipping. A genuinely-broken read still surfaces after the attempts are exhausted rather than hanging.

### Resume — re-run and it continues

Both export and import checkpoint into a small **`.ledger.json`** and can be re-run to continue where they stopped:

- **Export** writes each table with an atomic temp-file + rename, and records the table (and each asset) in the ledger only once it's fully written. A crash mid-table leaves **no half-written file** (the temp is discarded); re-running skips the completed tables/assets and redoes only the unfinished one. Assets are **content-addressed**, so resume is per-asset — a blob whose hash is already in the bundle is skipped.
- **Import** keeps its own ledger (`.import.ledger.json`) and skips already-applied tables on a re-run. Correctness never depends on the ledger, though: every row is **upserted by primary key**, so redoing an in-flight table is always safe, and each table's checksum + row count and each asset's hash are **verified** as they load — a truncated or corrupted bundle fails loudly instead of importing garbage.

Net: interrupt an export or import at any point — network drop, `Ctrl-C`, OOM-killed pod — and re-running the same command finishes the job without duplicating work or corrupting the target.

**A re-run of a COMPLETED import writes nothing, and says so.** The ledger lives in the bundle directory, so re-importing a bundle whose tables are all recorded skips every one of them. That is resume working — but the returned report counts the BUNDLE's rows either way, so `import complete … 10593 rows` would otherwise print over a target you had just truncated. The import warns instead:

```text
⚠ resume: 5 of 5 table(s) were already applied by an earlier run of THIS bundle
  directory, so this run wrote NO rows for them — that is every table in the
  bundle, so nothing was written at all. Tables: tenants, teams, projects,
  actors, auditLogs. Delete ./out/.import.ledger.json to force a full re-import.
```

The `--target api` path is unaffected: the instance unpacks each upload into a fresh temp directory, so it never carries a ledger between runs.

### What the summary line counts

`import complete … rows: N` counts the rows **this run wrote**, not the rows the bundle carries. The two differ more often than you would think, and the case where they differ most used to read as a success:

```
import complete — 0 rows written, every table was already applied by an earlier run
of this bundle directory { rows: 0, carried: 242950, skippedTables: 114, skippedRows: 242950 }
```

The resume ledger lives **inside the bundle directory**, so copying a bundle copies its ledger, and the copy then imports nothing — correctly, and with a warning that says so and names the file to delete. But the warning is not the last line, and an operator piping the output through `tail -1` sees only the last line. So the last line now tells the truth on its own: `rows` is what was written, `carried` is what the bundle holds, and `skippedRows` is what an earlier run had already applied.

### Progress & observability

A multi-hour job is not a black box. Both pipelines signal per **table** — never per row, so the reporting never slows the hot path:

- **`voltro data export|import` prints a line per finished table** — `export [7/23] users · 1.2k rows` — so you watch the run progress instead of staring at a silent terminal. A ledger-resumed table prints once with its recorded count.
- **Programmatically**, `runExport` / `runImport` take an optional **`onProgress`** callback. It fires a `start` then a `done` event per table, in FK-parent-first order, carrying the table name, its 0-based `index`, the `tableCount`, `rowsDone`, and `total` (the table's row count when known ahead of time — import reads it from the manifest; a live export learns it only once the table drains). A throwing progress renderer never aborts the job.
- **Traces + metrics are always on**, no wiring. Each table runs inside an `Effect.withSpan('data-transfer.export.table' | 'data-transfer.import.table')` (attributes `table` / `index` / `phase`), so if the app has tracing enabled the export/import shows per-table phase timing in the trace. Two metrics record throughput: `voltro_data_transfer_rows` (counter, tagged by `phase` + `table`) and `voltro_data_transfer_table_seconds` (histogram, tagged by `phase`).

```ts
import { runImport, type ProgressEvent } from '@voltro/data-transfer'

yield* runImport({
  store: target,
  bundleDir: './backup',
  onProgress: (e: ProgressEvent) => {
    if (e.event === 'done') console.log(`[${e.index + 1}/${e.tableCount}] ${e.table}: ${e.rowsDone} rows`)
  },
})
```

### Typed errors — CLI-catchable AND rpc-declarable

Every pipeline failure is a **tagged error**, caught by tag with `Effect.catchTag(...)`. The errors that appear on the `runExport` / `runImport` error channels — the **wire errors**, and therefore exactly what the `--target api` admin endpoints surface — are `Schema.TaggedError`, so a handler can declare them on an rpc procedure's `error:` schema and the rpc encoder marshals them across the wire round-trip-safely (no hand-rolled JSON per tag):

`BundleError`, `CodecError`, `IntegrityError`, `CrossDialectError`, `ImportModeError`, `RowsRefusedError`, `SchemaDriftError`, `MaskingError`, `ScopeError`.

The two internal errors — `NativeToolError` (native `backup`/`restore`) and `CompressionError` (folded into `BundleError` by the pipelines) — never cross the wire, so they stay plain `Data.TaggedError`: still catchable by tag, just no Schema surface.

```ts
import { Effect } from 'effect'
import { runImport, type SchemaDriftError } from '@voltro/data-transfer'

yield* runImport({ store: target, bundleDir: './backup', targetSnapshot }).pipe(
  Effect.catchTag('SchemaDriftError', (e: SchemaDriftError) => Effect.log(`refusing: schema drifted — ${e.diff.join('; ')}`)),
)
```

## Native backup / restore

```bash
voltro data backup ./backups/2026-07-01              # pg_dump --format=custom / mariadb-dump --single-transaction / …
voltro data backup ./backups/2026-07-01 --assets     # rows AND the stored blobs
voltro data restore ./backups/2026-07-01 --assets    # pg_restore / mariadb / … + the blobs
```

These shell out to the vendor tools resolved from your `DB_DIALECT` + connection env. They produce a dialect-native artifact (`db.dump`, `db.sql`, `db.sqlite`, `db.bacpac`) that is lossless and point-in-time consistent for **same-dialect** restore — the right tool for disaster recovery. Secrets are passed via the tools' environment variables (`PGPASSWORD`, `MYSQL_PWD`), never on the command line, where the tool supports it. The named tool must be installed and on `PATH`.

On **mariadb** the MariaDB-named binaries (`mariadb-dump`, `mariadb`) are preferred and Oracle's (`mysqldump`, `mysql`) are the fallback — with the reason carried into the failure, because the error that fallback produces (`Unknown table 'COLUMN_STATISTICS' in information_schema`, 1109) names a table nobody asked for. Two things worth knowing before you go and install the MariaDB client package:

- Do **not** reach for `--column-statistics=0`. That flag does not exist on `mariadb-dump`, so it patches the wrong client and breaks the right one.
- A MariaDB **12.x** client requires TLS by default. Running it *by hand* against a server without TLS fails with `TLS/SSL error: SSL is required` (2026) and needs `--skip-ssl`. These commands are not affected — it is the first manual call after installing that trips.

### `--assets` — the blobs are not in the dump

A vendor dump contains rows. Your blobs are in object storage, and no `pg_dump` has ever seen them. So a rows-only backup restores a database whose rows reference objects that are not there — and the reference and the object are checked at different times, which is why that state is discovered by a user, months later, rather than by the restore.

The backend it reads is the one your app **configured** — `storagePlugin({ provider: s3(…) })` if you installed it, the `STORAGE_*` env otherwise. (The commands used to resolve the env default unconditionally, so an app that configured its provider in code had its export, import and backup reading a different backend than the rest of it.)

`--assets` captures them alongside the dump, through the same content-addressed pipeline `voltro data export --assets` uses: each blob is streamed (never buffered whole), stored under `assets/<sha256>` so identical content is stored once, and listed in `assets/index.ndjson`. `restore --assets` streams them back and **re-hashes on the way**, so a corrupted artifact can never silently overwrite good bytes.

```
backups/2026-07-01/
├─ db.dump                       # the vendor artifact (rows)
├─ voltro-backup-stamp.json      # provenance, incl. what --assets captured
├─ assets/index.ndjson           # key → sha256 → size → contentType
└─ assets/<sha256>               # the blob bodies, deduped by content
```

Three refusals, each for a belief that is otherwise acted on silently:

- **`backup --assets` with no storage provider configured → refused.** There is nothing to capture, and a flag that is accepted and ignored lets you build a rollback story on an artifact that does not contain what you asked for. "Configured" means one of the two things a person actually did: installed `storagePlugin(...)`, or set `STORAGE_PROVIDER`. An in-memory provider nobody asked for is not a decision — and until recently it was what this check saw, which is why the refusal never fired and `--assets` wrote artifacts stamped as carrying blobs that held none.
- **`restore --assets` on a rows-only backup → refused.** You believe the blobs are in there. Restoring the rows anyway produces exactly the dangling state this exists to prevent.
- **restore *without* `--assets` on a backup that HAS them → warned, not refused.** Restoring rows without blobs is legitimate (a schema drill, a lower environment), and refusing it would push people at `--force`.

**A reference the provider cannot resolve is reported, not fatal.** A row in `_voltro_storage_refs` can point at an object that was deleted, or that never arrived because an earlier import ran without `--assets`. That is a fact about your data, and no backup can put back bytes that are not there — so the capture records the key, steps over it, and the run says how many:

```
warn  177 of 178 blob reference(s) point at objects the storage provider does not have;
      they are NOT in this backup and no restore can bring them back.
```

Aborting on the first one made `--assets` unusable for exactly the deployment that needed it: 178 references, one resolvable, and the run stopped at the second — leaving an `assets/` directory with a single blob, **no `voltro-backup-stamp.json`** (the writer never got that far), and nothing anywhere saying 177 objects had been skipped. The stamp is now written on every path, including the one where the asset phase fails, because it describes the **dump** and the dump is already on disk and correct. Without it, `restore` greeted an artifact this tool had written minutes earlier with *"an older/handmade backup. Cannot verify dialect or schema version."*

Only a genuine *not found* is treated this way. A 403 from a rotated credential or a 5xx from a backend outage still fails the capture — calling those "the object is gone" would turn a recoverable outage into a backup that quietly contains nothing.

**Three numbers, because they answer three questions.** `_voltro_storage_refs` holds one row per *reference*, several of which legitimately name one *key*, and the content-addressed store keeps one body per distinct *object*:

```
57 reference(s) → 16 key(s) → 16 object(s), 65476 byte(s) under assets/
```

The stamp carries all three (`references`, `count`, `objects`, with `totalBytes` and `objectBytes` beside them). It used to carry only the reference count under the name `count`, so a stamp read `57` over a directory holding 16 files — and anyone answering *"are all the blobs there?"* after a restore compared the two and found a 3.5× gap that was not one. A key named by several references is also fetched once now, rather than downloaded and hashed once per row.

Resume is per blob key, so re-running a `--assets` capture that was interrupted transfers only what is missing. The **dump itself has no resume** — a vendor artifact is one opaque file with no offset to restart from. If you need a resumable, chunkable, observable transfer, that is the logical path (`export` / `import`), and it is why the logical path exists.

### The stamp's skew warning compares the backup against the TARGET

`restore` reads `voltro-backup-stamp.json` before touching anything and warns when the backup's schema fingerprint differs from the target's. That warning used to say the difference was against "what this code declares", and it was not — the value it compares against is the target database's *live* schema, read by introspection at restore time. Bringing a target to the backup's shape makes the warning disappear while the declared fingerprint is a third value entirely, which is how the mislabel was caught. The comparison was always the useful one; only the sentence was wrong, and it sent readers looking for a code change where a database differed.

The same distinction shows up in `voltro db plan`, which prints `live … · declared …` rather than `from → to` for the same reason: **a hash of a live database never equals the hash of the declaration it came from.** Introspection cannot recover everything a declaration carries — generated expressions, `maxLength`, sensitivity markers — so the two are not comparable and are not meant to match. The plan's operation list is what says whether they agree; `0 operations` under two different fingerprints means they do.

### A restore that is interrupted refuses the next boot

`restore` writes one row into `_voltro_replace_in_progress` **before** the first destructive statement and removes it **after** the last write — the blobs included. Its presence at boot is a refusal naming the artifact that was going in.

This is the counterpart to `--allow-live`, and it guards from the other side: `--allow-live` asks you not to restore over a running instance, and this says *this database is mid-restore, do not serve it*. A half-restored database looks exactly like a normal one from the inside — every query answers, nothing errors.

**The restore artifact can erase the marker, and this table said otherwise.** It read "postgres, mysql, mariadb: drops only the objects the dump names — the marker survives". The reasoning is right and the premise was wrong: a native dump names the *whole* database, `_voltro_replace_in_progress` included, and a mysql-family restore writes `DROP TABLE IF EXISTS` in front of each table. The table sorts early, so the guard was removed near the *start* of the window it covers. Measured downstream: one row before the restore, zero after, twice.

Two changes, covering different dumps:

- **A backup taken by `voltro data backup` excludes the marker table** (`--exclude-table` / `--ignore-table`). It can no longer carry the thing that erases the guard on the way back in.
- **`restore` writes the marker back after the tool exits**, on the failing path as well as the succeeding one. That covers dumps taken before this version and dumps made by hand. If it was removed and rewritten you get a warning saying so; if it could not be rewritten you get an error, because the guard is then off for that run and nothing will stop the next boot.

**A restore that cannot write the marker at all is refused.** Two things can prevent it — the bookkeeping store will not open (wrong credentials, an unreachable database, a missing env var, no `app.config.ts` from here), or the table is not there yet — and both mean the same thing to you: this restore would run with no guard. The refusal names which one it was:

```
✗ refusing to restore: the in-progress marker cannot be written.
    reason: bookkeeping is unavailable: connect ECONNREFUSED 127.0.0.1:5432
```

This used to be a silent hole rather than a refusal, and worse than silent. The failure to open the store was caught and discarded, and the discarded value guarded *every* branch below it — including the refusal that would have reported the guard missing. So a restore ran on and, over a database with zero marker rows, printed *"the next boot will REFUSE, by design"*. The next boot did not refuse, and `voltro data clear-replace-marker` had nothing to clear. A restore is the operation you run against a target that is already unwell, so the precaution was falling away exactly when it was needed.

**`--no-marker`** restores without the guard, deliberately. It warns every time and names the reason the marker was unavailable. It exists because the accidental way did: if going unguarded is ever right, it should be something you typed.

| dialect | shape | effect |
|---|---|---|
| postgres, mysql, mariadb | the dump names the whole database, so the restore drops the marker table too | our backups exclude it; for any other dump the marker is written back after the tool exits |
| sqlite, turso | whole-file replacement — made **atomic** (temp file + rename) | there is no half-restored state to catch; a killed restore leaves the live file untouched |
| mssql | `sqlpackage /Action:Import` replaces the database | a *failed* import is the one case not covered here — verify with `--drill` |

Clear a marker deliberately with `voltro data clear-replace-marker --yes` once you have decided the current state is correct.

**`_voltro_data_transfers` is excluded for the same reason, one table over.** The restore opens its own run row there *before* the tool starts; a dump that carried the table dropped it mid-flight, and the update recording the outcome then wrote into a table that no longer held the row. The visible result was that a failed native restore did not appear in `voltro data transfers` at all — only the `backup` row the dump had brought over from the *source* database. The command that answers "did the restore finish" could not see the run asking.

Exactly those two tables are excluded, and the line is deliberate: a native restore into the same deployment *should* bring the migration ledger, the stored plans, the CDC offsets and the schedule claims — they describe the data being restored. These two describe the *restore*, and a record of an operation must not be overwritten by the operation it records.

### Both directions are in the history

`backup` and `restore` write a row to the same `_voltro_data_transfers` record `import` and `export` use, so `voltro data transfers` answers "did last night's backup finish" from the instance that ran it:

```
2026-08-23T02:00:00.000Z · backup native postgres + assets — finished (412 blob(s)) · via cli · from ./backups/2026-08-23/db.dump
2026-08-22T09:14:02.000Z · FAILED restore native mariadb — mariadb-dump: exited with code 2 · via cli · from ./backups/2026-08-21/db.sql
```

A native run reports **blobs**, not rows: the vendor tool reports no row count we can trust, and printing `0 row(s)` over a `pg_dump` that worked would be a measurement, and a wrong one. A target with no `_voltro_data_transfers` table still gets its backup — the closing line says it was not recorded, rather than implying it was.

### The provenance stamp — a restore that refuses the wrong DB

A native dump is opaque: it doesn't say which dialect made it, which schema shape it carries, or when. `backup` writes a sidecar `voltro-backup-stamp.json` next to the artifact recording exactly that — `dialect`, the live schema `fingerprint`, the `@voltro/cli` version, and the timestamp.

`restore` reads the stamp **before touching the DB** and acts on two failures that are otherwise silent until they corrupt:

- **Cross-dialect restore → refused.** Restoring a postgres dump while `DB_DIALECT=mysql` is never valid; it stops with an error instead of half-loading. Override with `--force` only if you genuinely know better.
- **Schema/code skew → warned.** If the backup's schema fingerprint differs from what the running code declares, restore prints a warning to run `voltro db apply` afterwards — the dump's shape predates (or postdates) this deploy's code. (Production boot already refuses on a fingerprint mismatch; the stamp surfaces it at restore time, before the boot.)

A backup with no stamp (older, or hand-made) restores with a caution rather than a hard stop.

### The restore drill — prove the backup, don't assume it

```bash
voltro data restore ./backups/2026-07-01 --drill --drill-url postgres://…/scratch
# or set DRILL_DB_URL and just: voltro data restore ./backups/2026-07-01 --drill
```

`--drill` restores the artifact into a **throwaway** database (from `--drill-url` / `DRILL_DB_URL`) and verifies it — **without ever touching the live DB**. It refuses a drill target that resolves to your live connection (a drill that `--clean`s production is the disaster it exists to rehearse against). After the restore it introspects the throwaway DB and compares its schema fingerprint to the backup's stamp:

- **zero tables restored** → FAIL (the dump is empty or unreadable — this backup would not recover you),
- **fingerprint disagrees with the stamp** → FAIL (the restore didn't reproduce what was backed up),
- **tables + matching fingerprint** → PASS.

It exits non-zero on any FAIL, so a scheduled CI job turns a silently-broken backup into a red build. Run it against your latest artifact on a cron — a backup you've never restored is a hypothesis, and this is how you keep it a fact. (The verify is schema-level; a full app boot against the restored DB is a heavier check you can layer on top.)

### Point-in-time recovery (PITR) is your database's job, not the framework's

`backup` is a point-in-time **snapshot**. "Restore to 14:32, just before the bad deploy" (PITR) needs continuous WAL/binlog archiving, which lives at the database/provider layer — pg's `archive_command` + a base backup (pgBackRest / WAL-G), a managed provider's continuous backup (RDS, Cloud SQL, Neon, PlanetScale). The framework deliberately does **not** reimplement it: layer PITR under these native snapshots at the infra layer. A weekly `voltro data backup` + provider PITR together give you both a portable artifact and a fine-grained restore point.

> **Test your backups.** A backup you've never restored is a hypothesis. Restore your latest artifact into a throwaway database and boot the app against it on a schedule — the stamp's dialect/fingerprint checks turn a silently-broken backup into a loud one, but only an actual restore proves the bytes are good.

## Masking (prod → dev/stage safely)

Cloning prod into a lower environment must not carry real user data. `voltro data export`
does this with **masking**: PII is replaced by realistic, referentially-consistent fakes
**at the source** — before a row is ever written — so real values never reach the bundle,
transit, or a developer's machine.

```bash
voltro data export ./out --profile dev
```

Masking is driven by **two layers**:

1. **Classification in the schema** — [`.sensitive(class)` / `.safe()`](/docs/database/sensitivity)
   on each column. This says *what kind* of data a column holds. It lives in the schema
   because the data's meaning is a property of the schema, not of one export.
2. **A per-environment masking POLICY** — how each class/column is transformed for *this*
   target. It lives in a profile so a target environment's whole recipe is one reviewable,
   version-controlled file.

```ts
// data-profiles/dev.profile.ts
import { defineDataProfile } from '@voltro/data-transfer'
export default defineDataProfile({
  subset: { seeds: { users: undefined } },
  masking: {
    seed: process.env.MASK_SEED!,
    // classes override the built-in defaults; columns override per-column
    classes: { freeText: 'redact' },
    columns: { '_voltro_mail_outbox.to': { fake: 'email' } },
    onUnclassified: 'error', // fail-closed (default)
  },
  consistency: 'snapshot',
  assets: true,
})
```

### Deterministic + seed-keyed

Every transform is deterministic and keyed on a secret **`seed`**:

- **Same input → same fake, everywhere.** One email becomes the SAME fake in every table
  it appears in, so joins survive; and it stays stable across re-runs, so dev data doesn't
  churn. Keeping the seed stable is **pseudonymisation**.
- **Rotate or discard the seed → the mapping is irrecoverable.** That makes the result
  **anonymisation**.

`seed` is REQUIRED — masking without one is a bug (pass it from the environment, never
commit it). Transforms are also **format-preserving** (a fake email is a valid email) and
**null-preserving** (a null stays null — nullability holds).

### Actions

An action is what a column's value becomes. Set them per class (`classes`) or per column
(`columns`, which wins):

| Action | Effect |
|---|---|
| `keep` | copy verbatim |
| `null` | set to `null` |
| `redact` | fixed placeholder (`[redacted]` for text, `null` otherwise) |
| `hash` | deterministic opaque hex (stable, non-reversible without the seed) |
| `dateShift` | shift a date by a seed-derived offset (relative intervals + ordering preserved) |
| `{ fake: '<class>' }` | a format-preserving fake of that class |
| `{ custom: (input) => … }` | your own transform (`input` = `{ value, table, column, columnType, seed }`) |

### Class → action defaults

A minimal policy is just a `seed` — every known class has a default action:

| Class | Default action |
|---|---|
| `email` `fullName` `firstName` `lastName` `username` `phone` `address` `company` `url` `ip` `creditCard` | `{ fake: '<class>' }` |
| `date` | `dateShift` |
| `secret` | `null` |
| `freeText` | `redact` |

A custom class with no entry in `classes` falls back to `redact`. Resolution order for any
column is: **`columns[table.column]` → the class's action (`classes` → built-in default) →
`.safe()` keeps → PK/FK keeps → `onUnclassified`.**

## Fail-closed

Masking is **fail-closed**. A column that is neither `.sensitive()` nor `.safe()` (and isn't
a PK/FK, and has no `columns` override) **refuses the export** and is named in the error —
so a newly-added column can never silently leak PII to dev. Classify it, or override it in
the policy.

The opt-out is `onUnclassified: 'keep'` (or `'null'`), which makes masking **fail-open** for
unclassified columns. Discouraged — it defeats the guarantee; prefer classifying the column.
See [why fail-closed](/docs/database/sensitivity#why-fail-closed).

**A policy this build cannot carry out is refused too**, before a row is read. An action of a
shape the applier does not understand — a typo like `{ action: 'fake', kind: 'email' }` where
the shape is `{ fake: 'email' }` — used to fall through to "copy the value", so the export
succeeded and shipped the raw column while the audit line counted it as masked. It is now a
`MaskingError` with `invalidActions`, reported separately from `unclassified` because the two
have different fixes: one needs a classification, the other needs the policy corrected.

## `--dry-run` — preview without writing

Preview a masking export **without writing a bundle** — the trust surface before real data
moves. It reads a small sample per table and reports, per masked column, the before→after;
a **leak scan** that warns when a KEPT column still LOOKS like PII (catches a
misclassification — a `.safe()` on something that isn't); and the fail-closed list.

```bash
voltro data export ./out --profile dev --dry-run
```

```text
  users
    email  [fake:email]  "ada@corp.com" → "grace.hopper1847@example.com"
    name   [fake:fullName]  "Ada Byron" → "Linus Torvalds"
    ssn    [null]  "078-05-1120" → null

  ⚠ possible leaks in KEPT columns:
    users.nickname looks like email: "ada@corp.com"

  ✗ unclassified (would BLOCK a real export — add .sensitive()/.safe() or a policy override):
    users.bio

  ✓ every exported column is classified — safe to run.
```

Nothing is written. `--dry-run` exits non-zero when the unclassified list is non-empty, so
it doubles as a CI gate for classification coverage. (It requires a profile with a masking
policy — there is nothing to preview otherwise.)

## Audit

A masking export records what it changed in the bundle's `manifest.json`, under `masking`:

```json
"masking": {
  "transformed": ["users.email", "users.name", "users.ssn"]
}
```

`masking.transformed` lists every `table.column` that was pseudonymised/anonymised (i.e.
every column whose action was not `keep`), so a reviewer can verify the copy was masked as
intended — without diffing the data. A policy `id` is recorded alongside it when the policy
sets one.

## Subsetting

`subset` exports a referentially-correct SLICE instead of whole tables. You give **seed
rows** per table; the exporter adds their **transitive FK-parent closure** — every row the
seeds (and their parents, recursively) point at — so the slice imports with **no dangling
references**.

```ts
subset: {
  seeds: {
    users: undefined,                          // every user (no predicate)
    orders: eq('status', 'open'),              // only open orders (a Predicate)
  },
}
// exports those rows PLUS every parent row they reference (users an order points at, etc.)
```

A `subset` **replaces** `scope` when both are set — only the seeded tables and their parent
closure are exported. Contrast the two:

- **`scope`** (`--tenant` / `--tables` / all) selects **tables** (a tenant's rows plus the
  row-subset FK parents they reference, or an explicit table set). Use it to move a tenant
  or named tables.
- **`subset`** selects **specific rows** and pulls in exactly the parents they need. Use it
  to carve a small, self-consistent slice ("these 1000 users and everything they reference").

**Child closure.** By default the closure follows **parents only** — a seeded user brings the
org it belongs to, but not that user's posts. Set `children` to also pull in the rows that
**reference** the seeded set, each scoped to the ids that actually point into it (never a whole
child table). The child rows' own parents fold back through the parent closure, so the slice
stays referentially complete:

```ts
subset: {
  seeds:    { orgs: eq('id', 'org_abc') },
  children: { roots: ['orgs'] },  // pull the org's users → their posts → those posts' comments
}
```

`children.roots` **anchors** the walk: a child comes along only when it references a `roots`
row (or a child already pulled in this walk), so the walk stays tight — a row that references
only a *shared* parent outside the anchor is not dragged in. (This is exactly what keeps a
`--tenant` export tenant-tight: its child walk is anchored on the `tenant()` tables.) Use
`children: true` for an unanchored org-slice takeout where there is no tenant boundary to
respect.

Subsetting reads the selected rows into memory to collect id sets — it's built for small
slices, not for halving a huge table (use `scope`/`--tenant` for that).

## Profiles

A **profile** bundles a target environment's whole recipe — scope/subset, masking,
consistency, compression, assets — into one default-exported object, so
`voltro data export --profile dev` is a single, reviewable, version-controlled description
of *how prod becomes dev*.

```ts
import { defineDataProfile } from '@voltro/data-transfer'

export default defineDataProfile({
  scope:       { kind: 'all' },        // or omit and use --tenant/--tables
  subset:      { seeds: { users: undefined } }, // replaces scope when set
  masking:     { seed: process.env.MASK_SEED! },
  consistency: 'snapshot',             // 'live' | 'snapshot'
  compression: 'zstd',                 // 'zstd' | 'gzip' | 'none'
  assets:      true,                   // include stored blobs
})
```

`--profile <name>` resolves, in order: `./<name>`, `./<name>.profile.ts`,
`./data-profiles/<name>.ts`, `./data-profiles/<name>.profile.ts`. So `--profile dev` finds
`./dev.profile.ts` or `./data-profiles/dev.ts`. The module's **default export** must be the
profile (use `defineDataProfile` for full type-checking).

**CLI flags override profile values.** `--assets`, `--compression`, `--consistency`,
`--tenant`, `--tables` all win over what the profile sets — so a profile is the default and a
flag is the one-off override.

## Which do I use?

| Goal | Command |
|---|---|
| Disaster recovery / scheduled backups | `voltro data backup` (native) |
| Clone prod → staging (masked, no real PII) | `voltro data export --profile <env>` (masking + subset) |
| Preview a masked export before running it | `voltro data export --profile <env> --dry-run` |
| Clone prod → staging (same dialect, unmasked) | `voltro data export` then `import` (or native backup/restore) |
| GDPR / per-tenant takeout | `voltro data export --tenant <id> --assets` |
| A small self-consistent slice of the data | `voltro data export --profile <env>` with a `subset` |
| Move Postgres → MySQL | `voltro data export` then `import` (cross-dialect lint applies) |
| Replace a dev/staging environment with your local state | `voltro data import <dir> --target api --mode replace --atomic` |
| Seed a fresh cluster through the running app (invariants + encryption) | `voltro data import <dir> --target api` (default `upsert`) |
| Move a bundle too big for one request | nothing extra — the upload chunks itself; `--chunk-size` only if you need a different size |
| Resume an upload that died | re-run the identical command |



---

<!-- source: en/cli/mcp.md -->
## MCP server (voltro-mcp)

_Wire a running Voltro api into Claude Code / Cursor as an MCP server — read the app's procedures, tables, workflows and JSON Schemas, EXECUTE the procedures you expose as agent tools under your app's own permissions, and run the framework's invariant checks. Over stdio or Streamable HTTP._

`@voltro/mcp` ships two standalone bins — **`voltro-mcp`** (stdio) and **`voltro-mcp-http`** (Streamable HTTP) — that serve a running api's capability manifest to a coding agent over the Model Context Protocol. The agent can then discover what the backend exposes — every rpc procedure with its input/output JSON Schema, the user tables, the workflows, the schema-driven-UI widget kinds — before writing UI or agent code.

Discovery is **read-only metadata** and is what you get with nothing configured beyond a URL: the bins talk to the same `GET /_voltro/inspect/manifest` endpoint the [inspect surface](/docs/cli/inspect) exposes, and honour its token gate. Two further surfaces are **off until you turn them on** — executing your app's agent tools, and the invariant checks. Both are covered below. The server advertises three MCP capabilities — **tools**, **resources**, and **prompts**.

## Setup

Four environment variables. The first two cover read-only discovery; the last two are what an executing tool call needs.

| Var | Default | Notes |
|---|---|---|
| `VOLTRO_INSPECT_URL` | `http://localhost:4000` | Base URL of the running api. |
| `VOLTRO_INSPECT_TOKEN` | _(unset)_ | Sent as `Authorization: Bearer <token>`. The inspect surface is fail-closed, so without it every call is a 401. |
| `VOLTRO_INSPECT_WRITE_TOKEN` | _(unset)_ | Required to EXECUTE an app tool — a tool call is a non-GET inspect request, and those need a second credential. Read-only discovery does not use it. |
| `VOLTRO_AGENT_TOKEN` | _(unset)_ | The **app** credential a tool call executes as. Never the inspect token — see below. |

### Claude Code

```bash
claude mcp add voltro -- npx -y @voltro/mcp
# an api on a non-default port:
claude mcp add voltro --env VOLTRO_INSPECT_URL=http://localhost:4001 -- npx -y @voltro/mcp
```

### Cursor / generic MCP config

```json
{
  "mcpServers": {
    "voltro": {
      "command": "npx",
      "args": ["-y", "@voltro/mcp"],
      "env": { "VOLTRO_INSPECT_URL": "http://localhost:4000" }
    }
  }
}
```

## The tools

| Tool | Returns |
|---|---|
| `voltro_list_procedures` | Every rpc procedure with its kind; `[public-rest]` / `[agent-tool]` markers for projected descriptors. |
| `voltro_get_procedure` | One procedure's kind, input/output JSON Schema, source file(s), table targets, and projections. |
| `voltro_search_procedures` | Procedures whose tag contains a substring. |
| `voltro_list_tables` | The app's USER tables (column count, reactivity). |
| `voltro_get_table` | One table's full column list (types, nullability, FK targets, enums). |
| `voltro_list_workflows` | The registered durable workflows. |
| `voltro_list_widgets` | The schema-driven-UI widget kinds. |
| `voltro_check_invariants` | The framework's invariant checks against the running app — see below. |

Plus one `app_<procedure>` tool per procedure your app exposes as an agent tool AND its policy admits — see the next section. Those are the only tools that execute anything.

## The resources

The server also exposes the manifest as MCP **resources** — stable, addressable `voltro://` URIs an agent reads. `resources/list` enumerates them (fresh from the manifest each call); `resources/read` returns the metadata as JSON.

| URI | Contents |
|---|---|
| `voltro://manifest` | The whole capability manifest as JSON. |
| `voltro://procedure/<tag>` | One procedure's kind, input/output JSON Schema, source, and table targets. |
| `voltro://table/<name>` | One table's full column list. |

## The prompts

Reusable MCP **prompt templates** that render against the _live_ manifest, so the returned messages carry the app's real schema rather than a generic stub. `prompts/list` advertises them; `prompts/get` renders one.

| Prompt | Arguments | Renders |
|---|---|---|
| `scaffold_procedure` | `kind`, `purpose` | A brief to draft a new query/mutation/action, listing the app's real tables + sibling procedures of that kind. |
| `explain_table` | `table` | The table's schema + the procedures that read/write it. |
| `wire_ui_for_procedure` | `tag` | A brief to call one procedure and render its result, embedding its real input/output schema. |

## Executing your app's procedures

A procedure annotated `exposeAsTool` can be **called** by the agent — the tool body is the real rpc handler, run under a `Subject` your app's own auth chain resolved. So the agent's ceiling is that subject's permissions, by construction: there is no second authorization path, because there is no second path. A guard that refuses the subject refuses the agent.

It is off until you say otherwise, at five independent gates:

```ts
// app.config.ts
export default {
  agents: {
    tools: { allow: ['todos.*'], deny: ['*.purge'], includeWrites: true },
    mcp: true,
  },
}
```

```ts
// mutations/todos.create.mutation.ts
export const descriptor = defineMutation({
  name: 'todos.create',
  input: Schema.Struct({ title: Schema.String }),
  guards: [{ scope: 'todos:write' }],
  exposeAsTool: { description: 'Create a todo for the signed-in user', confirm: false },
})
```

1. **`agents.mcp: true`.** Not implied by anything else. Having an inspect token is not consent to let an agent execute procedures.
2. **`VOLTRO_INSPECT_TOKEN`** — the transport is fail-closed; `voltro dev` mints one per project, `voltro serve` mints nothing.
3. **`VOLTRO_INSPECT_WRITE_TOKEN`** + the `x-voltro-inspect-write` header. A tool call is a POST, and every non-GET inspect request already needed a second credential. An existing deployment with only the read token therefore executes nothing.
4. **`VOLTRO_AGENT_TOKEN`** — the app credential the call executes AS, sent on its own `x-voltro-agent-authorization` header. **Required.** The inspect bearer is an operator credential; letting it double as an app identity would be exactly the second authorization path, and running as the anonymous subject instead would execute under a principal nobody chose. With [`apiKeys: true`](/docs/configuration/api-keys) your app already mints a scoped credential for this — scope it to what the agent may do, not to what you may do.
5. **`agents.tools`** — the same `AppToolPolicy` the in-process [`appTools`](/docs/ai/tools) loop takes, so one policy covers both. `deny` beats `allow`; `includeWrites: true` is required before any mutation or action is callable at all.

Then the app's own guards run. Nothing above replaces them.

### `confirm` tools are not mounted here

`confirm` means a human approves the concrete call before it executes. There is no human in the MCP server's process, and there is no way to produce one: a confirmation carried in the tool's arguments is written by the model, and an MCP client's approval prompt is a property of that client — several harnesses auto-approve. So a `confirm` tool is dropped, with that reason, rather than mounted in the hope that the far side asks.

Writes confirm by default. An app that wants one callable unattended says so per descriptor (`exposeAsTool: { confirm: false }`) or app-wide (`agents.tools.requireConfirmForWrites: false`) — both are edits a reviewer sees in the diff.

### Naming, and what an agent sees

`todos.create` mounts as `app_todos_create` (MCP tool names are `[A-Za-z0-9_-]`). The tag is resolved back through the listing the server rendered, never by un-mangling the name the model produced, so no amount of argument shaping selects a different procedure. Writes are marked `[WRITE]` in the description — the model has no other signal that one of two tools destroys data.

Everything that did NOT mount is reported with a reason (`GET /_voltro/inspect/agent/tools` returns `dropped[]`), because a tool silently missing from an agent's set is a support ticket that opens with "the agent says it can't do that".

### In the audit trail

An agent call runs the same plugin interceptor chain as a socket call, so `plugin-audit` records it as usual. It additionally stamps `via: 'agent'` on the write attribution, with the subject id of the **person** the agent acted as — an agent never escalates identity, which is precisely why an unmarked agent write would be indistinguishable from a human one. A change-event tap reads it as `event.via`.

### What this does NOT defend against

Stated rather than implied, because a bound you assume is worse than one you do not have:

- **Prompt injection that steers the model into misusing a tool it IS permitted to run.** The allowlist bounds WHICH tools exist; it cannot bound intent. Tool results are your app's data, and app data can contain instructions.
- **A client holding all three credentials.** It can call any admitted tool with any arguments. The bound is the subject's permissions — which is the design, and the reason to scope `VOLTRO_AGENT_TOKEN` narrowly.
- **Call rate.** `maxPerRun` is reported for a client to honour; honouring it is the client's. Use [`plugin-ratelimit`](/docs/plugins/ratelimit) on the procedure for a bound that holds regardless of who is calling.

## Verifying your own work

`voltro_check_invariants` runs the framework's own invariant checks against the **running** app and returns a machine-readable verdict — the loop that turns "I generated some code" into "I checked it". `GET /_voltro/inspect/checks` is the same thing over HTTP.

| Check | Answers |
|---|---|
| `browser-safety` | Does the generated rpcGroup transitively value-import a server-only module? The finding carries the full **import chain** — a bare specifier says a rule broke, the chain says which shared `lib/` file broke it. |
| `procedure-access` | Does every wire-exposed procedure declare `guards:` or `openAccess:`? Runs the same verdict the boot gate runs, including `security.defaultDeny`. |
| `schema-convergence` | Has the live schema drifted, and are operations pending? Read from the same snapshot `voltro db plan --against` reads. |
| `server-only-exposure` | Does a wire-reachable query declare a `.serverOnly()` column in its output? |

Each answers `pass`, `fail`, or **`unavailable`** — and `unavailable` is never a pass. Two of these read the source tree, and a deployed `voltro serve` has no generated rpcGroup to walk (frequently no `src/` at all after a `pnpm deploy`), so it reports them `unavailable` **with the reason** rather than omitting them. Three green checks that you cannot distinguish from "nobody looked" would be worse than no answer.

```json
{
  "checks": [
    { "id": "browser-safety", "status": "unavailable",
      "reason": "this process has no generated rpcGroup to walk — the check reads the SOURCE import graph…" },
    { "id": "procedure-access", "status": "fail",
      "summary": "1 wire-exposed procedure(s) declare no access decision",
      "findings": [{ "tag": "todos.secret", "kind": "query", "file": "queries/todos.secret.query.ts" }],
      "fix": "give each one either `guards: [{ scope: '…' }]` or `openAccess: '<why it is public>'`…" }
  ],
  "summary": { "pass": 2, "fail": 1, "unavailable": 1 },
  "mode": "serve"
}
```

`voltro doctor`'s rule set is deliberately NOT here: it is a source-tree scan with its own allowlist file and exit-code contract, it would answer `unavailable` on the one deployment shape this surface exists to reach, and re-hosting it behind HTTP would be a second implementation of a large thing. Run the command, on the machine that has the source.

## Streamable HTTP transport

For clients that speak MCP over HTTP, **`voltro-mcp-http`** serves the same surface over the current **Streamable HTTP** transport (the single-endpoint POST/GET model that replaced the old HTTP+SSE dual-endpoint). One endpoint handles:

- **POST** a JSON-RPC message → a JSON response, or an SSE stream (`text/event-stream`) carrying the response(s) when the client's `Accept` allows it. An `initialize` POST mints a session and returns it in the `Mcp-Session-Id` header; every later POST must echo that header.
- **GET** (with `Accept: text/event-stream`) → opens the server→client SSE channel.
- **DELETE** → ends the session.

```bash
VOLTRO_MCP_HTTP_PORT=4100 VOLTRO_INSPECT_URL=http://localhost:4000 npx -y @voltro/mcp voltro-mcp-http
```

| Var | Default | Notes |
|---|---|---|
| `VOLTRO_MCP_HTTP_PORT` | `4100` | Listen port. |
| `VOLTRO_MCP_HTTP_PATH` | `/mcp` | The single MCP endpoint path. |

## Freshness + failure behavior

The manifest is read through a TTL-cached source (~10 seconds): a procedure you add during a `voltro dev` session shows up on the next tool call — no MCP-server restart. When the api is unreachable or the token is wrong, the tool output says so (`(no procedures — manifest unavailable: …)`) and the bin logs the reason to stderr at boot, instead of silently presenting an empty app.

## Protocol scope

MCP over JSON-RPC 2.0. `initialize` negotiates the protocol revision (`2025-06-18`, `2025-03-26`, `2024-11-05`) and advertises the `tools`, `resources`, and `prompts` capabilities; methods are `ping`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, `prompts/get`. The stdio bin frames this as newline-delimited JSON-RPC; the HTTP bin serves it over Streamable HTTP. Both transports route to the same protocol core, all exported from `@voltro/mcp`: the pure `handleMcpRequest` (`callTool`, `listResources`/`readResource`, `listPrompts`/`getPrompt`) plus `handleMcpRequestAsync`, which handles the two methods that need a round trip to the app — `tools/list` folds in the admitted agent tools, and `tools/call` executes one or runs the invariant checks. With no live connection configured, `handleMcpRequestAsync` behaves exactly like the pure one.



---

<!-- source: en/cli/update.md -->
## Update

_voltro update — what the command does and does not do. It bumps and installs; it does not make your app boot. The boot refusals this release ships, and the order you meet them._

`voltro update` upgrades an app to the latest framework release. It does three things in order:

1. **Bump** every `@voltro/*` dependency in `package.json` to the target version.
2. **Install** with your project's package manager — see [Which package manager](#which-package-manager) below.
3. **Run the codemods** shipped with the target version. A codemod is either a **transform** (rewrites your source) or **`manual`** (prints written steps, only when your app is affected). **Every one of 0.34.0's 21 codemods is `manual`** — nothing is rewritten for you, and there is no diff to review afterwards.

**`voltro update` does not make your app boot.** It moves versions and prints
instructions; deciding what those instructions mean for your code is yours.
0.34.0 ships **six boot refusals** — three of them fire on `voltro dev`, before
you deploy anything — and `update`, `db apply` and `typecheck` all pass while an
app is dead in every one of them. Start with [`voltro doctor`](#start-here-voltro-doctor),
then read [the boot refusals](#the-boot-refusals-and-where-you-meet-them).

For the per-change narrative — what each of the 21 notes is about and why —
read [Upgrading to 0.34.0](/docs/releases/upgrading-to-0-34). This page is the
command's own contract.

```bash
voltro update                 # bump to the latest published version, install, run codemods
voltro update --to 0.4.0      # pin an explicit target version
voltro update --dry-run       # preview the bump + which codemods would run — writes nothing
voltro update --force         # allow a dirty working tree (not recommended)
voltro update --only 0.14.0/03_pages-suffix   # run just these codemod(s), comma-separated
voltro update --exact         # pin exact versions (drop the ^ / ~ range prefix)
voltro update --help          # every flag — always answered, even on a dirty tree

# Recover the codemods after a MANUAL version bump (no bump, no install):
voltro update --codemods-only --from 0.3.0            # re-run codemods 0.3.0 → installed
voltro update --codemods-only --from 0.3.0 --to 0.4.0 # explicit delta
```

## Start here: `voltro doctor`

`voltro doctor` is the command that lists what will refuse to boot, and it
reports the **exact set the boot refuses on, from the same function** — not a
second implementation that can disagree with it.

```bash
voltro update
voltro doctor          # every undecided procedure + every unverified webhook, by tag and file
voltro doctor --json   # accessDecisions.undecided / webhookVerification.unverified — for CI
```

Run it before you try to start anything. Its two most important sections are the
two source-shaped refusals below:

```text
access decisions · security.defaultDeny ON
  ✗ no access decision                      18
  ✓ openAccess, declared on purpose          0

  ✗ notes.list  (query)
      api/notes/list.query.ts
  …

  `voltro dev` and `voltro serve` REFUSE to boot on these. Give each a decision:
  `guards: [{ scope: '…' }]`, or `openAccess: '<why anyone may call it>'`.
```

**What doctor cannot see.** It reads your source tree, so it covers the access
decisions and the webhook declarations. The other four refusals are properties
of your *environment* — a connection URL, `NODE_ENV`, a migration that has not
run against a particular database — and no source scan can predict them. Read
the list below for those.

## The boot refusals, and where you meet them

Six of them ship in 0.34.0. `voltro update` succeeds, `voltro db apply`
succeeds and `voltro typecheck` succeeds in **all six** — the access decision is
a runtime boot gate, not a type error, and the other five are environment facts
no compiler is looking at. They are listed here in the order you actually meet
them: the first three on your own machine, the last three in a container.

### On your laptop — `voltro dev`

**1. A wire-exposed procedure that decides nothing.** `guards:` used to default
to *allowed*, so a `*.query.ts` with no guard was callable by any authenticated
session. Every discovered procedure now declares `guards:` or `openAccess:`, or
neither `voltro dev` nor `voltro serve` starts.

```text
[access] 18 wire-exposed procedures declare no access decision, and this app runs with `security.defaultDeny`:

    notes.list  (query)
      api/notes/list.query.ts
    …

  Each of these is callable by ANY authenticated session. Give each one a
  decision — the two are equally acceptable and they are not the same claim:

    guards: [{ scope: 'invoices:read' }]        the caller must hold a scope
    openAccess: 'public pricing, no user data'  anyone may call it, and why
```

Three things worth knowing before you start editing:

- **`openAccess` takes a reason, not a boolean.** It is what makes "we decided
  this is open" distinguishable from "nobody looked".
- **Do not rubber-stamp with a scope every caller already holds.** That
  satisfies the gate, reads as protection, and enforces nothing.
- **A procedure only other server code calls wants neither.** Mark it
  `internal: true` and it leaves the wire entirely (and then it must not carry
  `openAccess` — the definers refuse that combination).
- **Your plugins' procedures are not your problem.** The gate reads your app's
  own discovered files only.

**The one-field escape hatch**, if you need to ship before you have decided
everything:

```ts
// app.config.ts
export default { security: { defaultDeny: false } }
```

That restores the old default-allow for the WHOLE app, in one place a reviewer
can see. There is deliberately no env var for it — an env var is how a security
default gets turned off in one CI job and stays off. `voltro doctor` keeps
listing the undecided procedures while it is off, marked advisory.

**2. An incoming webhook that does not say how it authenticates its caller.**
An incoming webhook is a public, unauthenticated POST that runs your
application code. The transport refuses to mount one that declared nothing:

```text
incoming webhook '/webhooks/stripe' is mounted without declaring how it authenticates its caller.
An incoming webhook is a public POST that runs your application code, so the framework refuses
to mount one that nothing verifies. Declare it on the descriptor:
  · provider: stripeWebhookProvider()  — or any provider preset (HMAC + replay window)
  · signature: { _tag: 'hmac', algorithm: 'hmacSha256', header: 'X-Signature', ... }
  · verification: 'provider'           — the handler verifies with the provider's own SDK
  · verification: 'none'               — deliberately public (gateway / IP allow-list owns it)
A signature-verified webhook also needs its shared secret in VOLTRO_WEBHOOK_SECRET_<ID>;
the framework mints no secret for you.
```

`verification: 'none'` is a legitimate answer when a gateway or IP allow-list
owns the trust boundary. It has to be *said*, which is the whole change.

**3. A mysql / mariadb / mssql URL asking for TLS the dialect cannot honour.**
Both dialects used to DROP a TLS request rather than reject it, so
`DB_URL=mysql://…?ssl=true` connected in plaintext with no warning. Only the two
modes the cross-dialect `ssl` boolean can express are accepted; everything else
throws where the connection is built — which is `voltro dev`, `voltro serve`,
`voltro db apply` and `voltro migrate` alike.

```text
DB_URL '?sslmode=verify-full' is not supported by the mysql/mariadb dialect —
use 'require' (TLS without certificate verification) or 'disable' (plaintext).
```

| URL says | Result |
|---|---|
| `?sslmode=require` / `?ssl=true` / `?ssl=1` | TLS, certificate NOT verified |
| `?encrypt=1` | same (mssql only — tedious' spelling) |
| `?sslmode=disable` / `?ssl=false` / `?ssl=0` | plaintext, explicitly |
| `prefer`, `allow`, `verify-ca`, `verify-full`, `?ssl=yes`, a CA-profile name | **throws at boot** |

If your URL said `?ssl=true` you were being lied to — that connection has been
plaintext, and it is real now. **Confirm your server accepts TLS before rolling
out.** Check it from the database rather than from the config:

```sql
-- mysql / mariadb: empty = plaintext, a cipher name = encrypted
SHOW STATUS LIKE 'Ssl_cipher';
-- mssql: FALSE / TRUE
SELECT encrypt_option FROM sys.dm_exec_connections WHERE session_id = @@SPID;
```

postgres, sqlite and turso are unaffected.

### In the container — `voltro serve` / `voltro start`

These three fire only on a deploy environment (`NODE_ENV=production` or
`staging`), which is exactly why they are the expensive ones: nothing on your
machine reproduces them.

**4. Pending `migrations/*.migration.ts` that have never run against this
database.** It always had to run before serve; what changed is that skipping it
is loud. Serve's other schema guard is a declarative fingerprint diff, and a
file migration exists for the changes a state diff cannot infer — a data move, a
backfill, a cross-table rewrite. Those move no fingerprint, so the guard passed
and production ran un-migrated.

```text
serve: refusing to boot — 3 pending file-based migration(s) have never run against this
database. They perform the changes a schema diff cannot infer (data moves, backfills, table
splits), so the declarative fingerprint check below cannot see them and would have let this
process serve un-migrated data.

Run them from your pre-deploy job — `voltro db migrate .` (schema + files) or `voltro db files .`
(files alone) — or set VOLTRO_AUTO_MIGRATE=0 to bypass every boot schema check. `voltro serve`
never applies them itself: a rolling deploy would start N replicas and each would try.
```

Serve will not apply them for you, deliberately: a rolling deploy starts N
replicas, each would try, and the migration lock turns that into N-1 processes
blocked on boot. A refusal is recoverable in one command; a fleet wedged behind
a lock is not.

**5. `plugin-search` on the in-memory backend.** The heap-resident index is
per-process AND non-durable — it starts empty after every deploy and nothing
re-seeds it — so a single replica does not make it correct.

```text
plugin-search refuses to boot in production on the in-memory backend.

The memory index lives in THIS process's heap. Two consequences, both silent:
  • every replica holds a different index, so a result depends on which replica served you;
  • the index starts EMPTY after every restart/deploy, and nothing re-seeds it automatically.

Configure a durable engine in app.config.ts:
  searchPlugin({ backend: { engine: 'typesense',    url: …, apiKey: … }, indexes })
  …
```

If your deployment genuinely is one process that calls `backfillIndex` at
startup, declare it: `searchPlugin({ singleProcessMemoryIndex: true, indexes })`
— a claim the plugin holds you to, not a mute switch. Full reasoning:
[the memory backend refuses to boot in production](/docs/plugins/search#the-memory-backend-refuses-to-boot-in-production).

**6. `SSR_CACHE=postgres` with no database in the web process's environment.**
`voltro start` used to select the postgres ISR cache only when `PG_HOST` was
set, while every template and every deployment doc configures `DB_URL` — so an
app that asked for the shared cache the documented way silently got the
per-process memory one, reported at `info` as if it were the default. Both sides
go through the connection resolver now, and the mismatch is fatal on a deploy
environment:

```text
SSR_CACHE=postgres, but nothing in the environment names a database (looked for DB_URL,
DB_PRIMARY_URL, DB_DIRECT_URL, DB_MIGRATE_URL, DB_HOST, PG_HOST). Refusing to fall back to
the per-process memory cache: it is not shared between instances and does not survive a
restart, so the pages this process serves would differ from its replicas' with nothing to
indicate it.
```

Either give the web process a `DB_URL`, or drop `SSR_CACHE=postgres` and take
the memory cache deliberately. Off a deploy environment it warns and falls back
instead. Two knock-on effects with nothing to edit: pages declaring
`cacheInvalidatesOn` that had *no* live invalidation now have it (a real change
in origin load), and a web process with no database that declares
`cacheInvalidatesOn` gets a boot warning naming those routes.

## Taking only part of the jump — `--only`

Ids are what `--dry-run` prints:

```bash
voltro update --dry-run
voltro update --codemods-only --from 0.13.0 --only 0.14.0/03_pages-suffix,0.14.0/02_reactive-by-default
```

Useful when part of a jump is load-bearing (without it the app does not build or
its routes 404) and part is elective: take the necessary ones, get back to a
committable tree, then run the rest. An id that matches nothing in the jump is an
error listing the ids that do — "it did nothing" and "you typed it wrong" would
otherwise look identical.

There is no `--required` flag, deliberately. "Required" would have to mean *this
app does not run without it*, and that is a property of your app rather than of
the codemod: the pages rename is unavoidable for a project with pages and
irrelevant to an api-only one. You know which ones you need; we would be guessing.

## In a workspace, the whole workspace moves

Run `voltro update` anywhere inside a workspace — a `pnpm-workspace.yaml`, or a
`workspaces` field in an ancestor `package.json` — and **every member
`package.json` that declares `@voltro/*` is bumped to the same version**, with
the install running **once at the workspace root**.

This is not a convenience. Your api and your web app share generated types (the
rpcGroup) and a session cookie shape; if the api moves to 0.6.0 while
`apps/web` and `packages/ui-*` stay on 0.5.0, the mismatch shows up as a runtime
decode error in the browser, not as a build failure. Half-upgraded is the worst
state to be in, so `voltro update` never leaves you there.

The plan output — and `--dry-run` — lists every file it will touch:

```text
voltro update: 0.5.0 → 0.6.0
  workspace: /repo (4 package.json with @voltro/* deps)
  package.json
    @voltro/cli: ^0.5.0 → ^0.6.0
  apps/api/package.json
    @voltro/cli: ^0.5.0 → ^0.6.0
    @voltro/database: ^0.5.0 → ^0.6.0
  apps/web/package.json
    @voltro/client: ^0.5.0 → ^0.6.0
  packages/ui-admin/package.json
    @voltro/web: ~0.5.0 → ~0.6.0
  package manager: pnpm
  install runs in: /repo
```

A standalone (non-workspace) project is unaffected: its own `package.json`, its
own install, in place.

## Already bumped by hand? Recover the codemods

If you bump `@voltro/*` versions in `package.json` yourself and install first, a
plain `voltro update` sees the installed version already equals the target and
reports **"already on X — nothing to do"** — skipping the codemods AND the
printed manual steps for the delta you actually crossed. To re-apply them without
touching `package.json` again:

```bash
voltro update --codemods-only --from <version-you-came-from>
```

`--codemods-only` (alias `--run-codemods`) runs the codemods + manual notes for
`[from, to]` against the already-installed tree — no version bump, no install.
`--to` defaults to the installed version; pass it to pin an explicit delta.
`--from` also works on a normal `voltro update` to override the auto-detected
source version.

## The clean-tree guard

`voltro update` refuses to run on a dirty git working tree — commit or stash
first. Use `--dry-run` to preview without touching anything, or `--force` to
override the guard (you accept a mixed diff).

The guard is about the writes `update` makes on your behalf: the version bump
across every workspace `package.json`, the lockfile the install rewrites, and —
in a release that ships one — a **transform** codemod rewriting your source.
When the jump's codemods are all `manual`, as 0.34.0's 21 are, `update` writes
nothing under `src/` at all, and the work the printed notes describe is a
separate commit you author yourself.

`--help` / `-h` is answered *before* the guard, so `voltro update --help` prints the flag list even on a dirty tree. The same holds for `voltro doctor --help`.

## If the install fails

The bump is written before the install runs, so a failed install leaves your `package.json` on the target version — and **no codemods applied**. `voltro update` says so explicitly, because the codemods for a jump ship *inside* the target version: a failed install never put them on disk, so there is nothing that could have run them. Fix the install, run it, then apply the codemods you are missing with the command the failure message prints for you:

```bash
voltro update --codemods-only --from 0.5.0 --to 0.6.0
```

## What gets bumped

Every `@voltro/*` entry in `dependencies` and `devDependencies` — in every workspace member, see above — with the range style preserved (`^0.3.0` stays caret, `~0.3.0` stays tilde) unless you pass `--exact`. Non-registry specs (`workspace:*`, `catalog:`, `link:`, …) are left untouched — they're already resolved by your monorepo or catalog.

### And the peer dependencies the framework requires

`@effect/*` are **peer** dependencies, so your app declares them directly. When a
release moves its peer range, bumping only `@voltro/*` leaves you installed
against the old ones:

```txt
Aligning peer dependencies the framework requires:
  @effect/rpc       ^0.75.1 → ^0.76.0   (apps/api/package.json)
  @effect/platform  ^0.96.2 → ^0.97.0   (apps/api/package.json)
```

`update` reads those requirements off the freshly installed `@voltro/*` packages
and re-installs if anything moved. Without it your package manager only *warns*,
and the app compiles and boots on a graph the framework was never tested against
— which is the failure mode with no symptom until there is one.

It is deliberately conservative:

- **Only peers you already declare.** One resolved transitively is not `update`'s
  to add.
- **Only when your range is genuinely lower.** Pinned ahead, or pinned exactly at
  the floor (`0.76.0` vs `^0.76.0`), is left alone — that is a choice.
- **Only ranges it can judge** (`^`, `~`, `>=`, exact). A union (`^1 || ^2`), a
  bounded range, `workspace:` / `catalog:` — untouched.

If two framework packages disagree about one peer, it says so and changes
nothing: that is our bug, not yours to absorb silently.

## When the install cannot run on this host

Some projects install in a container with their own store, from an offline
mirror, or in a locked-down CI image. `voltro update` runs your package manager
on the machine you invoke it from, so on those hosts the install step fails —
and it fails *after* the version bump is written, which leaves the tree
half-upgraded.

`--no-install` splits the command where those projects need it split:

```bash
voltro update --no-install --to 0.14.0   # writes the bump, stops, says what is left
# ...install however this project installs...
voltro update --codemods-only --from 0.13.0 --to 0.14.0
```

Step two is not optional and the command says so: the codemods for a jump ship
**inside** the target version, so nothing can run them until the install has put
that version on disk.

## Which package manager

`voltro update` never assumes npm. It resolves your project's package manager in this order, starting in the app directory and walking **up to the repo root**:

1. The **`packageManager` field** in a `package.json` (the corepack standard) — authoritative, wins over any lockfile.
2. A **lockfile** at that level — `pnpm-lock.yaml`, `yarn.lock`, `bun.lock` / `bun.lockb`, `package-lock.json`.
3. **npm**, only when nothing declares one.

Walking up matters in a workspace: a scaffolded Voltro project keeps its lockfile at the monorepo root, so running `voltro update` from `apps/api` still finds `pnpm` rather than falling back to npm and running `npm install` against a pnpm workspace.

The same resolved manager is used for the **registry lookup** of the latest version (`pnpm view`, `yarn npm info`, `bun pm view`), so a private or scoped registry configured in your `.npmrc` / `.yarnrc.yml` is honored. `npm view` is only a last-resort fallback.

## Codemods

Each breaking public-API change in a release ships a **codemod**, and there are exactly two kinds:

- A **transform codemod** rewrites your source automatically — renamed imports, moved modules, changed component props, restructured call signatures. The rewrite is scoped to files that actually import the affected symbol. Where the affected sites can be found but the fix needs your judgment, it inserts `// TODO(voltro-migration): …` markers so you can locate every spot.
- A **manual codemod** prints written steps during the update, `appliesTo`-gated so you see it only when your app is actually affected. It writes nothing.

**0.34.0's are all manual — 21 of them, zero transforms.** That is not an
omission. The largest change in the release asks a question only you can answer
("who may call this procedure?"), and a transform could have answered it
mechanically for every procedure in your app — declaring your entire surface
open on purpose, in one commit nobody reads, with a reason the tool invented.
The framework does not sign that.

So on this jump the output is a wall of text and no diff:

```text
Manual steps required (could not be automated):

▸ 0.34.0/03_procedure-access-decision — Every wire-exposed procedure declares an access decision (`guards:` or `openAccess:`)
  YOUR APP WILL NOT BOOT UNTIL EVERY WIRE-EXPOSED PROCEDURE DECIDES WHO MAY
  CALL IT. …
```

Read the notes. They are the only artefact the upgrade produces, and each one
prints only because your tree matched it.

Codemods that span multiple versions run in order (e.g. upgrading `0.2.0 → 0.4.0` runs the `0.3.0` and `0.4.0` codemods in sequence).

## The database is separate

`voltro update` does **not** touch your database. Framework-owned `_voltro_*` tables (workflow runs, schedules, …) are reconciled by the declarative differ, not by codemods: when a release changes one of those tables, your next `voltro db apply` (or `voltro dev` boot, which auto-applies) picks up the change.

Use `voltro db apply` (the declarative diff), not `voltro db migrate` (the imperative file-runner) — only the former reconciles framework tables. If your app also ships `migrations/*.migration.ts`, `voltro db migrate .` runs both halves and is what refusal 4 above asks your pre-deploy job for.

## Restart every process — a running one keeps the OLD modules

`voltro update` changes what is on disk. A process that was already running when
you ran it keeps the module graph it loaded at boot, so it goes on executing the
previous version indefinitely — and against a `.framework` directory that has
since been rewritten.

That mix is worse than either version alone. A deployment lost half an hour to a
pod whose api had started before the upgrade: it served requests, reported
healthy, and returned no SSR at all, because the running process held the old
modules while the build output on disk was new.

Restart every process after an update, including ones you did not deploy:

```sh
kubectl rollout restart deploy/api deploy/web   # or: docker compose up -d --force-recreate
```

`voltro dev` reloads itself, so a development machine is not affected. Anything
long-running is — a `voltro serve` / `voltro start` container, a worker, a
process a supervisor kept alive across the upgrade.

## After the update — the checklist

```bash
voltro update
voltro doctor          # ← the one that can fail. Every undecided procedure + unverified webhook.
voltro db apply        # reconcile any changed framework tables
voltro typecheck       # your code against the new API surface
voltro dev             # the first boot that actually exercises the gates
```

**`update`, `db apply` and `typecheck` all pass on an app that will not start.**
That is the shape to internalise: the access decision is a runtime boot gate, not
a type error; the webhook declaration is a descriptor property, not a signature;
and the environment-shaped refusals are facts about a container you have not
started yet. The only two steps in that list that can tell you the truth are
`voltro doctor` and an actual boot.

For a deploy, add the container-side ones to your pre-deploy job before the
image rolls:

```bash
voltro db migrate .    # schema diff AND file migrations — refusal 4
# and check by hand: the mysql/mssql DB_URL's ?sslmode (3), a durable search
# backend (5), and DB_URL on the WEB process if it sets SSR_CACHE=postgres (6)
```

## Where to read next

- [Upgrading to 0.34.0](/docs/releases/upgrading-to-0-34) — the per-change narrative behind the 21 notes
- [Migrate](/docs/cli/migrate) — schema changes end-to-end
- [Build & start](/docs/cli/build-and-start) — production paths



---

<!-- source: en/cli/env.md -->
## Env

_voltro env — inspect and sync the typed-env manifest; emit env.generated.d.ts so CI tsc knows your env vars._

`voltro env` works with the **typed-env manifest** — the set of environment variables your app declares (via `envVar(...)`) and the framework requires. It's how a missing or malformed env var becomes a boot-time error with a clear message instead of a mysterious runtime failure.

```bash
voltro env            # check (default) — validate the current env against the manifest
voltro env check      # explicit
voltro env sync       # write/update .env.example from the manifest
voltro env types      # emit env.generated.d.ts (typed process.env for CI tsc)
voltro env turbo      # emit the turbo globalEnv/globalPassThroughEnv list
```

## `check` (default)

Validates the current environment against the declared manifest and fails (non-zero) on a missing required var or a value that doesn't parse. Run it in a pre-deploy step to catch a misconfigured environment before the app boots. Run with no subcommand and it defaults to `check`.

## `sync` — keep `.env.example` honest

Writes `.env.example` from the manifest so the committed template always matches what the app actually reads — new `envVar(...)` declarations show up without hand-editing.

## `types` — typed `process.env` for CI

Emits `env.generated.d.ts`, which types `process.env` to your declared vars. Commit it (or generate it in CI before `tsc`) so a typo in a `process.env.MY_VAR` reference is a type error, not a runtime `undefined`.

## `turbo` — monorepo cache correctness

Emits the `globalEnv` / `globalPassThroughEnv` entries for `turbo.json`, so Turbo's cache invalidates when a relevant env var changes and passes the right vars through to tasks.

## Related

- [Secrets](/docs/cli/overview#common-env-vars) — generating secret values with `voltro secret`.
- [Configuration](/docs/configuration/environment) — declaring env vars with `envVar(...)`.
