Skip to content
PtahPtah

Atlas migrate commands

You have an Atlas-format migration directory — or scripts that manage one with atlas migrate ... — and want to run that workflow through Ptah. This page covers the ptah-compat migrate verbs: what each one does, a worked example, and the behavior details that differ from a first guess. Every invocation on this page uses the separate ptah-compat drop-in binary; the install steps plus the flag translation rules are on the Atlas compatibility overview.

Atlas-compatible command Ptah behavior
ptah-compat migrate apply Atlas-format apply path equivalent to ptah migrations up; executes every Atlas OSS directory format and refuses an Atlas directory whose atlas.sum is missing or stale.
ptah-compat migrate down Forwards to ptah migrations down with mapped Atlas flags and the Atlas revision-table layout by default; --dev-url verifies the rollback plan first. Failed rollbacks retain Ptah’s recoverable dirty state.
ptah-compat migrate status Atlas-format migration status with Atlas revision-table metadata; refuses an Atlas directory whose atlas.sum is missing or stale.
ptah-compat migrate hash Forwards to ptah migrations hash; writes atlas.sum by default.
ptah-compat migrate validate Silently verifies atlas.sum on success; --dev-url replays migrations to validate SQL execution.
ptah-compat migrate lint Forwards to ptah migrations lint with Atlas changeset selectors, dev-database replay, and Atlas report output.
ptah-compat migrate new Creates an Atlas single-file skeleton migration; equivalent to ptah migrations create.
ptah-compat migrate set [version] Moves Atlas revision history to the selected version without executing migration SQL; refuses an Atlas directory whose atlas.sum is missing or stale.
ptah-compat migrate diff Replays local migrations on --dev-url, diffs them against the desired state, and writes the selected layout with atlas.sum updated atomically.
ptah-compat migrate import Imports local file:// migration directories from Atlas-supported formats into a separate Atlas single-file directory.
ptah-compat migrate checkpoint [name] Forwards to ptah migrations checkpoint; writes a cumulative-schema checkpoint, Atlas single-file format by default or the ptah pair under --dir-format ptah.
ptah-compat migrate test [paths] Forwards to ptah migrations test with Ptah-native YAML test cases.
ptah-compat migrate edit {name | version} Forwards to ptah migrations edit and rewrites the directory checksum.
ptah-compat migrate rebase {name | version} Forwards to ptah migrations rebase; one migration per run.
ptah-compat migrate rm {name | version} Forwards to ptah migrations rm and rewrites the directory checksum.
ptah-compat migrate ls Forwards to ptah migrations ls; lists the directory’s migration files with -s/--short and -l/--latest, and refuses an Atlas directory whose atlas.sum is missing or stale.
ptah-compat migrate show {name | version}... Forwards to ptah migrations show; prints each named migration’s SQL, and refuses an Atlas directory whose atlas.sum is missing or stale.
ptah-compat migrate push Atlas CE boundary stub; the native ptah migrations push to any OCI registry is the open replacement.

Per-verb status detail — Atlas differences, waivers, and the inputs that fail explicitly — is on Atlas-compatible commands.

Almost every migrate verb registering --dir defaults it to file://migrations, so ptah-compat migrate apply --url "$DATABASE_URL" run from a project root reads ./migrations with no flag at all. That is apply, diff, edit, hash, lint, ls, new, rebase, rm, set, show, status, test and validate, and it includes the eight verbs Atlas documents the default on, with the same value (#1241).

Two verbs are outside it, and the difference is worth knowing before a run: migrate checkpoint reaches ./migrations without the flag carrying a default, and migrate down refuses a run with no directory at all — migrations directory is required — because rolling back against a directory nobody named is a destructive guess.

The default is a default, not a fallback. Every layer that names a directory outranks it — --dir, PTAH_DIR, PTAH_MIGRATIONS_DIR, and atlas.hcl migration.dir — and a --dir naming a directory that is not there fails rather than quietly reading ./migrations:

Terminal window
ptah-compat migrate apply --url "$DATABASE_URL" --dir file://migrtions
# Error: atlas migrate apply --dir: open migrations directory: openat migrtions: no such file or directory

The default also does not skip anything. A defaulted directory reaches the atlas.sum gate exactly as an explicit one does, so an unhashed or drifted ./migrations is still refused with Error: checksum file not found or Error: checksum mismatch.

The writing verbs create the directory they are pointed at, including missing parents: ptah-compat migrate new add_users --dir file://db/migrations creates db and db/migrations. A path component that already exists and is not a directory is still refused, and nothing is written.

Atlas-style migration files can include migration.sql, down.sql, the default checks.sql, and ordered checks/*.sql sections inside txtar archives. Ptah executes migration.sql on apply and down.sql on rollback, and enforces every check file as a pre-migration gate. Each assertion must be a top-level SELECT returning exactly one column and one row with a truthy scalar. All assertions in a file must pass unless a file-level -- atlas:assert oneof requires at least one; an empty oneof file fails closed.

Dialect-aware splitting preserves PostgreSQL escape strings and MySQL/MariaDB semantic comments instead of rewriting assertion SQL. Checks use a dedicated physical session that Ptah discards afterward. Transaction-capable drivers roll back, while ClickHouse uses the disposable session directly because its driver does not implement transactions.

For MySQL/MariaDB executable comments, Ptah validates the effective SQL and evaluates version guards against the connected server. Numeric prefixes shorter than five digits remain part of the executable SQL body. Hidden statement delimiters and non-SELECT effective bodies fail closed before query execution.

A guard counts as live whenever it is less than or equal to the server’s own version number (major*10000 + minor*100 + patch), so /*!80000 ... */ runs on MySQL 8.0 and on every later release too. A large guard is not a way to keep SQL inert: MySQL 26.7 encodes as 260700 and honors /*!99999 ... */. MariaDB ignores the MySQL 50700-99999 band whatever its own version is, and treats higher numbers as MariaDB versions.

A failure aborts before any body statement, matching Atlas’s enforcement point. Ptah ignores unrelated embedded files.

Create an Atlas-style migration:

-- atlas:txtar
-- migration.sql --
CREATE TABLE users (
id integer PRIMARY KEY,
email text NOT NULL UNIQUE
);
-- down.sql --
DROP TABLE users;

Name the file with a migration version, for example:

migrations/20260721120000_create_users.sql

Hash and validate the directory:

Terminal window
ptah-compat migrate hash --dir file://migrations
ptah-compat migrate validate --dir file://migrations

Successful hash and validate commands are silent. The hash command writes migrations/atlas.sum; commit that file with the migration.

Apply it, then check status:

Terminal window
ptah-compat migrate apply \
--url "$DATABASE_URL" \
--dir file://migrations
ptah-compat migrate status \
--url "$DATABASE_URL" \
--dir file://migrations

Expected output includes:

Migrating to version 20260721120000 from 1 pending migrations.
Migration complete. Current version: 20260721120000
Migration Status: OK
-- Current Version: 20260721120000
-- Next Version: Already at latest version
-- Executed Files: 1
-- Pending Files: 0

migrate status is the one compatibility verb whose output a pipeline parses with a machine rather than reads, so it mirrors the Atlas report shape by default — field names, sentinel strings and value encodings included. A deploy gate written as grep -q 'Migration Status: OK' works unchanged, -- Current Version: matches, and a database with nothing applied reports the sentence No migration applied yet rather than 0. Native ptah migrations status keeps its own block: the two surfaces are allowed to differ and only the compatibility one is a contract.

Recovering from a migration body that failed part-way

Section titled “Recovering from a migration body that failed part-way”

When a transactional body fails and Rollback succeeds, ptah-compat removes the zero-progress revision row. The next apply retries the whole file without --allow-dirty, matching Atlas. Native ptah migrations up keeps its durable failure record instead.

On MySQL and MariaDB a file rollback does not always reach zero progress: the server may commit around DDL. ptah-compat does not guess the boundary from SQL keywords. The Atlas revision row is an InnoDB witness updated on the same physical transaction before and after each statement. An implicit server commit makes the matching applied count and partial_hashes durable with the body; an ordinary rollback removes both the user DML and its witness. A plain-DML body that rolls back leaves no committed prefix and is retried whole. A witnessed DDL/DML prefix stays dirty because retrying it would repeat committed SQL.

This recovery mode requires InnoDB for the Atlas revision table, the session’s default storage engine, and every existing base table in the selected database. An explicit non-InnoDB CREATE, ALTER, or storage-engine setting is refused before the migration runs. Resetting an engine setting to DEFAULT is refused because its effective value can differ from the verified session default. CREATE TABLE ... LIKE is also refused because the new table inherits an engine that the session default does not prove.

On MySQL, the migration account must hold TRIGGER at database or global scope so Ptah can see a complete trigger catalog. MariaDB exposes each trigger’s identity and target table without TRIGGER, so it does not require that privilege for this catalog check. Ptah refuses MySQL file mode when it cannot prove complete visibility.

The MySQL grant must be global or must name the selected database exactly in SHOW GRANTS. Ptah decodes escaped literal wildcard characters in the database name, but deliberately does not infer coverage from an unescaped % or _ pattern because MySQL’s partial_revokes setting changes that pattern’s meaning.

MySQL-family file bodies fail closed on SQL whose effects Ptah cannot tie to the InnoDB witness:

  • Transaction controls such as BEGIN, COMMIT, and SET autocommit.
  • Durable server-state operations such as SET GLOBAL, SET PERSIST, RESET, and CREATE, ALTER, or DROP DATABASE or SCHEMA.
  • Any sql_mode assignment, because changing grammar or quoting rules after preflight can make the server execute SQL that Ptah did not inspect.
  • SELECT or TABLE with INTO OUTFILE or INTO DUMPFILE, which writes outside the InnoDB transaction.
  • USE and qualified references to another database. The connection URL must name the database whose engines Ptah validates.
  • Executable comments, nested or dynamic SQL, and table locks.
  • Definitions of indirect database objects, references to existing views or trigger-bearing tables, and stored-routine calls.
  • Custom migration functions, whose inner statements are opaque to Ptah.
  • Statement interceptors, which can replace inspected SQL with another execution path.

Preflight also refuses a session that already enables parser-changing ANSI_QUOTES, MSSQL, or NO_BACKSLASH_ESCAPES behavior, including through the connection DSN or a server default.

Statement-level rejection errors identify the statement number and safety class without echoing the SQL. Migration-function and interceptor refusals identify the affected direction instead because their inner statements are opaque.

MySQL and MariaDB do not support --tx-mode all, along with every other target that commits DDL as it runs; see what --tx-mode all cannot carry. Ordinary session settings remain valid. Ptah runs the body on one pinned session, discards it afterward, and replays safe settings such as SET SESSION time_zone from a verified committed prefix before an automatic retry.

Temporary tables are also allowed: their DDL does not make permanent InnoDB work durable by itself, and discarding the session prevents temporary state from leaking into a retry. If a later implicit commit makes a prefix containing a temporary object durable, automatic resume refuses because that object cannot be reconstructed safely.

A same-named temporary table cannot shadow the Atlas revision table: before reading or writing revision metadata, Ptah refuses and discards a pinned session that already contains that temporary table. It also rejects statements that directly reference the metadata relation, including through a schema-qualified name. A durable unknown-outcome witness blocks automatic retry until the database is inspected and the revision is repaired.

The migration advisory lock serializes Ptah clients that use the same lock name. It cannot freeze DDL from a client that ignores that lock. Do not run out-of-band DDL from the safety preflight until the migration finishes.

When a statement committed under --tx-mode none, or its outcome is unknown, ptah-compat preserves the revision row. migrate status reports that row as a half-applied file, because Current Version counts it:

Migration Status: PENDING
-- Current Version: 20260721120100 (1 statements applied)
-- Next Version: 20260721120100 (1 statements left)
-- Executed Files: 2 (last one partially)
-- Pending Files: 1
Last migration attempt had errors:
-- SQL: ALTER TABLE users ADD COLUMN email TEXT
-- ERROR: failed to execute migration SQL: ...

Fix the migration, rerun ptah-compat migrate hash, and rerun the apply with --allow-dirty. The retry reuses the dirty row instead of recording a second one and skips the statements the earlier attempt committed — under --tx-mode none above, statement 1 — only after proving that committed source prefix is unchanged. Atlas-format rows use the cumulative partial_hashes entry at applied. Editing the unapplied suffix is allowed, and a later retry failure cannot lower applied below that committed prefix even when the transaction mode changed. Atlas needs no flag here; --allow-dirty stays required so a half-applied migration is never resumed by accident.

The dirty source file must still be present. If an exact Flyway identity remains dirty after its file is removed, --allow-dirty refuses before pre-migration checks, apply preflight, or another migration body and names that exact identity. Restore the reviewed source file or repair the recorded history explicitly; a different pending file cannot supply the missing body or committed-prefix contract.

Automatic resume refuses and names ptah migrations repair --version <v> when a run was interrupted mid-statement, the statement count changed, the committed source prefix changed, or partial_hashes is malformed or disagrees with applied. It also refuses negative applied or total values and applied > total. Legacy Atlas rows without partial_hashes may resume only while the stored full-file hash still matches. Revision listing, status, and version operations reject the same invalid counters instead of classifying an equal negative pair as clean.

Roll back using the down.sql section. A bare ptah-compat migrate down reads the Atlas revision rows migrate apply wrote and starts the rollback without reading stdin:

Terminal window
ptah-compat migrate down \
--url "$DATABASE_URL" \
--dir file://migrations \
--to-version 0

Expected output ends with:

✅ Migration rollback completed successfully!
Database is now at version: 0

Review the URL, migration directory, and target before running the command. The Atlas-compatible surface has no confirmation prompt and does not accept the native --confirm flag. Native ptah migrations down keeps its prompt.

Ptah validates every selected down body before rollback starts. If one is missing, the command leaves both the schema and Atlas revision rows unchanged. Dry runs use the same dirty-state, checksum, checkpoint, and down-body validation path as real rollbacks, while suppressing schema and revision writes.

If a rollback fails after execution starts, the Atlas-format revision row stays dirty and records Ptah/down in operator_version. Resume through the native repair command because the drop-in surface intentionally has no repair verb:

Terminal window
ptah migrations repair \
--db-url "$DATABASE_URL" \
--migrations-dir ./migrations \
--dir-format atlas \
--revision-format atlas \
--version 2 \
--resume-from 2

Use the failed version and next down-statement number reported by status. If the compat command used --revisions-schema, pass that value as --migrations-schema here.

Add --dev-url to reset a disposable dev database, replay the migration directory to the target’s current version, and verify the rollback there before the target is touched:

Terminal window
ptah-compat migrate down \
--url "$DATABASE_URL" \
--dev-url "$DEV_DATABASE_URL" \
--dir file://migrations \
--to-version 0

The dev database must select a different live database or catalog from --url. Ptah rejects equivalent URL aliases first, then connects and verifies the actual dialects and selected database/catalog names before resetting the dev database. Equal live names fail closed across different endpoints.

Native ptah migrations commands read the same directory when --dir-format atlas is passed; the native lifecycle is documented under Versioned migrations. If parsing fails, force --dir-format atlas and inspect the migration file for section names: Ptah recognizes migration.sql and down.sql, and other section names are not executed.

ptah-compat migrate apply reads a local Atlas migration directory and records runtime history in Atlas revision-table format by default. The optional positional amount applies only the first N pending migrations. Use --baseline to mark earlier migration files as applied without executing their SQL bodies before applying the remaining pending migrations.

