Skip to Content

← All archived runs

Run: demo-reset-audit-and-cron

run.md

Run: demo-reset-audit-and-cron

  • branch: claude/pipeline-demo-reset-audit-cron-10aps7
  • pr: #813

02_define/output/spec.md

Spec: Persist the reset audit trail and schedule the nightly reset

  • slug: demo-reset-audit-and-cron
  • personas: Admin
  • touches: packages/services/src/db/models/tenant.ts, packages/services/src/db/services/demo-reset/, apps/web/app/(app)/admin/demo-data/, apps/web/app/(app)/admin/settings/demo-data/page.tsx, apps/web/app/api/cron/demo-reset/route.ts, apps/web/vercel.json
  • complexity: standard

Problem

The demo tenant is the sales surface for the Refine the bridge initiative (Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners): a prospect walkthrough runs against it, so it has to open clean and its destructive operations have to be accountable. Neither holds today.

Who reset the tenant, and when, is recorded nowhere. The admin button's result lives only in client component state — it vanishes on navigation, and a reset run from another session or device is invisible. The cron path logs to console and persists nothing. view-as-audit cannot host the record either: it is itself purged on every reset. That is a gap against the demo tenant's stated hard constraint, "an audit trail of who reset and when" (audit report §1.2 item 2).

And the nightly reset never runs. apps/web/vercel.json carries only the 02:00 CSM snapshot job; /api/cron/demo-reset ships complete and authorised but unscheduled, deliberately pending a hand-run and a two-run diff on preview (§1.2 item 3). Both preconditions are now met — preview carries DEMO_TENANT_CLERK_ORG_ID, so a flagged demo tenant exists there — so the tenant still accumulates a demo's residue overnight for no remaining reason.

Proposed change

Record the latest reset on the tenant document (decision D9 — a tenant field, not a new collection: the purge never touches the tenant document, so the record survives the very operation it describes, with no policy classification to maintain).

resetDemoTenant writes the record itself, so the admin button and the nightly sweep produce identical records rather than each inventing their own. The caller identifies the actor: the server action passes the signed-in admin; the sweep passes the cron. Each record carries who, when, the outcome, the purge and re-seed totals, and the storyline that was seeded — and, on a failed reset, the reason instead of the totals.

The actor's display name is captured at write time, so the page renders "who" without a second lookup and the record stays readable even if that user later disappears from the tenant.

Failures are recorded only once the tenant has cleared requireDemoTenant — a guard refusal writes nothing at all, because the tenant it refused is by definition not a demo tenant and nothing may write to it. Recording the failure must never mask the underlying error the caller sees.

The admin demo-data settings page then renders the record server-side on load ("Last reset: …"), replacing reliance on transient client state. The existing button keeps its live per-collection breakdown for the reset the admin just ran; the persisted record is what the page shows on a cold load.

Finally, schedule the cron: {"path": "/api/cron/demo-reset", "schedule": "0 1 * * *"} in apps/web/vercel.json — an hour before the 02:00 snapshot job, so each morning's snapshot describes the freshly reset world (D10). The route's "Not yet scheduled" header comment is rewritten to describe the schedule that now exists.

Acceptance criteria

  • A reset from the admin button records, on the tenant document: the acting admin (id and display name), the timestamp, a success outcome, the total documents purged, the leads re-seeded, and the storyline id.
  • A reset from the nightly sweep records the same fields for each tenant it resets, with the actor recorded as the cron rather than a user.
  • A reset that fails after requireDemoTenant has passed (e.g. no Clerk-backed admin to re-seed as, or a purge that throws) records a failure outcome with the reason, and replaces any previous record. The caller still receives the original error unchanged; a failure to write the record does not replace it.
  • A reset refused by requireDemoTenant (tenant missing, or not flagged isDemo) writes nothing to that tenant.
  • The record holds the latest reset only — a second reset replaces the first (D9).
  • /admin/settings/demo-data shows the last reset — who, when, outcome — read on the server during render, present on a cold load in a fresh browser session with no prior client state. A tenant that has never been reset shows an explicit never-reset state, not a blank or a placeholder date.
  • apps/web/vercel.json carries {"path": "/api/cron/demo-reset", "schedule": "0 1 * * *"} alongside the existing snapshot job.
  • The /api/cron/demo-reset route header comment no longer says the job is unscheduled and describes the schedule that now exists.
  • The tenant-scoping guarantee is intact: requireDemoTenant still precedes every write on both paths, and the new tenant fields are the only additions to what a reset writes outside the seeder.
  • Unit coverage for the record the service produces on the success and post-guard failure paths, alongside the existing policy.test.ts / reseed-actor.test.ts.

Out of scope

  • Reset history. The tenant field holds the latest reset only — D9 chose this explicitly. No history collection, no append-only log.
  • Any seeder change. demo-data and the storyline are untouched; the record reports the seeder's existing result, it does not alter it.
  • A sweep-level record. Nothing is written when the sweep finds zero isDemo: true tenants — there is no tenant to write to. The route's existing 500 on an empty sweep stands unchanged.
  • The per-collection purge breakdown in the persisted record. The record carries totals; the live breakdown stays a property of the button's in-session result.
  • Reworking the button's live result panel beyond what rendering the persisted record requires.
  • Backfilling the record for resets already run. Tenants start with no record until their next reset.
  • Any change to cron authorisation (CRON_SECRET) or to the route's response contract.

Open questions

  • none

Context budget: read the stub, _source/decisions.md (D9, D10) and _source/audit-report.md §1.2 in place of a scope.md — this epic was cut from an audit and its breakdown records that it has no scope.md; decisions.md is its settled equivalent. Plus targeted reads of the five files the stub cites, to fix touches: and the exact write points. Initiative/objective carried from the stub rather than re-derived from the knowledge map.

03_build/output/notes.md

Build notes: demo-reset-audit-and-cron

  • commits: 5f98a34 feat: persist the reset audit trail, schedule the nightly reset · dc0eb5e style: collapse an import Husky did not format

