retrofit0.10.0-wip.8

drift

retrofit drift reads a schema dump, typically pg_dump --schema-only, though any DDL that re-creates the schema’s current shape will do. It infers what changed since the oplog’s head and appends the ops that bring the oplog to that shape, asking a question where a change has more than one reading. Under --dry-run it reports those ops and writes nothing.

Read concepts.md first for the oplog / cut / element identity vocabulary.

What drift sees

Two inputs, both authoritative for their side of the diff:

  1. The cut of your unsealed tail. drift replays your oplog up to the last appended op and asks the genealogy model for the set of live elements (tables, columns, constraints, indexes, views) with their current names, types, and adjacencies. This is the before state: not what the database currently shows, but what your oplog would produce if applied.
  2. The target SQL. Parsed as DDL. drift treats this as a state (“this is what the schema looks like now”) regardless of how it got that shape. Typically a pg_dump --schema-only capture, or any DDL that re-creates the same shape by hand. Migration scripts (Flyway, Liquibase, hand-written .sql) belong to import instead: they are authored ops, and import translates each statement literally. See import.md for migration-script intake. Custom DML statements take different paths in the two verbs: import preserves INSERT, UPDATE, DELETE, and MERGE by default, while drift captures the INSERT rows of the tables declared as reference data. See § Reference data below.

drift is per-schema. Schema selection follows the six-step resolver chain: pass --schema <name> explicitly, or rely on [directory_scope] / implicit oplog-dir / default_schema. Unlike import, drift does not lazy-create: the schema must already be registered.

retrofit drift dump.sql                                       # resolver picks schema
retrofit drift --schema sales dump.sql                       # explicit
retrofit drift --schema sales dump.sql --dry-run             # diff only; never write
retrofit drift --schema sales dump.sql -i                    # force editor open
retrofit drift --schema sales dump.sql --seal                # apply, then seal the new tail
retrofit drift --schema sales dump.sql --ignore-drops        # author no Drop ops
retrofit drift --schema sales dump.sql --ignore-drops=table,column  # skip DropTable/DropColumn; author other drops
retrofit drift --schema sales dump.sql --show=additions      # report-side filter (writes unchanged)

-i and --dry-run together refuse kind-mismatch.

Flags

Write side

FlagEffect
(default)Converge: emit Add / Create / Alter for additions, Drop for removals
--ignore-dropsSuppress every Drop op the diff would have emitted
--ignore-drops=<kinds>Suppress only named Drop kinds. CSV of table, column, index, view, materialized-view, constraint, foreign-key, primary-key, not-null, identity, schema (kebab-case, matching the Drop* operator-class suffixes)

Suppressed drops are still reported in the dry-run plan and event stream as deferred-drop events, so the user (or CI) sees what was deferred. The plan header reflects them too: with --ignore-drops, the removals count is rendered as M removals (K deferred) when K > 0.

The suppression holds for interactive answers too: an answer that would author a suppressed drop refuses drift-drop-deferred-conflict (see § Failure modes). A join’s surplus key column is the exception: --ignore-drops=column keeps it and the join lands. Answer another way, or narrow the CSV.

On a survivor-extract question the recovery is skip or a narrower CSV. To reshape without either, author the split directly with the invocation the advisory prints; that path preserves each moved column’s identity and authors no Drop at all.

Use cases: untrusted drops (author additions now and review drops separately); CI drift detection (drift --ignore-drops --dry-run against prod, with additions auto-PR’d and drops alerted on). Matches Terraform’s prevent_destroy / Pulumi’s ignoreChanges.

Report side

Independent of write behavior; filters what the dry-run and live event stream surface:

FlagEffect
--show=both (default)List additions and removals
--show=additionsShow only additions
--show=removalsShow only removals

--show is presentation-only: writes proceed for both directions regardless of value. The drift plan: header above the stream and the post-apply summary below it are NOT filtered; they always show the full diff shape and what was written. Use --ignore-drops to gate writes.

