Prod pipeline

How schema changes flow from a PR through review to a production database. Why `voltro db apply` runs as an explicit deploy step, never on boot. Per-env fingerprint check + the refuse-to-boot behaviour.

The framework's hard rule: voltro start (the production runtime) NEVER auto-applies migrations. The dev-mode behaviour where voltro dev boots refuse-to-start on a blocked plan + auto-apply otherwise is intentionally not extended to prod. Schema changes mid-rolling-deploy without review are the largest data-risk class the framework could create; we don't.

What prod boot DOES is compare the declared-schema fingerprint against the latest _voltro_migration_plans.fingerprint. Match → serve traffic. Mismatch → refuse to boot with a structured error.

The end-to-end flow

┌─────────────────────────────────┐
│ 1. Developer edits schema in PR │
└────────────────┬────────────────┘
                 │
                 ▼
┌──────────────────────────────────┐
│ 2. CI runs `voltro db plan`      │
│    against a staging-style       │
│    snapshot. The printed plan    │
│    (classes + fingerprints) goes │
│    into the PR for review        │
└────────────────┬─────────────────┘
                 │
                 ▼
┌──────────────────────────────────┐
│ 3. Reviewer reads the plan       │
│    classification, fingerprints  │
│    + DSL annotations             │
│    Approves PR                   │
└────────────────┬─────────────────┘
                 │
                 ▼
┌──────────────────────────────────┐
│ 4. Merge to main                 │
│    CI/CD deploys new image       │
│    BEFORE traffic switch a       │
│    one-shot job runs             │
│    `voltro db apply` against     │
│    the prod DB (re-diffs live)   │
└────────────────┬─────────────────┘
                 │
                 ▼
┌───────────────────────────────────┐
│ 5. Traffic switches               │
│    Prod boot: fingerprint match   │
│    → serves                       │
└───────────────────────────────────┘

There's a slot for the apply step in every common deploy tool (k8s init container, ECS task pre-deploy hook, Heroku release phase, Cloud Run job-on-deploy, Fly.io release_command). The shape is identical: run a one-shot container/process that holds the migration credentials + executes voltro db apply. It re-diffs the live DB against the deployed code's declared schema and applies the resulting plan — there is no pre-serialised plan file to pass; the apply re-computes the diff at run time. The serving process never gets the migration-grade credentials.

Per-env fingerprint check

voltro start (prod runtime) does this on every boot:

const declaredFp = fingerprintSchema(declaredSnapshot(tables))
const lastApplied = await sql`
  SELECT fingerprint FROM _voltro_migration_plans
  ORDER BY appliedAt DESC LIMIT 1
`
if (lastApplied?.fingerprint !== declaredFp) {
  // PROD-MISMATCH outcome → refuse-to-boot.
  process.exit(1)
}

The structured error on mismatch:

[voltro:start] auto-migrate: SCHEMA FINGERPRINT MISMATCH —
  declared = a8f2c9d10b3f4e62
  live     = 8f507ba1e1aadad5
Run `voltro db apply --plan plan.json` from the deploy pipeline before serving.
exit 1

This propagates as a non-zero exit code, k8s + ECS + Cloud Run mark the pod/task CrashLoopBackOff / failed deployment → automatic rollback to the previous image. Operators see the loud crash + know to run the apply step.

VOLTRO_AUTO_MIGRATE=0 skips this check entirely — useful when migrations are handled by a separate ops process + the boot doesn't need to verify. Tradeoff: a drift goes undetected until the next manual voltro db drift run.

Previewing the plan in CI

Run voltro db plan against a snapshot of the prod schema (NOT prod itself — never expose prod credentials to CI). Add --json to emit the plan as a machine-readable artifact you can save and commit:

DB_URL=<staging-snapshot-url> voltro db plan --json > plan.json

The reviewer reads either the printed plan or the JSON. The saved plan.json then becomes the input to voltro db apply --plan plan.json (see below), applying the EXACT reviewed diff. A bare voltro db apply (no --plan) re-diffs live at deploy time instead — both flows are supported; the plan-file flow is the one the prod refuse-to-boot message points you to.

# CI step — runs in staging or a snapshot-replica env, against a real
# connection:
DB_URL=<staging-snapshot-url> voltro db plan

The printed plan goes into the PR for the reviewer:

schema diff: 4 operations, 0 blocked

  ✓ ALTER TABLE users ADD COLUMN bio text                  # safe
  ⊕ ALTER TABLE users ADD COLUMN email text                # backfill: sql`...`
  ⊕ UPDATE users SET email = 'unknown-' || id || '@local'
  ⊕ ALTER TABLE users ALTER COLUMN email SET NOT NULL
  ✓ CREATE INDEX users_email_idx ON users(email)           # safe

  safe: 2   needs-default: 0   needs-backfill: 1
  rename: 0   lossy: 0   blocked: 0
  fingerprint: 8f507ba1e1aadad5 → a8f2c9d10b3f4e62

