Skip to Content

← All archived runs

Run: demo-scope-visuals

run.md

Run: demo-scope-visuals

  • branch: claude/pipeline-demo-scope-visuals-5mj4qm
  • pr: #798

03_define/output/spec.md

Spec: Scope & licence visuals — the RBAC engine, rendered

  • slug: demo-scope-visuals
  • personas: Admin
  • touches: apps/web/app/(app)/admin/settings/scope, apps/web/app/(app)/admin/settings/_components/tabs-config.ts, apps/web/lib/route-policies.ts, packages/services/src/permissions, packages/services/src/shared
  • complexity: standard
  • demo: none

Problem

Role-based scoping is a headline sales point — six personas, deny-by-default routes, per-tenant permission templates — and the platform genuinely has it: a typed permission registry, a resolver, per-tenant role templates, and 73 route policies whose allow-lists are derived from those templates. None of it is visible in a walkthrough. The presenter switches personas and narrates "notice what you can't see" from memory, so the strongest structural proof that this is a multi-tenant B2B platform rather than six hardcoded dashboards never reaches the screen. The one surface that shows the model today — the role-templates editor — is an editing grid of toggles, which is the wrong artifact to point at in front of a prospect and the wrong one to hand a client's IT reviewer. This is punch-list G of the demo-tenant batch, carrying Refine the bridge / Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners: the demo tenant now has a world (demo-tenant-foundation, demo-seed-storyline) and a way to walk it (demo-presenter-switching); this makes the engine underneath it legible.

Proposed change

A read-only Scope & licences tab at /admin/settings/scope, derived entirely from the live RBAC sources — nothing about permissions, routes or enforcement changes.

  1. Per-persona scope matrix. For each of the six roles, the effective granted capabilities rendered from the tenant's stored role templates via roleTemplateService.listForTenant — the same rows the resolver reads — grouped by the registry's features and actions (FEATURES / ALL_FEATURES), with each cell showing granted-or-not and its own / full data scope. Because the source is the stored templates, an admin toggling a permission in the role-templates editor changes this screen on the next request with no code edit. admin is rendered as the allow-all superuser the resolver short-circuits it to, not as a template of toggles.

  2. Per-persona reach: what each role can open. A companion view over ROUTE_POLICY_RULES listing, per role, the route surfaces that role may reach — and marking each row as either derived (the allow-list falls out of requiredPermission inverted against the platform defaults) or explicit (a roles + reason rule the registry does not yet model, showing the reason verbatim). This is the deny-by-default story made concrete: 73 rules, and a role sees what it sees because of the template, not because someone wrote a list.

  3. Licence-class grouping over capability. The three settled classes — Platform, Sales, Delivery (Paul, 2026-08-06: "Platform Licence & Admin can't have one without the other. Sales Licence. Delivery Licence") — introduced as a single role→class mapping in packages/services and rendered as a band over the scope matrix, so the walkthrough can say "this is what a Sales licence gets you" against real capability rather than a slide. The mapping is admin → Platform, vendor → Sales, csm | sdm | expert → Delivery, customer → none (the customer is the other side of the engagement, not a seat). It is report-only: nothing reads it to allow or deny anything, and the later licence-class-reporting stub (v1-user-hierarchy) builds its per-user report and counts on this same mapping rather than a second one.

  4. Honest about what it can't show. The screen states plainly, on the surface itself, that it renders role-level effective scope for this tenant: per-user grants and revocations (UserPermissionOverride) refine an individual's access at the action layer and are not shown, and route allow-lists derive from the platform-default templates at module load, so a tenant that has customised a template can reach a route whose action layer then narrows it. Where the tenant's stored template differs from the platform default, the role is badged Customised — the same provenance flag the editor already surfaces.

  5. Read-only by construction. No form, no action, no mutation: the tab renders and links to the role-templates editor for anyone who wants to change something. It grants no capability and adds no enforcement anywhere.

