demo-presenter-switchingrun.md03_define/output/spec.mdview-as, admin gate untouchedThe demo tenant now exists — flagged isDemo, six persona users, a seeded world
(demo-tenant-foundation, #790) — but the only way to move between those personas mid-pitch is
view-as, which was built for admin permission-testing, not a client walkthrough. Its switcher
sits in the sidebar among admin chrome, it leads with bare role emulation that deliberately
renders own-scoped views empty, and while emulating it is buried in the persona's own sidebar
footer, so persona → persona takes a dig through a submenu. The source report calls friction-free
persona switching "the one genuine gap" (punch-list C), and the cut resolved it as
presenter-led: a real admin drives the existing switcher and no auth is bypassed. Until the
switch is clean, the walkthrough that carries Refine the bridge / Q2-2026 Objective 1 —
Establish Product-Market Fit with Vendor Partners stalls on internal tooling in front of the
prospect.
An isDemo-gated presenter layer over the existing view-as machinery. Nothing about the
authority changes: resolveViewAs and its three enforcement points, the enterViewAs /
exitViewAs server actions, the cookie, and the audit trail are all reused exactly as they are.
The layer is a presentation of capability the platform already has.
isDemo: true,
the switcher's data gains a pinned list: one entry per emulatable persona (CSM, SDM, Expert,
Vendor, Customer), naming the tenant's storyline user for that role, and the bare-role
"Personas" section is omitted so no walkthrough click can land in an empty own-scoped view.
Every pinned entry is faithful user emulation — the same kind: "user" input the switcher
sends today. A role with no user is simply absent; a role with more than one keeps the
existing grouped submenus below the pinned list so every user stays reachable.isDemo only, the banner drops the amber
audit-warning treatment for a neutral strip that reads as a presenter control — the emulated
person and role by name, with "Back to admin" in place of "Exit View as". It stays persistent
and stays unmistakable; it just stops looking like an internal alarm during a client session.enterViewAs redirects to the emulated role's
ROLE_HOME, so each switch lands on that persona's dashboard with the seeded world populated.enter / exit audit records all keep exactly
today's semantics. Tenants with isDemo: false render and behave exactly as they do now.ROLE_HOME with the
seeded world populated.isDemo: false, the switcher and banner are unchanged from today — bare-role
personas plus grouped user submenus in the sidebar, the amber banner with "Exit View as", and
no quick-switch in the banner.view-as audit trail through enterViewAs / exitViewAs, and
a non-admin — or a forged/cross-org cookie — still cannot enter emulation anywhere.isDemo, today's shape off it,
a role with no user omitted, a role with several users pinning one and keeping the rest
reachable, and non-admin still receiving no switcher data at all.proxy.ts or the route policies.resolveViewAs, the admin gate, the same-org check, or the cookie binding.demo-seed-storyline (stub 2). This run reads
whatever users the demo tenant has; it does not create, rename, or seed any.enter record for the new emulation); no new exit record is synthesised.enterViewAs / exitViewAs, resolveViewAs, or the tenant model — this run adds
no server action and no new authority.04_build/output/notes.mdfeat: demo-presenter-switching — presenter persona quick-switch over view-asapps/demo prototype to port)apps/web/lib/view-as-switcher-data.ts (new): the switcher's shaping, extracted as a pure
function so the acceptance criteria are unit-assertable — resolveSwitcherData(realRole, isDemo, users). Admin-only (any other real role returns null). Off isDemo it returns exactly today's
shape; on it, the presenter shape — one pinned user per emulatable persona, personas: [] so no
bare-role emulation is offered, and a per-role submenu kept only where a role has more users than
the pinned one. Same pure-core-plus-async-wrapper split the auth helpers already use.apps/web/lib/view-as.ts: now a thin request-bound wrapper — resolves the real role, the tenant
and the directory, then defers to the pure function. isDemo is read off the already-resolved
tenant (getTenantOrNull is React.cached), so no extra query. ActiveViewAs gains
presenter.apps/web/components/view-as/presenter-persona-items.tsx (new): the pinned persona items,
rendered identically in the sidebar switcher and the banner quick-switch rather than duplicated.apps/web/components/view-as/view-as-switcher.tsx: on presenter, the "Personas" (bare-role)
section is replaced by the pinned list. The user submenus below are unchanged.apps/web/components/view-as/view-as-banner.tsx: on presenter, a neutral strip (bg-muted /
border-border) reading "Presenting as <name> <role badge>", a "Switch persona" dropdown, and
"Back to admin". Off it, the amber banner and "Exit View as" exactly as before, with no
quick-switch rendered.apps/web/app/(app)/layout.tsx: passes the pinned list to the banner (empty off isDemo).apps/web/components/sidebar/app-sidebar.tsx: type import moved to the new module.apps/web/lib/view-as-switcher-data.test.ts (new): unit tests written from the criteria.Not touched, deliberately: resolveViewAs and the three enforcement points, enterViewAs /
exitViewAs, the cookie, the tenant model. The presenter layer sends the same
kind: "user" input the switcher already sent, so the audit contract is unchanged.
enterViewAs, which redirects to the emulated
role's ROLE_HOME. Populated dashboards depend on the seeded world, not on this diff.personas: [] in the presenter shape, and the
switcher renders the pinned list in that branch.exitViewAs
(which redirects to ROLE_HOME.admin).isDemo: false unchanged — the non-presenter branch is today's markup and today's data
shape; the quick-switch renders only when presenter.enterViewAs /
exitViewAs, and authority still resolves through resolveViewAs.null, pinned list on isDemo,
today's shape off it, a role with no user omitted, a role with several users pinning the
first and keeping the rest reachable. Written from the criteria; not run locally (the factory
runs them).isDemo: true while signed in as its admin. If the
preview points at a database without the flagged tenant, the correct observation is the non-demo
path (AC 4) — the presenter path is then a production check, not a preview one.getActiveViewAs now calls getTenantOrNull(). It is React.cached and the
layout already resolves the tenant in the same request, so this should add no query — confirm
that holds.USER_ROLES (csm, sdm, expert, vendor, customer); within a role, users
sort by name, so the pinned entry for a multi-user role is the alphabetically first. That rule is
the tie-break for a case the demo tenant does not have today (one user per persona).demo-seed-storyline (stub 2,
not yet built) renames them without touching this code.05_verify/output/verify.mdprocess.env reads, nothing owed to turbo.json → globalEnv), database ✅ (no
schema/index/migration; the one read is the already tenant-scoped listTenantDirectory), auth ✅
(gate byte-identical), deploy ✅.complexity: standard) · 3 findings, all 3 fixed on branch in
0b711b5.resolveSwitcherData without weakening (and is now unit-pinned); the new client entry point
re-validates through the unchanged enterViewAs (admin + same-tenant); no new data reaches a
non-admin; the client/server bundle boundary holds (@/lib/auth is a type-only import). One
pre-existing, out-of-scope issue flagged separately — see Findings.Preview: https://web-git-claude-demo-presenter-switching-y2uuhm-sustentus.vercel.app
(deployment CUv71nxSkGvzbVNt3kEswp9wk6tD, Ready on 0b711b5)
/ and /sign-in both 200 (agent)Quality green on the head commit: format, lint, typecheck, and the test suite including the
10 unit tests for the pinned-list derivation (agent, via the PR's check runs)enterViewAs/exitViewAs; resolveViewAs, the same-org check, the cookie by-binding and
the three enforcement points are unchanged in the diff (agent, traced in the diff)null, pinned list on isDemo,
today's shape off it, role with no user omitted, storyline account pinned over an
alphabetically-earlier filler, unpinned users still reachable, empty-pinned fallback (agent)ROLE_HOME (operator — needs the demo tenant; see the caveat below)isDemo: false is unchanged: amber banner, "Exit View as", bare-role
personas, no quick-switch (operator — this is what the preview exercises by default)Preview caveat (carried from Build). The demo tenant is a production tenant. Presenter mode
only renders where the resolved tenant has isDemo: true, so unless the preview resolves that
tenant, the preview exercises the non-demo path (AC 4) and the presenter path (AC 1–3) is a
production check. Whatever gets ticked should say which of the two was actually observed.
0b711b5). The demo environment documents eleven customer logins (demo-customer plus
customer-1…customer-10) alongside the storyline accounts, so name order could have handed the
presenter an unseeded filler account mid-pitch — the exact failure this feature exists to
prevent. The pin is now deliberate: the account matching the provisioning convention
demo+<role>@…, falling back to the first user when nothing matches. Rule chosen by Jamie at
Verify. The test that encoded the old behaviour was rewritten to assert the new one.0b711b5).
resolveSwitcherData now falls back to the standard switcher shape when a demo tenant yields no
pinnable user (persona users that have not yet signed in once carry no Clerk id), so an admin is
never left with a menu they cannot emulate from.0b711b5) for the banner.
enterViewAs redirects on success, so any returned value is a failure; the banner now surfaces
it inline instead of reading as a dead control in front of the room.
Accepted, not fixed: the sidebar switcher discards the same result. That is pre-existing
behaviour on the non-demo path, and adding error UI inside a sidebar dropdown is a different
change from this one; recorded here rather than scoped in.tenant.isDemo,
written only by the flag-demo-tenant migration from the CI-only DEMO_TENANT_CLERK_ORG_ID.
Before the feature is used in front of a client, confirm (a) that migration ran against
production with the variable set, and (b) each persona user has signed in at least once (that is
what lazily provisions the app-user row the pin reads).listTenantDirectory filters isDeleted but
not isActive. Pre-existing; the presenter path makes it more consequential. For intake, not
this run.apps/docs/app/technical/demo-environment/page.mdx (added by the already-merged #790) lists
live sign-in credentials for the production demo accounts — one shared password, the six persona
logins and eleven customer logins — committed to the repo and published to the docs site. Not
this branch's code, and fixing it properly means rotating the passwords, not just editing the
page. Recommended as its own lane.06_ship/output/investor-update.mdWho it's for: Admin — whoever presents the platform to a prospect What shipped: On the demo tenant, a presenter moves between the five storyline personas by name, from the sidebar or the banner, landing on each populated dashboard. No auth is bypassed. Why it matters: Refine the Bridge / Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners: the walkthrough shows the product, not the tooling.
Dig deeper: https://github.com/sustentus/sustentus/pull/794
06_ship/output/release.mdQuality Project (format, lint, typecheck, tests) plus the four pipeline advisories;
web preview deployed. No check ever went red on this run.apps/docs/app/technical/demo-environment/page.mdx — new "Presenter persona
switching" section under Access (one login, the pinned personas, the banner quick-switch, the
demo+<role>@… pin convention, and the first-sign-in prerequisite)apps/docs/app/business/roles/page.mdx — one paragraph under "Admin View As"
covering how the same machinery presents on a demo tenantisDemo flag, so no customer tenant sees any difference — there is nothing to announce in the
help centre. The audience is the internal presenter.#product-update on merge (2026-08-12) by ship-note.yamlROLE_HOME — implemented and traced in the diff; not demonstrated on the preview (see
below)personas: [] in the presenter shape; unit-coveredisDemo: false unchanged — the non-presenter branch is today's markup and today's data shape;
unit-coveredenterViewAs /
exitViewAs; verified in Verify (code review, security review, production readiness all
confirmed the gate byte-identical)What was not demonstrated, stated plainly. The demo tenant is a production tenant, so the
Vercel preview exercises the non-demo path unless it resolves a tenant flagged isDemo. The
signed-in, demo-tenant halves of criteria 1–3 and the auth sign-in check were left unticked in
verify.md and were not demonstrated by the agent. Jamie ticked Ready to merge and invoked
Ship with those lines outstanding; that is the recorded decision, not an omission.
Deploy precondition still open. Presenter mode does nothing until tenant.isDemo is actually
written: confirm the flag-demo-tenant migration ran against production with
DEMO_TENANT_CLERK_ORG_ID set, and that each persona account has signed in at least once (first
sign-in provisions the app-user row the pinned list reads).
listTenantDirectory filters isDeleted but not isActive, so a deactivated user is eligible to
be pinned. Pre-existing; for intake.enterViewAs result the same way the banner used to. Fixed in
the banner only; pre-existing on the non-demo path.apps/docs/app/technical/demo-environment/page.mdx publishes
live credentials for the production demo accounts — one shared password across seventeen logins,
plus the demo email inbox password — committed to the repo and served on the docs site. Predates
this run (#790). Fixing it means rotating the credentials, not just editing the page.