Skip to Content

← All archived runs

Run: demo-presenter-switching

run.md

Run: demo-presenter-switching

  • branch: claude/demo-presenter-switching-y2uuhm
  • pr: #794

03_define/output/spec.md

Spec: Presenter persona switching — demo-grade view-as, admin gate untouched

  • slug: demo-presenter-switching
  • personas: Admin
  • touches: apps/web/components/view-as, apps/web/lib/view-as.ts, apps/web/app/(app)/layout.tsx
  • complexity: standard
  • demo: none

Problem

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

Proposed change

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.

  1. Curated quick-switch on the demo tenant. When the resolved tenant carries 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.
  2. A quick-switch in the banner while emulating. On the demo tenant the persistent banner — already rendered on every page of the emulated experience — carries the same pinned list plus "Back to admin", so persona → persona is two clicks from anywhere and the presenter never opens admin chrome inside the client-facing view. The admin's own view (no banner) keeps the curated sidebar dropdown as the way in.
  3. A demo-appropriate banner. On 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.
  4. Clean landings. Unchanged and depended upon: enterViewAs redirects to the emulated role's ROLE_HOME, so each switch lands on that persona's dashboard with the seeded world populated.
  5. Gate untouched. Admin-only and same-org authority, the cookie binding to the creating admin, the three enforcement points, and the enter / exit audit records all keep exactly today's semantics. Tenants with isDemo: false render and behave exactly as they do now.

Acceptance criteria

  • On the demo tenant, an admin presenter reaches any of the five emulatable storyline personas by name in ≤2 clicks — both from their own admin view (sidebar switcher) and from inside any emulated persona (banner quick-switch) — landing on that persona's ROLE_HOME with the seeded world populated.
  • On the demo tenant the switcher offers no bare-role emulation: every switching affordance is user-emulation, so no walkthrough click produces an empty own-scoped view.
  • On the demo tenant the banner renders in the neutral presenter treatment, names the emulated person and role, and its "Back to admin" control returns the presenter to the admin home.
  • On a tenant with 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.
  • Every switch still writes the view-as audit trail through enterViewAs / exitViewAs, and a non-admin — or a forged/cross-org cookie — still cannot enter emulation anywhere.
  • The pinned-list derivation is unit-covered: presenter list on 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.

Out of scope

  • The no-login public launcher — explicitly not this batch (cut decision 2026-08-11); no bypass is added to proxy.ts or the route policies.
  • Any weakening of resolveViewAs, the admin gate, the same-org check, or the cookie binding.
  • Storyline content — names and numbers come from demo-seed-storyline (stub 2). This run reads whatever users the demo tenant has; it does not create, rename, or seed any.
  • Emulating the admin persona. The presenter is the demo admin, so "Back to admin" is an exit, not an emulation — matching today's exclusion of admin from emulatable personas.
  • Changing the audit contract. A direct persona → persona switch keeps today's semantics (an enter record for the new emulation); no new exit record is synthesised.
  • Any change to enterViewAs / exitViewAs, resolveViewAs, or the tenant model — this run adds no server action and no new authority.

Open questions

  • none

04_build/output/notes.md

Build notes: demo-presenter-switching

  • commits: feat: demo-presenter-switching — presenter persona quick-switch over view-as
  • demo: none (Design was skipped for this batch — no apps/demo prototype to port)

What changed

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

Acceptance criteria status

  • Five personas in ≤2 clicks from both surfaces — sidebar: open switcher → pinned persona; banner: "Switch persona" → persona. Both call enterViewAs, which redirects to the emulated role's ROLE_HOME. Populated dashboards depend on the seeded world, not on this diff.
  • No bare-role emulation on the demo tenant — personas: [] in the presenter shape, and the switcher renders the pinned list in that branch.
  • Neutral presenter banner naming person and role, with "Back to admin" calling 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.
  • Audit trail intact — every switch still routes through the untouched enterViewAs / exitViewAs, and authority still resolves through resolveViewAs.
  • Derivation unit-covered — non-admin and signed-out get 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).

Notes for Verify

  • The demo tenant is a production tenant. The Vercel preview only shows presenter mode if the preview environment resolves a tenant with 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.
  • Worth a close look: 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.
  • Persona ordering follows 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).
  • Storyline names are whatever the seeded users currently carry — demo-seed-storyline (stub 2, not yet built) renames them without touching this code.

