Skip to Content

← All archived runs

Run: demo-reset-ops

run.md

Run: demo-reset-ops

  • branch: claude/pipeline-demo-reset-ops-togpul
  • pr: #797

03_define/output/spec.md

Spec: Demo reset — purge-and-reseed, manual trigger + nightly cron

  • slug: demo-reset-ops
  • personas: Admin
  • touches: packages/services/src/db/services/demo-data, packages/services/src/db/services/tenant, apps/web/app/(app)/admin/demo-data, apps/web/app/(app)/admin/settings/demo-data, apps/web/app/api/cron/demo-reset, apps/web/vercel.json, apps/docs/app/technical/demo-environment
  • complexity: complex
  • demo: none

Problem

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

Proposed change

A resetDemoTenant service that purges then reseeds, plus the two ways to fire it and the docs correction.

1. resetDemoTenant in @sustentus/services

A 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 }

2. Purge is deny-by-default over tenant-scoped collections

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:

  • The tenant-scoped catalogue is purged, not preserved. The stub left this open. 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.
  • Lead request IDs are reset explicitly. They are allocated from 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.
  • Non-persona seeded people are data, not identity. The nine customer companies and five delivery experts the seeder upserts by email have no Clerk login; they are purged and recreated. The six personas have one, so they survive and their _ids are stable across resets.

Corrected at Verify (2026-08-13). service, platform and industry are built from createTaxonomySchema, which gives each a per-tenant tenantId — they are not global, as this section first claimed, and were originally left unclassified. metric-definition is the mirror error: genuinely platform-wide, it was wrongly listed as kept. csm-portfolio-snapshot moved 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 word tenantId in the source.

3. Identities survive

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.

4. Manual trigger — admin button

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.

5. Nightly cron

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.

6. Runbook and docs

  • A client-session runbook — what to do before and after a walkthrough — added to the docs page's demo-data section.
  • A docs sync of 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.

Failure behaviour

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.

Acceptance criteria

  • 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.
  • Two consecutive resets against the seeded demo tenant leave identical per-collection counts, and those counts match a single seed run: 25 live engagements plus the 36-engagement tail, one batch token in the world, no remnants of earlier batches.
  • A reset run against a tenant that has been walked through (extra leads, a CSAT response, a status change, notifications) returns it to the same counts as the criterion above.
  • The six persona users survive with their _ids unchanged and can sign in immediately after a reset; no Clerk API call is made by the reset path.
  • The reset refuses before purging when the tenant has no admin-role user with a clerkUserId.
  • Global collections are untouched: tenant, action-type and metric-definition document counts are identical before and after a reset, and no unscoped deleteMany exists in the reset path.
  • Every model whose schema carries 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.
  • The admin reset button appears on /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.
  • The nightly job completes inside its 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.

Out of scope

  • Snapshots, backups, point-in-time restore. Reset means purge-and-reseed; the docs sync deletes those promises rather than building them.
  • Generalised tenant-deletion tooling. This path only ever runs behind requireDemoTenant.
  • Changing what the seeder generates. The storyline is fixed by demo-seed-storyline; populateDemoLeads stays append-only and is called unchanged.
  • Making the seeder itself idempotent. Convergence is the reset's job, not the seeder's.
  • Any Clerk-side change — no user creation, deletion, rename or org change.
  • Behaviour change for non-demo tenants. Nothing new runs for a tenant without isDemo: true.
  • A reset audit trail or history UI. The cron log and the action result are the record.
  • The plaintext demo passwords committed on the docs page — flagged by demo-seed-storyline as a separate /pipeline bug, still not this run's to fix.

Open questions

  • Non-blocking: the demo tenant's 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.
  • Non-blocking: purge-then-reseed on a 300s ceiling is the run's tightest budget — the seed alone is ~1,600 sequential round-trips. If Verify finds the nightly job at the edge, batching the purge's deleteMany calls concurrently is the first lever, not a schedule change.

04_build/output/notes.md

Build notes: demo-reset-ops

  • commits: feat: demo-reset-ops — purge-and-reseed service, admin trigger + nightly cron
  • demo: none (Design was skipped for this batch — the spec header carries demo: none)

What changed

  • 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 * * *.
  • Tests: policy.test.ts (registry drift, three cases) and reseed-actor.test.ts.

Two facts from source that shape the implementation

  • The tenant plugin turns AC 6 into a runtime guarantee, not just a convention. Every tenant-scoped model applies 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.

One spec detail corrected against source

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.

