Make Postgres the single authorization evaluator. Do not adopt Casbin now, and do not hand-roll a discipline-dependent guard either.
All three advisors converged: one canonical Postgres authorize(action, resource) RPC backed by the RLS helpers that already exist, with edge functions reduced to transport that call it and stop interpreting policy themselves. The anti-drift teeth are structural, not willpower: a CI gate that fails the build on any direct role check inside an edge function, plus a generated parity test over every role by action. Casbin stays trigger-gated for the day a paying tenant needs custom roles a flat table cannot express.
| Advisor | Round 1 | Round 2 (after cross-examination) | Final |
|---|---|---|---|
| WozClaude Opus | Stay + harden; Casbin on trigger. Conceded its own weakest point up front. | CONCEDED PT 1 Adopted Andrej's single-evaluator + CI-gate mechanism as "strictly better than mine." Dropped its weak "second source of truth" framing. | C hybrid |
| AndrejGPT-5.4 | Option C: harden now, clean off-ramp to Casbin later. Went into the repo and measured the edge surface. | Sharpened to the strongest version: Postgres as the one evaluator, edge as transport, CI bans on wrong-claim reads, a parity matrix test. Granted the Casbin reframe force, still declined it. | C hybrid |
| ChaseAIGemini 3.1 Pro | Option A: stay and harden. Blunt: an external engine here is "cargo-cult complexity." | Named the anti-drift mechanism: a pre-commit/CI AST checker that fails the build if an endpoint skips the central authorize() helper, plus a cross-vendor AI reviewer on the auth path. |
A + automation |
A and C are the same destination here: keep Supabase as the enforcer, make the policy canonical in Postgres, add automated enforcement. No advisor voted to adopt Casbin now. The debate's real work was on how to harden so it does not rot again, not on whether to switch.
Round 1 converged too easily, so Round 2 forced the one question all three had dodged: your fix is "centralize and be disciplined," but discipline already failed in this exact codebase. Why will it not drift again? If the answer is "code review" or "be careful," Casbin wins by default. All three conceded the setup and answered with a mechanism instead.
I forced the strongest pro-Casbin argument onto the panel: the edge tier has no policy engine today, just drift-prone if statements, so putting embedded Casbin there is giving a tier with zero structured authorization its first real evaluator, not bolting a second one onto RLS. All three granted the reframe has force. All three still declined, on one shared argument.
Not a hypothetical. While arguing, both Andrej and Woz opened the actual files and found a real drift defect, the exact failure class the mechanism is meant to kill.
| Finding | Detail | Source |
|---|---|---|
| Wrong-claim role check | The JWT hook stamps app_role, but qbo-invoice and qbo-journal-entry gate on user.app_metadata?.role, a different claim source. The "role-gated" QBO write functions are checking a claim the hook does not populate the same way, so the gate is not enforcing what it appears to. | qbo-invoice/index.ts:171, qbo-journal-entry/index.ts:77 (confirmed by 2 agents); hook at 002_auth_permissions.sql:74 |
| Edge surface is almost entirely ungated | Of 38 edge entrypoints, 36 reference the service-role key, 4 call auth.getUser, and only 2 show an in-file role gate (2 of those read the wrong claim source). This is the concrete measure of the drift, and it is why a CI gate, not a guideline, is the fix. | Andrej repo scan, 2026-07-17 |
| No test script exists | package.json has no test script today, so the parity matrix test is net-new infrastructure. Without it, drift is unobservable. | Andrej, Round 2 |
authorize(action, resource, hotel) SQL function backed by the helpers already in 002_auth_permissions.sql:115-205 (app_role(), tenant_id(), can_access_hotel()). This is the one place a policy decision is made. Feed it from a role_permissions table so the role-to-permission map is data, not scattered strings.authorize() helper does not interpret policy; it calls the RPC. Retrofit it into all 36 service-role functions, starting with the 12 that have no caller check and the 2 that read the wrong claim. Nothing in TypeScript is left to drift.supabase/functions/** that fails the build on any direct role inspection: app_metadata?.role, user_metadata?.role, profiles.role, or literal role-string arrays. This check alone would have caught the qbo-invoice bug at commit time.tenant_id='default' rot by stamping and enforcing a real tenant claim on both paths.Casbin trigger (unchanged, now unanimous): adopt node-casbin embedded only when a paying tenant needs customer-defined custom roles or per-tenant policy that a flat role_permissions table can no longer express as plain rows, or when the same rule genuinely must live in both RLS and edge code with different logic. Until then, a second interpreter is a liability, not an upgrade.