demo-scope-visualsrun.md03_define/output/spec.mdRole-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.
A read-only Scope & licences tab at /admin/settings/scope, derived entirely from the live
RBAC sources — nothing about permissions, routes or enforcement changes.
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.
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.
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.
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.
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.
/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).ROUTE_POLICY_RULES, with every row
marked derived or explicit, and explicit rows showing their reason text.@sustentus/services (admin → Platform,
vendor → Sales, csm | sdm | expert → Delivery, customer → none), and nothing anywhere
reads that mapping to allow or deny an action./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.getAllowedRoles returns the same allow-list for every pre-existing path.licence-class-reporting (v1-user-hierarchy stub 6 of 6). This run ships the mapping it will
consume, not the report.UserPermissionOverride). The screen is role-level and
says so; a per-user effective-scope inspector is separate work.getAllowedRoles, the resolver, or existing route
policies beyond adding this screen's own entry.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.apps/demo change — the demo app stays the pipeline Design stage's prototyping surface.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.md0cd2878 licence classes (services) · acbdedd one home for the permission display
vocabulary + route-policy provenance · a4cb835 the read-only Scope & licences screendemo-tenant batch is report-based; Design was skipped, so nothing was ported)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.?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.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) → toGrantedScopes → scopeRowsFor.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).reason — routeReachForRole over EXPLAINED_ROUTE_POLICIES, rendered by
route-reach.tsx.@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).Alert above the licence band, and the
Customised / Platform default / Superuser — read-only badge on the active role.actions.ts, no "use server", no form. The only
interactive elements are next/link navigations.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.ROUTE_POLICIES is derived from the same resolution and sorted identically.
route-policies.test.ts is unchanged and still asserts /admin/settings/other → ["admin"].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)./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.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.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.05_verify/output/verify.mdd55fae0); 3 are Ship's work, not
blockers here (docs sync, changelog entry, rollback line in the PR body).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.route-policies.ts) — no HIGH or MEDIUM findings.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)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)"use server", no action file, no form, no
mutation reachable from the route. (agent)LICENCE_CLASS_BY_ROLE / rolesInLicenceClass
have exactly three consumers (the page, the two barrels, the test); no enforcement path
reads them. (agent)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)own/full scope from the tenant's stored
templates — operator?role=csm) — operator; this is the feature's central claim/service-leads row names /service-leads/create, then click that path as
admin and confirm the redirect matches what the row says — operatorrouteReachForRole 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).d55fae0):
filtered through isPermission.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.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.mdSettings → 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.
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.mdWho 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.mdpr: #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:
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.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).
own/full scope from the tenant's stored
templates — traced in Verify; operator demonstration outstanding on the preview.RoleTemplate rows the editor writes.reason text — and, after
the Verify fix, the sub-paths a more specific rule narrows.@sustentus/services;
nothing reads it to allow or deny.licence.test.ts (6) and scope-visuals.test.ts (13), green in CI./shared exports whose only consumer is the reverted screen.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.