Skip to Content

← All archived runs

Run: permission-refresh-hardening

run.md

Run: permission-refresh-hardening

  • branch: claude/permission-refresh-hardening-i649vd
  • pr: #669

01_define/output/spec.md

Spec: Permission refresh hardening

  • slug: permission-refresh-hardening
  • personas: all six (admin, CSM, SDM, expert, vendor, customer)
  • touches: packages/services (Clerk metadata write + Ably publish on permission/template/role change), apps/web (client permission context + realtime subscription)
  • complexity: standard

Problem

After 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.

Proposed change

  • On any change to a user's effective permissions — a per-user override, a role-template edit that affects them, or a role assignment change — mirror a small 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.
  • On the same change, publish an Ably event to the affected user(s). The client permission context subscribes, and on receipt refetches its effective permission set and re-renders levers/nav live.
  • Enforcement is unchanged — this is purely a client-freshness optimisation layered on top of the existing next-request-fresh server checks.

Acceptance criteria

  • Revoking a permission (via per-user override or role-template edit) updates an affected user's open session UI — levers and nav reflect the new set — without a manual reload, within Ably delivery + client refetch time.
  • Granting a permission likewise surfaces the newly-available controls in an open session live.
  • The value mirrored into Clerk 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.
  • Server-side enforcement behaviour is unchanged — no route/API check is altered; a manual reload still yields the same result it does today.
  • A user with no relevant permission change receives no spurious refetch (events are scoped to affected users only).

Out of scope

  • Forced sign-out or session revocation on permission change (the user stays signed in; only the rendered permission set converges).
  • Any change to enforcement logic or to the permission management UI (override editor, role-template editor) beyond emitting the mirror/event on save.
  • Cross-tab or multi-device coordination beyond what a per-user Ably event and Clerk claim already provide.

Open questions

  • none. (Ably channel shape, the 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.md

Build notes: permission-refresh-hardening

  • commits: feat: permission-refresh-hardening — live UI convergence on permission change

What changed

Server (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.
  • Wired the invalidator into the three write paths: 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).

Acceptance criteria status

  • Revoking a permission updates an affected user's open session UI without a manual reload — override revocation (setUserPermissions) and role-template edit both publish permissions_changed; the client refetches via router.refresh().
  • Granting a permission likewise surfaces new controls live — same path (grant edits publish too).
  • Clerk claim is a version hash only, within budget — clerk-mirror writes only publicMetadata.permissionsVersion (a short djb2 hash string), a few bytes vs the ~1.2 KB budget.
  • Server-side enforcement unchanged — no route/API check or resolver logic touched; the resolver only gained an alias export of its existing uncached path. A manual reload yields the same result as today.
  • No spurious refetch — events go to the per-user channel only; role-template changes fan out to exactly the tenant's users whose mirrored primary role matches. Unaffected users get nothing.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. Confirmed the Clerk backend API used (createClerkClient, ClerkClient, users.updateUserMetadata) and the Ably publish/channel helpers against the installed packages before wiring.

Notes for review

  • 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.
  • The live path (Ably → 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.
  • Deliberately no forced sign-out / session revocation and no management-UI change (spec Out of scope).

03_release/output/changelog.md

Permission changes now apply to open sessions instantly

You no longer need to reload the page to see a permission change take effect.

  • When an admin changes your role, your role's permissions, or the permissions set just for you, your open session updates on its own within moments.
  • The actions and navigation you can reach adjust live — so you're never left looking at controls you no longer have, or missing ones you were just given.
  • Nothing changes about what you're allowed to do; this only makes the screen catch up with it faster.

(Live changelog entry: apps/help/app/changelog/2026-07-17-permission-refresh-hardening/page.mdx.)

03_release/output/investor-update.md

Permission changes now reach open sessions live

Who 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.md

Release: permission-refresh-hardening

  • pr: #669 · merged: yes — 2026-07-17 (squash 8fde6f1)
  • CI: green — merged on a ticked Ready to merge box; branch protection would have blocked a red merge (single attempt, no polling)
  • technical docs: 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)
  • business docs: no business docs impact — this is a cross-cutting UI-freshness improvement; platform-overview/roles describe what personas can do, which is unchanged
  • release notes: both — changelog entry (apps/help/app/changelog/2026-07-17-permission-refresh-hardening/page.mdx) + investor draft, both in this PR
  • sent: investor update sent to 2 recipients on 2026-07-17

Review summary

Ran /code-review medium (spec complexity: standard) on the diff. No code changes needed; three bounded, accepted observations (recorded, not blocking):

  • Resolver 5s TTL vs. live refresh — 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.
  • Role fan-out concurrency — 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.
  • Per-user-doc Ably channel — for a multi-role principal the nudge targets one role-doc _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).

Acceptance check (vs spec)

  • Revoking a permission updates an affected user's open session UI without a reload — Ably permissions_changed on override/template edit → client router.refresh().
  • Granting a permission surfaces new controls live — same path.
  • Clerk claim is a version hash only, within budget — clerk-mirror writes only publicMetadata.permissionsVersion (short djb2 hash).
  • Server-side enforcement unchanged — no route/API/resolver-logic change; resolver only gained an alias export of its existing uncached path.
  • No spurious refetch — per-user channel; role-template changes fan out only to users whose mirrored primary role matches.