Question policy

--on-question=<error|editor|skip> controls how the run handles ambiguity that drift can’t auto-resolve:

  • error: refuse with needs-interactive-resolution (overrides the default auto-elevation when $EDITOR / $VISUAL is set).
  • editor: open the editor; equivalent to -i once a question fires.
  • skip: emit each question as a skipped event with kind unresolved-question:<class> and proceed with the ops drift could mint. Useful for unattended regression harnesses that batch many drift steps and want to surface ambiguity in the skip manifest rather than stop on the first one.

Unset, when a question fires drift opens the editor if $EDITOR or $VISUAL is set, and otherwise refuses needs-interactive-resolution. --dry-run does neither.

Reference data

Reference rows are schema, so drift converges them as it converges DDL: the tables declared as reference data at drift’s new head are captured in the same run, with no flag. Declare a table with retrofit schema ref-tables add <table>, or pass --ref <table> for a table this run creates. A project with no declared table and no reference chain is untouched: its data statements stay in the skip manifest and no data file is written.

For each declared table, drift considers its INSERT statements (and a TRUNCATE or unqualified DELETE as an empty observation); it does not capture UPDATE, DELETE ... WHERE, or MERGE statements. The comparison runs at the new head, after drift’s DDL plan: a table whose rows differ from its chain is captured as a full observed table state (disposition=replace, one file per changed table), and an unchanged one reads already-captured. Rows of undeclared tables are never captured by drift and stay in the skip manifest; each is listed not captured with its cause. Seed rows are not schema: capture them with retrofit data capture --label <label> <source>. The retired --capture-data flag is unknown, and --label refuses flag-retired pointing at data capture --label.

A declared table drift cannot compare is reported with its cause, never read as drift:

ConditionReportRecourse
the table’s band is a COPY ... FROM stdin blocknot captured: <t> (...) — not comparable: COPY bandre-dump with pg_dump --inserts
the table’s chain cannot be folded offline (an append whose body is UPDATE, DELETE, or MERGE)the verdict carries not comparable: append at <op> ...capture a fresh replace at HEAD
the source states no rows for the table (a schema-only dump, pg_dump -t)reference rows not observed: N tables; JSON capture.not_observednone; absence is never drift
a reference chain whose table is no longer declared (ref-tables remove without data retire)not compared: <t> (declaration removed at <op>); JSON capture.not_comparedretrofit data retire <schema.table>, or declare it again

A captured table that lost a key since its chain is still captured (the observation is what the database holds), with a reference-key-lost warning naming the lost keys: reference data should be monotonic, so restore the row or mark it inactive through its status column instead. data capture raises the same warning.

A declared table the source observes empty (an unfired TRUNCATE or WHERE-less DELETE its only statement) is captured as replace body=none with a reference-table-emptied warning, the extreme removal: an empty lookup table usually calls for DropTable, Archive or schema ref-tables remove instead. data capture raises the same warning.

The exit code is unchanged: it means the run succeeded, with or without row drift. On a real run, drift --as=json carries the per-table verdicts, row deltas, and causes in the drift-run-summary record’s capture member; that stream is the seam CI asserts over.

Under --dry-run the per-table classification rides the stdout stream as one rehearsal record per table:

{"type":"rehearsal","table":"public.currencies","purpose":"ref",
 "labels":[],"records":1,"rows":12,"disposition":"captured"}

records is a record count and rows is a row count, so the record carries both; disposition, supersedes_anchor and overwrote_anchor describe what the capture would do to the chain. Under --dry-run the drift-run-summary record carries no capture member; these records are the per-table report.

Captured records join the same shared corpus that import produces, under the schema’s declared data_root. They are full observed table states (disposition=replace) anchored at the reconciled oplog head, so snapshot and migrate discover them through their normal corpus scan. See import.md § Custom DML statements for the data-file format and consumer behavior.

