clerk-role-syncrun.md01_define/output/spec.mdMongo user.role is written once at first JIT provisioning (ensureProvisionedFromClerk) and then
never re-synced. A role change made in the Clerk Dashboard never propagates to Mongo, so service
logic keyed on the stored role — discriminator selection in userService.update, customer
reactivation in resolveAppUserForTenant, operational lead/CSAT counts — runs on stale data
(auth-permissions findings G3). There are no Clerk webhooks in the platform at all today, so Clerk is
a write-once source rather than a live one. This advances Build the Bridge / 2026-Q2 Objective 3 —
Validate Technical Infrastructure & Payout Flow: a reliable role mirror is the prerequisite for the
rest of the platform-access-permissions epic (seeding permissions from roles), and it closes a
correctness gap that silently mis-routes users whose role was changed after first login.
Make the Mongo role mirror track Clerk, via two independent paths so sync never depends on a single mechanism:
apps/web/app/api/webhooks/clerk/route.ts, verified with Clerk's
svix helper (@clerk/backend), added to the proxy public-route matcher so it is reachable
without a session. It handles:organizationMembership.created / organizationMembership.updated — resolve the Clerk org to a
tenant via Tenant.clerkOrgId, map the membership role through mapClerkRoleToSystemRole, and
update the mirrored user.role for that (clerkUserId, tenantId) row. An unmapped Clerk role
is a no-op (fail closed), consistent with JIT provisioning.organizationMembership.deleted — deactivate (isActive: false), never delete, the tenant
user row for that membership.user.updated — refresh the profile mirror fields (firstname, lastname, email, avatar)
on the user's tenant row(s). Role is not sourced from this event.resolveAppUserForTenant — when an existing user is resolved and the live
Clerk session's mapped role differs from the stored user.role, update the mirror during resolve.
This covers the window before/without webhook delivery, so a user who signs in after a role change
is corrected on their next request regardless of webhook state.Role change and the discriminator (settled). Mongo users are Mongoose discriminators keyed on
role. A role change updates the base role field via a raw/base-model update (Mongoose guards the
discriminator key on the typed sub-model, so the sync writes through the base user model /
collection, not the discriminator). The outgoing persona's discriminator fields are left in place
(dormant) — this run does not $unset them. New persona-specific fields simply start absent (they
are all optional in the schema) and are filled by that persona's own flows. Rationale: the write is
simple and non-destructive, and no current read path treats a dormant field of the wrong persona as
authoritative — reads select the discriminator by the (now correct) role.
The service layer gets the new sync operations (in packages/services/src/db/services/users/): sync
role by Clerk membership, refresh profile by Clerk user id, and deactivate by Clerk membership — each
tenant-scoped and idempotent, so repeated webhook deliveries converge.
user.role for that tenant
row within webhook delivery time, without the user re-provisioning.resolveAppUserForTenant (JIT refresh), independent of the webhook.organizationMembership.deleted) sets isActive: false on
the tenant user row — the record is deactivated, not deleted.user.updated event refreshes the mirrored profile fields (firstname, lastname, email,
avatar) on the user's tenant row(s) without changing role.user.role; subsequent reads resolve
the new persona's discriminator, and the sync is idempotent under repeated delivery.organizationMembership event whose Clerk org has no matching Tenant.clerkOrgId, or whose
Clerk role does not map to a known persona, is a safe no-op (returns 2xx, changes nothing).permission-foundation and the cutover features).in-app-role-assignment.$unset-ing or migrating the outgoing persona's discriminator fields on a role change (dormant
fields are accepted this run, per the settled decision above).organizationMembership.created/updated/deleted and user.updated.user.updated scope questions were resolved during Define
(dormant old fields; user.updated refreshes profile mirror fields only).02_build/output/notes.mdfeat: clerk-role-sync — webhook route + JIT role/profile mirror syncapps/web/app/api/webhooks/clerk/route.ts (new): svix-verified Clerk webhook via
verifyWebhook (@clerk/nextjs/webhooks, falls back to CLERK_WEBHOOK_SIGNING_SECRET).
Verification failure → 400; unhandled event types → 2xx ack. Handles
organizationMembership.created/updated (re-mirror role), organizationMembership.deleted
(deactivate), and user.updated (refresh profile). Org→tenant is resolved via
tenantService.findByClerkOrgId; unknown org or unmapped role is a safe no-op.apps/web/proxy.ts: added /api/webhooks/clerk(.*) to the public-route matcher so the
session-less webhook isn't 302'd to /sign-in before it can verify its own signature.apps/web/lib/resolve-app-user-for-tenant.ts: JIT role refresh in the existing-user branch —
when the live Clerk session's mapped role differs from the stored mirror, syncRoleFromClerk
corrects it during resolve, so sync doesn't depend on webhook delivery alone.packages/services/src/db/services/users/index.ts: three idempotent, tenant-scoped methods —syncRoleFromClerk(tenantId, clerkUserId, role) — writes the role discriminatorKey through
the raw user collection (Mongoose refuses to change a discriminator key via an update);
per the settled spec the outgoing persona's discriminator fields are left dormant, not unset.deactivateByClerkUserId(tenantId, clerkUserId) — sets isActive: false (never deletes).refreshProfileFromClerk(clerkUserId, profile) — updates base profile fields across the
user's tenant rows (a user.updated event has no org scope); never touches role.user.role within webhook delivery time —
organizationMembership.updated → syncMembershipRole → syncRoleFromClerk, no re-provision.resolveAppUserForTenant — JIT refresh block, independent of the webhook.verifyWebhook throws → 400) and returns
2xx for a valid payload of an unhandled event type (switch default acks).organizationMembership.deleted sets isActive: false on the tenant row — deactivated, not
deleted, via deactivateByClerkUserId.user.updated refreshes mirrored profile fields (firstname, lastname, email, avatar) without
changing role — refreshProfileFromClerk.user.role (raw discriminator-key write); reads then
resolve the new persona's discriminator; the $ne guard makes repeated delivery idempotent.Tenant.clerkOrgId, or an unmapped Clerk role, is a safe no-op
(returns 2xx, changes nothing) — both handlers guard and return early.CLERK_WEBHOOK_SIGNING_SECRET (the svix signing secret from the Clerk Dashboard webhook
endpoint). Without it, verifyWebhook fails closed (400) — safe, but the webhook is inert until set.findOneAndUpdate would silently not change role. Dormant old-persona
fields are an accepted outcome per the Define decision (not a bug).03_release/output/investor-update.mdWho it's for: Admins — every persona benefits indirectly. What shipped: A signature-verified Clerk webhook plus a just-in-time refresh keep each user's stored role in sync, so a role change propagates without the user re-provisioning. Why it matters: Reliable role state is the foundation for platform permissions, advancing Build the Bridge — Objective 3: Validate Technical Infrastructure & Payout Flow.
Dig deeper: <merged-PR URL> · <changelog entry URL>
03_release/output/release.mdresolve-app-user-for-tenant.ts awaited
syncRoleFromClerk + re-fetch unguarded on the resolve hot path — a DB error would have failed the
whole request. Wrapped best-effort so a sync failure serves the stale mirror and is corrected on the
next request/webhook.?? "admin" → fail-closed change visible in the same file's diff is pre-existing
auth-hygiene-fixes work on the branch base, not this feature.