The directory’s integrity is checked before anything executes, matching official Atlas:

  • An Atlas directory whose atlas.sum does not verify is refused with Error: checksum mismatch.
  • An Atlas directory that carries no atlas.sum at all is refused with Error: checksum file not found; run ptah-compat migrate hash once and commit the file. A directory holding no top-level .sql file is not a checksum error — it reports No migration files to execute and exits 0, matching Atlas. The scan is top-level-only because the executed set is the set atlas.sum covers: a .sql file in a subdirectory, or a top-level .SQL, is not a migration on either tool (#976). Each such file is named on stderr as declined, which Atlas does not do — see Integrity and safety.
  • Directories read through ?format= (goose, flyway, liquibase, dbmate, golang-migrate) are gated on the atlas.sum the source directory carries, verified before the source layout is parsed. The covered file set is the one Atlas uses for that layout, so a golang-migrate down file and a Flyway undo file are not covered, and a layout whose covered set is empty is not refused. Run ptah-compat migrate hash --dir 'file://migrations?format=goose' to write it. “Not covered” also means “not executed”: for every layout the set apply runs is the set the verified checksum covers.

Both refusals exit 1 with output identical to ptah-compat migrate validate on that directory, no migration runs, and the target database is never created.

Migrations whose first line is the -- atlas:checkpoint directive get measured Atlas checkpoint semantics — a fresh database applies only the latest checkpoint plus later migrations, and a database that already applied pre-checkpoint history skips the checkpoint silently.

Adopting a database that already has objects

Section titled “Adopting a database that already has objects”

migrate apply refuses to adopt a database that already holds objects no migration recorded, matching Atlas. What counts as such an object depends on what the URL pinned, and the two answers are different.

A URL that pins a schema?search_path=public on PostgreSQL, or a database on a MySQL URL — puts the run in schema scope. The refusal names a table in that one schema:

Error: sql/migrate: connected database is not clean: found table "legacy_stuff" in schema "public". baseline version or allow-dirty is required

On SQLite the same refusal reports a count instead of a name: found multiple tables: 2. Views, sequences, and tables in other schemas are not tables in the connected schema and do not trigger it, and neither does the revision table itself.

A plain PostgreSQL URL that pins no search_path puts the run in realm scope, where the whole database is under review and the operand is schemas rather than tables. An empty extra schema is enough:

Error: sql/migrate: connected database is not clean: found schema "extra". baseline version or allow-dirty is required

At that scope only two things are tolerated: an empty public, and the schema holding this run’s revision table. Anything else — another schema, empty or not, or a table in public — refuses, and the refusal names the first offender by name. A public holding a table is reported as found schema "public", not as a table. A revisions schema holding more than the revision table reports a count: found 2 tables in schema "atlas_schema_revisions".

Only the search_path query parameter selects schema scope. A search path set through libpq’s options=-c search_path=… moves the session but leaves the run at realm scope, which is what Atlas does.

The check is an adoption gate, not a standing drift check. It runs only while the revision table holds no rows, so it fires on the first apply against a database somebody else’s tooling owns and never again — a managed database that later grows an unmanaged table applies its next migration normally. The refusal also fires under --dry-run and on a directory with nothing pending, because the question it answers is about the database rather than about the work.

Two flags opt in, and they cannot be combined:

  • --allow-dirty applies every pending migration against the existing schema.
  • --baseline <version> records history as starting at that version and applies only what comes after it.

Passing both exits 1 with Error: sql/migrate: baseline and allow-dirty are mutually exclusive before anything is recorded.

The gate is enforced on PostgreSQL, MySQL, MariaDB, and SQLite. Other dialects are not gated, because the behavior to match has not been measured on them. Realm scope is enforced on PostgreSQL only: a MySQL URL that names no database is refused by the connection before the gate is reached, so that combination never applies anything either. Native ptah migrations up has no equivalent gate; see #1231.

The revision table lands in the schema Atlas uses, atlas_schema_revisions, whenever the URL names a PostgreSQL-family database and neither --revisions-schema nor the project file says otherwise. That is what lets the same database be handed between the two binaries: each reads the history the other wrote, and reports No migration files to execute rather than treating the database as never migrated.

The default is scoped to that family because the location is a per-dialect fact. On MySQL a schema is a database, so the table stays in the one the connection opened; SQLite has no schema to name at all.

Terminal window
ptah-compat migrate apply 2 \
--url "$DATABASE_URL" \
--dir file://migrations

Supported Atlas apply flags include --dry-run, --tx-mode, --exec-order, --allow-dirty, --baseline, --revisions-schema, --lock-timeout, --to-version, --lock-name, --skip-lock, and --format. The pinned community binary registers every one of those except --to-version, --lock-name, and --skip-lock, which are documented on the wider Atlas distribution’s migrate apply and adopted here under #951.

--format executes a Go template against a Ptah apply result that mirrors Atlas’s public apply-template fields: Pending, Applied, Current, Target, Start, End, Driver, URL, and Dir; {{ json . }} emits the same result as JSON with database credentials redacted. With --env, Ptah can read env.url, migration, and format.migrate.apply from atlas.hcl. Dry-run plans read the stored Atlas revision rows and include only migrations that a real apply would select. They also run the same dirty-state, checksum, execution-order, and transaction-mode validations as a real apply.

The default --exec-order linear refuses a pending migration below the current version. Use non-linear order when the insertion is intentional:

Terminal window
ptah-compat migrate apply \
--url "$DATABASE_URL" \
--dir file://migrations \
--exec-order non-linear

An atlas.sum entry is a running hash over the files before it. Adding an earlier migration changes later entries even when those later files are byte-identical. Ptah verifies them against the chain projected from applied migrations, excluding the pending insertion.

After the insertion succeeds, Ptah reconciles clean revision rows to the new chain while it still holds the migration lock. It computes the full update set first, then commits every affected row in one transaction where the driver supports transactions. A failed row update leaves the whole set on the prior chain, so the next apply can retry it. A dry run, execution failure, or process stop before the revision is applied never rewrites later clean rows.

Equal non-zero application timestamps remain one candidate group. Ptah retries only when exactly one prior applied-set projection explains every affected row and each later row still matches the projection from its own application-time group. Mixed old and new hashes, ambiguous groups, and edited files fail closed.

ClickHouse permits one synchronous checksum mutation. A reconciliation that needs multiple row updates fails before the first mutation because the configured driver has no multi-statement transaction.

An env for_each can select several database targets. migrate apply runs them sequentially in stable expansion order and stops at the first failure. Formatted output contains one document per attempted target with one newline between adjacent documents. A structured execution failure stays in that target’s report; stderr remains empty and the process exits 1.

--to-version bounds the run at a migration version: every pending migration up to and including that version runs, and nothing above it does. A version the directory does not carry is refused before any migration executes, and the bound cannot be combined with the positional amount, because the two select different prefixes and neither outranks the other. Under --dry-run the bound narrows the reported plan the same way.

Terminal window
ptah-compat migrate apply \
--url "$DATABASE_URL" \
--dir file://migrations \
--to-version 20240101000002

--lock-name replaces the name of the session advisory lock that serializes migration runs (ptah_migrate by default). Two runs serialize only when they name the same lock, so this is how a Ptah run coordinates with another tool on the same database. A lock another process holds makes the run wait, bounded by --lock-timeout; an elapsed timeout fails the run before any migration executes. An empty value is refused rather than falling back to the default name.

--skip-lock acquires no lock at all: no wait, no timeout, and no serialization against another runner. The lock is taken before the pending set is computed, so a run with nothing left to apply still waits on a held lock, and exits 0 under --skip-lock in the same state. It cannot be combined with --lock-name, because there is no lock to name. On dialects with no advisory-lock semantics — SQLite, ClickHouse, CockroachDB, and Spanner — an explicit --lock-name prints a note on stderr naming the lock that was not acquired.

Atlas migration files may override global file or none with a leading header. A blank line after the header is accepted but not required:

-- atlas:txmode file
ALTER TABLE users ADD COLUMN email TEXT;

The header must sit in the unbroken run of line comments that begins on line 1, each comment starting in column 1. Measured on Atlas CE v1.3.0 with migrate apply --tx-mode all over one-statement directories, atlas:txmode none is honored on line 1 and on line 2 below another comment, and ignored when a blank line precedes it, when it is indented, when a blank line separates it from an earlier comment, and when it follows the statement. Ptah matches every one of those, and adds the thing CE does not do: an ignored directive is reported at WARN on stderr with its file, line and text, rather than dropped in silence. Ptah’s own -- +ptah directives answer to the same “before the first executable statement” rule with a more forgiving acceptance inside it — see Apply.

The accepted file values are file and none. File-level all, unknown values, duplicate values, and any explicit file mode under global all fail before the affected migration body or revision row changes. Validation follows the selected plan: global all validates the complete selected batch first; global file and none validate each file as execution reaches it. An amount or baseline that excludes a malformed file does not validate that file. On ptah-compat, these failures use Atlas’s leaf diagnostic without Ptah’s internal error applying migrations: wrapper. The native command keeps its error running migrations: context.

Inside a Ptah-supported txtar migration, migration.sql and down.sql carry independent modes. Ptah rejects a transaction-mode header placed before -- atlas:txtar; this safety check prevents the archive from being executed as one plain SQL stream. Atlas CE v1.3.0 instead ignores section-local modes and can classify that malformed outer-header shape as plain SQL, so this contour is an intentional safety difference rather than a parity claim.

A clean successful ptah-compat migrate apply writes nothing to stderr, matching Atlas CE: there is no progress narration, in a dry run or otherwise, so --format output survives the usual CI idiom of folding both streams together.

Terminal window
ptah-compat migrate apply --url "$DATABASE_URL" --dir file://migrations \
--dry-run --format '{{ json . }}' 2>&1 | jq

Three things still reach stderr, by design. A command that fails prints its Error: … diagnostic there and exits 1. A Warn-level runtime diagnostic that exists on no other channel — such as function ordering or a dev database that would not close — is still reported. An atlas.hcl name that Atlas CE accepts without acting on also produces a location-aware warning that the construct has no effect. Valid circular foreign keys are rendered in two phases and do not produce a warning. None of these diagnostics appears on a clean run.

ptah-compat migrate down holds the same contract, with or without --format. Without --format it forwards to the native ptah migrations down, which starts its own run log; the Atlas surface pins that log to the same Warn threshold the rest of this binary uses, so a successful dry run and a successful rollback both leave stderr empty (stokaro/ptah#969).

The equivalent native command, ptah migrations up --dry-run, does narrate each statement it would execute through its run log; that narration is selected by the native --log-level and --log-format flags. The Atlas surface does not model those flags, but a forwarded verb passes through any flag its native target registers, so ptah-compat migrate down --log-level info is accepted and restores the full narration. See Apply migrations.

The apply path executes every Atlas OSS migration directory format selected by migration.format or the directory URL ?format= parameter: atlas, golang-migrate, goose, flyway, liquibase, and dbmate. The native atlas format is captured unchanged, preserving atlas.sum verification and down migrations.

Every other format is captured first and then converted in memory to Atlas single-file, up-only migrations, so apply executes only the source tool’s forward (up) SQL and never its down, rollback, undo, or metadata section. This shares format parsers and up/down semantics with ptah-compat migrate import. Conventional Liquibase import adds a persistence adapter that splits changesets into numeric Atlas files; direct apply retains its numbered-file requirement and source-file boundary.

An explicit ?format= query on the effective directory URL, from either migration.dir or CLI --dir, overrides the migration.format project default, matching Atlas; an empty query value selects the native atlas format.

The local directory is opened through a rooted handle and captured twice before the target database is opened. The command aborts when the captures differ or a migration symlink escapes the root. Apply planning, execution, atlas.sum verification, and --format output all use the resulting immutable filesystem instead of reopening the path.

Terminal window
# Apply a Goose directory directly — no separate import step.
ptah-compat migrate apply --url "$DATABASE_URL" \
--dir "file://migrations?format=goose"

Flyway versions are compared component-wise like Flyway itself (V1.5 sorts before V2, V1.10 after V1.9). Ptah gives each file two deliberately separate values:

  • The exact Flyway token is the revision identity. Apply records 1.5, 01, .foo, 1R, or another opaque token byte for byte; set addresses that token; --baseline and the extended --to-version address that same token; and status and lint print it. An ordinary version token ending in R remains versioned rather than inheriting native Atlas repeatable behavior.
  • A numeric key governs execution order and linearity. Uniquely scored ordinary versions keep a stable key when another file is inserted. Equal-order tokens such as 1 and 01, or two nonnumeric tokens in the same score band, use a walk-position tie slot; a surviving baseline also occupies its own lower band.

The ordering key uses a fixed-width major.minor.patch projection when the token has that shape (minor and patch 099) and also covers timestamp and nonnumeric forms. It is an implementation detail, not a version an operator must copy from output or a revision table.

Exact identity lets Atlas CE consume a history Ptah wrote. Reusing a history in the other direction can still stop at Ptah’s per-revision checksum validation: CE and Ptah encode those checksums differently, so Ptah refuses rather than silently adopting a body it cannot verify.

A same-token V2 to B2 transition has one extra ambiguity when both files carry identical SQL. CE records both as ordinary applied rows and retains neither source prefix; Ptah therefore refuses that CE row by name. A baseline executed by Ptah carries an internal combined applied/baseline marker, still displayed as applied, so its later runs are provable without treating it as a --baseline history boundary. migrate set adds the manual bit to that marker and displays manually set; Atlas CE can still read the row, while Ptah retains enough information for a later apply.

A successful explicit --baseline that selects the surviving B2 is different: its pure baseline row carries a durable source-baseline marker and settles that exact identity plus every migration below its boundary. Baselining V2 before introducing B2 does not carry that marker, so the later ambiguous replacement still fails closed instead of silently skipping the baseline body.

Persisted exact tokens remain visible after the Flyway source mapping no longer owns them. This keeps CE’s V.foo revision .foo readable after a baseline squashes it or its source file is removed, without materializing a pending migration. The exact retired token still participates in Flyway source-order checks. Atlas’s measured .atlas_cloud_identifier bookkeeping row remains excluded. Status reports Current using CE’s textual maximum over applied source tokens while numeric high-water remains internal to execution.

migrate set answers a different question from status and linearity: whether a retired row lies above the selected target in Flyway’s numeric component order. It therefore keeps retired V9 history when setting V10, but removes retired V2 history when setting V1. When two source tokens need missing file-role or walk-position context to break a tie, such as 01 versus 1 or x versus y, Ptah refuses before changing revision metadata instead of guessing from their byte order.

Ptah-written mapped rows carry operator_version='Ptah/source-identity'. The marker does not change the exact version or Atlas-visible status; it proves that a numeric revision value is a source token rather than an older Ptah ordering key. If a retired numeric token collides with a current migration’s historical ordering-key candidate, apply refuses without repair SQL instead of rewriting the valid history row.

That numeric order governs atlas.sum and execution, and it is not the comparison that decides whether a migration was added out of order. Flyway answers that on the version token as text, where "10" sorts below "2", so a project’s tenth migration added beside an already-applied V2__x.sql is refused rather than executed:

Error: error applying migrations: out-of-order pending migrations for current
version "2": "10" (use --exec-order=non-linear to apply or
--exec-order=linear-skip to ignore)

The refusal names only the exact source version; normal direct-operation output does not expose the internal numeric order key as the active migration identity. --exec-order=non-linear runs the migration, --exec-order=linear-skip leaves it pending, and a directory with no recorded history is unaffected — applying V2__x.sql and V10__y.sql together for the first time runs both, in that order. A repeatable (R__) is version "" to Atlas, which sorts below every token, so one added to a database that already has history is refused by the same rule. Once its empty revision is settled, editing and rehashing the single repeatable remains a no-op, matching the pinned community binary.

Several inputs still fail before Ptah opens the target database rather than guess at semantics: unknown formats; goose files whose directives are out of order; dbmate files missing their up directive; and two source files that resolve to the same identity. A single Flyway repeatable migration is converted and executed with Atlas CE’s exact empty revision identity; its reserved numeric slot is only its ordering key. Two repeatables would share that empty identity, so Ptah refuses them before mutation instead of reproducing Atlas CE’s partial apply and panic. See stokaro/ptah#742 and stokaro/ptah#1098.

Goose directives are parsed as a state machine, not filtered line by line, and the accepted set matches Atlas. Directive names are case- and space-sensitive exactly as Atlas matches them: -- +goose Up is a directive, -- +goose up, -- +goose Up and -- +Goose Up are not. A name may carry trailing text (-- +goose Up extra is still Up). Lines Atlas does not recognize as section directives stay in the migration body and are executed with it. The exact whole-file -- +goose NO TRANSACTION line becomes Atlas transaction-mode metadata in Ptah’s converted view, so a direct migrate apply runs that source migration outside a transaction. Ptah consumes the exact annotation as metadata even inside StatementBegin; it does not leak into the SQL body. Near-matches and unrecognized annotation-like lines inside a statement block remain literal SQL; recognized section-changing directives still refuse.

A goose file that contains no section directive at all is executed in full: the whole file is the migration. Such a file has no rollback section that could leak onto the apply path, so there is nothing to protect against. A file with a broken directive set is a different thing and is still refused — Down before any Up, a second Up, StatementBegin outside a section, an unmatched StatementEnd, or any section directive inside a StatementBegin block. See stokaro/ptah#981.

The up section runs from the start of the file through the first Down, so SQL written above the Up directive is executed rather than silently dropped. An intentionally empty up section is recorded as an applied revision with zero statements rather than being skipped.

Three behaviors differ from Atlas on purpose. All three are cases where matching would mean reproducing a defect, so Ptah is stricter — never looser — than Atlas.

-- +goose down instead of -- +goose Down.

Atlas exits 0. The typo is not recognized, so the line folds into the body as a comment and the rollback SQL under it executes — the migration is created, dropped, and recorded as successful.

Ptah refuses, naming the line and the correct spelling. A case error in a directive must not silently roll back a migration.

Atlas exits 0, records the revision with 0 of 0 statements and creates nothing, so the migration is marked done and never runs. migrate import then writes a zero-byte file over the authored SQL and hashes it into atlas.sum.

Ptah refuses, because nothing in the file would execute.

An Atlas directory holding an R-suffixed migration

Section titled “An Atlas directory holding an R-suffixed migration”

For example 1R_view.sql or R__view.sql.

Atlas exits 0 and executes it, keyed on the opaque version string the file name spells (1R, R).

Ptah refuses, naming every such file. Ptah’s migration identity is an int64 version and a repeatable has none, so the only alternatives were executing it under a version no other tool records, or dropping it — which is what Ptah used to do, silently.

An R-suffixed file only reaches a Ptah directory from outside: the community binary’s own migrate import writes 1R_name.sql, and ptah-compat migrate import writes the same migration on a reserved numeric slot instead, so a directory Ptah imported is unaffected. Rename the file to <version>_<name>.sql and re-run ptah-compat migrate hash to execute it. Ordering is not preserved by that rename: Atlas sorts directory entries by file name, so 1R_view.sql runs before 1_users.sql, while 2_view.sql runs after it.

Ptah refuses only exact near-miss spellings of the four section directives. Prose that merely begins with one (-- +goose up to date) and unrecognized names (-- +goose Frobnicate) stay comments, as they do in Atlas. The pinned community binary does not register migrate apply --dir-format, and Ptah rejects it on migrate apply too; the directory format is selected there by the ?format= query on --dir, as Apply a migration directory describes.

The direct migrate down --format path uses the same snapshot for rollback planning, optional --dev-url shadow verification, target execution, and the rendered report. migrate status, migrate lint, and migrate set also capture their local directory before database work and do not reopen it later.

ptah-compat migrate diff accepts a local --dir migration directory, a directly connectable --dev-url, and one desired schema source through --to. The source can be one or more local schema files, one directly connectable database URL, one local Atlas migration directory, or one env:// reference into the evaluated atlas.hcl environment. With --env, Ptah can read env.schema.src, env.dev, migration.dir, format.migrate.diff, and supported diff policy from atlas.hcl.

Ptah snapshots the desired schema first, cleans the dev database, and replays the migration directory into it. It compares the replayed state to the snapshot, cleans the dev database again, and only then writes Atlas-style .sql migration files plus atlas.sum when changes exist. The final cleanup also runs after replay, introspection, comparison, or context-cancellation failures.

Use a disposable dev database. Ptah only reads a database used as --to; it never cleans or mutates that database. Ptah rejects a desired database that identifies the same host, port, and database as --dev-url, even when credentials, connection options, scheme aliases, or an explicit default port differ. Repeated --schema values filter both sides of the comparison and the generated output.

They do not narrow the dev database realm replayed and cleaned by Ptah.

If atlas.sum already exists, Ptah validates it before replaying migrations and fails on checksum drift instead of silently rehashing edited files. Ptah holds the migration-directory lock while it captures and verifies one immutable directory snapshot, replays that exact snapshot, and publishes the result.

It also holds an exclusive dev-database lock from desired-schema resolution through final cleanup. PostgreSQL, YugabyteDB, MySQL, MariaDB, and SQL Server use session advisory locks. SQLite, ClickHouse, and CockroachDB use an operating-system lock keyed by normalized database identity. The latter coordinates Ptah processes on one host; cross-host ClickHouse and CockroachDB replay is unsupported.

A dialect without a safe locking mechanism fails before Ptah cleans the dev database. Generated migration files are staged before publication, and atlas.sum is atomically replaced last as the batch commit marker. An OS-backed lock prevents cooperating processes from planning against the same directory concurrently and is released by the operating system if a process exits unexpectedly.

The generated versions and checksum are derived from the captured snapshot. Ptah rejects a migration added after that capture instead of publishing above an unreplayed file. atlas.sum is updated only after every migration file of the run was written. A failed write rolls the generation back immediately.

If the process exits between publishing the SQL files and publishing atlas.sum, the durable publication journal remains next to the migration directory; the next lock holder compares the checksum commit marker and either finalizes the committed batch or removes only the hard-linked files owned by the interrupted batch.

Terminal window
ptah-compat migrate diff add_users \
--dir file://migrations \
--to file://schema.sql \
--dev-url "sqlite://dev.db"

Expected output includes:

Created migration file: .../migrations/20260721120001_add_users.sql
Updated migration checksum: .../migrations/atlas.sum

Use a live database as the desired schema:

Terminal window
ptah-compat migrate diff mirror_schema \
--dir file://migrations \
--to "$DESIRED_DATABASE_URL" \
--dev-url "$DEV_DATABASE_URL"

Use an evaluated project attribute as the desired schema:

Terminal window
ptah-compat migrate diff mirror_schema \
--config file://atlas.hcl \
--env local \
--to env://url

env://src and env://schema.src resolve the selected environment’s schema sources. env://url and env://dev resolve database URLs, while env://migration.dir resolves the configured migration directory. When --to is omitted, migrate diff resolves the selected environment’s schema.src through the same typed path, including database-valued defaults. An env:// reference must be the only --to value. Mixed source kinds, multiple database or migration-directory sources, nested env:// references, dialect mismatches, and source/dev aliases fail before Ptah connects to the dev database.

Use an Atlas migration directory as the desired state:

Terminal window
ptah-compat migrate diff import_history \
--dir file://migrations \
--to file://desired-migrations \
--dev-url "$DEV_DATABASE_URL"

The desired directory is checksummed and replayed on the dev database to capture a schema snapshot. Ptah cleans the dev database before it evaluates the output directory. A source loading failure therefore does not create the output migration directory, and a successful source replay does not leave its objects behind.

Atlas OSS registers migrate diff --dry-run as a hidden flag. Ptah accepts the same hidden flag and prints the generated SQL instead of writing a migration file or updating atlas.sum:

Terminal window
ptah-compat migrate diff add_users \
--dir file://migrations \
--to file://schema.sql \
--dev-url "sqlite://dev.db" \
--dry-run

Use --lock-timeout to bound waiting for both the migration-directory lock and the exclusive dev-database lock. The default migration-file format matches Atlas’s two-space SQL indentation template. Use --format to render the generated migration SQL through Atlas-style Go templates with sql and .MarshalSQL, for example to disable indentation:

Terminal window
ptah-compat migrate diff add_users \
--dir file://migrations \
--to file://schema.sql \
--dev-url "sqlite://dev.db" \
--format '{{ sql . "" }}'

With --edit, the generated migration files open in $VISUAL or $EDITOR before atlas.sum is finalized, so hand-tuned SQL still validates; --edit cannot be combined with the hidden --dry-run flag because dry runs write no migration file to edit.

--qualifier matches Atlas’s single-schema semantics: every object named by the generated statements is prefixed with the custom schema qualifier, so the file can be applied to a schema other than the one it was planned against:

Terminal window
ptah-compat migrate diff add_items \
--dir file://migrations \
--to file://schema.sql \
--dev-url "postgres://user:pass@localhost:5432/dev" \
--qualifier tenant
# generates: CREATE TABLE "tenant"."items" (...)

The qualifier is supported on PostgreSQL, CockroachDB, YugabyteDB, MySQL, and MariaDB dev databases and applies to tables, columns’ foreign-key references, table-level constraints, indexes, and drops. Invalid values (control characters, ., quotes), unsupported dialects, plans spanning several schemas, and statement kinds Ptah cannot re-qualify yet (for example enum types) fail explicitly before any migration file or checksum is written. As with Atlas, replaying a directory that contains qualified migrations requires the qualifier schema to exist on the dev database.

--schema accepts repeated or comma-separated schema names and narrows the replayed dev database state plus the resolved desired state before the diff is planned. With diff.concurrent_index.create = true in the selected atlas.hcl env, newly added indexes are planned as PostgreSQL CREATE INDEX CONCURRENTLY statements.

Files carrying such statements start with the Atlas -- atlas:txmode none file directive, which both Atlas and Ptah honor by executing the file outside a transaction.

Because those statements must not silently strip transaction safety from ordinary DDL, a mixed plan is split the same way Ptah’s native generator splits it: a <name>_transactional file with the transactional statements followed by a <name>_concurrent_indexes file tagged -- atlas:txmode none; mixes that cannot be split automatically (for example enum value additions alongside table changes) are refused.

The rollback is planned through the same dialect and capability set. Each reverse index operation selects concurrency independently. A concurrent create becomes the exact table-qualified concurrent drop when supported and a blocking drop otherwise; the same rule applies to the reverse create after a concurrent drop. An explicitly requested unsupported forward operation still fails before a migration or atlas.sum is published. On MySQL and MariaDB, the rollback also removes a backing index the forward foreign-key addition caused the server to create. It preserves any prior or same-run covering index, even under a different name, and refuses a plan that later removes every such index.

All five foreign directory layouts carry the ordinary rollback. Goose can also represent a no-transaction requirement from either direction with its whole-file -- +goose NO TRANSACTION directive. That directive makes both sections non-transactional, including when only one direction requires it. The other four layouts remain fail-closed when either direction requires no-transaction execution because their safe transaction metadata has not been proven. The native Atlas layout remains forward-only and carries -- atlas:txmode none on its own file when required.

A docker:// dev database is provisioned on the verbs that take one: the container is started, used and removed by the command.

migrate diff opens the migration directory and its parent once, before anything is staged, and keeps both handles for the rest of the run. The parent is held as well because the publication journal and the commit marker sit beside the directory, so an interrupted run stays recoverable even when the directory itself was left half-built.

Every later step names a direct child of one of those two handles: the staged files, the published migrations, atlas.sum, the journal, the commit marker, the rollback quarantine, and orphan cleanup. Recovery runs through the same handles, so an interrupted batch is withdrawn from the objects the run opened. The directory pathname survives only in reported paths and error text, and no write step resolves it a second time.

This draws the boundary against a directory replaced after the run validated it. Replacing the pathname, or re-pointing a symlink on the way to it, no longer selects where the run writes: a migration or an atlas.sum can land only in the directory that was captured and verified. When the directory came from atlas.hcl, both handles are opened through the project root, so a replacement cannot move the write outside that root either.

Two things stay keyed to the pathname on purpose. The cross-process lock file is created beside the directory before any handle exists, because it is cooperative mutual exclusion between Ptah processes rather than a boundary against a hostile writer, and every verb has to agree on its identity. The --edit callback also receives absolute staged paths, because an external editor cannot take a handle; the files it returns are re-validated through the retained handle before anything is published.

ptah-compat migrate validate verifies the migration directory against atlas.sum. When --dev-url is set, Ptah first checks integrity and then treats the dev database as scratch space: it drops user tables and replays the migration directory to validate SQL execution semantics. If integrity drift is found, Ptah reports the drift and does not connect to the dev database.

A successful validation is silent, including a successful --dev-url replay. Checksum mismatches exit 1, print Atlas-compatible recovery guidance to stdout, and print Error: checksum mismatch to stderr. If atlas.sum is missing, the command prints the same recovery guidance and writes Error: checksum file not found to stderr. For added, edited, or removed migration files, the stdout guidance includes the first mismatched atlas.sum line, file name, and reason.

An empty migration directory is the one shape where a missing atlas.sum is not drift: there is nothing for it to cover, so ptah-compat migrate validate exits 0 with no output, matching the pinned Atlas community binary v1.3.0 and matching what ptah-compat migrate apply already does on the same directory (No migration files to execute). The moment the directory holds a migration file the refusal returns, byte-identically. ptah-compat migrate lint --latest follows the same rule: an empty directory selects nothing and exits 0, which is what a repository linting its migrations in CI does before the first migration exists. The scope selector is what makes that 0 legitimate — an empty directory exits 0 only when --latest or --git-base was given. With neither, the run is refused before the directory is read (see Lint migrations below).

This compatibility behavior is scoped to the ptah-compat binary. Native ptah migrations validate keeps Ptah’s success banner and native error output; missing or malformed sum files remain exit-2 usage failures, and native ptah migrations lint still refuses an empty directory with no *.sql migration files found.

Terminal window
ptah-compat migrate validate \
--dir file://migrations \
--dir-format atlas \
--dev-url "sqlite://dev.db"
Terminal window
ptah-compat migrate lint \
--dir file://migrations \
--dev-url "sqlite://dev.db" \
--latest 1

Both flags in that example are required, and they are two separate requirements with two separate refusals, each matching the pinned Atlas community binary v1.3.0 byte for byte:

invocation exit message
no --dev-url 1 required flag(s) "dev-url" not set
no usable selector, including --latest 0 without --git-base 1 --latest or --git-base is required

An argv missing both answers the --dev-url sentence, because that is the one the pinned binary answers on the same argv.

A scope may come from the selected atlas.hcl environment instead of the command line: a lint { latest = 1 } or a lint { git { base = … } } satisfies the requirement with nothing spelled on the command line. An explicit --latest 0 clears configured lint.latest but leaves an explicit or configured Git selector eligible. With --git-base, zero follows Git selection instead of conflicting with it. Positive --latest N remains exclusive with Git, and an N larger than the directory analyzes every migration. The scope refusal comes before the migration directory is read and before --dev-url is contacted, which is the part that matters: --dev-url is scratch space and the run cleans it, so an unscoped invocation that answered would drop tables in a database the pinned binary never connects to.

Two environment variables relax these requirements. They relax different ones, and neither implies the other:

  • PTAH_ATLAS_LINT_WITHOUT_DEV_URL=1 drops the dev-database requirement. Ptah’s analyzers reach a verdict from the migration files alone; the run still needs a scope.
  • PTAH_ATLAS_LINT_ALL_VERSIONS=1 drops the scope requirement and lints the whole directory. The run still needs a dev database unless the variable above is also set.

Both default to off so a pipeline written against the community CLI gets the same refusal here that it gets there, and both are environment variables rather than flags because the conformance cli-surface tier asserts flag parity with the pinned binary. Native ptah migrations lint needs neither a dev database nor a scope and is unaffected by both.

migrate lint --dev-url treats the dev database as scratch space: it drops user tables, replays the migration directory, and then runs static lint reporting. A docker:// value is provisioned first: the container is started, replayed on, and removed when the command ends.

With no --format and no project template, ptah-compat migrate lint prints a compatibility report: an Analyzing changes … header, a per-version block listing each analyzer group’s diagnostics and mapped rule IDs, a -- ok (…) line per version, and a summary of version statuses, semantic schema changes, and diagnostics. For mapped Atlas diagnostics, the compatibility renderer reproduces the measured wording, analyzer documentation links, wrapping, and suggested-fix layout. Ptah-only diagnostics remain visibly labeled and do not link to unproven Atlas analyzer codes. Native ptah migrations lint keeps Ptah’s more detailed diagnostic prose and remediation guidance.

A statement affecting several objects reports per object, and the two destructive shapes are not the same:

  • A DROP TABLE naming several tables produces one diagnostic and one suggested fix per dropped table, ordered by table name compared byte-wise, so the suggested-fix header pluralizes and the diagnostic count rises with the number of tables.
  • One ALTER TABLE dropping several columns produces one diagnostic naming every dropped column in clause order, under a single suggested fix.
  • One ALTER TABLE adding several non-nullable columns without a default produces one diagnostic per column, in clause order.

Native ptah migrations lint splits its findings the same way, so the two surfaces never disagree about how many objects a statement affects. Each per-object finding names its object in the native message, which is also what keeps each SARIF result’s fingerprint distinct when several of them share a rule, a file, and a line.

The dev database is what a lint run compares against, so it also decides which objects the run analyzes. A PostgreSQL-family --dev-url carrying ?search_path=<schema> puts exactly that one schema under review:

  • an object in a different schema raises no diagnostic and counts as no schema change, because it was never part of the state the run compares against;
  • an unqualified reference resolves into the reviewed schema, so it stays under review, and so does a reference written out with the reviewed schema’s own name;
  • one statement is measured per object: DROP TABLE users, other.audit_log; under search_path=public reports users and counts one schema change;
  • an index is measured by the schema of the table it is on whenever the statement names a table, because the index name is bare there: CREATE INDEX idx ON app.users (id); under search_path=public counts no schema change and raises no diagnostic, while the same statement on a public table counts one and raises PG101. DROP INDEX idx ON app.t; — MySQL’s, MariaDB’s and SQL Server’s spelling — is measured the same way;
  • a DROP INDEX that names no table is measured by the qualifier on the index itself, which is the only one the statement has: DROP INDEX app.idx; under search_path=public counts no schema change and raises no diagnostic, while DROP INDEX public.idx; and the unqualified DROP INDEX idx; each count one and raise PG106.

A --dev-url that names no schema puts the whole connected database under review and filters nothing, which is also what every non-PostgreSQL dev URL does. A search_path naming more than one schema is not a scope: it is read as a single schema name, so it scopes nothing and every object stays under review.

An ALTER TABLE’s schema changes belong to its table’s schema, never to the column or constraint it names, and a CREATE SCHEMA is measured against the reviewed schema by its own name.

The scope decision is made once per statement and drives both outputs, so a statement the scope removed contributes neither a schema change nor a diagnostic, and one it kept can contribute either. A diagnostic that names no object at all — the rules that report a statement rather than the objects in it — follows the decision already taken for the statement it belongs to: dropped when the scope removed that statement, and otherwise reported, since there is nothing to measure a scope against and a hazard must not be silenced on an unestablished boundary. ALTER TABLE app.users ADD CONSTRAINT … under search_path=public therefore reports nothing, where it used to raise PG105 about a change it had already counted as zero.

A statement is removed only when every table it names is out of review, not only the one it alters. ALTER TABLE app.child ADD CONSTRAINT c FOREIGN KEY (pid) REFERENCES public.parent (id); under search_path=public names two, and validating that key holds a SHARE ROW EXCLUSIVE lock on public.parent for the duration, so PG306 is reported: the hazard lands on a table the run is responsible for. The same statement referencing app.parent reports nothing. This is one place the two outputs deliberately describe different statements — the constraint lands on app.child, so the reviewed schema still counts zero changes for it. A count of zero is not a statement of safety.

Silence is not exclusion. A statement outside Ptah’s SQL grammar is left under review whatever schema it names, because a boundary that could not be read must not be able to drop a diagnostic. That grammar boundary is why TRUNCATE app.users; and DROP FUNCTION app.recalc(); are reported under search_path=public while counting no schema change.

DROP INDEX is not an example of it: the parser models the statement, so DROP INDEX public.idx; counts one schema change, matching the pinned community binary v1.3.0, and DROP INDEX app.idx; is scoped out whole — no schema change and no PG106, which is also what the community binary reports. Ptah raised PG106 for the app form before stokaro/ptah#1296; nothing about the reviewed schema became quieter, since the public form still raises it.

Two DROP INDEX forms are still outside the grammar. Both are refused by the parser rather than half-recorded, so each counts no schema change and keeps its diagnostics under every scope:

  • PostgreSQL’s multi-index DROP INDEX a, b;, which one DROP INDEX node cannot hold;
  • SQL Server’s backward-compatible DROP INDEX t.idx;, where the qualifier names the table rather than a schema. Reading it the way every other dialect spells the same text would scope the drop to a schema nobody wrote. Ptah renders SQL Server index drops as DROP INDEX idx ON t, which is read exactly.

Three constructs are still measured by a name that carries no schema, because Ptah’s parser records none for them. Measured on PostgreSQL 17.10 under search_path=public, CREATE SEQUENCE app.s; counts one schema change, CREATE TRIGGER trg … ON app.t; counts one and raises PG308, and CREATE POLICY p ON app.t; counts one — where the pinned community binary v1.3.0 counts no schema change and raises no diagnostic for all three. They over-report rather than under-report, and each is a warning at exit 0, so a scoped run is never told less than the truth about its database. They are listed here rather than left to be discovered.

The boundary applies to ptah-compat migrate lint only. Native ptah migrations lint keeps every object under review, whatever the dev URL selects, so the two surfaces deliberately disagree about scope.

The reason the boundary exists on the compatibility surface is that the tool it replaces reviews only what the dev URL covers, and matching that is the whole point of the surface. The reason it does not exist natively is that the justification for it — an object outside the dev URL’s reach was never in the before-state the run compares against — describes a diff-based analyzer, and Ptah’s linter reads SQL text. That is why it reports TRUNCATE and DROP SCHEMA, neither of which produces a diff. Scoped natively, DROP TABLE app."Users", app.audit_log; under search_path=public reports nothing and exits 0, on the one surface where no other tool can be consulted about whether that is right.

A rename retires one logical name and introduces another. The two surfaces describe that single event at different altitudes:

  • ptah-compat migrate lint reports it as a destructive change to the retired name — DS102 for ALTER TABLE … RENAME TO, RENAME TABLE … and MySQL’s bare ALTER TABLE … RENAME new_name; DS103 for RENAME COLUMN and its form without the COLUMN keyword. The diagnostic names the old name rather than the new one, carries the matching pre-migration-check suggested fix, is error-severity, and exits 1.
  • Native ptah migrations lint reports BC101, which explains the operational hazard and prescribes add-new/backfill/drop-old across releases. It stays a warning there.

Exactly one of the two is emitted per rename, so neither surface reports one statement twice.

A statement renaming several objects follows the same two shapes the destructive drops do. One ALTER TABLE renaming several columns — the MySQL multi-clause form — produces one diagnostic naming every renamed column in clause order, under a single suggested fix. One RENAME TABLE naming several pairs produces one diagnostic and one suggested fix per renamed table, ordered by table name compared byte-wise. Ordering is per statement: consecutive ALTER TABLE … RENAME TO statements report in the order they are written.

Renaming an object the same migration file created reports nothing on either surface: no deployed application version ever saw a name this migration itself introduced.

Because the retired name is a destructive change, -- atlas:nolint destructive suppresses a rename on the compatibility surface and -- atlas:nolint incompatible does not.

Index, key and constraint renames are not reported at all: deployed application code does not name them.

On the compatibility surface a rename also reports the column it introduces. Renaming a NOT NULL column with no DEFAULT produces a second diagnostic — MF103, “adding a non-nullable column will fail in case the table is not empty” — naming the new column and the retired column’s type. A RENAME COLUMN statement carries neither that type nor its nullability, so this one is not read from the migration text: ptah-compat migrate lint reads the schema state the version starts from off the --dev-url database during the replay it already performs, before the version runs and while the retired column still exists. A run without --dev-url reports the retirement alone.

Three consequences follow from where the facts come from:

  • The type is spelled the way the database canonicalizes it, not the way the migration writes it: int reports as integer and varchar(20) as character varying(20).
  • A retired column that is nullable, or that carries a DEFAULT, has no such diagnostic — the introduced column cannot fail on existing rows.
  • A type whose diagnostic spelling Ptah has not measured keeps the diagnostic but prints Ptah’s own labeled wording for it rather than a guess at the other tool’s.

The two halves belong to different analyzers, so -- atlas:nolint destructive silences the retirement and leaves the addition, and -- atlas:nolint data_depend does the reverse.

Native ptah migrations lint reports no addition. It models a rename as a rename, and a rename does not fail on a populated table, so BC101 stays its single finding.

The report is written to stdout even when findings fail, and error-severity findings still exit with code 1. The native ptah migrations lint output is unchanged. Custom output is selected by --format, by format.migrate.lint, or by Atlas’s lint { log = "…" } template; an explicit CLI --format wins over a project template, and a selected --env lint.log overrides a global one.

The command captures the migration directory once before checking atlas.sum, selecting --latest versions, replaying migrations, and rendering reports. Checksum status, findings, statement metadata, and formatted output therefore describe the same immutable inputs.

The Replay Migration Files step reports the number of semantic schema changes the selected migrations express, recovered from Ptah’s dialect-aware parse of their DDL — the same parser the replay and planner use — not a count of statements or files. One statement can contribute zero changes (an operational INSERT/SELECT or a construct outside the DDL grammar), exactly one (a single CREATE), or several (a multi-action ALTER TABLE, or a DROP TABLE naming several tables), so this change count and the new-migration-file count differ in general. A table rename counts as two changes — the old name stops naming an object and the new one starts — while a column rename counts as one, because the table it modifies stays the same object.

Ptah also validates and fully loads the migration provider, including Atlas templates, before dropping any objects from the dev database. A malformed migration directory therefore leaves the existing dev database state intact; cleanup starts only after the replay plan is valid.

For the code-by-code status of the analyzer checks Atlas marks as Pro against Ptah’s lint rules, see Lint rules.

A statement-local -- atlas:nolint <selector> suppresses the statement directly below it. A blank line between the directive and the statement detaches the two: the directive then suppresses nothing, exactly as the community binary treats it.

The whole-file header form — a first nonempty -- atlas:nolint <selector> line followed by a blank line — is enabled only under the ptah-compat migrate lint compatibility profile. A bare file header ignores the file completely, so it is absent from .Files and per-file analysis steps.

Supported analyzer selectors are destructive, data_depend, concurrent_index, incompatible, and nestedtx. They name rule families, so they mean the same thing on both surfaces.

A code selector names the code the running surface prints: every code ptah-compat migrate lint emits is suppressed by that code, and every code ptah migrations lint emits is suppressed by that code. The three codes the compatibility surface renames are the only place the two differ — a column drop prints DS102 natively and DS103 on the compatibility surface, so -- atlas:nolint DS103 silences it there and -- atlas:nolint DS102 silences it natively. Where two native rules share one printed code, the selector reaches both: -- atlas:nolint DS103 silences a DROP COLUMN and a column type change alike.

A code selector matches one code exactly and never widens into a family, on both surfaces: -- atlas:nolint DS and -- atlas:nolint D suppress nothing, while the native -- ptah:nolint DS still silences the whole family. An unrecognized selector is accepted and suppresses nothing, without a warning — the pinned community binary behaves the same way, and matching it was chosen over diagnosing the typo. Ptah’s own .ptah-lint.yaml disabled-rules remains strict and rejects a selector matching no registered rule.

Migrate-up safety keeps its native directive semantics: the whole-file Atlas header never reopens the apply-time destructive gate.

--dir atlas://<repository> reads the directory from an OCI registry instead of from disk. The reference carries no registry host, so it resolves against the namespace PTAH_ATLAS_REGISTRY names — the same resolution an atlas.hcl migration.dir uses:

Terminal window
export PTAH_ATLAS_REGISTRY=ghcr.io/acme
ptah-compat migrate status --dir "atlas://app?tag=prod" --url "$DATABASE_URL"
ptah-compat migrate apply --dir "atlas://app?tag=prod" --url "$DATABASE_URL"
ptah-compat migrate validate --dir "atlas://app?tag=prod"
ptah-compat migrate lint --dir "atlas://app?tag=prod" --dev-url "$DEV_URL" --latest 1

tag selects a moving tag and defaults to latest; version selects an immutable one. Naming both is refused, because a tag moves and a version does not.

The directory is read-only. migrate hash, migrate new and migrate diff refuse against it and name the reference you typed:

Error: atlas migrate hash writes to the migration directory, and atlas://app?tag=prod came from a
registry, which Ptah reads and does not write back to; write to a local directory and publish it
with `ptah migrations push`

That refusal does not need a reachable registry, and neither does the one for a missing namespace — nothing is fetched until a verb reads the directory.

Publish the directory with ptah migrations push; the artifact is an ordinary OCI one, pulled with ordinary registry credentials, and no hosted service is in the path.

Atlas-compatible migration metadata commands default to Atlas directory format. ptah-compat migrate hash, lint, new, set, status, and validate register --dir-format with Atlas’s default value atlas.

All six accept every Atlas source layout — golang-migrate, goose, flyway, liquibase, and dbmate — under either spelling Atlas accepts, and produce the same atlas.sum from both:

Terminal window
ptah-compat migrate hash --dir "file://migrations?format=goose"
ptah-compat migrate hash --dir file://migrations --dir-format goose

Each layout covers a different set of source files, matching Atlas; see the compat command reference for the per-layout rules and for the inputs that stay refused.

When both spellings are given, the ?format= query wins, which is what Atlas does. Values are matched verbatim on every CE-comparable path that resolves a layout — the six above plus diff and import: --dir-format ATLAS and --dir-format " atlas " are refused rather than normalized, and an empty value selects the Atlas layout. migrate import resolved its own source format until #1235 cell 9.8 was closed as a class; it accepted FLYWAY and " flyway " and read ?format= with an empty value as no selection, all three of which Atlas refuses.

A rejected value is refused with the measured CE wording — unknown dir format "bogus" — on all nine CE-comparable paths, apply included through its ?format= query. Fuller-surface commands such as checkpoint, test, edit, rebase, and rm keep Ptah’s longer diagnostic, which names the accepted layouts.

format is the only --dir query key that selects anything. On the eight verbs that accept a --dir query — apply, diff, hash, lint, new, set, status and validate — any other key is ignored, as Atlas ignores it, and named on standard error so a misspelled ?fromat=goose does not quietly read the directory in the Atlas layout. The exit code and standard output are unchanged. Set PTAH_STRICT_DIR_QUERY=1 to refuse an unrecognized key instead. migrate checkpoint, down, edit, rebase, rm and test register --dir as well and refuse any query on it, so the note never appears there; see the compat command reference for that split.

migrate new writes the selected layout rather than only reading it. The created file names and their contents follow the source tool’s own convention, and atlas.sum is rewritten over the set that layout covers:

--dir-format files created covered by atlas.sum
atlas <version>_<name>.sql, empty the file
golang-migrate <version>_<name>.up.sql and .down.sql, both empty the .up.sql only
flyway V<version>__<name>.sql and U<version>__<name>.sql, both empty the V file only
goose <version>_<name>.sql holding -- +goose Up / -- +goose Down the file
dbmate <version>_<name>.sql holding -- migrate:up / -- migrate:down the file
liquibase <version>_<name>.sql holding --liquibase formatted sql the file

Two inputs are refused on a non-atlas layout. A migration name is required, because a file named by the version alone is one Ptah’s own migrate apply cannot read back on four of the five layouts. --edit is refused, which is what Atlas does for a non-Atlas directory as well.

The <version> in every row of that table is the UTC yyyyMMddHHmmss second the command ran in, on every layout, and it is the same value migrate diff stamps. migrate new and migrate diff step forward only to get past a version the directory already holds — never to get past the newest one. A directory whose newest migration is dated in the future therefore receives today’s version, sorting below that migration, which is what Atlas was measured to do and what both of those verbs now do (#938). migrate new used to bump to newest + 1 instead, so the same directory could hold both shapes.

Two verbs bump past the newest migration instead, and each has a reason it must. migrate checkpoint’s version has to outrank every migration it squashes, or a fresh database bootstraps from the checkpoint and then applies a migration whose SQL that checkpoint body already contains. migrate rebase moves a migration to the END of history, so a version sorting below the newest one would not move it at all. Into a directory holding 20200101000000_users.sql and 29991231235959_archived.sql, checkpoint wrote 30000101000000_squash.sql; migrate apply against an empty database then reported 1 pending migrations and produced both tables; and migrate new into that same directory took today’s second, sorting below the 2999 migration. One binary, two rules, on the one directory shape that separates them.

Whichever rule applies, the step lands on a second that exists. 29991231235959 plus one as an integer is 29991231235960 — sixty seconds past the minute, which time.Parse refuses under the very layout the rest of the name uses — and that is what the bump used to write, 29991231235961 on the run after it. Every Atlas-layout version migrate new, migrate diff, migrate checkpoint and migrate rebase write now reads back as the UTC second it looks like, so the bump beside a 29991231235959 neighbor lands on 30000101000000 and then on 30000101000001, and two migrations created inside the same :59 second land on the next minute rather than on a sixtieth one.

migrate rebase was the last of those four to stamp that shape. It took a Unix epoch for every layout, so moving a migration to the end of an Atlas directory numbered 1_init.sql, 2_second.sql wrote a ten-digit 1786268355_init.sql beside fourteen-digit neighbors, and beside a 29991231235959 migration it wrote 29991231235960 and then 29991231235961. It now reads the same UTC clock the other three do for an Atlas directory, and keeps the epoch for the paired ptah layout described next, whose names cannot carry a fourteen-digit version at all. atlas migrate rebase --help on the community binary prints 'atlas migrate rebase' is not supported by the community version. and exits 0, so there is no measured stamping behavior to match here — the same position migrate checkpoint is in.

Native ptah migrations create --dir-format ptah keeps the paired layout’s own rule, the clock or newest + 1, whichever is greater: nothing outside Ptah reads that layout, so no compatibility argument moves it, and a version that only goes forwards is the safer of the two. Its versions are ten digits, so they never look like a timestamp and the calendar rule above does not apply to them. What it will not do is walk past 9999999999, the largest version its fixed-width NNNNNNNNNN name can hold. It refuses instead, because the eleven-digit name it used to write was one the reader silently skipped.

migrate diff writes every selected layout under both spellings. It writes the forward and rollback pair for golang-migrate and flyway, one file with both directive sections for goose and dbmate, and a Liquibase changeset with --rollback: lines. The refreshed atlas.sum covers the selected layout’s file set. migrate apply registers no --dir-format at all, matching Atlas, and selects a converted source directory through ?format= on --dir.

Goose writes -- +goose NO TRANSACTION when either direction requires no-transaction execution; its directive governs the whole file and therefore both directions. golang-migrate, Flyway, dbmate, and Liquibase remain fail-closed for those plans because their safe transaction metadata has not been proven. Their refusal happens before any migration file or atlas.sum is written.

ptah-compat migrate set [version] moves Atlas revision history to the selected boundary without executing migration SQL. It preserves existing clean rows through the target, inserts missing rows as manually set, keeps dirty-row diagnostics with the combined applied and manually-set type, and removes rows above the target.

apply, status, set, validate, new and diff all verify the directory’s integrity file before doing anything else, with the same output on all six (#974, #1086). The refusal precedes the database connection, so an unreachable --url or --dev-url does not hide it; on set it precedes the positional-version check too, and on diff it precedes --to and --dev-url being required at all. The empty-directory and non-SQL-directory exemptions described under Apply a migration directory apply identically, so a CI bootstrap that creates an empty migrations/ keeps working.

new and diff are the two that WRITE, so for them the gate is a preflight rather than a check alongside the work: nothing is created, and no atlas.sum is rewritten, on a directory the gate refuses. A --dir naming a directory that does not exist yet is not a checksum error on either tool — both verbs create it, which is how the first migration of a project gets written.

The six shared scheme-hint consumers — hash, validate, status, lint, new, and diff — require their command-line --dir to name a scheme:

Terminal window
ptah-compat migrate new add_users --dir migrations
# Error: missing scheme for dir url. Did you mean "file://migrations"?

The stderr line ends with the bytes 20 0a: one ASCII space followed by the line feed. Nothing is created. The requirement applies to PTAH_DIR as well as the flag.

A directory named by atlas.hcl migration.dir still accepts a bare path and remains looser than Atlas, as tracked in #1186. PTAH_MIGRATIONS_DIR, which is the native --migrations-dir under its environment name, still takes a plain path.

A migration name cannot contain a path separator

Section titled “A migration name cannot contain a path separator”

The name becomes part of the file name, so a / in it selects a directory that is not there. Both writing verbs refuse it and write nothing (#1231):

Terminal window
ptah-compat migrate new "sub/dir_name" --dir file://migrations
# Error: atlas migrate new "sub/dir_name": migration name must be a single file
# name element, without a path separator

The community CLI refuses the same input, with the raw open …: no such file or directory of the write it did not expect to fail; Ptah names the rule instead, in the same words the foreign-layout path has always used. Nothing else about a name is refused on an Atlas-layout directory: a space, a backslash and .. are all accepted, because they are accepted there.

The refusal lands where the file would be written, which matters on migrate diff: a diff that finds no changes writes nothing and never reaches the name, so it still exits 0 — the same as the community CLI.

lint deliberately does not enforce it, but only for a missing integrity file: linting a directory that has never been hashed is how you inspect one before adopting it. It does not tolerate drift. On a hashed directory whose migration was edited, added to, or removed from, both lint implementations exit 1 on the checksum mismatch, so this is not a route for inspecting a directory that has already drifted.

down enforces it too, and did not always. It reads a native Atlas directory and executes rollback SQL from it, so a hashed directory whose migration was edited is now refused there exactly as it is on status. Before that, status exited 1 on such a directory while down reported normally and exited 0 — integrity verification guarding the constructive direction and not the destructive one, which is backwards, because down is the direction where the result cannot be inspected afterwards.

The refusal is not a parity claim. The community binary refuses migrate down outright, so there is no behavior to match: gating it cannot exit 0 where that binary exits 1, and “matching is the floor, not the ceiling” is what leaves room for the refusal. The two paths through the verb refuse with different text for that reason. The default forward inherits the native refusal from ptah migrations down and prints ptah’s own drift report; --format runs the shared optional-hash gate and renders the Atlas-format rollback report. An unhashed directory remains usable, a drifted hashed directory is refused, and PTAH_ALLOW_UNVERIFIED_MIGRATION_DIR=1 permits the same explicit recovery as native down, with a warning. Strict CE policy rejects that extension variable and retains the CE refusal surface.

checkpoint also enforces it now, on the native verb the compat surface forwards to. It was the worst of the ungated set: it replays the whole 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 afterwards. The command now verifies and replays one immutable snapshot, then its rooted writer refuses publication if the directory no longer matches that snapshot before adding the checkpoint and refreshing atlas.sum.

rm, rebase and edit remain divergent — they write an atlas.sum over a directory whose previous contents were never verified, turning drift into apparent cleanliness. All three are verbs the community binary refuses outright, so like down they have no behavior to match; they are listed because the hazard is the same one, not because a comparison exists. No issue tracks closing that gap yet. new and diff used to be on this list and were gated by #1086; the predicate they now share is the one described above, so the remaining four need a decision about the missing-atlas.sum case rather than new machinery.

With --env, it reads env.url, migration.dir, and migration.revisions_schema from atlas.hcl; explicit --url, --dir, and --revisions-schema flags keep CLI precedence. ptah-compat migrate status also accepts --revisions-schema and runs against Atlas revision-table metadata.

A pre-apply check sequence for CI looks like:

Terminal window
ptah-compat migrate hash --dir file://migrations
ptah-compat migrate new add_users --dir file://migrations
ptah-compat migrate validate --dir file://migrations --dev-url "sqlite://dev.db"
ptah-compat migrate status --url "$DATABASE_URL" --dir file://migrations

ptah-compat migrate import converts a local file:// migration directory from an Atlas-supported format into a separate Atlas single-file directory and writes atlas.sum:

Terminal window
ptah-compat migrate import \
--from "file://flyway?format=flyway" \
--to "file://migrations"

A successful compatibility import is silent. Inspect the destination directory and its atlas.sum to see what was written, rather than reading a progress message off stdout. Rejections stay loud on stderr.

The command is intentionally fail-closed: use a destination directory different from the source directory, and start with a destination that does not already contain .sql migration files or atlas.sum. Flyway repeatable migrations are not in that list: they are converted onto a reserved version slot above every versioned migration, and the destination file name carries that slot rather than an R suffix, so the imported directory keeps one-time migration semantics instead of Flyway-style reapply semantics.

For Liquibase formatted SQL, a directory containing only numbered SQL names keeps the one-source-file-to-one-Atlas-file conversion. If any covered SQL file has a conventional name such as changelog.sql, the importer parses the entire covered SQL set as formatted SQL, orders files lexically and changesets by appearance, and writes global versions 1..N with the changeset author and ID in each file name. Version tokens are left-padded to the digit width of N, so an 11-changeset stream is named 01_... through 11_... and atlas.sum keeps the same order as Liquibase. A headerless or malformed member refuses the whole import before the destination is created. The resulting numeric Atlas directory and its atlas.sum validate and apply under both Ptah and Atlas CE. Liquibase XML, YAML, and JSON changelogs remain unsupported.

The source directory’s atlas.sum is verified first. If the source carries one, it must cover the source before anything is converted, and the source checksum is checked ahead of the destination rules above — a tampered source is refused whatever the destination looks like, and no destination directory is created. If the source carries no atlas.sum at all, the import proceeds: a directory another tool wrote has never been hashed, and importing it is what this verb is for. See Integrity and safety.

The native ptah migrations import converts the same source formats into Ptah-native migrations instead; see Import from another tool.

ptah-compat migrate apply --format

  • Top level: .Driver, .URL, .Dir, .Env, .Pending, .Applied, .Current, .Target, .Start, .End, .Error, and JSON .Message for successful or no-op reports.
  • Each .Pending and .Applied entry: .Name, .Version, .Description. Applied entries also expose .Applied, .Skipped, .Checks, and statement .Error.

ptah-compat migrate diff --format

  • .Changes, .MarshalSQL, plus the sql helper for generated migration SQL.

ptah-compat migrate lint --format

  • Top level: .Env.Driver, .Env.URL, .Env.Dir, .Steps, and .Files.
  • Each step entry: .Name, .Text, .Error, and .Result.
  • Each file entry: .Name, .Text, .Error, and .Findings.

ptah-compat migrate status --format

  • Top level: .Env.Driver, .Env.URL, .Env.Dir, .Available, .Applied, .Pending, .Current, .Next, and .Status.
  • Each available and pending migration file entry: .Name, .Version, and .Description.
  • Each applied revision entry: .Version, .Description, .Type, .Applied, .Total, .ExecutedAt, .ExecutionTime, .Error, .ErrorStmt, and .OperatorVersion.

ptah-compat migrate down --format

  • .Env, .Planned, .Reverted, .Current, .Target, .Total, .Start, .End, and .Error.

The shared report shape and URL redaction rules are in Atlas-compatible output and redaction.