Skip to content
PtahPtah

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.

ptah migrations hash writes ptah.sum: a hash of every migration file, committed alongside them.

Terminal window
ptah migrations hash --dir ./migrations
ptah migrations validate --dir ./migrations

Expected output includes:

Wrote ./migrations/ptah.sum
4 migration file(s) hashed
OK: migrations directory matches ptah.sum

The 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.

Any change to a hashed file — a hotfix applied in place, a merge gone wrong — makes validation fail and name the file (exit 1):

Terminal window
ptah migrations validate --dir ./migrations
migration directory does not match ptah.sum:
changed: 0000000002_add_posts.up.sql

The 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.sql

The 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.

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 was
SKIPPED and this run is executing migration SQL that no reviewed checksum
covers:
migration directory does not match ptah.sum:
changed: 0000000002_add_posts.up.sql

A 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.

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):

Terminal window
ptah migrations up \
--db-url "sqlite://app.db" \
--migrations-dir ./migrations \
--verify-sum
error: migration sum verification failed: ptah.sum not found; run `ptah migrations hash` to create it

That 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:

Terminal window
ptah migrations validate \
--dir oci://registry.example.com/acme/app-migrations:v1

It 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.sum
travels inside the artifact, so verifying it proves the pulled files are
internally consistent, not that they are the reviewed ones. This tag resolved
to sha256:<digest>; pass oci://ghcr.io/acme/app-migrations@sha256:<digest> to
pin 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 found

Nothing 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 *.sql

Atlas 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.

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:

Terminal window
ptah migrations validate \
--dir ./migrations \
--dev-url "sqlite://replay.db"

Expected output includes:

OK: migrations directory matches ptah.sum
OK: migration SQL validated on dev database

A 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 TEXT

The 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.

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=abort
DROP 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.

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.

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 does
not 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.

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.