Drift detection

How the framework detects schema drift (live DB ≠ last applied fingerprint), what causes drift, and how to reconcile it — with the planner's introspection, or via corrective plan, or by accepting + re-baselining.

Drift = the live database's schema doesn't match the fingerprint of the last applied voltro db apply plan. It's a passive detection — the framework only knows about drift after introspecting + comparing fingerprints. The detection itself is cheap (one COUNT + one fingerprint compare per check); the reconciliation path depends on cause.

How drift gets detected

Three trigger paths:

  1. Manualvoltro db drift runs the check on demand: exit 0 match, 3 no baseline, 4 drift
  2. On boot (dev) — every voltro dev boot runs the planner, which detects drift implicitly (the plan will be non-empty)
  3. Periodic (cloud) — the cloud dashboard polls each app's /_voltro/inspect/migrations endpoint; drift state is in the response

All three paths produce the same DriftSnapshot shape:

{
  isDrifted: boolean,
  liveFingerprint: string,             // current introspected schema fingerprint
  lastAppliedFingerprint?: string,     // the BASELINE the newest row recorded
  lastAppliedAt?: string,              // when it was applied
  lastAppliedId?: string,              // plan id
}

isDrifted: falseliveFingerprint === lastAppliedFingerprint, or no baseline was recorded — nothing compared is not the same as nothing changed, and it is never reported as drift.

What it compares, and what it does not

The baseline is _voltro_migration_plans.liveFingerprint: the fingerprint of the live schema as it was immediately after the last voltro db apply. Not fingerprint, which is the declared snapshot's hash — introspection cannot recover everything a declaration carries (generated expressions, maxLength, sensitivity markers), so a live hash and a declared hash never agree and comparing them reports drift on every clean database.

Both sides hash the whole live schema, framework tables included. A framework upgrade that adds a _voltro_* column therefore shows as drift until the next db apply records a new baseline — honest, since the live schema did change, and it self-heals on the apply the upgrade needs anyway.

No baseline yet

A ledger row written before liveFingerprint existed has no baseline, and so does a database whose schema was already current when it upgraded. voltro db drift says so and exits 3:

$ voltro db drift
db drift: no drift baseline recorded yet cannot compare

Run voltro db apply once — a no-op apply backfills the baseline too. Until then use voltro db plan, which compares declared against live directly.

Exit 3 is deliberately not 0. "Did not compare" is not "clean", and a CI gate on the exit code has to be able to tell them apart — otherwise it passes vacuously on a stable schema, which is the failure drift detection exists to prevent.

exit meaning
0 compared, live matches the baseline
3 no baseline — did NOT compare
4 compared, live diverged

Common causes

Manual DDL

Someone ran ALTER TABLE ... or CREATE INDEX ... via psql / DataGrip / Adminer instead of the framework. The live DB has changes the planner's history doesn't reflect.

$ voltro db drift
db drift: live schema DIVERGED from last applied state
  baseline:      8f507ba1e1aadad5  at 2026-06-15 14:32:00  (plan_mig_5k78)
  live now:      a8f2c9d10b3f4e62

Something changed the live schema after the last apply. This command can see
THAT it changed, not what or who the fingerprints are hashes, not a diff.

Your DECLARED schema is already satisfied `voltro db plan` reports 0 operations
against this database. So the live schema is not wrong, only unrecorded: something
applied a change without going through the planner (a hand-run ALTER, a DBA
window, a restored dump), or it touched a table your code does not declare.

If that was deliberate and the schema is right, record it:

    voltro db drift --accept

It updates the latest ledger row's baseline to the live schema and invents no
history entry. Drift then measures from here.

When db plan is NOT empty it says that instead, with the count — so the two cases are told apart by the command rather than left to you. It used to close by asserting that a zero-operation plan meant "a table your code does not declare", which is one of two possibilities and the less likely one.

Out-of-band auto-applier

Multiple tools applying to the same DB (the framework + a separate Flyway / Liquibase process / hand-written deploy script). The other tool's changes don't go through _voltro_migration_plans.

Truncated history table

_voltro_migration_plans was truncated, restored from a backup, or the DB was restored to a point-in-time before the latest applies. The live schema is post-apply but the table doesn't know it.

Replica fingerprinted instead of primary

The drift detector ran against a read-replica that's lagging. Wait for the replica to catch up + re-check. (The framework's drift detector targets primary by default; this only bites when the user explicitly points the check at a replica URL.)

Reconciliation paths

Path 1 — accept the live state

When the live DB IS what you want (the manual DDL is correct, only bypassing the planner was sloppy), first make sure your declaration says so: edit the *.entity.ts files until voltro db plan diffs empty. Then record the live schema as the baseline:

voltro db plan            # must report 0 operations — declared == live
voltro db drift --accept

