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 the apply runs in a one-shot job with NODE_ENV unset / staging, holding migration credentials, NOT in the serving process
  3. Acquires the advisory lock + executes the plan
  4. 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.

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.