A divergent capture at an unchanged oplog head replaces the existing, unwitnessed version as a set: the matched files rewritten where they sit, surplus pages deleted and named in the report, and each replaced file reported overwrote: with its digest (git retains the old bytes). A witnessed key refuses (data-witnessed-key).

Capture accepts INSERT-style dumps such as pg_dump --inserts or pg_dump --column-inserts. A COPY ... FROM stdin data block whose rows do not parse as SQL stops the source parse: with a declared table (or --ref) drift refuses the run copy-form-source, pointing to the INSERT-style dump forms; without one it refuses parse-error. Either way it writes neither the oplog delta nor a data file.

FlagEffect
--ref <table>Declare a table as reference data (one this run creates, or one already live); repeatable
--table <pattern>Restrict captured source tables by bare relname or glob; repeatable
--skip-manifest <path>Write the capture skip manifest as JSONL

Seal

--seal appends a Seal op covering drift’s new tail after a successful apply. Bare --seal derives the reason from the source ("drifted to <basename>"); --seal=<text> overrides with a custom reason. The Seal passes the gate op seal runs over the range it is about to seal, before anything is written: a reference record the range would cover with no witness inside it (a prior data capture never witnessed) refuses data-witness-missing, and a reference replace anchored in the range with no upsert form at its anchor refuses replace-no-upsert-form (docs/migrate.md). Drift’s own capture anchors on the Seal line, outside that range, so it is never what refuses.

Objects the project does not own: [[exclude]]

A target dump often carries objects your application does not author: a migration runner’s history table, extensions the hosting provider installs, schemas it creates. A project declares them once, as an [[exclude]] array in retrofit.toml (kind = table, extension or schema; name, a table’s schema, and a required reason), and drift removes every statement an entry owns from the target before anything reads it. The plan is computed over the target less those statements, so an exclusion alone never changes the plan or the exit status.

Each entry that owned at least one removed statement is reported on every run as an exclusion-applied advisory on stderr (~ (excluded) table public.audit — 2 statement(s) — <reason>) and, under --as=json, as an excluded event on stdout beside skipped; drift-run-summary tallies the reported entries as excluded, apart from skipped_by_class. An entry that owned nothing in a run is listed in one courtesy line (-q removes it): a misspelt entry leaves its object as divergence. A kept statement that references an excluded table or extension refuses exclusion-dependent (naming the statement by src_file:line), and an entry naming an object the oplog already holds refuses exclusion-in-log (naming the op); both refuse before anything is written and exit 2. A kept reference into an excluded schema keeps the disposition a reference into an unmanaged schema has and is reported as excluded-schema-referenced. No flag adds to or overrides the set, and drift never edits the oplog to honour it. See getting-started.md for adoption.

What drift produces

drift authors ops in both directions by default. Elements in the target but not the cut materialize as Add* / Create* ops. Elements in the cut but not the target materialize as Drop* ops. The supplied source is authoritative for the schema’s current shape; refusing to mint drops would leave your oplog asserting a fictional model.

A Drop* op loses no rows by itself; the oplog is a scratchpad. Rows are lost when a script carrying the DROP is applied: a migrate script over the op, which emits it with no flag, or a snapshot --include-drop=scoped|schema prologue.

Use --ignore-drops to suppress drops you don’t trust; see § Flags.

Three kinds of event in the stream:

  • op-applied: drift found an unambiguous transition and authored the op directly. No question to the user. Examples: a table appeared in the target that the cut doesn’t have (CreateTable); a column type changed and only one column with that name exists (CastColumn); a SetDefault / DropDefault / SetReplicaIdentity / comment change. These land in the oplog without confirmation.
  • question: drift found a transition whose shape is real but whose authoring intent is ambiguous. The user (or $EDITOR, or an LLM driving $EDITOR) picks from a candidate list. Question classes: the rename/cast set (table-rename, column-rename, column-cast, combined-rename-cast, index-rename, view-rename) and the structural set (copy-table, extract-table, join-table, merge-table, partition-table, departition-table; see § Data-motion SMOs).
  • skipped: drift encountered a statement it authors no op for (e.g. grants and default privileges, a standalone sequence, an object outside --schema). Skipped statements carry through to the report with their kind, name and class; they do not silently disappear. The class decides the exit; see § Exit status.