Route policy. A new explicit entry for /admin/settings/scope with roles: ["admin"], placed ahead of the /admin/settings prefix rule — matching how role-templates is registered even though the prefix rule would already cover it — plus the SETTINGS_TABS entry (roles: ["admin"]) so the tab appears in the settings nav. The presenter is the demo admin and reaches this screen directly; the other five personas are the subject of the screen, reached in the walkthrough through view-as.

Acceptance criteria

  • /admin/settings/scope renders every one of the six roles with its effective granted capabilities and each grant's own/full scope, read from the tenant's stored role templates (falling back to the platform default where a tenant has no stored row).
  • Editing a permission in the role-templates editor changes what this screen shows on the next request, with no change to the screen's own code — demonstrated by toggling one permission and reloading.
  • Each role's reachable route surfaces are listed from ROUTE_POLICY_RULES, with every row marked derived or explicit, and explicit rows showing their reason text.
  • The three licence classes are rendered as groupings over capability, using a single role→class mapping exported from @sustentus/services (admin → Platform, vendor → Sales, csm | sdm | expert → Delivery, customer → none), and nothing anywhere reads that mapping to allow or deny an action.
  • A role whose stored template differs from the platform default is badged as customised, and the screen states in-surface that per-user overrides are not shown and that route allow-lists derive from platform defaults.
  • The screen performs no writes: no server action, no form submission, no mutation reachable from it.
  • /admin/settings/scope has its own route-policy entry allowing admin only; a non-admin role reaching the path is redirected exactly as any other admin settings path is, and the tab is absent from the settings nav for non-admins.
  • No existing route policy, permission template, resolver behaviour, or proxy behaviour changes — getAllowedRoles returns the same allow-list for every pre-existing path.
  • The licence mapping and the matrix/route derivations are unit-covered: every role maps to exactly one class or none; a customised stored template is reflected in the matrix while the platform default is not; a derived route row and an explicit route row each resolve to the expected roles.

Out of scope

  • Licence enforcement, billing, seat purchasing, usage metering, or any price attached to a class — the class structure is a commercial decision, not a product one (Paul, D-11 on the v1-user-hierarchy scope), and BR-27 holds: licence classification never gates anyone.
  • The per-user licence report — every user listed with their class and the count per class — is licence-class-reporting (v1-user-hierarchy stub 6 of 6). This run ships the mapping it will consume, not the report.
  • Editing roles, permissions, or overrides from this screen. The role-templates editor stays the only write surface.
  • Showing per-user grants/revocations (UserPermissionOverride). The screen is role-level and says so; a per-user effective-scope inspector is separate work.
  • Any change to the deny-by-default proxy, getAllowedRoles, the resolver, or existing route policies beyond adding this screen's own entry.
  • Widening the audience: the screen is admin-only (decision at Define, 2026-08-13). A vendor- or customer-facing "here is your boundary" view is a later candidate, not this run.
  • The sales role. The demo prototype's licence mapping predates the real role enum; the mapping built here covers exactly the six roles in USER_ROLES, and a future sales role is v1-user-hierarchy's to add.
  • Any apps/demo change — the demo app stays the pipeline Design stage's prototyping surface.

Open questions

none

