permission-foundationrun.md01_define/output/spec.mdThe platform has no permission concept — authorisation is scattered across four uncoordinated
enforcement layers (proxy route policies, server-action allowedRoles, hand-rolled route handlers,
and a separate capabilities.ts UI matrix), each a role-array check with no single source of truth.
There is no way to change what a persona can do, or to grant/revoke a capability for one user,
without editing code. This is stub 3 of the platform-access-permissions epic and advances the
Build the Bridge initiative's 2026-Q2 Objective 3 (Validate Technical Infrastructure & Payout Flow):
a payout/invoicing flow cannot be trusted without a coherent authorisation model underneath it. This
run builds the authorisation engine dark — the registry, models, resolver and shadow-mode
logging — so nothing consumes it yet and no user behaviour changes. Enforcement cutover (stubs 5–7)
and management UI (stubs 10–12) come later.
Everything lands in packages/services (plus one shadow-mode hook in web). Per the settled product
decisions (findings §7, binding): role templates as editable per-tenant defaults, per-user
grant/revoke overrides on top, admin as a hard superuser, and a data-scope qualifier (own/full) on
grants.
packages/services/src/permissions/registry.ts)
mapping feature → its standard + custom actions, forming the single permission vocabulary
(invoices.approve, leads.qualify, …) as a `${Feature}.${Action}` template-literal type.
The vocabulary is seeded from the Capability union keys in
apps/web/lib/workspace/capabilities.ts — vocabulary only; that file's role→access cells are
known-bad and are not carried over. Code-defined (not a Mongo collection) so it stays typo-proof
and every future enforcement point can exhaustively type-check against it.RoleTemplate model — per tenant: role → default permission set, each entry carrying an
optional scope qualifier (own | full). Seedable from platform defaults. This is the editable
per-persona default ("change what all CSMs can do").UserPermissionOverride model — per user per tenant:
{ tenantId, userId, grants[], revocations[], updatedBy, updatedAt } with a bounded audit trail of
who changed what when. This is the per-user layer on top of the template.allowedRoles call sites + route policies as they
behave today), NOT from the docs feature-role matrix, so the dark engine mirrors reality on day one.resolveEffectivePermissions(tenantId, clerkUserId) returning the effective
permission set with scopes: (template(role) ∪ grants) − revocations, with admin
short-circuited to allow-all (hard superuser). Two Mongo reads (template + override), memoised
per-request via React cache() plus a short in-memory TTL; no Clerk round-trip on the hot path.apps/web/lib/actions/index.ts, that records the resolver's verdict alongside the existing role
check for the same request and flags disagreements. Logging only — it never changes the decision,
so it is safe to ship dark and catches seed errors before any cutover.Permission is `${Feature}.${Action}` and
an unknown feature.action string is a compile error. Its feature/action vocabulary is derived
from the Capability union in apps/web/lib/workspace/capabilities.ts (vocabulary only).RoleTemplate and UserPermissionOverride models exist in packages/services, follow the
house idiom (plugin trio, tenant-leading ESR indexes, bounded arrays), and store scope
qualifiers on grants and an audit trail (updatedBy, updatedAt) on overrides.resolveEffectivePermissions(tenantId, clerkUserId) returns effective permissions =
(template ∪ grants) − revocations, verified by unit tests covering scope qualifiers and the
admin allow-all short-circuit; it does no Clerk round-trip and is memoised per request.allowedRoles + route policies, not the docs matrix), with a
working down.capabilities.ts
UI matrix reading the resolved set (stubs 5–7).permissionsVersion (or any permission data) into Clerk claims/metadata — optional
hardening (stub 13).auth-hygiene-fixes (the depends-on for this stub).(tenantId, clerkUserId) matches
the principal the parallel multi-role-principal stub produces — coordinate the concrete
principal/context type at Build if that stub has landed, but it does not change what this run builds.02_build/output/notes.mdfeat: permission-foundation — registry, models, resolver, shadow mode (dark)All new code is in packages/services plus one shadow-mode hook in apps/web. The engine ships
dark — nothing enforces on it; only the (env-gated) shadow log reads the resolver.
packages/services/src/permissions/registry.ts — the feature-set registry: a typed FEATURES
constant and a Permission = `${Feature}.${Action}` ``template-literal type. Vocabulary
transcribed (vocabulary only) from theCapabilityunion inapps/web/lib/workspace/capabilities.ts— its role→access cells are known-bad and were not carried
over.servicescan't import fromapps/web(wrong dependency direction), so it's transcribed with
a note.ALL_PERMISSIONS/isPermissionderive fromFEATURES so they can't drift.packages/services/src/permissions/types.ts — PermissionScope (own|full), ScopedPermission,
EffectivePermissions.packages/services/src/permissions/compute.ts — the pure permission algebra
((template ∪ grants) − revocations, admin allow-all short-circuit, deterministic version hash),
extracted so it's unit-testable with no DB.packages/services/src/permissions/compute.test.ts — node:test units covering the scope
qualifiers and the admin allow-all short-circuit (+ union/revocation, unknown-permission dropping,
determinism). Added a test script to the services package (mirrors the existing apps/web pattern).packages/services/src/permissions/resolver.ts — resolveEffectivePermissions(tenantId, clerkUserId): reads the role from our own user mirror (no Clerk round-trip), two Mongo reads
(template + override) for a non-admin, memoised per request via React cache() and across requests
by a short in-process TTL, then defers to compute.packages/services/src/permissions/defaults.ts — PLATFORM_ROLE_TEMPLATE_DEFAULTS, the per-role
seed derived from the current real enforcement behaviour (route-policies.ts + the
allowedRoles call sites), NOT the docs matrix. Scope qualifiers preserve today's own/portfolio
semantics.packages/services/src/permissions/shadow.ts — logShadowPermissionCheck: compares the resolver's
verdict against the live legacy decision and logs divergence. Never changes a decision, never
throws. Inert unless PERMISSIONS_SHADOW is set (see "Notes for review").packages/services/src/db/models/{role-template,user-permission-override}.ts — the two models
(plugin trio, tenant-leading unique indexes, bounded arrays, audit trail, scope qualifiers).packages/services/src/db/migrations/1784000000000-permission-foundation-seed-role-templates.ts —
seeds one role template per tenant per role from the platform defaults; idempotent + non-destructive
($setOnInsert), symmetric down.db/models/index.ts (+2 models), permissions/index.ts, server/index.ts (exports the
permissions module).apps/web/lib/actions/index.ts — the shadow-mode hook: resolveActionContext now computes the
legacy decision first, fires the shadow comparison best-effort, then applies the identical legacy
gate. Added an optional requiredPermission?: Permission for the (later) cutover.feature.action is a compile error — Permission is a
template-literal over FEATURES; isPermission guards DB-sourced strings.RoleTemplate + UserPermissionOverride follow the house idiom (plugin trio, tenant-leading
ESR indexes, bounded arrays), with scope qualifiers on grants and an audit trail on overrides.resolveEffectivePermissions = (template ∪ grants) − revocations with the admin allow-all
short-circuit, verified by unit tests (scopes + admin); no Clerk round-trip; request-memoised.allowedRoles, not the docs matrix), with a working down.pnpm --filter @sustentus/services test → 6 pass (the permission algebra).apps/web typechecks against the built @sustentus/services dist, so the new server exports
(logShadowPermissionCheck, Permission) resolve only after the services build — CI builds
services before typechecking web (turbo ^build), so this is expected to pass in CI.PERMISSIONS_SHADOW), default off. This keeps it truly inert — no
extra DB reads in production until an operator switches sampling on to collect comparison data. It
still "records verdicts on real action-context resolution" when enabled. This is a deliberate
refinement of the spec's shadow criterion, not a reduction: the hook is wired on every action, the
gate just controls when it does work.allowedRoles/route-policy behaviour, encoded once in defaults.ts; the shadow log is the
mechanism that reconciles any divergence before an enforcement cutover trusts them.CONVENTIONS.md still says "no test infrastructure", but apps/web already ships
node:test+tsx units (capabilities.test.ts et al.) with a test script. Acceptance criterion 3
requires unit tests, so I followed that existing, working pattern rather than the stale line.(tenantId, clerkUserId) matches the principal the parallel
multi-role-principal stub produces — no coordination change was needed for this dark run.03_release/output/investor-update.mdWho it's for: Admins; every persona once enforcement follows. What shipped: A typed permission engine — role templates, per-user overrides and one resolver — replacing scattered role checks. It runs dark: no user-facing change. Why it matters: It de-risks the payout flow, giving Build the Bridge's Objective 3 — Validate Technical Infrastructure & Payout Flow — a coherent authorisation model.
Verified by unit tests and shadow-checked against live behaviour before any cutover.
Dig deeper: <merged-PR URL> · <changelog entry URL>