A terminal record ({"type":"ok"} or {"type":"failed",...}) closes every run. See cli-output-contract.md for the full JSON shape.

Exit status

drift’s exit status depends on its skips, as import’s does. Every skipped item carries a class from the classifier import uses:

  • decision: set aside by a named rationale. The class covers an object outside --schema (cross-schema), materialized views, grants and default privileges, a bare DROP, COPY data, a cross-schema foreign key, the pg_dump preamble rows drift tolerates (setval, schema and extension comments, DROP … IF EXISTS, standalone sequences), unresolved-question:<class> under --on-question=skip, a function or trigger entangled with a skipped table rename, a VALID → NOT VALID change PostgreSQL cannot express, and a dependent whose every unresolved reference is such a root: a view, materialized view or policy over a cross-schema or materialized-view object, or a view, policy or foreign key over a table an unanswered question claims.
  • preserved: DML set aside, as on import.
  • gap: content retrofit has no operator for, or cannot bind. Every other skip kind, including one the classifier does not list (which also raises a drift-skip-unclassified warning).
  • internal-defect: a parser version-boundary tripwire; a drift-skip-internal-defect warning, and the run still exits 0.

The run exits 0 over itemized content: ops applied (or previewed under --dry-run) and decision, preserved or internal-defect skips. Exit 0 does not mean “no divergence”; expressible drift still applies ops at exit 0.

When any gap skip stands, the run exits 2 with drift-unreconciled-skips, on --dry-run and real runs, in text and --as=json alike. The drift-run-summary record (with its skipped_by_class tally) streams first; a real run’s appended ops, and a --seal seal, persist before it fails. The hint names each gap kind with its count. A view, policy or foreign key that read a table under a question this run answered is named apart: rerun drift, and the next run plans it against the answer.

--allow-capability-gaps accepts the gaps: exit 0, with the same summary record. A stock pg_dump --clean dump needs it, because its preamble’s ALTER TABLE … DROP CONSTRAINT / ALTER COLUMN … DROP DEFAULT statements skip as gaps.

Outside the skip contract:

  • Every refusal, parse error and needs-interactive-resolution (exit 3) fires first and wins.
  • question items and --ignore-drops’ deferred-drop items never change the exit.
  • Content drift drops with no item at all never reaches the exit: an EXCLUDE constraint (inline or ALTER TABLE … ADD CONSTRAINT), and an index or ENABLE ROW LEVEL SECURITY on a table drift pairs as a rename.

What drift appends vs. what it asks

drift’s contract: every candidate it advertises is one the resolver can execute, and an ambiguous step is a question.

  • Appended without a question: unambiguous structural diff. One table dropped, one table added with a different name and the same shape → RenameTable op-applied. The auto-rename heuristic fires only when there is exactly one viable pairing; any tie auto-elevates to a question. A policy or trigger that keeps its parent and payload under a new name pairs the same way (RenamePolicy / RenameTrigger); a tie there keeps the drop and create.
  • Asked (question event): more than one defensible pairing. Multiple drops and adds with overlapping shapes; a column whose type changed and whose name might have changed; a drop-add pair that could be either a rename or a real drop + a real add. drift shows you the candidates; you pick.

drift needs no --force for ordinary work: every ambiguous step becomes a question. With neither $EDITOR nor $VISUAL set, or under --on-question=error, drift refuses needs-interactive-resolution when questions remain; -i with no editor refuses the same way, and --dry-run never does.

What drift cannot produce, and why

The boundary is information-shape, not capability. drift sees one cut and one target snapshot. Some operators carry meaning that cannot be derived from end-state alone.

