demo-roster-seederrun.md02_define/output/spec.mddemo-roster-provisioning (#855) gave the demo tenant twenty real Clerk logins, but the seeder still
behaves as though it owns the people. It resolves each seat by email, then by role, then invents a
"Demo CSM" stand-in, and it writes the storyline's attributes — a customer's companyName, country,
industry and vendor attribution; an expert's position — only in the create factory
(demo-data/index.ts, the createUser path). Every roster member now exists before the seeder runs,
so every seat matches by email, the factory never fires, and the demo shows customers with no company
and experts with no position. Worse, deriveExpertOnboardingStatus (apps/web/lib/expert-onboarding.ts,
enforced by apps/web/proxy.ts) pins an expert to /expert/onboarding until they hold a position, a
bio, skills, products and one approved piece of evidence — and the storyline gives approved evidence to
one expert of six. The roster exists so those seats can be signed into and demonstrated; today they
still cannot be. This is the last of the three stubs behind Refine the bridge / Q2-2026 Objective 1
— Establish Product-Market Fit with Vendor Partners: a walkthrough that shows the same cast, fully
dressed, on preview and production is what makes the vendor demo repeatable.
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. Before the first
write, the run asserts that every roster address matches a Clerk-backed user in the tenant; a run with
any seat missing aborts and names every missing address in one message, not just the first. The
role-match fallback, the invented stand-ins and the soft-delete revival path all go, so preview runs
the identical path to production. Because the Reset demo data flow purges before it seeds, the same
assertion runs in resetDemoTenant ahead of the purge — reinstating, against the roster, the
pre-purge precondition that was removed when the stand-in chain was introduced, so a failed reset never
empties a tenant it cannot repopulate.
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, and row creation/activation belongs to demo:roster. Expert enrichment runs
after the catalogue upserts, since skills and products must resolve to ids the same run builds. 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:
deriveExpertOnboardingStatus reports complete for all five requirements.approved evidence row, and the storyline still contains at least
one draft, one submitted and one rejected row across the cast.skills and products resolve to catalogue rows the same run wrote — no
dangling ids, and non-empty for every expert./admin/settings/demo-data.demo-data/ creates a user or revives a soft-deleted one; createUser,
resolveUser, resolvePersona, findByRole, claimedPersonaIds and standInName are gone, and
no stand-in name (Demo CSM, Demo SDM, Demo Admin) can appear in a seeded tenant.DemoDataResult.personas survives with one
entry per seat. customersCreated and expertsCreated are removed from the result type and from
the two admin summary components that render them.firstname, lastname, email or avatar.storyline/active.test.ts gains assertions that every expert has a non-empty bio, skills and
products, and at least one approved evidence row; persona-resolution.test.ts no longer tests
a fallback ordering or a stand-in name.bio, skills, products) and the five added
approved-evidence rows — companies, engagements, values, copy and CSAT scores are untouched.clerkUserId → purge" rule already deletes the
superseded generated people and keeps the roster.demo-view-as-user-list (#861).demo-roster-provisioning (#855).manager, employment, isHierarchyTop). A stable roster makes it
possible for the first time; it is a follow-on, not this feature.isActive. demo:roster sets it on both its adopt and create paths, so row existence and activation
stay the roster script's job — the seeder writes storyline attributes only.demo:roster itself, the emulation gate, or the View As audit trail.None.
Read these with CONVENTIONS.md; they record what Define confirmed in the code, so Build does not
re-derive it.
What comes out of demo-data/index.ts. resolveUser, resolvePersona, findByRole, createUser
(with its duplicate-key revival branch), resolveSlotUser, claimedPersonaIds, and the
customersCreated/expertsCreated counters on DemoDataResult (~lines 120–123, 328–329, 385–520,
626, 682). persona-resolution.ts loses standInName, pickPersonaSource and
PersonaResolutionSource; what survives is PersonaResolution — the per-seat reporting, which stays
valuable: a run should still say which person filled which seat. StorylineAccount.isPersona /
StorylineExpert.isPersona lose their meaning for resolution (every seat is a persona now) — leave the
fields if other code reads them, and say so in the build notes if you remove them.
The assertion. One exported helper — roster addresses in, missing ones out — so both callers share
it and it is unit-testable without a database. It belongs at the point where index.ts already
resolves people ahead of everything else (~line 380, before the catalogue block at ~line 751), which
exists for exactly this reason: there is no transaction, so a mid-run failure leaves catalogue rows
behind. resetDemoTenant (demo-reset/index.ts, ~line 290) calls it alongside requireDemoTenant and
assertReseedActor, before the PURGE_TARGETS loop. The stale comment at ~lines 277–283 explaining
why the old precondition was dropped must be rewritten, not left.
Enrichment ordering. Customer enrichment can sit where the accounts loop is today. Expert
enrichment must move after the catalogue block, next to the existing languages/isAvailable
re-assert loop (~lines 687–715) — fold that loop into the wider pass rather than leaving two. The
existing vendor-attribution fill (~lines 632–660) is fill-only-when-unset by design; enrichment of the
storyline-owned fields is unconditional $set, so keep the two distinct and keep the comment that
explains the dangling-ref case.
Resolving skills and products. cataloguePathFor (~line 927) already maps a key to
{ product, service, skills } from productsWithServices and skillsByService. Author the expert's
skills/products as names on StorylineExpert and resolve them against the catalogue rows the
run wrote (catalogue.skills, catalogue.platforms[].products); a name that resolves to nothing is a
storyline error and should throw, the same way an unknown account or expert key already does.
Evidence. storyline.evidence currently holds four rows (bergqvist approved + submitted,
okafor draft, duval rejected) at saas-vendor.ts:3443. Add an approved row for okafor,
lindberg, duval, reinhart and whitlock, keeping the three non-approved rows. The seeding loop
(~line 2230) already dedupes on (tenantId, expert, title), so idempotence is free if titles are
distinct.
Docs. technical/development/preview-database → "Whose faces you see" (line 173) tells the reader
to seed before running demo:roster, because "the seeder only populates rows it creates itself".
That workaround is exactly what this removes — order stops mattering. technical/demo-environment
describes the reset. Both change with the code; the stub confirms this is intended.
Context budget: within band.
03_build/output/notes.mdf011d7c feat (seeder, storyline, admin UI, runbooks) · 8aa57c6 fix (restored import)8aa57c6The first push was RED and it was mine: removing the user factory took the only visible use of
isDuplicateKeyError with it, so I dropped the import — but the catalogue's own upsert reverses a
soft-delete the same way, one function further down. Typecheck and lint both failed on that single
line; restoring the import fixed both. Nothing else was wrong, and the tests ran clean once lint
stopped short-circuiting the workflow.
demo-data/persona-resolution.ts: the module stops describing a fallback order and starts
enforcing a rule. pickPersonaSource, PersonaResolutionSource and standInName are gone;
missingRosterEmails and assertRosterComplete replace them, both pure so the seeder and the
reset share one definition of "incomplete" and it is testable without a database.
PersonaResolution survives, minus its source field — with strict attachment every seat is
matched by email, so the label carried no information.demo-data/index.ts: the user factory is gone — resolveUser, resolvePersona, findByRole,
createUser with its duplicate-key revival branch, resolveSlotUser and claimedPersonaIds.
One User.find({ clerkUserId: { $type: "string" } }) loads the tenant's Clerk-backed people,
assertRosterComplete runs against it before the first write, and seatUser resolves each seat
from the result. Customer enrichment is unconditional on every run; expert enrichment moved
after the catalogue upserts, because skills and products resolve to ids that do not exist
before them. customersCreated / expertsCreated are off DemoDataResult.demo-reset/index.ts: assertRosterComplete runs in purgeAndSeed before the PURGE_TARGETS
loop — reinstating, against the roster, the pre-purge precondition that was removed when the
stand-in chain arrived (its stale explanatory comment is rewritten). It sits inside the try so
the refusal is recorded on tenant.lastReset and the missing addresses are readable at
/admin/settings/demo-data rather than only in logs.storyline/types.ts: StorylineExpert gains required bio, skills and products.
isPersona's doc no longer describes seeder resolution — it now only marks the seat a
walkthrough is presented from, which is what active.test.ts pins.storyline/saas-vendor.ts: bio, skills and products for all six experts, and an approved
evidence row for the five who had none. The existing draft, submitted and rejected rows are
untouched, so the review queue still shows all four states.storyline/default.ts: the same three fields, because it is still Storyline-typed. It stays
the inactive spare world; nothing else about it changed.apps/web/.../demo-data-button.tsx, demo-reset-button.tsx: the two "created" summary rows and
the persona source label are gone, and the dialog copy no longer describes a fallback chain
that does not exist.apps/docs demo-environment + preview-database: the runbooks told the operator to seed before
provisioning, because the seeder only dressed rows it created. That is now backwards — the seeder
refuses without a roster — so the step order is swapped and the reasoning replaced.Customer.updateOne / Expert.updateOne
with an unconditional $set, on people who already existed. Provable end to end only against a
real tenant (DoD smoke, step 2/3)./expert — position, bio, skills and products are all written, and
every expert now holds an approved evidence row, which is the five requirements
deriveExpertOnboardingStatus checks. Smoke step 3.active.test.ts.namedIds throws on a name
the catalogue does not hold, and active.test.ts asserts the storyline only names catalogue
entries, so the throw is unreachable from an authored storyline.assertRosterComplete, with
unit tests for the "every, not the first" behaviour.PURGE_TARGETS and inside the try that writes tenant.lastReset. Smoke step 6.packages/services and apps/web.personas survives, the two counters do not — including in both admin summary components.$set or an upsert, and
evidence still dedupes on (tenantId, expert, title). Smoke step 5.firstname, lastname, email or avatar — none appears in any $set.persona-resolution.test.ts rewritten around the assertion;
active.test.ts gains four expert-profile assertions.vendor: vendorId into the customer enrichment $set and deleted the dangling-ref block
(referencedVendorIds / liveVendorIds). That block existed only because the vendor used to be
a stand-in that the purge destroyed and the re-seed recreated; the vendor seat is now a roster
member that survives every reset, so the state it guarded cannot arise. AC1 asks for
attribution on every customer, which the unconditional set gives directly.seatUser that the spec does not ask for. Without it a seat filled
by a user Clerk gave the wrong organisation role would be updated through the role-scoped
discriminator model (Customer.updateOne adds role: "customer"), match nothing, and produce a
half-dressed demo with no error anywhere. demo:roster --check reports the same drift; this
makes the seed refuse rather than seed around it. Five lines, but it is new behaviour — say so
if you would rather it went.getVendorModel() no longer runs during a seed. Removing the last use of the Vendor model
took it out of the Promise.all, so the vendor discriminator is not registered by this path any
more. Reads go through the base User model and writes are plain ObjectId refs, so nothing here
needs it — but it is the kind of removal worth a second opinion.docs-sync owns apps/docs at Ship. I did
them here because the spec's touches: names both pages and the passages became actively false
with this diff — a runbook telling an operator to seed first would now make the seed refuse. Ship's
docs-sync should still sweep for anything I missed rather than assume the pages are done.Context budget: within band.
04_verify/output/verify.mddcaf7fe6 — settled via ci-status.sh after the last pushmarketing also built; demo, docs, help-centre and storybook were skipped builds
for this commit's diff.main)complexity: complex) — 10 findings; 7 fixed on branch, 1 accepted, 2
recorded below. The CI Claude review (Review diff against CONVENTIONS.md) is enabled, ran, and
posted nothing — it reviews convention drift only, so an independent pass was run as well.User.find by clerkUserId) is
tenant-scoped, and the reset stays behind requireDemoTenant.Waived by the operator. The six criteria below need a reset against a demo tenant whose Clerk roster is provisioned; the agent has no preview credentials and cannot perform them. Asked at the gate, the operator chose to record them unverified and carry the risk into Ship. They are recorded here as not demonstrated — not as passing — so the run's evidence stays honest.
/expert, not /expert/onboarding — NOT demonstrated (operator waived)/admin/settings/demo-data — NOT demonstrated (operator waived). This is the criterion that
would have caught the blocking defect found below.The five criteria that are ticked on the PR are evidenced by the diff and the unit suite, which CI ran green — not by the preview.
Blocking, fixed on branch (dcaf7fe6)
assertRosterComplete
matched roster addresses by email; the seeder resolved each seat through its role's discriminator
and threw on a role mismatch. A role-drifted address therefore passed the check, the purge ran,
and the re-seed threw — emptying a tenant it could not rebuild, which is the exact outcome the
precondition exists to prevent. Found independently by both the code review and the readiness
audit. missingRosterSeats now keys on (email, role) and both callers share it.Fixed on branch alongside it
(email, role).$set: { location: { country } } replaced the whole location subdocument, discarding
street, zipCode and region — harmless when it only ran at creation, destructive now it runs
on every reset. Written as the dotted path "location.country".matchedCount, so a soft-deleted, re-roled or moved row would be
silently skipped and the demo would come out half-dressed. enrichSeat now throws on a miss,
which covers every such case at the point of the write rather than only the role case.user.vendor and its projected field were dead after the vendor-reconciliation block was deleted
in Build. Removed.SEEDED_USER's doc-comment still claimed the seeder recreates purged users by email. Rewritten —
that purge is no longer reversible by the next seed.active.test.ts pins that the experts between them cover every catalogue skill. Skills are a
hard filter in matching (filterEligibleExperts), and seeded leads take their skills from the
catalogue rather than from the storyline, so a gap there is an empty shortlist on the surface
this run exists to demonstrate. (Not a regression — before this run experts had no skills at all
and matched nothing — but nothing pinned it.)Accepted, not fixed
createUser's revival path is
gone, so a hidden row makes both the seed and the reset refuse, and demo:roster cannot repair
it either. This is the spec's deliberate choice — acceptance criterion 7 requires the revival
path be removed — so it stands. Recovery is a manual isDeleted: false on the row. Worth an
intake stub if it ever bites.Recorded for the owner, not acted on
account.region is still never written to the customer. The storyline authors it and the
lead carries it, but the user's location.region stays empty, so demo customers are invisible to
the user directory's Region filter and to SDM regional scoping. Pre-existing, not introduced
here, and outside this spec's field list — a candidate for demo-data-quality.getVendorModel() no longer runs during a seed, so the vendor discriminator is not
registered by this path. Nothing in this diff needs it, but it is a latent trap for the next
caller that assumes registration.Context budget: within band.
05_ship/output/changelog.mdOn a demo tenant, Reset demo data no longer invents anyone. Every seat in the storyline is filled by a real login, matched on address and role, and the reset fills in what each person is meant to show: a customer's company and country, an expert's position, bio, skills, products and an approved piece of evidence. Those details are written on every reset, not just the first, so a person added before a reset is dressed by the next one rather than left blank.
The practical difference is that the cast you demonstrate is the cast you can sign in as. Every expert now clears their onboarding checklist, so signing in as one lands on their dashboard instead of on the setup page — which is what made most of them impossible to show before.
If the tenant is missing anyone the storyline needs, the reset stops before it deletes anything and names every address that is missing, so you can fix the gap and try again. Previously a reset in that state would empty the tenant and then fail to refill it.
Nothing outside demo tenants is affected, and no one's name, email address or avatar is changed by a reset — those stay as they are in your sign-in provider.
05_ship/output/investor-update.mdWho it's for: Admin — the presenter running a demo What shipped: Reset demo data fills every seat from the fixed roster of real logins, and dresses each person on every reset. Why it matters: Refine the Bridge — preview and production now show the same fully-dressed cast, and all six experts can be signed in as, not one.
Dig deeper: https://github.com/sustentus/sustentus/pull/863 · https://help.sustentus.com/changelog/2026-08-19-demo-roster-seeder
05_ship/output/release.mdci-status.sh after the last push. One red round
during Build (a dropped isDuplicateKeyError import, fixed in 8aa57c6); green every round
since.technical/demo-environment (provisioning order reversed — the roster now comes
before the first seed, because the seeder refuses without it) and
technical/development/preview-database ("Whose faces you see" — the seed-first workaround
removed). Both updated in this PR. Swept the rest of apps/docs: the only other mentions
(business/roles, business/feature-role-matrix/leads) are navigational and still true.#product-update by ship-note.yaml on mergeapps/docs/archive/pipeline-runs/demo-roster-seeder/, and the
demo-roster-logins epic with it — this is its third and last stub, and its two siblings (#855,
#861) have merged.storyline/active.test.ts, green in CI.namedIds throws on
an unresolvable name, and the storyline tests make that throw unreachable from an authored
storyline.persona-resolution.test.ts, now matched on (email, role) after Verify found the
email-only check would let a reset purge a tenant it could not rebuild.packages/services and apps/web.personas survives; the two created-counters are gone from the result type and both admin
components.firstname, lastname, email or avatar.persona-resolution.test.ts rewritten, active.test.ts gained five
assertions./expert, not /expert/onboarding — not demonstrated.The five unticked criteria need a reset against a demo tenant with a provisioned Clerk roster,
which neither Build nor Verify could perform. Asked at the Verify gate, the operator chose to
record them unverified and carry the risk into Ship rather than hold the merge. verify.md holds
the same record. They remain the first thing to exercise on the demo tenant after this lands —
particularly the pre-purge refusal, which is the criterion that would have caught the blocking
defect Verify found.
Context budget: within band.