Context budget: within the Inputs table — the intake stub, the batch breakdown and its _source/report.md §G (this scope has no scope.md; the cut is report-based by design), plus one scoped read of the RBAC surfaces the stub named (packages/services/src/permissions/**, apps/web/lib/route-policies.ts, the admin settings tab config and the existing role-templates tab) to fix touches: and confirm what already exists. The v1-user-hierarchy scope.md was read for the licence-class rows only (D-11, BR-27, FR-12) to keep this run's mapping identical to the one that scope settled.

04_build/output/notes.md

Build notes: demo-scope-visuals

  • commits: 0cd2878 licence classes (services) · acbdedd one home for the permission display vocabulary + route-policy provenance · a4cb835 the read-only Scope & licences screen
  • demo: none (the demo-tenant batch is report-based; Design was skipped, so nothing was ported)

What changed

  • packages/services/src/permissions/licence.ts (new): the role→licence-class mapping — admin → Platform, vendor → Sales, csm | sdm | expert → Delivery, customer → null. Pure data, no consumer that gates on it. Exported from permissions/index.ts and from /shared.
  • packages/services/src/shared/index.ts: also exposes the registry (FEATURES, ALL_FEATURES), the PermissionScope type and the licence mapping. All three are the DB-free half of the permission engine, which is what /shared already exists to carry (the file's own comment says so for PLATFORM_ROLE_TEMPLATE_DEFAULTS). This is what lets lib/scope-visuals.ts unit-test without dragging Mongoose into the vitest node environment — the same reason lib/view-as-switcher-data.ts reads USER_ROLES from /shared.
  • apps/web/lib/permission-labels.ts (new): humanisePermissionPart, PERMISSION_FEATURE_GROUPS and toGrantedScopes. These were already duplicated verbatim across the role-templates settings tab and the per-user permissions panel; the new screen needed the same three, so rather than adding a third copy (CONVENTIONS.md → "Grep before writing a helper", "never add a second copy") they were lifted once and all three consumers repointed. The two existing pages lose ~50 lines between them and render identically.
  • apps/web/lib/route-policies.ts: adds the /admin/settings/scope rule (admin only, ahead of the /admin/settings prefix rule), and introduces EXPLAINED_ROUTE_POLICIES — every rule resolved to concrete roles with its provenance (derived + the required permission, or explicit + the reason string). ROUTE_POLICIES is now a projection of that list rather than a second traversal of the rules, so the middleware's view and the screen's cannot drift. Resolution logic, sort order and getAllowedRoles are byte-for-byte equivalent.
  • apps/web/lib/scope-visuals.ts (new): scopeRowsFor (a role's granted map projected across the whole registry, so gaps render as loudly as grants) and routeReachForRole.
  • apps/web/app/(app)/admin/settings/scope/ (new): the page plus four presentational server components — role switcher, licence band, capability matrix, route reach.
  • apps/web/app/(app)/admin/settings/_components/tabs-config.ts: the Scope & licences tab, admin only, mirroring the route policy.

Decisions taken while building (all inside the spec)

  • The role selector is a URL parameter, not client state (?role=csm). The spec requires the screen to perform no writes and adds no interactivity requirement, so keeping it a server component was the cheaper and more faithful option — and it makes each persona deep-linkable, which suits the walkthrough the feature exists for.
  • admin renders as the superuser it is. listForTenant returns the full registry for admin and marks it read-only; the matrix says in words that the resolver short-circuits admin to allow-all, rather than implying a template governs it.
  • Licence "capability count" is a union, not a sum — the distinct permissions held by at least one role in the class, so Delivery isn't triple-counted across csm/sdm/expert.

Acceptance criteria status

  • Six roles with effective capabilities and own/full scope, from the tenant's stored templates — roleTemplateService.listForTenant (which itself falls back to the platform default per role when a tenant has no stored row) → toGrantedScopesscopeRowsFor.
  • A template edit shows up with no change to the screen's code — the page reads the same RoleTemplate rows the editor writes and the resolver reads; no permission list is hardcoded anywhere in the new code. Needs the operator smoke pass to demonstrate (below).
  • Route surfaces per role from the policy table, each marked derived or explicit, explicit rows showing their reasonrouteReachForRole over EXPLAINED_ROUTE_POLICIES, rendered by route-reach.tsx.
  • Three licence classes grouped over capability from a single mapping exported by @sustentus/services; nothing reads it to allow or deny — the only consumers are this screen and its unit test (grep -rn "LICENCE_CLASS_BY_ROLE\|rolesInLicenceClass" confirms).
  • Customised roles badged, and the in-surface statement about per-user overrides and platform-default-derived route allow-lists — the Alert above the licence band, and the Customised / Platform default / Superuser — read-only badge on the active role.
  • No writes — the new route has no actions.ts, no "use server", no form. The only interactive elements are next/link navigations.
  • Own route-policy entry allowing admin only; tab absent for non-admins — the policy entry plus the SETTINGS_TABS row (roles: ["admin"]), and resolveActionContext({ allowedRoles: ["admin"] }) in the page as defence in depth.
  • No existing policy, template, resolver or proxy behaviour changes — the only rule added is the new path's; ROUTE_POLICIES is derived from the same resolution and sorted identically. route-policies.test.ts is unchanged and still asserts /admin/settings/other → ["admin"].
  • Unit coverage from the criteria — packages/services/src/permissions/licence.test.ts (every role maps to exactly one class or none, the classes partition, the customer is unlicensed) and apps/web/lib/scope-visuals.test.ts (the matrix reads the passed template rather than the platform default; a derived row and an explicit row each resolve as expected).

Notes for Verify

  • The one criterion a preview must demonstrate, not the diff: "a template change is reflected without touching the screen's code". Toggle a permission for CSM in /admin/settings/role-templates, save, then open /admin/settings/scope?role=csm and confirm the cell moved. The code path makes it true by construction, but it is the feature's whole claim.
  • Scope check on the refactor. Two files outside the spec's touches: were edited — role-templates/page.tsx and components/users/user-profile/user-permissions.tsx — solely to delete duplicate helpers into the new shared home. Both are pure substitutions with no behaviour change; worth eyeballing that the rendered permission labels are unchanged on both surfaces.
  • /shared grew four exports. They are the DB-free half of an engine that already had a foot in that entry point, and each has a consumer today, but the barrel-as-public-API rule makes it worth a second opinion.
  • Nothing was run locally — format, lint, typecheck, tests and the build are all CI's, per the block-local-checks hook. The Quality workflow runs on this PR (it is no longer markdown-/pipeline/**-only), so its Run tests step is the first real signal on the two new test files.
  • No new environment variables, no dependency changes, no migration, no index.

05_verify/output/verify.md

Verify: demo-scope-visuals

  • production-readiness: run — 5 findings. 2 fixed on branch (d55fae0); 3 are Ship's work, not blockers here (docs sync, changelog entry, rollback line in the PR body).
  • code-review: medium (spec complexity: standard) — 1 finding, the same route-reach defect the readiness pass raised independently. Fixed on branch. The CI Claude review is off on this repo (Review diff against CONVENTIONS.md reported skipped), so the skill ran rather than triage.
  • security-review: run (diff touches route-policies.ts) — no HIGH or MEDIUM findings.
  • playwright: TODO — manual DoD smoke performed instead

DoD smoke (on the preview — each line says who verified it)

Preview: https://web-git-claude-pipeline-demo-scope-visuals-5mj4qm-sustentus.vercel.app

Every criterion on this screen is behind a Clerk sign-in as an admin, so the agent half is limited to tracing the code path; the demonstrations are the operator's. Nothing below is claimed as demonstrated that was not.

  • /admin/settings/scope has its own route-policy entry allowing admin only — traced: route-policies.ts:137-147, plus page-level resolveActionContext({ allowedRoles: ["admin"] }) and the roles: ["admin"] tab entry. No existing path's allow-list changes (nothing is a prefix of the new path). (agent)
  • No existing route policy, template, resolver or proxy behaviour changes — traced: ROUTE_POLICIES is a projection of EXPLAINED_ROUTE_POLICIES with the identical sort; getAllowedRoles untouched; route-policies.test.ts unchanged and green in CI. (agent)
  • The screen performs no writes — traced: no "use server", no action file, no form, no mutation reachable from the route. (agent)
  • The licence mapping is report-only — traced: LICENCE_CLASS_BY_ROLE / rolesInLicenceClass have exactly three consumers (the page, the two barrels, the test); no enforcement path reads them. (agent)
  • Unit coverage from the criteria — licence.test.ts (6 cases) and scope-visuals.test.ts (13 cases, incl. the 4 added with the fix). Green on the Quality workflow's Run tests step. (agent, via CI)
  • Six roles render with effective capabilities and own/full scope from the tenant's stored templates — operator
  • A role-template edit is reflected on the next request with no code change (toggle a CSM permission, save, reload ?role=csm) — operator; this is the feature's central claim
  • Route reach lists derived/explicit rows with reasons, and the exceptions now render — check the admin /service-leads row names /service-leads/create, then click that path as admin and confirm the redirect matches what the row says — operator
  • Licence classes render as capability groupings with the customised/platform-default badge — operator
  • Tab absent, and path redirects, for a non-admin persona — operator
  • auth: admin sign-in + dashboard OK — operator
  • payments: not touched — no payment path in the diff. (agent)
  • notifications: none expected — no notification, email or Ably code in the diff. (agent)

Findings & cleanup

  • Route reach over-stated what a role could open (readiness §1 + code-review, independently) — routeReachForRole filtered by roles.includes(role) and ignored that getAllowedRoles matches longest-path-first, so a Prefix row claimed a subtree the role could be refused: admin told it reaches /service-leads (but /service-leads/create is vendor-only) and /projects (but /projects/create is customer-only); same shape for any role with invoice.view and not invoice.approve on /finances vs /finances/approvals. The intro copy asserted unruled paths were the only refusals, which is precisely the wrong inference. Fixed on branch (d55fae0): rows carry their narrowing sub-paths, the row names them, the copy states both refusal mechanisms, and four tests cross-check every named exception against getAllowedRoles itself. Direction of the original error was over-claiming reach, never hiding a grant — a credibility defect, not a security one (security review agrees).
  • Licence count could exceed the registry total (readiness §2) — the band counted raw keys off the stored template while the matrix projects across the live registry, so a permission string a rename left behind would inflate one and not the other. Fixed on branch (d55fae0): filtered through isPermission.
  • Docs not synced; no changelog entry (readiness §3, §4) — business/roles lists the admin settings surfaces and technical/packages/services describes what /shared carries; both are now stale, and a new admin-visible tab warrants a changelog page. Not a Verify blocker — these are Ship's step 2 and step 3, done in this same PR. Recorded here so Ship doesn't miss them.
  • Rollback story not in the PR body (readiness §5) — cleanly revertible (no env var, dependency, migration or index), but unstated. One line for Ship to add.
  • Note for the reviewer, not a finding — the new getAllowedRoles cross-check test loops over the exceptions. CONVENTIONS.md → Testing bans computing expected values with loops; this computes none, it asserts against the production oracle. Deliberate, and the most valuable test in the set: it pins the visualisation to the enforcer instead of to a second copy of the matching logic, which is the exact gap that produced the defect.

Context budget: within the Inputs table. The three review passes read the branch diff and the files it names; no wider sweep.

06_ship/output/changelog.md


title: See what every role can do, on one page date: 2026-08-13T12:00:00Z personas: [admin] slug: demo-scope-visuals pr: https://github.com/sustentus/sustentus/pull/798

See what every role can do, on one page

Settings → Scope and licences is a new read-only view of what each of the six roles in your organisation can do, and which parts of the platform each one can open.

  • Pick a role and see every permission it holds and every one it doesn't, with whether each applies to a person's own records or the whole organisation.
  • A second table lists the areas that role can reach — and, where a more specific rule narrows one, the exact pages inside it that are still closed to them.
  • Each row says why the role has that access: because a permission in its template grants it, or because the area is restricted to named roles for a stated reason.
  • Licence classes — Platform, Sales and Delivery — are shown as groupings over those capabilities, so you can see what each class actually covers. They are reporting only, and never stop anyone signing in or working.
  • Everything is read from the same permission templates the platform enforces, so a change you make in Settings → Role templates shows up here the next time you load the page. A "customised" label marks any role you've changed from the platform default.

The view changes nothing on its own — it grants no access and edits nothing. Per-person exceptions are managed on each user's own record and aren't shown here.

06_ship/output/investor-update.md

The permission model is now something you can show, not just describe

Who it's for: Admin, and anyone demonstrating the platform What shipped: A read-only screen showing what each role can do and where it can go. Why it matters: Refine the Bridge is about reporting clarity — this scoping was invisible in a walkthrough.

It reads the live permission templates, so it cannot drift from what is enforced.

Dig deeper: https://github.com/sustentus/sustentus/pull/798 · https://help.sustentus.com/changelog/2026-08-13-demo-scope-visuals

06_ship/output/release.md

Ship: demo-scope-visuals

  • pr: #798 · merged: yes — squash-merged as dd84b1e on 2026-08-13

  • CI: green — all 10 checks on 69b9dd1, including Quality Project (format, lint, typecheck, tests). One earlier red: format:check on 3c188da, fixed in ab78dd4 (Husky's formatter cannot run in this container — no installed node_modules).

  • technical docs: apps/docs/app/technical/packages/services/page.mdx — the Permissions section now names the four additions to the /shared entry (FEATURES, ALL_FEATURES, PermissionScope, the licence mapping) and why the split exists, plus a paragraph stating that licence classes are reporting and never a gate.

  • business docs: apps/docs/app/business/roles/page.mdx — the Admin persona gains the scope and licences tab in the settings hub list, a "what they see" entry, and a capability bullet.

  • release notes: both

  • sent: no — the note did not go out. ship-note.yaml did fire on the merge (run 31699508996) and finished green having sent nothing. Two independent reasons:

    1. Its "Work out which runs shipped" step diffed the PR head commit (a497cc9), which the squash-merge made unreachable from main and the branch deletion removed outright: fatal: bad object a497cc9…. A || true swallowed the error, so the step concluded no ship note had shipped and skipped the send. Every squash-merged run since the workflow landed on 2026-08-10 hit this — no ship note has ever been delivered by it.
    2. Its send config (RESEND_API_KEY, INVESTOR_RECIPIENTS, EMAIL_FROM) is unset at repo level, so the send would have died on send-investor-update.sh's config guard even had the slug resolved.

    Both are addressed in the ship-note-run-detection chore; the secrets are an ops task. Re-send by hand once the config exists: Actions → Ship note → Run workflow, slug demo-scope-visuals (dry run by default — read the recipients before turning it off).

Acceptance check (vs spec)

  • Six roles render effective capabilities with own/full scope from the tenant's stored templates — traced in Verify; operator demonstration outstanding on the preview.
  • A template edit is reflected next request with no code change — no permission list is hardcoded; the page reads the same RoleTemplate rows the editor writes.
  • Route surfaces listed per role with derived/explicit provenance and reason text — and, after the Verify fix, the sub-paths a more specific rule narrows.
  • Three licence classes grouped over capability from a single mapping in @sustentus/services; nothing reads it to allow or deny.
  • Customised badge, and the in-surface statement about per-user overrides and platform-default-derived route allow-lists.
  • No writes — no server action, form or mutation on the route.
  • Own route-policy entry, admin only; tab absent for non-admins.
  • No existing policy, template, resolver or proxy behaviour changed.
  • Unit coverage — licence.test.ts (6) and scope-visuals.test.ts (13), green in CI.

Notes

  • Rollback: revert-commit is sufficient. No environment variable, dependency, migration or index is introduced, and no existing behaviour is modified — reverting removes a route, a tab entry and four /shared exports whose only consumer is the reverted screen.
  • The Verify stage fixed two defects on this branch before Ship ran (d55fae0): the route-reach table over-stated reach by ignoring rule specificity, and the licence count could exceed the registry total. See 05_verify/output/verify.md.