Skip to Content

← All archived runs

Run: preview-demo-storyline

run.md

Run: preview-demo-storyline

  • branch: claude/preview-demo-storyline-pipeline-ob5ib1
  • pr: #804

03_define/output/spec.md

Spec: Preview demo storyline — a populated, sign-in-able preview world

  • slug: preview-demo-storyline
  • personas: Admin
  • touches: packages/services/src/db/services/demo-data, packages/services/scripts, packages/services/package.json, apps/web/app/(app)/admin/demo-data, .github/workflows, apps/docs
  • complexity: standard
  • demo: none

Problem

Stubs 1 and 2 of this scope made the preview database migrated (#799) and baseline-seeded (#801), but a preview URL still shows an empty world: no tenant data, no engagements, nothing to look at. That is the last gap in Refine the Bridge / 2026-Q2 Objective 3 — Validate Technical Infrastructure & Payout Flow: the point of a preview deployment is to test a change before it merges, and you cannot test a change against a blank screen.

The demo-tenant batch already built the world we want — a storyline seeder (#795) and a converging purge-and-reseed (#797, shipped) behind an isDemo guard (#790). Two things stop it reaching preview. First, the preview tenant is not flagged isDemo: true, and it cannot be flagged the way production was: the flag-demo-tenant migration has already run on the preview DB as a recorded no-op, so setting its env var now would never re-fire. Second, and larger, the seeder resolves each storyline persona to a tenant user by hardcoded email (demo+<role>@apoyar.eu) and throws missingPersona when one is absent. Preview runs a separate Clerk instance where the people signing in are the team, using their own accounts — so the seeder fails on the first persona, every time.

Proposed change

Match personas that already exist, instead of demanding six specific ones. For each storyline slot marked isPersona, the seeder resolves the tenant user to attach in this order: the tenant's user with the slot's storyline email (today's behaviour); failing that, the tenant's existing user holding that slot's role, picked deterministically when several qualify; failing that, a stand-in created from the storyline's own fictional identity, exactly as non-persona pool entries are created today. Seeding therefore succeeds in any tenant, weaving in whoever is really signed in. The seeder reports how every slot was resolved so the loud-failure signal that this replaces is not simply lost — it becomes a visible line on the admin screen rather than an exception. No storyline content changes.

Flag the preview tenant with an explicit, re-runnable step. A db:flag-demo-tenant script sets isDemo: true on the tenant matching DEMO_TENANT_CLERK_ORG_ID, idempotently, and a workflow_dispatch workflow runs it against the GitHub preview environment carrying the same production-database denylist guard migrate-preview uses. Re-runnable beats ordering-fragile: it works whenever it is run, including after the preview DB is rebuilt.

Seed through the shipped path. With the tenant flagged, re-seeding preview is the existing admin-gated Reset demo data action on any preview URL — resetDemoTenant, already converging and already refusing non-isDemo tenants. No new seeding entry point.

Runbook on the preview-database docs page, cross-referenced from the demo-environment page.

Acceptance criteria

  • Seeding a tenant that contains none of the storyline persona emails succeeds instead of throwing missingPersona — every isPersona slot resolves by email, then by role, then by creating a stand-in.
  • When several tenant users hold a slot's role, the one chosen is deterministic: two runs against the same tenant pick the same user.
  • populateDemoLeads returns, per persona slot, which resolution path was taken (matched-by-email, matched-by-role, created), and the admin demo-data screen shows it.
  • Seeding the production demo tenant is unchanged: all six slots still resolve matched-by-email and the seeded world is identical to what #795 produces today.
  • pnpm --filter @sustentus/services db:flag-demo-tenant sets isDemo: true on the tenant matching DEMO_TENANT_CLERK_ORG_ID; a second run reports it already flagged and changes nothing; an unset variable or an unmatched org fails loudly, naming what is missing.
  • A workflow_dispatch workflow runs that script against the GitHub preview environment, refuses to run against the production database, and names the database and org it flagged in its job summary.
  • On a preview URL: the preview demo admin signs in, view-as reaches all six persona dashboards, and after the flag step plus a Reset demo data the headline surfaces render populated.
  • Reset demo data on preview is refused for any tenant not flagged isDemo: true, and the preview-database docs page documents the flag-then-reseed sequence as a repeatable action.
  • No production Clerk identifiers or secrets appear in preview configuration — the preview org ID is a preview-environment variable, and nothing from the production Clerk instance is copied across.

Out of scope

  • Provisioning six fictional demo+<role>@apoyar.eu users in the preview Clerk instance. Superseded: the team signs in to preview with their own Clerk accounts, which is what makes the persona-matching change above necessary rather than optional.
  • Any change to the storyline content itself — fixed by demo-seed-storyline (#795). This run changes how persona slots are resolved, not what the world contains.
  • Automatic re-seeding on every PR. The seeder is append-only per batch, so per-PR runs would accumulate; seeding stays on demand.
  • Pointing demo-reset-ops' cron at preview. That machinery shipped (#797) but is unscheduled; scheduling it for preview is a tweak-lane follow-up, not this run.
  • Removing the plaintext demo passwords from the demo-environment docs page — pre-existing, flagged during demo-tenant-foundation verify, still its own decision.
  • Any change to requireDemoTenant / demo-guard semantics.

Open questions

  • none

Context budget: two premises in the intake stub were stale and are corrected above — demo-reset-ops (#797) is shipped on main, and the demo-environment runbook's "personas are matched by email" contract is drift that this run replaces. Verifying both meant reading demo-reset/index.ts, db-migrate.yaml and the two docs runbooks beyond the Inputs table.

04_build/output/notes.md

Build notes: preview-demo-storyline

  • commits: feat: preview-demo-storyline — resolve personas by email → role → stand-in, re-runnable demo-tenant flagging, preview runbook
  • demo: none

What changed

  • packages/services/src/db/services/demo-data/persona-resolution.ts (new): the resolution order as a pure decision (pickPersonaSource) plus standInName, and the PersonaResolution / PersonaResolutionSource types. Split out as a pure core so the order is checkable without a database — the same shape demo-guard.ts used for assertDemoTenant.
  • packages/services/src/db/services/demo-data/persona-resolution.test.ts (new): written from the acceptance criteria — email beats role, role beats create, empty tenant creates.
  • packages/services/src/db/services/demo-data/index.ts: the substance. findByRole finds the tenant's real signed-in user for a slot's role (clerkUserId is a string, lowest _id); resolvePersona walks email → role → stand-in and records the outcome; resolveUser keeps the pool on email-only; resolveSlotUser picks between them per slot. missingPersona and its two throw sites are gone — the vendor and CSM now resolve through the same path as the customer and expert personas. DemoDataResult gains personas: PersonaResolution[].
  • packages/services/src/db/services/index.ts: the two new types on the barrel, so apps/web can render the report.
  • packages/services/scripts/flag-demo-tenant.ts (new) + package.json db:flag-demo-tenant: idempotent isDemo flagging keyed on DEMO_TENANT_CLERK_ORG_ID. Already-flagged is a reported no-op; unset variable or unmatched org is a loud error naming what to fix.
  • .github/workflows/preview-demo-flag.yaml (new): workflow_dispatch only, environment: preview, carrying migrate-preview's two preflights (required config, production-database denylist) and writing the database + org into the job summary. Manual by design — flagging writes to a shared database and should not be a pull-request side effect.
  • apps/web/app/(app)/admin/demo-data/_components/demo-data-button.tsx: a "Persona seats" block listing each slot, the email it attached to, and how it resolved; dialog copy updated. The SUMMARY_ROWS key type is narrowed to the numeric fields (CountKey) — keyof DemoDataResult now includes an array, which is not a ReactNode.
  • apps/web/app/(app)/admin/demo-data/_components/demo-reset-button.tsx: same narrowing (SeededCountKey) for the same reason. No behaviour change.
  • apps/docs/app/technical/development/preview-database/page.mdx: new "The demo world on preview" section — the four-step stand-up, why flagging is a script rather than a migration, and whose faces appear in a preview world.
  • apps/docs/app/technical/demo-environment/page.mdx: corrected the drifted matching contract in three places (runbook step 2, "Populating Demo Leads", "Demo Tenant Identification"), and pointed the preview case at the runbook above.

Acceptance criteria status

  • Seeding a tenant with none of the storyline persona emails succeeds — missingPersona is deleted; every isPersona slot falls through email → role → stand-in.
  • Deterministic pick when several users hold a role — findByRole sorts _id ascending and filters to clerkUserId: { $type: "string" }, so the seeder's own stand-ins (which never carry one) can't shift the pick between runs.
  • populateDemoLeads reports the resolution path per slot, and the admin screen shows it — DemoDataResult.personas, rendered as "Persona seats".
  • Production demo tenant unchanged — all six emails exist there, so every slot takes the email branch, which is the pre-existing findByEmail lookup verbatim.
  • db:flag-demo-tenant is idempotent and fails loudly on an unset variable or unmatched org.
  • workflow_dispatch workflow against the preview environment, production-DB denylist, names database + org in the summary.
  • Preview sign-in → view-as across six dashboards → populated headline surfaces — ops step, not code. Runs once the preview Clerk demo org exists and the runbook's four steps are done; Verify should smoke it on the preview URL.
  • Re-seed on preview repeatable and refused for non-isDemo tenants — the refusal is resetDemoTenant's existing requireDemoTenant (unchanged, shipped in #797) and the sequence is documented; the observation on preview is the same ops step as above.
  • No production Clerk identifiers in preview configuration — ops step: nothing in the diff carries an identifier, and the workflow reads vars.DEMO_TENANT_CLERK_ORG_ID from the preview environment, but "the value set there is the preview org" is checked by a human.

Notes for Verify

  • Three criteria are ops, not code, in the same way demo-tenant-foundation's were: they need the preview Clerk demo organisation to exist and someone to have signed in. Verify should confirm the code half and that the runbook is executable, then smoke the preview URL if the org is up.
  • The loud failure this removes was deliberate. demo-seed-storyline refused to seed a tenant missing a persona so a botched provisioning couldn't produce a half-anonymous world. That refusal is now a report instead: the run says matched by email / matched by role / stand-in created per seat. On the production demo tenant anything other than six matched by email lines means provisioning drifted — worth a look during review that this is a fair trade.
  • Consequence worth checking: populateDemoLeads now creates users on a tenant it would previously have refused. It is still admin-gated, and resetDemoTenant is still isDemo-gated, so nothing reaches an unrelated tenant without a tenant admin doing it deliberately.
  • demo-reset purges users without a clerkUserId, which now includes persona stand-ins. That is correct and converging — the re-seed recreates them — but it means a reset on preview does not preserve stand-in _ids the way it preserves real signed-in personas'. No code change; flagging it because demo-reset/index.ts's "identity survives" comment reads as broader than it is.
  • findByRole uses clerkUserId: { $type: "string" }, matching the partial index clerkUserId_tenantId_role_unique — worth a second pair of eyes that mongoose passes $type through uncast on a String path.
  • Tests: only pickPersonaSource / standInName are unit-covered. The resolution walk is DB-bound and the unit tier has no Mongo, so it waits for the integration tier rather than being faked with a mocked model chain.
  • Quality runs on this PR — it touches packages/services, apps/web, apps/docs and .github/workflows, so it is not one of the run-folder-only PRs the workflow skips. Format, lint, typecheck and test all come from CI.
  • Verify amended this run: see 05_verify/output/verify.md for what the reviews changed and why.

05_verify/output/verify.md

Verify: preview-demo-storyline

  • production-readiness: run — 2 blocking, 4 non-blocking. Both blockers closed on branch; 3 of the 4 non-blocking fixed, 1 recorded below.
  • code-review: medium (spec complexity standard) — 5 findings, 4 fixed on branch, 1 accepted. The CI Claude review is not enabled on this repo (Review diff against CONVENTIONS.md reports skipped), so the skill was run directly.
  • security-review: run — no qualifying HIGH or MEDIUM findings.
  • playwright: TODO — manual DoD smoke performed instead

What the reviews changed

The code review found a blocker that would have made the feature's headline flow fail on the very environment it was built for: resetDemoTenant still called assertPersonasPresent, which refuses any tenant not holding the storyline's own Clerk-backed persona emails. Preview holds none of them, so every preview reset would have refused — the exact "flag, then Reset demo data" sequence the spec chose as the seeding path. Build changed the seeder and did not follow the contract into the reset service.

The security review's most useful output was not a vulnerability but a confirmation of load-bearing order: removing missingPersona had also removed an accidental gate keeping populateDemoLeads off real tenants (no real tenant holds demo+<role>@apoyar.eu, so the throw was doing the work of an authorization check). The explicit requireDemoTenant added here is strictly stronger, and it must ship in the same commit as the removal — it does.

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

  • Seeding a tenant with none of the storyline persona emails succeeds — traced in the diff (agent): missingPersona and both throw sites are gone; every isPersona slot goes through resolvePersona → email → role → createUser. Not yet observed against a database.
  • Deterministic pick when several users hold a role — traced (agent): findByRole sorts _id ascending, filters to clerkUserId: { $type: "string" } so the seeder's own stand-ins can never shift the pick, and excludes claimedPersonaIds so two seats cannot collapse onto one person.
  • populateDemoLeads reports the resolution path per slot, and the admin screen shows it — traced (agent): DemoDataResult.personas, rendered as "Persona seats". Rendering not yet observed signed-in.
  • Production demo tenant unchanged — traced (agent): all six emails exist there, so every seat takes the email branch, which is the pre-existing findByEmail lookup verbatim.
  • db:flag-demo-tenant idempotent, loud on unset variable or unmatched org — traced (agent) in scripts/flag-demo-tenant.ts; not yet executed against a database.
  • workflow_dispatch workflow, production-DB denylist, names database + org in the summary — traced (agent) in .github/workflows/preview-demo-flag.yaml; not yet dispatched.
  • Preview sign-in → view-as across six dashboards → populated headline surfacesOPERATOR, NOT YET DONE. Blocked on ops: the preview Clerk demo organisation does not exist yet. Runbook: apps/docs → Technical › Development › Preview database → "The demo world on preview".
  • Re-seed on preview repeatable, refused for non-isDemoOPERATOR, NOT YET DONE. Same ops prerequisite.
  • No production Clerk identifiers in preview configurationOPERATOR, NOT YET DONE. Nothing in the diff carries an identifier (confirmed by the security review), but "the value set on the preview environment is the preview org" is a human check.
  • auth: no route policy, middleware or sign-in path touched — no new routes, both actions keep allowedRoles: ["admin"] and take tenantId from resolveActionContext, never from client input (agent, confirmed by the security review).
  • payments: not touched (agent — no invoice/payout write path in the diff; the seeder writes invoice fixtures, unchanged by this run).
  • notifications: none expected — populateDemoLeads writes documents directly through the models and raises no notifications, emails or external side effects by design (agent).

The gate cannot pass on the three operator lines above. They are not "probably fine": they are the only evidence that the feature works at all, and the preview Clerk org has to exist first.

Findings & cleanup

Fixed on branch:

  • resetDemoTenant refused every preview reset — assertPersonasPresent and its now-unused helper storylinePersonaEmails deleted, with their two describe blocks (the seeder can no longer throw for a missing persona, so the precondition it guarded is void). assertReseedActor and requireDemoTenant remain: both security-relevant refusals still run before the first delete.
  • populateDemoLeads had lost its only guard against non-demo tenants — added an explicit await requireDemoTenant(tenantId) before the first write. Judgement call worth your veto: this also means a developer's local tenant can no longer be populated without flagging it first. The alternative was leaving a tenant admin able to write ~60 fabricated engagements, invoices and CSATs into a live tenant, attributed to their real colleagues, with no undo.
  • Dangling vendor ref after reset+reseed — a persona customer matched by role survives a purge (they are Clerk-backed) but the stand-in vendor they pointed at does not. The backfill only filled when vendor was unset, so that account's revenue attributed to a deleted vendor and read as zero. Now a dangling ref counts as unset; a live attribution is still never overwritten.
  • Over-counted "stand-in created" — resolveSlotUser inferred created from the resolution branch, so a revived soft-deleted row was counted and labelled as a creation. It now takes createUser's actual flag.
  • Two persona seats of the same role could collapse onto one person — latent (today's storyline has one persona account and one persona expert, on different discriminators). Closed anyway with claimedPersonaIds + $nin, four lines.
  • No repo-side path to flag production. Adding requireDemoTenant to the seeder made production's isDemo load-bearing for the Populate button, and that flag's only writer is a migration that no-ops silently and can never re-fire. The flag workflow now takes a target input; the production job runs under environment: production, so required reviewers gate it exactly as they gate migrate-production.
  • Stale contract in apps/web/app/api/cron/demo-reset/route.ts — its "not yet scheduled" rationale said preview can have no isDemo tenant, which this run makes false.

Accepted, not fixed:

  • Reset is still not atomic, and the failure mode moved. Dropping assertPersonasPresent removed a pre-purge check; the seeder can still throw after the purge (any createUser write, or a non-duplicate error on the revival path). Previously such a failure left the tenant populated; now it can leave it purged and partial. There is no transaction. The recovery is to run the reset again, which converges — that is the rollback story, and it is stated here rather than assumed. Not worth a transaction for a demo path.

Open for you (ops, before or at merge):

  • Confirm the production demo tenant actually carries isDemo: true before this merges. If the runbook's ordering was missed when #790 shipped, the flag is absent, and on merge the production Populate demo data button starts refusing. The new production target of the flag workflow is the repair, but knowing whether it is needed requires looking at the tenant document.

CI

Quality on 3061e83 was green but covered the wrong tree — the review fixes were still uncommitted when it ran. Re-run on 947698a: format:check failed on this file and on 04_build/output/notes.md.

The content problem was trivial — *italics*, which prettier normalizes to _italics_. Getting to it was not, and the reason is a factory inconsistency worth its own chore lane:

.prettierignore carries pipeline/runs/** ("immutable records ... reformatting them rewrites history"). Empirically the two prettier entry points disagree about it:

  • Husky → lint-stagedprettier --write <explicit paths> honours the ignore and silently skips run-folder files. It ran over both files on two separate commits and changed nothing.
  • **CI → prettier --check "**/*.{ts,tsx,md}" (glob)** does *not* skip them, and fails the build.

So run-folder markdown is in a gap: CI enforces formatting on it that the local formatter refuses to apply. Every run whose notes are not accidentally prettier-clean lands red, and the agent cannot fix it — block-local-checks.sh blocks the prettier binary too. Jamie authorised a one-off here and the files were formatted through prettier's API directly. The durable fix is a chore: either drop the pipeline/runs/** entry so Husky formats run files like everything else, or add the same exclusion to the format:check glob so CI stops checking what nothing can format.

Context budget: within the Inputs table plus the diff. The two audit skills read wider by design.

06_ship/output/changelog.md


title: Demo data now fits the people already in your tenant date: 2026-08-13T16:30:00Z personas: [admin] slug: preview-demo-storyline pr: https://github.com/sustentus/sustentus/pull/804

Demo data now fits the people already in your tenant

Configure → Demo data used to insist on a fixed set of six demo logins. If any one of them was missing, the whole run refused and nothing was written. It now works with whoever is actually in your tenant.

  • Each role in the demo story — the vendor, the CSM, the customer contact, the delivery expert — is filled by looking for that person's own login first, then by any signed-in colleague holding that role, and only then by creating a stand-in.
  • After the run, a Persona seats panel lists every role, the person it attached to, and which of those three routes it took. Nothing is guessed at silently.
  • People matched this way are never renamed. The demo story is told through your own team where your team is present.
  • Populate demo data is now refused outright on any tenant not marked as a demo tenant, the same rule Reset demo data already followed. It can no longer write sample engagements, invoices or survey responses into a live tenant.

If your tenant already has the six standard demo logins, nothing changes: every seat still matches those, and the world you get is the one you had before.

06_ship/output/investor-update.md

A preview URL can now show a populated world instead of a blank one

Who it's for: Admin, and anyone testing a change before it merges What shipped: Demo data seeds against whoever is already in the tenant, instead of demanding six fixed logins. Why it matters: Refine the Bridge — Validate Technical Infrastructure: a preview that proves nothing is a blind spot at merge time.

Populating demo data is now refused on any non-demo tenant, closing a gap that had been held shut only by accident.

Dig deeper: https://github.com/sustentus/sustentus/pull/804 · https://help.sustentus.com/changelog/2026-08-13-preview-demo-storyline

06_ship/output/release.md

Ship: preview-demo-storyline

  • pr: #804 · merge: authorised — Ready to merge ticked; this commit rides the squash
  • CI: green on 44b5f07 after three rounds. Round 1 covered the wrong tree (the verify fixes were still uncommitted). Round 2 and 3 failed format:check on two run-folder markdown files. Round 4 green. The Quality run for 44b5f07 had to be dispatched by hand — GitHub created no run for that push even though quality.yaml carries no path filters.
  • technical docs: apps/docs/app/technical/development/preview-database (new "The demo world on preview" section, rewritten against the shipped flagging mechanism) · apps/docs/app/technical/demo-environment (the drifted persona-matching contract corrected in three places)
  • business docs: no business docs impact — no service-journey step or feature-role-matrix entity changes behaviour; what changed is who a demo seat attaches to.
  • release notes: both
  • sent: ship note queued for #product-update by the merge (ship-note.yaml)

Acceptance check (vs spec)

  • Seeding a tenant with none of the storyline persona emails succeeds — missingPersona deleted; email → role → stand-in. Verified by reading the diff at Verify; not yet observed against a database.
  • Deterministic pick when several users hold a role — findByRole sorts _id, filters to clerkUserId strings, excludes already-claimed seats.
  • Per-slot resolution reported and rendered — DemoDataResult.personas → "Persona seats".
  • Production demo tenant unchanged — all six emails exist there, so every seat takes the pre-existing email branch.
  • db:flag-demo-tenant scriptdropped at Ship, superseded by #805, which wired DEMO_TENANT_CLERK_ORG_ID into migrate-preview and added 1786579200000-reflag-demo-tenant-preview.ts. Shipping a second mechanism for one job would have left two ways to flag a tenant and docs describing the wrong one.
  • workflow_dispatch flagging workflow — dropped with the script, same reason.
  • Preview sign-in → view-as across six dashboards → populated headline surfaces — ops, not done. Needs the preview Clerk demo organisation.
  • Re-seed on preview repeatable and refused for non-isDemo — same ops prerequisite; the refusal itself is requireDemoTenant, unchanged and now also applied to the seeder.
  • No production Clerk identifiers in preview configuration — nothing in the diff carries one (confirmed by the security review); that the preview environment variable holds the preview org is a human check.

What this run actually delivered

The flagging half was overtaken by #805 while this PR was in Verify. What remains — and what the run was really about once the stub's premise was corrected — is the seeder: persona seats resolve against the tenant's own people rather than six hardcoded addresses, the run reports how each seat resolved, and populateDemoLeads gained the explicit requireDemoTenant gate that the removed missingPersona throw had been providing by accident.

Carried forward

  • Three acceptance criteria remain unproven on a preview URL. They are ops steps blocked on the preview Clerk demo organisation existing. The runbook is on the preview-database docs page.
  • .prettierignore inconsistencypipeline/runs/** (now .icm/runs/**) is exempt for the local formatter but not for CI's glob-based format:check, so run-folder markdown can only pass by being written prettier-clean by hand. Detail in 05_verify/output/verify.md → CI. Wants a chore.
  • Quality did not trigger on one push and had to be dispatched manually. If that recurs it is a gate that can silently stop reporting.

Context budget: within the Inputs table. The merge with main pulled in #803 (the pipeline/.icm/ restructure) and #805, both of which had to be read to resolve the conflict honestly.