Summary
DataProviderMigrate migrate diffs RLS policies by name only. If a policy's
using: / withCheck: expression changes in schema.yaml but the name stays the
same, the tool reports Schema is up to date — no operations needed and the
database keeps the old, wrong predicate — silently, forever.
This is not a cosmetic drift issue. It cost us a full production outage.
Impact (real incident, 2026-09-04)
migrations/schema.yaml declared:
- name: tenants_self_membership_select
operations: [Select]
roles: [app_user]
using: "app_tenant_id() is null and is_member(app_user_id(), id)"
Production actually had the predecessor expression:
(current_setting('app.tenant_id'::text, true) IS NULL) AND is_member(app_user_id(), id)
The corrected expression had been in schema.yaml since PR #275 and make migrate
had been run repeatedly against prod since. Every run said "up to date". The old
predicate is permanently false on any pooled connection (once any transaction on
a backend runs SET LOCAL app.tenant_id, the placeholder's reset value becomes '',
never NULL again), so GET /tenants/me returned [] for every user of the platform.
There was no signal anywhere: exit code 0, no warning, no drift report.
Reproduction
DataProviderMigrate migrate --schema s.yaml --output "$PG" --provider postgres
with a policy p whose using: is expression A.
- Edit
s.yaml so policy p keeps its name but its using: becomes expression B.
- Re-run the same command.
Expected: the tool detects the changed predicate and replaces the policy
(DROP POLICY + CREATE POLICY, gated behind --allow-destructive if you consider
the drop destructive).
Actual: Schema is up to date — no operations needed. The database still enforces
expression A.
--allow-destructive does not help — the diff is empty before phase filtering, so
there is nothing for it to permit.
Why the workaround is bad
The only way to land the fix through DataProvider was to rename the policy
(tenants_self_membership_select → ..._v2) so the differ saw it as a new object.
That leaves a dead policy in the database until a separate destructive pass, and it
puts a version suffix into a name that should be stable. Our house rules forbid
hand-written DDL, so there is no clean escape hatch.
Asks
- Diff policy
USING / WITH CHECK expressions, not just names. Comparing
pg_get_expr(polqual, polrelid) after normalisation would catch this.
- If exact expression comparison is too brittle (Postgres reformats predicates —
app_tenant_id() comes back as public.app_tenant_id(), literals get ::text
casts), then at minimum emit a warning listing every policy whose stored
expression does not match the declaration. Silence is the part that hurt.
- Same question applies to other body-bearing objects —
functions: bodyLql,
check constraints. If those are also name-only, they have the same failure mode.
Related, seen in the same run
Every migrate run ends with:
SCHEMA INTEGRITY CHECK FAILED
public.topup.status: default expected 'pending' but found 'pending'::text
The integrity checker is not normalising Postgres' ::text cast on a text column
default, so it reports a false positive on a converged schema. This trains operators
to ignore the tool's final line — which is exactly why the policy drift went unnoticed
for as long as it did. Worth fixing alongside.
Security note
DataProviderMigrate echoes the full connection string, password included, to
stdout on every run:
Output: Host=...;Port=5432;Database=postgres;Username=...;Password=<cleartext>;SslMode=Require;...
That lands in CI logs, terminal scrollback, and agent transcripts. Please mask the
password (or drop the line).
Versions: DataProviderMigrate (dotnet tool, current as of 2026-09-04), PostgreSQL 15
(Supabase), connecting through the Supavisor pooler.
Summary
DataProviderMigrate migratediffs RLS policies by name only. If a policy'susing:/withCheck:expression changes inschema.yamlbut the name stays thesame, the tool reports
Schema is up to date — no operations neededand thedatabase keeps the old, wrong predicate — silently, forever.
This is not a cosmetic drift issue. It cost us a full production outage.
Impact (real incident, 2026-09-04)
migrations/schema.yamldeclared:Production actually had the predecessor expression:
The corrected expression had been in
schema.yamlsince PR #275 andmake migratehad been run repeatedly against prod since. Every run said "up to date". The old
predicate is permanently false on any pooled connection (once any transaction on
a backend runs
SET LOCAL app.tenant_id, the placeholder's reset value becomes'',never NULL again), so
GET /tenants/mereturned[]for every user of the platform.There was no signal anywhere: exit code 0, no warning, no drift report.
Reproduction
DataProviderMigrate migrate --schema s.yaml --output "$PG" --provider postgreswith a policy
pwhoseusing:is expression A.s.yamlso policypkeeps its name but itsusing:becomes expression B.Expected: the tool detects the changed predicate and replaces the policy
(
DROP POLICY+CREATE POLICY, gated behind--allow-destructiveif you considerthe drop destructive).
Actual:
Schema is up to date — no operations needed. The database still enforcesexpression A.
--allow-destructivedoes not help — the diff is empty before phase filtering, sothere is nothing for it to permit.
Why the workaround is bad
The only way to land the fix through DataProvider was to rename the policy
(
tenants_self_membership_select→..._v2) so the differ saw it as a new object.That leaves a dead policy in the database until a separate destructive pass, and it
puts a version suffix into a name that should be stable. Our house rules forbid
hand-written DDL, so there is no clean escape hatch.
Asks
USING/WITH CHECKexpressions, not just names. Comparingpg_get_expr(polqual, polrelid)after normalisation would catch this.app_tenant_id()comes back aspublic.app_tenant_id(), literals get::textcasts), then at minimum emit a warning listing every policy whose stored
expression does not match the declaration. Silence is the part that hurt.
functions:bodyLql,check constraints. If those are also name-only, they have the same failure mode.
Related, seen in the same run
Every migrate run ends with:
The integrity checker is not normalising Postgres'
::textcast on a text columndefault, so it reports a false positive on a converged schema. This trains operators
to ignore the tool's final line — which is exactly why the policy drift went unnoticed
for as long as it did. Worth fixing alongside.
Security note
DataProviderMigrateechoes the full connection string, password included, tostdout on every run:
That lands in CI logs, terminal scrollback, and agent transcripts. Please mask the
password (or drop the line).
Versions: DataProviderMigrate (dotnet tool, current as of 2026-09-04), PostgreSQL 15
(Supabase), connecting through the Supavisor pooler.