Schema-context ops

CreateSchema is N/A:single-schema-context. drift runs against one schema; the schema set is a CLI precondition, not a drift output. Use retrofit schema create instead.

Data-motion SMOs: CopyTable, ExtractTable, JoinTable, SplitTable, MergeTable

First-class via op add + snapshot. drift recognizes four of the five from an end-state diff and surfaces each as a question (candidate, never a silent guess):

  • CopyTable: source persists plus a same-shape twin.
  • ExtractTable: either arm. The consume arm drops the source and adds ≥2 tables whose disjoint columns cover it, and offers an accept. The survivor arm drops nothing and may add exactly one table: a surviving table loses columns while an added table appears carrying them. That arm is advisory only: it offers no accept and stamps no confidence; its only candidate is drop-add. See below.
  • JoinTable: ≥2 disjoint-column drops + 1 union-shape add, FK-gated; the editor fills in the join predicate and kind.
  • MergeTable: dropped same-shape sources beside a live into. into is a member of the sources’ naming family and sets the high tier but is never a source; an added into (versioned or snapshot sources collapsing into a new table) is medium. The accept fills one predicate.<source> per source and mints MergeTable with into keeping its ElementID (an added into mints CreateTable first); a predicate left empty for any source declines the whole question to a DropTable per source. A family whose sources or into differ in a column raises drift-merge-shape-mismatch naming it and takes no accept.

The survivor-arm extract raises drift-survivor-extract-unrecognized at warning level rather than passing silently as a flat DropColumn + CreateTable pair: it pairs the moved columns by name alone, without the shared-key signal a recognized ExtractTable is graded on. Which arm applies is decided by whether the source survives, never by column names.

drop-add reshapes the target now, but each moved column’s ElementID dies and re-mints. The identity-preserving path is to author the split yourself, following the invocation the advisory prints (the spellings below). The advisory names the re-projected shared column when the added table carries one, and withholds the invocation entirely when any advertised column carries a live identity facet, because that split would either refuse outright or land somewhere other than the state you declared.

drift also surfaces the two identity-preserving partition converts (PartitionTable / DepartitionTable) as questions when a plain heap appears partitioned in the target, or the inverse.

An existing table that joins or leaves a partitioned parent the target keeps mints the membership op itself: a cut table the target attaches (ALTER TABLE … ATTACH PARTITION, pg_dump’s spelling) mints AttachPartition after the table’s own deltas, and a partition the target declares free-standing mints DetachPartition. A re-home to another parent, a re-bound, a PARTITION OF spelling over an existing table, an attach whose columns are not the parent’s, and a detached born partition that declares state the detach does not carry (its own key, CHECK or index) keep the pg-declarative-partition-convert-unsupported skip, with a hint naming why.

SplitTable is excluded (N/A:no-end-state-signal): its per-sibling row predicate lives in the migration script, not in residual schema, so a split leaves no recoverable signal in a cut → target diff. The split keeps its source, so a kept source beside same-shape adds is CopyTable’s signature: drift mints no SplitTable, the copy-table question names op add SplitTable … --conditions= as the alternative, and a same-shape add no copy marker names raises the warning drift-split-unrecognized naming the same.

To author any of these directly:

retrofit op add CopyTable      <schema> <source-table> <new-name>
retrofit op add ExtractTable <schema> <source-table> --into "hot=<col>"
retrofit op add ExtractTable <schema> <source-table> --into "hot=<col>" --rest <new-source-name>
retrofit op add JoinTable   <schema> "<a>,<b>" <combined> "<join-predicate>" inner
retrofit op add MergeTable     <schema> "<a>,<b>" <into> --conditions="predicate=<predicate-a>" --conditions="predicate=<predicate-b>"
retrofit op add SplitTable     <schema> <source-table> "hot,cold" --conditions="predicate=<predicate-1>" --conditions="predicate=<predicate-2>"

