Skip to Content

← All archived runs

Run: permission-foundation

run.md

Run: permission-foundation

  • branch: claude/permission-foundation-pipeline-5c4uv6
  • pr: #651

01_define/output/spec.md

Spec: Permission foundation — registry, models, resolver (dark)

  • slug: permission-foundation
  • personas: admin
  • touches: packages/services/src/permissions (new), packages/services/src/db/models, packages/services/src/db/migrations, apps/web/lib/actions/index.ts
  • complexity: complex

Problem

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

Proposed change

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.

  • Feature-set registry — a typed code constant (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.tsvocabulary 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.
  • Seeding migration — a ts-migrate-mongoose migration that seeds per-tenant role templates from the current real enforcement behaviour (the 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.
  • ResolverresolveEffectivePermissions(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.
  • Shadow mode — a comparison log, hooked at action-context resolution in 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.

Acceptance criteria

  • The feature-set registry is exhaustively typed: 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.
  • A ts-migrate-mongoose migration seeds per-tenant role templates matching today's observed role→access behaviour (from allowedRoles + route policies, not the docs matrix), with a working down.
  • Shadow-mode logging records resolver-vs-legacy verdicts on real action-context resolution and surfaces disagreements; there is zero behaviour change for users (no enforcement path reads the resolver this run).

Out of scope

  • Any enforcement cutover — server actions, route policies, route handlers, or the capabilities.ts UI matrix reading the resolved set (stubs 5–7).
  • Any management UI — per-user permission editor, role-template editor, audit viewer (stubs 10–12).
  • In-app role assignment / Clerk membership writes and Clerk webhook re-sync (separate stubs).
  • Mirroring a permissionsVersion (or any permission data) into Clerk claims/metadata — optional hardening (stub 13).
  • The P0 hygiene fixes (G1–G5); those ride auth-hygiene-fixes (the depends-on for this stub).

Open questions

  • none. The four product decisions (findings §7) are settled and binding; the registry shape (§5.1) and resolver contract (§5.2) are specified. The resolver signature (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.md

Build notes: permission-foundation

  • commits: feat: permission-foundation — registry, models, resolver, shadow mode (dark)

What changed

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.tsPermissionScope (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.tsnode: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.tsresolveEffectivePermissions(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.tsPLATFORM_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.tslogShadowPermissionCheck: 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.
  • barrels: 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.

Acceptance criteria status

  • Registry exhaustively typed; unknown 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.
  • Migration seeds per-tenant templates from today's observed role→access behaviour (route policies + allowedRoles, not the docs matrix), with a working down.
  • Shadow-mode logging records resolver-vs-legacy verdicts on real action-context resolution and surfaces disagreements; zero behaviour change (no enforcement path reads the resolver).

Verify result

  • Unit tests: pnpm --filter @sustentus/services test → 6 pass (the permission algebra).
  • Mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. Note: 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.

Notes for review

  • Shadow mode is env-gated (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.
  • Seed accuracy is intentionally provisional. The template defaults are the best read of the real 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.
  • Tests: 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.
  • The resolver signature (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.md

One source of truth for who can do what — the permission foundation is in place

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