Tasks: Canonical Team Membership Store
Branch: prebuild/feat-canonical-team-membership | Spec: spec.md | Plan: plan.md
Status: SHIPPED (commits 1–8 merged on prebuild/feat-canonical-team-membership). Commit 8 ships conservatively — the ESLint guard and TypeScript members? removal are deferred to a follow-up that fires after the migrate-canonical-team-membership runbook has been applied in every environment (per the gating note in the Dependencies section).
Each numbered task is one commit. Tasks are sequential — never reorder. Each commit must be green (npm test + npm run lint in ui/) before moving to the next.
Acceptance command (run after every commit):
cd ui && npm test --silent -- --no-coverage 2>&1 | tail -5
Conventional Commits + DCO sign-off + Assisted-by: Cursor claude-opus-4-7 trailer on every commit.
Commit 1 — Canonical reader helper (Phase 0)
Title: feat(rbac): add canonical team-membership reader helper
Scope: Add the helper. No callers. No behavior change.
Files
- NEW
ui/src/lib/rbac/team-membership-store.tsloadActiveTeamMembers(team_slug): Promise<CanonicalTeamMember[]>countActiveTeamMembers(team_slug): Promise<number>isUserInTeam(team_slug, user_email): Promise<boolean>findUserRoleInTeam(team_slug, user_email): Promise<"admin" | "member" | null>loadTeamMemberCounts(team_slugs[]): Promise<Map<slug, number>>(for the list endpoint in Commit 4)- All readers filter
status: "active"and dedupe byCOALESCE(user_subject, user_email). Role escalation isadmin > member.
- NEW
ui/src/lib/rbac/__tests__/team-membership-store.test.ts- Empty team → 0 members.
- Single membership-source row → 1 member, correct role.
- Two rows for the same user (different
provider_id) → 1 member (deduped). - One row with
role: "admin", one withrole: "member", same user → role resolves toadmin. - Row with
status: "removed"→ excluded. - Row with
user_subjectset,user_emailempty → still counted. loadTeamMemberCountsover many team slugs → returns one entry per slug (deterministic, bounded query).
Acceptance
- New helper has ≥95% line coverage in
team-membership-store.test.ts. npm testgreen.- No other source files modified.
Commit 2 — Migrate auth-gate readers (Phase 1.a)
Title: refactor(rbac): migrate auth gates to canonical membership store
Scope: The two auth-critical readers. After this commit, these gates no longer consult team.members[].
Files
ui/src/lib/rbac/team-admin-guards.ts- L10: replace
(team.members ?? []).some(...)withawait findUserRoleInTeam(team.slug, email) === "admin". - Function becomes async if it is not already; update one or two call sites.
- L10: replace
ui/src/lib/rbac/login-openfga-bootstrap.ts- L63: replace
team.members?.find(...)withawait findUserRoleInTeam(team.slug, normalizedEmail).
- L63: replace
Tests
- Run the existing
__tests__/login-openfga-bootstrap.test.tsand any test that exercisesteam-admin-guards. Update fixtures to seedteam_membership_sourcesrows instead ofteam.members[]where the test asserts membership. - Add a regression test: a team with a populated
members[]but noteam_membership_sourcesrows must be treated as empty by these gates. (Confirms the new code reads only the canonical store.)
Acceptance
npm testgreen.git grep "team\.members" ui/src/lib/rbac/team-admin-guards.ts ui/src/lib/rbac/login-openfga-bootstrap.tsreturns zero hits.
Commit 3 — Migrate read-only API consumers (Phase 1.b)
Title: refactor(api): migrate admin team API readers to canonical membership store
Scope: Read paths in admin and dynamic-agents API routes. Writes are still dual at this point.
Files
ui/src/app/api/admin/teams/[id]/roles/route.ts(L245) — read members vialoadActiveTeamMembers.ui/src/app/api/admin/teams/[id]/resources/route.ts(L323) — same.ui/src/app/api/admin/teams/[id]/kb-assignments/route.ts(L49) —findUserRoleInTeam.ui/src/app/api/admin/teams/[id]/members/route.ts(L198, L291, L300) — read paths only; POST/DELETE bodies stay as-is (Commit 6 migrates writes).ui/src/app/api/admin/openfga/catalog/route.ts(L115) —loadActiveTeamMembers.ui/src/app/api/admin/openfga/baseline-profile/route.ts(L235) —findUserRoleInTeam.ui/src/app/api/dynamic-agents/teams/route.ts(L30) —findUserRoleInTeam.ui/src/app/api/auth/my-roles/route.ts(L89) —findUserRoleInTeam(orloadActiveTeamMembersthen filter, depending on response shape).ui/src/app/api/admin/users/route.ts— replace per-teamt.members ?? []iteration withloadActiveTeamMembers(t.slug)per team (this is grouped admin reporting; profiling will tell us if a single aggregation is needed; default to per-team helper).ui/src/components/admin/UserManagementTab.tsx(L116) — same pattern; this is a server component / receives its data via props, so the change happens upstream in the route handler that feeds it. Verify which.
Tests
- Update
__tests__/membership-sources.test.ts,__tests__/admin-teams.test.ts,__tests__/admin-write-routes.test.ts,__tests__/admin-team-resources.test.ts, etc. — fixtures useteam_membership_sourcesrows. - Add one regression test per route: a team with stale
members[]but no canonical rows yields an empty result through the API.
Acceptance
npm testgreen.git grep "team\.members\|t\.members" ui/src/app/api ui/src/componentsreturns zero hits in non-write code paths (the only remaining hits should be the writer code in[id]/members/route.ts, which Commit 6 handles).
Commit 4 — Member counts on the list endpoint (Phase 1.5)
Title: feat(api): include team.member_count in GET /api/admin/teams
Scope: One API change, one schema bump, one new index.
Files
ui/src/app/api/admin/teams/route.ts- GET handler: after fetching teams, call
loadTeamMemberCounts(slugs[])and mergemember_countinto each team object in the response. - Schema: response type now includes
member_count: number.
- GET handler: after fetching teams, call
ui/src/lib/rbac/team-membership-store.ts- Add the index creation in the same module's bootstrap (or wherever
team_membership_sourcesis initialized):team_membership_sources({team_slug: 1, status: 1}). Idempotent — safe to run on every startup.
- Add the index creation in the same module's bootstrap (or wherever
ui/src/app/api/admin/teams/__tests__/admin-teams.test.ts(or the closest existing test file)- Asserts
member_countis the count of distinct active members per team. - Tests dedupe behavior: two rows for the same user are counted as one.
- Asserts
Acceptance
npm testgreen.GET /api/admin/teamsresponse now containsmember_countfor every team.- The new index is created on first request after deploy (verified manually in the smoke step).
Commit 5 — Admin UI consumes member_count (Phase 1.c)
Title: refactor(ui): consume team.member_count on Admin Teams page
Scope: All four call sites of team.members.length / team.members.map(m => m.user_id) in (app)/admin/page.tsx.
Files
ui/src/app/(app)/admin/page.tsx- L1596:
count={team.members.length}→count={team.member_count ?? 0}. - L824, L838, L1861, L2474:
team.members.map(m => m.user_id)/team.members.forEach(...)→ fetch the member list on demand fromGET /api/admin/teams/[id]/members(which returns canonical-store rows). - The "list of all users in any team" iteration (L824/838) should use a new helper hook or a single
GET /api/admin/users?teams=...call rather than N+1 per-team lookups. Acceptable interim: server-side join in the page-level data loader.
- L1596:
ui/src/components/admin/TeamDetailsDialog.tsx- Drop the
team.members[]fallback (L1xx — confirm with grep when editing).
- Drop the
ui/src/app/(app)/admin/__tests__/admin-page.test.tsx- Update fixtures to provide
member_countinstead ofmembers[].
- Update fixtures to provide
Tests
- Page renders with correct counts when teams have only
team_membership_sourcesrows (nomembers[]).
Acceptance
npm testgreen.- Manual smoke: log into the dev UI, open the Teams page. Auto-provisioned teams now show their real member counts.
git grep "team\.members\|t\.members" ui/src/app/\(app\) ui/src/componentsreturns zero hits.
Commit 6 — Migrate write paths (Phase 2)
Title: refactor(rbac): drop teams.members[] writes from all paths
Scope: Stop writing the legacy field. After this commit, every write goes only to team_membership_sources (+ OpenFGA).
Files
ui/src/lib/rbac/identity-group-sync-reconciler.ts- Remove
syncTeamEmbeddedMemberandunsyncTeamEmbeddedMember(the temporary denorm fix added today). - Remove the embedded-member rollback branches in
rollbackPhase2.
- Remove
ui/src/app/api/admin/teams/[id]/members/route.ts- POST (manual member add): replace
$push: { members: ... }withupsertTeamMembershipSource({source_type: "manual", ...}). - DELETE: replace
$pull: { members: ... }withmarkTeamMembershipSourceRemoved.
- POST (manual member add): replace
ui/src/app/api/admin/users/[id]/teams/route.ts— same pattern.ui/src/app/api/admin/teams/route.ts— POST team creation: dropmembers: []initialization. Creator-as-admin is recorded viaupsertTeamMembershipSourceimmediately after team insert.- Update tests in
__tests__/admin-write-routes.test.ts,__tests__/team-creation-openfga-sync.test.ts,__tests__/manual-team-source.test.ts, etc.
Tests
- Existing tests that asserted on
team.members[]shape must be migrated to assert onteam_membership_sourcesrows (or removed if they were testing the legacy dual-write). - Regression: creating a team and adding a manual member produces exactly one
team_membership_sourcesrow, noteam.members[]mutation.
Acceptance
npm testgreen.git grep "\\\$push.*members\\|\\\$pull.*members\\|members:\\s*\\[" ui/srcreturns zero hits in production code.git grep "team\\.members\\|t\\.members" ui/srcreturns zero hits outside of__tests__/and the migration script.
Commit 7 — Migration script (Phase 3.a)
Title: chore(scripts): add canonical-team-membership migration script + Make target
Scope: One-shot migration that backfills missing membership_sources from teams.members[] and $unsets the field. The script does not run automatically; this commit only adds it.
Files
- NEW
scripts/migrate-canonical-team-membership.ts- Connects to Mongo using the same env conventions as
seed-config.ts. - Args:
--dry-run(default) and--apply. - For each team doc with non-empty
members[]:- For each member entry, check whether an active
team_membership_sourcesrow already exists for that user (byuser_emailoruser_subject). - If not, the script reports it (dry-run) or upserts a row with
source_type: "manual",created_by: "migration:2026-05-26-canonical-team-membership",provider_id: "manual"(apply).
- For each member entry, check whether an active
- After all backfills,
$unset: { members: "" }on every team doc that had the field. - Idempotent: re-running is a no-op (the source rows already exist;
$unsetof a non-existent field is a no-op). - Exit code 0 on success; non-zero with structured stderr on failure.
- Connects to Mongo using the same env conventions as
Makefile- Target:
migrate-canonical-team-membershipruns the script in dry-run by default;APPLY=1 make migrate-canonical-team-membershipapplies.
- Target:
- NEW
docs/docs/specs/2026-05-26-canonical-team-membership/mongodb-migration.md- Operator runbook.
- "How to dry-run", "how to apply", "how to verify", "how to roll back".
Tests
- A unit test for the script's pure functions (the diff calculation), if practical.
- Otherwise, manual verification on a copy of dev data is acceptable; documented in
mongodb-migration.md.
Acceptance
npm testgreen (no production code changed).make migrate-canonical-team-membershipruns and produces a sensible dry-run output against the local Mongo. (User-driven manual smoke; not automated in CI for this commit.)
Commit 8 — Schema cleanup + ESLint guard (Phase 3.b)
Title: chore(rbac): remove members[] from team schema and add lint guard
Scope: Remove the members[] field from the TS type definitions. Add a lint rule that fails any future PR that re-introduces it.
Files
ui/src/lib/rbac/identity-group-sync-reconciler.ts(and any other module that definesIdentitySyncTeamorTeam):- Remove
members?: TeamMember[]from the type. - Remove the
as anycasts that were workarounds for$push/$pullon the embedded array.
- Remove
ui/src/lib/rbac/types.ts(or wherever the canonical Team type lives) — same field removal.ui/src/lib/rbac/__tests__/migrations/registry.test.tsandui/src/lib/rbac/migrations/registry.ts(L385) — adapt or remove the migration that iteratesteam.members ?? [].- NEW
ui/eslint.config.mjs(or.eslintrc.cjs— match the repo's existing config)- Custom rule (no-restricted-properties or grep-based pre-commit hook):
- Disallow
team.members,t.members, andteamDoc.membersreads. - Disallow
$push: { members: ... },$pull: { members: ... },$addToSet: { members: ... }writes. - Allow only in
scripts/migrate-canonical-team-membership.ts.
- Disallow
- Custom rule (no-restricted-properties or grep-based pre-commit hook):
Tests
- Lint rule fires on a deliberately-bad fixture in a
__tests__/lint-fixtures/directory and is silent on the production code. - Full Jest suite green.
npm run lintgreen.
Acceptance
npm testandnpm run lintboth green.git grep "members:\\s*TeamMember" ui/srcreturns zero hits outsidemigrate-canonical-team-membership.tsand tests.- Spec docs updated:
spec.mdandplan.mdget a "Status: SHIPPED" header line.
Dependencies
Commit 1 — no deps
Commit 2 — needs Commit 1
Commit 3 — needs Commit 1
Commit 4 — needs Commit 1, parallel-safe with 2 and 3 but commit AFTER 3 because the same test files are touched
Commit 5 — needs Commit 4
Commit 6 — needs Commit 5 (no reader of legacy field remains)
Commit 7 — needs Commit 6 (so the script's "no readers, only $unset" assumption holds)
Commit 8 — needs Commit 7 to have been APPLIED in dev/prod (gated on the migration actually running)
Verification After Final Commit
cd ui && npm test --silent -- --no-coverage— all green.cd ui && npm run lint— all green, including the new guard.make migrate-canonical-team-membership— dry-run output looks correct.APPLY=1 make migrate-canonical-team-membership— applies cleanly. Re-run is a no-op.- Rebuild
caipe-ui-prod, log in as a user with OIDC group claims that triggers auto-create-teams. Open the Admin → Teams page. Auto-provisioned teams display correct counts. Open Team Details for one — members are listed correctly. db.teams.findOne({ members: { $exists: true } })returnsnull.- spec-102 RBAC matrix (
make test-rbac-*) passes if the local environment supports it; otherwise capture in the PR description as a follow-up smoke.
Out of Scope (deferred)
- The Phase 4 bake/observability work; it is operational, not implementation.
- Any Slack-bot / RAG-server side membership reads — those services already query
team_membership_sourcesvia the BFF and inherit the fix.