# Atlas-compatible commands

Per-command status for every ptah-compat verb, with Atlas differences and known gaps.

Source: https://docs.ptah.run/v0.8.1/reference/atlas-commands/

The Atlas-compatible reference records what each `ptah-compat <command>` does,
where it differs from Atlas, and which inputs fail explicitly. Usage, flags,
and worked examples live on
[Atlas migrate commands](../../atlas/migrate-commands/) and
[Atlas schema commands](../../atlas/schema-commands/); the surfaces and
translation model are on the
[Atlas compatibility overview](../../atlas/overview/). Native verbs are on
[Native commands](../native-commands/).

The Atlas-compatible commands are hosted by the separate `ptah-compat` binary,
a drop-in replacement for scripts that need Atlas-style root commands; the main
`ptah` binary has no Atlas command paths. The invocations on this page are
written as `ptah-compat <command> ...`, the name the binary ships under. Each
verb section names its native `ptah` twin.

## Every Atlas-compatible command

The index below is generated from the `ptah-compat` command tree, so it names
every command path the binary answers to and no path it does not. Rows are
sorted, which keeps a namespace's verbs together, and the **What it does**
cell is the command's own one-line description. What each verb does in full,
and where it differs from Atlas, is the section for that verb further down;
`version`, `license` and `completion` are under
[Utility commands](#utility-commands).

A row marked `group` is a namespace rather than an operation. `ptah-compat
help` and the four `ptah-compat completion` shells have rows because the
shipped binary answers to them; `__complete` and `__completeNoDesc` ship too,
and no walk of the tree reaches them, so they are named here rather than
listed.

<div class="ptah-reference-filter" data-mode="next-table">
  <label for="atlas-command-filter">Filter commands <input id="atlas-command-filter" type="search" placeholder="migrate apply, schema diff…" /></label>
  <p data-ptah-filter-status role="status" aria-live="polite"></p>
</div>

<!-- BEGIN GENERATED COMPAT COMMANDS -->
| Command | What it does | Notes |
| --- | --- | --- |
| `ptah-compat completion` | Generate the autocompletion script for the specified shell | group |
| `ptah-compat completion bash` | Generate the autocompletion script for bash | — |
| `ptah-compat completion fish` | Generate the autocompletion script for fish | — |
| `ptah-compat completion powershell` | Generate the autocompletion script for powershell | — |
| `ptah-compat completion zsh` | Generate the autocompletion script for zsh | — |
| `ptah-compat help` | Help about any command | — |
| `ptah-compat license` | Print license information | — |
| `ptah-compat migrate` | Atlas migrate commands | group |
| `ptah-compat migrate apply` | Apply pending migrations | — |
| `ptah-compat migrate checkpoint` | Squash migration history into a cumulative-schema checkpoint | — |
| `ptah-compat migrate diff` | Compute migration diff against a desired schema | — |
| `ptah-compat migrate down` | Roll back migrations | — |
| `ptah-compat migrate edit` | Edit a migration file and update the directory checksum | — |
| `ptah-compat migrate hash` | Write or update the migration directory checksum | — |
| `ptah-compat migrate import` | Import migrations from another tool | — |
| `ptah-compat migrate lint` | Lint migration files | — |
| `ptah-compat migrate ls` | List the migration files in the directory | — |
| `ptah-compat migrate new` | Create a new migration file | — |
| `ptah-compat migrate push` | Push migration directory to a remote registry | — |
| `ptah-compat migrate rebase` | Move a migration to the end of history and update the directory checksum | — |
| `ptah-compat migrate rm` | Remove a migration file and update the directory checksum | — |
| `ptah-compat migrate set` | Set migration revision state | — |
| `ptah-compat migrate show` | Print the contents of one or more migration files | — |
| `ptah-compat migrate status` | Show migration status | — |
| `ptah-compat migrate test` | Run declarative migration tests against a dev database | — |
| `ptah-compat migrate validate` | Validate migration directory integrity | — |
| `ptah-compat schema` | Atlas schema commands | group |
| `ptah-compat schema apply` | Apply a desired schema to a database | — |
| `ptah-compat schema clean` | Clean database schema objects | — |
| `ptah-compat schema diff` | Diff desired schema against another schema | — |
| `ptah-compat schema fmt` | Format schema files | — |
| `ptah-compat schema inspect` | Inspect a database schema | — |
| `ptah-compat schema plan` | Plan a declarative migration for a schema transition | group |
| `ptah-compat schema plan approve` | Approve a plan in a remote registry | — |
| `ptah-compat schema plan lint` | Run analysis (migration linting) on a plan file | — |
| `ptah-compat schema plan list` | List plans in a remote registry | — |
| `ptah-compat schema plan new` | Create a new plan file for the schema transition | — |
| `ptah-compat schema plan pull` | Pull a plan from a remote registry | — |
| `ptah-compat schema plan push` | Push a plan to a remote registry | — |
| `ptah-compat schema plan rm` | Remove a plan from a remote registry | — |
| `ptah-compat schema plan test` | Run schema plan tests | — |
| `ptah-compat schema plan validate` | Validate a plan file against the schema transition | — |
| `ptah-compat schema push` | Push schema state to a remote registry | — |
| `ptah-compat schema stats` | Schema statistics | group |
| `ptah-compat schema stats inspect` | Count the objects in a live schema and emit them as OpenMetrics | — |
| `ptah-compat schema test` | Run declarative schema tests against a dev database | — |
| `ptah-compat schema validate` | Report structural problems in a desired schema without a database | — |
| `ptah-compat script` | Run a declared data operation | group |
| `ptah-compat script exec` | Run a declared exec script | — |
| `ptah-compat script loop` | Run a declared loop script | — |
| `ptah-compat script query` | Run a declared query script | — |
| `ptah-compat version` | Print Ptah version information | — |
<!-- END GENERATED COMPAT COMMANDS -->

## Utility commands

`ptah-compat version` prints Ptah build information.

`ptah-compat license` prints Ptah's MIT license and the license-clean Atlas
compatibility notice.

`ptah-compat completion <shell>` generates completion output for the
Atlas-compatible command tree under the invoked executable name, so an
installation renamed for a drop-in completes under the name it was invoked as.
The [Atlas compatibility overview](../../atlas/overview/) documents that
rename.

## Commands strict mode removes

`PTAH_ATLAS_STRICT_COMPAT=1` selects a separate Atlas Community Edition policy,
which constructs a smaller command tree before dispatch. The
[Atlas compatibility overview](../../atlas/overview/) states what that selector
is for and what else it narrows. This section is the command-level answer: 28
of the 52 paths above leave the surface, and they do not all answer the same
way.

- **refused** — 12 paths stay registered and hidden, so each one's own gate
  answers and the message names the path that was invoked.
- **refused by its group** — 10 paths sit below a group that is itself refused.
  The group's gate answers and names **the group**, never the path invoked, so
  a script searching the message for its own verb finds nothing.
- **absorbed by the group** — 2 paths are unregistered below a group cobra will
  run, so that group runs in their place.
- **unknown command** — 4 paths are unregistered below the root, which cobra
  cannot run, so it reports the first segment it could not resolve.

Two paths exit `0`, and they are the ones worth knowing about.
`ptah-compat migrate ls` and `ptah-compat migrate show` are not registered
under `PTAH_ATLAS_STRICT_COMPAT=1`, so the `migrate` group runs in their place:
it writes its own help to standard output, writes nothing to standard error,
and exits `0`. A caller testing only the exit code cannot tell that from the
verb having run and found nothing. Test for the output the verb produces, or
leave the variable unset.

A real invocation usually carries a flag, and that one fails:

```text
$ PTAH_ATLAS_STRICT_COMPAT=1 ptah-compat migrate ls --dir file://migrations
Error: unknown flag: --dir
```

The message is about a flag, for a verb that is not there, and it exits `1`.

The fourth class names only the first segment it could not resolve, so the
sub-verb never appears:

```text
$ PTAH_ATLAS_STRICT_COMPAT=1 ptah-compat script query
Error: unknown command "script" for "atlas"
```

The name in that message is the one the binary reports itself under rather than
the file name it was installed as.

<!-- BEGIN GENERATED STRICT COMPAT CLASSIFICATION -->
<div class="ptah-wide-table">

| Command | Under `PTAH_ATLAS_STRICT_COMPAT=1` | Exit | Stream | The answer names |
| --- | --- | --- | --- | --- |
| `ptah-compat migrate checkpoint` | refused | `1` | stderr | `ptah-compat migrate checkpoint` |
| `ptah-compat migrate down` | refused | `1` | stderr | `ptah-compat migrate down` |
| `ptah-compat migrate edit` | refused | `1` | stderr | `ptah-compat migrate edit` |
| `ptah-compat migrate ls` | absorbed by the group | `0` | stdout | `ptah-compat migrate` |
| `ptah-compat migrate push` | refused | `1` | stderr | `ptah-compat migrate push` |
| `ptah-compat migrate rebase` | refused | `1` | stderr | `ptah-compat migrate rebase` |
| `ptah-compat migrate rm` | refused | `1` | stderr | `ptah-compat migrate rm` |
| `ptah-compat migrate show` | absorbed by the group | `0` | stdout | `ptah-compat migrate` |
| `ptah-compat migrate test` | refused | `1` | stderr | `ptah-compat migrate test` |
| `ptah-compat schema plan` | refused | `1` | stderr | `ptah-compat schema plan` |
| `ptah-compat schema plan approve` | refused by its group | `1` | stderr | `ptah-compat schema plan` |
| `ptah-compat schema plan lint` | refused by its group | `1` | stderr | `ptah-compat schema plan` |
| `ptah-compat schema plan list` | refused by its group | `1` | stderr | `ptah-compat schema plan` |
| `ptah-compat schema plan new` | refused by its group | `1` | stderr | `ptah-compat schema plan` |
| `ptah-compat schema plan pull` | refused by its group | `1` | stderr | `ptah-compat schema plan` |
| `ptah-compat schema plan push` | refused by its group | `1` | stderr | `ptah-compat schema plan` |
| `ptah-compat schema plan rm` | refused by its group | `1` | stderr | `ptah-compat schema plan` |
| `ptah-compat schema plan test` | refused by its group | `1` | stderr | `ptah-compat schema plan` |
| `ptah-compat schema plan validate` | refused by its group | `1` | stderr | `ptah-compat schema plan` |
| `ptah-compat schema push` | refused | `1` | stderr | `ptah-compat schema push` |
| `ptah-compat schema stats` | refused | `1` | stderr | `ptah-compat schema stats` |
| `ptah-compat schema stats inspect` | refused by its group | `1` | stderr | `ptah-compat schema stats` |
| `ptah-compat schema test` | refused | `1` | stderr | `ptah-compat schema test` |
| `ptah-compat schema validate` | refused | `1` | stderr | `ptah-compat schema validate` |
| `ptah-compat script` | unknown command | `1` | stderr | `ptah-compat script` |
| `ptah-compat script exec` | unknown command | `1` | stderr | `ptah-compat script` |
| `ptah-compat script loop` | unknown command | `1` | stderr | `ptah-compat script` |
| `ptah-compat script query` | unknown command | `1` | stderr | `ptah-compat script` |

</div>

<!-- END GENERATED STRICT COMPAT CLASSIFICATION -->

## Migrate commands

### `ptah-compat migrate apply`

Applies Atlas-format migration directories with Atlas-compatible apply flags
and Atlas revision bookkeeping by default. With `--env`, reads `env.url`,
`migration`, and `format.migrate.apply` from `atlas.hcl`.

Executes every Atlas OSS directory format selected by `migration.format` or a
`?format=` directory URL query; non-`atlas` formats are converted in memory to
up-only migrations.

Honors the `-- atlas:checkpoint` file directive: a fresh database applies
only the latest checkpoint plus post-checkpoint migrations, and a database
that already applied pre-checkpoint history silently skips the checkpoint,
matching measured Atlas behavior.

**Fails before the target database is opened:** unknown formats, two Flyway
files with the same exact revision identity, goose/dbmate files missing their
up directive, colliding versions, an Atlas directory that fails `atlas.sum` verification, and
an Atlas directory that carries no `atlas.sum` at all while holding at least one
top-level `.sql` file — both checksum refusals are byte-identical to
`ptah-compat migrate validate` and nothing is applied. A directory with no
top-level `.sql` file reports `No migration files to execute` and exits `0`.

Direct Flyway apply records each source version token byte for byte, including
dotted, dot-prefixed, padded, nonnumeric, baseline, token-ending-`R`, and empty
repeatable tokens. `--baseline` and the extended `--to-version` address those
exact tokens; the numeric projection controls execution order only. One
repeatable migration can own the empty token and remains settled after its body
is edited and rehashed. Two repeatables collide and are refused before the
target opens. Applied opaque history remains readable after its source file is
removed, without recreating pending work; its exact token still protects
source order. The known `.atlas_cloud_identifier` bookkeeping row remains
metadata rather than becoming a migration.

In `--format '{{ json . }}'` output, an exact empty current or target identity
is emitted as an explicit empty `Current` or `Target` member. Pending and
applied file records likewise keep an explicit empty `Version`. A genuinely
absent current or target state omits that member.

The revision table must distinguish every exact source token under its
configured version collation. Ptah-created MySQL and MariaDB tables use
`utf8mb4_bin`, and SQL Server tables use `Latin1_General_100_BIN2`. An existing
table whose collation aliases two covered tokens, such as `A` and `a`, is
refused before migration SQL runs rather than applying one body and failing on
the second primary-key write.

Atlas CE accepts a Ptah-written exact-token history, and Ptah continues a
CE-written one. Each side records a different per-revision checksum:

- CE stores the source directory's `atlas.sum` h1.
- Ptah stores the digest of the migration body.

Ptah accepts either when verifying a row, so an existing CE-managed Flyway
database needs no `migrate set` or revision-table rewrite before Ptah applies
the next migration. Ptah keeps writing its own encoding for rows it adds,
because an `atlas.sum` h1 chains over every preceding file and a history keyed
on it stops verifying whenever an unrelated migration is inserted ahead of it.
A file edited after it was applied is still refused, since neither checksum
matches bytes that changed.

A
same-token `V2`/`B2` row is also refused when CE's ordinary applied type cannot
prove which byte-identical source ran; Ptah's own executed-baseline marker still
renders as `applied` and does not create `--baseline` boundary semantics. A
successful explicit `--baseline 2` that selects `B2` writes the ordinary
baseline type plus a durable source-baseline marker, settling that exact
identity on this and later runs. Baselining `V2` does not authorize a `B2`
introduced later with the same token. A Ptah `migrate set` of an executed
baseline renders as `manually set` while retaining a separate settled-baseline
marker; CE can still read the row.

The scan is top-level-only, matching what `atlas.sum` covers and what Atlas CE
reads. A `.sql` file in a subdirectory, or a top-level `.SQL`, is not a
migration and is not executed; each one is named on stderr as declined, which
Atlas CE does not do — see [#976](https://github.com/stokaro/ptah/issues/976).

A leading `-- atlas:txmode file` or `-- atlas:txmode none` header overrides
global `file` or `none` for that migration. File-level `all`, unknown values,
duplicates, and explicit file modes under global `all` fail before the affected
body or revision row changes. The directive belongs to the initial line-comment
header; a blank line after the header is accepted but not required. Txtar
`migration.sql` and `down.sql` sections carry independent modes; a mode before
the `-- atlas:txtar` marker is rejected as an unsafe archive classification.

Directories in an external tool's format are gated on the `atlas.sum` the source
directory carries, verified before the source layout is parsed and before the
database is opened. The covered file set is Atlas's for that layout, so a
golang-migrate down file and a Flyway undo file are not covered, and a layout
that carries no `atlas.sum` and whose covered set is empty is not a checksum
error. What executes is what the verified checksum covers, for every layout.

**Rejected on this verb, matching Atlas OSS:** `--dir-format`.

`--to-version` bounds the run at a migration version: every pending migration up
to and including it runs, and nothing above it does. The bound is enforced where
the apply plan is built, inside the migration lock, so a concurrent writer
cannot turn it into a different set of migrations; a version the directory does
not carry is refused rather than rounded to a neighbor, and the bound cannot be
combined with the amount argument, because the two select different prefixes and
neither outranks the other. The pinned community binary does not register the
flag — Atlas's published CLI reference does, which is what makes this a
Pro-surface addition rather than a CE parity row.

```bash
ptah-compat migrate apply --url "$DB" --dir file://migrations --to-version 20240101000002
```

**`--lock-name`** replaces the name of the session advisory lock that
serializes migration runs (`ptah_migrate` by default). Runs serialize only
against other runs naming the same lock. 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 silently falling
back to the default. On a dialect with no advisory-lock semantics the run
prints a stderr note naming the lock it did not acquire.

**`--skip-lock`** takes no lock at all, so a lock another process holds is
ignored rather than waited on and concurrent runs can interleave. It cannot be
combined with `--lock-name`.

Pre-migration checks — `-- +ptah check` directives and Atlas txtar
`checks.sql` / `checks/*.sql` sections — are enforced here as they are natively.
Atlas registers no `--skip-checks` on `migrate apply` (measured: CE v1.2.0
answers with `unknown flag`, and Atlas's own help surface registers it only
on `migrate down`), so the emergency bypass is the `PTAH_SKIP_CHECKS`
environment variable rather than a flag this surface must not grow:

```bash
PTAH_SKIP_CHECKS=1 ptah-compat migrate apply --url "$DB" --dir file://migrations
```

It reads like every other boolean `PTAH_*` variable — unset enforces the checks,
a valid boolean is honored, and anything else, an exported empty value included,
fails the run before a migration is applied. It warns on stderr while active and
bypasses checks only: `atlas.sum` verification and revision bookkeeping are
unaffected. See
[Pre-migration checks](../../versioned/integrity-and-safety/) and
[Boolean environment variables](../configuration/#boolean-environment-variables).

Native twin: [`ptah migrations up`](../native-commands/).

### `ptah-compat migrate status`

Reports Atlas-format migration status with Atlas revision-table metadata and
Atlas-format migration directories by default. Supports `--dir-format atlas`,
`--revisions-schema`, and Atlas Go-template `--format` output over `.Env`,
`.Available`, `.Applied`, `.Pending`, `.Current`, `.Next`, `.Status`, and — on
a half-applied migration — `.Count`, `.Total`, `.SQL`, and `.Error`.

For a direct Flyway directory, available, applied, pending, current, and next
versions use the exact source tokens. Numeric keys remain internal ordering
values and never replace those identities in the report. Current follows the
pinned binary's textual maximum over applied source tokens; numeric high-water
continues to govern execution internally. In JSON templates, a present empty
current identity and each empty file or revision identity remain explicit
empty members rather than disappearing through `omitempty`.

The default (no `--format`) report mirrors the Atlas shape, because this is the
verb pipelines parse:

```text
Migration Status: PENDING
  -- Current Version: No migration applied yet
  -- Next Version:    20260721120000
  -- Executed Files:  0
  -- Pending Files:   2
```

`Executed Files` counts revision rows and `Pending Files` counts directory files
not yet recorded. A half-applied migration annotates the first three lines
(`(1 statements applied)`, `(1 statements left)`, `(last one partially)`) and
adds a `Last migration attempt had errors:` block naming the failing statement.

Native twin: [`ptah migrations status`](../native-commands/), which keeps its
own block — only the compatibility surface is a contract with an existing
pipeline.

### `ptah-compat migrate hash`

Writes `atlas.sum` for the migration directory. `--dir-format` defaults to
`atlas`, so the compatibility path writes `atlas.sum` by default, and the
atlas layout forwards to `ptah migrations hash`. A successful compatibility
hash is silent, matching Atlas CE; inspect or commit the resulting `atlas.sum`
instead of relying on a progress message.

When `migration.dir` is a `data.template_dir` URL, the checksum belongs to the
immutable rendered view. The command remains silent and does not create an
`atlas.sum` beside the source templates.

### `ptah-compat migrate validate`

Silently verifies `atlas.sum` on success. Missing or mismatched checksum files
use Atlas-compatible exit-1 stdout/stderr diagnostics, and `--dev-url` cleans
the dev database and replays the migration directory to validate SQL
execution. Native `ptah migrations validate` keeps its own banner and exit
contract.

A `data.template_dir` URL is validated through its rendered snapshot; the
source templates are not modified.

### `ptah-compat migrate ls`

Lists the migration files the directory holds, oldest version first, one file
per line. `-s/--short` collapses each migration to its version, dropping the
description and the `.sql` suffix, and `-l/--latest` keeps only the newest
migration. No database is contacted and none of the SQL is executed.

The directory is verified against its `atlas.sum` before anything is listed, and
a directory carrying no `atlas.sum` is refused. An empty directory is not: there
is nothing to verify and nothing to list, so it exits 0 having printed nothing.

Native twin: [`ptah migrations ls`](../native-commands/), which defaults to
Ptah's own layout, offers `--dir-format`, and runs the checksum gate only under
`--verify-sum` — it is a read-only verb, so it describes a directory that has
drifted instead of refusing to.

### `ptah-compat migrate show`

Prints the SQL of each named migration, exactly as it sits on disk. A migration
is named by version or by file name, the positional is repeatable, and the
bodies are printed in the order named, separated by a blank line, with a
migration named twice printed once. Every named migration is located before any
of it is printed, so a run naming one migration that is not there prints
nothing.

The same `atlas.sum` gate as `ls` applies, for the same reason.

Native twin: [`ptah migrations show`](../native-commands/), which takes the
version on a repeatable `--version` flag rather than a positional and adds
`--direction` to select which half of a reversible Ptah pair is printed.

### Source directory layouts on the verbs that read a directory

`hash`, `validate`, `lint`, `status`, and `set` read a migration directory
written by another tool. `ls` and `show` do not: they register no
`--dir-format`, because the verbs they mirror register none either, and they
read the directory as the Atlas layout. The layout is selected with either spelling Atlas
accepts, and `hash` and `validate` agree on the resulting `atlas.sum`:

```bash
ptah-compat migrate hash --dir "file://migrations?format=goose"
ptah-compat migrate hash --dir file://migrations --dir-format goose
```

Accepted values are `atlas` (the default), `golang-migrate`, `goose`, `flyway`,
`liquibase`, and `dbmate`. `migration.format` in `atlas.hcl` selects the same
thing under `--env`. When the query and the flag disagree, the query wins: an
empty `?format=` selects the atlas layout whatever the flag says.

Each layout covers a different set of source files, matching Atlas:

| Layout | Files the checksum covers |
| --- | --- |
| `atlas`, `goose`, `liquibase`, `dbmate` | every top-level `*.sql`, ordered by name |
| `golang-migrate` | every top-level `*.up.sql`. The down file of a pair is not covered, so editing it is invisible to `validate` |
| `flyway` | the whole tree: `V` and `B` files, then `R` repeatables last. `U` undo files are dropped, and everything at or below the highest baseline is squashed |

Format names are matched exactly, so `GOOSE` and `" goose "` are unknown
formats rather than `goose`. A directory with nothing for the layout to cover —
an empty directory, or a `golang-migrate` directory holding only a down file —
hashes to the empty-set checksum and validates clean.

An empty `--dir-format`, a query parameter other than `format`, and a repeated
`format` parameter are all accepted and read exactly as Atlas reads them: the
empty value and the unknown key select the atlas layout, and a repeated key
takes the first value.

`format` is the only query key that selects anything. A key that selected
nothing is named on standard error and the run continues, on each of the eight
verbs that accepts a `--dir` query — `apply`, `diff`, `hash`, `lint`, `new`,
`set`, `status` and `validate`:

```text
note: atlas migrate apply --dir: ignoring migration directory URL query key
"fromat". Only ?format= selects the directory layout. Set
PTAH_STRICT_DIR_QUERY=1 to refuse an unrecognized key instead.
```

The exit code and everything on standard output are unchanged, so a script
reading either sees exactly what Atlas produces. The note exists because
dropping the key is what Atlas does and saying nothing about it is not: a
misspelled `?fromat=goose` selects no layout on either tool, so the directory is
read as the atlas layout while you believe it is being read as Goose. Set
[`PTAH_STRICT_DIR_QUERY=1`](../extension-variables/#ptah_strict_dir_query) to make that a
refusal instead.

`checkpoint`, `down`, `edit`, `rebase`, `rm` and `test` register `--dir` too and
refuse any query on it — `migration directory URL query parameters are not
supported for this command` — so neither the note nor the variable applies
there. The pinned community binary answers `unknown flag: --dir` on all six, so
this is stricter than a CLI with no contract on those verbs rather than a parity
gap.

Inputs that stay refused where Atlas CE exits 0, all of them loudly:

- a semicolon in the query, such as `?format=flyway;x=1`, which Atlas drops
  whole and reads as the atlas layout;
- a query on a `--dir` that a later `--dir` overrides;
- a stray positional argument, including one after `--`.

None of them can produce a wrong checksum. They are tracked in
[#990](https://github.com/stokaro/ptah/issues/990); the query rules are shared
with `migrate apply`, so relaxing one widens what the integrity gate accepts.

`migrate apply` registers no `--dir-format` at all, matching Atlas. It gates a
directory read through `?format=` over the same per-layout file set `hash`
writes, so what `migrate hash` writes is what `migrate apply` verifies.

`migrate new` and `migrate diff` both write the selected layout, gating the
directory over that layout's covered file set first. `migrate diff` composes
each layout's own files: a forward and a rollback file for `golang-migrate`
(`.up.sql` / `.down.sql`) and `flyway` (`V…` / `U…`), both halves under
directives in one file for `goose` and `dbmate`, and a changeset carrying
`--rollback:` lines for `liquibase`. `atlas.sum` is written over that layout's
covered file set, so the community binary's own `migrate validate` reads back
what Ptah wrote.

The generated SQL is Ptah's renderer's, on every layout including `atlas` — the
layout is what these follow, not the DDL text. On `liquibase`, Ptah writes ONE
changeset carrying the whole migration and all of its `--rollback:` lines, where
the community binary writes one changeset per statement; rolling the migration
back is exact either way, but Ptah does not offer per-statement rollback there,
because pairing each forward statement with a reverse statement would be a guess
about a reverse plan that is computed for the run as a whole.

The rollback half is planned against the state the migration starts from, not
the state it produces, so a forward migration that DROPS a table rolls back into
the CREATE TABLE that puts it back. That re-created table carries its own
primary key and its single-column foreign keys, and the rollback does not repeat
them; a rollback that did is refused by the server outright. A CHECK constraint
is not in the table body at all, so it is restored by its own statement.

Two differences survive that round trip, on this verb and on
`ptah migrations generate` alike. The restored primary key takes the server's
default name rather than the name it had, because the table body has nowhere to
put one. A column that was UNIQUE comes back both from the table body and from
the named constraint, so the restored table holds two unique constraints where
it held one. Neither stops the rollback from applying; both mean a
`schema diff` immediately after a rollback can report work to do.

### `ptah-compat migrate lint`

Runs Ptah migration linting with Atlas `--dir-format` defaulting to `atlas`.
It reads every Atlas source layout under both spellings, so a directory
another tool wrote can be linted without importing it first:

```bash
ptah-compat migrate lint --dir "file://migrations?format=golang-migrate" \
  --dev-url "sqlite://dev.db" --latest 1
```

The checksum step covers the file set that layout's `atlas.sum` covers, not the
Atlas one: on a `golang-migrate` directory, editing the covered `*.up.sql`
fails the lint and editing the uncovered `*.down.sql` does not, and a Flyway
`sub/V2__nested.sql` is covered.

Flyway lint reports exact source tokens in its migration detail lines. A single
repeatable uses a blank version detail and the generic analysis header, matching
the pinned community binary. If replay of an exact empty identity fails, the
error names it as `""` rather than leaving an anonymous gap in the diagnostic;
numeric and nonempty identities retain their ordinary spelling.

| Flag | Behavior |
| --- | --- |
| `--latest N` | Positive N selects the latest revision keys and remains exclusive with `--git-base`. Zero disables latest selection and configured `lint.latest`; explicit or configured Git may still select. With neither, the command returns `--latest or --git-base is required`. |
| `--git-base`, `--git-dir` | Map to native changeset linting. `--git-base` is the alternative to `--latest`. Changed Atlas repeatable files are selected by `R` or `<number>R`, not by a lossy numeric version. |
| `--dev-url` | Required. Infers the lint dialect, and cleans and replays migrations on directly connectable dev databases. |
| `--format` | Atlas Go-template output over `.Env`, `.Steps`, and `.Files`. The default is Atlas's migration-analysis text report. |

Both requirements are the community binary's, reproduced word for word — a run
missing `--dev-url` answers `required flag(s) "dev-url" not set` and one naming
no changeset answers `--latest or --git-base is required`, each at exit 1. Either
selector may come from the selected `atlas.hcl` env instead of the command line.
`PTAH_ATLAS_LINT_WITHOUT_DEV_URL=1` runs the analysis with no dev database, which
Ptah can do and that binary cannot.

A `docker://` dev database is provisioned: the container is started, used and
removed by the command. Atlas web reports remain an explicit gap.
Native twin: [`ptah migrations lint`](../native-commands/).

### `ptah-compat migrate new`

Creates a skeleton migration and updates `atlas.sum`; the native equivalent is
`ptah migrations create`. Every Atlas source layout is supported under both
spellings, and the created files follow the selected tool's convention:

| Layout | Files created |
| --- | --- |
| `atlas` | `<version>_<name>.sql`, empty |
| `golang-migrate` | `<version>_<name>.up.sql` and `.down.sql`, both empty |
| `flyway` | `V<version>__<name>.sql` and `U<version>__<name>.sql`, both empty |
| `goose` | `<version>_<name>.sql` holding `-- +goose Up` / `-- +goose Down` |
| `dbmate` | `<version>_<name>.sql` holding `-- migrate:up` / `-- migrate:down` |
| `liquibase` | `<version>_<name>.sql` holding `--liquibase formatted sql` |

`atlas.sum` is rewritten over the set the selected layout covers, so
`golang-migrate` and `flyway` create two files and cover one. On `atlas`,
`--edit` opens the created file in `$VISUAL`/`$EDITOR` before `atlas.sum` is
refreshed; on every other layout it is refused, as it is by Atlas.

For a project `migration.dir` backed by `data.template_dir`, the new root SQL
file and `atlas.sum` are synchronized to the template source directory. The
existing templates remain unchanged and the command stays silent.

A migration name is required on a non-`atlas` layout. Atlas accepts an omitted
name and writes the version alone, but such a file is one Ptah's own
`migrate apply` cannot read back on `golang-migrate`, `goose`, `liquibase` and
`dbmate`, so it is not created.

A migration name may not contain a path separator on this verb or on
`migrate diff`: the name becomes part of the file name, so a `/` in it selects a
directory that is not there. The run is refused and nothing is written, matching
the community binary's refusal of the same input
([#1231](https://github.com/stokaro/ptah/issues/1231)). Nothing else about a
name is refused — a space, a backslash and `..` are accepted, as they are there.

The directory's existing `atlas.sum` is verified first — over the selected
layout's covered file set — with the same output `migrate apply` and
`migrate validate` produce, and nothing is created when the check fails; see
[Which verbs enforce `atlas.sum`](../../atlas/migrate-commands/#which-verbs-enforce-atlassum).
An unrecognized `--dir` query key is ignored here and named on standard error,
as it is on the other seven verbs that accept a `--dir` query — `apply`,
`diff`, `hash`, `lint`, `set`, `status` and `validate`. It is not ignored on
`checkpoint`, `down`, `edit`, `rebase`, `rm` or `test`: those refuse a `--dir`
query outright, as the shared rules above record.

`--dir` must name a scheme on `migrate new`, `diff`, `hash`, `validate`,
`status`, and `lint`: `--dir migrations` is refused on those consumers with
`missing scheme for dir url. Did you mean "file://migrations"?` and creates
nothing. The stderr line ends with the bytes `20 0a`: one ASCII space followed
by the line feed. The same applies to its `PTAH_DIR` twin. A directory selected
by `atlas.hcl` `migration.dir` still accepts a bare path
([#1186](https://github.com/stokaro/ptah/issues/1186)).

Omitted entirely, `--dir` defaults to `file://migrations`, so
`ptah-compat migrate new add_users` creates `./migrations` and writes into it
([#1241](https://github.com/stokaro/ptah/issues/1241)). Missing parents are
created too: `--dir file://db/migrations` creates `db` and `db/migrations`. A
path component that exists and is not a directory is still refused, and nothing
is written. See
[The `--dir` default](../../atlas/migrate-commands/#the---dir-default) for how
the default ranks against `PTAH_DIR`, `PTAH_MIGRATIONS_DIR` and `atlas.hcl`.

### `ptah-compat migrate set [version]`

Moves Atlas revision history to the positional version without executing
migration SQL, with Atlas revision-table metadata and Atlas-format migration
directories by default. With `--env`, reads `env.url`, `migration.dir`, and
`migration.revisions_schema` from `atlas.hcl`; explicit `--dir`, `--url`, and
`--revisions-schema` flags keep CLI precedence. Flyway operands match exact
source tokens byte for byte, so `01` and `1` name different migrations. An
explicit empty positional operand selects a single repeatable's empty token.
The success summary renders that identity as `""`, both on the current-version
line and beside the changed revision, so the present token is not an anonymous
gap in operator output.

When the directory no longer owns an applied identity, metadata moves only if
the stored row preserves enough Flyway role information to order it against the
target. A known retired baseline stays before versioned migrations regardless
of its token. A known retired versioned row uses numeric component order
against a versioned target, but uses Flyway's raw-token squash cut against a
surviving baseline target: for example, B2 keeps retired V10 because `"10"`
sorts before `"2"`. The same raw-token order applies when both migrations are
known baselines: selecting B3 keeps retired B2, while selecting B10 removes
retired B20.

A single repeatable target follows retired versioned history even
though its exact identity is empty. This is deliberately safer
than Atlas CE v1.3.0: CE exits `0` but deletes the retired versioned row,
while Ptah exits `0` and keeps both revision identities. Rows that do not
distinguish those roles, including ordinary Atlas CE applied rows, refuse
before any revision row changes instead of guessing from token bytes.

Native twin:
[`ptah migrations set`](../native-commands/).

### `ptah-compat migrate down`

Forwards to `ptah migrations down` with mapped Atlas flags.

| Flag | Behavior |
| --- | --- |
| `--dev-url` | Replays and verifies the rollback plan on the dev database before the target is touched (native `--shadow-db`). |
| `--format` | Flag or `PTAH_FORMAT`; renders an Atlas Go-template report. Real and dry-run rollbacks are non-interactive. |
| `--revision-format` | Defaults to the `atlas` table layout, like `migrate set`. The native `ptah` pass-through selects the `ptah` layout. Both retain recoverable failed-down state. |

Because the forward defaults to the Atlas revision-table layout, a bare invocation
reverts the revisions `ptah-compat migrate apply` wrote.

A failed rollback stays dirty even with the Atlas layout. Resume it with
`ptah migrations repair --dir-format atlas --revision-format atlas`, using the
same database, directory, revision schema, version, and required
`--resume-from` statement as the failed compat run.

The command starts a real rollback without reading stdin, matching Atlas. It
does not accept the native `--confirm` flag. Review `--url`, `--dir`, and
`--to-version` before running it. Native `ptah migrations down` keeps its
interactive confirmation.

`--to-tag` rolls back to the version a tag names. Tags are recorded with `ptah
migrations tag` and live in the database beside the revisions, so resolving one
contacts nothing:

```bash
ptah migrations tag release-v1 --db-url "$DATABASE_URL" --version 20260801000001
ptah-compat migrate down --url "$DATABASE_URL" --dir file://migrations --to-tag release-v1
```

A tag nobody recorded is refused rather than resolved to zero, because the
rollback target defaults to `0` and a discarded tag would revert the whole
history where the operator asked for something bounded. Passing `--to-version`
as well is refused too: both name where to stop, and picking one silently would
roll back to a version nobody chose.

`--skip-checks` bypasses the pre-migration checks the down bodies being rolled
back carry — the `-- +ptah check` directives that abort a rollback when their
assertion does not hold. It means on this verb exactly what it means on
`migrate apply`, including through `PTAH_SKIP_CHECKS`.

`--plan` derives the rollback from the schema difference instead of running the
down bodies, which is what makes a migration with **no** down body revertible:

```bash
ptah-compat migrate down --url "$DATABASE_URL" --dir file://migrations \
  --to-version 1 --plan --dev-url "docker://postgres/16/dev"
```

The target version's schema is built on `--dev-url` and compared against the live
database, so the flag needs one: that schema exists nowhere else — not in the
live database, which is what is being changed, and not in any single file, since
a version's schema is the accumulation of every migration up to it.

Ptah's own revision and tag tables are excluded from the comparison. The live
database and the dev replay can name them differently, and a table one side has
and the other does not otherwise looks exactly like a table the rollback should
drop.

A derived plan is an inference about structure, while a down file is a statement
of intent. A down body can preserve data a derived `DROP` will not, and can order
operations in a way a structural comparison has no reason to choose. That is why
`--plan` is opt-in and the authored bodies stay the default.

### `ptah-compat migrate diff`

Verifies the directory's `atlas.sum`, replays the selected migration layout on
`--dev-url`, diffs it against `--to`, and writes new migration files in that
layout. `atlas.sum` updates only after every file was written; a failed write
rolls the whole generation back.

For a project `migration.dir` backed by `data.template_dir`, replay uses the
immutable rendered snapshot while publication writes the new root SQL files and
`atlas.sum` to the template source directory. Existing templates are not
rewritten, and a successful writing run is silent so the backing host path does
not leak through the virtual migration URL.

The checksum refusal comes first — before the dev database is connected to and
before `--to` and `--dev-url` are required at all, which is the order Atlas uses
— so nothing is created on a directory it refuses. A directory that has never
been hashed and already holds a migration is refused; one that does not exist
yet, or holds no top-level `*.sql`, is not, which is how a project's first
migration gets written. An unrecognized `--dir` query key is ignored;
`?format=` and `--dir-format` select any of the six writable layouts. The
directory is verified over that layout's covered file set before the dev
database is opened.

Goose carries a whole-file `-- +goose NO TRANSACTION` directive when either the
forward or exact reverse plan requires no-transaction execution. The directive
governs both sections. golang-migrate, Flyway, dbmate, and Liquibase remain
fail-closed for those plans because their safe transaction metadata has not
been proven. The Atlas layout remains forward-only and carries `-- atlas:txmode
none` on its own file when required.

Both spellings of the layout are read the way the other verbs that accept a
`--dir` query read them. The value is matched verbatim, so `--dir-format ATLAS`
and `--dir-format " atlas "` are rejected rather than coerced, and an explicit
`?format=` outranks `--dir-format` — `--dir "file://migrations?format=atlas"
--dir-format golang-migrate` writes the Atlas-layout migration. An
unrecognized query key selects no layout, so `--dir-format` still decides
there.

The verb takes at most one positional, the migration name, and a second one is
refused with `accepts at most 1 arg(s), received 2`
([#1231](https://github.com/stokaro/ptah/issues/1231)). The name may not contain
a path separator, checked where the file would be written: a diff that finds no
changes writes nothing, never reaches the name, and still exits 0 — which is
what the community binary does.

**Desired state (`--to`)** accepts one of: local `.hcl`, `.yaml`, `.yml`, or
`.sql` files; one directly connectable database URL; one local Atlas migration
directory; or one `env://` reference into the evaluated `atlas.hcl`
environment. Source kinds cannot be mixed, the database source must use the
`--dev-url` dialect, and a desired database must not identify the same database
as `--dev-url`.

**Flags**

| Flag | Behavior |
| --- | --- |
| `--dry-run` | Atlas-hidden; prints the generated SQL instead of writing files. |
| `--format` | Renders generated SQL with `sql` and `.MarshalSQL` templates. The default is Atlas-style two-space indentation. |
| `--schema`/`-s` | Narrows the current and desired schemas used for comparison and output. |
| `--edit` | Opens the generated migrations in `$VISUAL`/`$EDITOR` before `atlas.sum` is finalized. |
| `--env` | Reads `env.schema.src`, `env.dev`, `migration.dir`, `format.migrate.diff`, and supported `diff` policy from `atlas.hcl`. |

`--schema` narrows comparison only: migration replay and cleanup still own the
complete [dev database realm](../../concepts/database-urls-and-dev-databases/).

**Concurrent indexes.** With `diff.concurrent_index.create`, new indexes are
planned as `CREATE INDEX CONCURRENTLY` and their files are tagged with the
Atlas `-- atlas:txmode none` directive, splitting mixed plans into a
transactional file followed by a concurrent-index file. Unsplittable mixes are
refused.

**`--lock-timeout`** bounds waiting for both Ptah's local migration-directory
lock and the exclusive dev-database lock:

- 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;
- dialects without a safe dev-database lock fail before cleanup.

Cross-host ClickHouse and CockroachDB replay is unsupported.

**`--qualifier`** prefixes every object in the generated statements with a
custom schema qualifier on PostgreSQL-family, MySQL, and MariaDB dev databases.
Invalid values, unsupported dialects, multi-schema plans, and statement kinds
Ptah cannot re-qualify yet (for example enum types) fail explicitly before any
file or checksum is written.

A `docker://` dev database is provisioned: the container is started, used and
removed by the command.
Native twin: [`ptah migrations generate`](../native-commands/).

### `ptah-compat migrate import`

Imports local `file://` migration directories from `atlas`, `golang-migrate`,
`goose`, `flyway`, `liquibase`, or `dbmate` format into a separate Atlas
single-file directory and writes `atlas.sum`. Flyway repeatable migrations are
converted to one-time versioned Atlas files rather than emitted with an `R`
suffix, so the imported directory remains stable under Ptah's revision model.

A conventional Liquibase formatted-SQL name such as `changelog.sql` makes the
importer parse every covered SQL file and emit one numeric Atlas file per
changeset, in lexical file and changeset appearance order. Versions are padded
to the digit width of the final version so lexical checksum order stays numeric.
Headerless or malformed members refuse the whole import before destination
creation.

A successful compatibility import is silent; inspect the destination directory
and its `atlas.sum` instead of relying on a progress message. Failures are still
reported on stderr. The native `ptah migrations import` converts the same source
formats into Ptah-native migrations instead, and reports what it wrote.

`--from` and `--to` are resolved by the same rules as every other verb's
`--dir`. Both require a scheme, the source layout comes from `--from`'s
`?format=` query or from `--dir-format` with the query winning, and the value is
matched verbatim — `FLYWAY` and `" flyway "` are refused. An empty `?format=`
selects the Atlas layout and outranks `--dir-format`, which makes the import a
no-op that is refused rather than performed.

Refusals are answered in Atlas's order: the source scheme, then the layout
value, then whether the source directory exists, then whether it is already in
the Atlas layout, then the target scheme. A source directory that is not there
is reported as missing rather than as a layout conflict.

### `ptah-compat migrate checkpoint [name]`

Forwards to `ptah migrations checkpoint`, replaying the migration directory on
the `--dev-url` dev database and writing a cumulative-schema checkpoint.

| Argument | Maps to |
| --- | --- |
| `--dir` | The native migrations directory. |
| Positional name (optional) | The checkpoint description, used as the file-name stem. |
| `--dir-format=atlas` | Writes the Atlas single-file checkpoint (default). |
| `--dir-format=ptah` | Writes the ptah reversible checkpoint pair. |
| `-s, --schema` | The native `--schemas` allow-list; repeat the flag or pass one comma-separated value. |
| `--qualifier` | The native `--qualifier`: prefixes every object the checkpoint creates with a schema qualifier. |
| `--lock-timeout` | The native `--migration-lock-timeout`, bounding the wait for the dev database's migration lock during the replay. |
| `--edit` | The native `--edit`: opens the written checkpoint files in `$VISUAL`, then `$EDITOR`, and refreshes the directory checksum afterwards. |

`--edit` refuses before it replays anything when the session could not finish:
with no editor configured, and when standard input is not a terminal. The second
refusal is the one that matters in CI, where an interactive editor launched
without a terminal does not fail but waits. Set
`PTAH_ALLOW_NONINTERACTIVE_EDIT=1` when `$EDITOR` is a script that edits and
exits on its own; that is an environment variable rather than a flag because
Atlas registers no flag for it. The native `--editor` selects a specific editor
command and is deliberately not part of this verb's Atlas flag surface.

`--lock-timeout` bounds a lock only on dialects that implement advisory locking.
On one that does not — SQLite, ClickHouse — the run says so on stderr rather than
accepting a bound that binds nothing.

`--qualifier` is refused on a dialect Ptah cannot qualify, so a checkpoint is
never written half-qualified.

Not registered here: `--format`, whose Go-template report needs the compat
formatting path this verb does not have yet, and `--lock-name`, which belongs to
the named-lock family.

`--dir-format` selects the checkpoint convention, and **on this verb it
defaults to `atlas`**, matching the default Atlas registers and every other
compat migrate verb:

- **`atlas`** writes one up-only file, `<version>_<name>.sql`, whose first line
  is the `-- atlas:checkpoint` directive, and refreshes `atlas.sum`. The version
  is a UTC timestamp, raised to one above the newest migration in the directory
  when that is higher — including migrations in subdirectories, which the replay
  and the reader both see but a bare timestamp would sort below. There is no
  down file: the Atlas format is up-only, so an Atlas checkpoint is not
  reversible.
- **`ptah`** writes the reversible pair
  `NNNNNNNNNN_<name>.checkpoint.up.sql` / `.checkpoint.down.sql` and refreshes
  `ptah.sum`.

The native `ptah migrations checkpoint` keeps `ptah` as its default; only the
compat surface defaults to `atlas`. Native accepts `--dir-format=auto` as a
spelling and refuses it with a named message, because writing under it would
have to guess the file convention and which integrity file to refresh. On the
compat surface `auto` is not an Atlas directory format at all, so it draws the
ordinary unknown-format rejection instead.

A directory must end up with one integrity file and one file convention, so two
shapes are refused before anything is written:

- a checkpoint that would leave both `ptah.sum` and `atlas.sum` behind, which
  `--dir-format auto` cannot read; and
- an Atlas-format checkpoint into a directory holding ptah-convention
  migrations, hashed or not. There the checkpoint would be permanently
  invisible: discovery reads the ptah files and never sees it, while
  verification finds the `atlas.sum` and reports the directory as valid.

Re-hash or convert the directory into one format first.

The read side honors the `-- atlas:checkpoint` directive either way: applying a
checkpoint directory bootstraps a fresh database from the latest checkpoint and
silently skips the checkpoint on a database that already applied the
pre-checkpoint history, matching measured Atlas behavior.

Atlas keeps `migrate checkpoint` in its Pro build — the pinned CE binary
registers the verb but aborts with "not supported by the community version" and
registers none of its own flags — so this is a free Ptah capability rather than
an Atlas CE stub.

`?format=` on this verb's `--dir` URL is still refused; use `--dir-format`. CE
aborts every `migrate checkpoint` invocation, so there is no CE behavior to
diverge from here and refusing an unimplemented spelling loudly is the intended
outcome. The eight verbs that accept a `--dir` query honor the parameter;
`migrate diff` writes forward and reverse SQL in the selected layout.

### `ptah-compat migrate test [paths]`

Forwards to `ptah migrations test`.

| Atlas flag | Native equivalent |
| --- | --- |
| `--dir` | The native migration directory, Atlas-format by default via `--dir-format`. |
| `--dev-url` | The native throwaway database; an ephemeral SQLite database when omitted. |
| `--run` | The native case-name filter. |
| `--revisions-schema` | The native `--migrations-schema`: the schema a `migrate_to` step records revisions in. |
| Positional path (optional) | The directory of Ptah-native YAML test cases, default `./tests`. |

Exit codes match the native runner: 0 when all cases pass, 1 on test failure.

Atlas keeps `migrate test` in its Pro build, so this is a free Ptah capability
rather than an Atlas CE stub.

### `ptah-compat migrate edit {name | version}`

Forwards to `ptah migrations edit`: the positional maps to the native
`--version` (a migration file name contributes its leading version digits),
`--dir` maps to the native migration directory (Atlas-format by default via
`--dir-format`), the editor resolves from `$VISUAL`, then `$EDITOR`, and the
directory checksum is rewritten afterwards. Atlas keeps `migrate edit` outside
its community build, so this is a free Ptah capability rather than an Atlas CE
stub.

### `ptah-compat migrate rebase {name | version}`

Forwards to `ptah migrations rebase`: re-timestamps the selected migration
past every existing version and rewrites the directory checksum. Multiple
positional values and `a...b` version ranges are rejected loudly; forward one
migration per run. Atlas keeps `migrate rebase` outside its community build,
so this is a free Ptah capability rather than an Atlas CE stub.

### `ptah-compat migrate rm {name | version}`

Forwards to `ptah migrations rm`: deletes the selected migration's files and
rewrites the directory checksum. Atlas keeps `migrate rm` outside its
community build, so this is a free Ptah capability rather than an Atlas CE
stub.

### `ptah-compat migrate push`

Registered but not implemented because the command targets an account-bound
hosted registry protocol. `--help` reports that the command is not implemented
and exits 0; direct execution reports the same status and exits 1. The open
replacement is the native `ptah migrations push` to any OCI registry.

## Schema commands

### `ptah-compat schema inspect`

Inspects the `--url` source and writes Atlas-compatible schema output without
Ptah status banners.

**Sources (`--url`)** accepts one of: a live database URL; a local `.hcl`,
`.yaml`, `.yml`, or `.sql` schema file; a migration directory (a directory
containing `atlas.sum`); or an `env://` reference resolved through the
evaluated `atlas.hcl` env.

Non-database sources require `--dev-url` and are evaluated on the dev database:
it is reset, the source is materialized on it (schema files executed, migration
directories replayed), and the result is introspected. Inspecting a file
without `--dev-url` fails with Atlas's `--dev-url cannot be empty` message.

**Output formats**

| Output | How to request it |
| --- | --- |
| HCL | The default, or `--format '{{ hcl . }}'`. |
| SQL | `--format '{{ sql . }}'`. |
| JSON | `--format '{{ json . }}'`. |
| Custom templates | `{{ .MarshalHCL }}`, `{{ hcl . }}`, `{{ sql . }}`, `{{ mermaid . }}`. |

Bare `--format hcl`, `--format sql`, and `--format json` write those literal
words. They add no line feed, and database contents do not change the values.
Surrounding whitespace is also preserved: `--format ' hcl '` writes hex
`20 68 63 6c 20`, with no line feed. Those literal cases match Atlas CE v1.3.0.
Native `ptah schema inspect --format hcl|sql|json` keeps its rendered
shorthands.

**Split-write exports.** `{{ hcl . | split | write "schema" }}` and
`{{ sql . | split | write "schema" }}` support the documented Atlas split
strategies: per object (the default, with a `main.sql` `atlas:import` entry
point for SQL), `split "schema"`, and `split "type"`, plus an optional
file-extension argument. That entry point reads back: pointing a schema-file
source at `main.sql` loads every file it imports.

Exports render one output plan applied by a single writer. Duplicate output
paths, traversal or escape from the output directory, planned file/directory
collisions, and existing-directory destinations fail explicitly before anything
is written. The pinned Atlas CE binary rejects `split`, `write`, and `hcl` as
non-community template functions, so these exports are an open Ptah extension.

**Filtering**

- `--schema`/`-s` narrows inspection when supported by the database reader.
- `--include` positively selects the top-level resources that survive, with
  Atlas-style globs and `[type=...]` selectors. Repeated and comma-separated
  values union. Composition order is `--schema`, then `--include`, then
  `--exclude`. A selection that matches nothing renders no objects and keeps
  exit status 0, and reports itself on standard error; an empty value carries
  no selection and leaves inspection unfiltered.
- The OSS `--exclude` flag filters inspected resources with Atlas-style globs
  and `[type=...]` selectors, including the Atlas-documented
  `*[type=extension].version` field selector with schema-qualified globs.
- Child resources (columns, indexes, constraints, triggers, policies, grants)
  cannot be included on their own with `[type=column]`, which fails before any
  database is contacted.
- A positional spelling such as `table.column` is not refused on its shape: it
  is indistinguishable from a table literally named that. An identifier
  holding a dot is therefore selectable as `main."my.table"`, `a\.b\.c`, or
  bare `a.b.c`.
- Whether a selector matched is decided by the projection, not by the selector
  text: `path.Match` treats `.` as an ordinary character, so `table.column`,
  `table*column`, `table?column`, and `table[.]column` all reach past a
  top-level resource and select nothing. `schema apply` and `schema diff`
  refuse an empty `--include` selection; `schema inspect` keeps exit status 0
  and reports it on standard error.
- A selection that drops a dependency of a selected object is refused rather
  than rendered.
- Other field-level exclude selectors fail explicitly, and the refusal names
  the fields the selected resource types do support. Type selectors on
  non-final pattern segments fail too, except for the leading `[type=schema]`
  segment documented under
  [Leading schema type selector](../../atlas/retained-divergences/#leading-schema-type-selector);
  exporter blocks remain an explicit gap.

<!-- BEGIN GENERATED EXCLUDE FIELD SELECTORS -->

#### Subtractable fields

A field is subtractable when Ptah can remove it from the inspected document and
still write a document that means what the database holds. That is the whole
criterion, and it is narrower than "a field the reader captured": a column's
type is captured and cannot be subtracted, because a column without one is not
a column. A comment can go, and the table is still that table.

| Resource type | Subtractable fields |
| --- | --- |
| `base_table` | `comment` |
| `extension` | `version` |
| `materialized_view` | `comment` |
| `synonym` | `comment` |
| `table` | `comment` |
| `view` | `comment` |

`.*` names every field in the row for the selected type. A selector naming
anything else is refused rather than ignored, because a selector that silently
does nothing is how a user comes to believe a field was excluded.

Adding a field is a change to `excludeFieldSelectors` in
`internal/atlasfilter`, and this table is checked against it
(`TestExcludeFieldSelectors_MatchTheDocumentedSet`) — the two cannot drift.

<!-- END GENERATED EXCLUDE FIELD SELECTORS -->

The pinned Atlas CE binary rejects `schema inspect --include` with
`unknown flag: --include`; Atlas registers it. The measured
behavioral differences are tabulated under
[`schema inspect --include`](../../atlas/retained-divergences/#schema-inspect---include).

Native twin: [`ptah schema inspect`](../native-commands/).

### `ptah-compat schema apply`

Diffs a live database against the `--to` desired state, prints the planned SQL,
and applies it after interactive confirmation or explicit `--auto-approve`.

**Desired state (`--to`)** accepts one of:

- local `file://` `.hcl`, `.yaml`, `.yml`, or `.sql` schema files;
- one `file://` directory of `.sql` or `.hcl` schema files, read in filename
  order as an ordered script — the two formats together are ambiguous, other
  extensions are ignored, an empty directory is refused, a subdirectory is
  refused rather than descended into, and a file that declares an object an
  earlier file already declared is refused (`read state from "2_b.sql": table
  "users" already exists`) unless its declaration carries `IF NOT EXISTS` or
  `OR REPLACE`;
- one directly connectable database URL;
- one migration directory (a `file://` directory containing `atlas.sum`)
  replayed on the required `--dev-url` dev database;
- one `env://<attribute>` reference (`src`, `schema.src`, `url`, `dev`,
  `migration.dir`) resolved through the evaluated `atlas.hcl` env.

All `--to` values must be one source kind, database and migration-directory
sources accept one URL, and unsupported schemes such as `atlas://` fail before
the target database is contacted.

**Flags**

| Flag | Behavior |
| --- | --- |
| `--dry-run` | Prints the plan without applying. Mutually exclusive with `--auto-approve` on the command line. |
| `--auto-approve` | Applies without the interactive confirmation. Mutually exclusive with `--dry-run` on the command line. |
| `--tx-mode` | `file` and `all` execute the generated plan in one transaction; `none` executes statements without transaction wrapping. |
| `--format` | Atlas-style templates over planned changes with `sql`, `.MarshalSQL`, and the shared helper set including `json`. `{{ json . }}` renders `{Driver, URL, Changes{Applied\|Pending}}`. |
| `--exclude` | Filters matching resources out of both sides of the comparison before planning, as do disabled `schema.mode` values. |
| `--edit` | Opens the planned SQL in `$VISUAL`/`$EDITOR` before approval; the edited SQL is what gets applied. |
| `--file`/`-f` | Atlas's hidden alias, accepted for local HCL or SQL paths. |
| `--env` | Reads `env.url`, `env.src`, `env.schema.src`, `env.dev`, `env.exclude`, `env.schema.mode`, `format.schema.apply`, and supported `diff` policy from `atlas.hcl`. |

`--dry-run` and `--auto-approve` contradict each other — one asks for the plan
and no execution, the other for execution with no prompt — and the pair is
refused rather than silently resolved
([#1231](https://github.com/stokaro/ptah/issues/1231)):

```text
Error: if any flags in the group [dry-run auto-approve] are set none of the others can be; [auto-approve dry-run] were all set
```

The rule reads the command line, not the environment. `PTAH_DRY_RUN` is not a
typed `--dry-run` for this purpose, so a wrapper that exports it does not turn
every `--auto-approve` in the pipeline into a refusal: the run behaves the way
`--dry-run` alone does, printing the plan and applying nothing.

```bash
PTAH_DRY_RUN=1 ptah-compat schema apply -u "$DATABASE_URL" \
  --to file://schema.sql --dev-url "$DEV_URL" --auto-approve
# Planned schema changes: … (exit 0, nothing executed)
```

The variable does not work in the other direction either. Typing both flags is
still typing both, so adding `--dry-run` to the command line above is refused
with the same sentence whether or not the variable is exported.

`--env` evaluation includes local variable defaults, locals, `getenv`, `file`,
`fileset`, `format`, `jsondecode`, `jsonencode`, `tolist`, and the supported
`hcl_schema`, `sql`, `external`, `runtimevar`, and `template_dir` project data
sources. Data sources execute only when the selected config depends on them;
dependency order is resolved before command settings are read.

**`--schema`/`-s` and `--include`** scope both sides of the comparison.
`--schema` restricts them to the named schema scopes; `--include` positively
selects top-level resources with Atlas-style glob selectors and `[type=...]`
filters. Repeated values union deterministically, `--exclude` plus disabled
`schema.mode` values subtract afterward, cross-scope dependencies refuse the
plan with explicit diagnostics, and an explicit include selection matching
nothing refuses the apply.

For a live PostgreSQL desired schema, a selected extension outside the default
schema retains its installation schema. A create plans `CREATE SCHEMA IF NOT
EXISTS` followed by `CREATE EXTENSION ... WITH SCHEMA ...`, identical live
placements compare as synced, and drops remain supported. A placement change
is detected but fails before SQL output because Ptah does not yet plan `ALTER
EXTENSION ... SET SCHEMA`.

**`--plan file://<path>`** executes a pre-approved local plan file instead of
re-planning. Both plan formats are accepted, detected by content: the Atlas
`.plan.hcl` shape and Ptah's native format_version-1 `.plan.json`.

- A JSON plan is verified against its recorded source fingerprint — a drifted
  target refuses with a stale-plan error — and may run without `--to`.
- An Atlas-format plan requires `--to`, as Atlas does: its
  hashes are Atlas-computed with no local recipe, so the plan is replayed on
  a dev database from the target's current schema, and the reached state must
  equal the `--to` desired state before the target is touched. SQLite targets
  get a throwaway dev database automatically; every other dialect requires
  `--dev-url`.
- Before replaying, statements matching a deny-list of known escape
  constructs are refused by name before anything executes. The lint covers
  SQLite (`ATTACH`/`DETACH`, `VACUUM INTO`, storage-directory pragmas,
  `load_extension`), PostgreSQL (`DO` blocks, routine bodies and dynamic SQL
  calling file-access or `dblink` functions, `COPY ... PROGRAM` or `COPY` with
  a file path, `postgres_fdw`, `file_fdw`), MySQL/MariaDB
  (`LOAD DATA INFILE`, `INTO OUTFILE`/`DUMPFILE`, `LOAD_FILE`,
  `ENGINE=FEDERATED`, `CREATE SERVER`, `INSTALL PLUGIN`/`COMPONENT`,
  `DATA`/`INDEX DIRECTORY`), SQL Server (`xp_cmdshell`, `xp_dirtree`,
  `OPENROWSET`, `OPENDATASOURCE`, `BULK INSERT`, `sp_addlinkedserver`), and
  ClickHouse (`URL`, `File`, `S3`, `HDFS`, `MySQL`, `PostgreSQL` table
  engines).
- **The lint is best-effort, not exhaustive, and it is not a sandbox.** String
  concatenation alone defeats any scanner, so a `--dev-url` must point at a
  database you are willing to have a foreign plan file execute arbitrary SQL
  against.
- **Real enforcement exists only on SQLite dev databases** — the ephemeral one
  Ptah creates for SQLite targets, and an operator-supplied SQLite
  `--dev-url`, since the restriction keys on the dev dialect. Their sessions
  refuse `ATTACH`, `DETACH`, and `VACUUM INTO` at the engine level and cannot
  load extensions; Ptah verifies the restriction is in force before rehearsing
  and refuses to rehearse if it is not. Storage-directory pragmas and
  `writable_schema` are not covered, so the converges-to-`--to` verdict is a
  good-faith check rather than an adversarial one. See
  [Save and execute plan files](../../atlas/schema-commands/#where-enforcement-is-real).
- The replay also runs under `--dry-run`, so a plan can be verified without
  committing to apply it.
- Whenever a desired state is available, the end state is verified again on
  the target after the apply and a mismatch fails loudly; the verification is
  always on, like Atlas's.
- Registry `atlas://` plan URLs are rejected. `--plan` cannot be combined
  with `--file`, `--exclude`, `--schema`, `--include`, or `--edit`, and
  `--dev-url` combines with `--plan` only together with `--to`.

**`--lock-timeout`** bounds waiting for the session advisory lock that
serializes concurrent schema applies against one target. The lock is acquired
before target inspection and planning, held through simulation, confirmation,
and execution, and released on every exit path. Empty waits indefinitely, an
elapsed timeout fails before the target is inspected. PostgreSQL, YugabyteDB,
MySQL, MariaDB, and SQL Server take a real lock; every other dialect proceeds
unlocked with a stderr note. The native `ptah schema apply` refuses a typed
`--lock-timeout` on those dialects rather than noting it — see
[Apply a desired schema](../../direct/apply/).

**`--lock-name`** replaces the lock name for the run (`ptah_schema_apply` by
default). Runs serialize only against other runs naming the same lock, which is
both how a run coordinates with a different tool and how it opts out of the
default. An empty value is refused; on a dialect without advisory locks the
stderr note names the lock that was not acquired.

**`--skip-lock`** takes no lock at all: a lock another process holds is ignored
rather than waited on, so concurrent applies can interleave. It cannot be
combined with `--lock-name`.

**`--dev-url` rehearsal.** `--dev-url` is required whenever `--to` is not
already a live database, failing with Atlas's `--dev-url cannot be empty`
message otherwise; a database `--to` needs none, and
`PTAH_ATLAS_APPLY_WITHOUT_DEV_URL=1` restores planning without one. Before the
apply, `--dev-url` rehearses the exact ordered plan on the dev database — reset,
the target's current schema recreated, then the planned (or edited) statements
executed under the same transaction mode. A failed rehearsal refuses the apply
with the target unchanged; the dev database must not be the target and must
share its schema scope. The rehearsal runs under `--dry-run` too, so a dry run
cannot report a plan the real apply would refuse.

Native twin: [`ptah schema apply`](../native-commands/).

### `ptah-compat schema plan`

Computes the declarative migration from the `--from` target database to local
`--to` schema files and saves it as a local plan file. The default format is
the Atlas `.plan.hcl` shape — one `plan` block with `from`/`to` fingerprints
and the migration SQL — so the saved file is readable by Atlas's plan reader;
an `--output` path ending in `.json` writes the native fingerprinted JSON
plan (format version 1) instead. Without `--save`/`--output`/`--dry-run`, the
plan document prints to stdout.

**Flags**

| Flag | Behavior |
| --- | --- |
| `--save` | Atomically writes `<name>.plan.hcl`, using an Atlas-style UTC timestamp default name or `--name`. Refuses to replace any existing entry at the default path, including a concurrent writer or symlink. |
| `--output <path>`/`-o` | Chooses the location and atomically replaces that entry; a `.json` path selects the native JSON plan format. The plan name recorded inside a JSON plan stays fingerprint-derived unless `--name` is given. |
| `--dry-run` | Prints the plan document without saving. |
| `--auto-approve` | Accepted for Atlas CLI compatibility; a locally saved plan file is approved by operator review, so there is no prompt to skip. |
| `--edit` | Opens the planned SQL in `$VISUAL`, then `$EDITOR`, and saves the plan rebuilt from valid UTF-8 text. Comments round-trip, and dialect-aware statement severity and the destructive marker are re-derived from what you wrote. An edit leaving no statement is refused, and nothing is written. |
| `--name-format <template>` | Templates the name over `.FromHash` and `.ToHash`; hashes use Atlas's measured untagged standard-Base64 representation, and the Atlas template helpers (`json`, `upper`, `add`, `indent_ln`, …) are available. Cannot be combined with `--name`. A rendered `/` or `\` requires explicit `--output`. |
| `--skip-lint` | Accepted as an explicit no-op: `schema plan` runs no lint step, so there is nothing to skip. |
| `--format <template>` | Renders the plan through a Go template instead of printing the plan document. See [Plan report payload](#plan-report-payload). |
| `--directive <line>`/`-d` | Writes a migration directive into the plan file. See [Plan directives](#plan-directives). |
| `--env` | Reads `url` (the plan target), `schema.src`, `dev`, `exclude`, `schema.mode`, and supported `diff` policy from `atlas.hcl`. |

The JSON plan records the ordered SQL statements with per-statement safety
severity, the dialect, the exclude patterns, the schemas the plan reads beyond
a `--from` URL pinned to one schema, and SHA-256 fingerprints of the source and
desired schema states. The `.plan.hcl` shape carries only the
name, the fingerprints, and the migration SQL; Ptah writes its own sha256
fingerprints there (Atlas parses the file but verifies its own
base64 hashes, which have no local recipe), re-derives statement severity at
read time, and refuses to save as `.plan.hcl` a plan computed with `--exclude`,
or one whose desired schema names a schema the pinned `--from` URL does not
cover. The shape cannot record either, and without the second the stale-plan
check would not read that schema.

The `.FromHash` and `.ToHash` field names and their untagged standard-Base64
representation were verified against Atlas's own reference; their values still
differ because Ptah fingerprints its independent
representation.

Editing changes the statements, never the fingerprints. `from` still describes
the live source database, so apply-time staleness detection keeps working. `to`
still describes the schema the plan was computed against, which edited SQL may
no longer reach: `schema apply` replays an Atlas-format plan on a dev database
and requires it to converge on `--to` before touching the target, but a native
`.json` plan carries no such replay, so an edited JSON plan is only as good as
its review.

Everything that can refuse the plan without reading its statements — the
`--exclude`/`.plan.hcl` incompatibility, and every `--name-format` failure —
happens before the editor opens, so an edit is never thrown away over a problem
that was decidable beforehand.

A default plan name becomes a file name, so `--name` and `--name-format`
refuse path separators unless `--output` supplies the location separately.
Both flags always refuse control characters, `.`/`..`, and the characters
Windows forbids in a file name (`:*?"<>|`). Atlas's documented
`plan_{{ slice .ToHash 0 8 }}` example works against Ptah's untagged
standard-Base64 hash value; because standard Base64 can contain `/`, use an
explicit `--output` when the rendered name must never depend on file-system
path rules.

#### Plan report payload

`--format` takes the same Go template `schema apply`, `schema diff`,
`migrate apply` and the other reporting verbs take, and renders it instead of
printing the plan document. It selects the output rather than adding to it: a
plan asked to be saved is still saved, and the `Plan saved to file://…` line is
suppressed so it cannot land inside a rendered document.

The payload's field names are Ptah's. On the other eight verbs the shape
follows what Atlas prints, because Atlas executes those verbs and the names are
therefore observable; Atlas keeps `schema plan` in its Pro registry flow and
publishes help text for this flag and nothing a template could be written
against, so there was no shape to match — only one to choose and write down.
What it is chosen to match is the sibling verb: `.Changes[].Cmd` is
`schema diff`'s spelling, so a template written there reads a plan unchanged.

| Field | Meaning |
| --- | --- |
| `.Name` | Plan name, which is also the default plan file's base name. |
| `.Dialect` | Target dialect the statements were rendered for. Empty for a plan read back from `.plan.hcl`, which has no field for it. |
| `.From`, `.To` | The plan's source and desired-state fingerprints, the two values the plan file records. |
| `.Exclude` | Exclusion patterns the plan was computed with; omitted when there are none. |
| `.Destructive` | Whether any statement was classified destructive. |
| `.Changes` | Ordered planned statements, each with `.Cmd`, `.Severity` and `.Reason`. Always a list, so a synced schema renders an empty array rather than a sentence. |
| `.MigrationBody` | The plan file's `migration` attribute exactly as written, directives included. Read this to reproduce the artifact; read `.Changes` or `sql` to describe it. |

`sql` renders the statements as one script and takes the same optional indent
argument it takes on `schema diff`. The shared helpers (`json`, `json_merge`,
`upper`, `add`, `indent_ln`, and the color helpers) are all registered — on
`schema diff` the shared set is behind `PTAH_SCHEMA_DIFF_TEMPLATE_HELPERS`
because Atlas offers `sql` alone there, and no such narrower surface exists on
a verb Atlas does not run at all.

```console
$ ptah-compat schema plan --from "$DB" --to file://schema.sql --dry-run --format '{{ json . }}'
$ ptah-compat schema plan --from "$DB" --to file://schema.sql --dry-run --format '{{ sql . }}'
```

An empty or unparseable `--format` is refused before any database is opened,
and nothing is written.

#### Plan directives

`-d`/`--directive` writes a migration directive into the plan file, in the
leading comment block its readers honor — the unbroken run of line comments
that starts the migration body, closed by a blank line. A directive below that
block governs nothing, which is why the position is not the operator's to
choose.

Both spellings of a line are accepted: `atlas:txmode none` as the flag's help
spells it, and `-- atlas:txmode none` as a migration file spells it.

| Directive | Honored by |
| --- | --- |
| `atlas:txmode none`, `atlas:txmode file` | `schema apply --plan`, which executes the plan in that transaction mode. |
| `atlas:nolint [<selector>…]` | `schema plan lint`, which silences the named rules over the plan's SQL. |

Anything else is refused rather than recorded, including `atlas:checkpoint`:
a directive a plan file carries and nothing acts on is an instruction the
reviewer approves and the run ignores. `atlas:txmode all` is refused too, and
the refusal names `file` as the value to write instead — a transaction mode is
a file-level directive, and `all` is a global one.

A second `atlas:txmode` is refused whether or not it agrees with the first;
repeated `atlas:nolint` lines are ordinary, since each names its own selectors.

At apply time the directive resolves against `--tx-mode` under the rule a
versioned migration's directive already answers to, through the same code: the
directive wins, except under `--tx-mode all`, where the combination is refused
rather than silently decided.

```console
$ ptah-compat schema plan --from "$DB" --to file://schema.sql -o app.plan.hcl -d 'atlas:txmode none'
$ ptah-compat schema apply --url "$DB" --to file://schema.sql --plan file://app.plan.hcl
```

**Not implemented**

- Registry-bound `--push`, `--pending`, and `--repo` are recorded waivers
  that fail loudly.
- `--schema`, `--include`, and `--lock-timeout` fail explicitly until
  implemented.
- The registry sub-verbs (`approve`, `list`, `pull`, `push`, `rm`) stay
  unsupported-boundary stubs: they arbitrate plan state in a remote registry.
- `test` and `lint` are implemented and have their own sections below. Both are
  separate verbs over a saved plan file, and neither puts a lint or test step on
  `schema plan` itself, so `--skip-lint` is still a no-op there.

Atlas keeps `schema plan` in its Pro registry flow, so this is a free Ptah
capability rather than an Atlas CE stub.
Native twin: [`ptah schema plan`](../native-commands/).

#### `ptah-compat schema plan new`

Creates a plan file for the schema transition. It is `schema plan` restricted
to the flag set Atlas registers on this sub-verb, with saving always on:
Atlas gives `new` neither `--save` nor `--dry-run`, and its documented purpose
is to create the plan file.

The plan is written to `--output`/`-o` when given, and to `<name>.plan.hcl` in
the working directory otherwise; an existing default-named plan file is never
overwritten. An `--output` path ending in `.json` writes the native JSON plan
format. `--edit`, `--name`, `--name-format` and `--format` behave exactly as they do on
`schema plan`, and the same refusals apply to `--repo`, `--lock-timeout`,
`--schema` and `--include`.

`--save`, `--dry-run`, `--push`, `--pending`, `--skip-lint` and `--directive`
are **not registered** here, because Atlas does not register them here.

#### `ptah-compat schema plan validate`

Checks that the plan file named by `-f`/`--file` describes the transition from
the `--from` target database to the local `--to` schema files, without changing
the target database. On success it writes nothing to stdout and exits 0.

Two checks run, and they are the two `schema apply --plan` runs before it
touches anything:

1. the plan's recorded from-fingerprint must match the live `--from` database,
   for plans carrying Ptah's own `sha256:` fingerprints. An Atlas-written plan
   file carries Atlas hashes with no local recipe, so this check is skipped for
   those and the replay below is the only from-state gate.
2. the plan's statements are replayed on a dev database seeded from the
   target's current schema, and the state they reach must equal `--to`.

The replay always runs, in both plan formats — unlike `schema apply --plan`,
which may skip it for a fingerprint-verified native plan. A matching
from-fingerprint says the plan was computed against this database, not that its
statements reach `--to`, and the second question is the one this command exists
to answer. A SQLite target gets a throwaway dev database; every other dialect
requires `--dev-url`.

An explicit `--dev-url` is refused when it identifies the `--from` target,
even through an equivalent or percent-encoded SQLite path, a symlink or hard
link, or a network URL with different credentials, default-port spelling,
loopback alias, or driver-level endpoint/database override. Network URLs with
the same dialect and selected database name fail closed even across different
endpoints because DNS aliases and replicas cannot be proven independent.
Malformed target and desired-state URLs also fail closed. These shared checks
protect both `schema apply` simulation and `schema plan validate`, and run
before the dev database can be reset.

The plan's SQL is **not** required to equal a freshly computed plan's. Atlas
documents editing a saved plan's `migration` attribute, so what is checked is
where the statements arrive, not how they are spelled.

`--to` is required; without it the command reports Atlas's own wording,
`the flag "to" is required to verify the provided plan`. `--exclude` is
refused: a JSON plan records the patterns it was computed with and the Atlas
`.plan.hcl` shape records none at all, so flag-supplied patterns would verify a
different transition than the plan describes. Registry plan URLs
(`atlas://…`) are refused like they are on `schema apply --plan`.

#### `ptah-compat schema plan lint`

Analyzes the SQL of the plan file named by `-f`/`--file` and prints what Ptah's
migration lint rules find, without changing the target database.

The plan is verified before it is analyzed, with the same two checks
`schema plan validate` runs and on the same terms, including the `--dev-url`
refusals above. A plan that fails either check is refused, and nothing is
analyzed: a report about a plan that does not describe this transition would be
an accurate report about a change nobody is about to make.

The analysis is the one `ptah-compat migrate lint` runs over a migration file
holding the same SQL — the same rules, the same codes, and the same
`atlas:nolint` directives silence it. The report goes to stdout: a header
naming the number of statements analyzed, the findings grouped by analyzer with
their suggested fixes, and a summary carrying the schema-change and diagnostic
counts.

**Findings do not change the exit code.** A plan carrying a destructive change
exits 0 with the change described. A plan is a document an operator reviews and
approves, the lint report is what they review it with, and a report that
refuses on their behalf is one they cannot approve anything with. Both of the
following are deliberate:

- Set `PTAH_ATLAS_PLAN_LINT_FAIL_ON_ERROR=1` when a pipeline needs the report
  to gate. An error-severity finding then exits 1, with the report still on
  stdout so the failure names its own reason.
- Every run states on stderr what the report is a statement about. Ptah's rule
  set is its own and does not name every hazard a schema change can carry, so
  "no diagnostics found" means "these rules found nothing", not "this plan is
  safe". [Lint rules](../lint-rules/) records what each rule covers. The
  statement is printed on the clean run too, because that is the run that reads
  as an all-clear; it goes to stderr so a pipeline capturing stdout keeps a
  document of findings and nothing else.

`--format` is refused, as it is on the sibling plan verbs: rendering the
analysis through a caller's template is a second output contract, and the one
this verb has is the report on stdout. `--exclude`, `--repo`,
`--lock-timeout`, `--schema` and `--include` are refused for the same reasons
they are on `schema plan validate`.

#### Evidence for the three local sub-verbs

The **flag sets** of `new` and `validate` match a sanitized standard Atlas
v1.3.0 help bundle captured on 2026-08-02 with the exact binary and artifact
SHA-256 values pinned in testdata, and the published
[Atlas CLI reference](https://atlasgo.io/cli-reference). The flag set of `lint`
comes from that published reference alone; it carries the same set as
`validate`, and the two are written out separately in the tests so a later
divergence in one cannot silently rewrite the assertion for the other. Their
**behavior** is not established by help, and Atlas Community Edition (CE)
settles nothing because it aborts the entire `schema plan` path. Ptah records
that limitation in tests and documentation instead of printing development
provenance during normal commands; successful `new` and `validate` runs keep
stderr empty, and a `lint` run keeps stdout to its report. Runtime parity
remains tracked in
[`stokaro/ptah#1037`](https://github.com/stokaro/ptah/issues/1037).

The validation tests do not rely only on plans written by Ptah. They also run
an Atlas-produced `.plan.hcl` artifact against live
SQLite, then mutate the source, desired schema, migration SQL, HCL syntax, and
statement set. Its versioned bundle includes source and desired SQL plus a
manifest of file hashes, known capture facts, and the original evidence that
was not preserved. Every invalid semantic variant is refused without changing
the target. Structurally valid Atlas Base64 SHA-256 `from` and `to` hashes are
treated as unauthenticated metadata because their derivation is not public;
malformed values are rejected. The replayed end state, not a foreign hash, is
the integrity boundary.

#### `ptah-compat schema plan test [paths]`

Runs `test "plan"` cases from Atlas `.test.hcl` files against a throwaway
database. A case establishes a starting state, applies a saved plan file, and
asserts what the plan did:

```hcl
test "plan" "add_email" {
  schema {
    url = "file://snapshots/v1.sql"
  }

  exec {
    sql = "INSERT INTO users (id, name) VALUES (1, 'Ada')"
  }

  apply {
    url = "file://plans/add_email.plan.json"
  }

  exec {
    sql    = "SELECT email FROM users WHERE id = 1"
    output = "ada@example.com"
  }
}
```

| Block | Does |
| --- | --- |
| `schema { url }` | Brings the database to the state that source describes. Applied through the same convergence path `apply_schema` uses. |
| `apply { url }` | Reads the plan file and runs its statements, through the same reader and executor as `schema apply --plan`. |
| `exec { sql }` | Runs a statement. With `output`, runs it and compares the first result. |

Steps run in the order they are written, which is what makes a case mean
anything: a plan describes a transition **from** a state, so `schema` has to
establish that state before `apply`.

Before the plan runs, its recorded from-state is checked against the database
the case established. A snapshot that has drifted away from the state the plan
was computed for is testing that plan against a state it was never meant for,
and the case fails rather than reporting whatever the statements happened to do
there. Only a plan carrying Ptah's own sha256 fingerprint is checked: an
Atlas-authored plan's hashes have no local recipe.

The plan's own statements are executed, never recomputed. A plan file that
stopped matching what the planner now produces is precisely what this verb
exists to catch, and recomputing would hide it.

`--dev-url` names the throwaway database, and takes a `docker://` value like
every other verb that provisions one. Without it a SQLite database is created
per case and removed afterwards. `--run` filters cases by name, and a filter
matching nothing is refused rather than reported as a pass.

`test "schema"` and `test "migrate"` cases in the same files are left to
`schema test` and `migrate test`.

`lint` was deferred alongside it for a different reason, and that reason is
answered rather than outstanding. The worry was a linter in a gating position
reporting clean on a plan a wider rule set would flag. Ptah's answer is to take
the gate out of the default and put the coverage on the output: the verb
reports, the exit code is not a verdict unless a pipeline asks for one with
`PTAH_ATLAS_PLAN_LINT_FAIL_ON_ERROR`, and every run says on stderr that a
report without findings describes the rules rather than the plan. The analyzer
coverage itself is not private to this verb — it is the published set in
[Lint rules](../lint-rules/), reported identically by `ptah-compat migrate
lint`.

#### Registry sub-verbs

`ptah-compat schema plan approve`, `list`, `pull`, `push` and `rm` are
registered and not implemented, because each targets an account-bound hosted
registry protocol. `--help` reports that the command is not implemented and
exits `0`; direct execution reports the same and exits `1`:

```text
$ ptah-compat schema plan list
Error: ptah-compat schema plan list is not implemented by Ptah
```

`new`, `validate`, `lint` and `test` are the sub-verbs that run locally, and
each has a section above.

### `ptah-compat schema diff`

Diffs two desired-state sources and prints migration SQL.

**Sources.** Each of `--from`/`-f` and `--to` accepts one of:

- local `file://` schema files with `.hcl`, `.yaml`, `.yml`, or `.sql`
  extensions;
- one directly connectable database URL, whose live schema is introspected;
- one migration directory (a `file://` directory containing `atlas.sum`)
  replayed on the required `--dev-url` dev database;
- one `env://<attribute>` reference resolved through the evaluated `atlas.hcl`
  env.

Unsupported schemes such as `atlas://` fail during validation. The SQL dialect
is pinned by `--dev-url` first, then by `--from` and `--to` database URLs; local
schema files alone still require `--dev-url`.

**Flags**

| Flag | Behavior |
| --- | --- |
| `--format` | Atlas-style templates with `sql` and `.MarshalSQL`. |
| `--exclude` | Filters resources out of both sides before diffing, as do disabled `schema.mode` values. |
| `--schema`/`-s`, `--include` | Positively scope both sides, with the same selection semantics as `schema apply`. |
| `--env` | Reads `env.schema.src`, `env.dev`, `env.exclude`, `env.schema.mode`, `format.schema.diff`, and supported `diff` policy from `atlas.hcl`. |

Selection order matches `schema apply`: schema universe for schema-owned
resources, include selection, exclusion last, and cross-scope dependency
diagnostics. Database-wide extensions remain on both sides regardless of
installation placement. An extension-only `--include` selects their qualified
or bare identities; when a non-extension resource matches, all extensions ride
as non-removing support even beside extension selectors. Schema-only and
extension-only scopes remain authoritative for extension removal. Exclusions
still subtract afterward. A selection that matches neither side exits 1 with
no diff output rather than reporting a synced schema.

Native twin: [`ptah schema diff`](../native-commands/).

### `ptah-compat schema fmt`

Formats local `.hcl` files using HCL canonical layout. Native twin:
[`ptah schema fmt`](../native-commands/).

### `ptah-compat schema clean`

Cleans user-owned schema objects through Ptah's destructive database-cleanup
runtime.

| Flag | Behavior |
| --- | --- |
| `--dry-run` | Prints the planned cleanup. |
| `--auto-approve` | Skips the interactive confirmation, which is otherwise preserved. |
| `--include` | Full mode only. Keeps objects matched by Atlas resource selectors; dependent cleanup rows such as foreign keys and implicit sequences ride with their table. An owned sequence cannot be selected without its table. |
| `--exclude` | Full mode only. Subtracts objects matched by Atlas resource selectors. Every independently writer-owned object kind remains selectable; an owned sequence cannot be preserved while its table is selected for removal. |
| `--format` | Renders Atlas-style templates over the cleanup plan. |
| `--env` | Reads `env.url` and `format.schema.clean` from `atlas.hcl`. |

The plan reports the object kinds the target dialect's cleanup really destroys,
so a `--dry-run` or `--format` report is not narrower than the apply:

| Dialect | Reported and destroyed |
| --- | --- |
| PostgreSQL family | Foreign keys, tables, views, materialized views, enum, domain, composite and range types, and functions. PostgreSQL itself also reports standalone sequences, foreign tables, procedures, aggregates, collations, and default privileges. |
| MySQL, MariaDB | Foreign keys, tables, views, stored functions and procedures, events, and MariaDB sequences. |
| SQLite | Tables and views. |
| SQL Server | Foreign keys and tables. Views are not dropped, so they are not reported. |
| ClickHouse | Base tables, views, and materialized views. A materialized view is removed with `DROP VIEW`, which takes its inner storage table with it; live views and window views are neither read nor dropped. |

Ptah's own migration revision table is included in that accounting. On
PostgreSQL, MySQL, MariaDB and SQL Server the cleanup destroys it like any other
table, so the plan names it and the operator confirming a destructive run is
confirming the loss of the record of which migrations have been applied. On
SQLite the cleanup keeps it, so it is neither reported nor destroyed. Both
revision-table layouts are covered, Ptah's `schema_migrations` and the
Atlas-compatible `atlas_schema_revisions`, and a revision table configured under
any other name is reported as the ordinary table it is.

Objects that vanish as collateral of a listed drop are not listed separately:
indexes, triggers, non-foreign-key constraints, RLS policies, and comments. The
report order is alphabetical by object kind rather than an execution order. An
unscoped cleanup rebuilds its statements from the live catalog; a scoped
cleanup executes the reported `Cmd` values in a separate deterministic order
that removes known dependents before their dependencies. PostgreSQL uses live
catalog depth to order dependent views and materialized views of the same kind.
Every PostgreSQL-family target executes the complete scoped plan in one
transaction and retries selected dependencies after a `RESTRICT` refusal. An
external dependency rolls back earlier selected drops instead of leaving a
partially cleaned schema.

PostgreSQL `SERIAL` and identity sequences are recognized as implicit table
children, execute after their parent table, and are not reported as forbidden
standalone sequences by the strict CE oracle profile. A selector that tries to
make the owned sequence and its table disagree is refused before mutation.
Function objects and changes expose full declaration `Parameters`.

PostgreSQL-family scoped drops use `RESTRICT`, so the database refuses a
selected parent when an unselected view, foreign key, or other catalog
dependency still refers to it. The narrowed command cannot cascade beyond the
objects its plan reports.

Function `Cmd` values use PostgreSQL identity arguments, so overloaded,
defaulted, and OUT-only functions remain distinct and executable.

Native twin: [`ptah db drop-all`](../native-commands/), which drops every schema
object in the database. It registers no `--include` or `--exclude`, so the
narrowed cleanup this command performs is reachable only here.

### `ptah-compat schema test [paths]`

Forwards to `ptah schema test`.

| Atlas flag | Native equivalent |
| --- | --- |
| `-u`/`--url` | `--root-dir`. Accepts Go annotations, a SQL or HCL file, or a live database URL. |
| `--dev-url` | The native throwaway database; an ephemeral SQLite database when omitted. |
| `--run` | The native case-name filter. |
| `--var` | Repeatable HCL schema-variable values. An explicit `--url` keeps these values; a project `data.hcl_schema` source uses its block-scoped `vars`. |
| Positional path (optional) | The directory of Ptah-native YAML test cases. |

With `--env`, `schema.src` supplies the desired schema URL and `dev` the dev
database. A source from `data.hcl_schema` keeps that source's variable scope,
including an empty scope that excludes run-wide values. Exit codes match the
native runner: 0 when all cases pass, 1 on test failure.

Atlas keeps `schema test` in its Pro build, so this is a free Ptah capability
rather than an Atlas CE stub.

### `ptah-compat schema push`

Registered but not implemented because the command targets an account-bound
hosted registry protocol. `--help` reports that the command is not implemented
and exits 0; direct execution reports the same status and exits 1. The open
replacement is the native `ptah schema push` to any OCI registry.

## Related pages

- Runnable migrate workflows and format template fields:
  [Atlas migrate commands](../../atlas/migrate-commands/).
- Runnable schema workflows and format template fields:
  [Atlas schema commands](../../atlas/schema-commands/).
- Measured compatibility evidence: [Conformance](../../atlas/conformance/).