--accept writes the current live fingerprint onto the newest ledger row. Drift measures from there, and the next voltro db drift exits 0.

It refuses unless db plan is empty, and that guard is the whole point. Accepting a schema with operations still outstanding would record "this is what we applied" over a state nobody applied, and every later drift check would measure against that fiction. If operations are pending, run voltro db apply — that applies them AND writes a real baseline of its own.

It backfills the newest row rather than inserting one, because no migration ran and a history entry claiming otherwise would be worse than the gap it fills.

An empty voltro db apply does NOT re-baseline an existing baseline. It writes no DDL and no history row, so there is nothing for a --note to attach to — it will tell you the note was ignored rather than swallow it. (It does fill a baseline that is still NULL, which is a different case: a database that never had one.) --accept is the command whose job is to say "the live schema is right, the ledger just did not know".

Path 2 — corrective plan against drift

When the live DB has accumulated cruft + the declared schema is what you want:

voltro db plan         # see the diff between code + live
voltro db apply        # execute the diff, removing the drift

The plan diff will show the corrective ops:

schema diff: 2 operations, 0 blocked

  ✗ DROP INDEX manual_idx_we_forgot_to_remove  # safe (no FK depends on it)
  ⊕ ALTER TABLE users ADD COLUMN missing_field text  # backfill: sql`'default'`

  fingerprint: a8f2c9d10b3f4e62 → 8f507ba1e1aadad5

Apply lands the corrections + the new fingerprint matches the declared schema.

Path 3 — declared schema needs updates

The live DB has a column the declared schema doesn't reference, and you WANT to keep that column in the schema. Update the schema TS file to add it:

// users.entity.ts
export const users = table('users', {
  id: id(),
  email: text(),
  extra_field: text().nullable(),   // add to declared
})

Now the live shape matches the declared shape after the next plan (which will be empty). The drift "fixed itself" through code changes.

Drift on prod

Production drift is the most important to catch quickly because it suggests an unauthorised change to the production DB. The cloud dashboard's drift detector runs every 5 minutes against each customer's prod app + surfaces the divergence as soon as it appears.

Surfaces:

  • Dashboard banner on the affected app's Migrations tab
  • Audit log row tagged drift.detected

Out-of-band channels (Slack, email, PagerDuty) are intentionally NOT in the framework. Subscribe to the audit log via your existing observability stack — every drift event is a row your SIEM / monitoring already consumes, and your team's incident process kicks in from there.

The org's incident response process kicks in from there. Common immediate actions:

  1. Check the audit log for any non-CI DB access
  2. Run voltro db drift against a snapshot to confirm the divergence (the check is a structural fingerprint compare — it tells you THAT the schema diverged, not which rows changed)
  3. Decide: corrective plan or re-baseline?

What the dashboard shows

The Migrations tab's drift banner renders when isDrifted: true:

⚠ Schema drift detected
  last applied: 8f507ba1e1aadad5  at 2026-06-15 14:32:00
  live now:     a8f2c9d10b3f4e62

  → Run `voltro db plan` to see what your code expects vs the live DB.

Click → expands to a comparison view showing the introspected live shape + the declared shape, highlighting the divergent tables. (Cloud dashboard only; local devtools shows just the banner without the comparison view.)

What about replicas?

Each replica has its own catch-up state. The framework's drift detector compares against PRIMARY by default; replicas catch up via the normal replication stream + reach the same fingerprint within their lag window.

If you specifically want to monitor replica drift (rare; mostly relevant during major maintenance windows), the cloud dashboard's Settings page allows enabling "Replica drift monitoring" which polls each replica URL separately + alerts on lag > N minutes.

Drift after rollback

voltro db rollback itself records a new row in _voltro_migration_plans, so the fingerprint of that row matches the post-rollback state. No drift gets reported as a side-effect of rollback.

If something else changed the live DB between the original apply + the rollback, that drift was already present + the rollback doesn't surface it differently. Run voltro db drift after rollback to confirm reconciliation if you're suspicious.

Detecting drift is the easy part

The hardest part of drift response is figuring out what changed + who did it. The framework can tell you that the fingerprints differ + show the structural diff. It can't tell you who ran the DDL or why. Pair the framework's drift detector with:

  • Database audit logs (Postgres pgaudit, MySQL audit plugin, MSSQL Audit, SQLite no-op)
  • Network access logs (who reached the DB during the drift window)
  • Application logs filtered by trace id (if the drift happened during a request, trace shows the caller)
  • The team's normal incident response (Slack channel for accidental changes, post-mortem cadence)

TL;DR

Detect:    voltro db drift
Fix code:  voltro db apply   (apply corrective plan from current diff)
Adopt DB:  edit the *.entity.ts to match live until db plan is empty, then voltro db drift --accept
Backup:    if data was lost, restore from your DB backup system — the framework can't help