Skip to Content

← All archived runs

Run: demo-roster-seeder

run.md

Run: demo-roster-seeder

  • branch: claude/demo-roster-seeder-pipeline-315w4f
  • pr: #863

02_define/output/spec.md

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

  • slug: demo-roster-seeder
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer
  • touches: packages/services/src/db/services/demo-data, packages/services/src/db/services/demo-data/storyline, packages/services/src/db/services/demo-reset, apps/web/app/(app)/admin/demo-data/_components, apps/docs/app/technical/demo-environment, apps/docs/app/technical/development/preview-database
  • complexity: complex

Problem

demo-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 positiononly 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.

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

Acceptance criteria

  • After a reset, every seeded customer carries its storyline company name, country, industry and vendor attribution, and every expert carries its position, bio, skills and products — on a tenant whose people all pre-existed the run.
  • Signing in as any of the six roster experts lands on /expert, not /expert/onboarding: deriveExpertOnboardingStatus reports complete for all five requirements.
  • Every expert holds at least one approved evidence row, and the storyline still contains at least one draft, one submitted and one rejected row across the cast.
  • Each expert's seeded skills and products resolve to catalogue rows the same run wrote — no dangling ids, and non-empty for every expert.
  • A run whose roster is incomplete throws before writing anything, and the message names every missing address, not only the first.
  • A Reset demo data run on an incomplete roster refuses before the purge: the tenant still holds its data afterwards, and the failure — with the missing addresses — is readable at /admin/settings/demo-data.
  • No code path in 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.
  • The run still reports which person filled which seat: 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.
  • A second reset produces the same world as the first — same people, same attributes, no duplicate rows and no duplicate evidence.
  • A preview reset and a production reset produce the same cast in the same seats, differing only in Clerk user ids.
  • No run writes any person's 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.

