Summary
Parent organizations currently have no unified way to view or manage member roles across their child (sub-)organizations — admins must open each child org one at a time. This issue tracks Phase 1 of a phased plan (full design process/rationale in the linked discussion below): a unified navigation hub, built almost entirely from existing, already-trusted components, plus two independent bug fixes to existing membership code.
Phase 2 (a dedicated atomic bulk-write API) is explicitly not part of this issue — it's evidence-gated behind telemetry from Phase 1 usage and will be scoped separately if/when the data justifies it. See "Non-goals" below.
Background / design process
This PRD was produced via an adversarial multi-agent design process: three independent designs were explored (a first-class "membership matrix" bulk API, a declarative group/role-template propagation model, and a federated navigation-hub model), compared by an impartial analysis pass, stress-tested by an adversarial critique of the top-ranked design, and reconciled into the phased plan below. The navigation-hub approach won Phase 1 because it reuses battle-tested UI with zero new privileged write surface; the matrix-API approach's core ideas are preserved as an evidence-gated Phase 2 rather than discarded.
Scope (Phase 1)
1. Unified people directory
- Extend
organizations.subOrganizations.people (apps/web/src/routers/organizations/organization-sub-organizations-router.ts) with one additive field per membership/invitation/parent-membership entry: canManageMemberships: boolean, computed via the existing batched getOrganizationsAccessRoles helper (apps/web/src/routers/organizations/utils.ts). This is a UX hint only — every mutation still re-checks real authorization server-side.
- Update
PeopleSection (apps/web/src/app/(app)/organizations/[id]/sub-organizations/SubOrganizationSections.tsx) to add a row-selection checkbox column and turn membership/invitation badges into clickable affordances when canManageMemberships is true. No inline-editable cells — every action is a navigation trigger (opens a drawer or wizard), never an onChange-fired mutation inside a table cell.
2. Context drawer
- New component
MemberManagementDrawer that mounts the existing, unmodified OrganizationAdminMembers component (apps/web/src/components/organizations/OrganizationMembersCard.tsx) and OrganizationAdminContextProvider, scoped to a specific child org id, opened from a directory row. Drawer state lives in the URL query string. Closing the drawer invalidates the parent's subOrganizations.people query.
3. Guided bulk-action wizards
- "Add people to a child org": select people → pick one target child org → preview (role picker, auto-skip existing members/invitees) → sequential execute via the existing
organizations.members.invite mutation → per-row results summary with retry-failed.
- "Remove people from a child org": pick target org → select people with an existing membership/invitation there → preview with an authority pre-check and irreversibility warning → sequential execute via existing
organizations.members.remove / deleteInvite → shared results-summary component.
- No new tRPC procedures for either wizard.
4. Two independent bug fixes (ship regardless of the above)
addUserToOrganization (apps/web/src/lib/organizations/organizations.ts) is missing the lockOrganizationMembershipMutation advisory lock that removeUserFromOrganization and addSsoUserToOrganization already take — add it, closing a race condition on concurrent membership writes for the same (organization_id, kilo_user_id) pair.
setChildMemberships's removal loop (apps/web/src/routers/organizations/organization-members-router.ts) has no role filter — it will silently strip a child-org owner/billing_manager membership if omitted from the request, today prevented only client-side. Add a server-side role !== 'member' guard before removing. Note: organization-members-router.test.ts (around lines 624–657) currently has a test asserting the vulnerable behavior as correct ('removes unselected child organization memberships regardless of role') — this must be rewritten to assert the fixed contract.
5. Telemetry (gates Phase 2)
- Emit events for wizard runs, repeat-run-within-15-minutes, large selections (≥15 people), drawer opens, and partial-failure rate. Observe for ~6 weeks across enough parent orgs to be meaningful before deciding whether Phase 2 (dedicated bulk-write backend) is warranted.
Non-goals for this issue
- No new
membershipMatrix/reconcile tRPC procedures or spreadsheet-grid UI (deferred to an evidence-gated Phase 2).
- No cross-org atomic "move" operation (remove from A + add to B is two independent wizard runs in Phase 1 — a known, accepted gap).
- No declarative/recurring role-template mechanism (out of scope for both phases as currently planned).
Testing
- Unit: wizard row-eligibility filtering, sequential-execution/partial-failure reducer,
addUserToOrganization lock-acquisition, setChildMemberships role-filter predicate.
- Integration: rewrite the existing
setChildMemberships removal test to assert owner/billing_manager rows survive when omitted; add canManageMemberships coverage to organization-sub-organizations-router.test.ts (inherited parent access vs. plain member).
- Component: badge-vs-button rendering by
canManageMemberships; drawer context-nesting safety (renders child-scoped role, not an outer parent-scoped one).
- E2E: drawer role-edit round-trip, add-wizard with an auto-skipped existing member, remove-wizard with a forced per-row failure.
Summary
Parent organizations currently have no unified way to view or manage member roles across their child (sub-)organizations — admins must open each child org one at a time. This issue tracks Phase 1 of a phased plan (full design process/rationale in the linked discussion below): a unified navigation hub, built almost entirely from existing, already-trusted components, plus two independent bug fixes to existing membership code.
Phase 2 (a dedicated atomic bulk-write API) is explicitly not part of this issue — it's evidence-gated behind telemetry from Phase 1 usage and will be scoped separately if/when the data justifies it. See "Non-goals" below.
Background / design process
This PRD was produced via an adversarial multi-agent design process: three independent designs were explored (a first-class "membership matrix" bulk API, a declarative group/role-template propagation model, and a federated navigation-hub model), compared by an impartial analysis pass, stress-tested by an adversarial critique of the top-ranked design, and reconciled into the phased plan below. The navigation-hub approach won Phase 1 because it reuses battle-tested UI with zero new privileged write surface; the matrix-API approach's core ideas are preserved as an evidence-gated Phase 2 rather than discarded.
Scope (Phase 1)
1. Unified people directory
organizations.subOrganizations.people(apps/web/src/routers/organizations/organization-sub-organizations-router.ts) with one additive field per membership/invitation/parent-membership entry:canManageMemberships: boolean, computed via the existing batchedgetOrganizationsAccessRoleshelper (apps/web/src/routers/organizations/utils.ts). This is a UX hint only — every mutation still re-checks real authorization server-side.PeopleSection(apps/web/src/app/(app)/organizations/[id]/sub-organizations/SubOrganizationSections.tsx) to add a row-selection checkbox column and turn membership/invitation badges into clickable affordances whencanManageMembershipsis true. No inline-editable cells — every action is a navigation trigger (opens a drawer or wizard), never anonChange-fired mutation inside a table cell.2. Context drawer
MemberManagementDrawerthat mounts the existing, unmodifiedOrganizationAdminMemberscomponent (apps/web/src/components/organizations/OrganizationMembersCard.tsx) andOrganizationAdminContextProvider, scoped to a specific child org id, opened from a directory row. Drawer state lives in the URL query string. Closing the drawer invalidates the parent'ssubOrganizations.peoplequery.3. Guided bulk-action wizards
organizations.members.invitemutation → per-row results summary with retry-failed.organizations.members.remove/deleteInvite→ shared results-summary component.4. Two independent bug fixes (ship regardless of the above)
addUserToOrganization(apps/web/src/lib/organizations/organizations.ts) is missing thelockOrganizationMembershipMutationadvisory lock thatremoveUserFromOrganizationandaddSsoUserToOrganizationalready take — add it, closing a race condition on concurrent membership writes for the same(organization_id, kilo_user_id)pair.setChildMemberships's removal loop (apps/web/src/routers/organizations/organization-members-router.ts) has no role filter — it will silently strip a child-orgowner/billing_managermembership if omitted from the request, today prevented only client-side. Add a server-siderole !== 'member'guard before removing. Note:organization-members-router.test.ts(around lines 624–657) currently has a test asserting the vulnerable behavior as correct ('removes unselected child organization memberships regardless of role') — this must be rewritten to assert the fixed contract.5. Telemetry (gates Phase 2)
Non-goals for this issue
membershipMatrix/reconciletRPC procedures or spreadsheet-grid UI (deferred to an evidence-gated Phase 2).Testing
addUserToOrganizationlock-acquisition,setChildMembershipsrole-filter predicate.setChildMembershipsremoval test to assert owner/billing_manager rows survive when omitted; addcanManageMembershipscoverage toorganization-sub-organizations-router.test.ts(inherited parent access vs. plain member).canManageMemberships; drawer context-nesting safety (renders child-scoped role, not an outer parent-scoped one).