demo-roster-loginsbreakdown.mdscope.md behind this batch.The demo tenant's seeded people are props. The seeder writes them straight through the models with
no clerkUserId, and that one missing field is what makes them unreachable: the View As switcher
filters on it, enterViewAs resolves its target by it, and a real session matches a row by it. So
fourteen of the storyline's twenty people cannot be viewed as and cannot be signed into. Jamie's
answer is to stop generating people at all: he creates a fixed set of real Clerk accounts on
jamie.nisbet+<lastname>.<role>@sustentus.com, signs in as them once, and the seeder attaches to
that roster rather than inventing it. The non-user data can be wiped from both preview and
production, so there is no migration to write — the existing Reset demo data button already
purges everything tenant-scoped, and its existing user rule ("no clerkUserId → purge") deletes the
old fake people on its own.
Two consequences settled in the same session. The roster must exist in both Clerk instances so
preview and production behave identically — which means the seeder's email → role → stand-in
fallback chain goes, and with it the preview behaviour that wove whoever was signed in into the
world. And the View As switcher's pinned-persona convention (demo+<role>@…) matches nothing under
the new addresses, so pinning is dropped for a plain user list that marks name and role.
Technical demo environment (technical/demo-environment) and the preview database runbook
(technical/development/preview-database). Touches packages/services/src/db/services/demo-data/,
packages/services/scripts/, and the View As switcher in apps/web/lib/.
demo-view-as-user-list and demo-roster-seeder are independent of each other and both depend only
on demo-roster-provisioning. The linear order puts the switcher first on the impact tie-break:
stub 1 invalidates the demo+<role>@… pin convention, so a demo run between stubs 1 and 2 falls
back to "first user alphabetically" per role — the exact failure the pinning was built to prevent.
storyline/saas-vendor.ts
are unchanged apart from the email addresses. demo-data-quality owns that ground.demo-public-access owns those, and nothing here
changes the presenter-led access model.clerkUserId → purge" rule already does the right
thing for both the old fake rows and the new roster.Context budget: within band.
_done/demo-roster-provisioning.mdThe storyline's twenty people are fictional addresses on the reserved .example domain that exist
nowhere but Mongo, so fourteen of them cannot be viewed as and none of them can be signed into. The
six that can are hand-made Clerk accounts, stood up by a runbook that says "create the user, then
sign in once as them" — twenty times, and twice over, because preview and production are separate
Clerk instances. Nothing in the platform turns a Clerk account into an app-user row except a
sign-in, so forty manual sign-ins is the current price of a working demo, and a typo'd address fails
silently as an unmatched persona rather than as an error.
Make the roster an explicit, derived thing and give it one command.
The storyline's addresses become the agreed roster convention,
jamie.nisbet+<lastname>.<role>@sustentus.com — real, deliverable, and identical in both Clerk
instances. Names are unchanged; only addresses move. A pure rosterOf(storyline) helper derives the
twenty { email, role, label } entries from the storyline's team, accounts and experts, so
there is one source of truth and no second list to drift.
A pnpm demo:roster script joins db:seed and db:migrate in packages/services/scripts/. Against
whichever Clerk instance CLERK_SECRET_KEY points at, it resolves-or-creates each roster user,
ensures organisation membership with the mapped org:<role>, and mirrors the app-user row — so the
sign-in is no longer what provisions anyone. A --check mode reports the roster's state without
writing. The Clerk organisation comes from tenant.clerkOrgId, so no new environment variable is
introduced.
| Name | Clerk org role | |
|---|---|---|
| Alex Rendell | jamie.nisbet+rendell.admin@sustentus.com |
org:admin |
| Sofia Almeida | jamie.nisbet+almeida.csm@sustentus.com |
org:csm |
| Ben Hartley | jamie.nisbet+hartley.sdm@sustentus.com |
org:sdm |
| Orrery Software | jamie.nisbet+orrery.vendor@sustentus.com |
org:vendor |
| Anneke Vos | jamie.nisbet+vos.customer@sustentus.com |
org:customer |
| Elsa Håkansson | jamie.nisbet+hakansson.customer@sustentus.com |
org:customer |
| Ruth Delaney | jamie.nisbet+delaney.customer@sustentus.com |
org:customer |
| Jonas Brandt | jamie.nisbet+brandt.customer@sustentus.com |
org:customer |
| Siobhan Kerr | jamie.nisbet+kerr.customer@sustentus.com |
org:customer |
| Pieter Claes | jamie.nisbet+claes.customer@sustentus.com |
org:customer |
| Mads Kjeldsen | jamie.nisbet+kjeldsen.customer@sustentus.com |
org:customer |
| Camille Renard | jamie.nisbet+renard.customer@sustentus.com |
org:customer |
| Dev Raghunathan | jamie.nisbet+raghunathan.customer@sustentus.com |
org:customer |
| Elaine Merrivale | jamie.nisbet+merrivale.customer@sustentus.com |
org:customer |
| Elin Bergqvist | jamie.nisbet+bergqvist.expert@sustentus.com |
org:expert |
| Chidi Okafor | jamie.nisbet+okafor.expert@sustentus.com |
org:expert |
| Anders Lindberg | jamie.nisbet+lindberg.expert@sustentus.com |
org:expert |
| Margot Duval | jamie.nisbet+duval.expert@sustentus.com |
org:expert |
| Stefan Reinhart | jamie.nisbet+reinhart.expert@sustentus.com |
org:expert |
| Nina Whitlock | jamie.nisbet+whitlock.expert@sustentus.com |
org:expert |
The admin seat is a dedicated roster account rather than one of the three real admins, so the world does not depend on which real person happens to be signed in and preview matches production exactly.
rosterOf(activeStoryline) returns the twenty entries above, each with the role its seat
implies, and is covered by a unit test that fails if a storyline person is added without an
address.pnpm demo:roster against a demo tenant leaves twenty app-user rows, each carrying a
clerkUserId and the role its Clerk organisation membership maps to — with no sign-in
performed.clerkUserId is adopted — the existing row
is stamped, not duplicated — so the script is safe whether it runs before or after a seed.pnpm demo:roster --check writes nothing and names exactly what is missing, mis-roled or
unrecognised; on a complete roster it reports clean.isDemo: true, and refuses to run with no
CLERK_SECRET_KEY rather than half-provisioning.demo-roster-seeder.demo-view-as-user-list.demo+…@apoyar.eu accounts from Clerk. Their app-user rows survive the
reset (they carry a clerkUserId) and will show in the directory until removed by hand; that is
an operational tidy-up, not code.The end-to-end pattern already exists and should be reused rather than reinvented:
apps/web/app/(app)/service-leads/create/provision-customer.ts does
users.getUserList → users.createUser({ skipPasswordRequirement, skipLegalChecks }) →
organizations.createOrganizationMembership({ role: 'org:<role>' }) →
userService.ensureProvisionedFromClerk, including the diagnostics for the two ways Clerk rejects
this (no passwordless method enabled; the org role key missing). packages/services already carries
@clerk/backend as a direct dependency, and
packages/services/src/permissions/clerk-mirror.ts is the house pattern for a lazily-constructed
createClerkClient that no-ops without a secret key. loadRepoEnv (scripts/load-repo-env.ts) is
how the sibling scripts read .env.
The adoption case in the acceptance criteria is the one real trap.
ensureProvisionedFromClerk catches duplicate-key errors and returns silently, so a plain create
against an address the seeder already owns leaves the account permanently locked out:
resolveAppUserForTenant then finds no row by Clerk id, provisioning no-ops on the unique index
tenantId_email_role_unique, and the request returns "User not found in this organisation". Stamping
the existing row is what avoids it; the partial unique index
clerkUserId_tenantId_role_unique makes the stamp idempotent.
Two Clerk-side prerequisites, in both instances, belong in the runbook this PR updates:
org:csm, org:sdm, org:vendor and org:expert must exist as organisation roles (org:customer
already does — hence CLERK_ORG_CUSTOMER_ROLE), and the demo organisation's maximum membership
count must exceed twenty. Dormant accounts are not billed — Clerk charges monthly actives, and View
As never signs anyone in.
Clerk owns identity from here: firstname, lastname, email and avatar are mirrored onto every
row by the user.updated webhook (refreshProfileFromClerk), so the names must be set in Clerk to
the storyline's names — including the vendor seat, whose "first name" is the company, Orrery Software. touches: packages/services/src/db/services/demo-data/storyline/,
packages/services/scripts/, packages/services/package.json,
apps/docs/app/technical/{demo-environment,development/preview-database}/.
_done/demo-roster-seeder.mdThe seeder is a user factory. It creates the storyline's people, revives soft-deleted rows through
the unique index when a create collides, and invents a "Demo CSM" stand-in when a seat has nobody in
it — a fallback chain built so preview could seed without the production personas, and the reason
preview and production tell the story through different people. With a roster in place none of that
is wanted, and one part of it actively breaks the world: a person's companyName, country, industry
and position are written only in the create factory, so a user who already exists is matched by
email and left bare. Every roster member already exists, so every seeded customer would show without
its company and every expert without its position. Separately, an expert who signs in is pinned to
/expert/onboarding by the middleware until they have a position, a bio, skills, products and at
least one approved piece of evidence — and the storyline gives approved evidence to one expert of
six — so the personas the roster exists to make signable still cannot be demonstrated.
The seeder stops owning people and owns only the world. Two halves of one behaviour:
Attach strictly. Every seat resolves by email against the roster and nothing else. A seat with no matching Clerk-backed user aborts the run — naming every missing address, before the first write, so a failed reset never leaves a tenant half-populated. The role-match fallback, the invented stand-ins and the soft-delete revival path all go, and preview runs the identical path to production.
Enrich every run. The fields the storyline owns are written onto the matched people on every run,
idempotently, rather than only at creation: a customer's company, country, industry and vendor
attribution; an expert's position, bio, skills, products, languages and availability. Identity is
never written — firstname, lastname, email and avatar belong to Clerk, which re-mirrors them
on the person's next sign-in. The storyline's expert entries gain the bio, skills and products this
needs, and every expert gains at least one approved piece of evidence, with the existing draft,
submitted and rejected pieces kept alongside so the review queue still shows all four states.
The result is that clicking Reset demo data is the whole operation: the purge removes the old
generated people (they carry no clerkUserId) and the world, and the re-seed rebuilds the world
around the roster, who survive because they do.
/expert, not /expert/onboarding./admin/settings/demo-data.firstname, lastname, email or avatar is written by a run.clerkUserId → purge" rule already deletes the
superseded generated people and keeps the roster.demo-view-as-user-list.demo-roster-provisioning.manager, employment, isHierarchyTop). A stable roster makes it
possible for the first time; it is a follow-on, not this feature.The enrichment half is an existing pattern made into a rule rather than a new idea:
demo-data/index.ts already re-asserts languages and isAvailable on matched experts, and the
vendor attribution on matched customers, with comments explaining exactly this problem — that a
Clerk-backed person survives every reset and so never re-enters the create factory. The change is to
widen that pass to every storyline-owned field and delete the create-only duplication.
What comes out is larger than what goes in: resolveUser, resolvePersona, findByRole,
createUser with its duplicate-key revival branch, claimedPersonaIds, and the
customersCreated / expertsCreated counters on DemoDataResult. persona-resolution.ts loses
standInName and its fallback ordering; what survives is the per-seat reporting, which stays
valuable — a run should still say which person filled which seat.
The abort must come before the first write. The existing code already resolves people ahead of everything else for this reason (a failure mid-run leaves catalogue rows behind with no transaction to roll back), so the assertion belongs at that same point.
The expert gate is deriveExpertOnboardingStatus in apps/web/lib/expert-onboarding.ts, enforced
by apps/web/proxy.ts — position, bio, skills, products and approved evidence, all five. Skills and
products must resolve to the catalogue the same run builds, so enrichment has to follow the
catalogue upserts rather than the people resolution.
This PR is what makes the preview parity real, and it removes the behaviour
technical/development/preview-database documents under "Whose faces you see" — preview no longer
seeds around whoever is signed in. Confirmed as intended; the page has to change with the code.
touches: packages/services/src/db/services/demo-data/,
packages/services/src/db/services/demo-data/storyline/,
apps/docs/app/technical/{demo-environment,development/preview-database}/.
_done/demo-view-as-user-list.mdOn a demo tenant the switcher pins one account per persona by a naming convention — the storyline
address demo+<role>@… — and falls back to the first user in the group when nothing matches. The
roster convention is jamie.nisbet+<lastname>.<role>@sustentus.com, which matches that prefix for
no seat at all, so every persona would pin to whichever account sorts first alphabetically. That is
precisely the failure the pinning exists to prevent: a presenter two clicks into an account with no
seeded world in front of a client. With a roster there is no longer a "default" account per role to
pin anyway — every seat is deliberate, and the presenter should choose a person by name.
Drop pinning and bare-role emulation on the demo tenant. The switcher becomes one direct-user selector listing every Clerk-backed user in the tenant, each entry showing the person's name and their role, ordered by role and then by name, so a presenter picks a person rather than decoding a convention. The presenter banner keeps its presenter treatment and carries the same labelled list, so a walkthrough still moves persona to persona from any page.
Tenants without the isDemo flag are untouched: they keep today's shape — bare-role personas plus
per-role user submenus.
isDemo renders exactly as it does today — bare-role personas and per-role
submenus, unchanged.demo-roster-seeder and demo-roster-provisioning.The shaping is already pure and unit-tested in apps/web/lib/view-as-switcher-data.ts
(resolveSwitcherData), with the async wrapper in apps/web/lib/view-as.ts supplying the real role,
the tenant flag and the directory — so this is a change to a pure function plus its test, not to any
authorisation path. pinnedForRole and the pinned field go; PERSONA_LABELS stays and becomes the
role label rendered against each name. view-as-switcher-data.test.ts currently builds its fixtures
on the demo+<role>@… convention and needs rewriting against the roster convention.
The directory read (userService.listTenantDirectory) already selects clerkUserId, and the wrapper
already filters on it — a user without one cannot be emulated, because
resolveAppUserForTenant resolves the emulated principal by Clerk id. With the roster in place every
demo user has one, so the list is the full cast.
Both consumers of the switcher shape have to move together: the sidebar switcher
(apps/web/components/view-as/view-as-switcher.tsx) and the banner's quick-switch
(view-as-banner.tsx, presenter-persona-items.tsx) both read pinned. touches:
apps/web/lib/view-as-switcher-data.ts, apps/web/lib/view-as.ts, apps/web/components/view-as/,
apps/docs/app/technical/demo-environment/.