retrofit0.10.0-wip.8

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|revise plus the persistent --as=json for output mode. The legacy spelling emits a deprecated-flag warning. (Default framing is log, the on-disk record grammar; add and revise are 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 --output is not registered. reconcile exits 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:

InvocationWhat the human text surface shows
-veverything 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
-qdrops those incidental lines; advisories about the artifact itself still print
-qqdrops 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=json carries every occurrence uncollapsed at all times;
  • it composes with the ladder without special cases: --no-consolidate picks what the tier would show, the ladder picks whether it shows anything, so -qq --no-consolidate prints 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-context advisory renders as a note: 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:

VerbStdout
op addone + <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-runone 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 removesilent on success
op remove --dry-runone bare opid per line for each removed op
op revisebare opid
op revise --batchone bare opid per edit
op revise -ione bare opid per surviving op
op showone 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 sealsealed <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 --dumpone 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.
initsilent on success
schema createone + <opid> CreateSchema <name> line (mints the schema’s first op)
schema listone <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-tablesone 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 removesilent on success
schema engineone line: current engine pin, e.g. postgres:17
data listone <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 retireone 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 scaffoldone 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 checkdata 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.
snapshotfull DDL document streamed to stdout
migratefull migration script streamed to stdout (BEGIN;/COMMIT;-wrapped by default)
importone + <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).
driftleads 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-dirtytext diff before commit; nothing after commit
reconcile --alltext 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>)]
FieldWidthNotes
<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-emptyreal 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 minoperator 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>variesdotted 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 ok must handle both failed and unverifiable.

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:

VerbStdout 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 --dumpsingle bare document {"operators":[{...descriptor},...]} (name-sorted); no type key, no terminal ok (single-document exception).
identifyon 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 retireJSON 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 witnessone 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 --allone {"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 stable code. Advisory note codes inventories the codes, and the inventory is non-exhaustive by design.
  • notes[] rides both the appended record 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. Every appended payload initializes warnings to [], so an empty array is not evidence the op was checked and cleared.
  • One authored op yields exactly one appended record, so its tail_after IS the true persisted tail. A rejoinable ExtractTable is one op too (see the text-mode op add --dry-run row).
  • The one exception is a DropUnique (or DropPrimaryKey) of a UNIQUE constraint that adopted its index USING INDEX: PostgreSQL drops that index with the constraint, so op add also writes the companion DropIndex chained after it and streams a second appended record (a second preview under --dry-run, a second + line in text mode) whose prev is the drop’s opid and whose note explains the companion. Each record’s tail_after is its own opid, so the last one names the true tail.
  • An ExtractTable’s derived rejoin family reaches the author only as the extract-rejoin-key-added advisory 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-divergence among them) appear on stderr only, each as a note: <token> line plus a prose body. Most stay raw text under --as=json; extract-reverse-ordinal-divergence becomes two {"level":"note",...} objects. The set is open: treat stderr as required reading for op add rather 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’ note field on stdout and nothing goes to stderr; under text it is a note: 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 a SetReferenceData dedupes: 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-op schema ref-tables and --ref report). 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 of appended, then the terminal ok, exit 0, identically under --dry-run and --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 snapshot record, so treat ok as the terminal shape rather than the whole stream. The order is snapshot-data?, snapshot, data-excluded*, ok.
  • snapshot-data rides before the snapshot record whenever at least one data record was selected, and is omitted when none was. It is not label-gated: base-corpus ref rows 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 into ddl, 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-excluded appears once per (purpose, table) counted out of the data band, after the snapshot record (the artifact emits first).
  • On an OWNERSHIP exclusion (a corpus file under a shared data_root root whose header names a schema this run does not own) the same record carries two ADDITIONAL members, "cause" (other-schema when the named schema is registered, unregistered-schema when 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" of 0 reports 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-label attested were left unnamed on purpose, when there are any. The run-level apply-risk-data-exclusions warning 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, -v only in text mode, but always present as a stderr advisory object under --as=json) and corpus-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=json they take the {"level":"warning","code":"view-render-skipped"|"element-render-skipped",...} shape on stderr, keyed on level/code rather than type/kind. They are advisory (exit stays 0), while the sibling render-blocked-on-* failures are terminal. import routes 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 appended record per minted op and one skipped record per dropped statement. raw_stmt_kind is the PG AST node name, distinct from kind, which can be a semantic class like cross-schema; drift’s skipped event omits raw_stmt_kind because drift doesn’t carry the AST node through its differ. src_file names the source file line is 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’s src-file, and it is omitted, not empty, for skips with no source file (e.g. snapshot --skip-manifest view/element-render records).
  • Under --seal[=<text>] a trailing Seal appended record carries a reason: 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-summary record 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_class is an additive sub-tally splitting the SAME skipped total along the disposition axis (keys gap, integrity, preserved, decision, internal-defect) and is omitted when nothing was skipped. skipped stays the aggregate over every class, so the class counts partition it rather than re-base it. Only gap and integrity can drive the exit 2; preserved (non-SMO DML set aside to the DML sidecar) and decision exit clean.
  • sealed is present only under --seal-per-file, and counts the sources that sealed. sources_zero_op rides 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 against sealed (84 sources, 82 seals, sources_zero_op: 2). It is a source-level tally, not a skip: it never enters skipped/skipped_by_kind/skipped_by_class and never affects the exit code.
  • Terminal data keys are in alphabetical order; key by name, not position. Terminal skipped is the integer count of skipped statements; per-statement detail lives only in the skipped records, and the per-kind tally lives only on import-summary, never on the terminal envelope. run_raw_script is present only when non-zero. data.dry_run: true is present only under --dry-run and absent on real runs, as drift’s data.applied is present only on a real run.
  • A run passing both --force and --purge-orphaned-corpus that wrote the oplog and found at least one orphan adds a purged_orphaned_corpus object to the same data, 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 type values (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 (child present 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. Its by_kind keys are the apply-risk-* codes from Advisory note codes, in lexical key order.
  • Under --ddl-only the run DECLINES every preserved-DML statement (authored DML, the non-observational members of a pg_dump data band, and the synthesized body=none floors) 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-statement skipped ones, and import-summary’s skipped_by_kind tallies them under the same cause word, so the three surfaces agree; the aggregate skipped is unchanged.
  • rows is ABSENT on the declined by --ddl-only cause, on the note and on the manifest entry alike, never 0 and never null: the cause declines a NON-observational population (authored UPDATE/DELETE/MERGE, INSERT … SELECT, and floors), for which no VALUES-tuple count is the row count. Every other cause word keeps its numeric rows, including the dump data band cause, whose (0) for a COPY … FROM stdin head is legitimate. A band row that IS observational (INSERT-form, or a COPY … FROM stdin head) therefore keeps the dump data band cause and its numeric rows even under this flag; only a band-stamped statement with no such count (a DELETE/UPDATE/MERGE, or a synthesized floor) joins this cause.
  • A declined CLEARING statement (TRUNCATE t, unqualified DELETE FROM t) over a table carrying live chain records refuses the whole run before any write with code data-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 typereplacement
warningtype == "advisory", dispatch on code
findingtype == "advisory", dispatch on code
notetype == "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 redirected retrofit snapshot > schema.sql then 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, the fork-in-range refusal on an unreconciled fork in (genesis, --at] (exit 1, mirroring migrate’s envelope and its retrofit 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 under 2>&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 puts type first), so de-duplicate on the parsed object or on a stable identity field such as code, 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 wantrecipe
stdout advisoriesselect(.type == "advisory")
stderr advisoriesselect(.level and (.type | not))
every other recordselect(.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. finding carries function-synthesized-stub and function-identity-uncanonicalized; warning carries 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.

CodeAxisFires onMeaning
data-loss-on-applyinformationop 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 advisoryapplying the op discards or narrows existing data
apply-risk-validating-fkavailabilityAddForeignKey with not_valid unseta validating FOREIGN KEY scans and locks the table; author it not_valid=true, then ValidateConstraint later
apply-risk-validating-checkavailabilityAddCheck with not_valid unseta validating CHECK scans and locks the table; same NOT VALID / VALIDATE split
apply-risk-add-not-nullavailabilityAddNotNull, unless a validated CHECK proves the column NOT NULL at the op’s input cutSET NOT NULL scans the table under an exclusive lock
apply-risk-add-uniqueavailabilityAddUniquebuilding the unique index takes an exclusive lock for the duration of the build
apply-risk-rename-in-useavailabilityRenameTable, RenameColumnrenaming 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-nullavailabilityvalidate --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 envelopethe 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-unsealedavailabilityvalidate --fail-on-apply-risk only: a schema with no Seal, so its window begins at rootnames how many tables the window treated as newborn (fields schema, table_count); never changes the exit
data-motion-cascade-scopescopeevery data-motion operatornames how many columns and same-table constraints/indexes cascaded from the source; foreign keys are not cascaded
data-motion-source-consumedscopeMergeTablethe 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-independencescopeCopyTablethe copy and its source are independent identities from this op forward; later edits to one do not propagate
data-motion-fresh-sequencesscopeCopyTable or SplitTable over a source with an IDENTITY columnthe 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-declarativescopeSplitTableSplitTable 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-owedscopeExtractTable, CopyTable, SplitTable over a declared reference table; JoinTable with a declared source; MergeTable with a declared source and a declared intoone 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-inheritedscopeCopyTable, SplitTable, ExtractTable over a declared reference tableone 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-unjudgedscopevalidate: 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 itone 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-transactionatomicityChangePrimaryKey cascading ≥1 dependent FK rewritethe cascade must run inside one transaction to apply atomically; the default --envelope=own satisfies this, caller / independent do not
drift-skip-internal-defectscopedrift: 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-unclassifiedscopedrift: 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 structured notes[] (JSON) / one note: <code>: <message> stderr line per entry (text). It is the only verb that emits the data-motion-* / cascade-* family: those advisories describe an authored op’s genealogy scope, which no other verb mints. At most one apply-risk-* finding per op. Under JSON the same notes[] appears on both the appended record and the terminal envelope (see the op add row).
  • drift: data-loss-on-apply and apply-risk-* only; both ride the op-applied record’s warnings[] (JSON) or a warning: stderr line (text), mode-exclusively: a finding appears on exactly one channel per mode, never both.
  • import: the apply-risk-* findings are not emitted per op. They fold into one run-level import-apply-risk-summary stdout stream record whose by_kind map counts them by code (see the import rows).
  • 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, with migrate’s own newborn walk, it raises the apply-risk-window-* codes above the mode split and fails through the structural-gate envelope (exit 2), Detail by code and count, Hint naming each code’s recovery. The same facts ride data.apply_risk = {envelope?, envelope_source?, counted[]{code, schema, opid, table, pair?}, unsealed[]{schema, table_count}}, present whenever the flag is set; envelope / envelope_source appear only when a same-window pair was counted. Without the flag validate emits 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:

SurfaceShape
exclusion-appliedadvisory 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 arraythe 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 tallyimport-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-referencedadvisory 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-dependentrefusal (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-logrefusal (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:

  1. No suppression: retrofit wrapped the file, so there was nothing to advise about.
  2. 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.
  3. 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.
  4. The advisory tier was quieted: -qq drops 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=json carries it in full at every level.
  5. The condition was attested away: --allow-external-atomicity states 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

ConditionExitGroup
Success or non-fatal warning0GroupSuccess / GroupWarning
Lex/parse failure (toml/oplog parse, refusal-class envelopes such as migrate’s)1GroupLexParse
User or environment error, dirty-write refusal, recovered-panic internal error, a loss-bearing import skip, or an unreconciled drift skip2GroupUserEnv / GroupDirtyWrite / GroupInternal / GroupImportLoss / GroupDriftLoss
Editor unavailable, no confirmation channel3GroupEnvUnavailable
SIGINT / editor-interrupted130GroupSignalCancel

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-gaps downgrades 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:

TypeShapeMeaning
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-changedthe same shape, with repairs[].kind = type-changedA 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 --as flags: 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 can read line-by-line in shell pipelines, and jq -c round-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 | jq for 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 trailing error: line that carries the message. The op revise -i editor buffer’s block also embeds <code>: <message>, because the buffer is what the user edits on retry and the error: 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 show over a 100k-op log can’t buffer 50MB into a single JSON object. Per-line records preserve streaming and let consumers tail output incrementally.

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