What changed

  • packages/services/src/db/models/tenant.ts: optional lastReset sub-document — at, actorKind (user | cron), actorId, actorLabel, outcome, purged, seededLeads, storylineId, error. No default, so absence is what distinguishes a never-reset tenant from a reset with empty totals. Optional and unindexed → no migration (checked against the mongoose-model skill's idiom: plugin trio and factory untouched, no new index, no reserved pathname).
  • packages/services/src/db/services/demo-reset/index.ts: resetDemoTenant takes a ResetActor ({kind:"user",id,label} or {kind:"cron"}) and records the outcome itself. Pure resetRecord(actor, at, detail) builds the record — the clock is the caller's, so both outcomes are unit-covered without a DB, mirroring assertReseedActor. The purge/seed sequence moved into a private purgeAndSeed so the try/catch wrapping it stays readable. resetAllDemoTenants passes {kind:"cron"}.
  • packages/services/src/utils/date.ts: formatDateTime — date alone is ambiguous for a job that runs nightly. Added to the existing shared date home rather than as a third copy (utils/date.ts and components/workspace/format.ts already both carry a formatDate).
  • apps/web/app/(app)/admin/demo-data/actions.ts: passes the signed-in admin, label from the existing displayName helper with email as fallback.
  • apps/web/app/(app)/admin/demo-data/_components/demo-reset-summary.tsx: new server component rendering the persisted record; explicit never-reset copy.
  • apps/web/app/(app)/admin/settings/demo-data/page.tsx: renders it above the reset button from the tenant the page already loads — no new query.
  • apps/web/vercel.json + app/api/cron/demo-reset/route.ts: the 0 1 * * * entry, and the header comment rewritten from "not yet scheduled".

Acceptance criteria status

  • Button reset records actor id + display name, timestamp, success, purged total, leads re-seeded, storyline id — resetDemoTenant success path.
  • Sweep records the same fields with the cron as actor — resetAllDemoTenants passes {kind:"cron"}; actorLabel is the shared CRON_ACTOR_LABEL, actorId absent.
  • Post-guard failure records the reason and replaces the previous record ($set); the original error is rethrown untouched. Building the record happens inside recordReset's try precisely so an actor-id cast failure cannot replace it.
  • A guard refusal writes nothing — requireDemoTenant is outside the try, so nothing in the recording path is reachable for a tenant it refuses.
  • Latest only — a single $set on one sub-document, no history.
  • Page shows who/when/outcome server-side on cold load; never-reset state is explicit copy.
  • vercel.json carries {"path": "/api/cron/demo-reset", "schedule": "0 1 * * *"}.
  • Route header comment rewritten to describe the live schedule.
  • Tenant scoping intact — requireDemoTenant still precedes every write on both paths; lastReset is the only addition to what a reset writes outside the seeder.
  • Unit coverage — reset-record.test.ts (6 tests, success + post-guard failure, user + cron), plus formatDateTime cases alongside the existing formatDate ones.

Notes for Verify

  • Husky's pre-commit format did not fire in this session — the first Quality run failed on format:check for a single import in demo-reset/index.ts that lint-staged would normally have collapsed. Fixed by hand (Prettier is blocked locally by the hook). Worth knowing that the pre-commit safety net was not actually in play for this branch, so formatting rests on CI.
  • Tests written, not run — per the contract. reset-record.test.ts and the two date.test.ts additions are the new assertions; read them off the Quality check run.
  • resetDemoTenant signature changed (actor is now the second positional param). Both call sites are updated — the server action and the sweep — and there are no others; worth a grep at review to confirm.
  • formatDateTime renders in server-local time (UTC on Vercel). Its test constructs the date from local components, like the neighbouring formatDate test, so it is timezone-stable; the rendered page shows UTC rather than the admin's local time. Called out rather than solved — a tz-aware surface is a wider decision than this run.
  • The cron cannot be smoke-tested on the preview. Vercel fires crons on production deployments only, so the 0 1 * * * entry proves itself on the first night after merge. The button path exercises the identical recording code on the preview.
  • Schema change ships without a migration by design (optional, unindexed). Existing tenants simply have no lastReset until their next reset — which is the never-reset state the page renders.

04_verify/output/verify.md

Verify: demo-reset-audit-and-cron

  • production-readiness: run (diff touches the database) — 6 findings, no blockers in the code; 3 fixed on branch, 1 escalated to the operator, 2 sent to intake. Sections: secrets ✅ · database ✅ · auth ✅ · output ⚠️ · delivery ⚠️ · deploy ⚠️ (pending merge).
  • code-review: medium (spec complexity standard) — CI Claude review is disabled (ENABLE_CLAUDE_REVIEW unset; the "Review diff against CONVENTIONS.md" job reports skipped), so this was run locally rather than triaged. 2 findings, both also caught by production-readiness; both addressed.
  • security-review: run (the diff newly persists a person's name, with email as fallback) — no HIGH or MEDIUM findings. Run inline rather than fanned out into sub-agents, per this session's no-subagent rule; the same false-positive filter was applied.
  • playwright: TODO — manual DoD smoke performed instead
  • CI on the verify commit b724874: Quality green (format · lint · typecheck · tests), Migrate preview database green, Audit database green — the last being independent confirmation that the lastReset sub-document introduced no leanness finding.

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

Preview: https://web-git-claude-pipeline-demo-reset-audit-cron-10aps7-sustentus.vercel.app (deployment 35yUH2qJBaFnWxogChhYhQmMndHK, READY on 46cc80f).

Agent-verified — reachable without signing in:

  • Preview is up and the feature's page is correctly gated — GET /admin/settings/demo-data returns 307 → /sign-in?redirect_url=%2Fadmin%2Fsettings%2Fdemo-data (agent)
  • AC7 vercel.json carries {"path": "/api/cron/demo-reset", "schedule": "0 1 * * *"} alongside the 02:00 snapshot job — file read on the branch tip (agent)
  • AC8 route header no longer claims the job is unscheduled; it states the 01:00 schedule, that Vercel fires crons on production only, and the single-tenant sizing assumption (agent)
  • AC5 latest-only — a single $set of the whole lastReset sub-document, no history collection, no $push; a failure record therefore also drops the success-only totals (agent)
  • AC9 tenant scoping — requireDemoTenant sits outside the recording try (demo-reset/index.ts:288), so a tenant it refuses receives no write at all; lastReset is the only addition to what a reset writes outside the seeder (agent, traced in diff)
  • AC3 the record build happens inside recordReset's try, so an actor-id cast failure cannot replace the error the caller is about to rethrow (agent, traced in diff)
  • Signature change fully propagated — resetDemoTenant(tenantId, actor, storyline?); both call sites updated, repo-wide grep finds no third (agent)
  • No new process.env reads anywhere in the diff, so turbo.json globalEnv needs no addition; CRON_SECRET was already catalogued (agent)

Operator-demonstrated — all four reported passing by Jamie, 2026-08-17:

  • AC1 a reset from the button records the acting admin's name, the timestamp, success, purged total, leads re-seeded and storyline id (operator)
  • AC6 /admin/settings/demo-data shows the last reset on a cold load in a fresh browser session, with no client state in play (operator)
  • AC6 a tenant that has never been reset shows the explicit never-reset copy rather than a blank or a placeholder date — checked before the first reset, the only time it is observable (operator)
  • auth: admin persona signs in and reaches the dashboard (operator)
  • payments: not touched by this diff — no check required
  • notifications: none expected — this run adds no notification path (agent, verified in diff: no notifyX call, no Ably publish, no Resend send)
  • AC2 cron actor — not demonstrable before merge, by design. Vercel fires crons against production deployments only, so the first unattended firing is the first real run. The button path (AC1, passed above) exercises the identical recording code; the cron-specific half is the {kind:"cron"} argument, unit-covered in reset-record.test.ts. Left unticked deliberately — it is the one criterion nothing before merge can evidence.

Walkthrough clip (#build): not recorded at the time of writing. Visibility, not a gate — the contract is explicit that the business's approval ended at Scope.

Findings & cleanup

Fixed on this branch:

  • Timestamp had no timezone (both reviews) — an admin in CEST would read a 01:00 UTC cron as having run at 03:00. Note the obvious fix was wrong: appending a literal "UTC" to the old date-fns format string would have been a lie off-Vercel, since format renders in the runtime's zone. formatDateTime now formats the instant explicitly in UTC via Intl and labels it — true in every environment, and it makes the test timezone-independent rather than merely timezone-lucky.
  • Stored error text was unbounded and rendered on a product surface — capped at MAX_RESET_ERROR_LENGTH (500) at write time; the full text stays in the function logs.
  • Single-tenant sizing was undocumented — the sweep resets tenants sequentially inside one 300s budget. Recorded in the route header, including the consequence that matters to this feature: a timeout is the one failure the audit trail cannot show, because the function dies before recordReset runs and lastReset still reads as the previous night's success.

Escalated and RESOLVED — no code change needed:

  • A nightly-red cron if production has no flagged demo tenant. resetAllDemoTenants resolves targets from isDemo: true, set only by 1786492800000-flag-demo-tenant, which skips silently when DEMO_TENANT_CLERK_ORG_ID is unset — and db-migrate.yaml documents that as a valid state, so nothing in the branch could assert it. Jamie confirmed (2026-08-17) that production holds a flagged demo tenant, so the sweep has a target and the 500-on-empty path is unreachable in practice. The route's response contract is therefore left exactly as it was — which is what the spec's out-of-scope line required. Note the guarantee is operational, not structural: if the flag were ever cleared, the job goes red nightly rather than silently doing nothing, which is the behaviour you want from a misconfiguration.

Deferred to Ship (in this PR, not after):

  • apps/docstechnical/demo-environment still says the route is "not yet scheduled" — now false — and does not mention tenant.lastReset. docs-sync runs at Ship.
  • No changelog entry yet; the reset summary panel is new admin-visible UI, so one is warranted. changelog-entry runs at Ship.

Sent to intake, not release scope:

  • A third formatDateTime lives in apps/web/components/leads/progress-list.tsx:16 with different output; the new shared one is the right home and the local one should collapse into it.
  • Raising the sweep's maxDuration (or bounding the sweep) before a second demo tenant exists.

Process note carried from Build: Husky's pre-commit lint-staged did not fire in this session — the first Quality run failed on format:check for one import. Formatting on this branch rested entirely on CI, which caught it.

05_ship/output/changelog.md


title: The demo tenant now opens clean each morning, and records who reset it date: 2026-08-17T10:40:00Z personas: [admin] slug: demo-reset-audit-and-cron pr: https://github.com/sustentus/sustentus/pull/813

The demo tenant now opens clean each morning, and records who reset it

Resetting the demo tenant used to leave no trace. The result appeared once, in the browser tab that ran it, and vanished on navigation — so if a colleague reset the tenant, or you reset it yesterday from another device, there was nothing to tell you.

Configure → Demo data now shows the last reset: who ran it, when, and whether it succeeded, along with how much was cleared and re-seeded. It is read fresh every time the page opens, so it is there after a reload, on another machine, and for anyone else on the tenant. A tenant nobody has reset yet says so plainly rather than showing a blank.

The reset also runs on its own at 01:00 UTC each night, so the demo world is back to the approved storyline before the working day starts instead of carrying the residue of the last walkthrough. Nightly runs are recorded the same way, listed as the scheduled job rather than a person — so a morning that opens on a clean tenant is something you can confirm rather than assume.

Times are shown in UTC and labelled, so a reset never reads as having happened at a different hour than it did.

Resetting by hand works exactly as before, and what the reset clears and keeps is unchanged.

05_ship/output/investor-update.md

The demo tenant opens clean every morning, and every reset says who ran it

Who it's for: Admins running client walkthroughs What shipped: The demo tenant resets itself nightly, and the admin page shows who last reset it and when. Why it matters: Walkthroughs start on the approved story, not yesterday's residue — Refine the Bridge, Q2-2026 Objective 1: Establish Product-Market Fit with Vendor Partners.

Before this, that was recorded nowhere.

Dig deeper: https://github.com/sustentus/sustentus/pull/813 · https://help.sustentus.com/changelog/2026-08-17-demo-reset-audit-and-cron

05_ship/output/release.md

Ship: demo-reset-audit-and-cron

  • pr: #813 · merge: authorised — Ready to merge ticked; this commit rides the squash
  • CI: green through Verify (Quality — format · lint · typecheck · tests; Migrate preview database; Audit database). Re-checked once on the ship commit before the merge.
  • technical docs: apps/docs/app/technical/demo-environment (the "not yet scheduled" claim was false as of this branch — replaced with the live 01:00 schedule, the single-tenant sizing assumption, and a new Audit trail subsection) · apps/docs/app/technical/deployment (the cron table gained the 0 1 * * * row, with a line on why the ordering against the 02:00 snapshot job is deliberate)
  • business docs: no business docs impact — the only demo tenant mentions under business/ are snapshot management and presenter persona switching, and this run touches neither. The change is an admin/demo-ops capability, documented under technical/.
  • release notes: both
  • sent: ship note queued to #product-update on merge (2026-08-17) — ship-note.yaml fires on the squash and sends 05_ship/output/investor-update.md verbatim

Acceptance check (vs spec)

  • Button reset records the acting admin (id + display name), timestamp, success, purged total, leads re-seeded, storyline id — demonstrated on the preview by the operator (Verify)
  • Sweep records the same fields with the cron as actor — unit-covered (reset-record.test.ts); not demonstrable before merge, since Vercel fires crons against production only. Proves itself on the first nightly run after this merge.
  • Post-guard failure records the reason and replaces the previous record; the caller's original error is rethrown unchanged — unit-covered, and the record build sits inside recordReset's try precisely so a cast failure cannot mask it
  • A guard-refused reset writes nothing — requireDemoTenant is outside the recording try (traced in Verify)
  • Latest reset only — one $set of the whole sub-document, no history (D9)
  • /admin/settings/demo-data shows who/when/outcome on a cold load, and an explicit never-reset state — both demonstrated on the preview by the operator (Verify)
  • apps/web/vercel.json carries {"path": "/api/cron/demo-reset", "schedule": "0 1 * * *"}
  • Route header rewritten to describe the live schedule
  • Tenant scoping intact — requireDemoTenant still precedes every write on both paths
  • Unit coverage for the record on success and post-guard failure paths — green in Quality

Notes

Three findings from Verify were fixed on this branch rather than deferred: the timestamp is now formatted explicitly in UTC and labelled (appending a literal "UTC" to the old format string would have been wrong — date-fns format renders in the runtime's zone, which is UTC only on Vercel), the stored failure reason is capped at 500 characters, and the single-tenant sizing assumption is recorded in the route header and now in the docs.

One finding was escalated rather than fixed: resetAllDemoTenants 500s on an empty sweep, so a production without a flagged demo tenant would go red nightly. Jamie confirmed production holds a flagged demo tenant, so the path is unreachable and the route's response contract stays untouched — which the spec's out-of-scope line required. The guarantee is operational, not structural: clear that flag and the job goes red nightly rather than silently doing nothing.

Sent to intake rather than release scope: a third formatDateTime in apps/web/components/leads/progress-list.tsx that should collapse into the shared one, and raising the sweep's maxDuration before a second demo tenant exists.