RPP Authorization: Cross-Vendor Debate Ruling

Generated 2026-07-17 · A 2-round adversarial debate between ChaseAI (Gemini 3.1 Pro), Andrej (GPT-5.4), and Woz (Claude Opus) on whether RevParPro should adopt an external authorization engine or stay on Supabase. Follow-on to the access audit and 8-engine diligence.
Ruling: Option C, precise hybrid (unanimous)

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.

Why this matters given the standing critique. Two vendors with no stake in RPP's code (Gemini, GPT-5.4) were free to pick the external engine. Neither did. But they also refused to rubber-stamp the "stay and harden" plan I authored: they forced a concession that my original plan was under-specified, replaced "centralize and be disciplined" with a concrete structural mechanism that is genuinely better than what I proposed, and Andrej surfaced a live authorization bug in the process. Woz conceded point 1 outright and adopted Andrej's mechanism. The panel improved the answer rather than confirming mine, which is the point of running it cross-vendor.

Section 1The panel and the final vote

AdvisorRound 1Round 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.

Section 2The crux that decided it

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.

table plus a TS guard alone will drift again. I concede that. The only non-vibes fix is structural: make edge stop interpreting policy. Put one canonical DB authorize(action, resource) RPC in Postgres, backed by the helpers that already exist for RLS. Then make TS authorize() a transport wrapper only. Add two hard gates: CI fails on any direct role inspection in supabase/functions, and one generated parity matrix test that hits the RPC for every role x action case. Andrej (GPT-5.4), Round 2
You are right to call out willpower as a garbage mechanism. The specific mechanism is the automation backbone: a pre-commit AST checker that scans every edge file, and if an endpoint does not invoke the single central authorize() helper, the build fails. ChaseAI (Gemini), Round 2
I concede it. Willpower is not a mechanism. Andrej's proposal is strictly better than mine and I adopt it. That single CI check would have caught the qbo-invoice bug at commit time. It converts drift from a discipline problem into a red build. Woz (Claude), Round 2

Section 3The Casbin case got a fair hearing and still lost

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.

The deciding argument: the edge tier does not need its own evaluator, because Postgres is already the root of trust via RLS. An embedded Casbin on the edge is a second interpreter whose policy model must stay semantically aligned with the SQL/RLS model forever. A tested engine you must keep in sync is more driftable than zero second engine. The least-driftable move is one evaluator, and RPP already has one in Postgres. Casbin's value (a real policy DSL) only starts paying when the model outgrows a flat table, which is the trigger, not today.

Section 4The live bug the debate surfaced

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.

FindingDetailSource
Wrong-claim role checkThe 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 ungatedOf 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 existspackage.json has no test script today, so the parity matrix test is net-new infrastructure. Without it, drift is unobservable.Andrej, Round 2

Section 5What to build (the ruling, made concrete)

  1. One Postgres evaluator. A single 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.
  2. Edge functions become transport. The shared TypeScript 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.
  3. CI gate with teeth. An AST/grep check over 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.
  4. Parity matrix test. Add a test script (there is none today) that asserts the evaluator's decision for every role by action pair, so any policy change that diverges breaks a red build instead of leaking silently.
  5. Finish the audit's open items. Extend the finance/deal gates to the balance-sheet and deal-period tables migration 259 missed, and kill the 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.