Public, documented API for module-owned migration chains that adopt a
core-baseline table (dev_docs/guides/2026-09-05-module-table-extraction-guide.md)
to verify its shape before claiming ownership — not just its presence.
The bug this exists to let module authors close
A module chain adopts a core table with CREATE TABLE IF NOT EXISTS /
ALTER TABLE ... ADD COLUMN IF NOT EXISTS, then stamps ownership with
COMMENT ON TABLE ... IS '<marker_prefix><N>'. IF NOT EXISTS checks
existence, never shape: a column narrowed by hand outside migrations
(say, a money column quietly changed from numeric(15,2) to
numeric(10,2) by an operator working straight against the database)
survives adoption completely silently — exit 0, marker written, the
narrower type never noticed. This module closes that gap by giving a
module's adoption step a way to assert "the table I am about to claim
actually has the shape I expect" before it writes the marker.
This module is a thin, stability-committed facade over
PhoenixKit.Migrations.Repair.Differ and PhoenixKit.Migrations.Repair.Probe
(core's existing structural-diff engine for mix phoenix_kit.repair) —
unlike those two, which carry no external-API stability claim, this
module's public functions are safe for a module package to depend on
across core releases.
verify_shape/3 — the vocabulary
checks is a flat list of per-object shape declarations, in the exact
same class/check/expected-shape vocabulary
PhoenixKit.Migrations.ExpectedSchema already uses internally for its
own manifest entries (see PhoenixKit.Migrations.ExpectedSchema.Object's
moduledoc, "Per-class shape keys") — so a module author copies a
familiar shape, not a novel one. A :column check, matching a real
manifest entry's fields exactly:
%{
class: :column,
check: {:catalog, %{kind: :column, table: "phoenix_kit_subscription_types", column: "price"}},
expected: %{type: "numeric(15,2)", not_null: true, default: nil}
}An :index check (a real, current core manifest entry —
phoenix_kit_users_username_uidx, a partial unique index):
%{
class: :index,
check: {:catalog, %{kind: :index, name: "phoenix_kit_users_username_uidx"}},
expected: %{unique: true, method: "btree", keys: ["username"], opclasses: ["citext_ops"], predicate: "(username IS NOT NULL)"}
}A :constraint check (primary key):
%{
class: :constraint,
check: {:catalog, %{kind: :constraint, table: "phoenix_kit_orders", name: "phoenix_kit_orders_pkey"}},
expected: %{type: "p", columns: ["uuid"]}
}Only the fields PhoenixKit.Migrations.Repair.Differ.compare/3 actually
reads for that class need to be present in expected — see its
moduledoc for the exact field list per class (columns: type,
not_null, default; indexes: unique, method, keys, opclasses,
predicate; constraints: dispatched on expected.type, see Differ).
verify_shape/3 validates this itself before ever calling Differ — see
"Never raises, even on a malformed checks entry" below.
Building a real expected map — don't hand-copy manifest source text
expected values must be the CANONICAL text the querying server itself
renders (format_type/pg_get_expr/pg_get_indexdef output) — never a
SQL-source spelling. character varying(120), never varchar(120); a
timestamp default is either now() or CURRENT_TIMESTAMP depending on
how it was declared, and those are two different strings that do not
compare equal to each other. Any default expression that references a
schema-qualified function or type must be qualified for the REAL prefix
you are checking against, not the literal, unsubstituted
"__SCHEMA__" placeholder token core's own manifest source carries
internally — copying a manifest entry's raw fields by hand (e.g. by
grep-ing expected_schema.ex and pasting what you find) reproduces
that placeholder and manufactures a false drift on the very first check
(default: "__SCHEMA__.uuid_generate_v7()" will never equal what a real
connection reads back).
The right way to build a real expected map from an actual, current core
object is through the manifest's own public accessors, never by reading
expected_schema.ex's source directly:
manifest = PhoenixKit.Migrations.ExpectedSchema.objects(prefix)
object = Enum.find(manifest, &(&1.id == "column:phoenix_kit_subscription_types.price"))
expected = PhoenixKit.Migrations.ExpectedSchema.Object.newest_shape(object)ExpectedSchema.objects/1 already substitutes "__SCHEMA__" for the
real, validated prefix you pass it — across every field, including
every :revisions entry (the same per-object substitution
PhoenixKit.Migrations.ExpectedSchema.Object.materialize/2 performs;
objects/1 applies its own equivalent internally, so there is no need to
call materialize/2 again on what it returns). Object.newest_shape/1
is the right shape to declare in checks for a table you are adopting
NOW — but ONLY for an object your own module has never itself touched.
Skip any object whose presence is :legacy_optional — spec §3.7's
bimodal drift, where either state is normal on an old install, is not a
shape a module's own fresh adoption should assert as required.
Object.newest_shape/1 is core's OWN idea of the shape — not
necessarily what your module actually declares today
If your module's own migration chain has already changed an object
beyond what it inherited from core, expected for THAT object must come
from YOUR module's own DDL/declared shape, not from core's manifest —
core's Object.newest_shape/1 still describes core's baseline, which may
no longer be what's actually on the table. This is not hypothetical:
phoenix_kit_billing's own V5 migration does
ALTER TABLE ... ALTER COLUMN user_uuid DROP NOT NULL on both
phoenix_kit_invoices and phoenix_kit_transactions — a deliberate,
intentional widening — while core's manifest still declares user_uuid
NOT NULL for both (core has not yet gone through the Phase 1 process,
@excluded_exact + a manifest regeneration, for this specific change —
see the extraction guide). A module using Object.newest_shape/1
unmodified for user_uuid on either table would get a false drift on
every single adoption run, forever, until core catches up. shape_at/2
exists for the rarer case of deliberately pinning to an older, still-valid
revision of an object your module has NOT modified; neither function is
the right tool for an object your OWN module has changed — declare that
expected by hand, from what your own DDL actually creates.
verify_shape/3 takes one Probe.snapshot/2 of the target server, then
runs every check against it and collects every drifted object in one
pass — it never stops at the first mismatch, so a caller sees the full
diff at once. An object the caller expects but that is entirely absent
(dropped by a stray manual DROP INDEX, say) is reported as drift too,
with its own "missing: ..." reason — this is deliberately not the same
question mix phoenix_kit.repair answers (whether to create it); it is
purely "does reality match what I declared".
Never raises, even on a malformed checks entry
verify_shape/3 validates every checks entry completely before it
ever reaches Probe.lookup/2 or Differ.compare/3 — neither of those
was built to reject a bad input gracefully. All of the following are
reported as drift, with an "invalid check: ..." reason, instead of
crashing or silently passing:
- the entry isn't a map, or is missing
:class/:check/:expected(would otherwise be aFunctionClauseErrorat the call site); classisn't a recognized class atom (FunctionClauseErrorfromDiffer.compare/3);check's ownkinddoesn't match the declaredclass— e.g.class: :indexon a check that actually names a:column(KeyErrorfromDiffer.compare/3reading an index-shaped field off a column-shaped observed map), or, more dangerously,class: :tableon a check that names a:column:Differ.compare( :table, _, _)always returns:matchregardless of content, so THIS specific mismatch doesn't crash at all — it silently reports a genuinely narrowed column as clean. Cross-checkingclassagainstcheck'skindcloses that hole, not just the crashing ones;expectedisn't a map at all (BadMapErrorfromMap.has_key?/2);expectedis a map but is missing a field thatclassneeds (KeyErrorfromDiffer.compare/3's own field reads).
Because this module is stability-committed for external callers
(see above), a caller mistake in its own checks list must surface as a
clear signal inside the normal {:drift, diffs} result, not a cryptic
internal crash from a module the caller does not own — and never a
silent :ok either, which is the more dangerous failure mode of the
two. This promise is specific to verify_shape/3; marker_conflict/5
makes no equivalent "never raises" claim (its documented {:error, _}
return covers real query/existence failures, not caller input errors).
~deparse~-marked reasons are full-severity drift here, unlike in mix phoenix_kit.repair
Differ's :index predicate and :constraint ("c"/"x") definition
comparisons fall back to raw pg_get_expr/pg_get_constraintdef
rendering (see Differ's own moduledoc) — two Postgres majors can
re-render an identical expression differently, so a reason resting only
on that text (Differ.deparse_text_only?/1) is not always real drift.
mix phoenix_kit.repair downgrades such a reason to informational on an
unverified major (its own server-version preflight,
PhoenixKit.Migrations.Repair's @supported_pg_majors). verify_shape/3
deliberately does not mirror that: it has no report/severity-tier
system of its own to hang an :info vs :error distinction on, and
introducing one would be new machinery this module does not otherwise
need. Adoption is also a rare, deliberate, operator-supervised migration
step — not a routine health check — so treating a possible cross-major
rendering artifact as full drift (rather than silently accepting it) is
the safer default here: worst case, a module author sees a reason to
double-check by hand; format_drift/1 still renders it plainly (stripped
of the internal marker prefix). If this proves too strict for a real
cross-major deployment, a module author's option today is to adjust its
own expected declaration for that specific object.
The recommended pattern: verify, then decline-and-raise on drift — or,
under an explicit operator opt-in, warn and adopt anyway
verify_shape/3 never raises on anything in its checks argument (see
"Never raises" above — that promise is verify_shape/3's specifically;
marker_conflict/5 DOES raise on a malformed CALL — an empty
own_marker_prefix, or a prefix Helpers.validate_prefix!/1 rejects —
those are programmer errors in how the function was called, not data the
function is meant to report on the way checks/drift is). Neither
function ever writes to the database — verify_shape/3 does run a
real, read-only Probe.snapshot/2 of the target server (which briefly
sets search_path = '' on the connection for the duration of the
snapshot; see Probe's own moduledoc for why), but neither function ever
executes DDL or writes a comment. This module is deliberately
mode-agnostic — it reports; the calling module chain decides how to
react. The following is the decided, shared contract for that reaction,
common to this module and phoenix_kit_legal's own adoption step (its
issue #23) — not something Adoption enforces itself.
Run the additive CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS statements first (as today), flush() them so they have
actually landed, then verify shape, then react to the result:
- By default — decline the marker and raise, with the diff
rendered into the message via
format_drift/1. Never silently skip, never auto-repair. - Under an explicit, per-host operator opt-in (a config toggle the
module chain itself reads — e.g.
Application.get_env(:my_module, :adoption_shape_check, :raise) == :warn; the exact key is the module's own choice, this module does not define or read one) — render the SAME diff viaformat_drift/1into anLogger.error/1call instead of a raise, write the ownership marker anyway, and let the migration succeed and the chain's version advance. The migration must NOT fail in:warnmode:mix phoenix_kit.updateregenerates a module's migration file on every run it is invoked for, and a migration that never completes would be regenerated and re-attempted forever.:warnaccepts the drift, it does not hide it — but the:error-level log line at the moment of adoption is the durable record of that, not a promise that a LATERmix phoenix_kit.repair/doctorrun will independently rediscover the same drift:Differitself excludes thenot_null: true, default: nilshape from comparison entirely (the same exemption this module separately compensates for — see below — repair has no such compensation, so it would never have seen that specific drift either), and any object a module has since gone through Phase 1 for (@excluded_exact, core's manifest regenerated to match the module's new shape) simply stops being asserted by core at all, at which point repair/doctor cannot see it as a mismatch by design, not by omission.
This is an explicit, reviewed, per-host operator decision — never a
default, never something a module flips on for itself. It exists for the
same reason marker_conflict/5's overwritable: option exists: some
hosts have a legitimate, already-understood reason their shape does not
match, and the alternative to a deliberate override is adoption never
completing on that host at all.
This same pattern (default raise, :warn opt-in) is mirrored, in short
form, in dev_docs/guides/2026-09-05-module-table-extraction-guide.md.
A real module chain's coordinator implements up(opts \\ []) (the
decentralized-migrations contract mix phoenix_kit.update discovers via
migration_module/0) and is itself invoked from inside a real,
generated Ecto.Migration callback — so execute/1 and repo() (both
from Ecto.Migration, available because the coordinator does
use Ecto.Migration) are the real DDL/repo-access mechanism, not
repo.query!/2. execute/1 only queues a command on the migration
runner (Ecto.Migration.Runner) — it does not run it immediately — so a
flush() between the queued CREATE TABLE and the read verify_shape/3
performs is required, or the verify would see the table as entirely
missing rather than checking its shape at all:
defmodule MyModule.Migrations do
use Ecto.Migration
require Logger
alias PhoenixKit.Migrations.Adoption
@marker_prefix "mym_schema:"
def up(opts \\ []) do
prefix = Keyword.get(opts, :prefix, "public")
repo = repo()
p = "#{prefix}."
execute("""
CREATE TABLE IF NOT EXISTS #{p}phoenix_kit_subscription_types (
"uuid" uuid PRIMARY KEY DEFAULT #{p}uuid_generate_v7(),
"price" numeric(15,2) NOT NULL
)
""")
# `execute/1` above only QUEUES the CREATE on the migration runner —
# `flush()` forces it to actually land before the read below, or
# verify_shape/3 would see the table as missing, not as drift.
flush()
checks = [
%{
class: :column,
check:
{:catalog,
%{kind: :column, table: "phoenix_kit_subscription_types", column: "price"}},
expected: %{type: "numeric(15,2)", not_null: true, default: nil}
}
]
case Adoption.verify_shape(repo, prefix, checks) do
:ok -> :ok
{:drift, diffs} -> handle_drift(diffs)
end
case Adoption.marker_conflict(
repo,
prefix,
"phoenix_kit_subscription_types",
@marker_prefix
) do
:ok ->
execute(
"COMMENT ON TABLE #{p}phoenix_kit_subscription_types IS '#{@marker_prefix}1'"
)
{:conflict, existing} ->
raise "MyModule cannot adopt phoenix_kit_subscription_types: " <>
"it already carries an unrelated marker (#{inspect(existing)})"
{:error, reason} ->
raise "MyModule could not read phoenix_kit_subscription_types' " <>
"current comment to check for a marker conflict: #{inspect(reason)}"
end
end
# Default: decline and raise, with an executable diff. Under an
# explicit, reviewed, per-host operator opt-in
# (`config :my_module, :adoption_shape_check, :warn` — the exact
# key is MyModule's own choice, `Adoption` neither defines nor
# reads one), log the SAME diff at :error and return normally
# instead — `up/1` then proceeds to write the marker anyway and
# the migration succeeds, so the chain's version advances rather
# than regenerating and re-attempting this same migration forever.
# The drift stays visible in the log either way; `:warn` accepts
# it, it does not hide it.
defp handle_drift(diffs) do
diff_text = Adoption.format_drift(diffs)
case Application.get_env(:my_module, :adoption_shape_check, :raise) do
:warn ->
Logger.error("""
MyModule: phoenix_kit_subscription_types has drifted from the shape \
MyModule expects, but :adoption_shape_check is set to :warn — adopting \
anyway and writing the ownership marker.
#{diff_text}
""")
_ ->
raise """
MyModule cannot adopt phoenix_kit_subscription_types: its shape on \
this database does not match what MyModule expects.
#{diff_text}
This is not something `mix phoenix_kit.repair` can fix for you: \
repair is additive-only (it can add a missing column or index, \
never alter an existing one's type, nullability, or definition) \
and has no notion of module ownership. Inspect the diff above, \
check the actual data before deciding (a narrowed column may \
already hold truncated values), then either run the corrective \
DDL by hand to bring the table back to this shape, update \
MyModule's own `expected` declaration if the drift is \
intentional, or set `:adoption_shape_check` to `:warn` for \
this host if you have reviewed the diff above and accept it. \
Re-run this migration once you have done one of those.
"""
end
end
endWhy decline-and-raise, not auto-fix
Auto-fixing DDL during unattended adoption (at app boot, with no operator
watching) carries the same silent-mutation risk this module exists to
close, just inverted — a column silently widened back is a smaller but
structurally identical mistake to a column silently narrowed: the host's
schema changes without anyone being told. mix phoenix_kit.repair
remains the one tool that ever issues DDL automatically — but only for
CORE's own tables, adding a genuinely missing column or index it created
in the first place. For a module-adopted table specifically, as the
worked example's raise message above notes, repair cannot help at all
(it is additive-only, and has no notion of module ownership) — a real
fix here is always a manual one. Keeping adoption's own shape check
detection-only mirrors the same principle for the one case where no
tool, repair included, can safely auto-correct: detection is safe to run
unattended, DDL mutation is not. An unhandled raise during a migration
halts the chain (the version marker is never advanced), so the host does
not boot into a half-adopted state and the operator sees exactly what
disagreed.
"Auto-fix" and "operator override" are different things, and only the
first is ruled out here. :warn (above) is a documented, deliberate
override — an operator explicitly saying "I have reviewed this drift
and accept it, adopt anyway" — not automatic repair: nothing DDL-wise
changes about the table itself, only whether the marker gets written
over the drift instead of adoption halting. The mode switch lives in the
calling module, not here, by design (see above).
marker_conflict/5
Reads the current COMMENT ON TABLE for an arbitrary, caller-given
table (not core's own phoenix_kit meta table — this is a new,
parameterized query, schema-anchored the same way
PhoenixKit.Migrations.Repair.Probe.raw_comment/2 anchors its read of
core's own marker). :ok when the comment is absent, nil, already
starts with the caller's own own_marker_prefix (an ordinary version
bump by the SAME module), or is a known core pre-squash legacy
descriptive comment (see below); {:conflict, existing_comment} for
anything else — another module's marker, or an operator's own
hand-written documentation comment on the table — so the caller can
decline to overwrite it rather than silently stomping it.
{:error, reason} when the query itself failed — this is deliberately
distinct from :ok; a caller that cannot tell whether a conflict exists
must not proceed as if there is none.
own_marker_prefix must be non-empty (an empty string would make every
comment match String.starts_with?(comment, "") and this function would
always report :ok, defeating the whole check) and must include the
same trailing separator the caller's own marker-write statement uses
(the existing convention across phoenix_kit_billing/phoenix_kit_legal
is a trailing colon, e.g. "pkb_schema:") — a prefix without it risks a
false match against an unrelated but similarly-named module
("pkb_schema" would also match a hypothetical "pkb_schema_v2:1").
Core's own pre-squash legacy comments
Before the squash at V135, core itself wrote plain descriptive (not
marker-shaped) COMMENT ON TABLE statements on baseline tables — not
only the handful billing/legal happen to adopt today, but essentially
every table core created before the squash, ~48 in total (storage,
posts/social, billing, AI, support tickets, user relationships, db sync,
shop, consent — see @core_legacy_table_comments for the exact table
list and text, confirmed by reading every descriptive COMMENT ON TABLE
in the pre-squash source directly, not a partial sample). On any host
installed before the squash, that text is still there for whichever of
those tables it created — not nil, not the adopting module's own
marker prefix. marker_conflict/5 treats an exact match against one of
these known strings, for its own table, as automatically overwritable
(the same outcome as "no comment"), so a correctly-written adoption step
does not fail on any pre-squash host adopting any core-baseline table —
not just the ones known modules adopt today, but future ones too (the
next module in the extraction series adopting, say, phoenix_kit_posts
or phoenix_kit_tickets, hits the same class of host and is already
covered).
overwritable: is for a comment marker_conflict/5 does NOT already
recognize as safe — core's own pre-squash comments are covered
automatically by the list above and never need this option. Typical use:
an operator's own hand-written note on the table, independently reviewed
and confirmed safe to overwrite for this specific adoption:
Adoption.marker_conflict(repo, prefix, table, @marker_prefix,
overwritable: ["an operator's own note, reviewed and confirmed safe"]
)This does not widen what counts as safe by default — only the two
sources above (core's own known list, and whatever the CALLER
explicitly supplies) are ever treated as overwritable; anything else
still declines with {:conflict, existing_comment}.
Summary
Types
One drifted object: the check that found it, and every disagreeing field's reason.
One object's declared expectation: class picks the
PhoenixKit.Migrations.Repair.Differ.compare/3 clause, check locates
the observed object in a Probe.snapshot/2 result (same shape
PhoenixKit.Migrations.ExpectedSchema.Object.check/0 uses), expected
is the per-class shape map Differ.compare/3 reads fields out of — see
the moduledoc for concrete examples per class.
Functions
Renders a verify_shape/3 {:drift, diffs} result's diffs list into
one consistent, human-readable, actionable message — so every module's
adoption-failure raise looks the same instead of each hand-rolling its
own inspect/1. See the moduledoc's worked example.
Reads the current COMMENT ON TABLE on table (schema-anchored to
prefix) and decides whether the caller may write its own marker there.
Verifies every declared checks entry against the real, current shape
of prefix on repo — one Probe.snapshot/2 round trip, then a
Differ.compare/3 per check. Returns :ok when every object matches;
{:drift, diffs} (never stopping at the first mismatch) otherwise, one
drift/0 entry per object that disagreed, was entirely missing, or
whose own checks entry was itself malformed (see the moduledoc's
"Never raises" section).
Types
@type drift() :: %{ check: PhoenixKit.Migrations.ExpectedSchema.Object.check(), reasons: [String.t()] }
One drifted object: the check that found it, and every disagreeing field's reason.
@type shape_check() :: %{ class: PhoenixKit.Migrations.ExpectedSchema.Object.class(), check: PhoenixKit.Migrations.ExpectedSchema.Object.check(), expected: map() }
One object's declared expectation: class picks the
PhoenixKit.Migrations.Repair.Differ.compare/3 clause, check locates
the observed object in a Probe.snapshot/2 result (same shape
PhoenixKit.Migrations.ExpectedSchema.Object.check/0 uses), expected
is the per-class shape map Differ.compare/3 reads fields out of — see
the moduledoc for concrete examples per class.
Functions
Renders a verify_shape/3 {:drift, diffs} result's diffs list into
one consistent, human-readable, actionable message — so every module's
adoption-failure raise looks the same instead of each hand-rolling its
own inspect/1. See the moduledoc's worked example.
This is the SAME rendering a caller uses for both reactions in the
decided :warn-override contract (see the moduledoc's "recommended
pattern" section): the message inside a default-mode raise, and the
message inside an :warn-mode Logger.error/1 call when an operator
has explicitly opted a host into adopting over drift instead of halting.
format_drift/1 itself has no opinion about which of those a caller
chooses — it only renders the diff.
A ~deparse~-marked reason (see the moduledoc's "~deparse~-marked
reasons" section) has its internal marker prefix stripped for display —
the raw, marked reason is still exactly what verify_shape/3 itself
returns in diffs; only this rendering strips it.
@spec marker_conflict(Ecto.Repo.t(), String.t(), String.t(), String.t(), keyword()) :: :ok | {:conflict, String.t()} | {:error, term()}
Reads the current COMMENT ON TABLE on table (schema-anchored to
prefix) and decides whether the caller may write its own marker there.
:ok when the table has no comment, its comment already starts with
own_marker_prefix, or the comment exactly matches a known-overwritable
string (core's own pre-squash legacy comments, or opts[:overwritable] —
see the moduledoc); {:conflict, existing_comment} otherwise;
{:error, reason} when the comment could not be reliably read at all.
Never writes anything — the caller decides what to do with a conflict
(the recommended pattern, per the moduledoc, is to decline and raise).
Options
:overwritable— a list of exact comment strings, in addition to core's own known pre-squash legacy comments, that the caller has independently confirmed are safe to overwrite. Defaults to[].
@spec verify_shape(Ecto.Repo.t(), String.t(), [shape_check()]) :: :ok | {:drift, [drift()]}
Verifies every declared checks entry against the real, current shape
of prefix on repo — one Probe.snapshot/2 round trip, then a
Differ.compare/3 per check. Returns :ok when every object matches;
{:drift, diffs} (never stopping at the first mismatch) otherwise, one
drift/0 entry per object that disagreed, was entirely missing, or
whose own checks entry was itself malformed (see the moduledoc's
"Never raises" section).
Never raises and never executes DDL — purely a read. See the
moduledoc's "recommended pattern" for how a caller is expected to react
to {:drift, diffs}.