preview-demo-storylinerun.md03_define/output/spec.mdStubs 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.
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.
missingPersona — every isPersona slot resolves by email, then by role, then by
creating a stand-in.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.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.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.view-as reaches all six persona
dashboards, and after the flag step plus a Reset demo data the headline surfaces render
populated.isDemo: true, and the
preview-database docs page documents the flag-then-reseed sequence as a repeatable action.preview-environment variable, and nothing from the production Clerk instance is
copied across.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.demo-seed-storyline (#795). This run
changes how persona slots are resolved, not what the world contains.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.demo-tenant-foundation verify, still its own decision.requireDemoTenant / demo-guard semantics.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.mdpackages/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.missingPersona is
deleted; every isPersona slot falls through email → role → stand-in.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".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.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.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.vars.DEMO_TENANT_CLERK_ORG_ID from the
preview environment, but "the value set there is the preview org" is checked by a human.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.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.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.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.05_verify/output/verify.md for what the reviews changed and why.05_verify/output/verify.mdstandard) — 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.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.
missingPersona and both throw sites are gone; every isPersona slot goes through
resolvePersona → email → role → createUser. Not yet observed against a database.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.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.view-as across six dashboards → populated headline surfaces —
OPERATOR, 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".isDemo — OPERATOR, NOT YET DONE.
Same ops prerequisite.preview environment is the preview org" is a human check.allowedRoles: ["admin"] and take tenantId from resolveActionContext, never from client
input (agent, confirmed by the security review).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.
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.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.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.claimedPersonaIds + $nin, four lines.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.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:
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):
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.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:
lint-staged → prettier --write <explicit paths> honours the ignore and silently
skips run-folder files. It ran over both files on two separate commits and changed nothing.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.mdConfigure → 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.
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.mdWho 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.md44b5f07 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.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)service-journey step or feature-role-matrix entity
changes behaviour; what changed is who a demo seat attaches to.#product-update by the merge (ship-note.yaml)missingPersona deleted;
email → role → stand-in. Verified by reading the diff at Verify; not yet observed against a
database.findByRole sorts _id, filters to
clerkUserId strings, excludes already-claimed seats.DemoDataResult.personas → "Persona seats".db:flag-demo-tenant scriptDEMO_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 workflowview-as across six dashboards → populated headline surfaces — ops, not
done. Needs the preview Clerk demo organisation.isDemo — same ops prerequisite; the refusal
itself is requireDemoTenant, unchanged and now also applied to the seeder.preview environment variable holds the preview
org is a human check.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.
.prettierignore inconsistency — pipeline/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.