demo-view-as-user-listrun.md02_define/output/spec.mdThe demo tenant's View As switcher pins one account per persona by matching the address convention
demo+<role>@…, so a presenter's click always lands on the seeded storyline account rather than on
whichever filler login sorts first. demo-roster-provisioning (#855) moved every seat to
jamie.nisbet+<lastname>.<role>@sustentus.com, which matches that prefix for no account at all.
The pin therefore falls through to its "first user alphabetically" fallback for every role — exactly
the failure the pinning was built to prevent, and the switcher is now actively worse than no pin,
because it presents an arbitrary account as though it were the deliberate one. A presenter two
clicks into an empty account in front of a vendor partner is the demo failing at the moment it
matters, which is what the Q2-2026 Objective 1 work (Establish Product-Market Fit with Vendor
Partners, under Refine the bridge) rests on the demo doing well.
With a fixed roster there is no longer a "default" account per role to pin at all: all twenty seats are deliberate, each is a named person with a seeded world, and the presenter should pick the person they mean by name.
On a tenant flagged isDemo, the switcher stops offering pinned personas and bare-role emulation
and becomes a single direct-user selector: every Clerk-backed emulatable user in the tenant, listed
flat, each entry showing the person's name and their role, ordered by role then by name. The
presenter banner keeps its presenter treatment and carries the identical list, so a walkthrough
still moves person to person from any page.
Tenants without isDemo are untouched — bare-role personas plus per-role user submenus, exactly as
today.
The shape. resolveSwitcherData in apps/web/lib/view-as-switcher-data.ts stays the single
pure decision point; the change is to what it returns.
ViewAsSwitcherData.pinned is replaced by a flat users: ViewAsUserOption[]. pinnedForRole and
the demo+<role>@… convention it encodes are deleted outright — nothing reads an email address to
decide anything any more.presenter: true): users is every supplied user whose role is in
EMULATABLE_PERSONAS, sorted by role in the canonical USER_ROLES order (sales, csm, sdm, expert,
vendor, customer) and by name (localeCompare) within a role. personas is [] and groups is
[] — a demo presenter never opens a submenu and never emulates a bare role.presenter: false): unchanged — personas is every EMULATABLE_PERSONAS
entry, groups is today's per-role user grouping, and users is [].EMULATABLE_PERSONAS
already omits admin, so the demo list is 19 of the roster's 20 seats. The presenter already holds
admin — entering the admin seat would show them the view they just left.isDemo whose directory yields no emulatable
Clerk-backed user falls back to the non-demo shape, so an unprovisioned demo tenant keeps a working
switcher instead of a dead, empty menu. The condition moves from pinned.length === 0 to the
equivalent test on the new list.PERSONA_LABELS stays and is the role label rendered against each name.The two consumers move together. The sidebar switcher
(apps/web/components/view-as/view-as-switcher.tsx), the banner quick-switch (view-as-banner.tsx
via presenter-persona-items.tsx) and the layout that feeds the banner
(apps/web/app/(app)/layout.tsx, which passes viewAsData.pinned today) all read the new users
field. PresenterPersonaItems already renders name over role label, which is the required entry
shape; it now receives the full list. Nineteen entries do not fit a dropdown, so the presenter list
scrolls within the menu the way the existing per-role submenus already do.
Nothing in the emulation path changes: enterViewAs still receives { kind: "user", clerkUserId, role } from each entry, and the admin-only check, own-tenant lookup, cookie binding and audit
entries are untouched.
isDemo with the roster provisioned, an admin's View As menu shows a
single flat list of every Clerk-backed emulatable user in the tenant — 19 of the roster's 20
seats, the admin seat excluded — each entry showing the person's name and their role label.groups, no pinned entry and no bare-role entry.USER_ROLES order
(sales, csm, sdm, expert, vendor, customer), then by name within a role.isDemo renders exactly as it does today — bare-role personas for all six
emulatable roles plus per-role user submenus, and no flat user list.isDemo whose directory yields no emulatable Clerk-backed user renders the
non-demo shape rather than an empty menu.resolveSwitcherData returns null for any real role other than admin and for a signed-out
request, on either kind of tenant — a non-admin receives no switcher data at all.apps/web/lib/actions/view-as.ts or apps/web/lib/auth.ts changes.apps/web/lib/view-as-switcher-data.test.ts builds its fixtures on the roster convention and
covers every rule above; no test — and no source comment — asserts on or describes
demo+<role>@… as current behaviour.docs-sync, in this PR).demo-roster-seeder and the shipped
demo-roster-provisioning.Context budget: within band. No scope.md behind this batch — the demo-roster-logins cut was made
directly from Jamie's request of 2026-08-19 (recorded in intake/demo-roster-logins/breakdown.md),
as demo-roster-provisioning (#855) was before it, so there are no Q-n traces to carry.
03_build/output/notes.mdfeat: demo-view-as-user-list — a plain, role-labelled user list in the demo switcherapps/web/lib/view-as-switcher-data.ts: pinnedForRole and the demo+<role>@… convention it
encoded are deleted; ViewAsSwitcherData.pinned becomes a flat users. On a demo tenant the
shape is now presenter: true with every emulatable user in users, and personas/groups
both empty. The flat list is groupByRole(users).flatMap(…), so role order and name-within-role
come from the one existing ordering rule rather than a second sort. Nothing in the module reads
an email address any more.pinned.length === 0 to
the equivalent test on the flat list, so an isDemo tenant whose directory yields no emulatable
Clerk-backed user still renders the standard shape instead of a dead menu.apps/web/components/view-as/presenter-persona-items.tsx: takes users instead of personas,
and wraps its items in max-h-72 overflow-y-auto — the list is now the whole roster (19 seats),
not a pick of five, so both menus that hold it need to scroll. One scroll container here rather
than one per consumer.apps/web/components/view-as/view-as-switcher.tsx and view-as-banner.tsx: both read the new
users field. The switcher's data.groups block is unchanged and simply renders nothing on a
demo tenant, since groups is empty there.apps/web/app/(app)/layout.tsx: feeds the banner viewAsData.users in place of .pinned.apps/web/lib/view-as-switcher-data.test.ts: rewritten against the roster convention
(jamie.nisbet+<lastname>.<role>@sustentus.com), asserting the criteria rather than the
implementation. The fixtures carry an admin seat and two customers so "admin excluded" and
"role order, then name" are each distinguishable from the other rules.Nothing under apps/web/lib/actions/view-as.ts or apps/web/lib/auth.ts was touched: each entry
still sends the same { kind: "user", clerkUserId, role } the gate already validates.
EMULATABLE_PERSONAS
omits admin, and groupByRole builds only from it, so the admin seat never enters the list.groups: [] and
personas: [], and the switcher's submenu block is driven off groups.USER_ROLES order, then by name — asserted directly in
view-as-switcher-data.test.ts.PresenterPersonaItems off the same users array; the exit button is untouched.isDemo tenant is unchanged — the non-demo branch returns the same personas + groups
it always did, with users: []; covered by two tests.resolveSwitcherData returns null for a non-admin and for a signed-out request.lib/actions/ or lib/auth.ts is in the diff.demo+<role>@… as
current behaviour.apps/docs/app/technical/demo-environment/page.mdx, ~L203) — that
page is Ship's docs-sync job, in this PR.The first push failed Quality on two of the new assertions, not on the code: sorting is on the
display name the switcher renders ("Anneke Vos"), so the first name leads, and I had written the
two multi-customer expectations as though it sorted by surname. The implementation was right — it
is the pre-existing a.name.localeCompare(b.name), and "by name" is what the spec asks for — so
the fix corrected the expectations and left the behaviour alone. Writing the tests from the
criteria rather than from the implementation is what surfaced the assumption.
pnpm demo:roster run: without the roster provisioned, the directory yields no Clerk-backed user
and the tenant correctly renders the standard switcher, which looks like the feature failing
when it is the fallback working.div wrapper. Radix tracks menu items
through its collection context rather than DOM adjacency, so arrow-key navigation and typeahead
should still walk all 19 entries — worth a keyboard pass on the preview, since that is the one
thing the wrapper could plausibly break.Context budget: within band.
04_verify/output/verify.mduserService.listTenantDirectory) lives in
apps/web/lib/view-as.ts, which the spec listed under touches: but which needed no change and is
not in the diff.complexity: standard). Three findings — one fixed on branch, two
recorded below. The CI Review diff against CONVENTIONS.md check is skipped on this PR, so the
review was run here rather than triaged from CI.resolveSwitcherData is intact, enterViewAs (admin check, own-tenant lookup,
cookie binding, audit) is untouched, and the emulatable set is byte-for-byte the same population
groupByRole produced before — every user now in the flat list was already reachable through the
per-role submenus. Email exposure on the demo tenant decreases, since the submenus that rendered
addresses are gone.Agent-run (no preview credentials, so this is the honest limit of what I could demonstrate):
GET / returns 307 to
/sign-in?redirect_url=%2F, so an unauthenticated request reaches no switcher data at all
(agent).resolveSwitcherData returns null for any real role other than admin and for a signed-out
request — asserted in view-as-switcher-data.test.ts, green in the Quality run on 367a612
(agent).USER_ROLES order then by display name — asserted directly
in the unit suite, including the two-customer case (agent).isDemo tenant keeps today's shape, and a demo tenant with no emulatable Clerk-backed
user falls back to it — four assertions across both cases, green (agent).apps/web/lib/actions/view-as.ts and lib/auth.ts are
absent from the diff; traced by file, not inferred (agent).Operator-demonstrated — outstanding, this stage is not complete without them:
The demo tenant used for the smoke must have had
pnpm demo:rosterrun against it. Without the roster there are no Clerk-backed users, the fallback correctly renders the standard switcher, and that is indistinguishable from the feature not working.
Redundant scroll wrapper, invalid ARIA (presenter-persona-items.tsx) — fixed on branch
(367a612). Build added a <div className="max-h-72 overflow-y-auto"> around the items, which
nested 19 menuitems under a generic element inside Radix's role="menu". It was also
unnecessary: DropdownMenuContent in packages/ui already carries
max-h-(--radix-dropdown-menu-content-available-height) and overflow-y-auto, so the menu
already scrolled — and capped to the viewport rather than a fixed 18rem, which is the better
behaviour. Reverted to a fragment.
The demo list is the whole tenant directory, not the roster (view-as-switcher-data.ts) —
needs an owner decision; not fixed here. provisionCustomerForOrg
(apps/web/app/(app)/service-leads/create/provision-customer.ts, called from the lead-create
form) creates a real Clerk user and customer row in the current tenant during ordinary vendor use.
On the demo tenant those accounts join the presenter's list, alphabetically interleaved with the
ten roster customers and visually identical to them — a presenter can pick one and land in an
account with no seeded world.
This is not a regression and not a defect against the spec: the implementation does exactly
what the spec says ("every Clerk-backed emulatable user in the tenant"), and on main today the
dead pin already falls through to "first user alphabetically", which is strictly worse. But it
does mean the scope's premise — "every seat is deliberate" — holds only for a tenant that has been
reset and re-provisioned, not for one that has been demoed against. Fixing it needs a way to tell
a roster seat from a provisioned one, and the obvious candidate (matching the address convention)
is the exact mechanism this scope deleted. That is a Define-level decision, not a Build one, so I
have left the behaviour as specified rather than invent a rule.
Deactivated users appear in the list (listTenantDirectory) — recorded, not fixed. The query
filters isDeleted: { $ne: true } but selects isActive without filtering on it, so a deactivated
Clerk-backed user is offered for emulation. This is pre-existing and unchanged — it was equally
true of the per-role submenus before this PR, and remains true of them on non-demo tenants, where
criterion 5 requires today's behaviour exactly. Adding an isActive filter would change behaviour
the spec did not ask to change, on both tenant kinds.
apps/docs demo-environment page still describes pinning as current behaviour and the pin
fallback as a known gap (~L203). Spec criterion 10, deliberately Ship's docs-sync job in this
same PR — the one acceptance criterion still unticked.
Context budget: within band. One file outside the Inputs table was read to check a review claim —
provision-customer.ts — because the finding could not be confirmed or dismissed without it.
05_ship/output/changelog.mdOn a demo tenant, View As now shows everyone in one plain list — each person's name with their role beside it, ordered by role and then by name. You pick the person you mean, rather than a role that stands in for them, and there is no submenu to open first. The banner you get while presenting carries the same list, so you can move from one person to the next from any page.
Before this, the switcher pinned a single account per role and kept the rest behind submenus. The pin recognised accounts by an older address format that the fixed demo roster replaced, so it had quietly stopped finding the account it was meant to — and fell back to whichever account came first alphabetically. That could put you two clicks into an empty account with a client watching.
Nothing about access changed. View As is still admin-only, still limited to your own organisation, and entering and leaving a session is still recorded exactly as before. Tenants that aren't demo tenants are untouched.
05_ship/output/investor-update.mdWho it's for: Admins running a demo What shipped: On a demo tenant, View As is now one list of everyone — name and role, no submenus. Why it matters: The old pin no longer resolved, so a walkthrough could open an empty account in front of a prospect. Serves Refine the bridge → Q2-2026 Objective 1, Establish Product-Market Fit with Vendor Partners.
Permissions are unchanged.
Dig deeper: https://github.com/sustentus/sustentus/pull/861 · https://help.sustentus.com/changelog/2026-08-19-demo-view-as-user-list
05_ship/output/release.mdQuality Project success (format, lint, typecheck, 107 tests), the three
advisory pipeline checks success, Review diff against CONVENTIONS.md skipped by its own path
filter. Two red rounds earlier in the run, both mine and both fixed on branch: two test
assertions that ordered by surname when the sort is on the display name (60e3135), and the
invalid-ARIA scroll wrapper Verify's code review caught (367a612).apps/docs/app/technical/demo-environment — the "Presenter persona switching"
section now describes the flat list, its ordering, the admin-seat exclusion and the
empty-directory fallback; the paragraph carrying the demo+<role>@… pin and its alphabetical
fallback as a known gap is deleted, because this change closes it.apps/docs/app/business/roles — the Admin View As section's demo-tenant paragraph
described the switcher as pinning storyline personas; it now describes the by-name list.apps/help/app/changelog/2026-08-19-demo-view-as-user-list
(personas [admin], following the 2026-08-17-demo-reset-audit-and-cron precedent that demo
changes are announced to admins) and the ship note, 56 words against the 60-word cap, both
Dig deeper links filled with real URLs.ship-note.yaml fires on the squash-merge and emails the note to
#product-update. Not sent at the time this file was written.EMULATABLE_PERSONAS omits admin); unit-assertedgroups: [] and personas: [] on demo;
unit-assertedUSER_ROLES order, then by name — unit-asserted, including the
two-customer casePresenterPersonaItems off one users array; traced in the diffisDemo tenant is unchanged — unit-asserted, two casesresolveSwitcherData returns null for a non-admin and a signed-out request — unit-assertedlib/actions/view-as.ts and lib/auth.ts absent from
the diffdemo+<role>@…
as current behaviourThe operator DoD smoke was never performed. Verify recorded four signed-in checks as
outstanding — the 19-seat list on a provisioned demo tenant, entering a person and returning via the
banner, a keyboard pass over the list, and persona sign-in — and they are still outstanding at
merge. Jamie called ship with them open and ticked Ready to merge, which is his call to make;
this line records what that authorisation covers so the record is not read as "smoked and passed".
Everything ticked above rests on the unit suite and on tracing the diff, which is what an agent
without preview credentials can honestly demonstrate. Nothing in this change has been seen
rendering in a browser.
Two findings from Verify ride to main unresolved, both deliberately:
listTenantDirectory does not filter isActive, so deactivated users are emulatable.
Pre-existing, unchanged, and equally true of the non-demo submenus.Context budget: within band.