Admin RBAC Model C — QA Execution Report
Top-level summary
- Total spec §9 cases verified: 20
- PASS: 15
- PASS-WITH-CAVEAT: 2 (cases 11, 19 — behave as spec describes, but only through legacy fallback; real test requires DB migration)
- DEFERRED: 2 (cases 13, 14 — rely on UI flows not yet shipped; see gap list)
- BLOCKED: 1 (case 7 — "restore template defaults" button not implemented; server-side origin flip works)
- Test suite execution: 54 unit tests pass (27 rbac.test + 19 AdminDashboard.rbac.test + 8 rbac-enforcement.spec), 2 skipped (server-only import limitation). BONUS: 15 additional tests pass in
TeamGroupsPanel.rbac.test.ts(catalog invariants + optimistic-state mutations) — not mentioned in brief but already present. Grand total: 69 pass / 2 skipped. - TypeScript:
npx tsc --noEmitclean on the worktree. - DB state:
church_custom_groupstable does NOT exist;church_team_members.group_idsand.capabilitiescolumns do NOT exist. Code fails-soft to legacy-role behavior — intentional Phase 1 posture. - Drift note: mid-QA, a concurrent agent updated
src/lib/rbac.tsto add 2 new Care caps (care:broadcast,care:subscribers:view) — capability total is now 50 (not 48 as the spec states), 42 grantable (not 40). One stale assertion inrbac.test.ts:204still read "40" and was breaking the suite; 1 line fix applied (48→50 / 40→42 in the assertion + comment). Resolves the failing test. The capability-taxonomy section in the spec + the enforcement map will need to be updated to document the 2 Care caps.
GO / NO-GO verdict
CONDITIONAL GO for feat/admin-rbac-model-c → main.
The library, type taxonomy, legacy-fallback bridge, 69 passing unit tests, and API-gate wiring are production-ready. The branch is SAFE to merge: with the DB migration absent, every new read-helper returns empty / falls through to legacyRoleToCapabilities(), which exactly reproduces today's behavior. Zero user-facing regression.
BUT — the Admin Group Management UI (/api/premium/groups, /api/premium/groups/[id], /api/premium/members/[id]) will 500 on writes against the current DB because church_custom_groups does not exist. Either:
- Ship the migration in the same PR / release (recommended), OR
- Feature-flag the Group Management UI off until migration ships, OR
- Merge the library + tests now; hold UI behind a
groups:managecap that only admins hold (they do — legacy fallback gives admin every cap) but expose only read paths until migration runs.
The evidence-or-nothing rule is satisfied for the library layer. It is NOT satisfied for the writer surfaces until a real preview URL verifies group create/update/delete end-to-end.
Evidence & execution method
| Source | Outcome |
|---|---|
node --import tsx --test src/lib/__tests__/rbac.test.ts | 27 pass / 0 fail (after 1-line stale-assertion fix documented in Gap P2-drift below) |
node --import tsx --test src/app/admin/[token]/components/__tests__/AdminDashboard.rbac.test.ts | 19 pass / 0 fail |
node --import tsx --test src/app/api/__tests__/rbac-enforcement.spec.ts | 8 pass / 0 fail / 2 skipped (documented in-file: server-only import incompatibility, Playwright scope) |
node --import tsx --test src/app/admin/[token]/components/__tests__/TeamGroupsPanel.rbac.test.ts | 15 pass / 0 fail (catalog + optimistic-mutation contracts) |
npx tsc --noEmit | clean (no output) |
Supabase MCP probe: SELECT column_name FROM information_schema.columns WHERE table_name='church_team_members' AND column_name IN ('group_ids','capabilities') | Returns only access_token, role. group_ids and capabilities columns missing. |
Supabase MCP probe: SELECT table_name FROM information_schema.tables WHERE table_name='church_custom_groups' | Returns empty. Table missing. |
Direct probe: SELECT ... group_ids ... FROM church_team_members LIMIT 1 | ERROR: 42703: column "group_ids" does not exist — confirms the absence at runtime. |
Migration file search: C:/dev/churchwiseai-web/supabase/migrations/, C:/dev/pewsearch/migrations/, and C:/dev/churchwiseai-web/**/*.sql return NO files containing church_custom_groups or the Model C DDL. The spec calls for 20260419000000_rbac_model_c.sql; it does not exist.
Per-case results (spec §9)
Case 1 — Prayer Team member sees only the Prayer chip in Inbox
"Member in
[Prayer Team]opens Inbox — Only the Prayer chip is visible./api/premium/requests?type=visitorreturns 403."
- Method: Code inspection (
src/app/admin/[token]/components/AdminDashboard.rbac.ts:60-66) + unit test (AdminDashboard.rbac.test.ts:119-124) + enforcement (/api/premium/requests/route.ts:42-44appliesREAD_CAP_FOR_TYPE[type]viarequireCapability). - Result: PASS
- Evidence:
CAP_TABS.requests = ['inbox:prayer:read', 'inbox:visitor:read', 'inbox:callback:read', 'inbox:safety:read']. Prayer Team template holds onlyinbox:prayer:read→ chip visible viacanSeeTab.- Individual chip filtering happens in
RequestManager(outside this review's scope; verified cap mapping in enforcement spec). - API: with
type=visitor, route callsrequireCapability(request, 'inbox:visitor:read'); Prayer Team lacks this cap → 403{error: "Forbidden", missing: "inbox:visitor:read"}. - Test:
rbac.test.ts:231-241assertsprayer_teamtemplate has no visitor/callback/financial caps.
Case 2 — Multi-group union (Prayer + Care Team)
"Prayer + Visitor + Callback chips visible (union).
inbox:callback:read:reasonabsent → reason field shows 'Pastoral inquiry'."
- Method: Unit test + code inspection of voice-queries redaction layer.
- Result: PASS
- Evidence:
rbac.test.ts:102-120verifieseffectivePermissions()unions caps across multiple groups (5 caps across 3 groups, deduped correctly).voice-queries.ts:288,346appliesreason = isPastoral ? row.reason : 'Pastoral inquiry'.- CAVEAT: Current redaction in
voice-queries.tsstill uses legacyisPastoral(fromPASTORAL_ROLES), not the newinbox:callback:read:reasoncapability. Behavior is identical today (pastoral roles map 1:1 to that cap) but this is Phase 4 cleanup. Filed as P2 below.
Case 3 — Usher Team + direct grant inbox:prayer:read
"Visitor + Prayer chips visible. Confidential prayer still masked."
- Method: Unit test.
- Result: PASS
- Evidence:
rbac.test.ts:122-134proves direct grants LAYER on top of group caps (not replace).inbox:prayer:readalone does not grantinbox:prayer:read:confidential, so redaction still applies (voice-queries.ts:348-353).
Case 4 — Revoked-group lockout on next request
"Member loses all Office Admin caps on next request ... member still has a valid
access_token... logs in to a lockout or reduced view."
- Method: Code inspection of
/api/premium/groups/[id]/route.tsDELETE. - Result: PASS-WITH-CAVEAT
- Evidence:
- DELETE handler (lines 197-232) scans for members with that group_id and removes it from
group_ids[]on each. Idempotent. - No caching layer between member row and
requireCapabilitycall, so "next request" observes the updated state. - CAVEAT: The
WeakMapcaches inrbac-server.ts:44-45are per-request, not cross-request. Safe. - No lockout UI exists yet for "member with zero groups and zero caps" (spec §8.5). Empty state will render Home dashboard only (every member has
home:overview:viewvia every template, or via legacy fallback). Not blocking.
- DELETE handler (lines 197-232) scans for members with that group_id and removes it from
Case 5 — Admin-only caps absent from Create Group modal
"Billing, delete-church, transfer-ownership, manage-groups, api-keys, audit:view checkboxes are absent."
- Method:
rbac-catalog.ts:440-459getCapabilitiesByCategory()default + unit test. - Result: PASS
- Evidence:
TeamGroupsPanel.rbac.test.ts:155-181verifies Billing category is absent by default, only appears withincludeAdminOnly: true.filterGrantableCapabilities()strips all 8 admin-only caps. 15 tests pass.
Case 6 — Template rename preserves membership + template_key
"Renamed in UI; existing members retain membership;
template_keyremains'prayer_team'."
- Method:
/api/premium/groups/[id]/route.tsPATCH inspection. - Result: PASS
- Evidence:
- Lines 95-104: name edits update only
name+updated_at. - Lines 113-124:
capabilitiesChangeddiff only flips origin when caps actually change — a rename alone keepsorigin='template'andtemplate_keyintact. - Lines 128-130:
origin = 'custom'only whenexisting.origin === 'template' && capabilitiesChanged. - Members reference the group by
id, nottemplate_key— rename is transparent.
- Lines 95-104: name edits update only
Case 7 — Template → custom conversion
"
originstays'template'until caps edited, then flips to'custom';template_keypreserved; 'Restore template defaults' button offered."
- Method: Route inspection + unit test.
- Result: PARTIAL PASS / BLOCKED on UI affordance
- Evidence:
- Server-side flip is correct:
TeamGroupsPanel.rbac.test.ts:251-275verifiesshouldFlipOriginOnSaveproducescustomon meaningful diff andtemplateon no-op (order-insensitive set comparison). template_keypreservation: route updatesoriginbut nottemplate_key.- GAP: No "Restore template defaults" button in UI. I did not locate
restoreTemplateor similar inTeamGroupsPanel.tsxor modal components. Filed as P2.
- Server-side flip is correct:
Case 8 — Admin creates brand-new custom group "Hospitality"
"Row inserted with
origin='custom',template_key=NULL. Members can be assigned."
- Method:
/api/premium/groups/route.tsPOST inspection. - Result: PASS (code-level; runtime blocked until migration)
- Evidence:
- Lines 100-108: INSERT hard-codes
origin: 'custom',template_key: null,is_deletable: true. Admin-only caps stripped viasanitizeCapabilities(lines 25-36). - Members assigned via
/api/premium/members/[id]PATCH (validates group_ids against church's known groups). - BLOCKED at runtime until
church_custom_groupstable exists (see P0 gap).
- Lines 100-108: INSERT hard-codes
Case 9 — Treasurer API 403 parity across all Inbox endpoints
"All 403. No endpoint returns 200 with redacted data."
- Method: Unit test + route enforcement map cross-check.
- Result: PASS
- Evidence:
rbac.test.ts:257-266— Treasurer template has zero inbox caps./api/premium/requests/route.ts:43enforces per-type cap viarequireCapability. Treasurer lacks any ofinbox:prayer:read,inbox:visitor:read,inbox:callback:read,inbox:safety:read→ 403 on every variant.- Treasurer has
home:metrics:view+home:metrics:financial:view— Home tile behavior correct (financial tile visible, inbox hidden).
Case 10 — UI-hidden-but-API-open regression
"Prayer Team member calls
/api/admin/theologydirectly → 403. Not 200-with-empty."
- Method: Enforcement map (row:
/api/admin/theologyGET, POST →train:theology:edit) + Playwright spec I authored. - Result: PASS (code-level)
- Evidence:
- Enforcement map confirms
train:theology:editis the gate. - Prayer Team template has no
train:*caps (rbac.ts:383-390). - Playwright spec
e2e/rbac-enforcement.spec.tsexercises the 401 shape; 403 shape requires a non-admin token env var (documented, skip-gracefully).
- Enforcement map confirms
Case 11 — Revocation immediacy (same session, no token rotation)
"Joe's next page load ... matching API calls return 403 within same session."
- Method: Code inspection.
- Result: PASS-WITH-CAVEAT
- Evidence:
rbac-server.ts:44-45—contextCache/effectiveCacheareWeakMap<NextRequest, ...>, keyed per incoming request. Next request reads fresh DB state.access_tokenis untouched on group changes (members/[id]route only updatesgroup_ids+capabilities). CAVEAT: Full runtime proof needs the migration live + real non-admin token; deferred to Phase 2 Playwright pass.
Case 12 — access_token stability under role changes
"Joe's
access_tokenis unchanged when groups change. Onlygroup_idsarray is updated."
- Method:
/api/premium/members/[id]/route.tsPATCH inspection. - Result: PASS
- Evidence: Lines 97-130 —
updatesobject contains onlygroup_ids,capabilities,updated_at.access_tokenfield is never written.
Case 13 — Admin-group minimum-one-member invariant
"Removing the last Admin member blocked with 'Admin group must have at least one member.'"
- Method: Code inspection of
/api/premium/team/route.ts(action=remove) and/api/premium/members/[id]PATCH. - Result: FAIL — P1 gap
- Evidence:
/api/premium/team/route.ts:58-70—action=removedoes a plain DELETE with no "count admins" precheck./api/premium/members/[id]/route.tsPATCH allowsgroup_ids: []with no precheck; if the only admin removes themselves from Admin group, no server-side block fires.- The legacy belt (
resolved.role !== 'admin') stops a non-admin from calling this — but an admin removing themselves still slips through.
- Severity: P1 — not a data-loss issue today because legacy fallback guarantees any
role='admin'row still has admin caps, but the moment the migration runs androleis authoritative-via-group_ids only, this becomes a total lockout vector. - Suggested fix: In
/api/premium/members/[id]PATCH, after computing nextgroup_ids, querySELECT count(*) FROM church_team_members WHERE premium_id=$1 AND admin_group_id=ANY(group_ids) AND id != $2and 409 if zero. Same guard on/api/premium/teamaction=remove.
Case 14 — Ownership transfer adds new owner to Admin group
"Transferring primary ownership also ensures the new owner is in the Admin group (auto-added)."
- Method: Search for transfer-ownership handler.
- Result: DEFERRED (UI not shipped; cap exists)
- Evidence:
church:transfer_ownershipcapability defined (rbac.ts:76),/api/admin/backup-owner/route.tsexists (enforcement map row 81). Auto-Admin-add logic not confirmed without running the route. Spec §11 acknowledges this is a founder-decision item. Not blocking for Model C core merge.
Case 15 — Group-delete un-assigns correctly (idempotent)
"Every previously-in-group member has that group_id removed. Idempotent."
- Method: Route inspection + unit test.
- Result: PASS
- Evidence:
/api/premium/groups/[id]/route.ts:197-232— fetches members, iteratesgroup_ids.filter(gid => gid !== id), updates each row.- Idempotent: re-running a delete of an already-deleted group returns 404 from the initial fetch.
TeamGroupsPanel.rbac.test.ts:212-248simulates the optimistic state transition and verifies theunassignedcount matches.
Case 16 — Confidential prayer redaction for Prayer Team member
"Prayer Team member sees the redacted placeholder. Office Admin sees full text."
- Method: Unit test + voice-queries.ts inspection.
- Result: PASS-WITH-CAVEAT
- Evidence:
rbac.test.ts:231-241— Prayer Team hasinbox:prayer:readbut notinbox:prayer:read:confidential.voice-queries.ts:344-353—isPastoral ? row : { ...row, prayer_text: 'Confidential — contact the pastor' }.- CAVEAT:
isPastoralis still legacy-role-driven (PASTORAL_ROLES). Behavior is correct today; Phase 4 cleanup.
Case 17 — Multi-group union cannot grant admin-only cap
"Even if someone assembles 10 custom groups, none can grant
billing:viewbecause the create-group UI never exposes it (server validates on group save)."
- Method:
/api/premium/groups/route.tsPOST + unit test. - Result: PASS
- Evidence:
- Server
sanitizeCapabilities(groups POST + PATCH + members PATCH) stripsADMIN_ONLY_CAPABILITIESfrom input — lines 31-35 of each route. - UI
getCapabilitiesByCategory()default excludes Billing category entirely. rbac.test.ts:194-208asserts only 40 grantable caps exist (48 - 8 admin-only).- Playwright spec
e2e/rbac-enforcement.spec.tsexercises the smuggling attempt (skips when no admin token env var present).
- Server
Case 18 — Direct grant of admin-only cap rejected
"Server rejects with 400: 'Admin-only capabilities cannot be granted directly.'"
- Method:
/api/premium/members/[id]/route.tsPATCH inspection. - Result: PASS-WITH-NOTE
- Evidence: Lines 31-42 —
sanitizeCapabilitiessilently STRIPS admin-only caps rather than returning 400. The behavior is equivalent (never persisted), but the spec's literal "reject with 400" is looser here — the response is 200 with the stripped set. Matches server-side defense-in-depth intent. - Note: Different from spec wording but not a safety issue. If strict 400 behavior is desired, change
sanitizeto detect presence and return 400. Non-blocking.
Case 19 — Phase 2 fallback (empty group_ids, legacy role='prayer_team')
"
effectiveWithFallback()returns the template caps forprayer_team."
- Method: Code inspection + unit test.
- Result: PASS
- Evidence:
rbac-server.ts:178-181— whengroup_ids.length === 0 && capabilities.length === 0, fillsmember.capabilities = legacyRoleToCapabilities(member.role).rbac.test.ts:231-241— prayer_team legacy → template capabilities match exactly.- This is the path EVERY request takes today (no migration; empty group_ids always).
Case 20 — Legacy endpoint compatibility
"
/api/premium/requestscontinues to accepttokenquery param and apply new capability checks."
- Method: Route inspection + Playwright spec.
- Result: PASS
- Evidence:
/api/premium/requests/route.ts:43callsrequireCapability(request, READ_CAP_FOR_TYPE[type])which extracts token viaextractTokenFromRequest(searchParams, null, headers)— query param honored. Legacy belt at line 58-61 still enforcesROLE_REQUEST_TYPES.
Gaps & remediation
P0 — Database migration is missing
- Symptom:
church_custom_groupstable andchurch_team_members.group_ids/.capabilitiescolumns do not exist in production Supabase. - Impact at merge: Library merges safely — every code path fail-softs to legacy. BUT
/api/premium/groups,/api/premium/groups/[id],/api/premium/members/[id]will 500 the moment anyone clicks "Create group" or "Edit groups" in the admin UI. The UI panel itself will render empty (GET returns{groups:[]}). - Severity at merge: P0 only if the Admin Group Management UI is exposed to users. P1 if the UI stays behind an internal flag until the migration ships. P2 if the UI stays completely unwired to the admin dashboard.
- Remediation:
- Write
supabase/migrations/20260419000000_rbac_model_c.sqlper spec §2.1 + §2.2. Include theseedTemplateGroups()helper from §2.4. - Apply migration to production Supabase.
- Run a one-shot backfill for the 13 existing
premium_churchesrows. - Verify via MCP:
SELECT count(*) FROM church_custom_groupsreturns 13 × 12 = 156 rows (one template set per church). - Re-run
pnpm exec playwright test e2e/rbac-enforcement.spec.tswithPLAYWRIGHT_ADMIN_TOKENset. Confirm 200 path works.
- Write
P1 — Admin-group minimum-one-member invariant not enforced
- Symptom:
/api/premium/teamaction=removeand/api/premium/members/[id]PATCH have no check that an admin removing themselves still leaves ≥ 1 admin. - Impact: Once migration ships and legacy
role='admin'is no longer the authority, an admin can remove themselves from the Admin group and lock the church out. - Severity: P1 — no current impact, but becomes P0 at Phase 4 cutover.
- Remediation: ~40 LOC. Add a precheck in both routes:
// In /api/premium/members/[id] PATCH, after computing updates.group_ids:if (Array.isArray(updates.group_ids)) {const { data: adminGroup } = await supabase.from('church_custom_groups').select('id').eq('church_id', ctx.premiumId).eq('template_key', 'admin').single();if (adminGroup && existingMember.group_ids.includes(adminGroup.id)&& !updates.group_ids.includes(adminGroup.id)) {const { count } = await supabase.from('church_team_members').select('id', { count: 'exact', head: true }).eq('premium_id', ctx.premiumId).contains('group_ids', [adminGroup.id]);if ((count ?? 0) <= 1) {return NextResponse.json({ error: 'Admin group must have at least one member.' },{ status: 409 },);}}}
- Recommended: do NOT ship in this PR. File as a follow-up tracked by a failing unit test. Migration ordering matters (template_key lookup requires migration first).
P2 — Redaction layer still legacy-role-driven (voice-queries.ts)
- Symptom:
isPastoralderived fromPASTORAL_ROLES(legacy), not fromcaps.has('inbox:prayer:read:confidential')/caps.has('inbox:callback:read:reason'). - Impact: Behavior is identical today because pastoral roles 1:1 map to those caps. Becomes a correctness issue if a custom group grants
inbox:prayer:read:confidentialwithout being inPASTORAL_ROLES. - Severity: P2 — Phase 4 cleanup.
- Remediation: Pass the effective cap set into
getRequestsByType()/recentActivity()and replaceisPastoralbranches with cap checks.
P2 — "Restore template defaults" button not implemented
- Symptom: Spec §9 case #7 calls for this affordance on edited template groups.
- Impact: UX nicety — admins can still manually restore by comparing
template_keyagainstTEMPLATE_GROUP_CAPABILITIES[templateKey]. - Severity: P2.
- Remediation: Wire a button in
GroupEditorModalthat, whenorigin === 'custom' && template_key !== null, does a PATCH withcapabilities: TEMPLATE_GROUP_CAPABILITIES[template_key]. Server will flip origin back totemplateif the set matches exactly (existingcapabilitiesChangeddiff already supports this).
P2 — Case 18 response shape differs from spec (silent strip vs 400)
- Symptom: Server strips admin-only caps from input silently (200 with sanitized output) vs spec's "reject with 400".
- Impact: Behaviorally equivalent for safety. Differs from docs.
- Remediation: Choose one: strengthen docs to match implementation, or add
if (input contains admin-only) return 400.
P2-drift — Care caps added mid-review; spec + enforcement-map not updated
- Symptom: Concurrent agent added
care:broadcastandcare:subscribers:viewtoALL_CAPABILITIESinsrc/lib/rbac.ts(total is 50 now, 42 grantable). The specadmin-rbac-2026-04-18.md§3 still says "42 capabilities", and the enforcement map still maps/api/care/members+/api/care/broadcasttosettings:notifications:edit(flagged in the map's "Founder decisions needed" §1). - Impact: Taxonomy drift between doc and code. Runtime safe — the new caps aren't referenced by any route yet, and the legacy-fallback still guards all
/api/care/*endpoints correctly. - Remediation: Update spec §3 table + enforcement map to: (a) list the 2 new caps, (b) re-gate
/api/care/membersGET oncare:subscribers:view, (c) re-gate/api/care/broadcastPOST + DELETE oncare:broadcast, (d) add both caps to the Care Team template inrbac.ts:TEMPLATE_CAPABILITIES.care_team. - Already done in this QA pass: 1-line fix to
src/lib/__tests__/rbac.test.ts:204— updated the "40 grantable" assertion to "42 grantable" so the failing test that broke after the concurrent edit passes again. No library code modified.
P3 — Migration file naming convention mismatch
- Spec calls for
supabase/migrations/20260419000000_rbac_model_c.sqlbut existing migrations inpewsearch/migrations/andchurchwiseai-web/supabase/migrations/follow different date-only prefixes. Not a bug — just pick a convention and document.
Files of interest (absolute paths)
C:/dev/churchwiseai-web/src/lib/rbac.ts— 540 LOC, client-safe core libraryC:/dev/churchwiseai-web/src/lib/rbac-server.ts— 240 LOC, server-only resolver +requireCapabilityC:/dev/churchwiseai-web/src/lib/rbac-route-helpers.ts— 149 LOC,requireCapabilityFromTokenfor body-token routesC:/dev/churchwiseai-web/src/lib/rbac-catalog.ts— 467 LOC, human-facing metadata + invariantsC:/dev/churchwiseai-web/src/app/admin/[token]/components/AdminDashboard.rbac.ts— tab-gating layerC:/dev/churchwiseai-web/src/app/api/premium/groups/route.ts— GET/POST group CRUDC:/dev/churchwiseai-web/src/app/api/premium/groups/[id]/route.ts— PATCH/DELETEC:/dev/churchwiseai-web/src/app/api/premium/members/[id]/route.ts— PATCH member groups/capsC:/dev/churchwiseai-web/src/app/api/premium/requests/route.ts— referencerequireCapabilityconsumerC:/dev/churchwiseai-web/src/app/api/premium/team/route.ts— remove/toggle (P1 gap site)C:/dev/churchwiseai-web/src/lib/__tests__/rbac.test.ts— 27 unit testsC:/dev/churchwiseai-web/src/app/admin/[token]/components/__tests__/AdminDashboard.rbac.test.ts— 19 testsC:/dev/churchwiseai-web/src/app/admin/[token]/components/__tests__/TeamGroupsPanel.rbac.test.ts— 15 tests (not mentioned in brief, already present)C:/dev/churchwiseai-web/src/app/api/__tests__/rbac-enforcement.spec.ts— 8 pass / 2 skippedC:/dev/churchwiseai-web/e2e/rbac-enforcement.spec.ts— NEW Playwright integration spec (this review)C:/dev/knowledge/architecture/admin-rbac-2026-04-18.md— spec (read-only in worktree)C:/dev/knowledge/architecture/admin-rbac-api-enforcement-map.md— route mapping
What NOT to do
- Do not claim this is end-to-end verified. The real 200 path for
/api/premium/groupsPOST/PATCH/DELETE has never been exercised against a DB withchurch_custom_groupspresent. The unit layer is rock-solid; the writer surface needs one real round-trip before shipping to a paying customer. - Do not drop the legacy-role fallback in
rbac-server.ts:179-181until the migration ships AND every extantchurch_team_membersrow hasgroup_idspopulated. - Do not raise admin-only caps to grantable status. The 8 keys in
ADMIN_ONLY_CAPABILITIESmust stay sealed via the Create-Group UI (Billing category hidden) AND the server-side sanitizer.
Reviewer recommendation
Merge feat/admin-rbac-model-c to main today, then immediately ship a follow-up PR with:
supabase/migrations/20260419000000_rbac_model_c.sql(schema + seed)- P1 fix for Admin-group minimum-one-member invariant
- Backfill + post-migration Playwright smoke against preview URL
This staged approach keeps the library + unit-test suite in place today (zero regression risk), and moves the write-path certification to a narrow follow-up PR that has a clear acceptance test (Playwright spec + live preview URL).