Integrity and safety
You are about to let migrations touch a database other people depend on. This page shows the gates that decide whether the directory in front of you is the one you reviewed: the integrity file, replay validation, and pre-migration assertion checks — and what each one’s failure looks like. Which SQL those migrations are allowed to contain is a separate decision, made on Lint and gate unsafe SQL.
Prerequisites: a migration directory (see Generate migrations). The examples use fixed-version manual migrations and a local SQLite file.
The integrity file
Section titled “The integrity file”ptah migrations hash writes ptah.sum: a hash of every migration file,
committed alongside them.
ptah migrations hash --dir ./migrationsptah migrations validate --dir ./migrationsExpected output includes:
Wrote ./migrations/ptah.sum4 migration file(s) hashedOK: migrations directory matches ptah.sumThe file lists a directory-level hash and one line per migration file:
h1:tIMjgi/Ua3SltHhtGO6BksGhtMonFkeLmHl0+fM8p/o=0000000001_init.down.sql h1:iwBWserNbFegc3M+AZUbJlv00afJyT9smM0ezgVpk4o=0000000001_init.up.sql h1:5Lxcm8LHBcS4SgvVBg/frPdzEflRbvdjWh5UOSDATiM=Atlas-format directories use atlas.sum the same way.
Detect out-of-band edits
Section titled “Detect out-of-band edits”Any change to a hashed file — a hotfix applied in place, a merge gone wrong —
makes validation fail and name the file (exit 1):
ptah migrations validate --dir ./migrationsmigration directory does not match ptah.sum: changed: 0000000002_add_posts.up.sqlThe same drift blocks every native verb that executes SQL from the
directory. Each one verifies ptah.sum or atlas.sum before executing
anything, so a tampered migration never runs (exit 2):
error: migration sum verification failed:migration directory does not match ptah.sum: changed: 0000000002_add_posts.up.sqlThe gated set is the executing class, not a list of verbs that happened to be noticed:
| verb | what it executes from the directory |
|---|---|
up |
the pending up migrations, against --db-url |
down |
the rollback migrations, against --db-url |
test |
up and down, against --db-url |
checkpoint |
the whole history, against --shadow-db |
baseline |
the baselined history, against --shadow-db |
lint |
the whole history, against --dev-url |
repair --resume-from |
the remaining statements of the body that failed |
generate --replay |
the current schema history, against --dev-url |
generate --shadow-db |
prior history before candidate verification |
Each replaying command captures the migration directory once, verifies that snapshot, and executes those same bytes. A generation or checkpoint writer also compares the directory it is about to update with the authorized snapshot before it adds files, and a checkpoint writer compares the expected snapshot-plus-checkpoint again before publishing the sum. The sum is computed from that authorized expected state rather than from a newly reopened path. A change during the run is refused; the new checksum cannot legitimize history the command did not verify and replay.
repair is on that list only in its --resume-from spelling, and the
distinction is deliberate. A plain repair rewrites revision metadata and
executes none of the directory’s SQL, so it keeps working on a drifted
directory — clearing a dirty row is a recovery step you may need before you
can sensibly re-hash anything, and a gate there would send you to fix the
directory you are still trying to reason about. --resume-from executes
statements straight out of the migration file, so it gates like the rest.
status and set read the directory but execute none of its SQL. hash,
edit, rebase and rm exist to rewrite the integrity file, so verifying
it first would refuse their purpose — they are outside the class by the
predicate, not by oversight.
down was the last member outside the gate, and it was the worst one to leave
out. Measured on one hashed directory whose _init.down.sql was rewritten with
ptah.sum left stale, up exited 2 and refused while
down --target 0 --confirm exited 0 and executed the rewritten file; a
catalog census afterwards listed a table that appears in no committed
migration. Verification guarding the constructive direction and not the
destructive one is backwards, because down is the direction where the result
cannot be inspected afterwards — the objects are gone either way.
checkpoint mattered for a second reason. It replays the history onto a shadow
database and writes what it observed there into a new migration under a fresh
checksum, so drift was not merely executed but laundered into a directory that
verifies clean from then on.
Overriding the gate during a recovery
Section titled “Overriding the gate during a recovery”Refusing outright would remove a capability, so the gate has an escape:
PTAH_ALLOW_UNVERIFIED_MIGRATION_DIR=1 executes a drifted directory anyway. It
exists for the case that genuinely needs it — rolling back through a directory
whose sum is stale while recovering from a botched edit, where re-hashing first
would record the botched bytes as the intended ones.
It is an environment variable rather than a flag because the Atlas-compatible
surface asserts flag parity with the community binary, the same reason
PTAH_SKIP_CHECKS is spelled that way.
The command that owns a replay gate parses this variable at entry, before argument validation, directory access, or a database connection. A present empty value or another invalid boolean is therefore always an error; it cannot hide behind a dry run, a missing required flag, or a branch that happens not to reach the integrity check.
Using it is never silent. A run that overrides a real refusal says so on stderr and names what it accepted:
warning: PTAH_ALLOW_UNVERIFIED_MIGRATION_DIR is set; ptah.sum verification wasSKIPPED and this run is executing migration SQL that no reviewed checksumcovers:migration directory does not match ptah.sum: changed: 0000000002_add_posts.up.sqlA run against a directory that verifies skips nothing and therefore prints
nothing. The variable does not relax ptah migrations up --verify-sum:
that flag is an explicit request for a stricter contract than the default, and
an environment variable does not override what the command line asked for.
ptah-compat migrate validate, apply, status, set, new and diff
enforce the same gate on atlas.sum directories with Atlas’s own checksum
output, matching official Atlas behavior. Reporting is not exempt: on a hashed
directory whose only migration was deleted, an ungated migrate status
announced “Database is up to date”
(#974).
The verbs that write are on that list for a reason of their own. A gate that
fired only on the reading verbs would still let migrate new append a file to a
tampered directory and re-hash it on the way out, so the tampering would end up
inside a directory that verifies clean — the laundering shape recorded for
migrate import in #1095. All
six verbs refuse before anything is written, which is what the pinned Atlas
community binary v1.3.0 does on the same directory.
The fuller compatibility surface also gates formatted migrate down. Its
default policy matches native Ptah: an unhashed directory is allowed, a stale
sum is refused, and PTAH_ALLOW_UNVERIFIED_MIGRATION_DIR=1 permits an explicit
recovery with the warning above. Strict CE mode rejects the extension variable
and does not expose a successful down implementation, so the escape hatch
cannot make the strict surface more permissive than Atlas CE.
The sum file has to agree with itself, too
Section titled “The sum file has to agree with itself, too”Verification asks two questions, not one, and the second was missing until #1231: the entries must match the directory, and the directory-hash line on top must be the hash of the entry lines below it, in the order they are written. Both hash schemes bind that order, so moving a whole entry line — name and hash together — leaves a file that no longer hashes to the line it still carries.
That is what a reordered atlas.sum is, and it used to verify clean: every file
was found with the hash the sum recorded, and the hash recomputed over the
directory still matched the stale line on top. migrate validate printed
nothing and exited 0 while the community CLI exited 1; migrate apply ran every
migration.
The two tampered shapes are distinguishable and are reported differently, which is also what that CLI does:
atlas.sum |
reported as |
|---|---|
| entries reordered, top line untouched | checksum mismatch, with no entry named — the file contradicts itself, so no entry can be blamed |
| entries reordered, top line recomputed | checksum mismatch with L2: <file> was added — the file agrees with itself and disagrees with the directory |
Recovery is a decision, not a command: if the change is intentional, review
it and re-run ptah migrations hash; if it is not, restore the file from
version control. Use git diff on the migration directory to tell the two
apart. Run validate in CI and hash every shared directory so drift is
caught at review time, not at deploy time.
A directory that was never hashed
Section titled “A directory that was never hashed”Drift is one question; “there is no sum file at all” is a different one, and the two surfaces answer it differently on purpose.
Native ptah migrations up applies an unhashed directory. Hashing is
opt-in on this surface: a directory with no ptah.sum or atlas.sum has
never claimed integrity, so demanding one would break every project that has
not adopted ptah migrations hash yet. Turn the missing file into an error
where that matters — in CI, and on deploys of directories you do hash — with
--verify-sum (exit 2):
ptah migrations up \ --db-url "sqlite://app.db" \ --migrations-dir ./migrations \ --verify-sumerror: migration sum verification failed: ptah.sum not found; run `ptah migrations hash` to create itThat flag is the only thing on the native surface that rejects a never-hashed directory; a hashed one is always verified with or without it.
It is registered on migrations up, down, status and push. The
requirement is the same on each — carry a sum, and match it — but the subject
is not. On up, down and status it is the directory the run pulled, so
those three also print the resolved digest and the @sha256: reference that
pins it. On push it is the local directory about to be published, checked
before the upload; that command reports the tag it pushed and the resulting
digest as separate fields and constructs no pinned reference, because there is
no tag-resolved provenance to qualify yet.
ptah migrations validate asks the same question without executing anything,
and it takes an oci:// reference too:
ptah migrations validate \ --dir oci://registry.example.com/acme/app-migrations:v1It exits 0 when the artifact matches the sum it carries, 1 when a migration was
added, removed or edited out of band, and 2 when the artifact carries no sum at
all. Earlier releases answered stat oci://...: no such file or directory
here, which left the read-only integrity question answerable only by a verb
that writes.
HTTPS is the default here as everywhere else. --plain-http is registered on
this verb too, and like every other registration it is only for an explicitly
trusted local registry — never for a reference that looks like the one above.
Because the reference above is a tag, a successful run also prints the movable-tag qualifier described below, naming the digest the tag resolved to. A digest-pinned reference prints nothing extra.
That does not retire --verify-sum on the consuming verbs, and the reason is
timing rather than coverage. validate resolves the reference in its own
process; the consuming verb resolves it again in the next one. A movable tag
can select different bytes in between, so only the flag verifies the artifact
the same invocation is about to execute. Pin a digest, or pass the flag, or
both.
status is the one verb that runs no gate without the flag. It executes none
of the directory’s SQL, so it is outside the always-on class below, and it is
the verb an operator reaches for while diagnosing a directory that has drifted
— gating it by default would refuse to describe the thing being investigated.
Pass --verify-sum when the report itself has to be an integrity claim, such
as in a CI job that reads it to decide whether to deploy.
A sum check is worth what the sum’s provenance is worth. Either gate
compares a directory against the sum stored beside it. For a local directory
that sum was reviewed in version control next to the migrations. For an
oci:// artifact the sum travels inside the artifact, so anyone who can push
to the repository can rewrite the migrations, rehash them, repoint a tag, and
watch the check pass over bytes nobody reviewed. ptah migrations up therefore
qualifies the claim when a sum verifies over a tag-resolved artifact, naming
the digest the tag resolved to and the @sha256: reference that pins it:
Warning: oci://ghcr.io/acme/app-migrations:release is a movable tag: ptah.sumtravels inside the artifact, so verifying it proves the pulled files areinternally consistent, not that they are the reviewed ones. This tag resolvedto sha256:<digest>; pass oci://ghcr.io/acme/app-migrations@sha256:<digest> topin these exact bytes.The run still succeeds — the check did what it claims. A digest reference and
a local directory produce no such line, and
oci://ghcr.io/acme/app-migrations:release@sha256:<digest> is a digest
reference: the digest selects and is verified, the tag is only a label.
ptah-compat migrate apply, migrate status and migrate set refuse an
unhashed directory. Atlas treats a missing atlas.sum as a checksum error, so
the compatibility surface does too — measured against the pinned community
binary, which exits 1 and never creates the target database:
You have a checksum error in your migration directory.Please check your migration files and run 'atlas migrate hash' to re-hash the contents
Error: checksum file not foundNothing executes and the target is never opened, exactly as with a checksum
mismatch. Run ptah-compat migrate hash once and commit the atlas.sum it
writes. A directory that holds no .sql file anywhere in its tree — a freshly
created or .gitkeep-only migrations directory — is not a checksum error: it
reports No migration files to execute and exits 0, matching Atlas.
That scan reads the top level only, because an Atlas migration directory
executes exactly what its atlas.sum covers: top-level files whose name ends in
.sql, spelled in lower case. A .sql file in a subdirectory, or a top-level
.SQL, is not a migration and is not run
(#976).
That rule replaced a recursive one. Ptah used to discover and execute nested
files while hashing only the top level, so a migration in a subdirectory ran
with no checksum reaching it: migrate validate reported the directory clean,
and editing that file afterwards changed what ran without changing any hash.
Converging the executed set onto the covered set is what makes atlas.sum mean
something; refusing such directories, which is what the previous release did for
the unhashed half only, never closed the hashed half at all.
Ptah names what it declined. A file it found but did not treat as a migration is reported on stderr:
warning: sub/2_b.sql is not covered by atlas.sum and will not run; Atlas migrations are top-level files named *.sqlAtlas prints nothing here, and that silence is the reason Ptah does not copy it:
a directory whose only migration sits one level down hashes zero files,
validates clean, and applies nothing at exit 0, so a migration you committed
never runs and no output says so. Exit codes and stdout stay identical to Atlas;
only this stderr line is added. Move the file to the top level to have it run.
Native ptah migrations commands apply the same selection under
--dir-format atlas, which writes an atlas.sum and so is bound by the same
coverage. Auto mode without an atlas.sum keeps reading subdirectories and
stays self-consistent, because ptah.sum is computed from the same discovery
and covers every file it finds. The declined-file warning is currently printed
by the ptah-compat verbs only.
migrate lint is deliberately not gated, on either tool: inspecting a
directory that has drifted is the point of linting it. That exemption covers a
missing integrity file only — on a hashed directory that has since drifted,
both lint implementations exit 1.
migrate new and migrate diff are gated before they write. Both create a
migration file and rewrite atlas.sum, so an ungated run turned drift into
apparent cleanliness: the tampering survived and the checksum that would have
reported it was replaced. Since
#1086 the refusal is a preflight
— it happens before the migration file is created, before atlas.sum is
rewritten, and on diff before the dev database is connected to and before
--to and --dev-url are required at all, which is the order Atlas uses. A
--dir that does not exist yet is not a checksum error on either tool: both
verbs create it, which is how a project’s first migration gets written.
migrate import is gated on the source directory, with one exemption. A
source that carries an atlas.sum must verify against it before anything is
converted or written; a source that carries no atlas.sum at all is imported,
because a directory another tool wrote has never been hashed and importing it is
what the verb is for. That is the one place the rule differs from apply,
status and set, which refuse an unhashed directory outright, and it matches
Atlas on both halves. Until
#1095 the source was not checked
at all: a directory migrate apply refused was converted anyway, and the
destination was hashed over whatever the conversion produced — so a tampered
source came out as a directory migrate validate calls clean. Nothing is
written when the check fails; the destination directory is not created.
Directories read through ?format= are gated too. A goose, flyway,
liquibase, dbmate or golang-migrate directory is converted in memory and the
converted filesystem carries no integrity file — but the directory it was read
from carries atlas.sum beside its own migrations, and that is what is
verified, before the source layout is parsed and before the database is opened.
Run ptah-compat migrate hash --dir 'file://migrations?format=goose' once and
commit the file. The same applies when the layout comes from atlas.hcl
(migration { format = goose }) rather than from the URL.
Each layout is verified over the file set Atlas covers for it, which is not
always every .sql file:
| Layout | Covered by atlas.sum |
|---|---|
atlas, goose, dbmate, liquibase |
every top-level *.sql |
golang-migrate |
every top-level *.up.sql — the down file is never covered |
flyway |
V/B/R files anywhere in the tree; U undo files and everything a baseline squashes are dropped |
So editing a golang-migrate down file or a Flyway undo file is invisible to the
check, exactly as it is in Atlas. A directory that carries no atlas.sum and
whose covered set is empty — a golang-migrate directory holding only a down
file, say — is not a checksum error and is not refused.
For every layout, “not covered by atlas.sum” also means “not executed”: the
set migrate apply runs is the set the checksum it verified covers. Flyway was
the exception until #982 — its
importer selected a wider set than Atlas hashes, so a superseded baseline or a
lowercase-prefixed file could run SQL no checksum protected. The importer and
the hasher now share one selection rule, so that class of gap cannot reopen
without a failing test.
Replay on a dev database
Section titled “Replay on a dev database”Hashes prove the files are unchanged, not that the SQL executes. Add
--dev-url to also clean a disposable
dev database and replay
the whole directory on it:
ptah migrations validate \ --dir ./migrations \ --dev-url "sqlite://replay.db"Expected output includes:
OK: migrations directory matches ptah.sumOK: migration SQL validated on dev databaseA migration that no longer executes — here one that alters a table dropped by
an earlier edit — fails the replay (exit 2):
error: error validating migration SQL on dev database: replay migration 3 on dev database: failed to execute migration SQL: sqlite: SQL execution failed: SQL logic error: no such table: missing (1)SQL: ALTER TABLE missing ADD COLUMN nickname TEXTThe dev database is dropped clean on every run — point it at a scratch database of the target engine, never at a real environment.
The bytes a command runs are captured once
Section titled “The bytes a command runs are captured once”Local migration commands capture the migration directory before database
connection, checksum verification, provider registration, and destructive
linting. up, down, status, lint, and set therefore use the same
immutable SQL and metadata bytes throughout one invocation. Each command
compares two captures and aborts only when the observed captures differ.
This best-effort check cannot defeat coordinated writers or ABA changes that restore the original bytes before the next observation. Hostile writers require trusted immutable input, manifest or process controls, or filesystem-level snapshots. Relative CLI directories are rooted at the working directory and symlink escapes are rejected, while explicit absolute paths remain supported.
An atlas.hcl migration directory remains bound to the project directory
handle opened for config evaluation until capture completes; replacing the
project pathname does not retarget it. Relative and absolute project values
must remain inside that root after symbolic-link resolution. Parent traversal,
outside absolute paths, and symbolic-link escapes are refused when their
resolved destination leaves the root.
Pre-migration checks
Section titled “Pre-migration checks”Guard a migration on a data-state precondition with a -- +ptah check
directive, which runs before the migration’s statements and aborts if the
assertion fails:
-- +ptah check name="users_empty" assert="SELECT count(*) = 0 FROM users" on_fail=abortDROP TABLE users;A check guards the direction it is written in. A -- +ptah check in the up
body runs before the migration; one in the down body runs before the rollback,
which is where a precondition is often worth asserting most. The two are
independent: an up check does not guard a rollback and a down check does not
guard the migration.
Each check is a separate read against committed state that runs before the statements of its own body, so a failing assertion leaves nothing applied and exits non-zero.
What a check may say
Section titled “What a check may say”The vocabulary is deliberately small, and every limit below is a refusal rather than a silent narrowing:
| Attribute | Accepted |
|---|---|
name |
any label; it names the failure |
assert |
exactly one read-only top-level SELECT returning one column and one row |
on_fail |
abort only — there is no warn, skip or continue |
An assertion of any other shape fails closed before the migration runs, and so
does a write-shaped one. On SQL Server, NEXT VALUE FOR is refused statically
because it advances a sequence.
The read-only guarantee has two strengths depending on the target. PostgreSQL,
CockroachDB, YugabyteDB, Spanner, MySQL and MariaDB run the assertion in a
database-enforced read-only session. SQLite, SQL Server and ClickHouse get a
plain session, so there the guarantee rests on the static shape check alone. Checks are rejected under --tx-mode all on a real apply (a
pooled read cannot see the batch’s uncommitted state) – see
what --tx-mode all cannot carry
for the whole interaction – and
ptah migrations up --skip-checks is an emergency bypass. On the
Atlas-compatible surface that bypass is spelled PTAH_SKIP_CHECKS=1, because
Atlas registers no --skip-checks on migrate apply and ptah-compat adds no
flags Atlas does not have. This is the open, local half of Atlas Pro’s
pre-migration checks; the Cloud approval-policy half is intentionally out of
scope.
Checks in a dry run
Section titled “Checks in a dry run”A check is a read, and a dry run intercepts only writes. Evaluating every check in a preview would therefore ask each migration’s guard about state that only exists once its predecessors apply — state the dry run has, by construction, refused to produce. The answer would be a fact about the preview, not about the migrations.
So a dry run evaluates a migration’s assertions only where the state it observes is the state a real apply would evaluate them against: the first migration executed in the run. That is a position in the run, not a version and not a place in the directory — a migration sitting second in its directory is first in the run once its predecessor is applied, and its checks are evaluated normally from then on.
Every check is still parsed and statically validated wherever it sits. A
malformed -- +ptah check directive, or an assertion that is not a single
read-only SELECT, is decided by its text alone and is reported in a dry run
exactly as on a real apply. Only the database evaluation is deferred, and the
run names what it deferred on stderr:
Deferred pre-migration checks for 1 migration (20260101000002): a dry run doesnot create the state they assert on, so they are evaluated on apply.A dry run under --tx-mode all follows the same rule rather than refusing the
directory: it opens no batch transaction, so the uncommitted-state problem that
motivates the refusal on a real apply does not arise.
Exit codes are the contract
Section titled “Exit codes are the contract”Every gate above communicates through the CLI exit code — 0 clean, 1 for
an expected negative result such as drift found, 2 for errors and refused
operations — so CI wiring is a matter of checking $?. The full per-command
table is in Exit codes.
Next steps
Section titled “Next steps”- Deciding which SQL is allowed to run at all? Lint and gate unsafe SQL.
- Wiring these gates into pull requests? CI.
- A gate fired and the migration needs rework? Maintain migration history.
- Asserting behavior rather than safety? Test migrations and schemas.