demo-reset-opsrun.md03_define/output/spec.mdThe demo tenant only ever accumulates. demoDataService.populateDemoLeads is append-only by
design — its own doc comment says a re-run "appends a fresh batch of engagements"
(packages/services/src/db/services/demo-data/index.ts:72), and every run stamps a new 8-char
batch token (line 248) rather than replacing the previous one. demo-seed-storyline made the
world reproducible — the generator is fully authored, no Math.random() — but reproducible is
not the same as recoverable: there is still no way back to the opening storyline, so the tenant
was deliberately seeded once and its own spec recorded "until demo-reset-ops lands, the demo
tenant is seeded once" as a consequence to accept. Every client walkthrough that creates a lead,
answers a survey or moves a status leaves that residue behind for the next prospect to see.
The docs make it worse. apps/docs → technical/demo-environment documents snapshot creation and
restore (### Snapshot Management, #### Creating Snapshots, #### Restoring Snapshots) and
tells the reader "to reset a tenant, use snapshot restore below" — machinery that was never built.
An agent or a presenter reading that page as spec is reading fiction.
This is the last hardening step before the demo tenant is presentable on demand, and it advances Refine the bridge / Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners: a demo you can only run once is not a sales asset.
A resetDemoTenant service that purges then reseeds, plus the two ways to fire it and the docs
correction.
resetDemoTenant in @sustentus/servicesA new export beside demoDataService, following the same index.ts + instance.ts pattern. It
takes a tenantId, calls the existing requireDemoTenant guard
(packages/services/src/db/services/tenant/demo-guard.ts) before touching anything, purges,
then reseeds by calling populateDemoLeads. It returns the counts of both halves:
{ purged: Record<collectionName, number>, seeded: DemoDataResult }
The purge does not hardcode the list of things the seeder writes — that list rots the moment a
model is added. Instead it deletes, in every tenant-scoped collection, every document matching
the tenant, minus an explicit keep-list. A collection is tenant-scoped if its schema carries
tenantId; there are 33 such models today and 6 global ones.
Kept (survives a reset):
| Collection | Why |
|---|---|
user — rows with a clerkUserId string |
the six Clerk-provisioned personas are identity |
tenant-setting, tenant-integration |
tenant configuration, set at provisioning |
role-template, user-permission-override |
RBAC configuration, attached to surviving users |
sla-definition, location |
admin-governed definitions and territory config |
industry |
the seeder reads it and never writes it |
csm-portfolio-snapshot |
so the CSM table has a prior day to compare to |
Purged: everything else that is tenant-scoped — lead, status-history, activity,
proposal, quote, invoice, milestone, csat, expert-rating, lead-expert-match,
blocker, change-control, action-item, project-message, notification,
onboarding-blueprint, expert-evidence, sdm-outreach-state, view-as-audit, counter,
service, platform, product, skill, product-service-link, and every user row
without a clerkUserId string.
Never touched: the genuinely global collections — tenant, action-type,
metric-definition — which carry no tenantId at all, so a tenant-scoped deleteMany cannot
reach them. Nothing in this run issues an unscoped delete.
Four consequences of this shape, decided here rather than left to Build:
service,
platform, product, skill and product-service-link are rebuilt identically by the seeder's
upsertNamed, so purging them costs nothing and buys strict convergence: a service an admin adds
mid-demo on /admin/settings/services does not survive, which is what "reset" has to mean.industry is the exception, because the seeder only reads it. It reuses whatever the tenant
already has ("Industry stays a flat taxonomy"), so purging it would leave the reseed with no
industries to attach leads to. It is kept despite sharing service's schema.tenant.leadRequestSequence,
not from the counter collection (which nothing writes), so the reset zeroes that field on the
Tenant document — otherwise the IDs climb on every reset and the world is not the same world
twice. counter is still purged, for completeness rather than effect._ids are stable across resets.Corrected at Verify (2026-08-13).
service,platformandindustryare built fromcreateTaxonomySchema, which gives each a per-tenanttenantId— they are not global, as this section first claimed, and were originally left unclassified.metric-definitionis the mirror error: genuinely platform-wide, it was wrongly listed as kept.csm-portfolio-snapshotmoved to kept because purging it destroyed each night's snapshot an hour after it was written. The drift test now keys on schema construction rather than the wordtenantIdin the source.
The reset makes no Clerk API calls at all — it only deletes Mongo documents. The Clerk
organisation, the six Clerk users and their memberships are untouched by construction, and the
clerkUserId keep-rule preserves their app-user documents, so a persona signs straight back in
with the same _id and the same storyline identity.
The reseed needs a creator id (populateDemoLeads(tenantId, createdBy)): it uses the surviving
admin persona — the tenant's admin-role user carrying a clerkUserId. If there is no such user
the reset refuses before purging rather than emptying a tenant it cannot repopulate.
A second button on the existing /admin/settings/demo-data tab, beside DemoDataButton, behind a
confirm dialog that names the tenant and states plainly that it deletes the current world. It calls
a new resetDemoData server action in apps/web/app/(app)/admin/demo-data/actions.ts, built on
the same three-step pipeline as populateDemoData (resolveActionContext({ allowedRoles: ["admin"] })
→ runActionBody), and reports the purged and seeded counts on completion. No new route and no
route-policy entry: /admin/settings is already admin-gated, and the page already carries
maxDuration = 300 for the seed.
A new GET /api/cron/demo-reset route mirroring apps/web/app/api/cron/csm-portfolio-snapshot/route.ts:
CRON_SECRET bearer check first, dynamic = "force-dynamic", maxDuration = 300. It resolves its
own targets — every tenant with isDemo: true — so no new environment variable is introduced
and turbo.json → globalEnv is unchanged (CRON_SECRET is already listed, line 81).
/api/cron(.*) is already public in apps/web/proxy.ts, so no proxy change. The route returns
500 when any tenant fails or when no isDemo: true tenant matched, so a sweep that reset
nothing cannot be recorded as a green cron.
The schedule is deliberately not in this PR. Preview deployments carry no isDemo: true tenant
(db-migrate.yaml withholds DEMO_TENANT_CLERK_ORG_ID from preview), so only the refusal paths
are reachable there and the first unattended firing would also be the first successful run of an
irreversible 26-collection purge. The route ships ready; the vercel.json entry
(0 1 * * *, an hour before the 02:00 snapshot job so each morning's snapshot describes the
freshly reset world) is a one-line follow-up once a reset has been run by hand from
/admin/settings/demo-data and two consecutive runs have been diffed. Decision recorded at Verify
by Jamie, 2026-08-13.
technical/demo-environment describing the reset that actually ships and
removing the snapshot machinery that never did: the ### Snapshot Management,
#### Creating Snapshots and #### Restoring Snapshots sections, the two bullets that promise
snapshots (lines 11 and 178), and the "to reset a tenant, use snapshot restore below" pointer at
line 268. ### What Gets Reset / ### What Gets Preserved are rewritten to match the keep-list
above. Ship's docs-sync does this in this PR.Purge and reseed run sequentially with no transaction — Mongo cannot span this many collections atomically here, and the seeder already has no transaction. An interrupted reset leaves the tenant purged or partially seeded; the remedy is to run the reset again, which converges. This is stated so Build does not invent a rollback that cannot exist. Errors are surfaced with the collection that failed; the cron route logs and returns 500 the way the snapshot job does.
resetDemoTenant calls requireDemoTenant before its first delete; a reset against a tenant
with isDemo: false, or a tenant id that does not resolve, refuses and deletes nothing —
unit-covered for both cases.batch token in the world, no remnants of earlier batches._ids unchanged and can sign in immediately after a
reset; no Clerk API call is made by the reset path.clerkUserId.tenant, action-type and metric-definition document
counts are identical before and after a reset, and no unscoped deleteMany exists in the
reset path.tenantId is classified as purge or keep — covered by a
test over the model registry that fails when a new tenant-scoped model is added and left
unclassified./admin/settings/demo-data, is reachable only by admin,
requires an explicit confirmation naming the tenant, and reports purged + seeded counts.GET /api/cron/demo-reset returns 401 without the CRON_SECRET bearer token, and with it
resets every isDemo: true tenant and returns their counts; it returns 500 rather than 200
when a tenant fails or no demo tenant matched. No vercel.json entry in this PR — the
schedule is a follow-up, per the decision above.maxDuration budget on the demo tenant, evidenced by a
successful Vercel cron invocation log. Deferred with the schedule — this is the evidence
the follow-up PR carries, not this one.turbo.json → globalEnv is unchanged, because the run introduces no new environment
variable.apps/docs technical/demo-environment describes the shipped reset and the client-session
runbook, with no surviving reference to snapshot creation or restore.requireDemoTenant.demo-seed-storyline;
populateDemoLeads stays append-only and is called unchanged.isDemo: true.demo-seed-storyline as a
separate /pipeline bug, still not this run's to fix.csm-portfolio-snapshot rows are purged nightly and rebuilt by
the 02:00 job, so the CSM activation table's changeVsYesterday reads zero on the first day
after a reset. Correct behaviour for a reset world, worth knowing before a walkthrough opens on
that table.deleteMany calls concurrently is the first lever, not a schedule change.04_build/output/notes.mdfeat: demo-reset-ops — purge-and-reseed service, admin trigger + nightly crondemo: none)packages/services/src/db/services/demo-reset/policy.ts (new): the deny-by-default
classification — 25 purged model files, 7 kept — as pure string data with no imports, so the
drift test stays hermetic.packages/services/src/db/services/demo-reset/index.ts (new): DemoResetService.
resetDemoTenant awaits requireDemoTenant, resolves the re-seed actor, purges, then calls the
existing populateDemoLeads unchanged. resetAllDemoTenants sweeps every isDemo: true tenant
for the cron. assertReseedActor is the pure refusal core, mirroring assertDemoTenant.packages/services/src/db/services/demo-reset/instance.ts (new): singleton, matching
demo-data/instance.ts.packages/services/src/db/services/index.ts: barrel exports demoResetService,
DemoResetResult, DemoResetSweepEntry.apps/web/app/(app)/admin/demo-data/actions.ts: resetDemoData server action on the same
three-step pipeline as populateDemoData, allowedRoles: ["admin"].apps/web/app/(app)/admin/demo-data/_components/demo-reset-button.tsx (new): destructive button
behind an AlertDialog that names the tenant; renders purged and re-seeded counts on success.apps/web/app/(app)/admin/settings/demo-data/page.tsx: hosts the new button; the maxDuration
comment now describes the reset as the page's longest action and drops the stale "until
demo-reset-ops lands" note.apps/web/app/api/cron/demo-reset/route.ts (new): CRON_SECRET bearer check, then the sweep.
Modelled on csm-portfolio-snapshot/route.ts.apps/web/vercel.json: adds /api/cron/demo-reset at 0 3 * * *.policy.test.ts (registry drift, three cases) and reseed-actor.test.ts.tenantPlugin, which hooks deleteMany and throws when the
filter carries no tenantId (plugins/tenant.ts:75–126). An unscoped delete in this path could
not run even if someone wrote one.deleteMany is a genuine hard delete. The soft-delete plugin only hooks pre(/^find/)
(plugins/soft-delete.ts:21), so the purge also removes already-soft-deleted rows — which is
what convergence needs. Conversely Tenant.find({ isDemo: true }) and the admin lookup are
filtered by it, so an archived tenant is never a sweep target.The spec's keep table lists taxonomy. models/taxonomy.ts is a shared schema factory
(createTaxonomySchema) with no model getter and no collection of its own, so it is not
classifiable and the drift test excludes it by requiring an export const get*Model. The concrete
lists are 25 purged / 7 kept over the 32 tenant-scoped models, verified to cover the filesystem
exactly. No behaviour differs from the spec's intent.
requireDemoTenant before the first delete — awaited at the top of resetDemoTenant, before
the actor lookup and the purge loop. Both refusal cases are unit-covered on the pure core in
tenant/demo-guard.test.ts (isDemo: false, and a tenant that does not resolve). The
ordering is structural and belongs to Verify's read of the diff: asserting call order in a
unit test would be testing implementation, which CONVENTIONS.md → Testing forbids.demo-seed-storyline removed Math.random()), so one seed run
follows every purge and exactly one batch token survives. Needs the DoD smoke on the
preview to be observed, not just argued — see Notes for Verify._ids unchanged; no Clerk call — the purge keeps user rows with
a clerkUserId string, and nothing in demo-reset/ imports a Clerk client.assertReseedActor, called before the purge
loop, unit-covered in reseed-actor.test.ts.deleteMany — the six global models are never
imported here, and every delete goes through deleteForTenant, which always supplies
tenantId.policy.test.ts scans src/db/models/ and fails on
an unclassified model, a double-classified one, or a stale name./admin/settings/demo-data, which is already admin-only, via an action gated on
allowedRoles: ["admin"].GET /api/cron/demo-reset 401s without the bearer token, resets every isDemo: true tenant,
registered at 0 3 * * *.maxDuration — cannot be met at Build. It needs a real
scheduled invocation against the demo tenant; crons do not run on preview deployments. This
is Verify/post-merge evidence, not something a diff can show.turbo.json → globalEnv unchanged — no new environment variable; CRON_SECRET was already
catalogued (line 81).apps/docs technical/demo-environment updated — deliberately Ship's, in this same PR.
The spec (§6) assigns the docs sync to Ship's docs-sync, so it is not done here. Until it
runs, the page still documents snapshot create/restore that does not exist.metric-definition, role-template, sla-definition, location,
tenant-setting, tenant-integration and user-permission-override are kept as tenant
configuration; everything else tenant-scoped goes. If one of those is in fact seeded content on
the demo tenant, the world stops converging and the failure is silent.onboarding-blueprint is purged; tenant.onboarding.status is not. The blueprint documents
are demo activity, while the coarse lifecycle status lives on the Tenant document, which is
global and untouched. Worth confirming on the preview that a reset does not bounce the demo
tenant back into first-run onboarding.deleteMany calls
in front of a ~1,600-round-trip seed. They run sequentially in a for loop — deliberately, for
a legible per-collection count — and batching them concurrently is the first lever if the
nightly job runs long.Quality and the
Vercel preview are the signal.node_modules and no core.hooksPath, so the pre-commit auto-format the
factory doctrine relies on never fires; Quality caught two Prettier diffs on the first push
(a breakable join(...) call at 81 chars, and JSX text that Prettier re-fills to 80). Both are
fixed. This will hit every agent PR raised from a web session, not just this one — worth a
/pipeline chore to close the gap rather than paying a CI round-trip each time.05_verify/output/verify.mdcomplexity: complex) — 7 findings. 3 fixed on branch, 4 accepted with
reasons below.The diff touches the database (irreversible deletes across 26 collections), auth (an admin-gated destructive action, a bearer-token cron route) and PII (it deletes user records). Both triggers matched; neither was skipped.
The success path is not reachable on preview, by design. db-migrate.yaml withholds
DEMO_TENANT_CLERK_ORG_ID from the preview job — "the shared preview DB has no demo tenant to
flag" — so no tenant carries isDemo: true there and requireDemoTenant refuses on all of them.
Only the refusal paths can be demonstrated on the preview; the convergence criteria need
production. This is the single largest gap in this run's evidence and it is the reason the cron
schedule is held back.
requireDemoTenant runs before the first delete — traced in the diff; the two refusal cases
are unit-covered on the pure core in tenant/demo-guard.test.ts (agent)reseed-actor.test.ts (agent)reseed-actor.test.ts (agent)policy.test.ts, and independently re-derived
against the filesystem during this stage: 34 detected, 34 classified, 0 stale (agent)deleteMany in the reset path — every delete goes through deleteForTenant,
which always supplies tenantId; tenantPlugin independently throws without one (agent)turbo.json → globalEnv unchanged — no new env var (agent)GET /api/cron/demo-reset 401s without the bearer token — traced; fails closed when
CRON_SECRET is unset (agent)_ids unchanged and can sign back in — same reason; note the
reset makes no Clerk call at all, so the org and logins are untouched by construction
(operator, pending)4ffe0fb). service, platform and industry are built from createTaxonomySchema, which
supplies tenantId, a per-tenant unique index and tenantPlugin — they are tenant-scoped, not
global as policy.ts claimed. None names tenantId in its own source, so the grep-based
detector left all three unclassified: a service or platform added during a walkthrough survived
every reset, which is the exact accumulation this feature exists to end. metric-definition was
the mirror error — genuinely platform-wide, it matched only because a comment explains it has no
tenantId. The detector now keys on schema construction, and two new tests pin the premise (the
factory really is tenant-scoping; no model is built from an untriaged factory). Classification
follows the seeder: it upserts service/platform (purge), and only reads industry (keep).d06770f). The seeder throws
missingPersona for an absent vendor or CSM, so a demo tenant missing one would be emptied and
then left empty — with every retry repeating the wipe. assertPersonasPresent now runs before
the first delete, against the persona set derived from the storyline rather than seeder
internals.counter did not reset lead request IDs and never could (review, commit d06770f).
They come from tenant.leadRequestSequence; nothing in the app writes the counter collection
at all. The reset now zeroes that field, so two resets really do produce the same request-ID
sequence. The false claim is corrected in policy.ts and the spec.4ffe0fb). It returned
200 with ok:false in the body, so Vercel recorded a green run over a sweep that reset nothing —
the exact shape the failure takes if isDemo was never set in production. Now 500, and an empty
target list counts as failure too.csm-portfolio-snapshot was purged nightly (readiness ⚠️, commit 4ffe0fb). The 02:00
snapshot was destroyed an hour later, so changeVsYesterday on the CSM activation table — a
surface walkthroughs open on — would have read blank permanently, not just on the first day as
the spec's open question assumed. Moved to kept.d06770f) — now wrapped.4ffe0fb) — curated labels.Record<PurgedModelFile, …> lock proves each name has a deleter, not the right one.
True: a mis-paired getter would type-check and silently leave a collection unpurged. Accepted —
the pairing is 26 one-line entries, read twice during this stage, and a mis-pair surfaces as a
permanently 0 count in the admin summary. Closing it properly needs the integration tier.resetDemoTenant is not — reordering them would leave the suite green.
Accepted: asserting call order is testing implementation, which CONVENTIONS.md → Testing
forbids, and the real assertion (a refused reset deleted nothing) needs a database.batch tokens. Accepted for now — with the schedule held back there is
only one trigger, so the window does not exist yet. It must be revisited in the follow-up that
enables the cron.deleteMany calls) targets the wrong half:
the purge is 26 round trips against the seed's ~1,600.The vercel.json cron entry is not in this PR (Jamie, 2026-08-13). The success path cannot be
exercised on preview, so the first unattended firing would also be the first successful run of an
irreversible purge. The route ships ready and documents its own suggested schedule; enabling it is
a one-line follow-up after a hand-run reset in production and a two-run count diff. Two acceptance
criteria travel with it and are left unticked here.
apps/docs technical/demo-environment still documents snapshot create/restore machinery that
has never existed. Assigned to Ship's docs-sync, in this same PR, per spec §6.Context budget: exceeded the Inputs table deliberately — the classification finding required
reading models/taxonomy.ts, three model files, both plugins and the seeder's persona and
catalogue paths to confirm the audit's claim rather than take it on trust.
06_ship/output/changelog.mdNo end-user changelog entry. Deliberate, not an oversight.
The help-centre changelog is written for the people who use Sustentus. This run ships an internal
operations tool for our own demo tenant: resetDemoTenant hard-refuses any tenant not flagged
isDemo: true, so there is no tenant on the platform — and no customer, of any persona — for whom
this changes anything. Announcing it in the help centre would describe a capability its readers
cannot reach.
The audience that does care is the team running client walkthroughs, and they are served by the two artifacts this run did produce:
#product-update (06_ship/output/investor-update.md), framed as velocity;technical/demo-environment,
which also drops the snapshot machinery that page had documented but that never existed.If the demo tenant ever becomes something customers touch directly, that is the change that earns a changelog entry — not this one.
06_ship/output/investor-update.mdWho it's for: Admin — the presenter running a client walkthrough What shipped: A reset that purges the demo tenant and re-seeds the approved storyline, so it converges instead of accumulating. Why it matters: Every prospect sees the same clean, credible world — Refine the Bridge / Q2 Objective 1, Establish Product-Market Fit with Vendor Partners.
The six persona logins survive the purge and sign straight back in.
Dig deeper: https://github.com/sustentus/sustentus/pull/797
06_ship/output/release.md6825719 (Quality Project success — format, lint, typecheck, tests; plus
Audit database, Migrate preview database, Project run labels and the three pipeline
advisories). One red round earlier in the run: two Prettier diffs on the first push, fixed in
ec3a406.apps/docs/app/technical/demo-environment/page.mdx — rewritten.github/workflows/ship-note.yaml sends on mergeTechnical. technical/demo-environment carried real drift: it documented snapshot creation and
restore, and told the reader "to reset a tenant, use snapshot restore below" — machinery that has
never existed in this codebase. That block is gone, replaced by what actually ships:
industry is kept while the rest of the catalogue is purged), the leadRequestSequence
reset, and the explicit statement that the operation is not atomic and converges on a re-run.CRON_SECRET gate, and the fact that it is deliberately not
scheduled yet.Also corrected: the overview bullet and the Setup flag list (both advertised snapshot management), and the Admin role capability list.
Business. No business docs impact. The reset refuses any tenant not flagged isDemo: true, so
no persona capability, service-journey step or feature-role-matrix entry changes for a real tenant —
business/** describes the product customers use, and this is tooling for our own demo.
Ship note only. Reasoning in changelog.md: the help centre is written for people who use
Sustentus, and no customer can reach this. The team running walkthroughs is the real audience, and
they are served by the ship note and the docs runbook.
requireDemoTenant before the first delete; both refusal cases unit-covered — Verifyreseed-actor.test.tsdeleteMany — traced; tenantPlugin throws
without a tenantId, so it is enforced, not merely observedpolicy.test.ts; re-derived independently at Verify
(34 detected, 34 classified, 0 stale)GET /api/cron/demo-reset 401s without the bearer token; 500s when a tenant fails or none
matchedturbo.json → globalEnv unchanged — no new environment variableapps/docs technical/demo-environment describes the shipped reset and the runbook, with no
surviving reference to snapshot creation or restore — this stageisDemo: true tenant (db-migrate.yaml withholds
DEMO_TENANT_CLERK_ORG_ID from preview), so only the refusal paths are reachable there_ids unchanged and sign back in — same reasonmaxDuration — travels with the schedule, which this PR
deliberately does not carry/admin/settings/demo-data, record per-collection
counts, reset again, diff. That closes the three convergence criteria above.apps/web/vercel.json —
{ "path": "/api/cron/demo-reset", "schedule": "0 1 * * *" }. The route documents this itself.Context budget: within the Inputs table.