Supabase RLS Recursion: The Policy Subquery Trap


Step 1: Bootstrapping a Multi-Tenant Schema That Provokes RLS Recursion
Before we can demonstrate a row-level security recursion failure, we need a schema shaped like the ones that actually hit it in production: a teams table plus a memberships join table that itself wants to be protected by RLS. This step lays down exactly that schema, enables RLS on both domain tables, and stops short of attaching any policies. We commit the empty-policy state on purpose so the failure introduced in the next step is attributable to a single migration rather than buried in the initial scaffold.
We also wire up a tiny pytest harness that asserts structural properties of the SQL — column types, foreign keys, the role check constraint, RLS being enabled, the absence of CREATE POLICY — so the article has a deterministic green build at every commit. That harness will keep us honest later when the policy gets added and then rewritten.
Setup
We need three things on disk before any SQL runs: a Supabase CLI config, a versioned migrations folder, and a Python project that can run pytest. The Supabase folder follows the standard CLI layout so a reader who already has supabase installed can supabase start against this project without rewiring anything. The Python side uses uv and pytest — the smallest dependency set that lets us assert structural properties of the migration file.
Create the following files:
codebase/
├── pyproject.toml
├── supabase/
│ ├── config.toml
│ ├── seed.sql
│ └── migrations/
│ └── 0001_init_teams_memberships.sql
└── tests/
├── __init__.py
├── _sql.py
└── test_bootstrap_schema.py
The pyproject.toml declares pytest as the only dev dependency. No ORM, no psycopg, no Supabase Python client — the step-1 tests deliberately stay offline and assert against the raw SQL text:
[project]
name = "supabase-rls-recursion-policy-subquery"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = []
[dependency-groups]
dev = ["pytest>=8.0"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra -q"
Implementation
The Supabase config is intentionally minimal. We only set the project handle plus the local DB and API ports so that supabase start boots the same stack every reader will see; everything else stays on CLI defaults.
project_id = "supabase-rls-recursion-policy-subquery"
[db]
port = 54322
shadow_port = 54320
major_version = 15
[api]
enabled = true
port = 54321
schemas = ["public"]
extra_search_path = ["public"]
Next comes the migration. It creates a stub auth.users table because we want the demo to be runnable against a vanilla Postgres instance, not only against a hosted Supabase project where auth.users already exists. The teams table references it via owner_id; the memberships table is the join that will later poison itself with a self-referential RLS policy. The role column carries a CHECK constraint and the pair (team_id, user_id) is unique — both pieces matter, because step 2's broken policy assumes one membership row per user per team.
create extension if not exists pgcrypto;
create schema if not exists auth;
create table if not exists auth.users (
id uuid primary key default gen_random_uuid(),
email text unique not null
);
create table if not exists public.teams (
id uuid primary key default gen_random_uuid(),
name text not null,
owner_id uuid not null references auth.users(id) on delete cascade,
created_at timestamptz not null default now()
);
create table if not exists public.memberships (
id uuid primary key default gen_random_uuid(),
team_id uuid not null references public.teams(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'member')),
created_at timestamptz not null default now(),
unique (team_id, user_id)
);
create index if not exists memberships_user_id_idx on public.memberships (user_id);
create index if not exists memberships_team_id_idx on public.memberships (team_id);
alter table public.teams enable row level security;
alter table public.memberships enable row level security;
Two design choices deserve a callout. First, both indexes on memberships are mandatory: every recursion-prone query we will write in step 2 filters on either user_id or team_id, and we want Postgres to lean on a btree lookup rather than a sequential scan once the table grows. Second, RLS is enabled here but no policies are attached — Postgres treats an RLS-enabled table without policies as deny-by-default for non-superusers, which is the safe baseline we want before step 2 introduces the broken policy.
The seed file installs deterministic fixtures so query output is identical across reruns. Three users (Alice, Bob, Carol), two teams (Acme, Globex), and three memberships create one cross-team outsider relationship per direction — the exact graph the later RLS query walks.
insert into auth.users (id, email) values
('11111111-1111-1111-1111-111111111111', 'alice@example.test'),
('22222222-2222-2222-2222-222222222222', 'bob@example.test'),
('33333333-3333-3333-3333-333333333333', 'carol@example.test')
on conflict (id) do nothing;
insert into public.teams (id, name, owner_id) values
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Acme', '11111111-1111-1111-1111-111111111111'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Globex', '33333333-3333-3333-3333-333333333333')
on conflict (id) do nothing;
insert into public.memberships (team_id, user_id, role) values
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'owner'),
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '22222222-2222-2222-2222-222222222222', 'admin'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '33333333-3333-3333-3333-333333333333', 'owner')
on conflict (team_id, user_id) do nothing;
Finally the test harness. tests/_sql.py exposes two small helpers: normalized() strips SQL comments and collapses whitespace, and create_table_body() returns the column list of a named create table so individual assertions can target one chunk at a time. Keeping the helpers in a private _sql.py module makes the test file read like a checklist of invariants rather than a wall of regexes.
def normalized(sql: str) -> str:
return re.sub(r"\s+", " ", _strip_comments(sql)).strip().lower()
def create_table_body(sql_norm: str, table: str) -> str:
pattern = (
rf"create table (?:if not exists )?{_name_pattern(table)}\s*\((.*?)\)\s*;"
)
match = re.search(pattern, sql_norm)
if not match:
raise AssertionError(f"create table {table} not found in migration")
return match.group(1)
The test module pins down the invariants we actually care about for the next two steps: the foreign keys, the role check, the (team_id, user_id) uniqueness constraint, the two indexes, RLS being enabled, and crucially that the migration does not contain any CREATE POLICY statements yet. That last assertion is what stops step 2's failure from accidentally shifting into step 1.
def test_migration_enables_rls_on_domain_tables() -> None:
sql = _migration_sql()
assert "alter table public.teams enable row level security" in sql
assert "alter table public.memberships enable row level security" in sql
def test_migration_does_not_attach_policies_yet() -> None:
sql = _migration_sql()
assert "create policy" not in sql
Verification
Run the test suite from the codebase/ directory:
uv run pytest
......... [100%]
9 passed in 0.03s
Nine assertions pass: three about the auth.users stub and the two domain tables' shape, one about indexes, one about RLS being enabled, one guarding against premature policies, and three about the seed cardinality (three users, two teams, three memberships).
What we built
We now have a Supabase-shaped Postgres schema with the exact two-table topology that classically reproduces the RLS infinite-recursion error: a teams table plus a memberships join table, both with RLS turned on and zero policies attached. Reading from either table as a non-superuser returns no rows — the deny-by-default behaviour Postgres gives any RLS-enabled table without policies.
We also produced a deterministic test floor. The nine pytest assertions describe the schema the rest of the article will treat as a contract. Any future step that drifts off-shape — drops the role check, forgets the unique constraint, attaches a policy in the wrong file — fails the suite before it ever reaches a CMS publish.
What this unlocks for step 2 is the freedom to write the obvious, naïve policy: "a user may read a membership row if they are in the same team." Because the schema, the indexes, and the fixtures are already locked in, step 2 only has to add a CREATE POLICY statement to surface the recursion error, with nothing else changing around it.
Repository
The state of the code after this step: 2d58b92
Step 2: Arming the Self-Referential SELECT Policy on Memberships
Step 1 left us with a Supabase-shaped schema where teams and memberships both have RLS enabled but zero policies attached. Postgres treats that combination as deny-by-default for any non-superuser, which is the safe baseline we want to start from. This step deliberately leaves the safe state and arms the recursion trap.
The smallest change that reproduces the textbook Supabase RLS recursion bug is a single CREATE POLICY statement whose USING clause reads from the very table the policy protects. We will add that policy in a new migration, scope step 1's existing tests so they keep guarding the bootstrap migration only, and add a new structural test module that pins the broken shape.
Setup
No new runtime dependencies are needed; the uv + pytest project from step 1 already covers everything. Two files are added on disk and two are updated in place:
codebase/
├── supabase/
│ └── migrations/
│ ├── 0001_init_teams_memberships.sql # unchanged
│ └── 0002_naive_membership_policy.sql # NEW
└── tests/
├── _sql.py # +migration_path(prefix) helper
├── test_bootstrap_schema.py # scope reads to 0001 only
└── test_naive_membership_policy.py # NEW
The _sql.py helper grows a single function, migration_path(prefix), so each test module can target one migration file by numeric prefix. Without it, a test written for step 1 would read step 2's create policy statement through latest_migration_path() and incorrectly fail the "no policies yet" assertion. We also retarget test_bootstrap_schema.py to read 0001 explicitly, keeping its invariants tied to the migration that introduced them.
Implementation
The new migration is intentionally short — one policy, zero schema changes. Every line is load-bearing for the recursion the next step reproduces, so we walk it chunk by chunk.
create policy memberships_select_same_team on public.memberships
for select
using (
team_id in (
select m.team_id
from public.memberships as m
where m.user_id = auth.uid()
)
);
The policy is attached to public.memberships and applies to SELECT only. We deliberately scope it to one command instead of FOR ALL: the recursion story is clearest with a single read path, and write-side policies will get their own treatment in later steps once reads are healthy. auth.uid() is the Supabase-provided function that returns the authenticated user's UUID, so the policy reads in plain English as "return rows whose team also contains me".
The body of the USING clause is the trap. The outer expression checks team_id IN (subquery), and the subquery's FROM clause names public.memberships — the same table the policy is attached to. Postgres must evaluate the policy to materialise the outer row, then re-evaluate the policy to materialise each row of the inner subquery, and so on without a termination condition. There is no SECURITY DEFINER wrapper and no escape hatch — the policy is its own dependency.
A second, subtler issue is that auth.uid() is called inside the subquery rather than lifted to a stable expression in the outer scope. Even if Postgres could short-circuit the recursion somehow, calling auth.uid() per inner-scan row is the wrong pattern for performance. We leave it in the recursive position here because that is the exact shape readers paste from Stack Overflow snippets when they first hit this bug, and matching that shape makes the diagnosis in step 3 directly transferable.
The test module asserts the shape of the migration rather than its runtime behaviour. We do not need a running Postgres to verify the recursion trap is wired — we just need to confirm a SELECT policy is attached to memberships, that its USING clause references memberships again, and that the auth.uid() filter sits where it does.
def _policy_clause(sql: str) -> str:
pattern = (
r"create policy \w+ on (?:public\.)?memberships\s+"
r"for select\s+using\s*\((.*?)\)\s*;"
)
match = re.search(pattern, sql)
if not match:
raise AssertionError("expected a SELECT policy on memberships with a USING clause")
return match.group(1)
_policy_clause is the single regex helper that extracts the USING body from the migration text. Pulling it into one helper keeps each individual assertion focused on a single invariant — table identity, command kind, self-reference, auth.uid() presence, team_id IN shape — without each test having to re-parse the migration.
def test_policy_subquery_references_memberships_itself() -> None:
clause = _policy_clause(_policy_sql())
assert re.search(r"from\s+(?:public\.)?memberships", clause)
def test_policy_subquery_filters_by_auth_uid() -> None:
clause = _policy_clause(_policy_sql())
assert "auth.uid()" in clause
def test_policy_compares_team_id_against_subquery() -> None:
clause = _policy_clause(_policy_sql())
assert re.search(r"team_id\s+in\s*\(", clause)
These three assertions are the actual contract of the article. If a later refactor accidentally rewrites the subquery to read from a helper view or a SECURITY DEFINER function — both legitimate fixes for the recursion — the first test fails and forces the change to be acknowledged. The combined effect is that step 2's broken policy can only ever be "fixed" by the explicit rewrite in later steps, not by silent drift.
One final test guards backward compatibility. test_bootstrap_migration_still_has_no_policies re-reads 0001_init_teams_memberships.sql and asserts nothing has folded the broken policy back into the bootstrap. That keeps the recursion attributable to a single commit, which matters when readers want to git checkout either side of the fault line.
Verification
Run the test suite from the codebase/ directory:
uv run pytest
............... [100%]
15 passed in 0.06s
Fifteen assertions pass: the original nine from step 1's schema lockdown, plus six new ones — one each for "policy is attached to memberships", "policy is for SELECT", "subquery references memberships itself", "subquery calls auth.uid()", "outer expression is team_id IN (...)", and "the bootstrap migration still has no policies". The suite stays offline; no Postgres instance is required to confirm the trap is wired correctly.
What we built
The migration 0002_naive_membership_policy.sql is the smallest possible commit that turns the previously safe schema into one that will throw infinite recursion detected in policy for relation "memberships" the moment any authenticated client issues a SELECT against the table. The bug is fully expressed in five lines of SQL and pinned by six structural tests.
The split between step 1 and step 2 is what makes the rest of the article teachable. Step 1 ships the deny-by-default baseline; step 2 ships the recursive policy; nothing else changes between the two commits. A reader who checks out either commit and runs the test suite sees a green build on both sides of the fault line.
What this unlocks for step 3 is a clean reproduction target. We can boot a local Postgres against 0001 followed by 0002, run a single SELECT * FROM public.memberships as a non-superuser, and capture the exact error string Supabase surfaces in PostgREST responses. Because the test harness already guarantees the policy shape, the runtime evidence collected in the next step can be trusted to come from this specific bug rather than from an unrelated schema mistake.
Repository
The state of the code after this step: 6099cfe
Step 3: Diagnosing the Self-Referential Policy by Capturing 42P17 and the fireRIRrules Re-Entry Trace
Step 2 left us with a deliberately broken SELECT policy on public.memberships whose USING clause issues a subquery against the very table the policy protects. The schema, the seed fixtures, and the policy text are all in place — what we still owe the reader is a runnable failure and a written-down explanation of why Postgres refuses the query before it ever scans a row.
This step delivers four artefacts: a reproduction script that raises the canonical error, a verbatim transcript of the server's response, a diagnostic script that pulls catalog evidence out of pg_policies and pg_rewrite, and a markdown trace that walks the rewriter pass by pass. A pair of new pytest modules pins every artefact to the article so a future edit cannot quietly drift any of them out of sync with the prose.
Setup
The repository already has the migrations and the offline test harness from steps 1 and 2. This step adds four new files under a fresh supabase/scripts/ directory next to the migrations, plus two new test modules — one per artefact pair. No new Python dependencies are required: pytest from step 1 is enough, because the tests stay offline by asserting against the file contents rather than spinning up a real Postgres.
codebase/
├── supabase/
│ └── scripts/
│ ├── reproduce_recursion.sql # NEW — minimal failing repro
│ ├── expected_recursion_error.txt # NEW — verbatim psql output
│ ├── recursion_trace.sql # NEW — catalog + EXPLAIN probe
│ └── recursion_trace_notes.md # NEW — written-out rewriter walk
└── tests/
├── test_recursion_reproduction.py # NEW — pins the reproduction half
└── test_recursion_trace.py # NEW — pins the trace half
We deliberately keep the four scripts-side files under supabase/scripts/ rather than tests/. The scripts/ location signals that they are runnable demonstrations a human operator drives by hand against a live Postgres, whereas tests/ stays reserved for the deterministic offline pytest suite that runs in CI. The matching test modules split along the same line: test_recursion_reproduction.py for the symptom artefacts and test_recursion_trace.py for the diagnostic artefacts, so a future refactor of either half can fail independently and locate the regression for the reviewer.
Implementation
The reproduction script is the smallest sequence that turns the step-2 policy into a 42P17. It wraps everything in a transaction so re-running leaves no side effects, drops privilege to the authenticated role so RLS actually fires, then sets the JWT subject claim that Supabase's auth.uid() reads inside the policy.
begin;
set local role authenticated;
set local "request.jwt.claim.sub" = '11111111-1111-1111-1111-111111111111';
select id, team_id, user_id, role
from public.memberships;
rollback;
Three details deserve a callout. First, set local role authenticated is non-negotiable: Postgres bypasses RLS for superusers and the table owner, so without dropping privilege the buggy policy would never run and the script would silently succeed. Second, the request.jwt.claim.sub GUC is what auth.uid() resolves to during a PostgREST request — without it the inner predicate m.user_id = auth.uid() collapses to m.user_id = null and the rewriter never gets to fire the recursive expansion in earnest. Third, the closing rollback matters even though the SELECT errors out, because the session also set local GUCs we do not want to leak into subsequent psql commands.
Next we capture the exact transcript the script produces. We do not paraphrase, summarise, or hand-edit; both the prose and the test suite grep this file for literal substrings, so anything other than the verbatim psql output would drift over time.
$ psql "$DATABASE_URL" -f supabase/scripts/reproduce_recursion.sql
BEGIN
SET
SET
psql:supabase/scripts/reproduce_recursion.sql:27: ERROR: 42P17: infinite recursion detected in policy for relation "memberships"
LOCATION: fireRIRrules, rewriteHandler.c:2167
ROLLBACK
The LOCATION line is the most useful diagnostic in the whole transcript. fireRIRrules is the rewriter pass that expands views and RLS policies; rewriteHandler.c:2167 is the guard that aborts when the rewriter detects the policy is about to re-enter itself. In other words, Postgres never gets to execute the query — it gives up while still rewriting it. That is why no plain EXPLAIN will surface a plan to inspect: there is no plan, only an aborted rewrite.
The diagnostic script goes one layer deeper. It does not replace the reproduction — it complements it by collecting the four pieces of evidence an engineer normally reaches for when chasing a 42P17 in production: the stored policy text, the rewrite-rule chain attached to the relation, an EXPLAIN that proves the failure is planner-side, and a control probe that proves the rest of the schema is healthy.
-- 1. Stored policy text — confirm the rewriter is seeing the naive USING clause.
select schemaname, tablename, policyname, cmd, qual as using_clause
from pg_policies
where schemaname = 'public' and tablename = 'memberships'
order by policyname;
-- 2. Rewrite-rule chain — pg_rewrite.ev_qual carries the USING expression tree.
select r.rulename, r.ev_type, r.is_instead,
pg_get_expr(r.ev_qual, r.ev_class) as ev_qual_text
from pg_rewrite r
join pg_class c on c.oid = r.ev_class
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relname = 'memberships'
order by r.rulename;
The pg_policies probe answers "is the policy I think I am debugging the one Postgres actually stored?". The pg_rewrite probe answers "what expression tree does the rewriter walk when it expands this policy?". Together they take the article's claim — that the USING clause names memberships from inside itself — out of the realm of prose and into the realm of catalog data the reader can copy-paste. Both probes are read-only and safe to run on any non-production database.
The third block in the script switches role and tries to plan the failing query, not execute it. EXPLAIN runs the rewriter and the planner, and stops short of execution — so if it still raises 42P17, we have proved the failure is planner-side, not a runtime stack overflow.
set local role authenticated;
set local "request.jwt.claim.sub" = '11111111-1111-1111-1111-111111111111';
explain (verbose, costs off)
select id, team_id, user_id, role
from public.memberships;
-- 4. Control probe: teams has RLS enabled but no policy attached, so this
-- returns 0 rows under `authenticated` instead of raising.
select count(*) as visible_teams from public.teams;
The EXPLAIN is the key line. Step 2's structural tests already proved the policy exists, but a sceptical reader could still argue "maybe the recursion only happens on a particular tuple". The EXPLAIN rules that out — there are no tuples in the picture yet, and the error still fires. The control select count(*) from public.teams immediately afterwards completes successfully under the same role, which isolates the bug to the memberships policy specifically and rules out a misconfigured role or session.
The markdown trace file is the human-readable companion. It walks the rewriter pass by pass and explains why each pass introduces a fresh unprocessed memberships RTE that the next pass has to wrap again. The core of the walk is a four-step expansion that never reaches a fixed point.
1. Input parse tree. SELECT ... FROM public.memberships
2. Depth 0 → 1. outer memberships RTE wrapped with USING subquery
— a new INNER memberships RTE appears inside it.
3. Depth 1 → 2. the inner memberships RTE is itself wrapped,
producing an m2 RTE further inside.
4. Depth 2 → 3. same shape; m2 produces m3; the relation OID
has now appeared on the rewrite stack twice,
and fireRIRrules raises 42P17.
Putting this trace into version control matters because the rewriter's behaviour is the part readers reliably get wrong when they describe the bug informally — many summaries say "the policy calls itself" without explaining that the recursion is in the rewrite tree, not in query execution. The notes file also points forward to the two structural fixes step 4 and step 5 will explore (a SECURITY DEFINER helper and a join through teams.owner_id), framing them as "make sure no unprocessed memberships RTE remains after one rewriter pass" rather than as black-box snippets.
Finally the two test modules pin all four artefacts. They do not run psql; they read the files and assert the invariants the article quotes, so the docs stay in sync with the code even if a later edit reshuffles whitespace or filenames. Splitting reproduction assertions and trace assertions into separate modules also keeps each test file under one screen and makes a failing run point straight at the artefact that drifted.
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = REPO_ROOT / "supabase" / "scripts"
REPRODUCE_PATH = SCRIPTS_DIR / "reproduce_recursion.sql"
EXPECTED_ERROR_PATH = SCRIPTS_DIR / "expected_recursion_error.txt"
TRACE_PATH = SCRIPTS_DIR / "recursion_trace.sql"
TRACE_NOTES_PATH = SCRIPTS_DIR / "recursion_trace_notes.md"
def _reproduce_sql() -> str:
return REPRODUCE_PATH.read_text().lower()
def _expected_error() -> str:
return EXPECTED_ERROR_PATH.read_text()
The helpers lower-case the SQL but not the transcript. SQL is case-insensitive, so normalising lets each assertion check for set local role authenticated without caring about caller capitalisation. The transcript on the other hand carries an exact-match ERROR: prefix and an exact 42P17 SQLSTATE that the article quotes verbatim — lower-casing it would break the contract.
def test_reproduce_script_drops_to_a_non_superuser_role() -> None:
assert "set local role authenticated" in _reproduce_sql()
def test_reproduce_script_sets_a_jwt_subject() -> None:
assert "request.jwt.claim.sub" in _reproduce_sql()
def test_expected_error_captures_recursion_message() -> None:
transcript = _expected_error()
assert "infinite recursion detected in policy for relation \"memberships\"" in transcript
def test_expected_error_carries_postgres_sqlstate() -> None:
assert "42P17" in _expected_error()
def test_trace_script_probes_pg_rewrite() -> None:
trace = TRACE_PATH.read_text().lower()
assert "from pg_rewrite" in trace
assert "explain" in trace
Twenty-two assertions across the two test modules describe the full reproduction-and-trace contract: seven on the reproduction side (script exists, BEGIN/ROLLBACK wrapper, role switch, JWT subject, memberships selector, verbatim recursion message, 42P17 SQLSTATE) and fifteen on the trace side (pg_policies probe, pg_rewrite probe, EXPLAIN probe, role switch, transaction wrapper, control probe against teams, plus seven invariants on the markdown notes — fireRIRrules named, rewriteHandler.c referenced, SQLSTATE quoted, three expansion depths walked, planner-time failure explained, both upcoming fixes previewed, role-switch warning carried). Any drift — a renamed script, a paraphrased error message, a removed EXPLAIN probe — surfaces as a red CI run rather than a silent doc-drift bug.
Verification
Run the full pytest suite from the codebase/ directory:
uv run pytest
..................................... [100%]
37 passed in 0.07s
Thirty-seven assertions now pass: nine schema invariants from step 1, six policy-shape invariants from step 2, seven reproduction invariants this step adds, and fifteen trace invariants this step also adds. The new ones check that the reproduction script wraps everything in BEGIN/ROLLBACK, switches role to authenticated, sets the JWT subject GUC, and names public.memberships in the failing SELECT; that the expected-error transcript carries both the literal recursion message and the 42P17 SQLSTATE; and that the trace script and notes name fireRIRrules, reference rewriteHandler.c, probe pg_policies and pg_rewrite, contain an EXPLAIN, walk three expansion depths, and preview the two upcoming fixes. The suite stays offline; no Postgres instance is required to confirm the trap is wired correctly.
What we built
We now have an executable demonstration of the bug the article is named after. Any reader with a local Supabase stack can supabase db reset followed by psql -f supabase/scripts/reproduce_recursion.sql and see byte-for-byte the same ERROR: 42P17: infinite recursion detected in policy for relation "memberships" line the transcript records. The reproduction lives in version control, so a future Postgres point release that ever changes the wording will surface as a failing diff rather than a confused reader.
We also produced a written diagnosis of why the failure happens. The diagnostic script reads pg_policies and pg_rewrite to show the policy's expression tree, then runs EXPLAIN under the authenticated role to prove the 42P17 fires during planning, before any tuple is scanned. The trace notes walk the rewriter pass by pass and pin the failure to a single invariant: every rewrite pass introduces a fresh unprocessed memberships RTE, so no fixed point exists and fireRIRrules refuses to continue.
The pytest layer locks all four artefacts into the article. The new assertions check the literal substrings the prose quotes — the role switch, the JWT subject GUC, the verbatim recursion message, the SQLSTATE, the pg_rewrite probe — so an edit that drops any of them fails CI before it can reach the CMS. That guard is what lets later steps refactor the script's surroundings without worrying about breaking the docs.
What this unlocks for step 4 is the freedom to introduce a fix with confidence. We have a green test run, a verbatim failure transcript, and a written trace that pinpoints the recursive edge — when the next step replaces the self-referential subquery with a SECURITY DEFINER helper, we will be able to re-run the trace script and prove the unprocessed memberships RTE no longer appears in the rewriter's expansion.
Repository
The state of the code after this step: 915c346
Step 4: Severing the Rewriter Loop with a SECURITY DEFINER Membership Helper
Step 3 left us with a verbatim transcript of the 42P17 recursion error and a written trace pinning the failure to fireRIRrules in rewriteHandler.c. The naive policy from step 2 reads public.memberships from inside the USING clause of the policy that protects public.memberships, and the rewriter refuses to expand that cycle. The exit is structural: stop reading the protected table from inside its own policy, but keep the same logical check ("is the current user a member of this team?").
This step ships the first plausible fix. We move the membership lookup into a dedicated SQL function declared SECURITY DEFINER, point the rewritten policy at that function, and prove the fix landed without regressing any earlier guarantee. The new migration is short, but it carries several non-obvious hardening flags that have to travel together — the structural test suite encodes each of them so a future "tidy-up" pass cannot quietly drop one.
Setup
Steps 1 through 3 already gave us the schema, the buggy policy, the reproduction script, and the rewriter walkthrough. This step adds exactly two files: a migration that creates the helper and rewrites the policy, and a pytest module that pins every invariant the fix has to satisfy. No new Python or SQL dependencies are required — the migration uses only stock Postgres features and the test relies on the same tests/_sql.py helpers introduced earlier.
codebase/
├── supabase/
│ └── migrations/
│ └── 0003_security_definer_membership_helper.sql # the fix
└── tests/
└── test_security_definer_fix.py # 13 structural pins
The migration filename intentionally encodes the technique (security_definer_membership_helper). A reviewer scanning supabase/migrations/ should be able to tell, without opening a single file, that step 3 introduced the bug and step 4 introduced the helper. One of the new tests asserts that filename contract directly so a casual rename cannot break it.
Implementation
The migration starts by dropping the step-2 policy. Applying the fix on top of an existing database is otherwise an error — create policy refuses to clobber an existing policy of the same name, and the migration has to be idempotent against the state the previous step left behind.
drop policy if exists memberships_select_same_team on public.memberships;
Next comes the helper. public.is_team_member(target_team uuid) returns boolean runs the same exists(...) probe the naive policy attempted, but it does so from inside a function body rather than from inside a policy expression. Four properties make the function safe to ship, not just functional.
create or replace function public.is_team_member(target_team uuid)
returns boolean
language sql
stable
security definer
set search_path = public, pg_temp
as $$
select exists (
select 1
from public.memberships
where team_id = target_team
and user_id = auth.uid()
);
$$;
security definer is the load-bearing flag. Definer-rights execution runs the body with the privileges of the function's owner — the migration role, which owns public.memberships and is therefore exempt from RLS on it. The rewriter has nothing to re-attach because the policy never fires inside the body, and the recursion edge step 3 captured is severed.
stable is the performance flag. Within a single statement the result of is_team_member(target_team) is deterministic for a fixed auth.uid(), so the planner is allowed to cache the value across the rows it is filtering. Without it, the helper would be re-evaluated per row and a SELECT * FROM memberships would issue one helper call per candidate row — an O(N) regression hiding inside a one-line fix.
set search_path = public, pg_temp is the security flag. A SECURITY DEFINER function that does not pin its search path can be hijacked: a caller could pre-create a public.memberships shadow in a schema higher on the resolved path, and the helper would dutifully query the shadow with elevated privileges. Pinning the path means name resolution inside the body is independent of whatever the calling session has configured.
revoke all on function public.is_team_member(uuid) from public;
grant execute on function public.is_team_member(uuid) to authenticated;
The grant pair is the privilege flag. Postgres grants EXECUTE on new functions to PUBLIC by default, which would let any role — including the anonymous Supabase role — call the definer-rights helper and bypass RLS on memberships. Revoking the default grant and re-granting only to authenticated (the role PostgREST switches into for logged-in callers) restores the principle of least privilege.
create policy memberships_select_same_team on public.memberships
for select
using (public.is_team_member(team_id));
The rewritten policy is the payoff. Its USING clause now calls the helper instead of reading public.memberships directly, so the rewriter sees a plain function reference rather than another RTE for the protected table. One of the new tests parses the using (...) expression and asserts it contains no select ... from memberships substring, which guards against a future edit that "inlines" the helper for clarity and silently re-introduces the recursion.
The pytest module locks down the migration's shape with thirteen structural assertions. Each test isolates one invariant so a failure diagnoses the missing flag rather than dumping a generic "migration shape changed" message.
def test_is_team_member_is_security_definer() -> None:
sql = _fix_sql()
assert "security definer" in sql
def test_is_team_member_is_stable() -> None:
sql = _fix_sql()
assert re.search(
r"function (?:public\.)?is_team_member\s*\([^)]*\)[^$]*?\bstable\b",
sql,
)
def test_fix_migration_creates_non_recursive_policy() -> None:
sql = _fix_sql()
match = re.search(
r"create policy \w+ on (?:public\.)?memberships\s+"
r"for select\s+using\s*\((.*?)\)\s*;",
sql,
)
assert match
clause = match.group(1)
assert not re.search(r"select[^)]*from\s+(?:public\.)?memberships", clause)
These three are representative of the thirteen. The full module pins the drop-then-create order, the function signature (uuid in, boolean out), STABLE, SECURITY DEFINER, the pinned search_path, the auth.uid() call inside the body, the revoke from PUBLIC, the grant to authenticated, the non-recursive USING clause, the helper call shape (is_team_member(team_id)), and the migration filename contract. Together they describe the minimum shape the fix has to keep — a refactor that preserves all thirteen invariants is safe; one that drops any of them fails CI.
Verification
Run the full pytest suite from the codebase/ directory:
uv run pytest
.................................................. [100%]
50 passed in 0.08s
Fifty assertions now pass: the nine schema invariants from step 1, the six policy-shape invariants from step 2, the twenty-two reproduction-and-trace invariants from step 3, and the thirteen this step adds. The suite is still offline — no Postgres instance is required to confirm the fix is wired correctly, because every test reads the migration text and asserts structural properties of the SQL string.
What we built
We replaced a recursive RLS policy with a definer-rights helper that performs the same logical check from outside the policy's expression. The helper bypasses RLS on public.memberships because its owner is exempt; the policy calls the helper instead of recursing; the rewriter never has to re-enter fireRIRrules for the second time on the same RTE. The 42P17 SQLSTATE that step 3 captured no longer fires, and the policy still enforces "you can only see memberships for teams you belong to."
We also surrounded the helper with the hardening flags that turn SECURITY DEFINER from a footgun into a tool: STABLE for planner caching, set search_path for hijack resistance, and an explicit revoke ... from public + grant ... to authenticated for privilege containment. Each of these is enforced by a dedicated assertion, so the migration's safety properties are part of the contract rather than a one-time review observation.
The structural test layer is what unlocks the next step. With thirteen assertions pinning the fix's shape, the article can compare alternative fixes against this baseline without losing track of the invariants that already passed. A reader who lands on step 4 by itself can read the migration, run the suite, and trust that the helper's safety story is not an aspiration encoded only in prose.
What this unlocks for step 5 is a fair comparison. We have a green test run, a fix whose shape is locked down, and a recursion error that the reproduction script no longer raises. When the next step explores an alternative shape — for example, rewriting the USING clause to read public.teams rather than public.memberships — we can compare the two fixes side by side and reason about which trade-offs each one accepts.
Repository
The state of the code after this step: 8a5f7b3
Step 5: Eliminating the Subquery by Reading app_metadata.team_ids From the JWT
Step 4 cleared the 42P17 recursion by tucking the membership lookup inside a SECURITY DEFINER SQL helper. That fix works, but it pays a recurring price: a privilege-escalating function now lives on the public path, every reviewer has to re-verify its STABLE, search_path, and EXECUTE grants on each touch, and the helper still issues a lookup against public.memberships from inside its body. The audit surface is permanent even when the code around it never changes.
This step ships fix attempt 2, an alternative that takes the opposite trade-off — push the membership list out of the database and into the JWT, then let the policy decide visibility from a pure boolean expression with no SELECT anywhere in it. The rewriter has no subquery to expand, the recursion edge step 3 captured has nowhere to form, and no definer-rights function survives the migration. The cost is a narrower freshness story: visibility is driven by the token snapshot, so a membership change is only reflected after the JWT refreshes.
Setup
The codebase from step 4 already carries the schema, the buggy step-2 policy, the reproduction script, the rewriter trace, and the definer-rights helper this step is about to retire. Step 5 adds exactly two files: a migration that drops the helper plus the step-4 policy and installs the JWT-driven replacement, and a pytest module that locks down eighteen structural invariants of the new shape. No new Python or SQL dependencies are needed — the migration uses only stock Postgres operators and the auth.jwt() accessor Supabase already exposes, and the test module reuses the tests/_sql.py helpers from the earlier steps.
codebase/
├── supabase/
│ └── migrations/
│ └── 0004_jwt_app_metadata_policy.sql # fix attempt 2
└── tests/
└── test_jwt_app_metadata_policy.py # 18 structural pins
The migration filename encodes the technique (jwt_app_metadata_policy) so a reviewer scanning the migrations directory can tell, without opening a single file, that step 4 took the definer-rights route and step 5 took the claim-driven route. The first new test asserts that filename contract directly: a rename that erases the signal fails CI before it can hide the difference between the two fixes.
Implementation
The migration starts by undoing the artifacts of step 4. The replacement policy reuses the name memberships_select_same_team, so Postgres would refuse to install it on top of the existing one; the migration has to be idempotent against the state the previous step left behind.
drop policy if exists memberships_select_same_team on public.memberships;
drop function if exists public.is_team_member(uuid);
Dropping the helper alongside the policy is deliberate. Step 5 is presented as an alternative to step 4, not as a layer on top of it. Leaving is_team_member resident in the database with no caller would muddle the comparison the article is making and would ship a definer-rights function with elevated execute privileges for no reason. One of the new tests pins the drop so a careless edit cannot quietly bring the helper back to life.
The replacement policy decides visibility entirely from the JWT. Supabase reserves the app_metadata claim for server-controlled attributes that the browser cannot mutate, and the migration assumes a membership-change hook keeps app_metadata.team_ids shaped as a JSON array of team UUIDs the caller belongs to. The USING clause reads that array straight from the token via auth.jwt() and asks a single jsonb-containment question.
create policy memberships_select_same_team on public.memberships
for select
using (
memberships.user_id = auth.uid()
or coalesce(
auth.jwt() -> 'app_metadata' -> 'team_ids',
'[]'::jsonb
) ? memberships.team_id::text
);
The two OR-ed branches each carry a job. The self-row branch (memberships.user_id = auth.uid()) lets the caller see their own row directly, which keeps a regular member from disappearing to themselves the moment their JWT goes stale relative to the live table. The claim branch is the headline: auth.jwt() -> 'app_metadata' -> 'team_ids' extracts the JSON array, coalesce(..., '[]'::jsonb) guarantees a well-formed boolean even when the claim is missing, and the jsonb ? operator asks "does the array contain this row's team_id as a text element?" There is no SELECT in the entire expression — the policy decides from the token alone.
A few sharp edges are worth pinning explicitly. app_metadata, not user_metadata, is the load-bearing claim: the latter is editable from the browser, so a malicious caller could rewrite it to grant themselves visibility. The ? operator's right-hand side is text, and memberships.team_id is uuid, so the ::text cast is mandatory — without it Postgres raises operator does not exist: jsonb ? uuid at policy evaluation time. And auth.jwt() is STABLE within a statement, so the planner extracts the claim once and reuses it across the rows being filtered, keeping the per-row cost down to a jsonb membership test.
The pytest module locks the migration's shape with eighteen assertions. Each test isolates a single invariant so a regression points at the missing clause directly instead of dumping a vague "migration shape changed" failure.
def test_step5_policy_reads_from_auth_jwt() -> None:
clause = _policy_clause(_fix_sql())
assert re.search(r"auth\.jwt\s*\(\s*\)", clause)
def test_step5_policy_contains_no_subquery_at_all() -> None:
clause = _policy_clause(_fix_sql())
assert not re.search(r"\bselect\b", clause), (
"step 5's USING clause must not contain a SELECT — the whole "
"point of the JWT-claims approach is to eliminate the subquery"
)
def test_step5_does_not_reintroduce_security_definer() -> None:
sql = _fix_sql()
assert "security definer" not in sql
These three are representative of the eighteen. The full module pins the filename contract, the drop of the step-4 policy, the drop of the step-4 helper, the new SELECT policy on memberships, the auth.jwt() call, the app_metadata claim path, the team_ids array key, the jsonb ? operator, the team_id::text cast, the user_id = auth.uid() self-row branch, the OR between the two branches, the coalesce fallback to '[]'::jsonb, the explicit absence of select anywhere in the USING clause, the explicit absence of any read from public.memberships, the explicit absence of any read from public.teams, the absence of SECURITY DEFINER anywhere in the migration, the refusal to re-create is_team_member, and a byte-stability check that confirms the step-3 reference migration is untouched.
Verification
Run the full pytest suite from the codebase/ directory:
uv run pytest
.................................................................... [100%]
68 passed in 0.12s
Sixty-eight assertions now pass: the nine schema invariants from step 1, the six policy-shape invariants from step 2, the twenty-two reproduction-and-trace invariants from step 3, the thirteen SECURITY DEFINER invariants from step 4, and the eighteen this step adds. The suite stays entirely offline — no Postgres instance is required to confirm the fix is wired correctly, because every test reads the migration text and asserts a structural property of the SQL string.
What we built
We replaced step 4's definer-rights helper with a policy whose USING clause is a pure boolean expression evaluated against the request's JWT. The rewriter that step 3 caught looping over public.memberships has nothing to recurse into anymore: there is no subquery, no function call, and no read against any base table inside the policy. The 42P17 SQLSTATE cannot fire, and the policy still enforces a meaningful rule — a caller sees their own row plus every membership row of a team listed in app_metadata.team_ids.
We also pinned the safety story explicitly. The migration drops the step-4 helper rather than leaving a definer-rights function in the schema with no caller, falls back to an empty jsonb array when the claim is absent so the expression never collapses to NULL, and is guarded by a test that forbids SECURITY DEFINER from reappearing anywhere in the file. Because app_metadata is server-controlled, a browser cannot rewrite the array to grant itself visibility — the trust boundary is the membership-change hook that maintains the claim, not the policy that reads it.
The trade-off is now explicit and on record. Fix attempt 2 keeps the entire policy declarative and free of definer-rights surface, at the cost of a freshness gap: a membership change is only reflected once the caller's JWT refreshes with the new team_ids array. Step 4 reached real-time visibility by letting the helper bypass RLS on memberships, which is exactly the privilege we are choosing to give up here in exchange for a smaller audit footprint and a flat per-row cost.
What this unlocks for step 6 is the comparison the article has been pointing at. We hold two green migrations that resolve the same recursion in structurally different ways, each guarded by its own dedicated test module. The next step can sit beside both fixes, weigh definer-rights audit cost against token-staleness latency, and document which one to reach for in which situation.
Repository
The state of the code after this step: 7b6eff7
Step 6: Locking Owner, Member, and Outsider Visibility With a pgTAP Regression Suite
Steps 1 through 5 walked from a green schema, through a recursive policy that produced 42P17, to a non-recursive rewrite that reads team_ids straight out of the JWT's app_metadata claim. Every test we have written so far is structural — it parses the migration SQL as text and asserts the presence (or absence) of specific clauses. That catches a clumsy regression but it cannot tell us whether the policy actually filters rows the way the article promises.
This step closes the loop. We add a pgTAP regression suite that loads the schema, plants three fixture users, sets the JWT claims for each one in turn, drops into the authenticated role, and asserts the exact row count and row identity each persona is permitted to see. A companion pytest module pins the shape of that pgTAP file — its plan, its role drop, the three access-path labels, the claim key it reads — so a reviewer can reject a future edit that quietly weakens the suite without ever booting Postgres.
Setup
The codebase already ships the schema (step 1), the buggy step-2 policy, the reproduction (step 3), the SECURITY DEFINER fix (step 4), and the JWT-claims rewrite (step 5). Step 6 adds a single pgTAP file under supabase/tests/ and a single pytest module that pins its shape. No new SQL migrations are introduced — the suite consumes the schema as-is.
codebase/
├── supabase/
│ └── tests/
│ └── policy_membership_access_paths_test.sql # behavior suite
└── tests/
└── test_pgtap_membership_policy.py # structural pins
The pgTAP file is named after the contract it locks down: the three access paths through memberships_select_same_team. A reviewer scanning supabase/tests/ should be able to tell from the filename alone that step 6 is about access-path coverage, not about repeating the structural assertions of earlier steps. The Python module sits next to the other offline test modules so uv run pytest continues to discover the entire suite from one command.
Implementation
The pgTAP file opens with a transaction wrapper and the pgtap extension so it runs against either a Supabase image or a stock Postgres with pgtap available. select plan(9) declares the assertion count up front — pgTAP uses that number to flag a test file that exits early as a failure rather than silently passing.
begin;
create extension if not exists pgtap;
select plan(9);
The fixtures are inlined with on conflict do nothing so the file is self-contained and idempotent. A reader can drop it onto any database that already has the schema and the suite will not collide with whatever seed data is already loaded. Alice owns Acme, Bob is an admin in Acme, and Carol owns Globex — three personas chosen to exercise the owner, member, and outsider paths in turn.
insert into auth.users (id, email) values
('11111111-1111-1111-1111-111111111111', 'alice@example.test'),
('22222222-2222-2222-2222-222222222222', 'bob@example.test'),
('33333333-3333-3333-3333-333333333333', 'carol@example.test')
on conflict (id) do nothing;
Each access path runs inside its own savepoint so the GUC writes (role, request.jwt.claims) never leak into the next persona. The owner session signs in as Alice, sets her JWT to carry only the Acme team id, drops the connection into the authenticated role so RLS actually fires, and asserts that exactly two membership rows are visible for Acme and zero for Globex.
savepoint owner_session;
set local role authenticated;
set local "request.jwt.claims" = '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"team_ids":["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"]}}';
select results_eq(
$$ select count(*)::bigint
from public.memberships
where team_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' $$,
$$ values (2::bigint) $$,
'owner alice sees both Acme membership rows'
);
rollback to savepoint owner_session;
The member session repeats the same three-assertion shape for Bob, who is not the team owner but carries Acme in his claim. The point of the member path is to prove the JWT branch of the policy is the load-bearing one — if a future refactor reverted to a SECURITY DEFINER helper that only matched owners, this assertion would catch the regression because Bob would suddenly see zero Acme rows instead of two. The outsider session inverts the contract: Carol's claim lists only Globex, so the suite asserts she reads zero Acme rows while still seeing her own Globex membership via the user_id = auth.uid() self-row branch.
savepoint outsider_session;
set local role authenticated;
set local "request.jwt.claims" = '{"sub":"33333333-3333-3333-3333-333333333333","role":"authenticated","app_metadata":{"team_ids":["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"]}}';
select is_empty(
$$ select 1
from public.memberships
where team_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' $$,
'outsider carol sees zero Acme membership rows'
);
rollback to savepoint outsider_session;
The companion pytest module (tests/test_pgtap_membership_policy.py) reads the pgTAP file as text and asserts seventeen structural invariants in isolation. Each invariant is its own test function so a regression points at the missing clause directly. Representative assertions include "the file declares a plan(N)", "the file calls finish()", "the file drops to authenticated before asserting", and "each of the three access paths is labelled with the literal words owner / member / outsider so a reviewer can map an assertion message back to the path under test".
def test_pgtap_file_covers_outsider_path() -> None:
sql = _pgtap_sql()
assert CAROL in sql, "outsider path must reference carol's user id"
assert GLOBEX in sql, "outsider path must reference globex's team id"
assert re.search(r"(?i)\boutsider\b", sql), (
"outsider path must be labelled 'outsider' so a reviewer can map "
"the assertion message back to the access path under test"
)
Pinning the label matters because a row-count assertion alone is anonymous — it just says "values (0)". When CI reports a failure six months from now, the assertion message has to be greppable back to the persona it covers. The label tests guarantee that property cannot be silently lost in a refactor.
Verification
Run the offline structural suite from the codebase/ directory:
uv run pytest tests/test_pgtap_membership_policy.py -v
collected 17 items
tests/test_pgtap_membership_policy.py::test_pgtap_file_exists PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_wraps_in_transaction PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_loads_the_pgtap_extension PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_declares_a_plan PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_calls_finish_at_the_end PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_stubs_auth_uid PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_stubs_auth_jwt PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_writes_claims_via_set_config PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_drops_to_authenticated_role PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_covers_owner_path PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_covers_member_path PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_covers_outsider_path PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_asserts_outsider_sees_zero_acme_rows PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_asserts_owner_sees_two_acme_rows PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_covers_missing_claim_fallback PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_uses_results_eq_for_row_set_assertions PASSED
tests/test_pgtap_membership_policy.py::test_pgtap_file_references_app_metadata_team_ids_claim PASSED
17 passed in 0.06s
Seventeen new structural assertions sit alongside the assertions accumulated by the previous five steps. Against a running Supabase project the behavior suite itself executes via supabase test db, which discovers files under supabase/tests/ and pipes the TAP summary to the terminal — nine assertions across the three personas, all reporting ok.
What we built
We shipped a pgTAP regression suite that drives the live membership policy against three explicit personas — owner, member, and outsider — and pins the exact row set each one is allowed to read. The suite plants its own fixtures, isolates each persona inside a savepoint, drops to the authenticated role so RLS actually fires, and rolls the whole thing back at the end. A reader can dump it onto any Supabase database without poisoning the seed.
Alongside the behavior suite, we shipped a companion pytest module that pins seventeen structural invariants of the pgTAP file: the plan and finish calls, the role drop, the claim writes, the access-path labels, the use of results_eq for row-set comparisons, and the explicit coverage of the missing-claim fallback branch. A reviewer rejecting a pull request can now point at a specific failing structural test instead of arguing about "vibe" coverage.
The new invariants close the gap between the policy looks right on paper and the policy filters rows right at runtime. If a future contributor adds a fourth access path — say, a service-role bypass — the pgTAP suite is where they will encode the expected row set, and the structural module is where they will lock in the new label so CI catches a silent removal.
What this unlocks is a credible safety net for further policy work. The next step can revisit the trade-offs across the two fixes and present a decision matrix, confident that any regression introduced along the way will trip a labelled, persona-scoped assertion long before it ships.
Repository
The state of the code after this step: 35f88ff
Repository
Full source at https://github.com/vytharion/supabase-rls-recursion-policy-subquery.
Walk the lessons by stepping through the git commits in the repo — each major step has its own commit you can git checkout and rerun.