Concepts
The mental model behind retrofit.
For installation and a hello-world session see
getting-started.md. For the rules retrofit
upholds see principles.md.
The premise
A traditional migration tool manages what the schema is. You hand-write DDL, the tool tracks which scripts have run, and the production schema is the sum of those scripts.
What retrofit manages is how the schema changes. You don’t write DDL. You record semantic operations (rename this column, cast this type, extract this wide table into two narrower ones) and retrofit materializes the SQL.
The shift matters because some changes are identity-preserving and DDL can’t see the difference. A column rename and a drop-then-add leave the same end-state schema. Only the change record says the column’s identity carried over: the foreign key still points at it and its rows are still there.
The oplog
The oplog is an append-only file of operators. One per line. Three header lines, then the ops:
# retrofit-oplog-format: 1
# retrofit-model-version: 1
# engine-version: 17
CreateSchema(01bd95b8116c, prev=000000000000, id=a9c670f51071, name="users")
CreateTable(b2c822ec011a, prev=01bd95b8116c, in=a9c670f51071, id=e295fa023ed9, name="people", reference_data=false)
AddColumn(45bcdb3fca16, prev=b2c822ec011a, in=e295fa023ed9, id=3befa1c90631, name="id", type="bigint", identity=by_default)One oplog file per registered schema. Plain text: git diffs it
cleanly, you can read it in a code review, you can grep it.
Each line has four kinds of slot:
<opid>: a 12-char hex identity minted when the op is constructed, drawn at random like an ElementID rather than derived from what the op says. It names the act, not the content: running the same command twice appends two ops with two ids, because it happened twice. An id is minted once and never re-minted for an unchanged line: reordering an op, rewriting itsprev=, rebasing it throughreconcile, or revising it to a value it already carries all leave it alone. Uniqueness within a log is load-bearing (a duplicate opid is avalidatefailure); reproducibility across two independent runs is not, and never was: replaying the same commands on a fresh oplog mints new ids. Same oplog → same output; same commands → equivalent, not identical, bytes.prev=<opid>: predecessor. Forms a singly-linked chain through the file. The first op in a schema hasprev=000000000000. Nothing an op carries feeds its id,previncluded, so revising an op early in the chain doesn’t cascade-rewrite later opids.<role>=<value>: payload. Element references (in=,on=,columns=…) bind by ElementID, not by name. Strings, hex IDs, enums, lists.id=<minted-id>: for an op that creates an element, the new element’s ElementID.
The slots keep two layers apart: the op’s own id and prev= name ops,
and the element slots (in=, on=, id=, columns=, …) name
ElementIDs. The catalog types every slot as one or the other, so this
layer separation is a property of the format, not a rule an operator
declares, and op info does not list it. An opid written into an
element slot names no element, and the verbs that read the log refuse
it as rule-exists-and-alive.
ElementID
Every element an op creates has an ElementID: schemas, tables, columns, constraints, indexes, views and materialized views, types, functions, triggers, policies and extensions.
The ElementID is minted at creation by the operator that births
the element (AddColumn for columns, CreateTable for tables, …) as
a random 48-bit draw. It is not derived from the element’s name, so
two oplogs describing the same schema hold different ElementIDs. A few
derived identities follow other anchors. The children import mints
for a recognized ExtractTable derive from the source DDL and
reproduce on re-import. The cpk.fk and detach.col children replay
synthesizes follow a drawn id= or opid, so they are stable within a
log and differ across independent imports. The cpk.fk children
follow ChangePrimaryKey’s id=, so op revise of that op keeps
them. A DetachPartition records the ids of the columns and CHECKs it
mints in its column-ids and adjacent-ids lists, and replay mints
those ids; only one whose lists are empty (written before the lists
existed) has replay synthesize detach.col and detach.adj ids that
follow its opid.
An ElementID is permanent for the element’s lifetime. It survives renames.
It survives MoveColumn. It survives ExtractTable and
JoinTable. It is not reused. It is invalidated only by tombstone
ops (DropTable, DropColumn, …).
This holds for containers too, not only leaves. A ExtractTable
that leaves live columns unassigned (the survivor arm) does not consume its source: the source survives the
transform in place under the same ElementID and the same birth op,
holding the unassigned columns. Only the --into children mint fresh
ElementIDs. --rest <name> renames that survivor in place, which is a
name transition, not a new identity. The survivor’s PK constraint
name, inbound foreign keys, triggers, privileges and PG table OID are
all continuing facts about the same table, never re-created.
When you write op add RenameColumn on=users.email name=email_address,
retrofit resolves users.email to the column’s ElementID and stores
that ID in the rename op, not the name. Snapshot then renders the
latest name for that ID. The foreign key that referenced the
column referenced the ID, not the name; nothing about the FK has to
change.
Ops bind to identity; a name is an attribute the latest op sets.
Snapshot vs. oplog
Two views of the same schema, and one store beside them:
- Oplog: the append-only history. Authored. Versioned in git. Says how the schema came to be.
- Snapshot: canonical DDL emitted by
retrofit snapshot. Says what the schema currently is. Reproducible from the oplog. - Data files: runnable per-table
<schema>.<table>.<YYYYMMDDHHMM>….data.sqlfiles under the schema’sdata_root(default:dataunder the project root) whereimportsets aside verbatim custom DML statements (INSERT/UPDATE/DELETE/MERGE) that fall outside retrofit’s vocabulary. Custom DML can’t live in the oplog (a later rename would break an opaque body), so it lives here. Each record is tagged with apurpose(transition/cleanse/ref/seed/unclassified);snapshotandmigratediscover the corpus automatically:refrows always apply, whileseedrows are opt-in when an emit command names their dataset with--label. Seeimport.md§ “the data file”.
You commit the oplog. The snapshot is build output.
oplog (authored, versioned)
│
│ retrofit snapshot
▼
DDL (build output, deterministic, discardable)The snapshot can be taken at any point in the oplog with
snapshot --at <opid>, which gives you the schema as it stood when
that op was the tail. Replay is total: same oplog, same op, same
snapshot bytes, every time.
Replay and genealogy
When retrofit needs to know “what does the schema look like right
now?” (for snapshot, for op add validation, for drift detection),
it replays the oplog through a deterministic engine that builds a
genealogy forest: a content-addressed tree where each addressable
element is a vertex, and each op contributes one or more edges.
Replay enforces structural rules (you can’t DropColumn a column
that doesn’t exist, you can’t AddPrimaryKey to a column that’s
already in another PK), normalizes SQL semantics (an identity=
column is implicitly not_null), and produces vertex content
hashes that anchor drift detection.
You don’t see the genealogy directly. You see its outputs: snapshot
DDL, op add validation errors, drift questions.
The unsealed tail
The oplog has two regions: sealed and unsealed.
- Unsealed tail: the working surface. You can append (
op add), edit (op revise), or remove (op remove) ops freely. You can open the editor buffer (op revise -i) and delete lines. No confirmation step guards it, because no database has applied it. That holds only while nobody has, so a tail shared through git is sealed before anyone applies it. - Sealed range: frozen history. Once you
op seal, the range is locked: a Merkle digest over the sealed ops’ canonical bytes is recorded, and any in-place edit to a sealed op breaks verification. Sealing is the act that promises “this is what production saw.”
You seal a range when you ship it. New work goes on a fresh unsealed tail behind the seal.
Recovery is git: put the file back from version control.
Rows in a database are the asset
(principle #1);
the oplog is source code. The destructive prologue (snapshot --include-drop=scoped|schema) is where row loss enters, and it is
opt-in.
Forks
prev= normally forms a single chain. A fork is the exception:
two or more ops sharing the same prev= (a branch point with more
than one successor). Forks arise the usual way branches diverge: two
git branches each op add onto the same op, and the merge=union
.gitattributes rule concatenates both appends into one file. The
result is a valid oplog with a non-linear tail: every op is still
there, but replay order across the branch point is ambiguous.
retrofit reconcile closes forks by rebasing onto a linear tail:
it picks an order for the divergent blocks and rewrites prev= so the
chain is single again (opids are unaffected, since nothing an op carries
feeds its id). It is non-interactive, so the flags carry the decision:
--keep/--swap: the bias. Prefer the first-seen block order, or the second-seen.--clean/--accept-dirty: the strictness. Write only if a conflict-free linear order exists, or write the requested order even when one block’s ops depend on the other’s (a dirty tail you then resolve withop revise).<fork-selector>/--all: reconcile one named fork, or every leaf fork in line order.
reconcile --all --clean refuses fork-nested (exit 1) when one
oplog holds two or more independent forks; run reconcile <fork-selector> --clean once per fork.
A fork of three or more blocks closes two blocks at a time:
- Steps run in line order, each joining the result so far with the next block.
--swapreverses each step’s order, except at a step with one sealed side, which writes the Seal first under either bias.seal-forces-orderrefuses the bias only when every step is such a step.- Under
--cleanthe run stops at the first step that does not close and writes nothing. It refuses with that step’s code, its message prefixedstep k of n:fork-no-cleanwhen the pair has no clean order, otherwise the step’s own refusal (a seal refusal, for one). - Under
--accept-dirtysteps close as under--cleanuntil the first with no clean order. From there the requested order is written, the run exits 2, and every later step is reported unchecked.op revisemends the linear result. - A fold whose every step closes clean refuses
--accept-dirty(flag-not-applicable).
validate reports forks, and by default fails the run on one;
reconcile resolves them. A Seal inside a fork constrains the order:
a single sealed block is written first (seal-forces-order), both
blocks sealed needs an explicit choice (fork-concurrent-seals), and
a Seal spanning both blocks refuses (fork-straddles-seal). A
selector that matches no fork refuses too (fork-selector-unknown).
Recovering from a merge maps each refusal
to the command that clears it.
One fork shape is worth naming because an opid is a draw. Two
developers on two clones run the same op add against the same
predecessor. Each mints its own id, so git merge sees two different
lines, the merge=union rule concatenates both, and the result is a
fork holding the same act twice. reconcile --clean refuses it
(fork-no-clean): replaying either order charges the duplicated act
against the rule that governs it (rule-unique-name-in for a
CreateTable, and so on), so neither order is clean. Because the two
lines differ, merge=union keeps both, and neither act is lost. Drop
the one you don’t want, or take the tail
deliberately with --accept-dirty and resolve it with op revise.
Recovering from a merge
After a git merge of two branches that each appended ops, the fork
is usually noticed by whichever command next reads the oplog, not by
reconcile. The refusal it prints carries a hint: line naming the
next step; this table collects them in one place.
| Refusal | Raised by | What it means | Recover with |
|---|---|---|---|
rule-prev-is-tail | op add, and the other verbs that edit the tail | An op’s prev= is not the op before it. Either a merge left a fork, or one tail op carries a stale prev=. The refusal cannot tell which. | Just merged: retrofit reconcile. Otherwise: retrofit op revise to fix the stale prev=. |
structural-gate, reporting schema-fork-unmerged | validate (exit 2) | The oplog carries an unmerged fork. | retrofit reconcile --schema <name> to inspect the fork, then pick a write mode. --allow-unmerged-fork reports without failing (exit 0). |
structural-gate, reporting apply-risk-window-* | validate --fail-on-apply-risk (exit 2) | The migration window since the latest Seal validates a constraint or sets NOT NULL on a table that may hold rows. Only under this flag; --allow-unmerged-fork does not affect it. | Author the AddForeignKey / AddCheck with not_valid=true, then ValidateConstraint in a later migration; for AddNotNull, AddCheck(<col> IS NOT NULL, not_valid=true) in an earlier migration, then ValidateConstraint and AddNotNull. |
fork-in-range | migrate, snapshot, data list | The requested range crosses an unreconciled fork, so the schema at that cut is ambiguous. | retrofit reconcile <shared-prev>, as the hint prints it. The selector is the fork’s shared prev=, not a range. |
seal-over-fork | op seal | The unsealed tail contains a fork, and a Seal would attest an order no migration can emit. | retrofit reconcile, then seal the linear tail. |
write-over-fork | op remove, op revise (single, --batch, -i), ops upgrade-sql-bodies, data witness, data repin (re-witnessing a witnessed record), schema ref-tables add/remove | The oplog holds a fork. Writing would close it in line order, or append beside it, settling an order only reconcile may choose, so the verb refuses before it writes or previews anything. | retrofit reconcile: --clean (with --keep or --swap to pick the order) when a property is set two ways, or --accept-dirty and then op revise when no order replays. |
fork-no-clean | reconcile --clean | Neither block order replays without a rule violation. | --accept-dirty to write the order anyway, then fix the tail with op revise. |
flag-not-applicable | reconcile --accept-dirty | The fork has a clean order, so there is nothing dirty to accept. | --clean. |
seal-forces-order | reconcile with --keep or --swap | Exactly one block carries a Seal, so that block is written first and the bias flag has nothing to choose. | Drop the bias flag. To reorder anyway, remove the Seal with op unseal on the reconciled oplog. |
fork-concurrent-seals | reconcile with a write mode | Both blocks carry a Seal. The bare report prints both Seals and which flag places which block first. | --keep or --swap, plus --allow-sealed-rewrite=<opids> naming each Seal of the other block: those Seals are removed and its ops reopen. Left unnamed, the run refuses seal-removal-refused. |
fork-straddles-seal | reconcile with a write mode | A Seal attests ops from both blocks, so no order preserves its digest. | Remove the spanning Seal with op unseal on a reconciled oplog, then re-run reconcile. |
fork-selector-unknown | reconcile <fork-selector> | The selector matches no fork’s shared prev=. | Bare retrofit reconcile lists each fork by its shared prev=. |
duplicate-opid, fork-duplicate-opid | validate; reconcile with a write mode | One opid sits on two lines, usually because a second rebase under the union driver kept both the old and the rewritten copy of a line. The refusal names the opid, each copy’s line, and how the copies differ. Neither command chooses between them. | Bare retrofit reconcile shows every copy; keep the one you mean and delete the others. |
structural-root-fork, fork-restart-roots | validate, op add, reconcile in every mode | The oplog holds two CreateSchema roots of one schema: one side restarted the log with import --force and the other appended to the old one. No order of the two histories is a merge, because the other side’s ops name element ids the restart replaced. --allow-unmerged-fork does not clear it. | Take the restarted side and add the other side’s ops again on it. |
The usual path is retrofit validate to confirm the fork, bare
retrofit reconcile to see whether a clean order exists (it writes
nothing), then reconcile --clean or reconcile --accept-dirty.
What an op actually carries
Take this line:
AddColumn(45bcdb3fca16, prev=b2c822ec011a, in=e295fa023ed9, id=3befa1c90631, name="id", type="bigint", not_null=false, identity=by_default)45bcdb3fca16: the opid. Drawn at mint time, never computed from the line’s content, so it names the act that authored this op rather than the bytes the op carries.prev=b2c822ec011a: predecessor. Rewriting it does not touch this op’s opid: the opid was drawn once and does not follow the chain.in=e295fa023ed9: target table (an ElementID; the table this column lives in).id=3befa1c90631: the ElementID this op mints (the new column’s identity).name="id": the user-facing name. Mutable viaRenameColumn.type="bigint": SQL type.not_null=false,identity=by_default: column attributes as the user wrote them. The persisted op preserves the original input verbatim; the PG semantic implication (identity=⇒not_null) is applied at replay, not atop addtime, so the oplog never silently rewrites what you said.
Replay reads this op, adds a column vertex under the table vertex and
applies the identity-implies-not_null rule; snapshot emits the column
declaration from the result.
Operators by category
The operator catalog is grouped by what kind of change the op
expresses. The full list with implementation status lives in
smo-catalog.md. The categories:
- Schema:
CreateSchema. Root element. - Table:
CreateTable,DropTable,RenameTable,CopyTable,ExtractTable/JoinTable(column-axis split/merge),SplitTable/MergeTable(row-axis split/merge),MoveColumn,SetReplicaIdentity,SetTableComment, and the declarative partitioning ops below.- Partitioning:
CreatePartition(born-as-childPARTITION OF),AttachPartition/DetachPartition(subordinate an existing table, emancipate one back),PartitionTable/DepartitionTable(identity-preserving convert between a plain heap and a partitioned parent). These are Table-category ops, not a category of their own: partitioning is a physical storage attribute of a table that persists across the change, so the op acts on a table the way a rename or a comment does.
- Partitioning:
- Column:
AddColumn,DropColumn,RenameColumn,CastColumn,CopyColumn,SetDefault/DropDefault, identity ops (AddIdentity,DropIdentity,SetIdentityGeneration), NOT NULL ops (AddNotNull,DropNotNull),DropGeneratedExpression,SetColumnComment. - Constraint:
AddPrimaryKey,ChangePrimaryKey,DropPrimaryKey,AddForeignKey,DropForeignKey,AddUnique,DropUnique,AddCheck,DropCheck,RenameConstraint,ValidateConstraint, constraint-attribute ops (deferrable, initially-deferred, enforced, inherit),SetConstraintComment. - Index:
CreateIndex,DropIndex,RenameIndex. - View:
CreateView,ReplaceView,RenameView,DropView, and the materialized-view ops (CreateMaterializedView,ReplaceMaterializedView,RenameMaterializedView,DropMaterializedView). - Type:
CreateType,DropType,RenameType,AddEnumValue,RenameEnumValue. - Policy:
CreatePolicy,ReplacePolicy,RenamePolicy,DropPolicy,SetRowLevelSecurity. - Function:
CreateFunction,ReplaceFunction,RenameFunction,DropFunction,SetFunctionComment. - Trigger:
CreateTrigger,RenameTrigger,DropTrigger,EnableTrigger,DisableTrigger. - Extension:
CreateExtension,DropExtension(under the schema-root category). - Raw:
RunRawScript(DO $$ … $$;escape hatch). - Log:
Seal. Not a schema operation; pins log integrity.
The change operators (Rename, Cast, Extract/Join, Split/Merge, the partition converts, Copy, Move) are the ones a migration tool can’t express. Create/Add/Drop exist to bootstrap and trim the schema; the change operators are what drives architectural evolution.
The five data-motion change operators
(CopyTable, ExtractTable, JoinTable, SplitTable,
MergeTable) are authored via op add and replay/render
correctly through snapshot. Two verbs also recognize a subset:
drift surfaces Copy / Extract / Join / Merge (plus the
partition converts) as questions from an end-state diff, and import mints a solid-match ExtractTable from a
migration script. SplitTable is recognized by
neither from end-state, because its per-row predicate leaves no schema
residue; author it with op add. See drift.md and
import.md for the recognizer details.
SplitTable(the PRISM SMO listed here, a row-axis refactoring that consumes the source) is unrelated to PG’s declarativePARTITION OFclause (a physical storage attribute of a persisting table). Declarative partitioning is owned by the five partitioning ops (CreatePartition,AttachPartition,DetachPartition,PartitionTable,DepartitionTable), not bySplitTable. The distinction is which table survives: a split consumes its source and forks lineage into siblings, while a partition convert keeps the parent’s identity and reorganizes its storage.INHERITS+CHECKis likewise a partition signal, never a split.
Authoring ops: import vs drift
Two ingress verbs, opposite postures. import is imperative:
it reads a .sql source and appends one op per recognized DDL
statement, with no inference. drift is declarative: it
reads a .sql source as the database’s current state and emits
the ops that converge the oplog to that state.
Decision rule: “Have I authored operations I want recorded?” →
import. “Has the database moved without me, and I want the oplog
to catch up?” → drift. See import.md and
drift.md for the full per-verb behavior.
Schema identity and the resolver
A retrofit project can hold one schema or many. Every schema has four properties; the CLI’s job is to map a verb invocation to a single registered schema by reading those properties.
- Name: the registry key. Case-insensitive (PostgreSQL unquoted-identifier rule).
- Path: the
[[schema]].pathinretrofit.toml. Where the oplog file lives. - Scope: directories that resolve to this schema. Implicit
from
dirname(path), plus optional explicit overlays in the top-level[directory_scope]TOML table. - Default pin:
default_schemainretrofit.toml. Last resort for schema-context verbs.
The resolver chain
Every schema-context verb (op*, snapshot, migrate,
reconcile, drift, import, schema move, schema engine,
schema scope add, schema set-default) runs the same six-step
chain. The first step that returns a schema wins:
--schema <name>flag.-f <path>(mutex with--schema). With a registry, looks up[[schema]]by path; without a registry, opens the file directly.[directory_scope]longest-prefix match against cwd.- Implicit oplog-dir longest-prefix match (
dirname(path)). default_schemafallback.- Refuse with
default-schema-unset.
In a single-schema project, step 5 carries the load and the user
types nothing. In a monorepo where each schema’s oplog lives in
its own subdir, step 4 carries it; again there is nothing to type. The
[directory_scope] table at step 3 is the explicit overlay when
your source dirs and oplog dirs don’t line up.
--chdir, -C <path> is the root-persistent flag that reroots cwd
for the whole run. It affects steps 3 and 4, the retrofit.toml
discovery walk, and any relative path passed to a verb.
Never silent at resolution
Every schema-context verb names its resolved schema:
- Text mode: the registered
schema-contextadvisory renders as anote: schema-context: <name>line on stderr before the verb’s main output. - JSON mode: a
"schema":"<name>"field on the terminal{"type":"ok",...}envelope.
-f <path> is a path-as-identity selector: direct reads suppress
the preamble (no resolver leg fired). In CI, set
RETROFIT_REQUIRE_EXPLICIT_SCHEMA=1 to refuse with
explicit-schema-required when neither --schema nor -f is
passed. The default is permissive so single-schema projects keep
zero ceremony.
Three lazy-create doors
Scaffolding a new schema does not need a schema create first.
Three authoring entry points lazy-create the schema when the
explicitly named schema isn’t yet registered:
retrofit import --schema <new> <source>: mints the oplog, registers[[schema]], auto-pinsdefault_schemaon the first-ever schema, then imports the DDL.retrofit op add CreateSchema <new>: the bare-form symmetry path.retrofit schema create <new> [<location>] [--scope <dir>...] [--is-default] [--engine-version <X>]: the rich-form authoring path with project-setup flags.
Lazy-create fires only on an explicit name; a schema the resolver
chain reaches is already registered. A typo on --schema mints a new
schema; recovery is schema remove. See
getting-started.md for the typical flows.
Stale data references and repair
A preserved data record is captured against the schema as it stood at its anchor: the op it is filed under. The schema keeps moving after that. By the time the record is emitted, a column it names may have been renamed, its table may have been renamed or dropped, or a column may have gained a constraint the stored rows cannot satisfy.
That is decided per record, at emit time, and the stored file is never rewritten. There are three outcomes:
- The table is gone. The record is omitted and reported as excluded,
cause
table-dropped. Its rows have nowhere to land, so there is nothing to emit and nothing to fix. - The record can be made true. The emitted text is rewritten
through the oplog: a renamed table or column is emitted under its
current name, and a column dropped from an
INSERTcolumn list (or anUPDATE ... SETleft-hand side) is removed along with its value. The emitted body carries a comment naming each repair, one courtesy line per repaired table lands on stderr, and a machine-readable record rides the JSON stream. Your data file on disk is untouched. - The record cannot be made true. A column the body reads was
dropped, a new
NOT NULLcolumn has no default, a cast is lossy, two captures of one table have forked. The run exits non-zero withstale-dml-reference, naming every refused record and its reason; the hint names the recourse: re-capture the table, or for a forked chain retire one file.
Repair follows identity, not names. Two references name the same thing when they resolve to the same object in the schema at the record’s anchor, so a table renamed twice still repairs, and a column dropped and re-added under the same name is refused rather than silently reused.
A name retrofit does not model (a function, a sequence, an unmanaged
table) is left exactly as written. --verbose lists them per body so
you can see what was passed through untouched.
Capturing from a corpus label
Rows captured under a cohort label (data capture <source> --label <l>)
land on that label’s seed[<l>] chain. Once the table is declared
reference data (schema ref-tables add <t>), data capture --from-label <l> [--table <t>] promotes them: the corpus is the source, so no file
operand is given and no database is read. The verb folds the label’s chain
for each selected table at the current head and captures the result on the
ref band as one literal INSERT with an explicit column list, each value
copied from the stored records. Every selected table is checked before
anything is written: an undeclared named table, a chain that does not
fold offline, or an empty fold refuses the whole run. The seed chain itself
is left as it is.
Which point a record is rendered against
A record is rendered against two points in the oplog: its anchor, and the point the verb is emitting for. Only the second one moves, which is why one stored file can emit verbatim, repaired, or refused across runs without changing on disk.
migratehas a window,--fromto--to, and emits the records whose anchor falls inside it, each rendered against the window’s end. A record anchored before--fromis not emitted at all; adml-behind-windowwarning names it so nothing is dropped silently. Widen the window if you meant to include it. An anchor the oplog does not contain cannot be placed in any window, and the run stops withdml-anchor-unresolved.snapshothas no window.--at <opid>is the one point it renders for (the tail when omitted), and every opted-in record is rendered against it.--atthe record’s own anchor therefore emits the stored body byte-for-byte;--ata later op emits it repaired, or refuses, as the ops in between require.- On
snapshot, a record whose anchor precedes the history you have (an oplog that starts after it, or an op it never contained) has no schema to be resolved against. Its names are left exactly as written and the body is emitted verbatim, exit 0;--verboselists them as unmanaged references, the same way an unmodelled name is listed.
Attesting a stale reference
--allow-stale-dml on migrate and on snapshot downgrades every
refusal in the run to a warning: repairs are still applied, the body is
emitted under a -- WARNING: stale reference comment naming the reason,
and the run exits 0. It is a deliberate override, not a repair: you are
asserting you know the emitted SQL is right anyway.
One case it does not cover: two data files claiming the same table under one label set through a rename (a forked chain). There is no emission that is correct for both, so the run refuses with or without the flag. Retire one of the files and re-run.
Consents
A consent is an input that clears a refusal a verb would otherwise raise. Every consent is one spelling, naming one fact, in one kind, and the spelling is the same token on every verb that takes the fact. There are two kinds:
- apply context: a standing fact about where the script runs. The flag takes no value.
- sealed history: operations a Seal attested are rewritten. The side-effect
arm (
reconcile,import --force) is attested one Seal at a time, by opid:--allow-sealed-rewrite=<seal opid>, repeatable and comma-separated. A value attests the Seal it names and selects nothing; a Seal left unnamed refusesseal-removal-refused. The same kind, as the command’s own named act, is taken byop unseal,op truncate --drop-sealsandschema remove(unless--keep-oplog), which take no spelling: the command is the attestation.
No consent is needed to emit an authored drop, whatever it drops. No consent is asked, either, for rows a rendered step loses (a down that drops a column written since, or narrows a type and rounds) or for a rule a rendered step re-imposes for PostgreSQL to check at apply; the render’s report and script name each.
The table below holds every consent and every outside row, each with its class.
An outside row clears a refusal, or answers a warning as
--allow-unnamed-label does, but accepts no fact of a kind above. The code
holds the same table; a test checks this one against it.
| Kind or class | Fact or what it does | Spelling | Verbs |
|---|---|---|---|
| missing input | supplies what the request left open; clears only the refusal naming it missing | [directory_scope] | config key |
| resource bound | a project property bounding what a run reads | data_band_max_bytes | config key |
| missing input | supplies what the request left open; clears only the refusal naming it missing | default_schema | config key |
| apply-context declaration | declares where the script runs, selecting what the render emits; not a fact accepted | engine_version | config key |
| apply-context declaration | declares where the script runs, selecting what the render emits; not a fact accepted | envelope | config key |
| inverse polarity | an opt-in refusal; turning it on adds a refusal | lock_timeout | config key |
| missing input | supplies what the request left open; clears only the refusal naming it missing | EDITOR | environment variable |
| missing input | supplies what the request left open; clears only the refusal naming it missing | RETROFIT_NOEDITOR | environment variable |
| inverse polarity | an opt-in refusal; turning it on adds a refusal | RETROFIT_REQUIRE_EXPLICIT_SCHEMA | environment variable |
| missing input | supplies what the request left open; clears only the refusal naming it missing | VISUAL | environment variable |
| missing input | supplies what the request left open; clears only the refusal naming it missing | --chdir | every verb (root flag) |
| missing input | supplies what the request left open; clears only the refusal naming it missing | --schema | every verb that resolves a schema |
| ingest coverage | covers DDL retrofit cannot represent on ingest | --allow-capability-gaps | drift |
| mode or selector | selects a mode or a subset of the run | --ignore-drops | drift |
| missing input | supplies what the request left open; clears only the refusal naming it missing | --interactive | drift |
| mode or selector | selects a mode or a subset of the run | --on-question | drift |
| ingest coverage | covers DDL retrofit cannot represent on ingest | --allow-capability-gaps | import |
| mode or selector | selects a mode or a subset of the run | --force | import |
| corpus disposition at oplog replacement | removes the corpus records a replaced oplog orphans; attests no Seal | --purge-orphaned-corpus | import |
| mode or selector | selects a mode or a subset of the run | --force | init |
| apply context | the apply context owns atomicity | --allow-external-atomicity | migrate |
| authored data-corpus content | accepts authored data-corpus content, as shipped | --allow-reading-replace-body | migrate |
| authored data-corpus content | accepts authored data-corpus content, as shipped | --allow-stale-dml | migrate |
| authored data-corpus content | accepts authored data-corpus content, as shipped | --allow-unclassified-dml | migrate |
| dataset left unnamed | attests that a labelled dataset is left out of the run on purpose; answers the not-named warning, clears no refusal | --allow-unnamed-label | migrate |
| window scope | widens the migration window the run reads | --allow-unreached-dml | migrate |
| authored data-corpus content | accepts authored data-corpus content, as shipped | --allow-unscreened-dml | migrate |
| apply-context declaration | declares where the script runs, selecting what the render emits; not a fact accepted | --envelope | migrate |
| inverse polarity | an opt-in refusal; turning it on adds a refusal | --lock-timeout | migrate |
| apply-context declaration | declares where the script runs, selecting what the render emits; not a fact accepted | --using | migrate |
| missing input | supplies what the request left open; clears only the refusal naming it missing | --interactive | op revise |
| mode or selector | selects a mode or a subset of the run | --accept-dirty | reconcile |
| authored data-corpus content | accepts authored data-corpus content, as shipped | --allow-reading-replace-body | snapshot |
| authored data-corpus content | accepts authored data-corpus content, as shipped | --allow-stale-dml | snapshot |
| dataset left unnamed | attests that a labelled dataset is left out of the run on purpose; answers the not-named warning, clears no refusal | --allow-unnamed-label | snapshot |
| mode or selector | selects a mode or a subset of the run | --include-drop | snapshot |
| apply-context declaration | declares where the script runs, selecting what the render emits; not a fact accepted | --using | snapshot |
| read-only gate disposition | a read-only gate reports without failing | --allow-unmerged-fork | validate |
| inverse polarity | an opt-in refusal; turning it on adds a refusal | --fail-on-apply-risk | validate |
| sealed history | operations a seal attested are rewritten | --allow-sealed-rewrite | reconcile |
| sealed history | operations a seal attested are rewritten | --allow-sealed-rewrite | import |
| sealed history | the same, as the command’s named act | none: the command | op unseal, op truncate –drop-seals, schema remove (unless –keep-oplog) |
The emit envelope
Your runner applies the emitted migration, and retrofit cannot observe
whether it has opened a transaction around the file. --envelope (or
[project] envelope in retrofit.toml) states it, with three values: own (retrofit writes its own BEGIN;/COMMIT;; the default, see
migrate.md),
caller (you opened a transaction; retrofit writes none), and
independent (each statement applies on its own).
Two consequences follow:
- The value decides who owns the mid-file boundary. A statement like
ALTER TYPE … ADD VALUEneeds the enclosing transaction closed around it. Underownretrofit emits that boundary. Under the other two it cannot (a bareCOMMIT;would end your transaction), so it emits none and says so on stderr rather than shipping a file that fails at apply. - The file states its own obligation. Every emitted script carries
one
-- envelope: …line naming what the consumer must do. Its audience is the human at review and post-mortem time; no runner parses it.
The mapping from runners to values is yours;
migrate.md lists each runner’s value with its
conditions. A runner that wraps
only part of a file is out of scope
(migrate.md):
no fourth value could describe it, because the cut lives in the
runner’s configuration.
Output contract
Every verb’s stdout/stderr is structured. --as=text is the default;
--as=json emits NDJSON: zero-or-more intermediate {"type":"<name>",...}
records followed by exactly one terminal {"type":"ok"} or
{"type":"failed",...} record. The single type discriminator names
every record on stdout. Errors land on stdout (not stderr) so a single
jq filter handles success and failure.
The full contract (which helper to call, per-verb shapes) is in
cli-output-contract.md.
What retrofit is not
- Not a migration runner. It emits DDL; running it against your
database is your job (or your CI’s).
retrofit migrateemits a forward script between two cuts (or the reverse, with--down), but it still writes to stdout; applying it is your pipeline’s responsibility. - Not a schema-as-code DSL. Schema-as-code tools have you author target state and the tool diffs. Here the input is the ops, not the target.
- Not a backup. Sealed history is tamper-evident, not durable. Your oplog file is in your git repo; that’s where durability comes from.
- Pre-1.0: breaking changes land without back-compat shims and are called out in commits.