The two ExtractTable spellings above are both the survivor arm: any live column left unassigned by --into stays on the source, which survives in place. --rest <name> renames that surviving source; it never names an extra child. Assign every live column to an --into child to get the consume arm instead.

op add ExtractTable <schema> <source-table> --into "<child>=<cols>" is how you keep the moved columns’ ElementIDs on a survivor-extract question; the alternative, answering drop-add, re-mints them. The <schema> positional is required: the command does not parse without it. Name only the columns that MOVED: a shared key the child re-projects is omitted from --into deliberately, because this path synthesizes the child’s rejoin key (NOT NULL + PRIMARY KEY + FOREIGN KEY back to the source) itself.

SplitTable (PRISM row-axis refactoring; keeps its source) is unrelated to PG’s declarative PARTITION OF clause (physical storage attribute; parent persists). Declarative partitioning is owned by AttachPartition / DetachPartition.

PG18-only syntax

drift parses with the PostgreSQL 17 grammar (libpg_query v6), so a target using PG18-only syntax (NOT ENFORCED, ALTER CONSTRAINT … [NO] INHERIT, VIRTUAL generated columns) does not parse. PostgreSQL versions gives every verb’s row at each major.

Interactive resolution

When drift has questions, it opens $EDITOR (or $VISUAL) on a buffer of resolution lines, one per question, with candidates already enumerated. You edit the buffer, save, exit; drift reads back your choices and authors the ops.

-i opens the editor even when there are no questions, which is useful for inspection.

--dry-run parses, diffs, and emits the report. It never opens the editor and never writes the oplog.

On a plan with no questions (every plan under --on-question=skip) the dry run mints and validates the ops exactly as the real run does, and a refusal comes back with the real run’s code and exit after the plan streams. Exit 0 there means the ops would apply. A plan with questions is rehearsed against the default answers, so only the refusals no answer can change (the extract-* family) fail the dry run; the rest wait for the real run’s answers.

See op revise -i for the buffer grammar; drift’s editor uses the same helper. (--seal semantics are covered in §Flags above.)

Failure modes

  • source-empty: a <source> that is a stream (-, <(…), a FIFO or another descriptor) delivered zero bytes, which on a pipe is the signature of a producer that failed upstream. Exit 2, Param source, refused before any parse; a zero-byte .sql file is read as it always was. Check the producer, or save its output to a .sql file and pass the path.

  • data-band-over-cap: a table’s INSERT band in the source is larger than the schema’s data_band_max_bytes (or the built-in default when the key is absent). The bound is checked before the source is parsed and before any plan output; every over-cap table is named in one envelope. Raise data_band_max_bytes for the named schema, or remove the key for the built-in default.

  • drift-unreconciled-skips: the run left a gap skip (exit 2; see § Exit status). Model the gap, re-scope --schema, or accept it with --allow-capability-gaps.

  • needs-interactive-resolution: drift has questions (or -i was passed) and no editor opens, because neither $EDITOR nor $VISUAL is set or because --on-question=error is in force. Set $EDITOR, drop --on-question=error, or run with --dry-run.

  • bootstrap-needed: drift was run against an empty or bootstrap-only oplog (the cut contains only CreateSchema, no user-facing ops). drift converges an existing oplog to a moved state; it does not mint the initial shape. Use retrofit import <source> --schema <name> to bootstrap from a .sql file. See import.md for bootstrap intake.

  • schema-not-registered: --schema <X> named a schema not in [[schema]]. drift does not lazy-create; run schema create <X> (or import --schema <X> <source> to bootstrap from DDL).

  • default-schema-unset: the resolver chain produced no result; pass --schema, --chdir, or run schema set-default.

  • unsupported-on-engine-version: the target SQL contains DDL the pinned engine version cannot produce. See retrofit schema engine for the current pin.

  • kind-mismatch: a flag-combination or argument shape drift rejects up front. These are -i with --dry-run, -i with --on-question=skip|error, --dry-run with --on-question=editor, the wrong positional arg count, an --on-question=<v> outside error|editor|skip, or a <source> with an unrecognized file extension / URL scheme, or an extension-less regular file (a stream with no extension is read as .sql).

  • drift-pre-flight-refused: the proposed items[] failed to replay against the cut in a pre-write dry run (e.g. a primary-key change that would dangle an FK). The inner precondition cause rides along; drift writes nothing.

  • rule-no-match: an emitted op failed its precondition validator against the current cut (the same op-validation envelope import surfaces).

  • drift-accept-not-offered: an interactive buffer edit answered accept for a candidate drift never offered for that target.

  • drift-drop-deferred-conflict: an interactive answer cannot be applied without authoring a Drop op the run already deferred via --ignore-drops. The answers it refuses are drop-add on a structural question: the DropTable of each consumed source, and on a survivor-extract the DropColumn of each moved column plus any live constraint or index that column carries; and accept on a join or an extract, the drop of each source constraint and index it authors: on a join, those the product lacks (including each kept index it rebuilds around the surplus key column); on an extract that consumes its source, each one it drops ahead of the ExtractTable (a primary key that reparents onto a child is kept). Refused all-or-nothing; the message names each deferred object by kind and name. Nothing is written. On a survivor-extract question the hint offers skip and narrowing the CSV.

  • drift-convert-no-partitions: a partition-table accept was taken but the target has no PARTITION OF child to convert.

  • drift-tied-answer-unresolved: the answers to a tied table-rename pick a pairing under which a target policy or trigger on a tie member does not resolve against the cut, though it does under the default pairing (a body alias that collides with the renamed member’s name, for example). The message names the statement. Nothing is written; answer another pairing or adjust the target statement.

  • drift-convert-recreate-incomplete: a partition-table or departition-table drop-add was taken, but the recreated table cannot carry target state the surviving table already holds (a key whose unmodeled facet, such as an adopted index’s INCLUDE list, the cut models; a policy whose target body does not resolve), or a target trigger whose callback function the run neither finds nor creates. The message names each such object. Nothing is written. The hint offers accept, which converts the table in place and keeps that state, only where the question offers it; a nested convert offers drop-add alone, so its hint is skip and an authored conversion.

  • buffer-parse-failed: an edited resolution buffer (from op revise -i-style interactive drift) is malformed (a rename line missing an element id, an unknown action, etc.). Detail names the line.

  • seal-nothing-to-seal: --seal was passed but drift produced no new tail to cover.

  • data-witness-missing: --seal was passed and the range the Seal would cover holds a reference record with no witness inside it; the same refusal op seal makes. Nothing is written. Run retrofit data witness, then seal.

  • replace-no-upsert-form: --seal was passed and a reference replace anchored in the range has no ON CONFLICT form at its anchor; the same refusal op seal makes (exit 2). Nothing is written. Follow the hint (docs/migrate.md), then seal.

  • flag-invalid-value: a closed-set flag was given a value outside its vocabulary (--show=<v> outside both|additions|removals, or --ignore-drops=<csv> naming a kind outside the closed set). The error envelope names the flag (Param) and the rejected value.

  • io-error: the source file is unreadable, the oplog cannot be written, the editor exited non-zero. Detail line carries the syscall reason.

  • parse-error: the source SQL or the oplog failed to parse. Line number in the error envelope.

  • drift-mint-unresolved: an op the plan authored names an object that is neither on the cut nor created in this batch, so it cannot be minted. This is a user-environment state, not an internal fault: the target SQL references a dependency drift never saw. Add the missing dependency to the target SQL, or author the op directly with op add.

  • add-fk-unresolved-ref (a skipped entry; under an open question-claimed reason it classes decision, otherwise gap, and the run exits 2 per § Exit status): a foreign key’s referenced table could not be bound at plan time, so the constraint is not minted. The skipped payload carries a reason naming which of the four states applies:

    • question-claimed: the referenced table is the subject of an unanswered table-rename question (or was consumed by a merge), so its identity is undecided. Answer the question, then re-run.
    • drop-claimed: the referenced table is dropped in this same batch. Keep the table, or drop the referencing constraint too.
    • ref-column-miss: the named referenced column is not live on the referenced table (commonly a co-renamed column). A partition owns no columns of its own, so a foreign key to a partition’s column takes this skip too, whether the partition is on the cut or created in this run (PARTITION OF or ATTACH PARTITION); reference the partitioned parent instead. When the referencing table is new in this run the skip’s kind is add-fk-unresolved-ref-column, naming the missing table.column.
    • unmodelled-facet: the constraint carries a column-restricted ON DELETE SET NULL (cols) / SET DEFAULT (cols) action retrofit cannot reproduce; minting it would silently widen the action to every column.

    With no reason, the reference names an object retrofit has never seen. In every case the remediation is the same: settle the referenced table’s disposition and re-run, or author the constraint directly with op add AddForeignKey. Use --as=json to read the reason and the unbound reference off the skipped payload.

  • add-unique-unmodeled-facet / add-pk-unmodeled-facet (a skipped entry classed gap; the run exits 2 per § Exit status): a target UNIQUE or PRIMARY KEY carries a facet its op cannot reproduce (NULLS NOT DISTINCT, INCLUDE, WITH (…) on the constraint; DEFERRABLE on a primary key), so it is not minted. ADD CONSTRAINT … USING INDEX idx is not such a facet: drift mints the constraint with backing_index when idx is on the cut or created earlier in the same plan, and an equal live key pairs with it as the same key. Nor is an INCLUDE (…) list equal to the one on the index an equal live key adopted through backing_index: that is the same key as snapshot and pg_dump spell it, so drift reports nothing. The same holds for a USING INDEX idx whose idx covers that list: it is the index the live key already adopted. When an index is the cause, the payload carries a reason and a hint:

    • index-unresolved: the target does not declare idx on that table, or the plan neither creates nor keeps it.
    • backing-index-ineligible: idx is not a unique btree index free of a predicate, expression keys, INCLUDE columns, non-default ordering, NULLS NOT DISTINCT, WITH (…) options, an operator class and COLLATE, or a constraint already adopts it.
    • adopted-index-include: the index an equal live key adopted covers columns the target key does not list the same way (a different INCLUDE (…) list, or none). No op changes an adopted index’s covering columns in place.

    For the first two, fix the index and re-run, or author the constraint directly with op add AddUnique / AddPrimaryKey / ChangePrimaryKey and its backing_index. For the third, declare the live list on the key in the target, or rebuild the key: op add DropUnique / DropPrimaryKey, then op add AddUnique / AddPrimaryKey, adopting an index with the target’s list through backing_index when the target declares one.

  • add-check-unmodeled-facet (a skipped entry classed gap; the run exits 2 per § Exit status): a target CHECK is declared NO INHERIT. Table inheritance is not modeled and AddCheck has no slot for the flag, so the constraint is not minted, and a live CHECK it pairs with is not reported as equal. import skips the same constraint as check-constraint.

Scope contract

What drift handles, by category:

  • Produced: every Add* / Create* / Set* / Drop* the diff resolves unambiguously, plus the rename/cast questions and the data-motion Copy / Extract / Join / Merge and partition converts surfaced as questions (see § Data-motion SMOs). The full per-operator inventory is in smo-catalog.md.
  • Out by information-shape: CreateSchema (single-schema-context: the schema set is a CLI precondition, not a drift output), SplitTable (no-end-state-signal: the per-row predicate leaves no schema residue), and ops with no DDL primitive at all (no-pg-primitive).
  • Not parsed: PG18-only syntax. drift parses with the PostgreSQL 17 grammar (libpg_query v6); see § PG18-only syntax.

Before 1.0, any release may change behavior; the release notes list what changed.