Out of scope

  • Storyline content beyond the new expert fields (bio, skills, products) and the five added approved-evidence rows — 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 — shipped as demo-view-as-user-list (#861).
  • Provisioning the accounts — shipped as demo-roster-provisioning (#855).
  • 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.
  • 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.
  • Any change to demo:roster itself, the emulation gate, or the View As audit trail.

Open questions

None.

Notes for Build

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

Build notes: demo-roster-seeder

  • commits: f011d7c feat (seeder, storyline, admin UI, runbooks) · 8aa57c6 fix (restored import)
  • ci: GREEN on 8aa57c6

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

What changed

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

Acceptance criteria status

  • Customer and expert attributes written on every run — 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).
  • Roster experts land on /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.
  • One approved row per expert, all four states still present — asserted in active.test.ts.
  • Skills and products resolve to catalogue rows of the same run — 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.
  • Incomplete roster throws before writing, naming every address — assertRosterComplete, with unit tests for the "every, not the first" behaviour.
  • A reset refuses before the purge, and the failure is readable in the admin UI — the call sits ahead of PURGE_TARGETS and inside the try that writes tenant.lastReset. Smoke step 6.
  • No path creates or revives a user; the named symbols are gone — verified by grep across packages/services and apps/web.
  • personas survives, the two counters do not — including in both admin summary components.
  • A second reset produces the same world — every write is an idempotent $set or an upsert, and evidence still dedupes on (tenantId, expert, title). Smoke step 5.
  • Preview and production produce the same cast — there is one code path left, and it reads the roster. Only demonstrable by running both (smoke).
  • No run writes firstname, lastname, email or avatar — none appears in any $set.
  • Tests updated — persona-resolution.test.ts rewritten around the assertion; active.test.ts gains four expert-profile assertions.

Notes for Verify

  • Two deliberate deviations from the spec's own "Notes for Build", both worth a look.
    1. The spec said to keep the vendor-attribution fill separate and fill-only-when-unset. I folded 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.
    2. I added a role check to 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.
  • The docs edits are Build's, not Ship's. Normally 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.
  • Nothing here is exercisable on the Vercel preview. The reset writes to the demo tenant's Mongo against a provisioned Clerk roster; six of the eleven criteria are only provable by the smoke sequence in the PR body.

Context budget: within band.

04_verify/output/verify.md

Verify: demo-roster-seeder

  • ci: GREEN on dcaf7fe6 — settled via ci-status.sh after the last push
  • previews smoked: web (https://web-git-claude-demo-roster-seeder-pipeline-315w4f-sustentus.vercel.app) — nothing in this change is reachable without signing in, so no criterion was demonstrated there. marketing also built; demo, docs, help-centre and storybook were skipped builds for this commit's diff.
  • production-readiness: run — one blocking defect found and fixed on branch (below); everything else passed (tenant scoping, no new env vars, no migrations, no unbounded arrays, no route or auth surface, clean rollback, clean merge against main)
  • code-review: high (spec 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.
  • security-review: not required — the diff adds no route, server action, auth path, payment surface or third-party PII. The one identity-adjacent read (User.find by clerkUserId) is tenant-scoped, and the reset stays behind requireDemoTenant.
  • playwright: TODO — manual DoD smoke is the operator's, and was waived (see below)

DoD smoke (on the preview — each line says who verified it)

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.

  • customers carry company/country/industry/vendor, experts carry position/bio/skills/products — NOT demonstrated (operator waived)
  • roster experts land on /expert, not /expert/onboarding — NOT demonstrated (operator waived)
  • reset on an incomplete roster refuses before the purge, failure readable at /admin/settings/demo-data — NOT demonstrated (operator waived). This is the criterion that would have caught the blocking defect found below.
  • a second reset produces the same world — NOT demonstrated (operator waived)
  • preview and production produce the same cast — NOT demonstrated (operator waived)
  • auth: roster personas sign in and reach their dashboard — NOT demonstrated (operator waived)
  • payments: not touched by this diff (agent — diff inspection)
  • notifications: none expected; no notification template, registry map or preference rule is touched (agent — diff inspection)

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.

Findings & cleanup

Blocking, fixed on branch (dcaf7fe6)

  • The pre-purge check and the seeder disagreed about what fills a seat. 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

  • Seat map keyed by email alone, while the user model allows one principal to hold a document per role per tenant — a seat could bind to whichever row the driver returned last. Now keyed (email, role).
  • Seat resolution was interleaved with the customer writes, contradicting the function's own no-transaction ordering comment. All twenty seats now resolve before the first write.
  • $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".
  • Enrichment writes did not check 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.
  • The Populate demo data dialog said seats are filled "by email". Now "by address and role".
  • 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

  • A soft-deleted roster row is now unrecoverable by the seeder. 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.md


title: Reset demo data now dresses the people you can sign in as date: 2026-08-19 personas: [admin] slug: demo-roster-seeder pr: https://github.com/sustentus/sustentus/pull/863

Reset demo data now dresses the people you can sign in as

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

The demo now shows the same cast you can sign in as

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

Ship: demo-roster-seeder

  • pr: #863 (https://github.com/sustentus/sustentus/pull/863) · merge: authorised — Ready to merge ticked; this commit rides the squash
  • CI: GREEN on the head that merges, settled via ci-status.sh after the last push. One red round during Build (a dropped isDuplicateKeyError import, fixed in 8aa57c6); green every round since.
  • technical docs: 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.
  • business docs: no business docs impact — no persona capability, service-journey step or platform-overview claim changes; this alters the demo tenant, not the product's behaviour for a customer.
  • release notes: both
  • sent: ship note queued to #product-update by ship-note.yaml on merge
  • close-out: archive the run to apps/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.

Acceptance check (vs spec)

  • Approved evidence for every expert, all four states still present — asserted in storyline/active.test.ts, green in CI.
  • Expert skills and products resolve to catalogue rows of the same run — namedIds throws on an unresolvable name, and the storyline tests make that throw unreachable from an authored storyline.
  • An incomplete roster throws before any write, naming every missing seat — unit-tested in 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.
  • No path creates or revives a user; the retired symbols are gone — verified by grep across packages/services and apps/web.
  • personas survives; the two created-counters are gone from the result type and both admin components.
  • No run writes firstname, lastname, email or avatar.
  • Tests updated — persona-resolution.test.ts rewritten, active.test.ts gained five assertions.
  • Customers and experts carry their storyline attributes after a reset — not demonstrated.
  • Roster experts land on /expert, not /expert/onboardingnot demonstrated.
  • A reset on an incomplete roster refuses before the purge, failure readable in the admin UI — not demonstrated.
  • A second reset produces the same world — not demonstrated.
  • Preview and production produce the same cast — 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.