Git merging two migration directories proves only that their filenames do not collide. ship db diff --json --no-input performs the semantic integration check against the merged database declaration.
Drop versus retained data
Branch A adds Users.timezone. Branch B adds Catalog.search_text. If the merged declaration keeps A's column but omits B's, a database at B would need to drop Catalog.search_text to reach the requested End.
ship db diff AppDb \
--name converge \
--json \
--no-input \
src/Schema.shp
Ship stops before writing a convergence migration:
{
"status": "migration_conflict",
"kind": "non_commutative_heads",
"parents": [
"483d20ebf564f6f9",
"c73d54e1a6340ac7"
],
"end": "c73d54e1a6340ac7",
"errors": [
"from 483d20ebf564f6f9: dropping column `Catalog.search_text` requires explicit migration intent; rerun with `--allow-destructive` after reviewing data loss",
"the requested End snapshot contains changes that are not the compatible union of parent c73d54e1a6340ac7 and the other migration heads"
]
}
Resolve the schema decision, not the graph:
Keep both columns in the declaration and rerun; Ship generates compatible parent histories and one empty
unifycheckpoint.If removal is intended and B is unpublished, rebase and regenerate B. If B is already live, author an ordinary reviewed reconciliation migration before unifying; an empty checkpoint never performs a drop.
Divergent type changes
If both branches change the same inherited column to different SQL types, they share one lineage ID but disagree on its End facts:
A: Catalog.price_cents Int -> Decimal(12, 2)
B: Catalog.price_cents Int -> String
The merged declaration chooses the product's actual destination type. The original branch SQL programs disagree, so Ship reports non_commutative_heads; it does not create an empty checkpoint. Reconcile on one ordered branch with an ordinary migration such as:
set_data_type(
from = Column(Start.Catalog, .price_cents),
to = Column(End.Catalog, .price_cents),
using = todo "write the SQL USING expression",
);
No unify checkpoint exists until the conversion is authored and every parent history reaches compatible semantics.
Divergent renames
Two branches can preserve one column ID under different names:
A: Users.full_name -> Users.display_name
B: Users.full_name -> Users.name
The merged declaration selects the final name, but that does not make the original rename SQL commute. The branch using the other name needs an ordinary ordered reconciliation migration. If Git leaves both names, Ship asks rename-or-create questions because two physical columns would now exist; it never assumes one should disappear.
Opaque backfill SQL
Raw SQL may read data and objects that are not visible from its declared output columns. Ship does not parse arbitrary SQL and claim a false commutativity proof.
When divergent branch tails contain backfill(...) or raw_sql(...), automatic unification stops unless those effects are already on their common path. Rebase one unpublished migration onto the other branch state, or author an ordinary reconciliation migration after reviewing both SQL programs.
What is always safe
The live database frontier never advances without all dependencies.
Missing rename or destructive intent never defaults to drop/create.
todomigrations have no executable SQL.Every accepted ordinary migration independently satisfies
interpret(Start, operations) == End.A unify checkpoint contains no SQL and collapses its complete parent frontier transactionally.