T 094
uid: T-094 title: Finalize RBAC β addressable people roster + user-management so features can target people by role status: done area: auth-rbac created: 2026-06-23 updated: 2026-06-23 precedes: T-095
RBAC sequence: precedes T-095 β the
it_supportrole can't be assigned until this roster + role-management layer exists. The people/role track (T-094 β T-095) is separate from the enforcement track (T-022 β T-025 β T-027). See the sequence map in T-022. (Marked by EOP Local Assistance, 2026-06-23.) Renumbered T-092 β T-094 on creation (2026-06-23). TheT-092UID collided three ways across in-flight branches:origin/mainalready had T-092 = "WOPC β render per-line-item notes under the fee type" (done, via PR #788) andorigin/nightlyhad T-092 = "Restore the ClaudeβNAS root-exec channel" (todo, a cloud agent). Per AGENTS.md "whoever merges second renumbers," this RBAC task took T-094 β past both, leaving T-093 for the nightly NAS task to renumber into when it reconciles with main. README next-free bumped to T-095. (The pre-existing mainβnightly T-092 collision between the WOPC + NAS tasks is flagged to the owner separately β not this agent's to resolve.) Source: EOP Local Assistance Β· local session.
Why this exists (the trigger)¶
Building the IR56M sign-and-seal distribution flow (T-047 phase 4 #5) surfaced a gap: when a
document is signed, the signing director should pick which actual people receive it β shown as real
identities with their role, e.g. "Jeffero Chan β Director, Jake Ngai β Director, Alison Tang β
Accountant" β not just abstract role names. Today the only real users are the two directors
(project_eop_user_base); roles like accountant / bookkeeper exist in the model
(aote-system/users β super_admin / admin / auditing_accountant / bookkeeper / project_admin / pending)
but there's no finalized roster of people in those roles and no management surface to add/assign
them. So a people-picker can't yet list "everyone in the web app with their role."
The owner flagged this directly while specifying the IR56M distribution recipients (see evidence). It's a prerequisite for any feature that addresses people β IR56M distribution, the planned notification center (T-037), email targeting, approvals.
Scope¶
Finalize the user-management / people side of RBAC (distinct from enforcement β see "vs T-022"):
- A canonical, queryable people roster: every web-app person with a stable identity (name, contact
email) + their role(s) + subsidiary access, readable by features that need to target recipients.
- A management surface (UI) to add people, assign roles, and set/revoke access β so non-director roles
(accountant, bookkeeper, β¦) become real, addressable users instead of placeholders. Reuse the existing
plumbing: lib/rbac/ (roles.ts, claims.ts, invitations.ts, approval.ts, accessExpiry.ts,
permissions.ts) + the invitations API (pages/api/admin/invitations/). The brief in the
project_user_mgmt_notification_onboarding memory is the starting point.
- A small shared accessor features can call β "list active people (with role + email), optionally
filtered by role/subsidiary" β so pickers (IR56M distribution, notifications) don't each re-derive it.
- Reconcile the directors registry (lib/directors/registry.ts, hard-coded 2 directors) with the
aote-system/users roster so a person isn't represented twice with different ids.
This task vs siblings (RBAC initiative)¶
The RBAC work is already split across UIDs; this one is the people/roster/management side, which none
of the others own:
- T-022 β enforcement (strict Security Rules for all DBs + position claims + flip RBAC_ENABLED).
- 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-DB SA scoping).
T-092 = the roster + management + addressable-people accessor. Cross-links T-047 (the driver) and
T-037 (notifications, which will consume the same roster).
Interim unblock for T-047 #5 (so distribution ships before this lands)¶
The IR56M distribution picker will source recipients from whatever real people exist today (the two directors + the IR56M recipient's payee email), with the default selection = directors + recipient + (accountant + bookkeeper when they exist). As T-092 fills the roster, those roles auto-appear in the picker with no IR56M change. The IR56M feature must degrade gracefully when a defaulted role has no person yet (skip + flag, never block the send).
Decision record¶
- β
Attestation (EOP Local Assistance): read
AGENTS.md; created this task by scope (checked the board β no existing task covers the user-roster/management layer; T-022 is enforcement-only), keeping the board current. - Source: EOP Local Assistance Β· local Claude Code session
- Proposed by: EOP Local Assistance Β· Approved by: the owner (asked for the task explicitly).
- Evidence β owner, 2026-06-23 (verbatim):
"list out not just the role name (like, Directors/recipient/super admin/ admin etc...) but the actual information of those people (like, Jeffero Chan - Director, Jake Ngai - Director, Alison Tang - Accountant). β¦ this instance actually also makes me realize that I'll have to further develop and finalize the RBAC. Can you help me create a task and stating why it was created?"
Log¶
- 2026-06-23 created (owner-requested) while building T-047 #5 IR56M distribution. Scoped as the RBAC people-roster/management layer; cross-linked to the enforcement sibling T-022 and the notifications consumer T-037.
- 2026-06-23 picked up by User Management (status: todo β doing). Design entry below.
Decision log¶
2026-06-23 β picked up, scope refined to "thin shared accessor + sync"¶
- β
Attestation (User Management): read
AGENTS.md; scope-scanned the board β confirmed no other task covers the people-roster/management layer; T-026 (which I just closed) is workflow CI, T-088 is degraded-link tolerance, neither overlaps. - Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
- Proposed by: User Management. Approved by: the owner.
- Rationale (the landscape I found): ~70% of T-094's surface area is already built.
lib/rbac/types.tsalready carries the full profile shape:uid,email,displayName,firstName/lastName,role,status,subsidiaryAccess,primarySubsidiary,jobTitle,organization,isExternal,phoneNumber,whatsappNumber,preferredContactMethod,groups. No schema change needed.lib/rbac/claims.ts::listUsers()is the canonical server-side enumerator (aote-system/users, filterable by status/role).pages/api/admin/users/{index,[uid]}.tsis the admin CRUD surface.components/settings/SettingsApp.tsxalready mounts a full Settings β User Management tab with sub-tabs Users / Approvals / Line Managers / Roles / Invitations, each backed by the admin API.- The current UI gap is narrow: the Users sub-tab's table shows only Name+Email / Role / Status / Actions, omitting subsidiary access β but the EDIT modal already exposes it. So the management surface is real; the table needs a subsidiary column for quick triage.
- What's actually missing β this PR's scope:
- A shared, addressable-people accessor for features that need to target recipients
(IR56M distribution per T-047 #5, future notification picker per T-037, approval
routing). New module
lib/people/roster.ts(server-side, wrapslistUsers()+ the directors registry); REST endpointGET /api/people/rosterfor client consumers; React Query hookusePeopleRoster()for client pickers. - Light reconciliation with the directors registry.
lib/directors/registry.tscarriesloginEmailsper director; the accessor cross-references by email, attachesisDirector- the registry id +
directorClosingName. Directors missing fromaote-system/users(haven't signed in yet, or are stale) are emitted as synthetic roster entries so a picker that needs "the directors" always sees both, regardless of user-record state. A full merger of the registry intoaote-system/users(so directors stop being a second source of truth) is deliberately out of scope here β it's the structural follow-up hinted at inlib/directors/registry.ts's own header comment ("When that lands, this registry should become a typed view over it rather than a second source of truth"). Open a separate T-NNN if/when it lands.
- the registry id +
- Surface subsidiaries in the existing Users tab so admins can triage who has what access without opening the Edit modal for every row.
- Why this shape and not a bigger structural rewrite: the registry merger is genuinely
larger work β it changes who owns the closing-signatory data, touches WOPC pipeline
invariants (
resolveRequiredClosingDirectoretc.), and needs a migration that re-keys user docs. T-094's stated goal is unblocking IR56M distribution and the notification centre, not consolidating director identity. A thin accessor satisfies the consumers today; the deeper consolidation can ship later with its own task carrying the WOPC blast-radius analysis. - Evidence β owner, 2026-06-23 (verbatim):
"You're User Management, register yourself as such please."
"Proceed with with 1-3 first then" (in reply to my task-triage of T-022/T-025/T-026/ T-027/T-083/T-088/T-094/T-095, where I recommended: 1) close T-026, 2) T-094, 3) T-095) - Plan: ship as PR off
claude/t094-people-rosterβ main; status flips todoneonce the accessor, endpoint, hook, and UsersTab subsidiary column land green ontsc.
2026-06-23 β closed (accessor + endpoint + hook + UsersTab subsidiary column shipped)¶
- β
Attestation (User Management): read
AGENTS.md; per the close-the-loop rule, capturing outcome + commits + blast-radius before flipping todone. - Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
- Outcome / verdict: shipped the thin shared accessor exactly as designed.
lib/people/roster.tsβ server-side accessor + thePersonRosterEntryshape;listRoster(filter?),getRosterEntryByUid,getRosterEntryByEmail. Directors missing fromaote-system/usersare emitted as synthetic entries withsource: 'directors-registry'so a picker that needs "the two directors" always sees both regardless of user-doc state. The directors registry is cross-referenced by email; the deeper structural merger (directors β users single source of truth) is the WOPC-blast-radius follow-up flagged inlib/directors/registry.ts's own header β not in this PR.pages/api/people/roster.tsβ GET endpoint, authenticated active users only, 30s shared CDN cache.lib/people/usePeopleRoster.tsβ React Query hook with 30s stale / 5min gc; multiple pickers share a single fetch.components/settings/SettingsApp.tsxβ UsersTab table now shows a Subsidiaries column with code chips and a β on the primary, closing the at-a-glance triage gap noted in the design entry above.- Commit:
d14dbeb4(PR β opened onclaude/t094-people-roster, will be merged tomainon owner's go-ahead). Status flipped todonein the merge commit. - Blast-radius note for other agents:
- T-047 #5 IR56M distribution picker β can now stop deferring and call
usePeopleRoster({ roles: ['admin', 'auditing_accountant', 'bookkeeper'] })for the default recipient set. Directors are always present (synth fallback) so the picker no longer has to special-case the registry. - T-037 notification centre β when it lands, the recipient picker pulls from the same hook.
lib/notifications/notify.tsβnotifyProjectsUsersstill iterateslistUsers()+ evaluates theprojectspage rule; no change here, but a future refactor could call the roster accessor instead for a consistent "addressable for projects" semantic.- Directors registry (
lib/directors/registry.ts) β unchanged, still the source for WOPC closing names / signature blocks. Anyone touching the registry should be aware the roster accessor readsloginEmails[0]to detect "the same person" β adding a director with nologinEmailswould leak past this matching. - Sequence-map impact: unblocks T-095 (
it_supportrole addition). The role picker UI it amends already exists inUserEditModal.tsxand now displays subsidiaries inline.