in-app-role-assignmentrun.md01_define/output/spec.mdChanging a user's primary role is Clerk Dashboard work today — the only in-app role path is customer
provisioning. Admins who otherwise manage access from inside the product (the point of the
platform-access-permissions epic) still have to leave Sustentus to promote or move someone between
personas. The extra-roles panel on the user-directory detail page already tells admins their
"primary role is changed from role assignment" — but that surface doesn't exist yet, so the sentence
points nowhere. This advances Build the Bridge / 2026-Q2 Objective 3 — Validate Technical
Infrastructure & Payout Flow: with the role mirror now live (clerk-role-sync), the last step is
letting admins drive the authoritative change (Clerk) from the directory and have Mongo follow
immediately, instead of waiting on the Dashboard and a webhook.
Add a primary role-change action to the admin user-directory detail page
(apps/web/app/(app)/users/[id]), sitting alongside the existing extra-roles panel:
clerkClient().organizations.updateOrganizationMembership({ organizationId, userId, role }) —
Clerk stays the source of truth for the org-membership role.syncRoleFromClerk(tenantId, clerkUserId, role) from clerk-role-sync so the Mongo mirror
reflects the new role on the very next request, without waiting for the organizationMembership.updated
webhook. The discriminator consequence is handled exactly as clerk-role-sync settled it: the
base role is written through the raw user collection and the outgoing persona's discriminator
fields are left dormant (not $unset). No new discriminator policy is introduced here.countByRole(tenantId) to check the admin count and rejects with a clear error; neither
the Clerk membership nor the Mongo mirror is touched when the guard trips.packages/services/src/db/audit surface, so access
changes are reviewable.The action is admin-only (same authorization the extra-roles actions already enforce) and tenant-scoped. Assignable roles are the standard mapped personas; customer provisioning keeps its own dedicated flow and is not routed through this action.
user.role mirror both reflect the new role, and the user's
effective permissions follow the new role's template on their next request (mirror re-synced
immediately, not on webhook delay).role through the raw user
collection per the clerk-role-sync policy; subsequent reads resolve the new persona's
discriminator and the outgoing persona's fields are left dormant (not unset).publicMetadata.roles) — that is the existing
Manage-roles panel / multi-role-switcher-ui; this run changes only the primary org-membership
role. The two surfaces stay coherent on the same detail page.user-permission-editor (stub 11); this is role change
only, no override editing.clerk-role-sync; this run consumes them, it does not change them.clerk-role-sync), the last-admin
guard, and the audit requirement are all settled. Confirmation UX and the exact placement of the
panel are presentation details for Build, not requirement decisions.02_build/output/notes.mdfeat: in-app-role-assignment — admin primary role change (Clerk write-through + immediate mirror re-sync)apps/web/app/(app)/users/[id]/actions.ts:assignPrimaryRole server action (admin-only, via resolveActionContext({ allowedRoles: ["admin"] }) — parity with the existing grant/revoke actions). It: reads the target's Clerk org membership; blocks a no-op (already that role); runs the last-admin guard before any write (if the current primary is admin and the new role isn't, userService.countByRole(tenantId).admin <= 1 → reject, nothing written); writes the new role through to Clerk via client.organizations.updateOrganizationMembership({ organizationId, userId, role: \org:${role}` })(Clerk is the source of truth); then immediately re-mirrors onto Mongo viauserService.syncRoleFromClerk(theclerk-role-sync` machinery — raw discriminator-key write, outgoing persona fields left dormant); then audits the change.console.info("[role-assignment] primary_role_changed", { tenantId, actorClerkUserId, targetUserId, targetClerkUserId, oldRole, newRole, at }) — following the house [tag] event { … } convention (see webhooks/clerk, lib/tenant). There is no general audit-log collection, and the lead-scoped ActivityService / the DB-health db/audit module are neither a fit; a new collection + migration would be scope creep for this feature.getManageableRoles to also return assignablePrimary (the GRANTABLE_ROLES set minus the current primary) so the client needs no server-only import to build its options.apps/web/components/users/user-profile/assign-primary-role-panel.tsx (new): client panel — shows the current primary badge and a Select + Change button that calls assignPrimaryRole, surfacing the action's error string inline (same pattern as ManageRolesPanel).apps/web/components/users/user-profile/manage-roles.tsx: renders the new primary-role panel above the existing extra-roles panel in the same admin "Manage roles" card, with copy clarifying primary vs. extra roles.Assignable primary targets mirror GRANTABLE_ROLES (admin, csm, sdm, expert, vendor) — customer is deliberately excluded, keeping the spec's "customer provisioning keeps its own dedicated flow and is not routed through this action" and matching the existing extra-roles surface.
user.role mirror both update, and permissions follow the new role's template next
request — updateOrganizationMembership then syncRoleFromClerk (immediate, not webhook-delayed).role through the raw user collection per clerk-role-sync
policy (dormant outgoing fields) — delegated to the existing syncRoleFromClerk.countByRole(tenantId).admin <= 1.console.info audit line.allowedRoles: ["admin"] gate as the extra-roles actions.customer isn't an
assignable target here.updateOrganizationMembership's role param is OrganizationMembershipRole
(org:${string}), which the `org:${role}` template literal satisfies.readMembershipRoles), consistent with the
grant/revoke actions; the last-admin count uses the Mongo mirror via countByRole, per the spec.03_release/output/changelog.mdYou can now change a user's primary role right from their profile in the user directory — no need to leave the product to do it.
03_release/output/investor-update.mdWho it's for: Platform administrators What shipped: Admins can change a user's role from the user directory, instead of leaving the product to edit it in the identity provider. Why it matters: Advances Build the Bridge / 2026-Q2 Objective 3 — Validate Technical Infrastructure & Payout Flow — by keeping access management in one trusted place.
Guard rails block removing the last admin, and every change is audited.
Dig deeper: <merged-PR URL> · <changelog entry URL>