A reviewer reads the SQL the applier will emit + checks the fingerprints. Blocked ops (red ) MUST be resolved in the PR before merge — voltro db apply refuses any plan with a blocked op (exit 2).

Applying against prod

The deploy job runs voltro db apply (re-diffing the deployed code's declared schema against the live prod DB):

voltro db apply --note 'PR #1234 — add user emails'

The applier:

  1. Introspects the live DB and plans the diff fresh (it does NOT ingest a plan file — the diff is computed against live at apply time)
  2. Refuses (exit 2) if any op is blocked, or refuses (exit 3) if NODE_ENV=production — so a bare apply runs in a one-shot job with NODE_ENV=staging, holding migration credentials, NOT in the serving process

NODE_ENV unset is no longer "not production". Every voltro db … and voltro migrate invocation resolves an unset NODE_ENV to production, exactly as voltro serve and voltro start do — so a bare voltro db apply in a pipeline that forgot the variable now refuses (exit 3) instead of silently applying an un-reviewed diff to production. Set NODE_ENV=development for a local database; use the --plan path below for a real one.

This is not only about the refusal. _voltro_traces and _voltro_undo_log are created only when tracing / undo capture are on, and both are on unless production — so an apply with NODE_ENV unset used to DECLARE two tables the serving container did not. The declared set is what the schema fingerprint hashes, so the apply recorded a fingerprint the container could not reproduce and voltro serve refused to boot with prod-mismatch, telling you to run the apply you had just run.

  1. Acquires the advisory lock + executes the plan
  2. Records the result in _voltro_migration_plans with source: 'auto-diff' + notes: 'PR #1234 — add user emails'

Applying a reviewed plan (--plan, works under NODE_ENV=production)

voltro db apply --plan plan.json applies a plan saved by voltro db plan --json. Unlike a bare apply it is allowed when NODE_ENV=production — because it re-introspects live and refuses unless BOTH fingerprints still match the saved plan:

  • fromFingerprint — the live schema the plan was generated against. Live drifted since? → aborts (exit 2, "live schema has drifted").
  • toFingerprint — the declared schema the plan targets. Schema files changed since? → aborts (exit 2).

So --plan can only ever apply the exact diff that was reviewed — never a stale or drifted one. That safety is what lets it run directly on the prod runtime, with no NODE_ENV-unset dance. It still refuses any plan with a blocked op (exit 2) and records the result with source: 'file'.

voltro db apply --plan plan.json --note 'PR #1234 — add user emails'

Because the diff is recomputed against live, an apply on an already- up-to-date DB is a clean no-op (schema is up to date — nothing to apply). That's what makes the apply safe to run in every pod of a stateless deploy.

Both spellings of the flag work: --plan plan.json and --plan=plan.json.

The FIRST deploy, against an empty database

Nothing special is required, and the plan you review is the whole story: on a database with no tables, voltro db plan --json includes the framework's own _voltro_* tables (the migration ledger, api keys, kv, outbox, traces …) plus actors, alongside your own. They are part of the declared schema, so they are planned, classified and applied by exactly the same code as your tables — expect a first-deploy plan to be ~20 operations larger than the diff you wrote.

Two consequences worth knowing:

  • The reviewed plan is complete. db apply --plan creates nothing beside it, so the fingerprint the plan was generated against is still the live schema when the guard checks it. (It did not used to be: the ledger tables were created before the fingerprint was taken, so the first deploy of every new database refused with the live schema has drifted one second after the plan was generated. Fixed.)
  • One table is deliberately absent from the plan: _voltro_migration_ops, the crash-resume ledger. It has to exist before the very first plan runs — the plan that creates everything else — so voltro db apply creates it itself, under the migration lock. A live _voltro_* table your schema does not declare is never planned for a drop, so it does not show up in the next diff either.

Apply timing relative to deploy

Two orderings, both common:

Apply before image swap (recommended): the new image is deployed but not serving yet. The apply runs against the live DB. Then traffic switches.

  • New schema is in place when the new code starts serving → no version mismatches
  • Old code is still serving until the swap → it must tolerate the new schema for a brief window
  • Constraints: every migration must be backward-compatible with the OLD code for the swap window. ADD columns (the OLD code ignores them) ✓. DROP columns (the OLD code might still write to them) ✗ → requires a 2-deploy dance (deploy 1: stop writing to col, deploy 2: drop col).

Apply after image swap: traffic is on the new code, the apply runs after. The new code must tolerate the OLD schema until the apply finishes.

  • New code is in place during apply → mid-apply rollback is easier (just rollback the apply, the new code can still talk to the old shape if you designed for it)
  • Migration is the LAST step → if it fails, the new code is already serving + needs the new schema. Outage.

For most teams the first is safer (the framework's _voltro_migration_plans.environment tracking expects this pattern). For specific workloads where a partial migration would be catastrophic (massive backfills, multi-hour rewrites), the apply runs first as a one-shot job, the deploy follows when it's done.

Multi-instance prod

The advisory lock around voltro db apply serialises concurrent applies. Two instances of the apply job racing the same plan → one acquires the lock, the other blocks until the first finishes + observes the post-apply fingerprint matches (it's a no-op now), exits 0.

This is the same mechanism that lets you run the apply in EVERY pod of a stateless deploy (the second-through-Nth no-op out fast) — useful when the deploy pipeline can't single out a designated migration runner.

Rollback paths

If the apply itself fails partway:

  • Postgres / MSSQL / SQLite: the transaction rolled back → live DB unchanged → fix the migration + re-run voltro db apply (it re-diffs from the unchanged state)
  • MySQL / MariaDB: DDL is implicit-commit, so completed ops stayed. Re-run voltro db apply — it re-diffs against the half-applied live shape and emits only the remaining ops. See multi-dialect.

If the apply succeeded but the new code is broken + needs to be rolled back:

  • The new-code rollback ≠ the schema rollback. The image deploys can revert via your normal CI/CD path; the schema stays at the new fingerprint.
  • There is no voltro db rollback <plan-id> for a planner plan. To back out a safe change, ship a schema PR that re-declares the old shape and apply it as a new forward plan; for lossy changes the old data is gone — restore from backup. See Rollback.

This is why the migration story is conservative + the prod refuse-to-boot is strict: once data is gone, no automated reversal brings it back.

What about staging?

The apply records environment: 'staging' when NODE_ENV=staging, else dev — each env's rows are tracked in the same _voltro_migration_plans table, tagged by environment. The CI flow typically applies to staging first, runs smoke tests against the new code, then to prod. The apply is idempotent against re-runs: because it re-diffs live each time, a second run against an already-migrated DB no-ops out.

The cloud dashboard surfaces per-env state with a multi-env tab in the cloud UI.

Rehearsing a migration against real data

The strongest check on this pipeline is not that each command exits 0 — it is that no row moved that you did not ask to move. This loop was built for a MariaDB cutover and caught three defects the framework's own suite did not, which is why it is written up here.

  1. Restore a backup into a throwaway database.

    voltro data backup ./rehearsal .
    DB_URL=$SCRATCH_URL voltro data restore ./rehearsal

    Use data backup / data restore — the NATIVE path — not the logical data export. The logical exporter re-shapes rows through the current declared schema, and the state a rehearsal exists to migrate from is precisely the one that does not match it.

  2. Take an exact census, before.

    SELECT table_name, COUNT(*) FROM ... -- one COUNT(*) per table

    It must be COUNT(*). information_schema.TABLE_ROWS is an estimate on InnoDB — routinely off by thousands, and it is what a fast version of this check would reach for. The slowness is the point.

  3. Run the exact production command sequence — the same one your deploy Job runs, in the same order:

    voltro db files .
    voltro db plan --json > plan.json
    voltro db apply --plan plan.json
  4. Take the census again, and require the difference to be explainable.

    A healthy run moves one row: the _voltro_migration_plans ledger entry. Anything else is a question, not a result.

Two things the census must get right, both learned by using it:

  • A soft drop is not a loss. With VOLTRO_SOFT_DROP=1 a dropped table reappears as <name>__dropped_<YYYYMMDDHHMMSS> with its rows intact. Reporting that as a vanished table trains people to ignore the check; reporting it as clean hides a real drop. Give it its own category.
  • A new table is not a discrepancy. A migration that adds one produces a table with no "before" count. Say so explicitly rather than letting a zero read as data loss.

To rehearse a schema several months old — the realistic case — craft the backup deliberately: a table a later migration added, a column a later one narrowed, a column the schema no longer declares. The planner is state-based, so it diffs live against declared and never replays a history; a six-month-old dump costs exactly one diff.

File-based migrations in this pipeline

voltro db apply runs pending migrations/*.ts first, then diffs — the same order the boot path uses.

voltro db apply --plan plan.json does not run them. It refuses when any are pending:

db apply --plan: refusing — 2 pending file-based migration(s).
  20260714_090000_split_full_name
  20260721_143000_backfill_slug

  These perform the changes a state diff cannot infer, so they change the shape
  this plan was computed against. Apply them and regenerate the plan:

    voltro db files .
    voltro db plan --json > plan.json
    voltro db apply --plan plan.json

That is not caution for its own sake. A saved plan was computed and reviewed against an earlier state; a file migration performs exactly the kind of change (a table split, a cross-table data move) that makes the plan stale. Running the migrations first would trip the fingerprint guard immediately afterwards and leave a half-applied deploy; running them after would apply a plan reviewed against a state that no longer exists.

So a pipeline that uses the saved-plan form needs voltro db files as its own step, before the plan is generated:

voltro db files .                          # authored data steps
voltro db plan --json > plan.json          # diff, now against the corrected shape
voltro db apply --plan plan.json           # reviewed, fingerprint-guarded

If you use plain voltro db apply instead, the first step is already included.