demo-roster-provisioningrun.md02_define/output/spec.mdThe demo tenant's twenty storyline people are fictional addresses on the reserved .example
domain that exist nowhere but Mongo. Nothing in the platform turns a Clerk account into an
app-user row except a sign-in, so fourteen of the twenty cannot be viewed as and none 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. 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. The
demo is how we put the product in front of vendor partners, so a demo that is expensive to stand
up and silently wrong when it drifts directly costs us the Refine the bridge initiative's
Q2-2026 Objective 1 (Establish Product-Market Fit with Vendor Partners).
Make the roster an explicit, derived thing and give it one command.
The roster convention. The storyline's twenty addresses become
jamie.nisbet+<lastname>.<role>@sustentus.com — real, deliverable, and identical in both Clerk
instances. Names are unchanged; only addresses move. The four internal seats gain names, which
StorylineTeam does not carry today:
| Name | Role | Clerk org role | |
|---|---|---|---|
| Alex Rendell | jamie.nisbet+rendell.admin@sustentus.com |
admin |
org:admin |
| Sofia Almeida | jamie.nisbet+almeida.csm@sustentus.com |
csm |
org:csm |
| Ben Hartley | jamie.nisbet+hartley.sdm@sustentus.com |
sdm |
org:sdm |
| Orrery Software | jamie.nisbet+orrery.vendor@sustentus.com |
vendor |
org:vendor |
| Anneke Vos | jamie.nisbet+vos.customer@sustentus.com |
customer |
org:customer |
| Elsa Håkansson | jamie.nisbet+hakansson.customer@sustentus.com |
customer |
org:customer |
| Ruth Delaney | jamie.nisbet+delaney.customer@sustentus.com |
customer |
org:customer |
| Jonas Brandt | jamie.nisbet+brandt.customer@sustentus.com |
customer |
org:customer |
| Siobhan Kerr | jamie.nisbet+kerr.customer@sustentus.com |
customer |
org:customer |
| Pieter Claes | jamie.nisbet+claes.customer@sustentus.com |
customer |
org:customer |
| Mads Kjeldsen | jamie.nisbet+kjeldsen.customer@sustentus.com |
customer |
org:customer |
| Camille Renard | jamie.nisbet+renard.customer@sustentus.com |
customer |
org:customer |
| Dev Raghunathan | jamie.nisbet+raghunathan.customer@sustentus.com |
customer |
org:customer |
| Elaine Merrivale | jamie.nisbet+merrivale.customer@sustentus.com |
customer |
org:customer |
| Elin Bergqvist | jamie.nisbet+bergqvist.expert@sustentus.com |
expert |
org:expert |
| Chidi Okafor | jamie.nisbet+okafor.expert@sustentus.com |
expert |
org:expert |
| Anders Lindberg | jamie.nisbet+lindberg.expert@sustentus.com |
expert |
org:expert |
| Margot Duval | jamie.nisbet+duval.expert@sustentus.com |
expert |
org:expert |
| Stefan Reinhart | jamie.nisbet+reinhart.expert@sustentus.com |
expert |
org:expert |
| Nina Whitlock | jamie.nisbet+whitlock.expert@sustentus.com |
expert |
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.
The derivation. A pure rosterOf(storyline) helper — new module
packages/services/src/db/services/demo-data/storyline/roster.ts, re-exported from
storyline/index.ts — returns the twenty { email, role, label } entries from the storyline's
team, accounts and experts. One source of truth, no second list to drift. label is the
person's display name ("Anneke Vos"; the vendor seat's is the company, "Orrery Software").
To let the four internal seats derive rather than be hardcoded, StorylineTeam gains a
firstname/lastname pair per seat alongside each existing *Email field. The spare Calderon
world (storyline/default.ts) gains the four names so it still satisfies the type; its addresses
are left untouched — it is not the active world and nothing provisions from it.
The command. packages/services/scripts/demo-roster.ts, wired as demo:roster in
packages/services/package.json beside db:seed and db:migrate, run the same way
(pnpm demo:roster from packages/services, or
pnpm --filter @sustentus/services demo:roster), reading .env through the existing
loadRepoEnv. Against whichever Clerk instance CLERK_SECRET_KEY points at, for each roster
entry it:
users.getUserList) or creates one
(users.createUser({ skipPasswordRequirement: true, skipLegalChecks: true })), setting
firstName/lastName from the roster label — Clerk owns identity, and the user.updated
webhook mirrors those names back onto the row;org:<role> in the tenant's clerkOrgId
(organizations.createOrganizationMembership), treating an already-existing membership as
satisfied;(tenantId, email, role) by stamping its clerkUserId when the row has none, and otherwise
calling userService.ensureProvisionedFromClerk.No sign-in is performed, and no new environment variable is introduced: the Clerk organisation
comes from tenant.clerkOrgId and the customer seat honours the existing
CLERK_ORG_CUSTOMER_ROLE override. The target tenant is the one tenant flagged isDemo: true;
with several, --tenant <tenantId> is required. Either way the tenant goes through the existing
requireDemoTenant guard before anything is written, and a missing CLERK_SECRET_KEY refuses up
front rather than half-provisioning. --check reports state and writes nothing.
The end-to-end Clerk pattern is reused from
apps/web/app/(app)/service-leads/create/provision-customer.ts — including its diagnostics for
the two ways Clerk rejects this (no passwordless sign-in method enabled; the org role key
missing) — and the lazily-constructed, no-op-without-a-key client from
packages/services/src/permissions/clerk-mirror.ts.
Why adoption is not optional. ensureProvisionedFromClerk swallows duplicate-key errors and
returns silently, so a plain create against an address the seeder already owns leaves the account
permanently locked out: resolveAppUserForTenant finds no row by Clerk id, provisioning
no-ops on 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.
rosterOf(activeStoryline) returns the twenty roster entries in the spec's table, each with the
role its seat implies and the person's display name as label, and is covered by a unit
test that fails if a storyline person is added without an address.storyline/active.test.ts asserts the new roster addresses (it pins the current
demo+…@apoyar.eu six today) and still passes.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 (via requireDemoTenant), refuses
to run with no CLERK_SECRET_KEY rather than half-provisioning, and requires --tenant
when more than one tenant is flagged isDemo: true.pnpm demo:roster in place of
the manual create-then-sign-in loop, and record the two Clerk-side prerequisites that must
hold in both instances: org:csm, org:sdm, org:vendor and org:expert exist as
organisation roles (org:customer already does — hence CLERK_ORG_CUSTOMER_ROLE), and the
demo organisation's maximum membership count exceeds twenty.isPersona flag and the
email → role → stand-in fallback chain — that is demo-roster-seeder (stub 3).demo+<role>@… pinned-persona convention, which this change
invalidates — that is demo-view-as-user-list (stub 2). Between this stub and stub 2 the
switcher falls back to "first user alphabetically" per role; the batch's breakdown records that
as accepted. apps/web/lib/view-as-switcher-data.test.ts builds its own
demo+<role>@apoyar.eu fixtures and is unaffected either way.storyline/saas-vendor.ts are
unchanged apart from the email addresses and the four new internal-seat names.storyline/default.ts); it gains only the four
internal-seat names the type now requires.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 — an
operational tidy-up, not code.Context budget: within band. The stub carried the codebase research; targeted reads confirmed
StorylineTeam carries no names today (the four internal-seat names are net-new), that
requireDemoTenant/assertDemoTenant already exist as the demo guard, that
CLERK_ORG_CUSTOMER_ROLE and CLERK_SECRET_KEY are already declared in turbo.json →
globalEnv, and that storyline/active.test.ts pins the current addresses. There is no
scope.md behind this batch — its breakdown.md records the cut as made directly from Jamie's
request, so there is no story or question sheet for approve to settle.
03_build/output/notes.mdfeat: demo-roster-provisioning — roster helper + demo:roster script,
fix: demo-roster-provisioning — give the vendor seat a last name for Clerkpackages/services/src/db/services/demo-data/storyline/types.ts: StorylineTeam gains a
firstname/lastname pair for the admin, CSM and SDM seats. The vendor seat gets none — it is
a company, and vendorName already authors that name; a second copy would be the drift the
roster exists to prevent. StorylineAccount.email's "fictional, non-deliverable" note is now
false and says what the address actually is.storyline/roster.ts (new): rosterOf(storyline) derives the twenty { email, role, label, firstname, lastname } entries from team, accounts and experts. Exported from
storyline/index.ts; not from the package barrel — the only consumers are the script and
its tests.storyline/saas-vendor.ts: all twenty addresses move to
jamie.nisbet+<lastname>.<role>@sustentus.com; the three human internal seats gain their
names. Names and every other field are untouched. The module header no longer claims .example
non-deliverability.storyline/default.ts: the spare Calderon world gains the three names the type now requires.
Its addresses stay on demo+…@apoyar.eu — nothing seeds it, and re-pointing it would invent a
second roster to keep in step.storyline/roster.test.ts (new) and storyline/active.test.ts: see below.packages/services/scripts/demo-roster.ts (new) + demo:roster in packages/services/package.json.apps/docs/app/technical/demo-environment/page.mdx and
.../development/preview-database/page.mdx: the runbooks.RosterEntry carries firstname/lastname as well as label. The spec said the script
sets Clerk's firstName/lastName "from the roster label", which implies splitting the label
on whitespace. That is lossy and wrong for the vendor seat — "Orrery Software" would be filed
under the surname "Software" — and the storyline already authors the parts. label is still
present and still the display name, so the acceptance criterion holds; the parts are carried
rather than re-derived.vendorName and a second copy would be
the drift the roster exists to prevent. The first provisioning run against a real Clerk instance
rejected the seat: ["last_name"] data doesn't match user requirements set for this instance.
A Clerk instance can require a last name, and undefined fails that check. The spec's "a pair
per seat" was right. The company is now split — vendorFirstname: "Orrery",
vendorLastname: "Software" — which every surface renders back whole, because they all display
[firstname, lastname].filter(Boolean).join(" "). A test asserts the pair still concatenates to
vendorName, so the duplication cannot drift, and a second asserts no seat has an empty half.isDemo: true tenant, else --tenant <id> is required. Both
paths go through the existing assertDemoTenant.--check exits non-zero when the roster is incomplete, so it is usable as a gate. The spec
only required that it report; this adds no writes.--check also names organisation members who are not on the roster — the spec's
"unrecognised". That is how the superseded demo+…@apoyar.eu accounts surface.rosterOf(activeStoryline) returns the twenty entries — roster.test.ts asserts the spec's
table literally (not recomputed), plus one test that fails if a storyline person has no
entry, one for duplicate addresses, and one for lowercase normalisation.active.test.ts asserts the new addresses — two tests changed: the persona-seat pins, and
the .example domain rule, which is replaced rather than patched. Every person is a real
login now, so the rule that no address can receive mail is gone; what replaces it asserts the
roster convention across all twenty, which is the invariant that actually matters.pnpm demo:roster leaves twenty rows carrying clerkUserId, no sign-in performed.clerkUserId is adopted (stamped, not duplicated).--check writes nothing and names what is missing, mis-roled or unrecognised.assertDemoTenant), refuses without CLERK_SECRET_KEY before
any write, requires --tenant when several tenants are flagged.CLERK_SECRET_KEY points at; nothing else is instance-specific.pnpm demo:roster and record the Clerk-side prerequisites.The last three are structurally met but only provable by running the script against a demo tenant and a Clerk instance, which Build cannot do. See below.
Running pnpm demo:roster --tenant <id> against a live tenant and Clerk instance failed on the
fourth seat with ["last_name"] data doesn't match user requirements set for this instance. The
vendor seat was the only entry with an empty lastname, and the script sent undefined. Fixed as
above. Two regression tests now cover it — the vendor pair concatenating to vendorName, and no
seat having an empty first or last name — so this cannot come back silently.
Note the run is partially applied: the admin, CSM and SDM Clerk users were created before it threw. That is safe and by design — re-running resolves them rather than duplicating, which is the idempotence the acceptance criteria call for, and the re-run is itself evidence for that criterion.
packages/services/scripts/ is outside the package's tsconfig.json include (src/**/*
only), so pnpm typecheck does not cover demo-roster.ts — the same gap the three existing
db:* scripts sit in. CI going green is therefore not evidence this file type-checks. Reviewing
it by eye is the only check it gets. Pre-existing; widening the include would be a drive-by.demo:roster --check (expect it to
name the twenty as missing), run demo:roster, re-run --check (expect clean), then re-run
the seed and --check again to confirm adoption did not duplicate. Needs both Clerk-side
prerequisites in place first.role: wantedRole as never) copies provision-customer.ts. The
SDK types the role as a closed instance-specific set the app never declares.apps/web/lib/view-as-switcher-data.ts now matches nothing. Its demo+<role>@ pin
convention is dead against the new addresses, so pinnedForRole falls back to the first user
per role — the degraded state the batch's breakdown.md accepted between stubs 1 and 2. Its own
test builds its own fixtures and is unaffected. Left alone deliberately: the spec puts the
switcher out of scope and demo-view-as-user-list deletes that function. Its doc comment still
points at the runbook section for a convention the runbook no longer documents — I corrected the
runbook (leaving it asserting something false was worse) but not the comment in stub 2's file.demo:roster needs the tenant to resolve isDemo and clerkOrgId.Context budget: within band.
04_verify/output/verify.mdstandard) — 4 findings. 3 fixed on branch (800efb3);
1 is stub 2's, recorded. The CI Claude review is off (Review diff against CONVENTIONS.md
reports skipped), so /code-review was run rather than triaged.Nothing in this run is exercisable on the Vercel preview: demo:roster is a CLI against Mongo
and Clerk. The agent half is therefore the unit suite plus tracing each criterion through the
diff; every runtime criterion is the operator's.
rosterOf returns the twenty entries, and fails if a storyline person has no address —
roster.test.ts, 8 tests, green in Quality (agent)active.test.ts asserts the new addresses and passes — green in Quality (agent)pnpm demo:roster and the Clerk-side prerequisites — read on the
docs preview (agent)clerkUserId, no sign-in performed (operator)clerkUserId is adopted, not duplicated (operator)--check writes nothing and names what is missing/mis-roled/unrecognised (operator)CLERK_SECRET_KEY, requires --tenant when
several are flagged (operator)resolveAppUserForTenant still matches on clerkUserId exactly as beforeuserService, never a notify* wrapper. See finding V-1 for what the address change does
mean for delivery.Operator evidence so far. Jamie reported "it has worked on the preview branch" (2026-08-19,
in conversation) after the vendor-seat fix. That is the provisioning happy path and is recorded
as such — but it is not yet evidence for the ticked-box list above, for two reasons: "it
worked" does not distinguish which of the six runtime criteria were exercised, and three
behaviour-changing commits landed after that run — 9167688 (name reconciliation in Clerk and
Mongo), and 800efb3 (the two script bugs). The --check drift note was one of the things
broken at the time of that run. A re-run on the current head is what closes these lines.
Fixed on this branch:
$set carried only clerkUserId
and isActive, so seed-then-roster left the seeder's stand-ins ("CSM User") in the app while
Clerk showed "Sofia Almeida" — the exact defect the storyline name fields were added to remove.
Fixed in 9167688: the roster now writes its names to both Clerk and Mongo whenever they
differ, which also closes the drift the module's own header promised to prevent.resolveUser returns an existing row untouched, so the create() factory that sets
companyName, location.country and industry never runs against a row the roster made
first. Roster-then-seed leaves every customer without a company, and steps 8/9 of the runbook
I wrote prescribed exactly that. Fixed in 800efb3: steps swapped, and both runbooks now say
why the first pass is ordered and why later ones are not.--check drift note printed a literal ".trim(), (code-review) — .trim() written
inside the template literal. Mine, from 9167688. Fixed in 800efb3.emailAddresses[0] (code-review) — not the primary address,
so a seat whose roster address is not first could be reported as a stray to delete by hand.
Fixed in 800efb3.Accepted, recorded rather than fixed:
.example non-deliverability guarantee is gone. Seeding stays safe by construction (the
seeder writes through models, never the notify* wrappers), but a quote/invoice/CSAT flow
clicked through on the production demo tenant now sends real Resend mail. Every address is a
plus-address of one mailbox — Jamie's own — so this is inbox noise, not disclosure to a third
party, and no real person's PII is exposed. Gating delivery on isDemo is a separate change;
not scoped here.pinnedForRole matches demo+<role>@,
which nothing does now, so it falls back to first-user-per-role and can land the presenter on
a filler account. Known and disclosed: breakdown.md accepts it between stubs 1 and 2, and
demo-view-as-user-list deletes that function. Left alone — the spec puts the switcher out of
scope. Its doc comment still points at a runbook convention the runbook no longer documents;
stub 2 rewrites that file wholesale.scripts/ is outside the package tsconfig include — pnpm typecheck does not
cover demo-roster.ts (496 lines). Pre-existing for the three db:* scripts; green CI is not
evidence this file compiles, and tsx strips types rather than checking them. Widening the
include is a chore, not this run's work. It is the reason the operator smoke matters more here
than on a typical run.Context notes from the security pass, neither a vulnerability:
org:admin. Inherent to email auth and
true of the six accounts this replaces, but it takes the seats behind that single mailbox from
six to twenty.--remove, so the
accounts and memberships it creates survive a revert and are tidied by hand. --check names
organisation members that are not on the roster, which is how the superseded
demo+…@apoyar.eu accounts surface.Context budget: within band. The three review passes read outside the Inputs table by design — that is what they are for.
05_ship/output/investor-update.mdWho it's for: Admin — whoever presents the demo to a vendor partner What shipped: One command provisions all twenty demo people as real logins, in preview and production alike. Why it matters: The demo is how we establish product-market fit with vendor partners — it is now cheap to stand up and cannot drift.
Forty manual sign-ins became one command.
Dig deeper: https://github.com/sustentus/sustentus/pull/855
05_ship/output/release.md0a5fa88 — all 10 check runs, Quality Project included (Migrate production database and Review diff against CONVENTIONS.md report skipped by design). Re-checked on
the ship commit before the merge.technical/demo-environment and technical/development/preview-database,
both rewritten in this PR — the runbook now ends in pnpm demo:roster instead of twenty
hand-made Clerk accounts, and records the two Clerk-side prerequisites. One stale (step 8)
cross-reference fixed here: Verify swapped the seed and roster steps, so the roster is step 9.
The rest of technical/** was swept and does not lag — no new env var (CLERK_SECRET_KEY,
CLERK_ORG_CUSTOMER_ROLE and MONGODB_URI are all already in turbo.json → globalEnv), no
new route, no new CI step, and development/database stays scoped to the three db:*
lifecycle commands rather than becoming a catalogue of every packages/services script./admin/demo-data is untouched.Dig deeper therefore
carries the PR link alone rather than an angle-bracket placeholder the send would refuse.ship-note.yaml fires on the merge and emails
05_ship/output/investor-update.md to #product-update (2026-08-19). Body is 59 words,
inside the 60-word cap.Three criteria are closed by the diff and its unit suite. The other six are runtime, and nothing
in this run could exercise them: demo:roster is a CLI against Mongo and Clerk, so it runs
nowhere the Vercel preview reaches. They are recorded honestly rather than ticked.
rosterOf(activeStoryline) returns the twenty entries with the role each seat implies and
the person's display name as label, and fails if a storyline person is added without an
address — roster.test.ts, 8 tests, green in Qualityactive.test.ts asserts the new roster addresses and still passes — green in Qualitypnpm demo:roster in place of the create-then-sign-in loop and
record the two Clerk-side prerequisites — read on the docs previewclerkUserId and the mapped role, no sign-in
performed (operator)clerkUserId is adopted, not duplicated (operator)--check writes nothing and names what is missing, mis-roled or unrecognised (operator)CLERK_SECRET_KEY, requires --tenant when
several tenants are flagged (operator)What the six rest on. Jamie reported the script working against the preview instance on
2026-08-19, which is the provisioning happy path. That run predates 9167688 (name
reconciliation) and 800efb3 (the two script bugs), so it is not evidence for the individual
lines above — verify.md says so, and said so before the gate was ticked. Ticking Ready to
merge with them open is the operator's call, made on that basis. The remaining exposure is
bounded: the script is refuse-first (no demo tenant, no CLERK_SECRET_KEY, no ambiguous tenant
→ it stops before writing), --check is a read-only dry run, and V-7 records that
packages/services/tsconfig.json includes only src/**/*, so green CI is not evidence the
496-line script compiles. Runbook step 9 against production is where all six get exercised for
real.
Context budget: within band.