# Compatibility differences

Cases where ptah-compat deliberately differs from the pinned Atlas community binary, with the measurement and current policy for each case.

Source: https://docs.ptah.run/v0.12.0/atlas/retained-divergences/

The drop-in rule has two directions and they are not symmetric. `ptah-compat`
must never exit `0` where the pinned Atlas community binary v1.3.0 exits `1`;
that direction is a correctness failure and there are no entries for it. The
other direction — `ptah-compat` exits `1` where that binary exits `0` — is a
usability failure for a drop-in replacement, and it is allowed only where the
refusal is more useful than the acceptance.

Each retained case records what both binaries did, so a reader can evaluate the
decision rather than only the outcome. The
one-line status per capability is on the [Feature matrix](../feature-matrix/).
Most entries came out of
[`stokaro/ptah#1241`](https://github.com/stokaro/ptah/issues/1241); the project
migration-directory boundary is tracked in
[`stokaro/ptah#1118`](https://github.com/stokaro/ptah/issues/1118).

## What parity means, and what it does not

The direction stated above — never exit `0` where the pinned binary exits `1` —
is the first of two commitments. The second is that a defect is not reproduced
for the sake of being identical. Where a measured behavior loses something the
author asked for, `ptah-compat` does the better thing and records the difference
here.

One case shows the second commitment on its own, and it is not an entry below
because nothing is refused. A migration can carry `-- atlas:txmode none`
directly above its statement, with no blank line between the two. That directive
marks a statement that must run outside a transaction, such as
`CREATE INDEX CONCURRENTLY`. In that shape the community binary drops the
directive, so the statement runs inside a transaction and the migration fails
partway through. `ptah-compat` honors both shapes.

`ptah-compat` therefore has two policy profiles. The default retains every
implemented Atlas Pro-like and best-effort capability on the drop-in surface.
`PTAH_ATLAS_STRICT_COMPAT=1` selects a Community Edition oracle profile for
conformance runs, which exposes the pinned CE command and flag inventory and
refuses extended authored or live schema content before output or mutation.

These boundaries also remain active under
`PTAH_ATLAS_STRICT_COMPAT=1`. Strict mode limits the CE capability inventory;
it does not reproduce an acceptance that discards an argument, hides an edited
migration, or loses recoverable state.

The same rule protects richer live schemas. The pinned community inspector can
omit object kinds outside its edition, and its cleanup can leave or handle a
catalog differently from Ptah's complete cleanup. Strict mode therefore
refuses a live Pro-only object before `schema inspect`, `schema apply`,
`schema diff`, or `schema clean` emits output, compares incomplete states, or
mutates the target. Default `ptah-compat` keeps Ptah's complete modeled-object
behavior; the refusal exists only in the CE oracle profile.

The command-specific inventory remains read-only. Cleanup validates the
writer's complete destruction inventory, including PostgreSQL procedures,
aggregates, foreign tables, collations, and default privileges. Inspection,
apply planning, and database-backed or replayed schema- and migration-diff
sources query those catalog-only kinds in the same schema scope. A dependent
Pro-only object such as a trigger cannot disappear with a table merely because
the cleanup plan does not print it as a separate line.

Cleanup validation uses the writer's schema scope: a global
extension installed in another PostgreSQL schema does not block cleaning the
selected schema. A sequence backing a `SERIAL` or identity column is likewise
not treated as a forbidden standalone sequence, because it rides with the
table that owns it. A selector cannot split that ownership: selecting the
sequence without its table, or excluding it while the table remains selected,
is refused before mutation.

`PTAH_ALLOW_NONINTERACTIVE_EDIT=1` remains available in strict mode. It permits
an already-configured scripted editor to run without a terminal; it does not
add an editor, command, flag, or migration semantic, so it is retained as an
execution-safety control rather than classified as a Pro capability.

The same fail-closed boundary covers authored extensions. Strict schema
workflows refuse YAML sources and a `schema apply` lint policy that the CE path
cannot enforce. Commands that execute, convert, or replay migration bodies
refuse Atlas txtar, Ptah directives, and SQL templates; a bare or unknown
`-- +ptah` directive marker is refused rather than ignored. Checksum-only reads
preserve those bytes. Default mode retains and executes the extensions. The
strict profile never turns an authored safety contract into an ignored comment
or configuration block merely to copy an edition limit.

The entries from here to [What holds the #1241
entries](#what-holds-the-1241-entries) were measured on
2026-08-09 against that binary, except where a section gives its own date, each
exit status read on its own line rather than through a pipe. Where a fixture
needed a hashed migration directory, that binary authored and hashed it, so no
checksum quoted in them was computed by Ptah. The sections after it carry their
own measurement conditions.

## The entries

| Entry | `ptah-compat` | Pinned community binary v1.3.0 |
| --- | --- | --- |
| [A trailing positional argument](#a-trailing-positional-argument) | refuses one on a verb that defines none, naming the flag the value belongs to | accepts it and discards it without a word |
| [An edited already-applied migration file](#an-edited-already-applied-migration-file) | compares each applied migration against the checksum recorded when it ran, and refuses on a mismatch | records the same value and never compares it |
| [A prefix migration inserted below the oldest applied revision](#a-prefix-migration-inserted-below-the-oldest-applied-revision) | refuses at the default `--exec-order linear` and names both ways forward | exits `0` with `No migration files to execute`, saying nothing about the file it passed over |
| [A dirty revision left by a non-transactional body](#a-dirty-revision-left-by-a-non-transactional-body) | discards the revision row only where it observed the rollback, so `--tx-mode none` needs `--allow-dirty` | keeps the row in both cases and resumes from it |
| [A project migration directory outside its root](#a-project-migration-directory-outside-its-root) | confines a project `migration.dir` to the directory holding `atlas.hcl` | writes the migration and `atlas.sum` wherever the project file points |
| [`sql()` inside `check.expr` and `index.where`](#sql-inside-checkexpr-and-indexwhere) | reduces `sql()` to the SQL it carries, in both attributes | refuses both with `incorrect type raw` |
| [Revision row for a migration whose body failed](#revision-row-for-a-migration-whose-body-failed) | matches the binary; native `ptah migrations up` keeps a durable failed row | writes no revision row when the body's transaction rolled back |
| [Failed rollback state](#failed-rollback-state) | marks direction and partial progress in the existing Atlas columns | hides the failed-down state |
| [Dirty retry verifies committed statements](#dirty-retry-verifies-committed-statements) | proves every skipped statement has unchanged source text before resuming | resumes from `applied + 1` by statement index |
| [Recorded revision `error` text on a failed migration](#recorded-revision-error-text-on-a-failed-migration) | records the SQLite driver's own wording | records its own driver's wording for the same condition |
| [Pre-migration checks in a dry run](#pre-migration-checks-in-a-dry-run) | exits `1` where the guard's verdict is knowable and negative | implements no check semantics, so a dry run exits `0` for every checked directory |
| [`atlas.hcl` `file()` confinement](#atlashcl-file-confinement) | reads `file()` only from the directory holding that `atlas.hcl` | reads an absolute, parent-traversal, or link-escaping path |
| [A migration that would be recorded over an invalid index](#a-migration-that-would-be-recorded-over-an-invalid-index) | refuses while `pg_index` reports the index unusable, and names `REINDEX` | applies the migration and records it over the unusable index |
| [`schema inspect --include`](#schema-inspect---include) | positively selects top-level resources through the apply and diff engine | does not register the flag: `Error: unknown flag: --include` |
| [Exclude field selectors](#exclude-field-selectors) | honors the suffixes it can carry out and refuses the rest | accepts every such suffix and honors none of them |
| [Leading schema type selector](#leading-schema-type-selector) | keeps the literal answer on every schema source | gives source-dependent answers, leaving the named table in a file diff's plan |
| [One version spelled two ways](#one-version-spelled-two-ways) | refuses the directory and names both files | applies both files as two revisions |
| [A revision spelled apart from its file](#a-revision-spelled-apart-from-its-file) | refuses the history and prints the statements that respell the rows | compares versions as text and leaves a new file unapplied |
| [A Liquibase rollback written by `migrate diff`](#a-liquibase-rollback-written-by-migrate-diff) | writes `--rollback <SQL>`, which Liquibase runs | writes `--rollback: <SQL>`, which Liquibase reads as a comment |
| [A database outside a one-database URL](#a-database-outside-a-one-database-url) | refuses a document that declares another database, or puts an object in one | reads a SQL file that creates the other database first as synced, and drops what it puts there |
| [A dev database holding an object the reset drops](#a-dev-database-holding-an-object-the-reset-drops) | refuses it and names the object | accepts it, then keeps or drops the object depending on the engine and the `search_path` |
| [A whole server cleaned without an opt-in](#a-whole-server-cleaned-without-an-opt-in) | refuses `schema clean` on a MySQL or MariaDB URL naming no database unless `PTAH_ALLOW_SERVER_CLEAN=1`, and lists the databases | drops every user database at exit `0` |
| [A dev server that is the `--to` side, or beside one database](#a-dev-server-that-is-the---to-side-or-beside-one-database) | refuses both on `migrate diff` before the replay | replays onto a `--to` server that holds no database, and diffs one database against a server |
| [A dev database beside a whole server](#a-dev-database-beside-a-whole-server) | refuses it on `schema apply` for every desired schema, before the dev database is contacted | plans a document declaring one database, and replays SQL onto the dev database's server before it refuses |
| [A realm beside one database on a dev server](#a-realm-beside-one-database-on-a-dev-server) | refuses the pair on `schema diff`, with the binary's own `schema apply` sentence for it | plans databases the one-database side never read |

## A `--config` selection naming more than one file

**Type.** Deliberate divergence

**Current boundary.** The pinned community binary registers `-c/--config` as
`strings`: comma-separated and repeatable, defaulting to `[file://atlas.hcl]`.
Ptah reads one project file, and `ptah-compat` refuses a selection that names
more than one rather than reducing it.

What the pinned binary registers is read from the capture this repository
commits beside its schema-plan tests, `atlas-v1.3.0-schema-plan-help/new.txt`,
whose `provenance.json` pins the binary by digest:

```text
-c, --config strings   select config (project) files using URL format
                       (comma-separated) (default [file://atlas.hcl])
```

`strings` and `(comma-separated)` are what that capture establishes. How that
binary resolves two files is not measured here and is not claimed.

Measured on `ptah-compat`, with two project files declaring one env each:

| invocation | `ptah-compat` |
| --- | --- |
| `-c "file://first.hcl,file://second.hcl" --env first` | exit `1`, `--config names 2 files (…), and Ptah reads one` |
| `-c file://first.hcl -c file://second.hcl --env first` | exit `1`, `--config was given more than once, and Ptah reads one project file` |
| `-c file://first.hcl --env first` | exit `0` |

The refusal exists because the alternative is a silent reduction. A surface that
reads one file out of two the operator named has to say which one it dropped, or
the next diagnostic blames whatever the missing half declared: an env selected
from the discarded file reads as an env that does not exist, which sends the
operator to fix the file they actually passed.

The flag keeps its `string` type rather than claiming CE's `strings`.
Advertising a list this surface then refuses would trade a silent loss for a
false promise, and the reader learns the limit from the refusal at the moment it
matters.

Reading several project files is the capability behind this boundary, and it is
not implemented. Nothing here forecloses it: the refusal names the flag, so a
run that starts working when the capability lands changes no spelling.

## A trailing positional argument

**Type.** Deliberate divergence

**Current boundary.** `ptah-compat` refuses a positional argument on verbs that
define none, and names the flag the value belongs to. That binary accepts one
and discards it without a word.

Measured on SQLite, one hashed migration directory shared by every row:

| command, with `trailingarg` appended | pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| `migrate status --dir file://migrations --url sqlite://s.db` | exit `0`, status printed | exit `1`, `name the migration directory with --dir` |
| `migrate validate --dir file://migrations` | exit `0` | exit `1`, same wording |
| `migrate lint --dir file://migrations --dev-url … --latest 1` | exit `0`, report printed | exit `1`, same wording |
| `schema inspect --url sqlite://s.db` | exit `0`, HCL printed | exit `1`, `name the database with -u/--url` |

The argument that keeps the refusal is that on every one of these verbs the
discarded value is the one an operator meant to pass to a flag. `migrate status
migrations` and `schema inspect sqlite://s.db` are both the natural typo for
the correct command, and both are exit `0` on that binary against the wrong
target: the first reports on `./migrations` because the flag defaulted, not
because the positional was read. A tool that answers a question the operator
did not ask, with no sign that it did so, is worse than one that refuses. This
is the reasoning the `--dir` defaults landed under, where a default that
silently swallowed a typo would have migrated the wrong directory at exit `0`.

The refusal is not carried behind an environment variable. A variable that
restored the acceptance would have to default to the permissive side. Feature
toggles instead opt in to more permissive behavior so that a typo lands on the
strict default. The separate `PTAH_ATLAS_STRICT_COMPAT` policy selector narrows
the command inventory for CE oracle runs and does not relax this refusal.

`migrate hash` takes no positional either and refuses through the same helper,
`cmdutil.NoPositionalArgsHint`. That cell is measured now, and it lands on this
same argument. Read 2026-08-17 on a hashed SQLite directory, exit status taken
from an unpiped invocation:

| argv | Atlas CE v1.3.0 | Ptah |
| --- | --- | --- |
| `migrate hash --dir file://mig extra` | `0`, zero bytes of output | `1`, `unexpected positional arguments ["extra"]: name the migration directory with --dir` |

The zero bytes are the finding rather than the exit status. That binary does not
report the extra word, and it does the work anyway: a migration added before the
run is hashed into `atlas.sum` exactly as if the positional had not been typed.
So the operator who meant `--dir extra` and dropped the flag gets a rewritten
checksum file for a directory they did not name, at exit `0`, with nothing
printed (stokaro/ptah#1623).

## An edited already-applied migration file

**Type.** Deliberate divergence

**Current boundary.** `ptah-compat migrate apply` compares each applied
migration against the checksum recorded when it ran, and refuses when they
disagree. That binary records the same value and does not compare it, so an
edited applied file is a no-op there.

Measured on SQLite and again on PostgreSQL 17.10, identical on both. Two
migration directories sharing the version `20240101000000` and differing only
in the body — `CREATE TABLE t1 (id integer primary key)` against
`CREATE TABLE t1 (id integer primary key, note text)`:

| step | pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| apply the first directory | exit `0`, table created | exit `0`, table created |
| apply the second directory | exit `0`, `No migration files to execute` | exit `1`, `checksum mismatch` |

The edit is real: the file that ran is not the file on disk any more. Treating
that as a no-op means the migration directory stops describing the database it
was applied to, and the difference surfaces later as a diff nobody can explain.
The refusal names the version and both checksums, and `--allow-dirty` is not
involved.

## A prefix migration inserted below the oldest applied revision

**Type.** Deliberate divergence

**Current boundary.** This entry covers a prefix migration that sorts below
every applied revision. `ptah-compat migrate apply` refuses at its default
`--exec-order linear` and names both ways forward. `--exec-order linear-skip`
leaves the insertion unapplied; `--exec-order non-linear` applies it. Those are
that binary's own flag and its own three values, not a Ptah addition.

An insertion between two applied revisions is a different observable state.
Measured on SQLite and PostgreSQL 17.10 on 2026-08-10, both binaries refuse
that interval insertion at the default order and apply it under `non-linear`.
The lower applied revision is the state that distinguishes that parity cell
from the prefix divergence below.

Measured on SQLite on 2026-08-12. Both directories were authored and hashed by
that binary through `migrate import`, so no checksum here was computed by Ptah.
`dirA` holds only the later migration; `dirB` prefixes it with an earlier one
and holds a byte-identical copy of the later one. `diff` between the two copies
exits `0`.

| step | pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| apply `dirA` | exit `0`, later migration applied | exit `0`, later migration applied |
| apply `dirB`, default order | exit `0`, `No migration files to execute` | exit `1`, names the version and both remedies |
| apply `dirB`, `--exec-order linear-skip` | — | exit `0`, insertion left unapplied |
| apply `dirB`, `--exec-order non-linear` | — | exit `0`, insertion applied |

The second row is the entry. That binary's default order is `linear` by its own
`--help`, and on that row it exits `0` while never applying the inserted
migration: the table that migration creates is absent from the catalog
afterwards, and only the later revision is recorded. It prints nothing about
the file it passed over. An operator who adds a migration to a directory,
runs apply, and reads exit `0` has been told the directory is applied when one
of its migrations never ran and never will.

That is the argument for the refusal, and it is not a strictness preference.
Reproducing an exit `0` that discards an operator's migration silently is the
kind of defect the parity rule declines to copy. The refusal names the version
it will not apply and both flags that resolve it, so the operator chooses
between skipping and applying rather than having the choice made and not
reported.

Nothing is removed by refusing it as the default. The third row is the pinned
binary's own outcome, reachable on request through its own flag, and it is
pinned by a test so it stays reachable. The fourth row is the outcome an
operator who inserted the migration on purpose usually wants, which that
binary's default order does not offer at all.

One thing here changed rather than being retained. The refusal used to be
reported as a checksum mismatch on the later migration, whose bytes had not
changed: the per-file entry in an integrity file is chained over the entries
before it, so inserting anything earlier changes the recorded value of every
applied migration above it. The value is an ordering key, not a content
identity, and the message claimed otherwise. That is fixed; the diagnostic now
describes the ordering fact it actually found. The genuine content-change case
keeps the checksum wording and is the section above.

## A dirty revision left by a non-transactional body

**Type.** Deliberate divergence

**Current boundary.** When a migration body fails, `ptah-compat migrate apply`
discards the revision row it wrote if — and only if — this invocation observed
the body's transaction roll back with nothing committed. Under `--tx-mode none`
there is no transaction to observe, so the row stays and the next apply refuses
until `--allow-dirty` is passed. That binary keeps its row in both cases and
resumes from it.

Measured on SQLite and again on PostgreSQL 17.10, identical on both. Two
migrations; the second creates a unique index that fails against duplicate rows
inserted between the two applies; the duplicate is then deleted and the apply
repeated. No migration file is edited at any point, so the integrity file stays
valid throughout:

| step | pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| apply the first migration | exit `0` | exit `0` |
| apply the second, duplicates present | exit `1` | exit `1` |
| repair the data, apply again, default `--tx-mode file` | exit `0` | exit `0` |
| repair the data, apply again, `--tx-mode none` | exit `0`, resumes | exit `1`, `is dirty: state=failed applied=0/1` |

The third row is the one that matters most and the two agree on it. The
fix-and-rerun flow that #1241 called permanently wedged recovers under the
default transaction mode, because a confirmed rollback that committed nothing
is discarded.

The fourth row is retained. `applied=0` is not proof that nothing happened when
no transaction wrapped the body: `--tx-mode none` exists so that statements
which cannot run inside a transaction can run, and on PostgreSQL a failed
`CREATE INDEX CONCURRENTLY` leaves an invalid index behind while the statement
counts as not applied. Ptah already refuses to record a migration over such an
index. Discarding the row automatically in that mode would hand the next run a
clean slate over a database that is not clean. `--allow-dirty` remains the way
through while the same migration body remains in the directory, and the
operator, unlike the tool, can look first. If that source file is removed,
Ptah refuses the now-unowned dirty exact identity even with `--allow-dirty`;
there is no body or verified prefix to resume.

## A project migration directory outside its root

**Type.** Deliberate divergence

**Current boundary.** A `migration.dir` declared in `atlas.hcl` must remain
inside the directory containing that project file after symbolic-link
resolution. The rule applies to relative and absolute values. An explicit CLI
`--dir` remains operator-owned: a directly named absolute directory keeps its
normal CLI behavior.

Measured on 2026-08-12 with `migrate diff`, a local SQL desired schema, and a
SQLite dev database:

| project `migration.dir` | pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| `file://migrations` | exit `0`, writes migration and `atlas.sum` inside the project | exit `0`, same artifacts inside the project |
| `file://../outside` | exit `0`, writes migration and `atlas.sum` outside the project | exit `1`, `outside allowed root`, outside directory untouched |
| `file:///absolute/outside` | exit `0`, writes migration and `atlas.sum` outside the project | exit `1`, `outside allowed root`, outside directory untouched |

The project file is repository-controlled input. Letting it select an arbitrary
write destination means a configuration change in a pull request can publish a
migration and replace an `atlas.sum` anywhere the process can write. That is not
equivalent to an operator explicitly naming an absolute `--dir` at the command
line. Binding the project value to the already-open project handle preserves
the useful explicit CLI capability while refusing the implicit external write.

The refusal also closes the spelling hole. Previously a contained relative
value carried the project root, while parent-relative and absolute spellings
silently dropped the root and fell through to unbounded CLI resolution. The
destination now determines the answer: every project-owned value is checked
against the same root before output or mutation.

## What holds the #1241 entries

The issue #1241 entries above are pinned by a test in
`internal/cli/atlas/compat_1241_retained_divergence_test.go`, and the trailing-positional
rows for `migrate status`, `migrate validate` and `schema inspect` are pinned in
`internal/cli/atlas/compat_overstrict_test.go`. The same focused file also pins all three
orders of the prefix insertion: the default refuses, `--exec-order
non-linear` applies and remains idempotent, and `--exec-order linear-skip`
leaves the insertion unapplied and stays that way on a repeat run. The last of
those is the one that keeps the entry honest — it is the claim that the pinned
binary's own outcome is still reachable, so it is guarded rather than asserted.

The project-root boundary is pinned through the real `ptah-compat` process in
`integration/atlas_project_migration_dir_confinement_e2e_test.go` and its Unix
symbolic-link companion. The tests prove both outside spellings and an escaping
symbolic link fail without artifacts, relative and absolute inside paths and a
linked project root succeed, and an explicit absolute CLI `--dir` remains
reachable.

Every case above is SQLite-measured; the two noted as also measured on
PostgreSQL 17.10 were re-run against a live server. The rest carry the
SQLite-only caveat that
[`stokaro/ptah#1241`](https://github.com/stokaro/ptah/issues/1241) declares.

The entries below this section come from other issues. Each names its own
measurement conditions, its own engine, and the test or tracking issue that
holds it.

## `sql()` inside `check.expr` and `index.where`

**Type.** Deliberate divergence, in the direction of accepting more

**Current boundary.** Ptah reduces a `sql()` call to the SQL it carries
everywhere an attribute is read as text, including `check.expr` and
`index.where`. The pinned community binary v1.3.0 refuses the same two with
`incorrect type raw`.

**Why it stays.** `sql()` exists so a schema file can carry an expression the
HCL grammar has no spelling for, and a CHECK body is the clearest case of one.
Refusing it in the two attributes that most need it would make the function
useless where it is most useful, to match a refusal that costs the operator a
capability rather than protecting them from anything. Nothing a document writes
this way renders differently: the call reduces to its argument text, which is
what an unquoted expression would have been.

The reduction is not optional. It landed because the fallback below it hands an
attribute's SOURCE TEXT to the renderer, which is how `CHECK (sql("n > 0"))`
reached a plan verbatim.

## Revision row for a migration whose body failed

**Type.** Compatibility behavior with a native safety difference

**Current boundary.** When a migration body fails inside a transaction and
rollback succeeds, the pinned community binary v1.3.0 writes no revision row.
`ptah-compat` reaches the same end state and retries the whole file on the next
apply. Native `ptah migrations up` keeps a durable failed row for status and
repair workflows.

Measured across the effective transaction-mode matrix:

```text
effective file transaction   binary 0 rows   ptah-compat 0 rows   native 1 row
effective all transaction    binary 0 rows   ptah-compat 0 rows   native 1 row
effective no transaction     binary 1 row    ptah-compat 1 row    native 1 row
```

The compatibility cleanup is fail-closed. It removes a zero-progress row only
after `Rollback` returns success. A rollback error, commit error, unknown
statement outcome, or any committed statement keeps the row. Those states
still require explicit recovery, so parity does not erase evidence when the
database outcome is uncertain.

**Evidence.** [`stokaro/ptah#1196`](https://github.com/stokaro/ptah/issues/1196),
[`stokaro/ptah#1333`](https://github.com/stokaro/ptah/issues/1333)

## Failed rollback state

**Type.** Deliberate divergence, in the direction of recording more

**Current boundary.** `ptah-compat migrate down` retains the Atlas
revision-table schema and does not reproduce the hidden failed-down state.
It marks rollback direction with the existing `operator_version` field and
makes a partial rollback recoverable through the existing progress and error
fields. A successful down still deletes the row. An Atlas reader can inspect
the same table, but may report a Ptah-recorded failed rollback differently from
one Atlas itself hid.

Native `ptah migrations down` records that state in both revision-table
formats. The row is marked failed with `error=<message>`, `direction=down`, and
`applied` set to the number of down statements that completed — `0` when the
first one fails or the dialect rolled the body back. Status therefore reports
the dirty state, repair has a row to act on, and a later up refuses to stack
work on the unfinished rollback.

Repair follows the recorded direction. `--resume-from` runs the remaining
*down* statements and removes the revision once the rollback finishes, and a
rollback that already committed a statement is refused rather than recorded
applied unless `--force` says the schema was restored. See
[Roll back migrations](../../versioned/rollback/).

The pinned community binary registers `migrate down` as a community-abort stub,
so the capability is unreachable there. Where the verb does run, a failed down
is not recorded: measured, after a down whose second statement fails, the body
is rolled back, the revision row still reads `applied=2, total=2, error=''`,
the status report names the version as applied, and a retry after repairing the
down file succeeds and deletes the row.

**Evidence.** [`stokaro/ptah#957`](https://github.com/stokaro/ptah/issues/957)

## Dirty retry verifies committed statements

**Type.** Deliberate safety divergence

**Current boundary.** The community binary resumes a dirty non-transactional
revision from `applied + 1` by statement index. Ptah first proves that every
statement it will skip has unchanged source text. Ptah-format rows store a
`partial:h1:` checksum for the committed prefix; Atlas-format rows use the
existing `partial_hashes` column. A changed prefix, malformed metadata, or
contradictory hash count is refused, as are negative progress counters,
`applied > total`, and a native `state=applied` row whose counters are
incomplete. Invalid metadata is rejected by revision listing, status, version,
and apply operations rather than being hidden as a clean row. A row carrying no
prefix metadata at all resumes only while its full-file hash still matches.

Editing only the unapplied suffix remains supported. If that retry changes from
`none` to `file` or `all` and its transaction rolls back, Ptah retains the
committed `applied` floor rather than making a later run replay SQL. Process
exit, context cancellation, or deadline while an autocommit statement is in
flight preserves the unknown-outcome marker.

This is stricter than the community binary in the safe direction: uncertain
recovery exits non-zero instead of skipping SQL on the strength of a stale
integer offset.

## Recorded revision `error` text on a failed migration

**Type.** Driver difference, not a behavior one

**Current boundary.** The Atlas revision table's `error` column records the
database's own message on both sides, but the two spell the same condition
differently because they use different SQLite drivers. On the same failing
migration:

```text
pinned community binary v1.3.0   no such table: missing_table
ptah-compat                      SQL logic error: no such table: missing_table (1)
```

The column carries the innermost error and nothing else — no Ptah-authored
prefix, and no repetition of the statement, which the adjacent `error_stmt`
column already holds in full. `error_stmt` matches byte for byte, terminating
semicolon included. A statement carrying bytes that are not valid UTF-8 is the
exception: those bytes are recorded as `\xFF` escapes, because the revision
table's text columns hold UTF-8 and the MySQL family refuses anything else.

What is left is `modernc.org/sqlite`'s wording. Closing it would mean rewriting
driver messages per driver and per dialect to match a different driver's
phrasing, which trades a cosmetic difference for a table of string edits that
goes stale silently. The native revision format is unaffected either way: this
applies only where the revision table is Atlas-shaped, and Ptah's own surface
keeps the context it adds.

**Tracking.** [`stokaro/ptah#1196`](https://github.com/stokaro/ptah/issues/1196)

## Pre-migration checks in a dry run

**Type.** Deliberate divergence

**Current boundary.** The community binary implements no check semantics, so it
flattens the archive and runs `checks.sql` as an ordinary migration statement;
in a dry run it executes no SQL at all and exits `0` for every checked
directory.

`ptah-compat` exits `1` where the guard's verdict is knowable and negative:

- an assertion that is malformed or is not a read-only `SELECT`, decidable from
  the text alone;
- a failing assertion on the first migration executed in the run, decidable
  against the live database and confirmed by the real apply failing the same
  way;
- a checked directory under `--tx-mode all`, which the real apply refuses
  outright.

Matching the community binary on those inputs would make the preview report
success for a run that cannot succeed.

A failure that is an artifact of the preview rather than a finding — a later
migration's guard asking about state the dry run refused to create — is not one
of them. `ptah-compat` exits `0` there, which both matches the community binary
and matches what applying the directory does. See
[Checks in a dry run](../../versioned/integrity-and-safety/#checks-in-a-dry-run).

**Tracking.** [`stokaro/ptah#661`](https://github.com/stokaro/ptah/issues/661)

## `atlas.hcl` `file()` confinement

**Type.** Deliberate divergence

**Current boundary.** `ptah-compat` reads `file()` and `fileset()` inside an
`atlas.hcl` only from the directory holding that `atlas.hcl`. Three shapes are
refused that the pinned community binary reads: an absolute path, a
parent-traversal path, and a plain relative name that resolves out of the
directory through a symbolic link. The refusal names which of the three applies
and points at `getenv()` for passing a value in from outside.

Measured on the pinned v1.3.0 build: `file("/etc/passwd")` and
`file("../../../../etc/passwd")` in an `atlas.hcl` both exit `0` with the file
read, and the contents reach an observable place — a database URL, an error
message on standard error.

The reason is that an `atlas.hcl` is usually repository-controlled and `file()`
is evaluated before anything is applied. Without confinement, a config file
arriving in a pull request can read any file the process can and place the
contents somewhere a reader of the output can see. That is a different class of
cost from the footguns the drop-in rule was written for. The exit code is `1`
either way for a configuration that works, so nothing a project already runs
changes; the refusal names its reason.

Both halves are measured rather than remembered. The Ptah half is pinned by
`TestOraclePlacesOutsideFileContentsWhereTheCallerCanSeeThem` in
`config/projectconfig`; the community half is pinned by four `ce-gating`
scenarios in the conformance repository, so the day a community build starts
confining `file()` the gate goes red and this divergence can be retired.

**Tracking.** [`stokaro/ptah#1042`](https://github.com/stokaro/ptah/issues/1042)

## A migration that would be recorded over an invalid index

**Type.** Deliberate divergence

**Current boundary.** On PostgreSQL, `ptah-compat migrate apply` refuses a
migration while an index that migration creates is reported unusable by
`pg_index` (`indisvalid` or `indisready` false). The pinned community binary
v1.3.0 applies it and records it.

Measured on PostgreSQL 17.10, identical fixture on both: a `members` table whose
duplicate rows made an earlier `CREATE UNIQUE INDEX CONCURRENTLY` fail, the
duplicates then removed, and one migration carrying `-- atlas:txmode none` and
the same `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS` statement.

| | pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| `migrate apply --allow-dirty` | exit `0`, `-- ok`, 1 statement | exit `1`, names the index and `REINDEX` |
| `pg_index` afterwards | `indisvalid=f`, `indisready=f` | unchanged, nothing written |
| revision row afterwards | `20260808000001` applied 1/1, no error | no row |
| duplicate `INSERT` afterwards | accepted, 2 rows share the email | n/a |
| after `REINDEX INDEX CONCURRENTLY` | n/a | exit `0`, `indisvalid=t`, row recorded, duplicate rejected |

The leftover keeps the name, so `IF NOT EXISTS` skips it and the statement
reports success over an index that enforces nothing. Recording that as applied
means the tooling reports a constraint the database does not have, and nothing
will look again. Ptah also rejects another index or non-index relation that owns
the schema-level name. It resolves an unqualified drop and target through the
active `search_path`, permits cleanup only when that exact drop will run first
in this attempt, and positively rechecks the active transaction or connection
before recording the revision. A drop skipped by dirty-resume is not cleanup.

The divergence is stricter, not looser: `ptah-compat` exits `1` where the binary
exits `0`, never the reverse, so no invocation the binary refuses succeeds here.

**Tracking.** [`stokaro/ptah#1101`](https://github.com/stokaro/ptah/issues/1101)

## `schema inspect --include`

**Type.** Deliberate divergence

**Current boundary.** `ptah-compat schema inspect --include` positively selects
the top-level resources inspection keeps, through the same engine as
`schema apply` and `schema diff`: schema universe, then include selection, then
exclusion. The pinned community binary does not register the flag at all —
`schema inspect -u sqlite://app.db --include users` exits `1` with
`Error: unknown flag: --include`. Atlas registers it, and Ptah's behavior
diverges from it in two measured ways, both deliberate:

| Input | Atlas | Ptah |
| --- | --- | --- |
| `--include 't1'` | `t1` with its columns, plus `schema "main"` | same selection |
| `--include '*.t1'` | pattern is read at child depth: `t1` rendered without its columns and `t2` rendered as an empty shell, exit `0` | `*.t1` is the wildcard spelling of the qualified name `main.t1`, so `t1` is rendered whole and `t2` is dropped |
| `--include 'main.t1.*'` | `Error: too many parts in pattern: ["main" "main" "t1" "*"]`, exit `1` | rejected before any database is contacted: child resources ride along with their parent and cannot be selected on their own |

Ptah has no child-level include selection in either spelling, so it keeps whole
objects instead of emitting partial ones. It also refuses a selection that drops
a dependency of a selected object, where Atlas renders the reference anyway —
its `*.t1` output keeps `primary_key { columns = [column.id] }` on a table whose
`id` column the same output omits. The Atlas-side rows are the recorded
transcripts, so behavior beyond those inputs is not established here.

There is no `atlas.hcl` spelling of this selector. Atlas documents `exclude` but
no `include` attribute on the `env` block; the community binary accepts
`include = [...]` there only because it accepts any unknown env attribute. A run
with `not_a_real = [...]` is likewise accepted and prints the full schema. Ptah
accepts `env.include` under the same unknown-name rule, leaves it without
effect, and writes a location-aware warning to standard error.

**Tracking.** [`stokaro/ptah#933`](https://github.com/stokaro/ptah/issues/933)

## Exclude field selectors

**Type.** Deliberate divergence

**Current boundary.** A field selector is the `.field` suffix behind a
`[type=...]` selector: `--exclude '*[type=table].comment'` asks for the comment
of every table to be dropped while the tables themselves stay. The pinned Atlas
community binary accepts every such suffix and honors none of them. Measured on
PostgreSQL 16 with two commented tables across two schemas, all three of

```text
--exclude '*[type=table].comment'
--exclude 'public.*[type=table].comment'
--exclude '*[type=table].*'
```

are exit `0` there with output byte-identical to the same command without the
flag, comments included.

`ptah-compat` honors the ones it can carry out — `[type=extension].version`,
`.comment` on `table`, `view` and `materialized_view`, and `.*` for all of them
— and refuses the rest by name before a database is contacted. So the first and
third of those commands are exit `0` with the tables rendered and their comments
gone, and a suffix such as `.charset` is exit `1` naming the fields that would
have worked.

The reason not to copy accept-and-ignore is the one recorded for `file()` above:
an `--exclude` selector is a scoping instruction, and the reason to write one is
usually that the object must not be touched. Accepting one and silently not
carrying it out defeats that intent with no diagnostic, and on `schema apply`
and `schema diff` the same shape of miss reaches a `DROP`. Both directions here
are safe under the drop-in rule: honoring a selector subtracts more from a plan
rather than less, and refusing one exits `1` where that binary exits `0`, never
the reverse.

The second of those commands is decided by the pattern-depth rule rather than
the field rule, so its exit status follows the URL. On a schema-bound URL both
binaries refuse it — the schema slot is already filled by the connection. That
binary quotes the prefixed pattern,
`too many parts in pattern: "public.public.*[type=table].comment"`; Ptah quotes
the one that was typed. On a URL that names no schema the pattern is
realm-relative, `public` fills the schema slot itself, and both binaries accept
it. Measured on both URLs against the same PostgreSQL 17 database.

**Tracking.** [`stokaro/ptah#933`](https://github.com/stokaro/ptah/issues/933)

## Leading schema type selector

**Type.** Deliberate divergence

**Current boundary.** `--exclude '*[type=schema].*[type=table]'` means every
table inside every schema on every Ptah schema source. The leading schema glob
may be narrowed, as in `app[type=schema].*[type=table]`.

The pinned community binary v1.3.0 gives source-dependent answers. On a live
PostgreSQL database containing tables and enums in `public` and `app`, the
selector removes both tables and keeps both enums. On a SQLite file diff between
one table and the same schema plus a second table, it exits `0` but leaves the
second table's `CREATE TABLE` plan unchanged. Removing the selector from that
SQLite run produces byte-identical output; `*[type=table]` is the control that
does remove the plan.

Ptah keeps the literal PostgreSQL answer on both source kinds. Making the
selector a no-op only for a file diff would make one accepted scoping
instruction mean two different things depending on its input. It would also report success
while leaving exactly the table the selector names in the migration plan. This
is a defect Ptah does not copy; the complete compatibility surface keeps the
coherent behavior rather than narrowing it to this community-binary result.

The integration contour pins the file-diff result in
`TestAtlasCompatLeadingSchemaTypeSelectorE2E`. The filter tests separately pin
the schema glob, the final resource type, and the surviving non-table objects.

**Tracking.** [`stokaro/ptah#933`](https://github.com/stokaro/ptah/issues/933)

## One version spelled two ways

**Type.** Deliberate divergence

**Current boundary.** `ptah-compat` refuses an Atlas directory that spells one
version number two ways, such as `1_a.sql` beside `001_b.sql`. The pinned
community binary v1.3.0 applies both files as two revisions.

Measured on PostgreSQL 18 on 2026-09-24, with the directory hashed by that
binary:

| | pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| `migrate apply` | exit `0`, applies `001` and then `1` | exit `1`, names both files and both spellings |
| revision rows afterwards | `001`, `1` | none, and no revision table |

Both binaries record a version as the file name spells it, so `001_b.sql` is
revision `001` on each. The order is where they part. That binary orders files
by name, and Ptah runs migrations in numeric order, where both files are `1`.
Nothing in the directory says which of the two should run first, so the refusal
names the files instead of choosing. Renaming one of them to a number of its
own gives a directory both binaries apply the same way.

`TestCompatMigrateApply_OneVersionSpelledTwoWaysRefuses` pins the refusal and
that nothing reaches the database, and the migrator's own tests pin the same
refusal for a pair of up and down files spelled apart.

**Tracking.** [`stokaro/ptah#3532`](https://github.com/stokaro/ptah/issues/3532)

## A revision spelled apart from its file

**Type.** Deliberate divergence

**Current boundary.** `ptah-compat` refuses a history whose revision row spells
a migration file's version another way, such as the row `1` for `001_a.sql`,
and prints the statements that respell the rows. The pinned community binary
v1.3.0 reads the same history by comparing versions as text.

Measured on PostgreSQL 18 on 2026-09-25. The revision rows were `1` and `2`, and
the directory spelled `001_a.sql`, `002_b.sql` and a new `003_c.sql`:

| | pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| `migrate status` | exit `0`, `Migration Status: OK`, current version `2`, no pending files | exit `1`, names the rows and prints one `UPDATE` per row |
| `migrate apply` | exit `0`, `No migration files to execute` | exit `1`, the same refusal |
| `003_c.sql` afterwards | never ran | never ran |

That binary sorts `003` below the recorded `2` as text, so it reads the new file
as applied and never runs it. Ptah refuses instead, because the readers of this
history disagree about what ran: to a reader that compares numbers, the row `1`
is the file `001_a.sql`, and to one that compares text it is not. A write that
addresses the row by the file's spelling misses it, so a rollback would run the
down migration and leave the row behind. Both binaries record a version as the
file name spells it, so neither writes this history; it comes from a table
written some other way, such as by hand. Running the printed statements gives
every row its file's spelling, and both binaries then agree.

`TestCompatMigrateApply_RevisionSpelledApartFromItsFileRefuses` pins the refusal
and that `003_c.sql` does not run. The migrator's tests pin the same refusal for
status, apply, rollback, checksum verification and `migrate set`, and live tests
run the printed statements on PostgreSQL, MySQL and ClickHouse.

**Tracking.** [`stokaro/ptah#3550`](https://github.com/stokaro/ptah/issues/3550)

## A Liquibase rollback written by `migrate diff`

**Type.** Deliberate divergence

**Current boundary.** `migrate diff` into the Liquibase layout
(`--dir 'file://migrations?format=liquibase'`) writes each line of the
changeset's rollback as `--rollback <SQL>`, with one blank after the keyword.
The pinned community binary v1.3.0 writes `--rollback: <SQL>`. Liquibase 5.0.4
reads a rollback line only with that blank, so it reads a line with the colon
as a comment in the changeset's SQL, and the changeset has no rollback.

Measured on SQLite on 2026-09-26, with a diff that creates `widgets` into an
empty directory, and the changeset each binary wrote run by Liquibase 5.0.4:

| | pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| rollback line written | `--rollback: DROP TABLE` | `--rollback DROP TABLE IF EXISTS` |
| Liquibase `update` | creates `widgets` | creates `widgets` |
| Liquibase `rollback-count --count=1` | exit `1`: no automatic rollback for raw SQL; `widgets` stays | exit `0`; `widgets` is dropped |

A rollback that spans several lines, such as the `CREATE TABLE` that undoes a
dropped table, is written as one `--rollback` line per line of SQL, and
Liquibase joins them with a line break after each. Liquibase rolled a dropped
`widgets` back to the table with both of its columns.

`PTAH_ATLAS_STRICT_COMPAT=1` does not change this. The layout `migrate diff`
writes is Ptah's in both profiles -- one changeset per migration and Ptah's
renderer's SQL, not the community binary's -- so the colon would buy no byte
parity, and it would lose the rollback the plan wrote.

`TestCompatMigrateDiff_LiquibaseRollbackIsOneLiquibaseRuns` pins the spelling
under both policies. `TestComposeMigrationArtifacts_LiquibaseRollbackIsOneLiquibaseReads`
reads the written changeset back through the reader that follows Liquibase's
formatted-SQL parser, and `TestLiquibaseDiffRollbackE2E_HappyPath` imports two
written migrations and rolls both back.

**Tracking.** [`stokaro/ptah#3752`](https://github.com/stokaro/ptah/issues/3752)

## A dev database holding an object the reset drops

**Type.** Deliberate divergence

**Current boundary.** `ptah-compat` refuses a dev database that holds anything
its reset would drop, not only a table. Each writer lists what its reset drops
through the query the reset runs, and the check reads that list:

- PostgreSQL, CockroachDB, YugabyteDB and Spanner: a view, a materialized view,
  a function, a procedure, an aggregate, a sequence, a type, a collation, a
  default privilege it would revoke, a global default privilege it would
  return to the built-in default, or on PostgreSQL a large object.
- MySQL and MariaDB: a view, a procedure, a function, an event, a sequence, or
  a system-versioned table.
- SQLite: a view.
- SQL Server: a view, a procedure, a function, a sequence, a synonym, a type,
  an XML schema collection, or a user schema.
- ClickHouse: a view, a materialized view, or a dictionary.
- Oracle: a view, a sequence, a synonym, or a type.

The refusal names the object, in the shape the pinned binary uses for a table:
`connected database is not clean: found view "v" in connected schema` when a
PostgreSQL URL pins a `search_path`, `found view "v" in schema "public"` when it
does not, and `found view "v" in schema "dev"` on the other engines, which name
the database or the account. SQLite has one schema, so it prints
`found view "v"`. Native `ptah` refuses the same databases. Objects an
installed extension owns are kept, and so is every PostgreSQL schema outside a
pinned `search_path`; neither refuses.

Measured on 2026-09-27 against PostgreSQL 18.6, with each object alone in the
dev database and `migrate validate` and `schema apply` run against it:

| Dev database | Pinned community binary v1.3.0 | `ptah-compat` without the refusal | `ptah-compat` |
| --- | --- | --- | --- |
| `?search_path=public`, a view, function, sequence, enum, domain, composite type or collation | exit `0`, the object kept | exit `0`, the object dropped | exit `1`, names the object, kept |
| no `search_path`, the same objects | exit `0`, every object dropped but the enum | exit `0`, the object dropped | exit `1`, names the object, kept |
| a large object, either scope | exit `0`, kept | `migrate validate` exit `0`, dropped | exit `1`, names it, kept |
| `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES`, `migrate validate` | exit `0`, kept with a `search_path`, revoked without one | exit `0`, revoked | exit `1`, names it, kept |
| `ALTER DEFAULT PRIVILEGES FOR ROLE app_owner REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC`, either scope, `migrate validate` | exit `0`, kept | exit `0`, returned to the built-in default | exit `1`, names it, kept |

Measured the same day on the other engines, with the same verbs:

| Engine and object | Pinned community binary v1.3.0 | `ptah-compat` without the refusal | `ptah-compat` |
| --- | --- | --- | --- |
| MySQL 26.7 and MariaDB 12.3: a view, procedure, function or event; on MariaDB a sequence or a system-versioned table | exit `0`, kept | exit `0`, dropped | exit `1`, names it, kept |
| SQLite: a view | exit `0`, dropped | exit `0`, dropped | exit `1`, names it, kept |
| SQL Server 2025: a view, procedure, function, sequence or synonym | `unknown driver` | exit `0`, dropped | exit `1`, names it, kept |
| SQL Server 2025: a type or an empty user schema | `unknown driver` | `migrate validate` exit `0`, dropped | exit `1`, names it, kept |
| ClickHouse 26.9: a view, or a materialized view with its rows | `unknown driver` | exit `0`, dropped | exit `1`, names it, kept |
| ClickHouse 26.9: a dictionary | `unknown driver` | `migrate validate` exit `0`, dropped | exit `1`, names it, kept |

No verb reaches the Oracle reset, because the dev lock refuses Oracle first. Its
reset drops a view, a sequence, a synonym and a type and keeps a function, a
procedure and a package; the check counts the first four.

The binary keeps most of those objects because it does not model them, so its
snapshot neither sees nor restores them. Keeping them in Ptah would mean hiding
them from every read of the dev database, since a replay and a rehearsal read
it back and compare it, and a migration's `CREATE ... IF NOT EXISTS` would
then pass against the kept object instead of creating its own. Refusing loses
nothing and proves as much as before. Where the binary drops an object in
silence, as it does on SQLite and on PostgreSQL with no `search_path`, that is
the defect this refusal does not copy.

A PostgreSQL text search configuration or dictionary is not refused, because
the reset never drops it. The realm cleanup drops a schema with `RESTRICT`, so
the schema that holds one stays, and the failure names it in the server's
words: `failed to drop user schema "public" from PostgreSQL database realm
(text search configuration keep_ts depends on schema public): ...`. The binary
keeps it with a `search_path` and drops it without one.

The divergence is stricter, not looser: `ptah-compat` exits `1` where the binary
exits `0`, never the reverse.

**Tracking.** [`stokaro/ptah#3808`](https://github.com/stokaro/ptah/issues/3808),
[`stokaro/ptah#3851`](https://github.com/stokaro/ptah/issues/3851)

## A whole server cleaned without an opt-in

**Type.** Deliberate divergence

**Current boundary.** A MySQL or MariaDB URL that names no database is the
whole server, and `ptah-compat schema clean` against it would drop every user
database. It refuses that, dry run included, unless `PTAH_ALLOW_SERVER_CLEAN=1`
is set, and the refusal lists the databases in the form a dry run prints them:

```text
Error: refusing to clean a whole MySQL or MariaDB server without PTAH_ALLOW_SERVER_CLEAN=1: the URL names no database, and the cleanup would drop every user database on the server:
- DROP DATABASE `app`
- DROP DATABASE `audit`
Set PTAH_ALLOW_SERVER_CLEAN=1 to clean the server, or name a database in the URL to clean that database alone
```

With the variable set, the confirmation and `--dry-run` work as on any other
URL. A URL that names a database is not affected, and neither is a server with
no user database. Native `ptah db drop-all` refuses the same way.

Measured on 2026-09-27 against MySQL 8.4.11 and MariaDB 11.8.9, a server
holding two user databases:

| Run | Pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| `--auto-approve` | exit `0`, both databases dropped | exit `1`, both listed, both kept |
| `--auto-approve`, `PTAH_ALLOW_SERVER_CLEAN=1` | not read | exit `0`, both databases dropped |
| `--dry-run` | exit `1`, `--dry-run` is not supported by the community version | exit `1` and the list; exit `0` and the plan with the variable |
| a table in one database with a foreign key into a database that sorts before it | exit `1`, error 3730 when it reaches the referenced database | with the variable, exit `0`: the key is dropped first |

A server is often shared, and the scope is chosen by what the URL leaves out
rather than by anything the operator names. One confirmation, or
`--auto-approve` in a script, would drop databases nobody listed, and the
community binary offers no dry run to look first. The variable restores the
binary's behavior, so strict mode keeps it.

The divergence is stricter, not looser: `ptah-compat` exits `1` where the binary
exits `0`, never the reverse.

**Tracking.** [`stokaro/ptah#3789`](https://github.com/stokaro/ptah/issues/3789)

## A database outside a one-database URL

**Type.** Deliberate divergence

**Current boundary.** A MySQL or MariaDB URL that names one database limits
`schema apply` and `schema diff` to it. A document that declares another
database, or puts a table, view, routine, sequence or trigger in one, is
refused by name before anything is planned.

Measured on 2026-09-28 against MySQL 8.4.11, with the URL naming `app`:

| Document | Pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| `CREATE TABLE other.u (…); CREATE TABLE t (…)` | exit `1`, `Error 1049 (42000): Unknown database 'other'` | exit `1`, `table "other.u" is in database "other", …` |
| `CREATE DATABASE other; CREATE TABLE other.u (…); CREATE TABLE t (…)` | exit `0`, `Schema is synced`: `other.u` is dropped without a word | exit `1`, `the document declares database "other", …` |
| an HCL document declaring `schema "other"` alone | exit `1`, `mismatched HCL and database schemas: "app" <> "other"` | exit `1`, `the document declares database "other", …` |
| `CREATE TABLE app.t (…)`, naming the URL's own database | exit `1`, `Error 1049 (42000): Unknown database 'app'`, run on the dev database | exit `0`, the table is in `app` |

The binary runs the document on the dev database, so what it accepts depends
on what that database can hold rather than on what the URL names. Without the
refusal, the plan creates `other.u` on the server, in a database the URL does
not name. The last row is the reverse: a name qualified with the URL's own
database is that database's, and the binary refuses it only because the dev
database has another name.

**Tracking.** [`stokaro/ptah#3928`](https://github.com/stokaro/ptah/issues/3928)

## A dev server that is the `--to` side, or beside one database

**Type.** Deliberate divergence

**Current boundary.** A `--dev-url` that names no MySQL or MariaDB database is a
whole dev server, which `migrate diff`, `migrate lint` and `migrate validate`
empty after the replay. `migrate diff` refuses two pairs before anything is
replayed:

- a dev server that is the server `--to` reads, compared by the server's UUID
  on MySQL and by its host name, port and data directory on MariaDB:
  `--to database must differ from --dev-url because the dev database is reset during planning`;
- a `--to` naming one database beside a dev server:
  `cannot diff a schema "app" with a database connection`.

Measured on 2026-09-27 against MySQL 8.4.11 and MariaDB 11.8.9:

| Run | Pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| `--to` and `--dev-url` the same server, holding no database | exit `0`, replays the directory there and empties it | exit `1`, nothing replayed |
| the same server holding a database | exit `1`, `found schema "r1"` | exit `1`, the same refusal |
| `--to mysql://host/r1` beside a dev server | exit `0`, writes `ALTER TABLE` statements for `r1` | exit `1`, nothing replayed |

A server that holds no database from the dev URL's point of view can still hold
what another account sees, and the cleanup would drop what the replay created
there. Comparing the server's identity costs one query. A database against a
server compares two scopes as if they were one.

The divergence is stricter, not looser: `ptah-compat` exits `1` where the binary
exits `0`, never the reverse.

**Tracking.** [`stokaro/ptah#3789`](https://github.com/stokaro/ptah/issues/3789)

## A realm beside one database on a dev server

**Type.** Deliberate divergence

**Current boundary.** Beside a dev server, `schema diff` refuses a URL naming
one database compared with an HCL document that declares more than one
database, in either order:
`cannot use HCL with more than 1 schema when --from is limited to schema "app"`.
The sentence is the one the binary's `schema apply` gives the same pair.

Measured on 2026-09-28 against MySQL 8.4.11. The server holds `app`, with a
table `t` carrying a column `extra`, and `more`, with a table `keepme`. The
realm file declares `app` and `more`:

| Run | Pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| `schema diff --from mysql://…/app --to file://realm.hcl --dev-url <dev server>` | exit `0`, plans `CREATE DATABASE more` and `ALTER TABLE app.t DROP COLUMN extra` | exit `1`, the refusal |
| the binary's plan, run against the server | `ERROR 1007 (HY000): Can't create database 'more'; database exists` | not planned |
| `schema diff --from file://realm.hcl --to mysql://…/app --dev-url <dev server>` | exit `0`, plans `DROP DATABASE more`, which holds `keepme` | exit `1`, the refusal |
| `schema apply -u mysql://…/app --to file://realm.hcl --dev-url <dev server>` | exit `1`, `cannot use HCL with more than 1 schema when url is limited to schema "app"` | exit `1`, refused before anything is contacted |

A URL naming one database reads that database alone, so a plan that creates or
drops another database reaches a database that side never read. The binary's
own `schema apply` refuses the pair, so its `schema diff` writes plans its
apply will not run. An HCL document declaring one database is compared with the
database, as the binary compares it. A SQL file or a migration directory beside
one database is refused by both, in the binary's words.

The divergence is stricter, not looser: `ptah-compat` exits `1` where the binary
exits `0`, never the reverse.

**Tracking.** [`stokaro/ptah#3885`](https://github.com/stokaro/ptah/issues/3885)

## A dev database beside a whole server

**Type.** Deliberate divergence

**Current boundary.** `schema apply` refuses a `--dev-url` naming one database
beside a target URL naming none, before the dev database is contacted. A plan
for a whole server creates and drops databases, and Ptah rehearses the plan
before it applies it. A rehearsal on one dev database would reach the other
databases of its server, so it needs a whole dev server, a `--dev-url` naming
no database. Where the binary refuses the pair, the sentence is the binary's.

Measured on 2026-09-28 against MySQL 8.4.11 and MariaDB 11.8.9, with the same
results on both. The target server holds `app`; the dev URL names `devdb`:

| `--to` | Pinned community binary v1.3.0 | `ptah-compat` |
| --- | --- | --- |
| an HCL document declaring `app` and `more` | exit `1`, `cannot use HCL with more than 1 schema when dev-url is limited to schema "devdb"` | exit `1`, the same sentence |
| an HCL document declaring `app` alone | exit `0`, plans `ALTER TABLE app.t ADD COLUMN name` | exit `1`, a dev database beside a whole server is refused |
| a SQL file creating `app` and `more` | exit `1`, `cannot diff a schema "devdb" with a database connection`, and `app` and `more` left on the dev database's server | exit `1`, the same sentence, the dev server untouched |
| a migration directory creating them | exit `1`, the same, with the same databases left | exit `1`, the same sentence, the dev server untouched |

The binary replays SQL onto the server the dev database is on, creating
databases outside it, and leaves them there when it refuses. Ptah refuses
before it connects. The divergence is stricter, not looser: `ptah-compat`
exits `1` where the binary exits `0`, never the reverse.

**Tracking.** [`stokaro/ptah#3885`](https://github.com/stokaro/ptah/issues/3885)

## Not on this page

`--to file://../schema.sql` and `--dir file://../dir` used to be refused as
`outside allowed root` where that binary exits `0` (item 11 of that issue). They
are accepted now, and the divergence is closed rather than retained.

The refusal came from a containment that applied to relative CLI paths only: the
identical file named by an absolute path was accepted, and `migrate diff --to`
against it succeeded. A boundary that refuses one spelling of a path and accepts
another spelling of the same path contains nothing, so the refusal could not be
defended as a safety control — and it cost a behavior the community binary has.
`pathguard.ResolveCLIPath` therefore imposes no boundary at all now, and both
spellings of one destination answer identically
([`stokaro/ptah#1622`](https://github.com/stokaro/ptah/issues/1622)).

Containment did not disappear with it. `pathguard.ResolveWithinRoot` and
`OpenDirectoryWithinRoot` take an explicit root and bind every spelling against
it, which is what the project migration-directory confinement uses, and a
`write` directive in a `--format` template still cannot compute a filename that
leaves the root the operator chose.

Both `migrate hash` cells of that issue -- the trailing positional of item 13
and `--var` of item 12 -- were unread when this page was written, because the
sandbox those sweeps ran in refused any command containing that bare word. They
were read on 2026-08-17 in an environment that does not, and they split:

- the trailing positional is a divergence, argued under **Positional arguments
  a flag already names** above, where it joins the verbs that already refuse
  one;
- `--var` is parity. `migrate hash --dir file://mig --var x=1` exits `0` on
  both binaries with byte-identical output, which is none.

Neither is an unknown on this page any more (stokaro/ptah#1623).
