The schema migration runner. The only TRUSTIVAN binary that takes an argument, and the only one that exits rather than running until stopped.

Synopsis

up and refingerprint take no arguments; down requires a step count. Anything else — no argument, a missing or non-positive count, an unknown flag, or an unrecognised word — exits non-zero with a usage message.

Subcommands

On success each prints a confirmation and exits 0. On failure the error is printed and the process exits non-zero, which is what makes it usable as an init container or a deployment gate.

down needs a count, and guards the audit trail

migrate down refuses without a step count; there is no “all”. Before it reverses anything it prints each migration in the plan and the DROP, DELETE and ALTER TABLE … DROP statements in its down file:
A plan that would remove the audit trail or an identity table — the identity audit events or their hash chain, users, user or workload identities — is refused unless --force-drop-audit is given. The trail is evidence you may be obliged to keep, and nothing restores it forward. The printed plan is re-checked under the migration lock, so a concurrent migrate up makes the rollback refuse rather than reverse something other than what was shown. A down migration returns the schema, not the data it dropped. On any database whose contents matter, take a backup first.

refingerprint after an identity change

A finding’s identity is a fingerprint computed from its stored identity columns. When a release changes the rule that computes it, fingerprints already in the database are stale — and a finding whose fingerprint moves would otherwise be seen as new, losing the triage state, age and audit trail attached to the old one. migrate refingerprint recomputes them in place, carrying that history across:
Run it after migrate up, on any deployment taking a release whose notes say the identity rule changed. It is idempotent and safe to repeat, and it is a no-op on a deployment where nothing has moved — so running it when in doubt costs a pass over the table and nothing else. It is a migration command rather than something the application can invoke because it runs as the migration role, which owns the schema and is exempt from row-level security. That exemption is what lets a single pass cover every tenant.

Read the report

The command prints a report on success and on failure — an interrupted run has still rewritten rows, and the count is what tells you where it got to. Two lines in it need a human:
  • Collisions. The corrected fingerprint already exists on the same asset, so the rewrite would merge two findings that each carry their own triage state. These are the duplicates the correction collapses, and which one survives is a decision the command will not make for you.
  • Unrepairable. The stored columns cannot rebuild an identity, so the finding was left on its existing fingerprint.
Neither stops the run, and neither is silent. A run reporting zero of both has rewritten everything it needed to.

Environment it reads

MIGRATIONS_PATH matters more than it looks. The default is relative, so running the binary from a directory that is not backend/ finds no migrations. In the container image the path is already correct; from source, run it from backend/ or set the variable to an absolute path. The binary also loads ../.env if present, which is what makes it work from backend/ in development without exporting anything.

Running it

Through make, which handles the working directory:
From source:

Not from the published image

The container image ships the API and worker binaries and the migration files, but not the migrate binary itself. There is no docker run … /app/migrate up. So for a containerised deployment, run migrations one of these ways:
  • Build a migration image from the same source tree, adding cmd/migrate to the build stage and copying the binary into the final stage. It needs the migrations/ directory, which the existing image already copies to /app/migrations.
  • Run migrate up from a checkout with network access to the database, before the deployment rolls.
Whichever you choose, it has to finish before the new API and worker start — see below.

Where it belongs in a deployment

Run migrate up to completion before starting the API or worker, not alongside them. Both expect the schema they were built against, and a process that starts against an older schema fails in whatever way the missing column happens to produce. The natural shapes are an init container that must exit 0 before the application containers start, or a deployment-pipeline step gated on its exit code. Do not run it as a sidecar. Running it concurrently on several nodes will not corrupt the schema — the underlying migration library takes an advisory lock in Postgres, so a second runner waits rather than applying the same migration twice. That is a safety net, not a design: run it once, from one place, then start everything else. See Upgrades.