multi-role-principalrun.md01_define/output/spec.mdEach user is tied to exactly one role — the Clerk org-membership role, mirrored once into Mongo — so
holding, say, both CSM and SDM means separate logins. The access-and-permissions direction
(2026-Q2 Objective 3, "Validate Technical Infrastructure & Payout Flow", under the Build-the-Bridge
initiative) needs a single principal to hold multiple granted roles and switch between them inside
one authenticated session, with no sign-out, while single-role users see zero change. Two hard
constraints shape the design: Clerk allows only one role per org membership, and every domain model
references users by role-typed foreign keys (Lead, Invoice, Quote, CSAT, Milestone,
Proposal, ExpertRating). This feature is the principal plumbing only — the same active-role
principal the permission resolver will consume — not the switcher UI or admin grant/revoke UI.
"One principal, many role documents, one active role" — the plumbing that lets a granted user's effective role change in-session, backward-compatible for everyone else.
publicMetadata.roles and are surfaced as a session-token claim so
middleware reads the granted set with no DB or Clerk API call. Granted set =
{ primary role } ∪ metadata.roles, each validated against the shared UserRole/VALID_ROLES
constant. A missing claim means "no extra grants" (see Decisions).sustentus-active-role, per tenant). A switchActiveRole(role)
server action validates role is in the JWT-verified granted set, sets the cookie, and redirects to
ROLE_HOME[role]. Middleware resolves the effective role = cookie value if in the granted set,
else primary. No cookie → exactly today's behaviour. A tampered cookie can at worst select a role the
user legitimately holds — no escalation, so no signing is needed.(clerkUserId, tenantId, role). Keep the discriminator model and all
role-typed FKs intact. Replace the clerkUserId_tenantId_unique index with a partial-unique
{ clerkUserId, tenantId, role } (partial on clerkUserId existing). Ship the index change as a
migration in packages/services/src/db/migrations/ (autoIndex is off); the migration verifies
there are no existing duplicate (clerkUserId, tenantId) pairs before swapping the index.userService.findByClerkUserId becomes role-aware (a role param /
findByClerkUserIdAndRole); add findRolesByClerkUserId for downstream (switcher/admin) consumers.
Provisioning stays lazy — the first switch into a role provisions that role's doc via
ensureProvisionedFromClerk, and experts keep isActive: false so the onboarding gate fires on
first switch into expert.apps/web/lib/auth.ts gains getGrantedRoles() and getActiveRole();
getRole()/requireRole() delegate to the active role. The effective-role resolution fails closed
(no ?? "admin" fallback — shared with the auth-hygiene-fixes G2 hardening; if that run already
removed the fallback this is a no-op, but this run must not depend on the fallback existing).admin, csm, sdm, expert, vendor. customer stays
non-switchable (externally provisioned, billing implications). (Report decision 1.)isActive: false), never hard-deleted — role-typed
FKs and historical attribution point at them. Revoking a user's active role falls back to primary
on the next request. (Report decision 2.)multi-role-switcher-ui), so the per-role vs per-person product
question does not block this plumbing.switchActiveRole server action with no
re-login, landing on ROLE_HOME[role].isActive: false); revoking the active role mid-session falls back to primary on next request.findByClerkUserId resolves the correct per-role doc; findRolesByClerkUserId returns the full
granted set of role docs; first switch into a not-yet-provisioned role lazily provisions its doc.clerkUserId_tenantId_unique with the partial-unique
{ clerkUserId, tenantId, role }, dry-runs clean on a production snapshot, and aborts if any
duplicate (clerkUserId, tenantId) pair exists.multi-role-switcher-ui.admin-view-as (it reuses this principal plumbing).customer switchable — externally provisioned, billing implications; not initially.permission-foundation, may run in parallel).none — the four report decisions are settled above, and the claim name is an ops coordination step (code treats a missing claim as "no extra grants"), not a build blocker.
02_build/output/notes.mdfeat: multi-role-principal — principal plumbing (model + migration, service API, active-role auth, switch action)packages/services/src/db/models/user.ts — replaced the clerkUserId_tenantId_unique
index with a partial-unique { clerkUserId, tenantId, role } (clerkUserId_tenantId_role_unique,
partial on clerkUserId being a string). One principal can now hold one doc per (tenant,
role). The discriminator model and every role-typed FK are untouched. The partial filter uses
$type: "string" rather than $exists: true because the collection carries non-Clerk rows with
clerkUserId: null (field present) that must stay unconstrained — $exists would include them and
they collide.packages/services/src/db/migrations/1784100000000-multi-role-principal-user-index.ts — new
migration that swaps the index (autoIndex is off). up aborts if any duplicate
(clerkUserId, tenantId) pair exists (the new unique index couldn't build), then drops the old
index and creates the new one; down is symmetric. Applies automatically on merge to main.packages/services/src/db/services/users/index.ts — findByClerkUserId gains an optional
role param (role-aware selection; omitted = existing single-role behaviour); added
findRolesByClerkUserId returning all of a principal's role docs; ensureProvisionedFromClerk's
existence check is now role-scoped so the first switch into a new role provisions that role's doc
instead of short-circuiting on a sibling doc.apps/web/lib/auth.ts — added ACTIVE_ROLE_COOKIE, GRANTED_ROLES_CLAIM (org_roles),
GRANTABLE_ROLES (internal roles only), pure deriveGrantedRoles / resolveEffectiveRole, and
async getGrantedRoles / getActiveRole. getRole() and requireRole() now delegate to the
active role.apps/web/proxy.ts — middleware resolves the effective role from the granted set plus the
active-role cookie (request.cookies), falling back to primary when the cookie is absent, stale,
tampered, or names a revoked role. No cookie → primary (unchanged).apps/web/lib/resolve-app-user-for-tenant.ts — when a granted user has switched into a
non-primary role, resolves that role's own doc, provisioning it lazily from the primary doc's
identity (username deliberately not copied — it's unique per tenant and would collide; expert
defaults to isActive: false so onboarding gates on first switch). Single-role users never enter
this branch.apps/web/lib/actions/switch-active-role.ts — new switchActiveRole(role) server action:
validates the target is in the granted set, sets the httpOnly active-role cookie, redirects to
ROLE_HOME[role]. Refuses a role outside the granted set.switchActiveRole (validate → set cookie →
redirect to ROLE_HOME[role]), no re-login.resolveEffectiveRole).isActive: false
(onboarding gate fires); revoking the active role drops it from the granted set, so the next
request falls back to primary.findByClerkUserId resolves the correct per-role doc; findRolesByClerkUserId returns the
full set; first switch into a not-yet-provisioned role lazily provisions its doc.clerkUserId_tenantId_unique with the partial-unique
{ clerkUserId, tenantId, role } and aborts on any duplicate (clerkUserId, tenantId) pair.
The dry-run on a production snapshot is an ops/reviewer step before the merge applies it.org_roles claim (from org-membership
publicMetadata.roles) to the session-token template on both the dev and prod Clerk
instances. Until then the claim is absent → every user has "no extra grants" → single-role
behaviour, which is the safe default and how parity is preserved.multi-role-switcher-ui; View As to admin-view-as.
switchActiveRole is the plumbing those will call.syncRoleFromClerk (webhook / JIT primary-role mirror) left as-is — it rewrites a doc's role
in place and is owned by clerk-role-sync. It only runs on the primary-role path and cannot
collide while no user holds extra grants yet (claim absent); coordinate any change there with this
principal when grants go live.03_release/output/investor-update.mdWho it's for: Internal admin, CSM, SDM, expert, and vendor users What shipped: Backend plumbing for one person to hold several roles and switch between them in-session — no change for single-role users. Why it matters: Foundational infrastructure for Build the Bridge, Objective 3 (Validate Technical Infrastructure & Payout Flow) — safer operation and demos from one login.
A production-safe index migration landed clean, with zero change for existing users.
Dig deeper: <merged-PR URL>
03_release/output/release.md$type: "string".)apps/docs/app/technical/** (only in the off-limits reports/**); the new multi-role plumbing is internal and not yet user-visible.multi-role-switcher-ui).ISSUE_RESOLVED.switch-active-role.ts omits resolveActionContext — accepted: the action validates against the
JWT granted set and needs no tenant/app-user resolution; adding it would be a pointless DB round-trip.auth() in resolveAppUserForTenant (via getActiveRole) — accepted: Clerk
memoises auth() per request, so the cost is negligible.readPrimaryRaw duplication across proxy.ts/resolve-app-user-for-tenant.ts — accepted as a
cosmetic dedup not worth churning a green, approved PR.switchActiveRole (validate granted set → set
httpOnly cookie → redirect to ROLE_HOME[role]), no re-login.resolveEffectiveRole).isActive: false (onboarding
gate fires); a revoked active role drops from the granted set so the next request falls to primary.findByClerkUserId resolves the correct per-role doc; findRolesByClerkUserId returns the full
set; first switch into a not-yet-provisioned role lazily provisions its doc.clerkUserId_tenantId_unique with the partial-unique
{ clerkUserId, tenantId, role } (partial on a string clerkUserId) and aborts on any duplicate
(clerkUserId, tenantId) pair; verified green on the preview database.