CLI output contract
This document is the canonical reference for what the retrofit CLI prints, where, and in what shape. It is the contract you script against.
The persistent root flag --as=text|json selects the output mode.
Stdout carries the disposition (a terminal envelope under JSON mode);
stderr carries advisories. Errors normally land on stdout under JSON so
a single jq filter handles both ok and error paths.
Channel exceptions lists the records that depart
from this rule.
The contract
The flag: --as=text|json
--as is a persistent root flag. It defaults to
text and applies to every subcommand. An unknown value refuses as
kind-mismatch (exit 2).
A handful of verbs accept legacy values for one-release backward compatibility:
op show --as=add|revise|json: deprecated; use--framing=log|add|reviseplus the persistent--as=jsonfor output mode. The legacy spelling emits adeprecated-flagwarning. (Default framing islog, the on-disk record grammar;addandreviseare the runnable-line framings.)op revise --as=revise|json: removed; the persistent flag supersedes it.reconcile -y / --yes / -i / --interactive / --force: removed; the mode flag (--clean/--accept-dirty) is the consent signal. These are registered as hidden aliases and exit 3 (flag-conflict) with a redirect message.reconcile --outputis not registered.reconcileexits 3 (flag-conflict) on any flag-parse error, an unregistered flag included; other verbs exit 2 for an unregistered flag.
The verbosity axis: --quiet / --verbose
-q/--quiet and -v/--verbose are persistent root flags
governing the human text advisory tier on stderr. They are orthogonal
to --as: --as picks the wire format, verbosity picks how much of
the advisory tier that format shows. -q+-v resolves quiet-wins
with no error. --verbose means the verbose level on every
command, op show included; no verb repurposes -v (op show’s
former local --verbose is spelled --annotate).
--quiet is a count: stack it to go quieter. --verbose stays a
plain flag: the ladder runs downward only, and there is no level
above verbose.
The ladder
Each position is defined by what it shows, not by how it is spelled internally:
| Invocation | What the human text surface shows |
|---|---|
-v | everything below, plus the diagnostics that are usually noise (the ones you ask for when something is already wrong) |
| (default) | the incidental lines that orient you (the schema-context advisory preamble, op add’s body findings, validate’s warning: lines) and everything below |
-q | drops those incidental lines; advisories about the artifact itself still print |
-qq | drops the whole advisory tier: every warning and every note about the work, whatever its subject |
--quiet also takes an explicit count (--quiet=2 is -qq), and
--quiet=0 is a legal way to say “not quiet”. The retired
--quiet=true / --quiet=false spellings are refused with a message
naming their replacement.
Never suppressible at any level (including -qq): all error:
output, every refusal, the JSON Success.Schema field, and the
result-carrying wire lines op seal sealed N ops as <OpID> and
snapshot engine_version:. No quiet level silences an error, a
refusal, or a command’s primary result, and none of them changes the
exit status.
One advisory outranks the ladder: internal-invalid-advisory, which
retrofit emits when the advisory machinery itself fails. It renders at
every level, because an advisory whose whole purpose is to report that
the tier is broken cannot be suppressible by that tier.
--as=json × verbosity is orthogonal: in JSON mode the ladder does
not apply; structured advisories ({"level":"note",...}) and the
Success.Schema field ride the envelope regardless of -q/-v/-qq,
so --as=json -qq drops nothing structured.
No --no-color flag: the output carries no color.
Repetition: --no-consolidate
When one advisory repeats within a run, the human text surface renders the repeats as one line carrying the first occurrence’s content, the number of occurrences, and the way back. Two or more occurrences collapse; a single occurrence renders unchanged.
The disclosure is part of the collapsed line, never a separate note: there is no path that reduces the output without saying so. The count includes the occurrence shown, so a group of twelve reads 12 occurrences rather than and 11 others, and a group whose occurrences do not all read alike says that too.
--no-consolidate is a persistent root flag that restores the full
list. It affects only verbs that raise a repeating advisory, so it
parses and does nothing on most of the tree.
Consolidation is a display transform only:
- it never changes the exit status;
- it is off entirely on a failing run, where every occurrence prints;
--as=jsoncarries every occurrence uncollapsed at all times;- it composes with the ladder without special cases:
--no-consolidatepicks what the tier would show, the ladder picks whether it shows anything, so-qq --no-consolidateprints nothing.
Attestation: answering an advisory instead of silencing it
No flag names an advisory code to turn it off. Where retrofit offers a way out of a recurring advisory, the flag names the fact that answers it, and the advisory then has nothing left to report.
migrate --allow-external-atomicity is such a flag. It states
that the apply context owns transaction atomicity, which answers the
no-wrapper advisory wherever that advisory arose from a delegated
envelope. On an attested run the advisory is not raised at all (in
text or JSON), while the emitted SQL and the exit status are
byte-identical to the unattested run.
An attested run records the attestation: the terminal envelope carries
data.external_atomicity_attested. Attesting one cause never silences
another: a run whose wrapper is also suppressed by
--down --into-schema still raises the advisory, and a note names the
--into-schema cause that survived.
When the fact never arose, the run prints a code-less note on stderr,
note: --allow-external-atomicity had no effect: this run's transaction wrapper was not suppressed by a delegated apply context, so the fact it attests never arose. The note is not a refusal, and the exit is
unchanged.
Resolved-schema surface
Every schema-context verb (op*, snapshot, migrate,
reconcile, drift, import, schema move, schema engine,
schema scope add, schema set-default) names its resolved
schema in output:
- Text mode: the registered
schema-contextadvisory renders as anote: schema-context: <name>line on stderr before the verb’s main stdout output (it rides the threaded advisory carrier; it is a courtesy-band note,-q-suppressible). Suppressed when-f <path>was used (path-as-identity bypasses the resolver) and when no schema-naming leg fired. - JSON mode: a
"schema":"<name>"field on the terminal{"type":"ok",...}envelope. The preamble is not repeated per intermediate record.
RETROFIT_REQUIRE_EXPLICIT_SCHEMA=1 makes the resolver refuse
with explicit-schema-required when neither --schema nor -f
fired. It is a CI hardening knob.
Stdout
text mode (default)
Verb-defined human output:
| Verb | Stdout |
|---|---|
op add | one + <opid> <Operator> <target> [<minted-annotation>] line per appended op (+ = minted; ~ = same-as-tail no-op note). Advisory notes ride stderr as note: <code>: <message> lines; see Advisory note codes |
op add --dry-run | one preview line per would-append op (same shape as the appended line; outcome prefix, no opid). A rejoinable ExtractTable previews as a single line: it records one op, and its rejoin family (rejoin columns, child PK, FK-back) is derived at render time rather than minted as follow-on ops. On stderr the preview raises every advisory the write path would raise for the same op, free-standing ones included (enum-value-commit-boundary, extract-survivor-arm-selected, extract-reverse-ordinal-divergence, extract-rejoin-key-added, schema-body-finding), each once; since no op was written, a schema-body-finding names the op (preview). |
op remove | silent on success |
op remove --dry-run | one bare opid per line for each removed op |
op revise | bare opid |
op revise --batch | one bare opid per edit |
op revise -i | one bare opid per surviving op |
op show | one line per op (default --framing=log: on-disk record grammar Operator(<opid>, prev=…, …). --framing=add for runnable replay lines, --framing=revise for revise lines). By default, sealed ops are hidden; pass --include-sealed to widen scope |
op seal | sealed <N> ops as <opid> on stderr, not stdout; stdout is silent |
op info <Operator> | reasoned reference view for one operator (summary, category, info-preservation, params with per-value engine gates, rules, min-engine version, example, related operators, spec ref; RULES gives each rule two columns, OP ADD for op add and the other commands that write an op and READING THE LOG for migrate, snapshot, validate, op seal and identify, each cell refuses or lets through), with a CATALOG ENTRY section after OVERVIEW for an operator that ships DDL (the catalog entry’s rows, identity, reverse, refusals, arms, aliases, tier and rendering, in its own words; while the entry’s design is withheld, a TODAY line in OVERVIEW in its place), then a steer to op add <Operator> --help for the syntax altitude. Unknown operator → unknown-operator error with a did-you-mean hint. |
op info --dump | one block per operator (name, category, info-preservation, min-engine, summary), name-sorted, with a lead line stating the operator count and pointers to the per-operator and --as json surfaces. |
init | silent on success |
schema create | one + <opid> CreateSchema <name> line (mints the schema’s first op) |
schema list | one <name> <path> <engine_version>[ (default)] line per registered schema, sorted by name; the engine_version is the resolved one (per-schema override, else [project]). An empty registry prints nothing on stdout and steers on stderr (courtesy band, -q-suppressible) |
schema ref-tables | one declared: <table> line per declared table bytewise ascending, then one skipped: <table> (partition child of <root>) line per glob-excluded attached child, then (on the edit forms) one <table> <entry> line per target, where <entry> is the minted or would-be SetReferenceData op in the on-disk oplog grammar, verbatim: SetReferenceData(<opid>, prev=<prev>, on=<element-id>, value=<bool>), the same line that lands in the oplog (a record, not a runnable command), or already-declared / not-declared where the name was a per-name no-op; then one disagreement line per table, then ok <schema> declared_tables=N disagreeing_tables=N dry_run=<bool>[ at=<opid>]. The bare parent and add/remove all print the complete resulting declared set, never a delta. |
schema remove | silent on success |
schema engine | one line: current engine pin, e.g. postgres:17 |
data list | one <schema.table> <state> floor=…[ witness=<token>] appends=N excluded=N(…) s3=N line per table; without --label only the ref band folds and each labeled chain counts under not-selected; a bare run prints the label inventory after the ref fold, as label <l> files=N lines in bytewise label order. witness= follows a floor only and carries the JSON floor.witness token verbatim. The floor spec carries a trailing removed=<k>[,<k>…] token only when a reference-chain replace floor dropped keys the prior chain state held (the JSON floor.removed_keys below, comma-joined), or removed=not-computable (<cause>) when that chain does not fold offline (a DELETE, an INSERT … SELECT, a DEFAULT marker at some record; <cause> is capture’s record locus, append at <anchor> (seq=N) <reason>), never a partial key list. A restate is not a floor and carries none, and a seed replace never carries it. s3=N counts the contributions anchored below a lower layer’s floor: an append or restate from a later --label layer, anchored at an earlier op than the floor the layers beneath it put on the table (the JSON s3[] below). It is information only: it changes no fold and refuses nothing, and s3=0 is the usual reading. Read-only. |
data capture / data retire | one captured … / retired … verdict per addressed table (already-captured / already-retired on an idempotent run). A dry run prints one would <disposition>: <table> (<rows>) rehearsal per table without writing; listed-but-unclaimed capture tables and their call to action follow the verdicts. A mint that replaces an occupied, unwitnessed key in place (every producer: import, drift, data capture, data retire, data scaffold) adds one overwrote: <schema.table>@<anchor> <labels> <path> <digest> (git retains the old bytes) line per replaced file, beside the overwrote @<anchor> verdict. A same-key append (an import) accumulates into the standing record instead of overwriting it, and reports one data-appended: <schema.table>@<anchor> <labels> <path> <digest> (+<N> statement(s)[, <M> replaced from <src-file>[, …]; git retains the old bytes]) line per rewritten file. The counts are the version’s, carried on its first rewritten file, and its other files read +0 statements; under --as=json it is {"type":"data-appended","table":…,"anchor":…,"labels":[…],"path":…,"digest":…,"added":N,"replaced":M,"replaced_from":[…]} (not appended, which is import’s minted-op record). An identical re-import reports neither. |
data scaffold | one dml-preserved: <path> (declared-axes scaffold, 1 inert record to fill) line when a file is minted; the carrier holds one worked, inert src=hand INSERT over the table’s live columns whose <column> placeholders data check refuses by name until filled (a purpose=cleanse carrier holds a DELETE … WHERE <predicate> instead), and migrate / snapshot refuse the unfilled record as dml-body-unfilled; a byte-identical re-run mints nothing. |
data check | data check: N file(s) checked, no findings on a clean corpus; when the run listed warnings and refused nothing, data check: N file(s) checked, no refusals; listed: <code> ×<n>[, …] instead, one entry per listed code (anchor-shape-mismatch, anchor-shape-unsupported, anchor-shape-absent, data-file-unfilled, data-file-unaddressed) sorted by code (notes such as witness-stale are not counted, and the exit stays 0); refused files are accumulated into one failure instead of stopping the walk. Corpus-LEVEL refusals ride that same single envelope rather than arriving as per-file findings: an unresolved record anchor refuses as data-file-anchor-unresolved with data corpus holds record(s) whose anchor no longer resolves, carrying its own hint (re-pin the named file with retrofit data repin <file> --anchor-op <opid>) instead of the aggregate’s re-capture recourse, which cannot re-pin an anchor. |
snapshot | full DDL document streamed to stdout |
migrate | full migration script streamed to stdout (BEGIN;/COMMIT;-wrapped by default) |
import | one + <opid> <Operator> <target> line per minted op + a terminal imported <N> ops; skipped <M> statements [(per-kind tally)] summary. Under --dry-run the lead verbs flip to would import / would skip so a tail-of-stdout reader can distinguish dry-run from real runs. Under --seal[=<text>] a trailing Seal op is appended with a reason field: bare --seal auto-derives imported <basename> (single source), imported <b1>, <b2>, … (multi-source argv order), imported <stdin> (stdin), each basename widened to the shortest trailing path unique within the batch when basenames collide (imported 001_a/up.sql, 002_b/up.sql; the same per seal under --seal-per-file); --seal=<text> overrides verbatim. When the skip set spans more than one disposition class, a trailing [<class> N, …] breakdown (sorted by class name) is appended so a text reader can tell the loss-bearing gap/integrity skips from the preserved DML and decision no-ops that exited clean; a single-class skip set omits the clause, since it would only restate the total. When EVERY skipped statement is preserved (non-SMO DML set aside to a data file, so nothing was dropped and the run exits 0), the lead verb becomes preserved <M> statement(s) instead of skipped <M> statements, since the mixed-set breakdown that would otherwise disambiguate is suppressed on a single-class set; the count itself is the unchanged aggregate either way, and --dry-run flips this verb to would preserve like the others. Under --seal-per-file a ; sealed <N> ops across <M> sources clause is appended; when some source minted no ops (and so was not sealed) a trailing (<K> sources minted no ops, so were not sealed) parenthetical accounts for the difference between the input count and <M>. Advisory lines are interleaved on stdout, not stderr (see Channel exceptions): one note: deferred data-motion op (resembled <SMO>): … line per deferred data-motion op, and, when any minted op is apply-risky, one run-level warning: <N> apply-risk ops minted (validating constraints / row-touching alters); review before applying to a populated DB; by kind: <slug> N, … summary line (one per run, never one per op). |
drift | leads with a drift plan: N additions, M removals[ (K deferred)], Q questions header line (text mode; JSON emits a {"type":"plan-header",...} first record); then one stream line per event (+ op-applied, ? question, ~ skipped, ! deferred-drop from --ignore-drops); see Stream line grammar; in interactive mode, the editor buffer is the per-question surface. --show={both|additions|removals} is a presentation-only filter on the event stream; the plan header, terminal summary, and oplog writes are unaffected. |
reconcile (bare) | four-section report (SUMMARY / forks / next-steps / halt diagnostic) |
reconcile --clean / --accept-dirty | text diff before commit; nothing after commit |
reconcile --all | text diff per fork + reconciled fork prev=… (N remaining) lines |
Stream line grammar
The verbs that emit a per-event stream (op add, op add --dry-run,
import, schema create, drift) share one line shape:
<prefix> <opid> <Operator> <bound-target> [(<annotation>)]| Field | Width | Notes |
|---|---|---|
<prefix> | 1 char | + minted / drift op-applied, ~ op_add same-as-tail note / drift skipped (see ~ overload note), ? drift question, = op_add deduped (a no-op that writes nothing) |
<opid> | always non-empty | real short-id (op_add +/~, drift op-applied), (preview) for op add --dry-run and drift pre-mint, (skipped) for drift ~ skipped and op_add = deduped (no op exists); see ~ overload note |
<Operator> | 18-char min | operator name; drift question wraps as <Operator?>. Drift ~ skipped rows repurpose this slot for a single-token skip-kind: either a dashed slug from drift’s differ (add-pk-unresolved-column, comment-column-unresolved, …) or a CamelCase pg_query AST node name from sqlparse.StmtKind (AlterTableStmt, IndexStmt, …), never a multi-word phrase. The slot is whitespace-free across every prefix, so awk '{print $3}' is awk-stable for the operator column on every row. |
<bound-target> | varies | dotted target |
<annotation> | optional, parenthesized | (constraint <name>), (index <name>) for op add mints |
Column invariant: awk '{print $1}' returns the prefix and
awk '{print $2}' returns the opid for every stream line. The opid
column is never empty: placeholders fill the slot for pre-mint and
no-op rows.
~ is overloaded across two distinct shapes that share the grammar
but populate different slots:
- op_add same-as-tail:
~ <opid> <Operator> <target>with a real opid (the existing tail’s short-id) and a catalog operator. - drift skipped:
~ (skipped) <SkipKind> <target>with the literal(skipped)placeholder in the opid slot and a single-token skip-kind in the operator slot: a dashed slug (add-pk-unresolved-column,comment-column-unresolved, …) or a CamelCase pg_query AST node name (AlterTableStmt,IndexStmt, …). Always whitespace-free.
Tools that switch on the operator column should also key off the
opid value ((skipped) vs. a 12-char short-id) to disambiguate the
two ~ shapes.
json mode
Stdout is NDJSON, and cardinality decides the envelope. The common
case is a stream of one-JSON-object-per-line records, all keyed by a
single type discriminator: each line is either an intermediate
{"type":"<name>",...} record or the terminal
{"type":"ok",...} / {"type":"failed",...} / {"type":"unverifiable",...}
record. The terminal record is always emitted on success or graceful
failure; its absence means the process was killed.
[ {"type": "<intermediate-name>", ...} ]* # zero or more intermediate records
{"type": "ok", ...} # OR
{"type": "failed", "code": "...", ...} # OR
{"type": "unverifiable", "code": "...", "entries": [...], ...}The shape lets a single jq filter handle every path:
retrofit X --as=json | jq 'select(.type == "ok" or .type == "failed" or .type == "unverifiable") | .type'unverifiable is the third terminal type. The oplog is readable
but carries a rule this build cannot evaluate; the run exits 2. A broken
sealed prefix is sealed-oplog-unparsable instead, exit 1.
A standing rule violation dominates: if any violation stands the
record is failed, whatever else the run observed. unverifiable is
reached only when nothing stands and something could not be asked.
Its entries array lists each unevaluable rule as
{"rule": "<code>", "opid": "<opid>", "reason": "<why>"}, so “this
oplog has an unanswerable question” is actionable: it names which
question and where.
A consumer matching only
okmust handle bothfailedandunverifiable.
violations is an extension array on a failed envelope, beside the
unverifiable/entries precedent above. It appears on an accumulating
refusal: one that evaluates every argument and reports each failure rather
than stopping at the first:
{"type": "failed", "code": "<covering-code>",
"violations": [{"argument": "<arg>", "code": "<per-failure-code>", "detail": "<why>"?}, …]}Where entries is homogeneous under one code, violations is
heterogeneous: each entry carries its OWN code beside the argument it
refused, and the envelope’s code is the covering code naming the
envelope’s KIND rather than the first failure’s, so a consumer branches per
entry rather than reading the first failure as the whole answer. Entries are
ordered as the arguments were given. detail is omitempty.
retrofit schema ref-tables’s ref-tables-rejected is the shipped
covering code; its entries carry
ref-table-not-live, ref-table-pattern-qualified or
reference-data-partition. retrofit data capture --from-label’s
from-label-refused is the second: a run refusing more
than one selected table carries one entry per refusal, each with its own
from-label-* code (from-label-undeclared, from-label-not-foldable,
from-label-empty, from-label-no-chain-for-table); a run refusing once
keeps that refusal’s own code on the envelope. The field is
omitempty, so an envelope with no entries carries no violations key.
Single-document exception. A verb whose entire success stdout is a
SINGLE JSON value emits that value bare: no leading type key, no
terminal ok line. The type discriminator earns its place only when
two or more values share the stream and a consumer must tell them apart;
on a one-value document it discriminates nothing, so it is omitted and
json.load(stdout) / jq '.' sees the payload directly. The verbs are
op info --dump --as=json (the {"operators":[...]} catalog document),
op info <Operator> --as=json (the single-operator descriptor), and
identify --as=json on an exact match (the outcome/opid/seal
document).
The graceful-failure path is unchanged: a failure still emits the
terminal {"type":"failed",...} envelope. So a consumer that may hit
either path decodes the first JSON value and inspects its type: absent
or non-failed means the success document, failed means the error.
Per-verb shapes:
| Verb | Stdout under --as=json |
|---|---|
op add | {"type":"appended",...} per appended op + terminal {"type":"ok",...}; a deduped SetReferenceData streams {"type":"skipped",...} in place of appended. See op add records. |
op add --dry-run | {"type":"preview",...same fields,"outcome":"would-mint|would-dedupe"} + {"type":"ok"}. would-mint mirrors minted; would-dedupe mirrors noted pre-write. The stderr advisories are the write path’s, carried the same way (see the text-mode op add --dry-run row). |
op remove | {"type":"ok","data":{"removed":["<opid>",...]}} |
op remove --dry-run | {"type":"preview","opid":"..."}* + {"type":"ok"} |
op revise | {"type":"ok","opids":["<opid>"]} |
op revise --batch | {"type":"ok","opids":["<opid>",...]} |
op revise -i | {"type":"ok","opids":["<opid>",...]} (no per-retry envelopes) |
op show | {"type":"item","operator":"...","opid":"...","prev":"...","sealed":true|false,"values":{...},"_meta":{...}?}* + {"type":"ok","data":{"count":N}}. sealed is true iff the op is inside a sealed range, the Seal record included; it is false on every unsealed-tail op and on every op of a log with no Seal. Text framings carry no sealed marker. |
op seal | {"type":"ok","opids":["<seal-opid>"]} |
op info <Operator> | single bare descriptor object {"name":"...","summary":"...","category":"...","params":[...],"rules":[{"kind":"...","when":"...","binding":"...","code":"...","param":"...","note":"...","enforcement":{"append":"...","replay":"..."}}],...} (enforcement is derived from when: both keys are always present, each refuses or lets-through, saying what happens to a violation when an op is added and when the log is read) plus, for an operator that ships DDL, entry (the catalog entry, snake-case keys; a withheld entry’s name and today only) and down_coverage ("covered" or "none": whether a migrate --down ghost row inverts the operator; "none" refuses the window, and a "covered" window can still refuse by name); no type key, no terminal ok (single-document exception). Unknown operator → terminal {"type":"failed","code":"unknown-operator",...}. |
op info --dump | single bare document {"operators":[{...descriptor},...]} (name-sorted); no type key, no terminal ok (single-document exception). |
identify | on an exact match, single bare document {"outcome":"exact","opid":"...","seal":"<name>|null"}; no type key, no terminal ok (single-document exception). seal is null both when the resolved opid is not a Seal and when it is a Seal carrying an empty reason, so null is not proof of a non-Seal opid. An ambiguous or unmatched reference is a terminal {"type":"failed",...} instead, so outcome is exact on every success document. Under --expect <ref> an exact match that resolves to a cut OTHER than the one named is also a terminal {"type":"failed",...} with code expect-mismatch, carrying the asserted cut as a top-level expected opid alongside the resolved one in message, so a gate branches on the code rather than on the emitted script. --expect passed with an empty value refuses expect-empty rather than reporting the ungated cut, so a gate whose cut variable expanded to nothing fails loudly instead of passing for any cut. --expect naming a cut no dump is identified at (root) refuses expect-unidentifiable: the assertion is unanswerable rather than false, so it does not report as a mismatch. A dump holding neither the (non-public) schema nor any of its objects is a database at root, and refuses from-dump-schema-absent with a hint naming migrate --from root, ahead of any --expect. A dump matching no cut refuses from-dump-behind when operations follow its nearest cut, carrying them as a top-level ops_after array (opid, operator, target, sealed, destructive); from-dump-working-copy when nothing follows it and its divergence changes an element an unsealed operation created or changed; and from-dump-no-match otherwise. Only from-dump-no-match’s hint names drift, and not when drift would plan nothing against the nearest cut: then detail lists the facets that did not pair (cut only: / dump only: lines) and the hint is a note:. |
init | {"type":"ok","data":{"config_path":"...","gitattributes_path":"..."?}} |
schema create | {"type":"appended",...CreateSchema fields} + {"type":"ok","data":{"opid":"<create-schema-opid>","path":"<oplog-path>"}} |
schema list | {"type":"item","name":"...","path":"...","engine_version":"...","default":bool}* (name-sorted) + terminal {"type":"ok","data":{"count":N}}. An empty registry is a success: no item records, count 0 |
schema ref-tables | {"type":"table","table":"..."} bytewise ascending, then {"type":"skipped","table":"...","root":"..."} for each glob-excluded attached child, then (on the edit forms) {"type":"entry","table":"...","value":bool[,"note":"already-declared|not-declared"][,"opid":"<opid>"][,"entry":"SetReferenceData(<opid>, prev=<prev>, on=<element-id>, value=<bool>)"]} per target, where entry is the on-disk oplog line verbatim (the text form’s <entry>), a per-name no-op carries note and neither entry nor opid, and --dry-run omits opid; then {"type":"disagreement","table":"...","purposes":[...],"category":"...","records":N[,"not_at_cut":"<opid>"]} in table order, then {"type":"ok","schema":"...","data":{"schema":"...","declared_tables":N,"disagreeing_tables":N,"dry_run":bool[,"skipped_tables":N][,"at":"<opid>"]}}. The shape is identical for the bare read and edits. |
schema remove | {"type":"ok"} |
snapshot | {"type":"snapshot-data",...}?, {"type":"snapshot","ddl":"<full doc>"}, {"type":"data-excluded",...}*, then terminal {"type":"ok"}. See snapshot records and snapshot.md. |
migrate | {"type":"migrate","ddl":"<full doc>"} + {"type":"ok"}. The data-band reports ride the same stream in a fixed order after the migrate record and before ok: data-repaired, data-type-changed, then data-excluded*: what the reader GOT, then what they did not. See Data-band records. |
import | {"type":"appended",...}* per minted op, {"type":"skipped",...}* per dropped statement, stdout advisories, an import-summary record, then terminal {"type":"ok","data":{...}}. See import records. |
drift (--dry-run / non-interactive) | leads with {"type":"plan-header","additions":N,"removals":M,"removals_deferred":K,"questions":Q} as the first stream record (removals_deferred is always present, 0 when --ignore-drops isn’t passed, so the schema is stable); then {"type":"op-applied","operator":"...","target":"...","element_id":"...","source_line":N,"warnings":[{"kind":"...","code":"...","from_type":"...","to_type":"...","message":"..."}]?} per minted op. warnings[] carries two entry shapes, distinguished by kind: the narrowing-cast advisory (kind=narrowing-cast, code=data-loss-on-apply) is the only one with from_type/to_type, while an apply-risk advisory carries just kind/code/message and omits from_type/to_type as cast-specific; on those entries kind repeats the code (e.g. both apply-risk-add-not-null). Both shapes are mode-exclusive: under text mode they surface on stderr as warning: <code>: <message> instead, and JSON mode keeps them single-channel on warnings[]. The stream then continues with {"type":"question","class":"...","target":"...","candidates":[...]} per editor-resolution prompt + {"type":"skipped","kind":"...","name":"...","schema":"...","line":N,"raw_text":"...","class":"gap|decision|preserved|internal-defect","reason":"..."?,"warnings":[{...}]?} per dropped stmt. class is the skip class as it streams (the shared classifier import uses, keyed on the RAW kind and the optional reason), and it is the SKIP class even on an unresolved-question:<class> skip, whose question class stays on the question record. Where an editor answer later settles a root a dependent was attributed to, the dependent streams decision while drift-run-summary.skipped_by_class carries the terminal’s gap. The optional warnings[] uses the same five-key entry shape as the op-applied narrowing-cast advisory and appears on the inverse constraint-validity skips (fk-cannot-un-validate, check-cannot-un-validate), where a VALID→NOT VALID change has no PG DDL surface, so drift skips it and explains why on the record. Then come, under --ignore-drops, {"type":"deferred-drop","operator":"<Drop*>","target":"...","element_id":"...","source_line":N} per suppressed Drop op (same operator/target the authored event would have carried; element id preserved so re-running without --ignore-drops mints the equivalent op against the same element. The element id is the stable half; the op’s own opid is minted per act, so the re-run’s op carries a new opid) + {"type":"drift-summary","appended":[...],"drop_add_count":N} + {"type":"drift-run-summary","drifted":N,"questions":N,"skipped":N,"skipped_by_kind":{"<raw kind>":N,…},"skipped_by_class":{"<class>":N,…},"applied":N?} (plus a trailing capture member) on every run that reaches the terminal (members in this order; applied and capture on a real run only; skipped_by_class omits a zero class; the record is identical with or without --allow-capability-gaps) + terminal {"type":"ok","data":{"applied":N?,"drifted":N,"questions":N,"skipped":N,"preamble":N?}} (the per-kind tally and the capture report are on drift-run-summary). When any gap skip stands, the run fails after the same drift-run-summary: the terminal is {"type":"failed","code":"drift-unreconciled-skips","message":"...","hint":"..."} instead of ok (so a failing run carries no preamble count), exit 2; see Exit codes. --allow-capability-gaps (bool, default false) waives that arm with import’s meaning: exit 0, terminal ok, drift-run-summary unchanged. --show={both|additions|removals} filters the per-item event stream only; the plan-header, drift-summary, and terminal ok envelopes are unaffected. A question record’s candidates[] may omit accept (the survivor-arm extract-table extract offers only {"action":"drop-add"}), and no field on the record says why: accept-absence is authoritative on the item, while the REASON is authoritative on the advisory channel (a warning-level record on stderr), so a plan-stream-only consumer must read stderr to learn it. Drift converges the reference tables declared at its new head on every run (no flag): when a table is declared, or the corpus holds a reference chain whose table no longer is, the stream also carries one {"type":"not-captured","table":"...","rows":N,"cause":"...","statements":{...}} per table the run lists rather than captures (a COPY … FROM stdin band on a declared table is cause: "not comparable: COPY band" with a hint naming pg_dump --inserts), one {"type":"not-compared","table":"...","removed_at":"<opid>","hint":"..."} per reference chain whose table’s declaration was removed, and at most one {"type":"not-observed","count":N,"tables":[...]} for the declared tables the source states no rows for (absence is never drift); a real run’s drift-run-summary record carries a capture member with tables[] (per-table disposition, rows, rows_added/rows_removed, changed_column, not_comparable), not_observed (a count) and not_compared (present when non-empty). A captured table that lost a key since its chain raises the reference-key-lost warning (table, count, name = the lost keys) beside the capture, as data capture does; a declared table observed empty (replace body=none) raises the reference-table-emptied warning (table), as data capture does. The exit code does not depend on row drift. |
data list | {"type":"item","table":"<schema.table>","state":"occupied|empty|composed|unspoken|none|dropped","floor":{...,"witness":"<token>","removed_keys":[…]?,"removed_keys_cause":"<cause>"?}|null,"appends":[{...,"witness":"<token>"},…],"excluded":[{"cause":"...","anchor":"...","file":"..."},…],"s3":[{"label":"...","anchor":"...","lower_label":"...","lower_floor_kind":"...","lower_floor_body":"...","lower_floor_anchor":"..."},…]} once per table, in bytewise table order, then {"type":"ok"}. s3[] lists the contributions anchored below a lower layer’s floor, one entry per contribution; the text line’s s3=N is its length. label and anchor name a later --label layer’s append or restate, and lower_label, lower_floor_kind, lower_floor_body and lower_floor_anchor name the floor the layers beneath it put on the table, whose anchor is the later op of the two. lower_label is the layer that placed that floor, spelled as floor.label spells it: ref for the reference band, the --label value otherwise. snapshot and migrate report the same fact under -v as the data-layer-below-floor note. floor.removed_keys is present only when a reference-chain replace floor dropped row-identity keys the prior chain state held (each key its key cells joined by ,, in chain order) and is absent, never [] or null, otherwise; a witnessed and an unwitnessed record render it identically, since it is read from the corpus, not the witness overlay. floor.removed_keys_cause (string) is present only when the chain does not fold offline, and spells the text row’s <cause>; removed_keys is then absent, never a partial list. data witness notes the same datum as witness-removed-key: <table>@<anchor> (<keys>) or … removed keys not computable (<cause>) (structured: keys or cause). witness is the per-record token, witnessed <opid> | witness-stale | unwitnessed, on the floor and on every append; a witness finding never refuses data list, it reads here instead (see data.md). A bare run with no --label emits {"type":"labels","present":[…]} (each entry {"label":<l>,"files":N}) after the per-table fold (the ref band; labeled chains read not-selected) and before {"type":"ok"}. data list is the consumer-side dry run of the data band: it emits no SQL and writes nothing. See snapshot.md. |
data capture / data retire | JSON closes with {"type":"ok","schema":"...","data":{"tables":[...]}}. A captured/retired table row carries table, disposition, rows, band, labels, and delta:{added,removed}, plus file, supersedes_anchor (with supersedes_cause = no-upsert-form | key-uncovered when an unchanged rowset still supersedes a floor that cannot render at its anchor), overwrote_anchor, moved_from:"append" when a capture of an append’s own source moves the key back to a floor, or cause when applicable. A listed capture row carries only table, disposition:"not-captured", rows, cause, and statements. Intermediate records ride the stream ahead of that terminal envelope, and which ones depends on the arm. A WRITING run emits {"type":"skipped","name":"...","rows":N,"kind":"<cause>"} per manifest entry (the --as=json substitute for text mode’s verdict lines), then {"type":"not-captured","table":"...","rows":N,"cause":"...","statements":{"<kind>":N,…}} per listed-but-unclaimed table, then a single {"type":"cta","cta":[...]} block when at least one table was listed. The cause is one of not declared (an undeclared table, no --label), no rung (a declared table whose statements the fold refuses, so no rung claims them), filtered by --table or cross-schema; the manifest entry’s kind carries the same word, so no rung is a per-table manifest class with the same key set as the others. The listed tables[] row carries the same statements breakdown. A DRY run emits the rehearsal records below INSTEAD: not-captured is a rehearsal disposition there, not a record type of its own, and no cta record is emitted. |
data scaffold | {"type":"dml-preserved","path":"...","count":1,"scaffold":true} when a carrier is minted (count is the one inert record the carrier holds, to be filled); a same-key replacement may add data-file-deleted records for surplus pages and one {"type":"overwrote","table":"<schema.table>","anchor":"...","labels":[…],"path":"...","digest":"sha256:…"} record per replaced file (the same record rides every producer’s stream). No rehearsal: scaffold has no --dry-run. |
data check | {"type":"data-check","checked":N,"listed":{"<code>":N,…}} when nothing is refused: listed counts each warning the run listed, by code (the text row’s codes), and is present and {} when nothing was listed; a CI step gates on it, since the listed warnings leave the exit 0; an accumulated refusal uses the normal terminal failed envelope with per-file findings. A corpus-level refusal rides the same terminal envelope under its own code (data-file-anchor-unresolved for a record whose anchor-op= no longer resolves), with the offending <file>: record seq=<n> anchors on <opid> lines as its detail. Per-file and corpus-level findings compose into ONE envelope; two are never emitted for one run. |
data check (per record) | {"type":"data-check-record","table":"<schema.table>","anchor":"<opid>","labels":[…],"witness":"witnessed <opid>|witness-stale|unwitnessed"} once per checked record, before the summary. The witness member is data list’s, token for token. The {"type":"data-check","checked":N,"listed":{…}} summary record follows them; a consumer reading the stream undifferentiated sees a new row type, so select the summary by .type == "data-check". |
data witness | one row per record: {"type":"witnessed|already-witnessed|skipped","table":"<table>","anchor":"<opid>","labels":[…],"opid":"<opid>"?,"cause":"<c>"?,"table_fate":"<fate>"?} (opid is absent under --dry-run and on skipped; table_fate is present only on a witnessed row whose table is not live at HEAD, spelling what became of it after the record’s anchor: dropped at op <opid>, joined into <table> at op <opid>, extracted into <tables> at op <opid>; the witness binds the element whatever its fate), then one {"type":"preview","table":"<schema.table>","outcome":"<o>","cut":"anchor|HEAD","anchor":"<opid>"?} per replace record and cut (anchor rides the anchor arm only; text: preview (anchor <opid>): … / preview (HEAD): …; a record whose table is not live at HEAD carries the anchor arm alone), then the terminal {"type":"ok"}. A witnessed reference record whose bodies do not fold offline raises the reference-chain-unfoldable warning on stderr (table, opid = the record’s anchor, reason = the record locus that does not fold); the exit is unchanged. A refusal is the normal terminal failed envelope. |
import / drift / data capture / data retire (rehearsal) | {"type":"rehearsal","table":"<schema.table>"[,"purpose":"<p>"],"purposes":["<p>",…],"labels":[…],"records":N[,"cause":"<c>"][,"statements":{"<kind>":N,…}]} once per table, ordered by <schema.table> bytewise ascending, ahead of the verb’s own records. purposes is always present: the purposes the run mints for the table, a bytewise-sorted set ([] when every statement is listed), because one run may mint one table’s statements under two purposes. The scalar purpose rides only when that set has exactly one member, so select on purposes; text renders the set purpose=<p>[,<p>…]. statements is the per-kind breakdown (INSERT, UPDATE, INSERT … SELECT, …), carried when the table’s outcomes are mixed or partly listed. cause is present only for a table something excluded. Observed-state capture adds "rows":N (a ROW count, where records is a RECORD count; neither substitutes), "delta":{"added":N,"removed":M}, plus "disposition", "supersedes_anchor", "overwrote_anchor" and "moved_from" where applicable. Drift’s dry-run terminal envelope does not duplicate the per-table projection. data scaffold registers no --dry-run and emits no rehearsal. |
reconcile (bare) | {"type":"report","summary":{...},"forks":[...]} + {"type":"ok"} |
reconcile --clean / --accept-dirty | {"type":"diff","mode":"clean|accept-dirty","fork":{...},"order":{...},"diff":{...},"post_rebase_violations":[...]?} + {"type":"ok"}. When --clean REFUSES, the terminal failed envelope carries code: "fork-no-clean" and an append-only "order_violations":[{"order":N,"rules":[{"code":"rule-...","opid":"...","message":"..."}]}]? key: one entry per candidate block order the oracle found UNCLEAN, positional (order is the block-order index), carrying the blocking engine findings that made it so. Orders the oracle found clean carry no entry, and a fork the oracle never evaluated omits the key entirely. Like post_rebase_violations, it is omitempty, so every other refusal envelope is byte-unchanged. detail names the same offending rules in prose; the code, exit status and --accept-dirty hint are unchanged. |
reconcile --all | one {"type":"diff",...} per leaf + final {"type":"ok"}. On halt the failed leaf’s {"type":"failed",...} lands on both streams; see Channel exceptions. |
On a multi-record stdout the discriminator field is type, identical
across intermediate and terminal records. Other top-level fields are
verb-specific and merged into the envelope when the payload is an
object. Terminal values are ok, failed and unverifiable;
intermediate values are verb-defined past-participle or noun forms
(appended, preview, applied, skipped, report, diff, item,
…). All three terminal values are reserved and must not appear as
intermediate type values. The
single-document verbs above (op info and identify) are the
exception: their success output is a bare value with no type key (see
the single-document exception).
The Detail field (detail: on text mode, "detail":"..." on JSON
envelopes) carries the factual narrative when the primary message
is too tight to fit it.
op add records
{"type":"appended","operator":"...","opid":"...","prev":"...","outcome":"minted|noted","target":"...","minted":{...}?,"tail_after":"...","values":{...},"note":"..."?,"notes":[{"code":"<advisory-code>","message":"..."}]?,"warnings":[...]?}
{"type":"ok","notes":[{"code":"<advisory-code>","message":"..."}]?,"note":"..."?}notes[]is the structured advisory list: every entry carries a stablecode. Advisory note codes inventories the codes, and the inventory is non-exhaustive by design.notes[]rides both theappendedrecord for the authored op and the terminal envelope, carrying the identical slice; read one and ignore the other rather than concatenating them.warnings[]is duplicated across the same two records the same way. Everyappendedpayload initializeswarningsto[], so an empty array is not evidence the op was checked and cleared.- One authored op yields exactly one
appendedrecord, so itstail_afterIS the true persisted tail. A rejoinableExtractTableis one op too (see the text-modeop add --dry-runrow). - The one exception is a
DropUnique(orDropPrimaryKey) of a UNIQUE constraint that adopted its indexUSING INDEX: PostgreSQL drops that index with the constraint, soop addalso writes the companionDropIndexchained after it and streams a secondappendedrecord (a secondpreviewunder--dry-run, a second+line in text mode) whoseprevis the drop’s opid and whosenoteexplains the companion. Each record’stail_afteris its own opid, so the last one names the true tail. - An
ExtractTable’s derived rejoin family reaches the author only as theextract-rejoin-key-addedadvisory on stderr, naming each derived column, child PK, and FK-back. It appears on no stdout record, so a stdout-only consumer sees no trace of elements that occupy real schema namespace. - The free-standing advisories (
enum-value-commit-boundary,extract-survivor-arm-selected,extract-rejoin-key-added,extract-reverse-ordinal-divergenceamong them) appear on stderr only, each as anote: <token>line plus a prose body. Most stay raw text under--as=json;extract-reverse-ordinal-divergencebecomes two{"level":"note",...}objects. The set is open: treat stderr as required reading forop addrather than working from a fixed list. note(singular) is the free-form, code-less string of the same-as-tail advisory. It is mode-exclusive: under JSON it is the records’notefield on stdout and nothing goes to stderr; under text it is anote:line on stderr.outcome:minted= the op was written;noted= the op was written and carries the same-as-tail advisory, its only trigger;deduped= nothing was written. Only aSetReferenceDatadedupes: one whose value the table’s resolved read already carries, on a table that is not an unjudged merge or join product (the same per-name no-opschema ref-tablesand--refreport). It streams= (skipped) already-declared|not-declared <schema>.<table>as a{"type":"skipped","operator":"SetReferenceData","outcome":"deduped","kind":"already-declared|not-declared","target":"..."}record in place ofappended, then the terminalok, exit 0, identically under--dry-runand--dry-run --explain.
snapshot records
{"type":"snapshot-data","band":"<data band text>"}?
{"type":"snapshot","ddl":"<full doc>"}
{"type":"data-excluded","purpose":"...","table":"...","count":N}*
{"type":"ok"}- Other records ride the stream around the
snapshotrecord, so treatokas the terminal shape rather than the whole stream. The order issnapshot-data?,snapshot,data-excluded*,ok. snapshot-datarides before thesnapshotrecord whenever at least one data record was selected, and is omitted when none was. It is not label-gated: base-corpusrefrows select with no flags, so a consumer keying on a label would drop reference rows. In JSON mode the band text lives on this record and is not spliced intoddl, unlike text mode, which places it between the tables and the constraints and indexes of the printed document, so mode equivalence holds for the DDL portion only.data-excludedappears once per (purpose, table) counted out of the data band, after thesnapshotrecord (the artifact emits first).- On an OWNERSHIP exclusion (a corpus file under a shared
data_rootroot whose header names a schema this run does not own) the same record carries two ADDITIONAL members,"cause"(other-schemawhen the named schema is registered,unregistered-schemawhen no registered schema owns that namespace) and"schema"(the owning namespace), and is keyed on (purpose, table, cause, schema) rather than summed onto (purpose, table). A"count"of0reports a foreign file that declares a table empty. Owned-record rows carry neither member. Owned rows emit first, then the ownership rows sorted on (table, cause, schema). - A
"cause":"not-selected"record also carries"unnamed_attested", the record’s labels that--allow-unnamed-labelattested were left unnamed on purpose, when there are any. The run-levelapply-risk-data-exclusionswarning leaves those labels out, so a run that attests every unnamed dataset raises none; the record still counts the rows. - Two stderr advisory codes accompany them:
corpus-records-owned-by-other-schema(per foreign table,-vonly in text mode, but always present as a stderr advisory object under--as=json) andcorpus-foreign-records-present(one run-level warning line at default verbosity, naming the total and every owning schema). - Render skips are not on this stdout stream. They ride the stderr
advisory channel, so under
--as=jsonthey take the{"level":"warning","code":"view-render-skipped"|"element-render-skipped",...}shape on stderr, keyed onlevel/coderather thantype/kind. They are advisory (exit stays 0), while the siblingrender-blocked-on-*failures are terminal.importroutes the other way and streams its advisories to stdout.
import records
{"type":"appended",...}
{"type":"skipped","kind":"...","name":"...","schema":"...","line":N,"raw_stmt_kind":"...","raw_text":"...","src_file":"..."}
{"type":"appended","operator":"Seal","reason":"<auto-or-custom>",...}
{"type":"import-summary","imported":N,"run_raw_script":N,"skipped":N,"skipped_by_kind":{"<kind>":N,…},"skipped_by_class":{"<class>":N,…}?,"sealed":N?,"sources_zero_op":N?}
{"type":"ok","data":{"dry_run":true?,"imported":N,"run_raw_script":N?,"skipped":N}}- One
appendedrecord per minted op and oneskippedrecord per dropped statement.raw_stmt_kindis the PG AST node name, distinct fromkind, which can be a semantic class likecross-schema; drift’sskippedevent omitsraw_stmt_kindbecause drift doesn’t carry the AST node through its differ.src_filenames the source filelineis relative to, so a record stays locatable across a multi-file import. It is the path relative to the project root (falling back to the absolute path for a source outside the root), the same spelling as the DML sidecar’ssrc-file, and it is omitted, not empty, for skips with no source file (e.g.snapshot --skip-manifestview/element-render records). - Under
--seal[=<text>]a trailingSealappendedrecord carries areason: auto-derived for bare--seal(imported <basename>/imported <b1>, <b2>, …/imported <stdin>, a basename that collides within the batch widened to its shortest unique trailing path), or the verbatim<text>for--seal=<text>. - The
import-summaryrecord precedes the terminal envelope, and--summary-file <path>writes the same record as a standalone JSON object. The first four keys are normative.skipped_by_classis an additive sub-tally splitting the SAMEskippedtotal along the disposition axis (keysgap,integrity,preserved,decision,internal-defect) and is omitted when nothing was skipped.skippedstays the aggregate over every class, so the class counts partition it rather than re-base it. Onlygapandintegritycan drive the exit 2;preserved(non-SMO DML set aside to the DML sidecar) anddecisionexit clean. sealedis present only under--seal-per-file, and counts the sources that sealed.sources_zero_oprides the same gate and is omitted when zero: it counts the batch sources that minted no ops and are therefore not sealed, so a reader can reconcile the input count againstsealed(84 sources, 82 seals,sources_zero_op: 2). It is a source-level tally, not a skip: it never entersskipped/skipped_by_kind/skipped_by_classand never affects the exit code.- Terminal
datakeys are in alphabetical order; key by name, not position. Terminalskippedis the integer count of skipped statements; per-statement detail lives only in theskippedrecords, and the per-kind tally lives only onimport-summary, never on the terminal envelope.run_raw_scriptis present only when non-zero.data.dry_run: trueis present only under--dry-runand absent on real runs, as drift’sdata.appliedis present only on a real run. - A run passing both
--forceand--purge-orphaned-corpusthat wrote the oplog and found at least one orphan adds apurged_orphaned_corpusobject to the samedata, shaped{"total":N,"by_file":{...},"by_table":{...},"pre_existing":N}, so a consumer parses ONE object rather than reading a second report printed alongside it. The object is omitted when any leg of that conjunction fails, so its absence is not a claim that nothing was orphaned. The placement is the contract; the field spellings inside that object are non-normative, so key off the object’s presence and tolerate its inner shape changing. - Advisories ride the stdout stream under two
typevalues (see Channel exceptions). Two kinds carry a contract:{"type":"warning","kind":"import-data-motion-deferred","resembled":"...","source":"...","span":"...","child":"..."?,"hint":"..."}per deferred data-motion op (childpresent only on a child-shape defer);{"type":"warning","kind":"import-apply-risk-summary","count":N,"by_kind":{"<apply-risk-code>":N,…}}, at most one per run, emitted only when at least one minted op is apply-risky, folding the per-op findings into a single event. Itsby_kindkeys are theapply-risk-*codes from Advisory note codes, in lexical key order.
- Under
--ddl-onlythe run DECLINES every preserved-DML statement (authored DML, the non-observational members of apg_dumpdata band, and the synthesizedbody=nonefloors) and lists them instead of minting them: one{"type":"not-captured","table":"...","cause":"declined by --ddl-only"}record per WRITTEN RELATION (a statement writing N relations contributes to N records), the matching{"type":"skipped","kind":"declined by --ddl-only","name":"<table>"}manifest entries, and ONE{"type":"cta",...}block after them. The declined statements take these per-TABLE records INSTEAD of their per-statementskippedones, andimport-summary’sskipped_by_kindtallies them under the same cause word, so the three surfaces agree; the aggregateskippedis unchanged. rowsis ABSENT on thedeclined by --ddl-onlycause, on the note and on the manifest entry alike, never0and nevernull: the cause declines a NON-observational population (authoredUPDATE/DELETE/MERGE,INSERT … SELECT, and floors), for which no VALUES-tuple count is the row count. Every other cause word keeps its numericrows, including thedump data bandcause, whose(0)for aCOPY … FROM stdinhead is legitimate. A band row that IS observational (INSERT-form, or aCOPY … FROM stdinhead) therefore keeps thedump data bandcause and its numericrowseven under this flag; only a band-stamped statement with no such count (aDELETE/UPDATE/MERGE, or a synthesized floor) joins this cause.- A declined CLEARING statement (
TRUNCATE t, unqualifiedDELETE FROM t) over a table carrying live chain records refuses the whole run before any write with codedata-declined-clear(exit 2).
function-synthesized-stub
An unresolved trigger callback is accepted with a finding.
Text mode emits this stream line on stdout (where <name> is the bare callback name):
finding: function-synthesized-stub: callback <name> is unresolved; a stub was synthesized and will fail at apply if <name> does not exist in the target database
JSON mode emits this NDJSON record on stdout:
{"type":"finding","kind":"function-synthesized-stub","function":"<name>"}
The JSON record carries the finding kind and callback identity; it does not
repeat the text renderer’s sentence. The text line is an intermediate stream
item before the human-readable import summary; the JSON record is an
intermediate stream item before the terminal ok envelope.
Neither form is a stderr advisory.
The advisory record
One shape serves both channels. On stdout it is one NDJSON line:
{"type":"advisory","level":"warning","code":"<code>","class":"<class>",
"message":"…", "<declared field>":…, "detail":"…", "hint":"…"}On stderr it is the same object minus the type key, field for
field, level, detail and hint included. One render path serves
both, so the two never drift.
Identity is code, alone. type is carrier framing and level is
the display register; kind does not appear on an advisory record at
all. detail and hint are present only when the definition declares
them, so a missing key means “none declared”, never “empty”.
class is closed; code stays open. The class set is
apply-risk, atomicity, data-motion, dml, render-skip, type,
schema, internal; a consumer may exhaust it. The code set is
open and will grow, so a consumer must tolerate one it does not
recognize. class is emphatically not a severity substitute: it
says what the advisory is about, not how much it matters. Severity, as
far as this contract has one, is level.
In text mode an advisory renders as the <register>: <message>
head, then one indented detail: <line> line per line of the rendered
detail when the definition declares one, then the rendered hint as an
indented hint: <hint> line when the definition declares one. A line
whose value renders empty is omitted entirely rather than emitted blank.
Both spellings are the ones errors and warnings use elsewhere, so an
advisory’s body reads the same whichever channel or carrier delivered it.
Retired type tokens
A threaded advisory arrives as the one advisory record above, never
under the warning, finding or note type values on stdout. A
consumer filtering a threaded advisory on a retired token goes silently
empty rather than erroring, which is why they are named here rather than
left to be discovered:
retired type | replacement |
|---|---|
warning | type == "advisory", dispatch on code |
finding | type == "advisory", dispatch on code |
note | type == "advisory", dispatch on code |
A threaded advisory never prints finding:: its line reads
warning: <code>: <message> or note: <code>: <message>. The plain
finding stream records (function-synthesized-stub,
function-identity-uncanonicalized) print finding: lines. A threaded
advisory’s identity is its code, and the register set is
{warning, note}; there is no finding register.
op add’s notes[] and drift’s per-op warnings[] are fields of
stdout records, not advisory records, and are reached by the per-verb
rows above (see Channel exceptions).
One code a correct build never emits
internal-reserved-type-violation is a carrier-internal diagnostic.
It reports that retrofit’s own advisory machinery was misused (a record
type reserved for the advisory carrier reached a generic writer), and it
is a defect in retrofit, not something the invoking command did wrong.
It carries the ordinary envelope plus two parameter fields, type_name
(the token that was refused) and entry_point (which writer took the
call), and declares neither detail nor hint.
Exclude it from advisory aggregation and route it to your own defect
channel. The rule generalises: exclude any advisory whose class is
internal and whose emission is carrier-internal: currently
internal-invalid-advisory and internal-reserved-type-violation.
Counting either as a finding about your schema would attribute a
retrofit bug to your work.
Stderr
text mode
Human-readable lines:
error: <code>: <message>[ at <path>[:<line>]]
detail: <detail-line>?
hint: <hint>?Validation-class errors (rule-*, rule-dropped, ambiguous-element,
no-match) MAY prepend a header block carrying the structured
identity slots that don’t fit the error: line:
# ==== VALIDATION FAILED ====
# drop_opid=<id>?
# element_id=<id>?
# ==== end VALIDATION FAILED ====
error: <code>: <message>
detail: <detail-line>?
hint: <hint>?The block renders only when at least one of drop_opid / element_id
is populated; otherwise the trailing error: line already
carries the code+message verbatim and the empty marker pair would just
be visual noise. The editor-buffer companion in op revise -i is the
load-bearing caller of the legacy embedded-message shape: its
buffer-prepended variant embeds <code>: <message> because the
trailing error: line never follows the user into the buffer they’re
editing.
The terminal unverifiable record (see the unverifiable discriminator
under json mode) carries its entries in text mode too: listing each entry’s
Rule, OpID and Reason is not conditional on output mode,
and text is the DEFAULT. One indented line per entry follows the
error: block, in the order the entries were recorded:
error: <code>: <message>
detail: <detail-line>?
hint: <hint>?
unverifiable: <rule>[ at <opid>][: <reason>]
unverifiable: <rule>[ at <opid>][: <reason>]There is one line per unevaluable rule, deduped by rule code (the
refusal’s identity), so one op with several
unevaluable rules names each of them. at <opid> and : <reason> are
omitted when absent, matching the omitempty fields on the JSON
entries objects; a rule with no code renders as (unnamed rule).
An accumulating refusal (one that reports every failing argument
rather than stopping at the first) renders its violations array the
same way, one indented line per entry after the error: block, in the
order the arguments were given:
error: <covering-code>: <message>
detail: <detail-line>?
violation: <argument>: <code>[: <detail>]
violation: <argument>: <code>[: <detail>]
hint: <hint>?Each entry carries its own per-failure code, under the envelope’s
covering code (see violations under json mode). A
trailing : <detail> is omitted when absent, matching the omitempty
member on the JSON objects.
Notes and warnings are line-prefixed and render as the advisory record describes for text mode:
note: <message>
warning: [<code>: ]<message>
detail: <detail-line>?
hint: <hint>?json mode
Stderr mostly carries advisories, each one JSON object on its own line:
{"level": "note", "message": "..."}
{"level": "warning", "code": "...", "class": "...", "message": "...", "detail": "..."?, "hint": "..."?, "<param>": …}An in-tier advisory, a warning or note raised through the output
gateway, always carries code and class, and its declared
parameter fields ride as flat siblings of them, never nested under a
params envelope. class is the taxonomy axis: a closed, ratified list
of apply-risk, atomicity, data-motion, dml, render-skip, type,
the named residual schema for codes those six do not absorb, and
internal for the reserved self-report code. It is independent of
level, which is the display register. class rides as its own field
because that is where it survives machine reading: a code’s leading
segment may repeat it for the human eye, but many codes do not carry one,
so the field is the parse contract and the prefix never is. detail and
hint are present only when the definition supplies them. A parameter value is a typed atomic, a typed
list, or a typed list of records, never a prose fragment.
A code-less courtesy or info line is deliberately not an advisory: it
emits {"level": …, "message": …} and carries no code, no class, and
no parameter fields. Its identity-free shape is the signal, not an
omission.
The human line puts the code after the class token, never in place of
it: warning: <code>: <message>, note: <code>: <message>. The code
and class values are wire tokens and are never localized; message is
the rendered template and carries neither the class token nor the code.
Coverage boundary. Codes are mandatory on in-gateway advisories
only. op add’s free-standing note: <token> lines and import’s
stdout {"type":"warning"|"finding","kind":…} records carry no registry
code, so stderr carries two grammars: the advisory object above and
op add’s raw lines (see Channel exceptions).
Consumers tail stderr for diagnostics; on an enveloped (multi-record)
stdout the type field is the canonical disposition. (Single-document
success stdout carries no type (see the single-document exception);
its disposition is “no terminal record, exit 0”.) Stderr keeps a
separate level discriminator (rather than reusing type) so the
stream origin is obvious to consumers doing 2>&1.
Channel exceptions
The lead’s rule (disposition on stdout, advisories on stderr) has
exceptions in both directions. The categories below are the known ones,
not a closed set: a merged-stream consumer must not assume every
stderr line is a level-keyed JSON advisory.
Failed envelopes on stderr. Four verbs put a terminal failed
envelope on stderr under JSON:
snapshot: only its seed / data-band pre-flight abort, raised after the cut is resolved but before the first DDL byte, goes to stderr instead of stdout. A redirectedretrofit snapshot > schema.sqlthen yields an empty file rather than a half-written artifact followed by an error object. Snapshot’s earlier failures (flag validation, engine-version resolution, replay, thefork-in-rangerefusal on an unreconciled fork in(genesis, --at](exit 1, mirroringmigrate’s envelope and itsretrofit reconcile <shared-prev>hint), and cut selection) land on stdout like every other verb’s, so this is a narrow per-path exception, not a property of the verb.data list: a corpus-scan refusal and an ambiguous-layer refusal go to stderr instead of stdout.schema ref-tables: a refusal reading the oplog, its cut or the corpus goes to stderr instead of stdout.reconcile --all: the failed leaf’s envelope on a mid-run halt is written to stdout and mirrored to stderr, so it appears twice under2>&1. The stderr copy is the contracted signal; the stdout copy is an accepted duplicate. The two records are semantically equivalent but not byte-identical (the stdout copy’s keys are alphabetical, while the stderr mirror putstypefirst), so de-duplicate on the parsed object or on a stable identity field such ascode, never by comparing raw lines.
Only the JSON stream differs; text mode writes every error to stderr.
The envelope shape is identical in every case. A consumer that must
catch every structured failure should therefore read both streams
(2>&1) rather than assume stdout-only. Terminal envelopes carry type
and no level, which tells them apart from stderr advisories.
Stream origin: level does not imply stderr. A threaded advisory
record on stdout carries level too, so under 2>&1 “has level”
does not tell the channel. Use these recipes:
| you want | recipe |
|---|---|
| stdout advisories | select(.type == "advisory") |
| stderr advisories | select(.level and (.type | not)) |
| every other record | select(.type and (.level | not)) |
select(.type) alone matches both the advisory records and every
ordinary stream record; do not use it.
import’s per-statement advisories are stdout stream records, not
stderr advisories. import streams its findings about the statements
it processed on stdout, so an advisory is ordered against the
appended / skipped records it describes. Dispatch is two-part. A
record threaded onto the advisory carrier arrives as
type == "advisory" and is identified by its code; a plain stream
record keeps its kind under one of two type values. So:
select(.type == "advisory")then switch on.code: the threaded half;select(.type == "warning" or .type == "finding")then switch on.kind: the plain half.findingcarriesfunction-synthesized-stubandfunction-identity-uncanonicalized;warningcarries the rest.
Do not filter to type == "warning" alone; it drops the finding
records and the advisory ones. This holds in both modes: JSON
emits the record on stdout, and text renders the same finding as a line
on stdout. A plain stream record (import-data-motion-deferred,
import-apply-risk-summary, import-extract-recognized,
serial-folded-to-identity, parameterized-type-deparse-failed, …)
gets a note:, warning: or finding: prefix chosen per record, so
the text prefix does not tell you the record type. The kind set is
open and not exhaustively documented, and the remaining keys on a
record are per-kind. A consumer must therefore switch on kind and
tolerate an unknown one (log and continue) rather than assume a closed
set or a fixed key shape. import records spells
out only the two kinds that carry a cross-version contract.
The carve-out covers that streamed family only; import still writes to
stderr, so do not run it under 2>/dev/null. One import warning stays
on the stderr channel: rollback-incomplete, emitted once per leg when
a failed run could not undo a file it had already written. It names the
residual path and is the one advisory a consumer most needs, so it rides
the diagnostic channel rather than a stdout stream that a caller may be
parsing as the run’s report.
Advisory content inside stdout records. op add’s structured
notes[] and drift’s per-op warnings[] are fields of stdout
records, and under --as=json they are the only copy. Each is
mode-exclusive, riding stdout under JSON and stderr under text, so a
finding is never duplicated across channels within one mode. Read the
per-verb rows rather than assume a single global stream rule.
Raw-text advisories. Several op add advisories
(enum-value-commit-boundary, extract-survivor-arm-selected,
extract-rejoin-key-added among them) are written straight to stderr as
note: <token> plus an optional free-text body, bypassing the output
gateway, so they stay plain text even under --as=json and have neither
level nor type (see op add records).
Raw refusal / violation diagnostics. The same bypass carries
op add’s refusal: <token> lines (plus body prose) and op seal’s
violation: diagnostic, which precedes the failed envelope on stdout.
The structured counterpart still arrives (a refusal’s stdout envelope
carries code: "refusal-<token>"), so parse the envelope for machine
decisions and treat these lines as human diagnostics.
A consumer parsing stderr as NDJSON must therefore tolerate a line that does not parse as JSON rather than treating it as a protocol error.
Advisory note codes
Advisory code values are part of the wire format: the spelling is the
contract, codes are added but never renamed, and a consumer switches on
the code rather than on the message text.
The table below inventories the three families that carry a cross-verb
contract. It is not exhaustive and is not a closed set: new codes
are added as operators gain advisories, so a consumer must treat an
unrecognized code as a forward-compatible advisory (log it, do not
fail on it) rather than assume the list below is complete.
| Code | Axis | Fires on | Meaning |
|---|---|---|---|
data-loss-on-apply | information | op add: any operator classified information-losing (IMinus), which covers unconditionally destructive ones such as DropColumn / DropTable, plus value-dependent cases such as a narrowing CastColumn. drift: narrowing CastColumn items only, so a drift-minted DropTable / DropColumn carries no advisory | applying the op discards or narrows existing data |
apply-risk-validating-fk | availability | AddForeignKey with not_valid unset | a validating FOREIGN KEY scans and locks the table; author it not_valid=true, then ValidateConstraint later |
apply-risk-validating-check | availability | AddCheck with not_valid unset | a validating CHECK scans and locks the table; same NOT VALID / VALIDATE split |
apply-risk-add-not-null | availability | AddNotNull, unless a validated CHECK proves the column NOT NULL at the op’s input cut | SET NOT NULL scans the table under an exclusive lock |
apply-risk-add-unique | availability | AddUnique | building the unique index takes an exclusive lock for the duration of the build |
apply-risk-rename-in-use | availability | RenameTable, RenameColumn | renaming an in-use object breaks clients still referencing the old name |
apply-risk-window-validating-fk, apply-risk-window-validating-check, apply-risk-window-add-not-null | availability | validate --fail-on-apply-risk only: an apply-risk-validating-fk / -validating-check / -add-not-null hazard in the migration window since the latest Seal, on a table not created (untainted) earlier in that window; also a not_valid=true add whose ValidateConstraint lands in the same window under the own / caller envelope | the gate-eligible window finding (fields schema, opid, table); it renders at every verbosity and fails the run through structural-gate (exit 2) |
apply-risk-window-unsealed | availability | validate --fail-on-apply-risk only: a schema with no Seal, so its window begins at root | names how many tables the window treated as newborn (fields schema, table_count); never changes the exit |
data-motion-cascade-scope | scope | every data-motion operator | names how many columns and same-table constraints/indexes cascaded from the source; foreign keys are not cascaded |
data-motion-source-consumed | scope | MergeTable | the source table(s) end at this op and into stays; snapshot DDL stops emitting the sources from this opid forward (a SplitTable keeps its source and raises none) |
data-motion-copy-post-fork-independence | scope | CopyTable | the copy and its source are independent identities from this op forward; later edits to one do not propagate |
data-motion-fresh-sequences | scope | CopyTable or SplitTable over a source with an IDENTITY column | the destination gets fresh sequences rather than sharing the source’s; a split’s pieces start at the source’s position (a MergeTable mints none) |
data-motion-partition-not-pg-declarative | scope | SplitTable | SplitTable is a row-axis split that keeps its source; it emits no PARTITION BY / PARTITION OF declarative-partitioning DDL; declarative partitioning is authored with PartitionTable, CreatePartition, AttachPartition and DetachPartition |
data-motion-product-capture-owed | scope | ExtractTable, CopyTable, SplitTable over a declared reference table; JoinTable with a declared source; MergeTable with a declared source and a declared into | one per product (fields table, target = the declared source(s), and-joined): the product is reference data and the rows the op gives it exist only in migrated databases, so capture it at HEAD (data capture <dump> --table <product>, then data witness) before op seal, which refuses reference-rows-uncaptured until then |
data-motion-declaration-inherited | scope | CopyTable, SplitTable, ExtractTable over a declared reference table | one per product (fields schema, table, target = the source): the product inherits the source’s declaration; schema ref-tables remove --schema <schema> <product> withdraws it when its rows are not reference data |
reference-declaration-unjudged | scope | validate: a JoinTable product whose sources disagree about the declaration, or a MergeTable’s undeclared into with a declared source, live at HEAD, with no later SetReferenceData on it | one per table (fields schema, table = the bare product or into name, opid, values = the undeclared tables: a join’s sources, a merge’s into): the op combined declared and undeclared tables into it without a judgment (for a join, replay’s ANY made the product reference data; a merge’s into keeps its own declaration); record one with schema ref-tables add --schema <schema> <product> or schema ref-tables remove --schema <schema> <product>. Never changes the exit; op seal refuses a range holding the op under the same code until the judgment is on the log |
cascade-non-atomic-on-no-transaction | atomicity | ChangePrimaryKey cascading ≥1 dependent FK rewrite | the cascade must run inside one transaction to apply atomically; the default --envelope=own satisfies this, caller / independent do not |
drift-skip-internal-defect | scope | drift: a skip classed internal-defect (a parser version-boundary tripwire, unreachable on valid PG with the pinned parser) | one warning per skip at drift’s terminal (fields skip_kind = the raw kind, name, schema, line); exit 0 |
drift-skip-unclassified | scope | drift: a skip that reached the classifier’s fail-closed default (an unlisted kind, or a reason-keyed kind with an unlisted reason) | one warning per skip at drift’s terminal (fields skip_kind, name, schema, line, reason naming the kind and, on a reason-keyed kind, the unlisted reason); the skip classes gap, so the run exits 2 drift-unreconciled-skips unless --allow-capability-gaps |
The axes are independent: data-loss-on-apply says the apply loses
information, the apply-risk-* family says the apply is slow or
locking, and the data-motion-* / cascade-* family says the op’s
scope or atomicity is wider than its name suggests. A run can carry
several, and none implies another.
The apply-risk-* remedies above shorten how long a lock is held. To
bound how long the apply waits for one (so a statement queued behind a
long-running query gives up instead of stalling every later query on the
table), emit with migrate --lock-timeout=<duration>, which writes a
SET LOCAL lock_timeout line at the head of every transaction the file
opens; see Bounding the lock wait
in docs/migrate.md.
None of these families is a refusal: an advisory never by itself changes the
verb’s disposition or exit code. A run carrying advisories can still
exit non-zero for an unrelated reason (a loss-bearing import skip, for
one), so the presence of an advisory says nothing about the exit.
Where each family surfaces:
op add: all three families ride the structurednotes[](JSON) / onenote: <code>: <message>stderr line per entry (text). It is the only verb that emits thedata-motion-*/cascade-*family: those advisories describe an authored op’s genealogy scope, which no other verb mints. At most oneapply-risk-*finding per op. Under JSON the samenotes[]appears on both theappendedrecord and the terminal envelope (see theop addrow).drift:data-loss-on-applyandapply-risk-*only; both ride theop-appliedrecord’swarnings[](JSON) or awarning:stderr line (text), mode-exclusively: a finding appears on exactly one channel per mode, never both.import: theapply-risk-*findings are not emitted per op. They fold into one run-levelimport-apply-risk-summarystdout stream record whoseby_kindmap counts them by code (see theimportrows).migrate: reuses the validating-FK predicate for its apply-time lock-risk pass, so the author-time and apply-time arms cannot drift.validate --fail-on-apply-risk: the one opt-in exception to “never by itself changes the exit”. Over each schema’s(latest Seal, head]window, withmigrate’s own newborn walk, it raises theapply-risk-window-*codes above the mode split and fails through thestructural-gateenvelope (exit 2), Detail by code and count, Hint naming each code’s recovery. The same facts ridedata.apply_risk={envelope?, envelope_source?, counted[]{code, schema, opid, table, pair?}, unsealed[]{schema, table_count}}, present whenever the flag is set;envelope/envelope_sourceappear only when a same-window pair was counted. Without the flagvalidateemits none of this.
Project exclusions: [[exclude]]
A project that declares [[exclude]] entries in retrofit.toml has the
statements they own removed from each parsed input before any walker reads it
(import, identify, drift, migrate --from-dump, data capture). The
set is project state; no flag adds to it, and the registry-less -f mode
applies none. Every run reports what it applied:
| Surface | Shape |
|---|---|
exclusion-applied | advisory on stderr, once per entry that owned at least one removed statement, in declared order: ~ (excluded) <kind> <name> — <n> statement(s) — <reason> (a table’s <name> schema-qualified), and under it one <src_file>:<line> <raw_stmt_kind> <target> line per listed statement: at the default verbosity each removed statement with a parser-visible reference to an object a kept statement or the log creates, at -v every one. Band Always, not floor-exempt: -q keeps it, -qq drops it with the advisory tier. |
{"type":"excluded","kind":"...","schema":"...","name":"...","reason":"...","statements":N,"removed":[{"src_file":"...","line":N,"raw_stmt_kind":"...","kind":"...","schema":"...","name":"..."}]} | a stream event on stdout under --as=json, once per reported entry, for import, drift and data capture; apart from skipped. schema is empty for an extension or schema entry; removed lists every removed statement at every verbosity. |
excluded array | the same objects on the terminal record of identify (the exact document) and migrate --from-dump (the ok envelope), whose stdout carries the match or the DDL. |
excluded tally | import-summary and drift-run-summary carry "excluded":N, the number of entries reported, apart from skipped_by_class; absent when no entry applied. |
excluded-schema-referenced | advisory on stderr, band Courtesy (-q drops it), once per schema entry that a kept statement references; the listed statements are the kept statements by src_file:line. The statement keeps the disposition of any reference into an unmanaged schema. |
exclusion-dependent | refusal (user-environment group, exit 2): a kept statement references an excluded table or extension. Names the first such statement by src_file:line, the object and the entry; the Hint names the recourse of its case. Raised before any write. |
exclusion-in-log | refusal (user-environment group, exit 2): an entry names an object a cut of the run’s log holds. Names the first such op by its kind and oplog line. Checked before exclusion-dependent, before any write; the oplog is never edited. |
An entry that owned nothing in a run is listed in one courtesy-tier line
(-q removes it); a run that refuses reports none of the above.
Advisory absence is not a negative claim
An advisory on stderr is a positive claim: when migrate writes
wrapper-suppressed, a file was emitted and that file carries no
BEGIN;/COMMIT; wrapper. The converse does not hold. The
absence of a wrapper-suppressed advisory must not be read as proof
that a wrapper was emitted.
Absence has five causes, and stderr cannot tell them apart:
- No suppression: retrofit wrapped the file, so there was nothing to advise about.
- A failed emission: the write did not complete, so no complete artifact exists for the advisory to describe. Advisories that assert an emission are withheld unless the emission completed successfully, so a failing run is silent about a file it never finished producing. In text mode a partial artifact may already have reached stdout before the failure; the withheld advisory says nothing about how much.
- A lost stderr write: advisory writes are best-effort and their result is discarded, so a warning retrofit did produce may not reach a consumer.
- The advisory tier was quieted:
-qqdrops the whole advisory tier from the human text surface, so nothing in it reaches stderr. This is a display control: the advisory was still raised, and--as=jsoncarries it in full at every level. - The condition was attested away:
--allow-external-atomicitystates that the apply context owns transaction atomicity, which answers the advisory’s question rather than silencing it. The advisory is then not raised at all, in either mode.
Only case 2 is distinguishable from case 1 by the terminal disposition:
a failed emission exits non-zero (the envelope names the cause: an I/O
fault is io-error, a recovered internal fault is migrate-internal).
Case 3 is not distinguishable this way: a lost advisory write does
not affect the run’s outcome, so it exits zero exactly as case 1 does.
Cases 4 and 5 are both distinguishable, and deliberately so: case 4 is
recorded by the flag in the invocation and leaves the machine record
untouched, and case 5 leaves a structural acknowledgement (below).
A consumer needing the positive fact (was this file wrapped?) must
read the emitted SQL itself. The wrapper, when present, is literal
BEGIN; / COMMIT; text in the artifact; under --as=json it is
inside the envelope’s ddl payload.
One structured surface does state a related fact separately. On a run
that passed --allow-external-atomicity, the terminal envelope’s data
object carries the reserved key external_atomicity_attested. Its
presence means the attestation was accepted; its absence means the run
was not attested, on every arm, so an audit consumer can always
distinguish wrapper suppressed, fact attested from no wrapper
question arose. It never states whether a wrapper was emitted, which
remains a question only the SQL answers.
The same reasoning applies to every advisory: this contract makes retrofit’s positive claims trustworthy; it does not make its silence meaningful.
Exit codes
| Condition | Exit | Group |
|---|---|---|
| Success or non-fatal warning | 0 | GroupSuccess / GroupWarning |
Lex/parse failure (toml/oplog parse, refusal-class envelopes such as migrate’s) | 1 | GroupLexParse |
| User or environment error, dirty-write refusal, recovered-panic internal error, a loss-bearing import skip, or an unreconciled drift skip | 2 | GroupUserEnv / GroupDirtyWrite / GroupInternal / GroupImportLoss / GroupDriftLoss |
| Editor unavailable, no confirmation channel | 3 | GroupEnvUnavailable |
| SIGINT / editor-interrupted | 130 | GroupSignalCancel |
The Group column names retrofit’s internal error class for the row. It is not on the wire: no record carries it.
Exit 2 is shared by five groups, so the exit value alone does not name
the cause: the terminal record’s code is the discriminator. A
loss-bearing import failure always carries the frozen
code import-loss-bearing-skips, covering both the capability-gap arm
and the integrity arm; a CI consumer matches
that code rather than inferring from exit 2. --allow-capability-gaps downgrades the
capability-gap arm to exit 0; the integrity arm has no such escape.
drift follows the same contract over its skipped items. Each one carries a class from the classifier import uses.
The run exits 0 over itemized content: ops applied (or previewed under
--dry-run) and skips classed decision or preserved, each set aside
by a named rationale. Exit 0 does not mean “no divergence”; expressible
drift still applies ops at exit 0. A skip classed internal-defect
also exits 0, with a drift-skip-internal-defect warning. When any
gap skip stands, on --dry-run and real runs alike, the run fails
with the frozen code drift-unreconciled-skips (exit
2) after the drift-run-summary record. Its hint names each gap raw
kind with its count, and names apart the dependents of a question the
run itself answered (rerun drift: the next run plans against the
answer). A real run’s appended ops, and a --seal seal, persist before
it fails. A skip kind the classifier does not list classes gap and
raises drift-skip-unclassified. --allow-capability-gaps waives the
gap arm: exit 0 with the same summary record. 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 the walker 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.
reconcile exits 2 for a file only a newer build can read, whatever
exit the same code carries on other verbs: an unknown
operator (unknown-operator with the upgrade hint, a ~ tombstone
included), a too-new format or model version (oplog-format-too-new,
model-version-too-new), and an unknown key or retrofit-* header
(writes-blocked-on-unknowns). The fix is the caller’s build, not the
log; the code tells them apart. Content the author fixes stays exit 1:
a conflict marker (oplog-conflict-marker), a repeated header
(missing-metadata-header), a renamed or split operator, a line with no
operator name, a fork it refuses. On reconcile, op revise -i and the
op verbs an oplog that exists but cannot be read refuses as
oplog-unreadable (exit 2), as on validate; a missing one stays
oplog-not-found (exit 2).
--allow-capability-gaps downgrades an invocation only when it carries
capability-gap skips, no integrity skips, and minted at least one
op. An integrity skip fails regardless of op count or
--allow-capability-gaps. The op count is batch-wide, not
per-source: an invocation that minted zero ops across all its
sources and carries a gap or integrity skip fails with exit 2 on
either arm, because a downgrade there would let a run that imported
nothing report success. So:
- A multi-source run is not caught merely because one source was
gap-only. If any other source minted an op, the batch is non-empty
and
--allow-capability-gapsdowngrades normally. - The zero-op batch may still have produced preserved DML, so the loss-bearing skip need not be the run’s only observable output; the guard keys on zero ops, not on zero output.
Under --as=json, the exit code matches the terminal record’s
disposition: type:"ok" exits 0 (or 0 with a warning),
type:"failed" exits per the table above, and
type:"unverifiable" exits 2. The
single-document verbs (op info and identify) emit no terminal record
on success and exit 0; on failure they still emit {"type":"failed",...}
and exit per the same table. So exit code and stdout disposition stay
consistent on both paths.
Data-band records
migrate and snapshot check each preserved data record against the
cut they render. Three record types report the result. They
ride stdout in this order, after the verb’s own artifact record and
before ok:
| Type | Shape | Meaning |
|---|---|---|
data-repaired | {"type":"data-repaired","table":"<schema.table>","path":"<data file>","anchor":"<opid>","render_point":"<opid>","repairs":[{"kind":"...","name":...,"from":...,"to":...,"op":...}],"comments_omitted":N} | The emitted body was rewritten so it is true at render_point. kind is one of rename-table, rename-column, drop-column. to is null on drop-column; op is present only where a dropped element records one. comments_omitted counts in-body comments the re-render did not carry. |
data-type-changed | the same shape, with repairs[].kind = type-changed | A column the body references changed type, but not in a way that can lose information. The body is emitted byte-for-byte as authored; nothing was rewritten. The record exists so the change is never silent. |
data-excluded | {"type":"data-excluded","purpose":"...","table":"...","cause":"...","count":N} | The record was not emitted. cause=table-dropped is the staleness arm: the table is gone at the render point, so its rows have nowhere to land. Each such table also raises one apply-risk-data-dropped warning on stderr whose hint names the DropTable op that removed it and the data file(s) holding its records, to purge; under --as=json that advisory object carries the same two values as opid and path (several files are , -joined, sorted). A table a JoinTable, SplitTable, MergeTable or InlineTable ended takes cause=table-joined, table-split, table-merged, table-inlined or table-unjoined (an UnjoinTable) instead: the same per-table apply-risk-data-dropped warning names that op as opid and its product(s) as target, and its hint owes no purge: the records stay as history and the product’s rows are its own capture. A seed record on a table inside its reference span at the render point takes cause=reference-data: it is dormant, never folded with or instead of the reference rows. Each dormant chain raises one apply-risk-data-dropped warning naming the op that opened the span as opid, the chain’s labels as labels, its first label as label, its exact --label set as label_cta, the table’s schema-qualified name at HEAD as target (empty when HEAD has no such table) and the hint’s rendered commands as recourse. |
A body that needed no repair produces no record at all.
The source-band bound is on the INGEST side. data capture, import and
drift refuse a source whose per-table INSERT band exceeds the schema’s
data_band_max_bytes with the terminal failed envelope
code: "data-band-over-cap" (exit 2), carrying an append-only
"band_over_caps":[{"schema":"<s>","table":"<t>","bytes":N,"bound":N,"statements":N,"source":"<file>"?}]
key: one row per over-cap table, ordered by bytes descending, then
(schema, table) byte-wise, then source. schema and table are
separate members (a bare name carries "schema":""); the numbers are plain
JSON integers; source appears only on import, whose sum is per source
file. The key is omitempty, so every other envelope is byte-unchanged.
Text mode renders the same rows, in the same order, as band: lines
spelling the pair schema.table.
Refusals are not on this stream. A record that cannot be made true fails
the run with stale-dml-reference on stderr (exit 2), listing every
refused record with its reason. --allow-stale-dml, accepted by both
migrate and snapshot, downgrades those refusals to warnings and emits
each body under a -- WARNING: stale reference comment at exit 0, except
a forked chain, which is not attestable and refuses either way.
A record whose anchor-op= names an opid the oplog no longer holds cannot
be positioned at all, and refuses the run rather than emitting (--allow-stale-dml
does not downgrade it). migrate refuses it as
dml-anchor-unresolved; snapshot has no window, so it refuses with
data check’s own data-file-anchor-unresolved envelope (same Message,
the same <file>: record seq=<n> anchors on <opid> Detail lines, and the
same retrofit data repin <file> --anchor-op <opid> hint) on stderr,
before the first DDL byte, exit 2. Only a record the band would emit
refuses: one that the run’s --labels do not select, that
--table / --exclude-table / --over leave out, or that is history-bound
is counted in data-excluded as before. data list is inspection and
never refuses it.
Why these rules
A few rationales for choices that look arbitrary:
- One persistent flag instead of per-verb
--asflags: humans don’t remember which verbs accept which output modes. One flag, one place to look. - JSON envelopes are single-line NDJSON (terminal
\n): consumers canreadline-by-line in shell pipelines, andjq -cround-trips them. - Errors emit on stdout under –as=json (not stderr): the disposition lives on a single stream so consumers can
retrofit X --as=json | jqfor both ok and error paths. Stderr stays for diagnostics. Channel exceptions lists the failed envelopes that reach stderr. - Validation-class errors get a header block in text mode when they carry structured identity slots (
drop_opid/element_id); the block lists only those slots, above the trailingerror:line that carries the message. Theop revise -ieditor buffer’s block also embeds<code>: <message>, because the buffer is what the user edits on retry and theerror:line does not follow into it. - No emoji, no color: this CLI is meant to be piped. ANSI sequences and emoji break grep, ripgrep, awk, and humans-with-screen-readers. Forever.
- Streaming over single-envelope:
op showover a 100k-op log can’t buffer 50MB into a single JSON object. Per-line records preserve streaming and let consumers tail output incrementally.