Repository navigation
feat(migrations): foreign defaults are drift on every door (#628) - #634
Merged
0x054 merged 5 commits intoOct 11, 2026
Merged
Conversation
This was referenced Oct 11, 2026
Closed
0x054
commented
Oct 11, 2026
0x054
left a comment
Contributor
Author
There was a problem hiding this comment.
Spec: PASS
- Minor ·
src/codec.rs,src/codec_plan.rs,src/operations.rs,src/query.rs,src/schema.rs,src/state.rs(plus the non-default hunks insrc/introspect.rs,src/migrate.rs,src/live_ir.rs) at 7b9fe27 · Extra · Every hunk in these six files isrustfmtreflow; none of them strips a default. The PR body's "Outside Files owned" list leaves them out. The cause is real: the base is not fmt-clean (rustfmt --checkonsrc/state.rsat c5b9e72 exits 1), and Verify'sprek run --all-filesruns thefmthook, so Verify forces the churn. The ticket's Files owned does not list these files, so the PR body should declare them as formatter output. - Minor ·
tests/test_migrate_defaults.pytest_a_serial_keys_sequence_is_never_drift_on_an_up_or_pass_built_databaseat 7b9fe27 · The name promises anup-built database, but the test builds only with the pass. AC2'sup-built and renamed-table side is still pinned: pin (e) runsferro migrate drifton the migrated database ofB3-rename-a-model, which passed on Postgres. The name overstates what the test covers.
Checked against the ticket:
- AC1: drift line and exit 4, baseline refusal, destructive
DROP DEFAULTin thePassReport, updates warning, and autogenerate'salter_column(server_default=None)with adowngrade()that raises "set outside ferro" are each covered on Postgres intest_migrate_defaults.py. - AC2:
owns_sequenceispg_get_serial_sequence(format('%I.%I', …)) IS NOT NULL(src/introspect.rs). The planner reads onlyautoincrement. No code matches onnextvaltext, and a Rust test shows the samenextvaltext without ownership is foreign. - AC3:
foreign_defaultgives SQLitenullable || !is_sqlite_literal. SQLite's backfill defaults (literal_default_value,render_json_backfill_default→'…') are always literals, so a pass-built file is never flagged. - AC4:
Side::foreign_defaultreturnsNonefor a declared side, andwritable_iris the identity there. A new test walks every casebook case and finds noDropDefault. The generator andgenerate/are untouched. - AC5:
NOT_COMPAREDand the adopting howto changed. The howto's "When baseline says no" names both fixes and the SQLite hand rebuild. - AC6: the verdict is decided once in
verdict()/sqlite_rebuilds. Goldens assert Native (PG) and Rebuild (SQLite),drops_data=false, and anIrreversible(reason)down on both dialects. They assert the outcome, not the implementation. - AC7: case
A2-foreign-default-on-the-before-sideruns through pins (a)–(g) on both dialects.FINDINGShas no new entry.FOREIGN_DEFAULTS_LEFTnames only the SQLite default the narrowing keeps. **Narrows:**is honoured: on SQLite the pass emits aSqliteInPlacewarning namingferro migrate new … --sql-stepand runs no DDL.- Declared out-of-scope files:
ferro-ddl-lowering/src/lib.rs(ADR-0052 home for sentences),revision.rs(has_twinand the target's writable values),run_plan/step_class.rs(#633's literal line plus the exhaustive-match arm) and thepass_report.pydocstring. I accept all four. - I-1 item 11: the enum-types and checks families and
translate.pydispatch are untouched apart from the newDropDefaulttwin branch.
Cannot verify:
test_session_settings_operations::test_every_operation_shape_is_scoped[postgres]is flaky under-n 8, per the worker. That file is outside Verify and outside this diff, and I did not reproduce it.
Quality: FAIL
- Important ·
crates/ferro-migrate/src/plan.rs:4628at 7b9fe27 · The new test helperforeign_default_tests::columnbuilds aSchemaColumnwithoutenum_retired_labels. That field became required when #633 landed on the integration branch (682c30b). I mergedorigin/feat/pinch-migration-needslocally, unpushed, into the PR head, andcargo test -p ferro-migrate --no-runfails:error[E0063]: missing field 'enum_retired_labels' in initializer of 'ferro_schema_ir::SchemaColumn'atplan.rs:4629in the merged tree. On the same merged tree,cargo test --no-default-features --features testing --no-runandcargo check --all-targetscompile clean. The textual merge is clean, so merging this PR as it stands breakscargo test -p ferro-migrateon the integration branch. Fix: rebase ontofeat/pinch-migration-needsand addenum_retired_labels: Default::default()to the literal. Theretired: falseline instep_class.rsthen drops out, as the PR body says.
Checked against the laws:
- Live defaults never reach a written value.
render_fromreadswritable_ir()on both sides,verdict()readstarget.writable_values()(soneeds_values/demands_values/fails_on_rowssee no catalog text), andrevision::writtenreadsplan.target.writable_ir()(sohas_twinsees none).emit.rs's backfill reads only those stripped models or declared ones.a_live_default_is_never_a_value_a_down_writespins that a down restoring a dropped required column stilldemands_values. Pin (e)column_defaultsandKEPT_DEFAULTSare unchanged and green. - Concern (1): the up is Native/Rebuild, and the down, planned models → live, carries
Execution::Irreversible(reason)throughSide::expresses, the same shape asRemoveEnumLabel. The reason reaches Alembic'sdowngrade()asraise RuntimeError(…set outside ferro…). This matches the ticket's prose, and the AC phrase names the same direction.
Verify: passed on 7b9fe27
uv run maturin develop: exit 0cargo test -p ferro-migrate: 419 passed, exit 0cargo test --no-default-features --features testing: 354 passed, exit 0uv run pytest …(the 10 files): 998 passed, 20 skipped, 1 xfailed, exit 0uv run prek run --all-files: exit 0, tree clean after
The worker's sdd-verify note is present on HEAD.
…drops foreign ones The live read now carries each column's server default and whether it owns a sequence. The one planner plans a DropDefault for every foreign default (a live default ferro did not leave): native on Postgres, a rebuild on SQLite, under migrate_destructive only, and reported otherwise; its down toward a live database is irreversible with its reason. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
…eign defaults drift prints '<table>.<column> has default <literal> in the database, none declared'; baseline refuses it and its success sentence now says only that objects ferro does not own were not compared; the Alembic bridge writes alter_column(server_default=None) with an irreversible downgrade. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
… side Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
…-migrate guide Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
The planner's test column gains enum_retired_labels; the rustfmt-only hunks in files this change does not touch are dropped; the serial-sequence test is split into its up-built and pass-built halves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
0x054
force-pushed
the
sdd/628-foreign-defaults-are-drift
branch
from
October 11, 2026 18:23
7b9fe27 to
2821853
Compare
0x054
commented
Oct 11, 2026
0x054
left a comment
Contributor
Author
There was a problem hiding this comment.
Re-review: 7b9fe27 → 2821853 (rebased onto 682c30b)
Spec: PASS
- Minor · formatter-only files left undeclared · ADDRESSED ·
src/codec.rs,src/codec_plan.rs,src/operations.rs,src/query.rs,src/schema.rs,src/state.rsandsrc/migrate.rsare no longer ingit diff 682c30b 2821853. The PR diff now touches 21 files, all owned or declared. - Minor · the "up or pass" test name · ADDRESSED · It is now two tests:
tests/test_migrate_defaults.py:292test_a_fresh_tables_key_sequence_is_never_drift_where_up_built_itbuilds the database withferro migrate upand asserts thatdriftexits 0 with "no drift".tests/test_migrate_defaults.py:304…_where_the_pass_built_itbuilds it with the pass (built_by_the_pass), then checks drift and baseline.
Quality: PASS
- Important ·
foreign_default_tests::columnwas missingenum_retired_labels· ADDRESSED ·crates/ferro-migrate/src/plan.rs:4648at 2821853 now setsenum_retired_labels: Default::default(). Because of the rebase, theretired: falseline instep_class.rscomes from the base and no longer from this PR. - Merge preview, re-run: I merged
origin/feat/pinch-migration-needslocally without pushing. It is now at cee6a19, which adds #635 since the rebase. The merge was clean, and each of these compiled with no errors:cargo check --all-targets,cargo test -p ferro-migrate --no-runandcargo test --no-default-features --features testing --no-run. I then rangit merge --abort. - The fix diff (
8927cff..2821853) adds no new breakage. It holds only the one field, the reverted formatting and the split test.
Verify: passed on 2821853
uv run maturin develop: exit 0cargo test -p ferro-migrate: 419 passed, exit 0cargo test --no-default-features --features testing: 354 passed, exit 0uv run pytest(the 10 files): 1001 passed, 20 skipped, 1 xfailed, exit 0uv run prek run --all-files: exit 0, tree clean after
The sdd-verify note on HEAD reads "verified 2026-10-11T18:23:08Z".
1 of 3 tasks
0x054
added a commit
that referenced
this pull request
Oct 12, 2026
…efaults and DropDefault runs on every door (#628) (#634) `ferro migrate drift` and `ferro migrate baseline` see column defaults. The live read carries each column's server default on both dialects, and the one planner plans a `DropDefault` op for every foreign default, a live default ferro did not leave. `drift` prints `<table>.<column> has default <literal> in the database, none declared`; `baseline` refuses it and its success sentence now says only that objects ferro does not own were not compared; the Postgres pass drops it under `migrate_destructive` and reports it; the SQLite pass warns naming the migrations door (the pass never rebuilds a table); the Alembic bridge writes `alter_column(server_default=None)` with an irreversible downgrade. Ferro's own defaults are never drift: the serial sequence a Postgres column owns, and a literal on a `NOT NULL` SQLite column. A casebook case pins every door. Closes #628 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #628
What
Someone sets a default by hand that no model declares:
Every door now sees it:
baselinerefuses it with the same line, exits 4 and records nothing.connect(migrate_destructive=True)drops it, and thePassReportlistsALTER TABLE "author" ALTER COLUMN "slug" DROP DEFAULT.migrate_updatesleaves it and warns, naming both fixes.op.alter_column('author', 'slug', server_default=None, …). Itsdowngrade()raises with the reason: no declared side holds the default, so there is nothing to put back.Ferro's own two defaults never show up as drift:
serialkey'snextval(...), recognised by the sequence the column owns (pg_get_serial_sequence) and never by matching its text. A renamed table's sequence therefore still counts as ferro's.NOT NULLcolumn, whatever the literal. This is the default ADR-0034 keeps when the pass adds a required column.On SQLite an expression default or any default on a nullable column is drift. The pass cannot rebuild a table, so it warns, names the fix (a hand rebuild, or a
ferro migrate new … --sql-step) and changes nothing.The successful-baseline sentence now reads:
baseline checked what ferro migrate drift checks: objects ferro does not own (functions, triggers) were not compared.How it works: the live read now carries each column's default (
LiveColumn.column_default) and whether the column owns a sequence (owns_sequence). The live IR copies these intoSchemaColumn.defaultandautoincrement. Inside the one planner,foreign_default(column, dialect)decides whether a default is foreign andplan_from_irplansMigrationOp::DropDefault { table, column, was }. The op's verdict is Native on Postgres and Rebuild on SQLite, withdrops_data=false.Plan::renderrenders it once. A plan between two declared snapshots never carries the op, so every generated file is unchanged.Verify
Ran the ticket's Verify block on
7b9fe27: passed (cargo: 419 + 354 tests; pytest: 998 passed, 20 skipped, 1 xfailed, which is the existing C2 finding; prek clean).TDD: RED
uv run pytest --db-backends=sqlite,postgres tests/test_migrate_defaults.py -xfailed as expected (drift printedDropDefault on author, because the drift module had no renderer for the new op yet). GREEN, same command: 16 passed, 10 skipped (dialect-specific cases).The full suite (
-n 8) had 1 failure,test_session_settings_operations.py::test_every_operation_shape_is_scoped[postgres]. It passes when run alone and does not touch defaults; it looks load-sensitive.Verify block corrected: no
Notes for the reviewer
DropDefaultis Native/Rebuild. The down (plan_down, used by the bridge'sdowngrade()) plans models → live, and the live side's foreign default comes back as the same op withExecution::Irreversible(reason)throughSide::expresses. This is the same pattern asRemoveEnumLabeltoward a live type. Two declared snapshots never plan it at all.defaultis now catalog text, soSide::writable_values/writable_irstrip it wherever a statement could write a value: the renderer, the verdict'sneeds_values, and the bridge'shas_twin. A down that restores a dropped required column therefore still demands values, as before. This is pinned ina_live_default_is_never_a_value_a_down_writes.wasfolds the cast Postgres adds to a literal for display:''::character varyingbecomes''. Any other text is kept as the catalog prints it.ReportKind::ForeignDefault { was }is themigrate_updateswarning (recurring, answered by aDropDefault).InPlaceChange::DropDefaultis the SQLite rebuild warning.A2-foreign-default-on-the-before-sidecarries aCase.hand_edit. Pins (e) defaults, (f) and (g) run it on the database they build at the before side. Pin (e) has a third database that the pass reconciles undermigrate_destructive.FOREIGN_DEFAULTS_LEFTnames the one default SQLite keeps (it warns about it). No newFINDINGSentry. A new test checks that no casebook case's generated plan carriesDropDefault.Outside Files owned
: dropped on the rebase onto feat(schema): retired enum labels (#627) #633, which carries the same line.step_class.rscompile fix (6ead977)crates/ferro-migrate/src/run_plan/step_class.rsalso gets one match arm,DropDefault→Expand. The match is exhaustive, and the op is never in a generated step anyway.crates/ferro-ddl-lowering/src/lib.rs: the report kind, the in-place change and their two sentences (foreign_default_warning,sqlite_drop_default_report). Every warning lives in this crate (ADR-0052).crates/ferro-migrate/src/revision.rs:DropDefaultis Alembic's own op (has_twin), and the bridge reads the target's writable values.crates/ferro-migrate/src/render.rswas owned.generate/rebuild.rsneeded no change, because the generator never meets the op.src/ferro/pass_report.py: one docstring word, addingForeignDefaultto the kinds list.Issue status:
Closes #628above (on the integration branch, the orchestrator closes it after merge).Fix report (review round 1)
New head
2821853, rebased ontoorigin/feat/pinch-migration-needs.src/live_ir.rshad no conflict.foreign_default_tests::columnnow hasenum_retired_labels: Default::default(). That literal was the onlySchemaColumnliteral this PR adds, andsrc/live_ir.rsalready has the field from feat(schema): retired enum labels (#627) #633.cargo check -p ferro-migrate --all-targetsandcargo check --all-targetsare clean.cargo test -p ferro-migrate: 419 passed.src/codec.rs,codec_plan.rs,operations.rs,query.rs,schema.rs,state.rsandmigrate.rsare back to their base contents.prek run --all-filespasses without them. Thecargo fmt --checkdiffs left in those files were already on the base.test_a_fresh_tables_key_sequence_is_never_drift_where_up_built_it(up, thendriftexits 0) and…_where_the_pass_built_it(pass, thenbaselinerecords).2821853, each exit status 0:maturin developcargo test -p ferro-migrate: 419 passedcargo test --no-default-features --features testing: 354 passedprek run --all-files🤖 Generated with Claude Code
https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR