Skip to content

T 022


uid: T-022 title: Enable RBAC enforcement β€” strict Security Rules (all DBs) + badge-based permissions model + flip RBAC_ENABLED status: done area: auth-rbac created: 2026-06-13 updated: 2026-06-23 precedes: T-025, T-027


RBAC initiative β€” sequence map (marked 2026-06-23, EOP Local Assistance). The RBAC/auth tasks are deliberately split (not merged), so this records the ORDER instead. Three parallel tracks: - Enforcement (this track): T-022 (hub β€” do FIRST) β†’ T-025 (client-SDK migration; needs the strict Rules live) β†’ T-027 (per-DB SA scoping; optional defense-in-depth, lowest priority). T-026 (Rules CI auto-deploy) is supporting tooling β€” no hard precede/succeed; can land independently, and is currently blocked on cross-session coordination. - People / role model: T-094 (roster + user-management β€” FIRST) β†’ T-095 (it_support role; needs T-094's role-assignment UI). - Auth specifics: T-083 + T-088 are independent (no ordering β€” parallelisable); T-057 β†’ T-058 were the historical pair (both done).

Each task carries succeeds:/precedes: frontmatter mirroring this. (Convention introduced here, owner- requested 2026-06-23.)

Goal

Make user access to Firestore enforced by the DATABASE, not just remembered in code: user-facing reads/writes go through the Firebase CLIENT SDK gated by Security Rules + custom claims; the Admin SDK is reserved for privileged/derived/ integration ops (journals, reports, bank/Drive/GCP/Telegram, claim mints, cross-entity admin).

Key finding (2026-06-13 scoping)

This is ~80% ALREADY BUILT, behind the RBAC_ENABLED flag: - lib/rbac/ β€” claims.ts, permissions.ts, subsidiaryAccess.ts, types.ts, config.ts. Custom claims (role/status/subsidiaries/primarySubsidiary) synced on profile create/update + sign-in, even while RBAC_ENABLED=false (SYNC_CLAIMS_WHEN_DISABLED). - Security Rules files for ALL 4 DBs (firestore.{tebs-erl,tebs-mel,tebs-epl,aote-system}.rules) wired in firebase.json β€” currently PERMISSIVE; STRICT role+subsidiary versions written but commented out. - Client SDK already used in some components (StudentDialog); user profiles in aote-system/users with role + subsidiaryAccess; roles: super_admin / admin / auditing_accountant / bookkeeper / project_admin / pending. So the work = FINISH + ENABLE, not design from scratch: tighten/verify the strict rules (incl. for the migrated documents per T-021), decide which Admin-SDK reads move client-side, scope per-DB, add a Rules test plan, flip the flag.

This task vs siblings (RBAC initiative)

T-022 is now scoped to the ENABLE step only. The other surfaces are their own UIDs: - T-025 β€” migrate user-facing Firestore reads/writes to the client SDK. - T-026 β€” re-introduce the firestore.rules CI auto-deploy workflow. - T-027 β€” narrow the Firestore Admin SDK credential (per-database SA scoping). So T-022 = tighten/verify the strict (commented) Security Rules for all 4 DBs incl. the migrated documents (T-021), complete position-level claims, add a Rules test plan, and flip RBAC_ENABLED.

SA coordination β€” answered 2026-06-13 (UNBLOCKED, zero conflict)

The SA-refactoring agent's refactor is pure GCP IAM and orthogonal to all auth work: - App's Firestore cred = firebase-adminsdk-fbsvc@aote-pms (FIREBASE_ADMIN_PROJECT_ID/ _CLIENT_EMAIL/_PRIVATE_KEY in Vercel) β€” UNTOUCHED by their refactor; still datastore.owner + firebase.sdkAdminServiceAgent at project scope. Narrowing it is mine β†’ T-027. - They are NOT touching Firebase Auth, custom claims, providers, or NextAuth. Claims (subsidiary + position/role) are entirely mine β€” ship anytime. - Client SDK + Security Rules are mine β€” ship anytime; NO timeline constraints. - CI: firebase-rules-deployer@ (role firebaserules.admin) + GH secrets FIREBASE_DEPLOY_KEY + FIREBASE_ADMIN_PROJECT_ID are ALREADY provisioned to auto-deploy firestore.*.rules; the workflow file was reverted at 93e64a2 (branch claude/audit-user-access-control-cch7t) and must be re-introduced β†’ T-026 (coordinate with the user-access-control session, not the SA agent). - Their ask back: if I mint any new SA, note it in memory project_sa_audit_aote_pms. - Caveat: MEL/EPL not yet incorporated β†’ tebs-mel/tebs-epl are conceptual; tebs-erl is the only real subsidiary DB; aote-system is system-config.

Log

  • 2026-06-13 created (was blocked on SA answers).
  • 2026-06-13 UNBLOCKED + re-scoped to the enable step; SA answers recorded; split client-SDK migration / CI workflow / SA-scoping into T-025/026/027.
  • 2026-06-23 expanded scope to own the role-model split β€” "complete position-level claims" promoted from a footnote into the primary deliverable. See decision-log entry below for the badge-based model the owner approved. The it-support capability that T-095 was going to ship as a standalone role is folded inside T-022 instead: it's one of the starter badges in the registry T-022 introduces. T-095 was dropped by Accounting (Infrastructure) on 2026-06-23 with an explicit hand-off to me; its originating problem (I-007 infra-alert leak) is already shipped/fixed via PR #785.

Decision log

2026-06-23 β€” expanded scope: badge-based, IAM-style permissions model

  • βœ… Attestation (User Management): read AGENTS.md; per the cross-agent-edit rule I'm appending this entry rather than rewriting the prior author's prose, and signing it with the Source: line below. The earlier ## Goal, ## Key finding, ## This task vs siblings, and ## SA coordination sections (EOP Local Assistance, 2026-06-13) stay as written β€” they remain accurate at the enforcement layer. This entry adds the permissions-model layer the enforcement work has to encode.
  • Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
  • Proposed by: User Management (the conceptual model). Approved by: the owner (the owner re-framed it into a badge / IAM-style model and approved baking subsidiary into each badge).

Why the rescope β€” the gap the prior wording left

lib/rbac/types.ts today carries one UserRole union that mixes two independent dimensions:

type UserRole = 'super_admin' | 'admin'                       // ← clearance / authority
              | 'auditing_accountant' | 'bookkeeper' | 'project_admin'   // ← position / job function
              | 'pending'

You can't say "Alison is a Bookkeeper with Admin clearance" β€” you'd have to pick one. The prior wording promised "complete position-level claims" but didn't define what a position-level claim should look like alongside everything else. This entry defines it.

The owner's preferred mental model (verbatim quotes under "Evidence" below) is named permission bundles (badges) attached to users, where: - Each badge declares a set of (surface, resource, action, subsidiary) grants in IAM-style permission strings. - Subsidiary is baked into the badge β€” e.g. mel-administrator is intrinsically the MEL-scoped one; there is no separate "soft" subsidiary field overriding the badge's grants. - A user holds one or more badges; effective rights are the union of those badges' grants. - "Clearance" (Super Admin / Admin) is just a class of badges that grants meta-actions β€” rbac:badges:edit@all, rbac:assign@all, audit-log:read@all. There is no separate clearance axis; it's badges all the way down.

The permission-string shape (working defaults β€” refine on contact with reality)

<surface>:<resource>:<action>@<subsidiary>

Axis Working values
surface coaching, projects, accounting, bank, contacts, records, dashboard, notifications, profile, file-archive, gadgets, tools, bank-accounts, dmarc, hk-banks, audit-log, rbac, infra
resource per logical resource within each surface, e.g. transactions, adjustments, period, journals under accounting; sessions, students, invoice under coaching. Codified incrementally as each surface is migrated to badge-checked actions.
action read, write (= create + update), delete, approve, configure. Wildcards: *.
subsidiary erl, mel, epl, jft, all. Single value, no list β€” a badge that needs MEL+EPL is two badges.

Wildcards allowed on each axis: accounting:*:read@erl (read everything in accounting under ERL), *:*:read@mel (read-only on every MEL surface).

The starter badge set the owner described

Each badge is a curated bundle of the primitives above. Subsidiary-baked: there is no separate subsidiary field on the user record once badges are live.

Badge id (working) What it grants β€” at a glance
mel-administrator coaching:*:*@mel, contacts:students:*@mel, notifications:*:read@all
epl-administrator projects:*:*@epl, contacts:clients:*@epl, notifications:*:read@all
bookkeeper-mel accounting:transactions:read+write@mel, coaching:invoice:read@mel, bank:accounts:read@erl
bookkeeper-epl accounting:transactions:read+write@epl, projects:invoice:read@epl, bank:accounts:read@erl
auditing-accountant-erl accounting:*:read@erl + :write+approve+configure on adjustments/period/journals; bank:*:read@erl
director-erl union of MEL+EPL admin + accounting+bank+records under ERL; high-value approvals; audit-log:read@all
super-admin (meta-badge) rbac:badges:edit@all, rbac:assign@all, audit-log:read@all, plus access to the Settings β†’ Badges editor
it-support infra:dmarc:read@all, notifications:infra:read@all β€” no accounting/projects/users surface

Stacking examples: the owner is director-erl + super-admin. An external auditor at year-end gets auditing-accountant-erl only, with accessExpiresAt. A coaching teacher who also does light EPL admin during summer gets mel-administrator + epl-administrator.

Migration path β€” what changes where

This is the work T-022 now owns end-to-end. Sub-steps land as separate PRs under this UID:

  1. Data model + claims. Add badges: string[] to UserProfile (alongside the existing role for read-back compatibility during the migration). Add BADGES registry to lib/rbac/types.ts with the primitives table + the starter badge bundles. Sync badges into Firebase custom claims via claims.ts so server-side guards can read them without a Firestore round-trip.
  2. Page rules. Refactor lib/rbac/pageRules.ts's PageRule to express "user must hold at least one badge whose grants intersect this set of required primitives" instead of allowedRoles. The existing per-page rule format (PR #424) already supports incremental migration via the Firestore overlay.
  3. Action permissions. Replace ROLE_PERMISSIONS with BADGE_PERMISSIONS keyed by badge id. Replace canPerform(role, status, permission) with canPerformAction(user.badges, permission) that scans across all of the user's badges.
  4. Strict Security Rules. The strict Security Rules (today commented out) read custom claims directly; rewrite them to read the badges claim and grant per-collection access by matching badge primitives.
  5. Existing-user migration. Map every existing (role, subsidiaryAccess) pair to the closest badge bundle; write the migration as scripts/migrate-users-to-badges.ts (idempotent, dry-run by default) and keep a one-PR-window of dual-read fallback in requireRbac so a rollback doesn't lock users out.
  6. Admin UI. Settings β†’ User Management β†’ Users tab gets a "Badges" column + a badge-picker in the Edit User modal (checkbox grid of badge ids). Settings gains a Badges editor tab so the owner can add new badges / edit primitives without a code deploy (admin-only, server-side validation, Admin-SDK writes β€” mirrors the /rbac/pageRules collection pattern from PR #424).
  7. Flip RBAC_ENABLED. With strict Rules deployed and migration complete, flip the flag.

Each step is a PR-sized increment; T-022 stays doing until all seven land. T-095 succeeds this (its it_support becomes a badge id under step 6, not a UserRole union member).

Progress
  • Step 1 β€” data model + registry + claims β€” DONE (branch claude/t022-badge-model). lib/rbac/badges.ts holds the permission grammar (surface:resource:action@subsidiary), the pure parser/matcher (parsePermission / grantSatisfies / expandBadges / badgesSatisfy), and the BADGES registry with all 8 starter badges. UserProfile.badges?
  • CustomClaims.badges? added (ids only; omitted-when-empty to stay under Firebase's ~1000-byte claim limit); buildClaimsFromProfile syncs them; ADMIN_PROFILE_UPDATE_KEYS accepts the field. 27 unit tests pin the grammar + union semantics + registry invariants. Dormant β€” no live gate consumes badges yet; role is still enforced. tsc clean; suite 424/424. Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
  • Step 2 β€” page rules + requireRbac understand badges β€” DONE (same branch). PageRule.requireAnyBadgeGrant?: string[] added; evaluatePageRule and requireRbac's RbacCheckOptions understand it with three-case semantics: (1) user satisfies badge gate β†’ pass, skip legacy checks; (2) user fails badge gate but the rule has legacy gates β†’ fall through to legacy (migration safety β€” role-only users keep working); (3) user fails badge gate AND the rule has no legacy gates β†’ deny (a badge-gated-only rule isn't a no-op). Status check still applies on the badge path so suspended users can't slip in. DEFAULT_PAGE_RULES adds badge-grant requirements alongside every existing legacy entry (no rule had its legacy fields REMOVED). NextAuth session/JWT propagate the new badges field through applyProfileToTokenUser + both callback emit points; types/next-auth.d.ts augments Session.user / User / JWT. 14 new unit tests pin the badge-shortcut semantics + DEFAULT_PAGE_RULES against the 8 starter badges. Dormant in practice β€” no user has badges yet, so the legacy gates still do all the work; but the moment an admin assigns a badge in step 6, the badge path activates for that user. tsc clean; suite 438/438. Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
  • Step 3 β€” action permissions (canPerformAction + bridge) β€” DONE (same branch). Two functions added to lib/rbac/badges.ts: canPerformAction(user, permission) is the pure badge check (badges + status='active'); canPerformActionOrLegacy(user, { badge, legacy? }) is the migration bridge that takes the badge path first and falls back to the existing canPerform(role, status, legacy) when the user has no matching badge. No ROLE_PERMISSIONS rewrite β€” the legacy table stays in place during the migration so role-only users keep working until step 7's flag flip. The bridge's signature lets call sites name BOTH grants explicitly ({ badge: 'accounting:transactions:write@mel', legacy: 'transactions:write' }) so the badge path can carry the subsidiary scope a pure legacy mapping couldn't (the legacy Permission strings are subsidiary-agnostic by design β€” transactions:write doesn't say "for which subsidiary"). Step 5+ will migrate API-route call sites incrementally to canPerformActionOrLegacy; once RBAC_ENABLED flips in step 7, the legacy field drops and bridge collapses to canPerformAction. 15 new unit tests pin the pure-badge semantics + the badge-or-legacy bridge (RBAC_ENABLED mocked true for the legacy-path tests). Dormant in practice β€” no live gate calls either function yet; both are available for the step-5+ caller migration. tsc clean; suite 453/453. Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
  • Step 4 β€” strict Security Rules read badges from claims β€” DONE (same branch). All four rules files (firestore.aote-system.rules, tebs-erl, tebs-mel, tebs-epl) now carry badge-aware helpers (hasBadge / hasAnyBadge reading request.auth.token.badges) and capability functions that gate per-surface access on EITHER a matching badge OR a matching legacy role (migration-window dual-gate). The commented strict alternatives are rewritten in terms of canReadProjects(), canWriteAccountingErl(), canReadCoaching(), canReadClients(), canWriteBankAccounts() etc β€” each capability enumerates the badge ids it accepts in one place per file, so a registry rename is a single rules edit not N. Live behaviour change in aote-system.rules only: the /users/{uid} strict write rule now accepts super-admin badge holders in addition to role: admin | super_admin (isAdminFromClaims() β†’ isAdminOrSuperBadge()); additive β€” no user holds badges today so behaviour is byte-identical. __tests__/lib/rbac/strictRulesBadgeDrift.test.ts pins the badge-id literals across rules files against the BADGES registry β€” a rename in TS that didn't update the rules fails the suite loudly. Rules still commented in strict mode β€” step 7 uncomments them; until then the live behaviour is the permissive baseline plus the one /users additive change. tsc clean; suite 457/457. Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
  • Step 5 β€” existing-user migration script β€” DONE (same branch). lib/rbac/badgeMigration.ts is the pure mapping: mapUserToBadges({role, subsidiaryAccess}) returns the closest badge bundle from the BADGES registry. Choices encode the migration semantic so post-step-7 access never NARROWS during the window: super_admin/admin β†’ ['super-admin'] (clearance); auditing_accountant β†’ auditing-accountant-erl + bookkeeper-{mel,epl} per granted subsidiary; bookkeeper β†’ bookkeeper-{mel,epl}; project_admin β†’ mel-administrator + epl-administrator; pending β†’ []. diffBadges(current, next) computes the set-equal / added / removed view used by the script's dry-run printer. scripts/migrate-users-to-badges.ts lists users, prints a per-user diff, and on --apply writes badges + re-syncs Firebase custom claims so the next request carries them. Flags: --apply (write), --uid=<u> (one user), --status=<s> (filter), --no-claims (skip claim sync β€” user re-signs to pick up). Idempotent: re-running on already-migrated users is a no-op. Dormant in practice β€” the badge ride is additive, the migration bridge (steps 2-4) keeps role-only users working, and strict rules stay commented until step 7. 27 new unit tests pin the mapping
  • diff. tsc clean; suite 484/484. Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
  • Step 6 β€” admin badge-picker UI β€” DONE (same branch). Added a "Badges (T-022)" section to components/settings/UserEditModal.tsx between Subsidiary Access and Line Manager. Two-column checkbox grid grouped by badge kind (operational / clearance), each row showing label + one-line description from the BADGES registry. A "Suggested for current role" hint runs the same mapUserToBadges the migration script uses, displays chips, and offers a one-click "Use these" button (disabled when the selection already matches). State selectedBadges: string[], initialised from user.badges on user-switch; sent in the PATCH body only when the set differs (order-independent equality so the audit trail and claims sync don't churn). API plumbing: UpdateBody.badges validated as Array<isBadgeId>; updateUserProfile's signature gains badges?; audit before/after snapshots capture the change. Picker is dormant for existing users β€” opens with whatever badges they already hold ([] for users not migrated yet); checking a box and Save runs through the same API path the script uses. tsc clean; suite 484/484. Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
  • Step 7 β€” strict Security Rules flip β€” STAGED IN PR (branch claude/t022-step-7-strict-rules, opened SEPARATELY from PR #797 because it has real production blast radius and must merge AFTER the migration script runs on prod). All four rules files (firestore.{aote-system,tebs-erl,tebs-mel,tebs-epl}.rules) flipped from permissive to strict: helpers uncommented + permissive allow … if isAuthenticated() lines replaced with the capability-function gates (badge OR legacy role) from step 4. Deploy runbook lives at docs/runbooks/t022-step7-strict-rules-flip.md β€” pre-merge checklist (migration applied + super-admin badges verified for bootstrap directors), smoke steps, rollback path. The RBAC_ENABLED env-var flip stays a separate operator action AFTER the rules deploy bakes for 24h. The legacy role half of the dual gate stays in the rules for a follow-up cleanup PR once we're confident no role-only path is in flight (typically 1-2 weeks after the flip). Drift guard still green; tsc clean; suite 484/484. Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW

T-022 work is now feature-complete on claude/t022-step-7-strict-rules; the remaining motions are operator merges + the prod migration window. After step 7 lands and bakes, open a follow-up T-NNN to drop the legacy role half from every capability helper and remove ROLE_PERMISSIONS.

Why subsidiary-in-badge and not subsidiary-orthogonal

The owner asked the question directly ("Would you think that it's stupid if I would like the subsidiary to bake into each badge?"). The answer is no β€” it's the AWS IAM pattern: each policy intrinsically scopes itself to the resource. Trade-off accepted: more badges (bookkeeper-mel, bookkeeper-epl, bookkeeper-mel+epl) instead of one bookkeeper badge Γ— subsidiary field, in exchange for every badge name being a complete grant on its own β€” no "soft override" confusion where someone holds a badge but a separate subsidiary field denies it. Auditing reduces to "show me this user's badges" β†’ done.

Granularity β€” working defaults (refine on contact with reality)

Action vocabulary picked as read Β· write Β· delete Β· approve Β· configure (CRUD + approve + configure, where write covers create+update). Resource granularity picked as per logical resource within each surface (so accounting has transactions, adjustments, period, journals, not just one accounting:*). Both were the recommended defaults at the AskUserQuestion prompt the owner answered "Proceed as discussed" on β€” the owner didn't explicitly pick either, so they're flagged as working defaults: if a real per-resource case comes up where these don't fit (e.g. wanting separate create vs update, or a bespoke verb like accounting:transactions:post), refine the table at the top of this section.

Evidence β€” owner, 2026-06-23 (verbatim)

"Even though it's complicated, but I'd imagine that the access for each place and the actions one could do there should have a check box for enabling and disabling, that could be saved as a preset or to call them access badges (if we wanna be cute about it), eventually, if one role wants to have access to different places, they should have multiple badges (in the concept of a key) that grants them the right to do certain things."

"for MEL, there should only be an Administrator handling the management of MEL; an Administrator handling the management of EPL, while a bookkeeping (which manages the administrating of the company's book that sums that operation of MEL+EPL), an auditing accountant and directors of ERL. And each of these roles should have certain access to the places on EOP."

"ERL (the legal front)=the Accounting page+Bank Access+records+subscription (also everything is under ERL's name legally)+everything under MEL+EPL that uses ERL's legal front."

"Would you think that it's stupid if I would like the subsidiary to bake into each badge?"

"For the badges that you've came up with, I'd actually like you to either sub-divide it into smaller badges, so that I can control the read/ write and actions on certain pages too (like the concept of IAM)."

"Proceed as discussed please" (approval to lock the rescope after the granularity question was interrupted; recommended defaults applied with a flag in the granularity section above.)

2026-06-25 β€” closed (steps 1-7 merged, migration applied, strict rules live)

  • βœ… Attestation (User Management): read AGENTS.md; closing-the-loop entry per the rule β€” outcome + commits + blast-radius note + the GH workflow + IAM fix that surfaced during deploy.
  • Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
  • Outcome / verdict β€” what landed:
  • PR #797 (steps 1-6, badge model + page rules + actions + Security Rules helpers + user migration script + admin UI badge picker) merged to main at f3d34df1. Dormant until the migration script ran; the only live behavioural change at this point was the additive /users write rule admitting super-admin badge holders alongside the legacy role check.
  • scripts/migrate-users-to-badges.ts --apply run on prod aote-system 2026-06-25. 4 users mapped: me@jefferochan.com (super_admin) β†’ ['super-admin'], alisonhytang@gmail.com (admin) β†’ ['super-admin'], jake@establishrecords.com (admin) β†’ ['super-admin'], jeffero.c@gmail.com (auditing_accountant + allAccess) β†’ ['auditing-accountant-erl','bookkeeper-epl','bookkeeper-mel']. Idempotency-confirmed (re-running dry-run reported zero diffs). Both bootstrap directors hold super-admin (the rescue valve named in the step 7 runbook).
  • PR #804 (step 7 β€” strict-rules flip) merged to main at cfcac3a4. Permissive allow … if isAuthenticated() lines across all four firestore.*.rules files removed and replaced with the capability gates from step 4 (badge OR legacy role bridge).
  • deploy-firestore-rules GitHub workflow run 28139736974 β€” initial run failed with serviceusage.services.get 403; granted firebase-rules-deployer@aote-pms the roles/serviceusage.serviceUsageConsumer role via the cloud-resource-manager REST API (cloud-agent SA had IAM admin); re-ran the failed job; second attempt completed green in 6 seconds. Strict rules now live across all four databases on the aote-pms Firebase project. The same 403 had silently failed PR #797 + PR #424's deploys previously β€” every rules push since 2026-05-18 had been failing to land in prod until this fix; documented in this entry so the next agent who touches the workflow knows the IAM dependency.
  • Commits: f3d34df1 (PR #797), cfcac3a4 (PR #804). Migration script + IAM fix have no commit (operator-action against prod from the cloud-agent sandbox; recorded here for audit).
  • What's NOT done in this task (deliberate):
  • RBAC_ENABLED=true in Vercel β€” the env-var flip is a separate, deliberate operator action 24h after the strict-rules deploy. The bridge means the rules behave correctly with the flag still false; flipping it tightens the server-side canPerform check on top.
  • Drop the legacy role half of the dual gate β€” tracked under T-107. Soak window: ~1-2 weeks of normal operation with strict rules live, zero PERMISSION_DENIED spikes attributable to a missing badge.
  • Blast-radius note for other agents:
  • Strict Firestore rules ARE live now. Every user-facing client-SDK read/write goes through the strict gates. The badge claim is on every active user's token; the legacy role claim too. The bridge admits either, so role-only paths still work for the soak window.
  • deploy-firestore-rules.yml workflow needs serviceusage.serviceUsageConsumer on its SA. Don't strip this grant without a replacement plan. The drift guard (__tests__/lib/rbac/strictRulesBadgeDrift.test.ts) protects the badge-id literals in the rules files but doesn't catch IAM regressions.
  • The 8-badge starter registry (lib/rbac/badges.ts BADGES) is now load-bearing for real prod access. Renaming a badge id requires editing all four rules files in lockstep
    • the drift guard (which fails loudly on miss).
  • Evidence β€” owner, 2026-06-25 (verbatim):

    "I need you to proceed with all While you'll be running the script for me as well" (explicit authorization to run the migration script on prod + drive the merges/deploy end-to-end.)