Skip to Content

← All archived intake epics

Intake: demo-roster-logins

breakdown.md

Breakdown: The demo tenant's people become a fixed roster of real Clerk logins

  • scope-slug: demo-roster-logins · source: Jamie's request (2026-08-19) and the codebase research answered in that session; no scope.md behind this batch.
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • personas: Admin (the presenter's own role) — CSM, SDM, Expert, Vendor and Customer are the seats the roster fills

What I understood

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.

Where it sits

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

Build order

  1. demo-roster-provisioning — define the roster and provision it into Clerk and Mongo with one command per environment — depends-on: none
  2. demo-view-as-user-list — replace the demo switcher's pinned personas with a labelled user list — depends-on: demo-roster-provisioning
  3. demo-roster-seeder — the seeder attaches strictly to the roster and writes each person's storyline attributes — depends-on: demo-roster-provisioning

Parallelizable

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.

Out of scope (whole scope)

  • Storyline content — the accounts, engagements, values and copy in storyline/saas-vendor.ts are unchanged apart from the email addresses. demo-data-quality owns that ground.
  • Automatic sign-in and the public demo host — demo-public-access owns those, and nothing here changes the presenter-led access model.
  • The reset's purge/keep policy. The existing "no clerkUserId → purge" rule already does the right thing for both the old fake rows and the new roster.
  • Multi-role principals. Every roster member holds exactly one role.
  • Any change to the emulation gate, its own-tenant check, or the View As audit trail.

Context budget: within band.

_done/demo-roster-provisioning.md

Stub: Define the demo roster and provision it in one command

  • feature-slug: demo-roster-provisioning
  • scope: demo-roster-logins
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 1 of 3

Problem

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

Proposed change

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

Acceptance criteria (rough)

  • 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.
  • Re-running writes nothing and reports every entry as already present.
  • An address already held by a seeded row with no 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.
  • The script refuses a tenant not flagged isDemo: true, and refuses to run with no CLERK_SECRET_KEY rather than half-provisioning.
  • Running it against the preview instance and the production instance yields the same twenty seats, differing only in Clerk user ids.

Out of scope (this feature)

  • Setting or rotating the accounts' passwords — that is done in Clerk.
  • Any change to how the seeder resolves or enriches people — that is demo-roster-seeder.
  • The View As switcher — that is demo-view-as-user-list.
  • Deleting the superseded 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.

Notes for Define

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

Stub: The seeder attaches to the roster instead of inventing people

  • feature-slug: demo-roster-seeder
  • scope: demo-roster-logins
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: demo-roster-provisioning
  • sequence: 3 of 3

Problem

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

Proposed change

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.

Acceptance criteria (rough)

  • After a reset, every seeded customer shows its company name and country, and is attributed to the vendor; every expert shows its position, bio, skills and products.
  • Signing in as any roster expert lands on /expert, not /expert/onboarding.
  • Every expert holds at least one approved piece of evidence, and the review queue still shows draft, submitted, approved and rejected examples across the cast.
  • A run whose roster is incomplete fails before writing anything, names every missing address, and the failure is visible at /admin/settings/demo-data.
  • No run creates a user under any circumstances, and no stand-in name appears anywhere in a seeded tenant.
  • A second reset produces the same world as the first — same people, same attributes, no duplicates.
  • A preview reset and a production reset produce the same cast in the same seats.
  • Nobody's firstname, lastname, email or avatar is written by a run.

Out of scope (this feature)

  • Storyline content beyond the new expert fields — companies, engagements, values, copy and CSAT scores are untouched.
  • The reset's purge/keep policy. The existing "no clerkUserId → purge" rule already deletes the superseded generated people and keeps the roster.
  • The View As switcher — demo-view-as-user-list.
  • Provisioning the accounts — demo-roster-provisioning.
  • Seeding the reporting line (manager, employment, isHierarchyTop). A stable roster makes it possible for the first time; it is a follow-on, not this feature.

Notes for Define

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

Stub: A plain, role-labelled user list in the demo View As switcher

  • feature-slug: demo-view-as-user-list
  • scope: demo-roster-logins
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: demo-roster-provisioning
  • sequence: 2 of 3

Problem

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

Proposed change

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.

Acceptance criteria (rough)

  • On the demo tenant an admin sees every Clerk-backed user in a single list, each showing name and role, and can enter any of them without opening a submenu.
  • The list is ordered deterministically by role then name, and carries no pinned entries and no bare-role entries.
  • The presenter banner offers the same list plus the way back to the admin's own view.
  • A tenant without isDemo renders exactly as it does today — bare-role personas and per-role submenus, unchanged.
  • A non-admin receives no switcher data at all, on either kind of tenant.
  • Entering and leaving an emulation still writes its audit entries, unchanged.

Out of scope (this feature)

  • The emulation gate itself — the admin-only check, the same-org check, the cookie's binding to the emulating admin, and the audit trail all stay exactly as they are.
  • Emulating anyone outside the current tenant, or any change to what an emulated session may do.
  • The seeder and the roster — those are demo-roster-seeder and demo-roster-provisioning.

Notes for Define

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