05_verify/output/verify.md

Verify: demo-presenter-switching

  • production-readiness: run — verdict "safe to merge", no blockers. Two ⚠️ fixed on branch (findings 1–2 below); one deploy precondition to confirm before the feature is used (below). Env ✅ (no new process.env reads, nothing owed to turbo.jsonglobalEnv), database ✅ (no schema/index/migration; the one read is the already tenant-scoped listTenantDirectory), auth ✅ (gate byte-identical), deploy ✅.
  • code-review: medium (spec complexity: standard) · 3 findings, all 3 fixed on branch in 0b711b5.
  • security-review: run — no HIGH or MEDIUM findings. The admin gate moved into 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.
  • playwright: TODO — manual DoD smoke performed instead

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

Preview: https://web-git-claude-demo-presenter-switching-y2uuhm-sustentus.vercel.app (deployment CUv71nxSkGvzbVNt3kEswp9wk6tD, Ready on 0b711b5)

  • Preview responds — / 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)
  • AC 5 — audit trail and gate intact: every switch still routes through the unchanged 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)
  • AC 6 — derivation unit-covered: non-admin and signed-out → 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)
  • AC 1 — five personas in ≤2 clicks from sidebar and banner, each landing on a populated ROLE_HOME (operator — needs the demo tenant; see the caveat below)
  • AC 2 — no bare-role emulation offered on the demo tenant (operator)
  • AC 3 — neutral presenter banner names person + role; "Back to admin" returns to admin home (operator)
  • AC 4 — a tenant with isDemo: false is unchanged: amber banner, "Exit View as", bare-role personas, no quick-switch (operator — this is what the preview exercises by default)
  • auth: admin persona signs in and reaches the dashboard (operator)
  • payments: not touched by this diff (agent)
  • notifications: none expected — this diff adds no notification and touches no notifier (agent)

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.

Findings & cleanup

  • Pinned persona was the alphabetically-first user, not the storyline account — fixed on branch (0b711b5). The demo environment documents eleven customer logins (demo-customer plus customer-1customer-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.
  • Empty presenter menu with the bare-role floor removed — fixed on branch (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.
  • A failed persona switch was silently swallowed — fixed on branch (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.
  • Deploy precondition (not a code finding). Presenter mode hangs entirely on 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).
  • Deactivated users are eligible to be pinnedlistTenantDirectory filters isDeleted but not isActive. Pre-existing; the presenter path makes it more consequential. For intake, not this run.
  • Out of scope, but security-relevant: plaintext demo credentials in the docs. 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.md

Client demos now switch persona in two clicks

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

Ship: demo-presenter-switching

  • pr: #794 · merged: yes — 2026-08-12
  • CI: green — Quality Project (format, lint, typecheck, tests) plus the four pipeline advisories; web preview deployed. No check ever went red on this run.
  • technical docs: 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)
  • business docs: apps/docs/app/business/roles/page.mdx — one paragraph under "Admin View As" covering how the same machinery presents on a demo tenant
  • release notes: ship-note-only. No end-user note: every change is gated on the tenant's isDemo flag, so no customer tenant sees any difference — there is nothing to announce in the help centre. The audience is the internal presenter.
  • sent: ship note queued to #product-update on merge (2026-08-12) by ship-note.yaml

Acceptance check (vs spec)

  • Five personas in ≤2 clicks from the sidebar and the banner, landing on the persona's ROLE_HOME — implemented and traced in the diff; not demonstrated on the preview (see below)
  • No bare-role emulation on the demo tenant — personas: [] in the presenter shape; unit-covered
  • Neutral presenter banner naming person and role, with "Back to admin" — implemented; not demonstrated on the preview
  • isDemo: false unchanged — the non-presenter branch is today's markup and today's data shape; unit-covered
  • Audit trail and gate intact — every switch routes through the unchanged enterViewAs / exitViewAs; verified in Verify (code review, security review, production readiness all confirmed the gate byte-identical)
  • Derivation unit-covered — 10 tests, green in CI

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

Carried forward (not this run)

  • listTenantDirectory filters isDeleted but not isActive, so a deactivated user is eligible to be pinned. Pre-existing; for intake.
  • The sidebar switcher discards the enterViewAs result the same way the banner used to. Fixed in the banner only; pre-existing on the non-demo path.
  • Security, needs its own lane: 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.