Acceptance criteria status

  • 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.
  • Two consecutive resets leave identical counts — purge is total over the 25 collections and the seeder is fully authored (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.
  • A walked-through tenant returns to the same counts — walkthrough residue (CSAT, notifications, activities, status history, view-as audit, portfolio snapshots) is in the purged set rather than in a list of what the seeder happens to write.
  • Persona users survive with _ids unchanged; no Clerk call — the purge keeps user rows with a clerkUserId string, and nothing in demo-reset/ imports a Clerk client.
  • Refuses when there is no Clerk-backed admin — assertReseedActor, called before the purge loop, unit-covered in reseed-actor.test.ts.
  • Global collections untouched, no unscoped deleteMany — the six global models are never imported here, and every delete goes through deleteForTenant, which always supplies tenantId.
  • Every tenant-scoped model classified — policy.test.ts scans src/db/models/ and fails on an unclassified model, a double-classified one, or a stale name.
  • Admin reset button with confirmation naming the tenant, reporting counts — on /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 * * *.
  • The nightly job completes inside maxDurationcannot 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.

Notes for Verify

  • Look hardest at the keep-list. It is the whole safety argument, and it is a judgement call per collection. 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.
  • The convergence criterion deserves a real two-reset run, not a code read: reset, record per-collection counts, reset again, diff. That is the criterion the whole feature exists for and the one most likely to surface something the diff hides.
  • The 300s ceiling is the known risk (spec, Open questions). Purge adds 25 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.
  • I did not run format, lint, typecheck or tests locally (blocked by design); Quality and the Vercel preview are the signal.
  • Husky does not run in this remote session, so the format gate has no local half. The container has no 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.md

Verify: demo-reset-ops

  • production-readiness: run — 2 blockers, 4 advisories. Both blockers resolved (one by a code fix, one by a shipping decision); 3 advisories fixed, 1 accepted.
  • code-review: high (spec complexity: complex) — 7 findings. 3 fixed on branch, 4 accepted with reasons below.
  • security-review: run — no findings at or above the confidence bar.
  • playwright: TODO — manual DoD smoke performed instead

Why the two conditional passes were required

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.

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

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)
  • Refuses when no Clerk-backed admin remains — unit-covered, reseed-actor.test.ts (agent)
  • Refuses when a storyline persona is absent — unit-covered, reseed-actor.test.ts (agent)
  • Every tenant-scoped model is classified — policy.test.ts, and independently re-derived against the filesystem during this stage: 34 detected, 34 classified, 0 stale (agent)
  • No unscoped 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)
  • Two consecutive resets leave identical counts — not demonstrable on preview (no demo tenant). Must be run in production before the schedule is enabled (operator, pending)
  • A walked-through tenant returns to the same counts — same reason (operator, pending)
  • Personas survive with _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)
  • auth: the six personas still sign in and reach their dashboards after a reset (operator, pending — production)
  • payments: not touched (agent)
  • notifications: none expected — the reset raises no notification, and the seeder it calls writes only through models (agent)

Findings & cleanup

Fixed on branch

  1. The purge classification had a hole its own drift test could not see (readiness ❌, commit 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).
  2. The purge ran before the seeder's preconditions (review, commit 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.
  3. Purging 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.
  4. The cron reported success when tenants failed (readiness ⚠️, commit 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.
  5. 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.
  6. Purge errors did not name the collection (review, commit d06770f) — now wrapped.
  7. Raw model file names shown to the admin (readiness ⚠️, commit 4ffe0fb) — curated labels.

Accepted, with reasons

  • The 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.
  • No test pins "refuses before purge". Both guards are covered as pure functions, but their ordering inside 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.
  • No lock against concurrent resets. A manual reset overlapping the cron could interleave purge and reseed and leave two 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.
  • One 300s budget covers a sequential sweep of all demo tenants. Fine at one tenant; two cannot fit. Also for the follow-up — fan out one invocation per tenant before a second demo tenant exists. Note the spec's original lever (batching the deleteMany calls) targets the wrong half: the purge is 26 round trips against the seed's ~1,600.

Decision taken at this stage

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.

Still open at the end of this stage

  • 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.
  • The spec was corrected in place three times during this stage (classification, request-ID sequence, cron schedule). Each correction is marked in the file; the PR body mirrors them.

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

Changelog: demo-reset-ops

No 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:

  • the ship note to #product-update (06_ship/output/investor-update.md), framed as velocity;
  • the client-session runbook and reset reference on the docs site, 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.md

The demo tenant can be reset to a known-good world on demand

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

Ship: demo-reset-ops

  • pr: #797 · merged: pending — see below
  • CI: green on 6825719 (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.
  • technical docs: apps/docs/app/technical/demo-environment/page.mdx — rewritten
  • business docs: no business docs impact (see below)
  • release notes: ship-note only — no end-user note
  • sent: queued — .github/workflows/ship-note.yaml sends on merge

Docs

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

  • Resetting the demo tenant — the gate, the admin path, what is purged, what survives (and why 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.
  • Via cron — the route, its CRON_SECRET gate, and the fact that it is deliberately not scheduled yet.
  • Client-session runbook — before and after a walkthrough.

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.

Release notes

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.

Acceptance check (vs spec)

  • requireDemoTenant before the first delete; both refusal cases unit-covered — Verify
  • Refuses before purging without a Clerk-backed admin, or with any storyline persona absent — unit-covered, reseed-actor.test.ts
  • Global collections untouched; no unscoped deleteMany — traced; tenantPlugin throws without a tenantId, so it is enforced, not merely observed
  • Every tenant-scoped model classified — policy.test.ts; re-derived independently at Verify (34 detected, 34 classified, 0 stale)
  • Admin reset button, admin-only, confirmation names the tenant, reports counts
  • GET /api/cron/demo-reset 401s without the bearer token; 500s when a tenant fails or none matched
  • turbo.json → globalEnv unchanged — no new environment variable
  • apps/docs technical/demo-environment describes the shipped reset and the runbook, with no surviving reference to snapshot creation or restore — this stage
  • Two consecutive resets leave identical counts — not demonstrable before production. Preview carries no isDemo: true tenant (db-migrate.yaml withholds DEMO_TENANT_CLERK_ORG_ID from preview), so only the refusal paths are reachable there
  • A walked-through tenant returns to the same counts — same reason
  • Personas survive with _ids unchanged and sign back in — same reason
  • The nightly job completes inside maxDuration — travels with the schedule, which this PR deliberately does not carry

Follow-up this run hands over

  1. Run the reset by hand in production from /admin/settings/demo-data, record per-collection counts, reset again, diff. That closes the three convergence criteria above.
  2. Then add the cron entry to apps/web/vercel.json{ "path": "/api/cron/demo-reset", "schedule": "0 1 * * *" }. The route documents this itself.
  3. Before a second demo tenant exists, revisit the two findings Verify accepted for later: no lock against concurrent resets, and one 300s budget covering a sequential sweep of all tenants.

Context budget: within the Inputs table.