permission-refresh-hardeningrun.md01_define/output/spec.mdAfter the permission-model cutover, a permission change applies on the affected user's next request — correct for enforcement (server-side checks are already next-request-fresh), but an open session keeps rendering the old UI (levers, nav) until the user navigates. For revocations this is the awkward case: a user can keep seeing controls they no longer hold until they happen to move. This run closes that gap so an open session converges on the new permission set live, without a manual reload. It is the optional P4 hardening phase of the Build the Bridge initiative (2026-Q2 Objective 3 — Validate Technical Infrastructure & Payout Flow): the enforcement work is already done; this hardens the perceived freshness that objective's payout/access flows depend on.
permissionsVersion hash into that
user's Clerk publicMetadata (and thus session claims) for client cache-busting. Only the hash
is mirrored, never the permission set itself.publicMetadata / session claims is a version hash only (never
the permission set), and the claim payload stays well inside the ~1.2 KB custom-claim budget.permissionsVersion hash function, and any debounce for rapid
successive edits are Build implementation details — they follow the notification skill's Ably
wiring patterns and do not change what gets built.)02_build/output/notes.mdServer (packages/services):
permissions/clerk-mirror.ts (new): mirrorPermissionsVersionToClerk(clerkUserId, version) — writes
only the permissionsVersion hash into the user's Clerk publicMetadata via @clerk/backend
createClerkClient (deep-merges the one key, leaves other metadata intact). Best-effort, never throws;
no-ops with a one-time warning when CLERK_SECRET_KEY is absent (same shape as the Ably no-op).permissions/invalidate.ts (new): the orchestrator. invalidatePermissionsForUser(tenantId, appUserId)
and invalidatePermissionsForRole(tenantId, role). For each affected principal it (1) resolves the
fresh permission version, (2) publishes a permissions_changed event on the per-user Ably channel
(tenant:{tenantId}:user:{appUserId} — the same channel the inbox uses, so only the affected user is
reached), and (3) mirrors the version into Clerk. Entirely best-effort — a failed nudge never fails the
admin's save.permissions/resolver.ts: exported resolveEffectivePermissionsFresh (the existing uncached resolve),
so the invalidation path sees the set after the write rather than a TTL-cached value from before it.permissions/index.ts: export the two invalidators + the fresh resolver.Web (apps/web):
hooks/use-permission-refresh.ts (new) + components/permissions/permission-refresh-listener.tsx (new):
a client hook/listener that subscribes the session to its per-user permissions_changed events and
calls router.refresh() (debounced 250 ms) — re-running the server components that gate levers/nav on
the resolved effective set, which are next-request-fresh. Mounted inside RealtimeProvider in
app/(app)/layout.tsx.users/[id]/permissions-actions.ts (setUserPermissions,
when changes > 0), admin/settings/role-templates/actions.ts (saveRoleTemplate when changes > 0,
resetRoleTemplate always), and users/[id]/actions.ts (assignPrimaryRole, after the Mongo role mirror).setUserPermissions) and role-template edit both publish permissions_changed; the
client refetches via router.refresh().clerk-mirror writes only
publicMetadata.permissionsVersion (a short djb2 hash string), a few bytes vs the ~1.2 KB budget.createClerkClient, ClerkClient, users.updateUserMetadata) and
the Ably publish/channel helpers against the installed packages before wiring.invalidatePermissionsForRole publishes + mirrors per affected user (one Clerk write each). Bounded by
the tenant's user count for that role (B2B, small); acceptable for an infrequent admin action. If a very
large tenant ever makes this heavy, the Clerk mirror could be dropped to the Ably-only path.router.refresh()) is the authoritative freshness mechanism; the Clerk mirror is
the durable version marker that survives reconnects/cold loads. Both are in scope per the spec.03_release/output/changelog.mdYou no longer need to reload the page to see a permission change take effect.
(Live changelog entry: apps/help/app/changelog/2026-07-17-permission-refresh-hardening/page.mdx.)
03_release/output/investor-update.mdWho it's for: All six user roles (admin, CSM, SDM, expert, vendor, customer) What shipped: Permission, role, and role-template changes now update a user's open session automatically, without a reload. Why it matters: Hardens the trustworthiness of the access-control layer under Build the Bridge (Objective 3: Validate Technical Infrastructure & Payout Flow).
Revocations converge within realtime-delivery time, and server-side enforcement is unchanged.
Dig deeper: https://github.com/sustentus/sustentus/pull/669 · https://help.sustentus.com/changelog/2026-07-17-permission-refresh-hardening
03_release/output/release.md8fde6f1)apps/docs/app/technical/packages/services/page.mdx — added a "Live UI convergence" note to the Permissions section (the page previously documented only next-request enforcement)apps/help/app/changelog/2026-07-17-permission-refresh-hardening/page.mdx) + investor draft, both in this PRRan /code-review medium (spec complexity: standard) on the diff. No code changes needed; three
bounded, accepted observations (recorded, not blocking):
router.refresh() re-runs the cached resolver, so an affected
user's UI can render stale for up to the TTL window before self-healing. Bounded, inherent to the
existing cache; enforcement unaffected. Accepted.invalidatePermissionsForRole issues one resolve + Clerk write per
affected user under Promise.all with no cap. Bounded by tenant size (B2B); flagged in build notes.
Accepted._id; a mismatch
with the session's active doc can miss (Clerk mirror still covers the durable path). Pre-existing
property of the shared per-user channel (notifications behave identically), not introduced here. Accepted.Conventions clean (arrow functions, type, async/await, /server import boundary, braced control flow).
permissions_changed on override/template edit → client router.refresh().clerk-mirror writes only
publicMetadata.permissionsVersion (short djb2 hash).