Skip to Content

← All archived intake epics

Intake: demo-data-quality

breakdown.md

Breakdown: Demo tenant data quality — audit-driven cleanup + ICP-accurate seeding

  • scope-slug: demo-data-quality · doc: _source/audit-report.md (Phases 1–3 audit/ICP/coverage report, 2026-08-14) + _source/decisions.md (Jamie's Phase 4 decisions, D1–D11 — this cut's equivalent of the Q-n table; there is no scope.md behind this epic)
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer

What I understood

The demo tenant's data layer was audited (schema, indexes, live prod/preview state via the nightly CI audit, reset policy, migrations), the ICP was derived from marketing + docs, and a coverage matrix of every feature surface the seed must light was built from code. Jamie approved a body of work: fix the correctness bugs the audit proved (two unique indexes that never build, no usable tenant-leading index on leads, an SLA stage map that predates the workflow engine), strip the proven-dead schema weight, fix two customer surfaces broken by product code, then extend the existing storyline seeder — never a parallel one — so every entity state and dashboard widget is exercised, reframed as a SaaS-vendor-with-services-arm world, with a persisted reset audit trail and the nightly reset cron finally scheduled. This epic cuts that into independently-shippable runs so any session can pick up the next stub cold; _source/ carries all the evidence so no stub needs to re-audit.

Where it sits

Data layer of every service-journey step (lead intake → delivery → invoicing → CSAT) and every feature-role-matrix entity; lands in packages/services (models, migrations, workflows-adjacent maps, demo-data, demo-reset) and apps/web (two customer surfaces, admin demo-data page, vercel.json cron).

Build order

  1. fix-broken-partial-uniques — rewrite the two $exists:false partial unique indexes that never build — depends-on: none
  2. hot-path-tenant-indexes — tenant-leading indexes for the queries dashboards actually run; drop clear prefix-redundancies — depends-on: none
  3. remove-dead-lead-fields — strip the proven-dead lead fields and the impossible awaitingApproval badge — depends-on: none
  4. remove-dead-user-fields — strip the user model's dead cluster; decide the never-written verification panel — depends-on: none
  5. remove-dead-misc-schema — drop the dead counter model; invoice/activity/notification/skill/status-history dead weight — depends-on: none
  6. sla-stage-map-engine-alignment — map all nine engine statuses to SLA stages so bands compute past intake — depends-on: none
  7. customer-surface-fixes — real data behind the customer blockers card; satisfaction strip off the nonexistent csat status — depends-on: none
  8. demo-reset-audit-and-cron — persist who-reset-when on the tenant document; schedule the nightly reset cron — depends-on: none
  9. storyline-full-coverage-generator — extend the Storyline contract + generator so every dark surface seeds (invoice/quote/milestone states, evidence, notifications, SDM outreach, snapshot, overdue items) — depends-on: sla-stage-map-engine-alignment
  10. saas-vendor-storyline — author the SaaS-vendor-services-arm world (content tables need Jamie's sign-off in the PR) and make it the active storyline — depends-on: storyline-full-coverage-generator

Parallelizable

Stubs 1–8 are mutually independent — disjoint files, each its own migration where one is needed — and can run as eight parallel sessions; the shared preview-migration lock serialises their migrations safely on its own. Stub 9 hard-depends only on 6 (its overdue/breached acceptance criteria are unverifiable until SLA bands compute), and should rebase over whatever of 3/5 has merged (same models are touched, different lines). Stub 10 is the only strictly sequential tail: it authors content against the contract 9 defines. Foundation-first tie-break orders 1–8.

Out of scope (whole scope)

  • Production residue purge (orphaned deleted-tenant documents, ~27 collections at up to 100% of sample) and dropping the 11 zombie collections — destructive prod migrations Jamie deferred to a separate gated run; evidence in _source/audit-report.md §1.1 / finding #4. Belongs to the db-audit-findings triage when picked up (D2).
  • Dropping the ~15 "unused index" suspects — the nightly $indexStats window is too weak (0 reads on nearly everything, restart window unknown); confirm over a longer window first (D1).
  • assertTransition enforcement for milestone/expert-evidence transitions — real doctrine gap, explicitly not selected for this round (D1/D5).
  • Consolidating lead's parallel stage booleans (bidPool, isActiveProject, isBrdApproved, isRejected) into the engine — load-bearing; needs its own scope.
  • The marketing/docs ICP contradictions (report Phase 2, A1–A8: SaaS-vendor vs services-firm framing, vendor lead-capability matrix conflict, laws-of-India terms boilerplate, stale tier names) — docs/marketing copy work, not data-layer work.

Context budget: cut from _source/ (audit report + decisions), not from a scope.md; codebase evidence was gathered by the audit session and is cited file:line in the report, so this cut read no source code itself.

_done/customer-surface-fixes.md

Stub: Fix the two customer surfaces broken by product code

  • feature-slug: customer-surface-fixes
  • scope: demo-data-quality
  • personas: Customer
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 7 of 10

Problem

Two customer-persona surfaces are wrong regardless of what the seed writes: the customer dashboard's blockers card renders a hardcoded fixture (flagged P0 in the fixture file itself), and /customer/satisfaction's "awaiting your feedback" strip filters leads on statusNames: ["csat"] — a status that does not exist in the workflow engine, so no engine-legal lead can ever light it.

Proposed change

Wire the blockers card to the customer's real blocker data (the health engine and workspace already read real blockers — reuse that path), and point the satisfaction strip at the engine's survey stage (survey_sent).

Acceptance criteria (rough)

  • _customer-fixture.ts deleted; the card shows the active project's real open blockers and an honest empty state when there are none.
  • A lead in survey_sent for the signed-in customer appears in the "awaiting your feedback" strip; completing the CSAT removes it.
  • No other persona surface changes.

Out of scope (this feature)

  • Any seeding change (the storyline stubs cover data).
  • Redesign of either surface — this is a data-wiring fix, not UI work.

Notes for Define

Decision D3. Evidence: _source/audit-report.md Phase 3 "surfaces seed data cannot light" items 1–2; apps/web/app/(app)/customer/dashboard/page.tsx:196 + _customer-fixture.ts:1-6; /customer/satisfaction page filter (statusNames:["csat"], page.tsx:37); real-blocker reads in services/customer-project/health.ts:60-134. touches: apps/web/app/(app)/customer/. Follow the server-action/web-route skills' patterns.

_done/demo-reset-audit-and-cron.md

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

  • feature-slug: demo-reset-audit-and-cron
  • scope: demo-data-quality
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 8 of 10

Problem

Who reset the demo tenant, and when, is recorded nowhere: the admin button's result lives only in client component state, the cron logs to console. That violates the demo tenant's hard constraint ("an audit trail of who reset and when"). And the nightly reset cron route ships ready but unscheduled — vercel.json carries only the 02:00 CSM snapshot — so the tenant does not actually open clean each morning.

Proposed change

Record the latest reset on the tenant document (Jamie chose the tenant-field option over a new collection, D9): actor (user id, or "cron"), timestamp, outcome, purged/seeded totals, storyline id — written by resetDemoTenant itself so both the button and the cron paths record identically, surviving trivially since the purge never touches the tenant document. Render it on the admin demo-data page ("Last reset: …"). Then schedule the cron: {"path": "/api/cron/demo-reset", "schedule": "0 1 * * *"} — an hour before the snapshot cron, exactly as the route's header comment prescribes.

Acceptance criteria (rough)

  • A reset from the button records actor + timestamp + outcome on the tenant; a cron sweep records "cron" as actor. Failed resets record the failure.
  • The admin demo-data page shows the last reset (who, when, outcome) on load — server-read, not client state.
  • apps/web/vercel.json carries the demo-reset schedule at 0 1 * * *; the route's "Not yet scheduled" header comment is updated to reflect reality.
  • The tenant-scoping guarantee is untouched: requireDemoTenant still precedes every write.

Out of scope (this feature)

  • Reset history (the tenant field holds the latest only — D9 explicitly chose this).
  • Any seeder change.

Notes for Define

Decisions D9 + D10. Evidence: _source/audit-report.md §1.2 items 2–3; demo-reset/index.ts:169-241 (where the write belongs, after the seed completes), apps/web/app/(app)/admin/settings/demo-data/page.tsx, apps/web/vercel.json, app/api/cron/demo-reset/route.ts:28-39. The tenant model gains optional fields — schema change without migration is fine here (optional, no index), but check the mongoose-model skill's idiom first. touches: packages/services/src/db/{models/tenant.ts,services/demo-reset/}, apps/web/app/(app)/admin/, apps/web/vercel.json, apps/web/app/api/cron/demo-reset/.

_done/fix-broken-partial-uniques.md

Stub: Fix the two partial unique indexes that never build

  • feature-slug: fix-broken-partial-uniques
  • scope: demo-data-quality
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 1 of 10

Problem

expert-rating and tenant-integration declare unique indexes whose partialFilterExpression uses $exists: false, which MongoDB rejects — the indexes never build, silently (Mongoose autoIndex failures are async). One-rating-per-(lead, vendor) and one-integration-per-catalogueKey are therefore unenforced server-side. The repo already diagnosed and fixed this exact bug on CSAT; these two were missed.

Proposed change

Rewrite both partial filters to the proven { isDeleted: false } form (the CSAT fix pattern) in the schemas, and ship the migration that builds them — handling any pre-existing duplicate rows first, the way the CSAT migration did.

Acceptance criteria (rough)

  • expert-rating unique {tenantId, lead, vendor} and tenant-integration unique {tenantId, catalogueKey} build successfully (migration up proves it).
  • Duplicates existing before the fix are resolved deterministically in the migration, with a documented rule (keep newest), not dropped silently.
  • Symmetric down.

Out of scope (this feature)

  • The generic soft-delete-vs-partial-index inconsistency on other models ({isDeleted:false} missing raw-driver docs — audit report finding #14).

Notes for Define

Decision D1 (_source/decisions.md). Evidence: _source/audit-report.md finding #2; expert-rating.ts:39-47, tenant-integration.ts:65-75; the pattern to copy is csat.ts:60-71 + migrations/1782500000000-add-csats-tenant-index.ts:31-35. touches: packages/services/src/db/models/{expert-rating,tenant-integration}.ts, packages/services/src/db/migrations/. Use the db-migration skill.

_done/hot-path-tenant-indexes.md

Stub: Tenant-leading indexes for the hot dashboard queries

  • feature-slug: hot-path-tenant-indexes
  • scope: demo-data-quality
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 2 of 10

Problem

leads — the hottest collection — has no usable tenant-leading index: its only tenantId-leading index is partial on requestId, so the main table, kanban, CSM portfolio, vendor analytics and the bid pool all run on non-tenant-leading indexes or scans with in-memory sorts. Quotes, invoices and expert-matches have hot query shapes with no supporting index at all.

Proposed change

One migration adding the indexes the audit matched to real queries, and dropping the clear prefix-redundancies:

Add — leads {tenantId, status, updatedAt: -1}, {tenantId, isActiveProject, csm}, {tenantId, createdAt: -1}, partial {tenantId, bidDeadline} on {bidPool: true}; quotes {tenantId, proposal}; invoices {tenantId, vendor, createdAt: -1}, {tenantId, status, createdAt: -1}, {tenantId, paid, paidAt}; lead_expert_matches {tenantId, expertId, wasExcluded}.

Drop (clear redundancy only) — product {tenantId, platform} (prefix of the unique), csmPortfolioSnapshot {tenantId, csm, snapshotDate: -1} (direction-twin of the unique), expert_evidence plugin single-field {tenantId} (declare tenantId in that schema like every sibling), users clerkUserId_1 (prefix of the Clerk unique).

Acceptance criteria (rough)

  • Schema .index() declarations and the migration agree exactly (index/migration lockstep).
  • The "unused-index suspects" list is NOT touched (deferred — needs a longer $indexStats window, D1).
  • Symmetric down.

Out of scope (this feature)

  • Activity's redundant single-field indexes — they ride with the activity field removals in remove-dead-misc-schema to avoid two PRs editing one model.
  • Any query-code change; this is indexes only.

Notes for Define

Decision D1. Evidence: _source/audit-report.md findings #3, #12, #15; query sites cited in the report's index section (services/leads/index.ts:165-228,300,987-1001, services/vendor/index.ts:634-1380, services/csm-portfolio/index.ts:255-309, services/quote/index.ts:137-242, services/invoice/index.ts:227-247, services/leads/index.ts:357-361). Note the runtime auditor treats the partial {tenantId, requestId} as satisfying its tenant check — consider fixing that blind spot in packages/services/src/db/audit/index.ts here too (small, same theme). touches: packages/services/src/db/models/, packages/services/src/db/migrations/. db-migration skill.

_done/remove-dead-lead-fields.md

Stub: Remove the lead model's dead fields

  • feature-slug: remove-dead-lead-fields
  • scope: demo-data-quality
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 3 of 10

Problem

The lead schema carries fields proven dead by repo-wide grep: attachments (an unbounded embedded array, never written or read), expiresAt, remarks, isApproved — plus awaitingApproval, which is rendered as a badge that can never appear because nothing ever writes it.

Proposed change

Remove the five fields from the schema and the awaitingApproval badge from the lead header; $unset migration for existing documents.

Acceptance criteria (rough)

  • attachments, expiresAt, remarks, isApproved, awaitingApproval gone from lead.ts and $unset across existing docs in the migration.
  • The badge render at apps/web/components/service-leads/lead-header/lead-header.tsx:139 removed.
  • No behavioural change anywhere else — these fields have zero live readers/writers by definition; typecheck in CI is the proof.

Out of scope (this feature)

  • The parallel stage booleans (bidPool, isActiveProject, isBrdApproved, isRejected) and isActive — load-bearing; deferred whole-scope (see breakdown).
  • The lead index changes (hot-path-tenant-indexes).

Notes for Define

Decision D1. Evidence: _source/audit-report.md finding #10; lead.ts:99-192 with per-field grep proofs in the report's schema section. remarks matches elsewhere in the repo are proposal.remarks — don't touch those. touches: packages/services/src/db/models/lead.ts, packages/services/src/db/migrations/, apps/web/components/service-leads/lead-header/. db-migration skill.

_done/remove-dead-misc-schema.md

Stub: Drop the dead counter model and the misc dead schema weight

  • feature-slug: remove-dead-misc-schema
  • scope: demo-data-quality
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 5 of 10

Problem

Proven-dead weight across five models: the entire counter model (its getNextId has zero callers — requestId allocation moved to tenant.leadRequestSequence; the only consumer is the demo reset purging it); invoice's pdf field (every doc stores a princexml sample URL) and its dead/drifted virtuals (isSubmitted tests a status that doesn't exist in the engine, isPaid unused); activity's refs to the retired status collection (oldStatus/newStatus — populated on every feed read, a MissingSchemaError risk, rendered by UI that can never show data) plus its dead fieldChanged/beforeValue/afterValue and never-written userId (double-indexed); notification's dead UPPER_SNAKE NOTIFICATION_TYPES enum (the real system writes dot.case strings); skill.metadata; status-history's dead enum arms (bid/finance are unpopulatable refPath targets; only "lead" is ever written), dead metadata, and its unused bare {status} index.

Proposed change

One cleanup run: remove the counter model file + its purge-map entry (the policy drift test walks model files, so removing the file removes the obligation) + drop the collection in a migration; strip the dead invoice/activity/notification/skill/status-history pieces listed above with $unset migrations where documents carry data; remove activity's oldStatus/newStatus populate calls and the UI render path; drop activity's redundant single-field leadId/userId indexes and status-history's {status} index in the same migration.

Acceptance criteria (rough)

  • counter.ts deleted; demo-reset/policy.ts + demo-reset/index.ts purge map updated; policy.test.ts green in CI; collection dropped in the migration.
  • Activity feed and workspace no longer populate or render oldStatus/newStatus; status changes keep rendering from message/metadata as they already do.
  • INotification.type typed as the real dot.case union (or string) — dead enum deleted.
  • Status-history entityType enum reduced to values actually written (Define decides: just "lead", or aligned to the engine's Workflow union minus unpopulatable names).
  • Every field removal has its $unset migration; symmetric downs.

Out of scope (this feature)

  • Invoice's paid/paidAt/isApproved dual-write of the status machine — live behaviour, needs its own scope.
  • Notification TTL/archiving (real finding, product decision about retention — not cleanup).
  • Quote/proposal duplicate-field consolidation (amounttotal, isDraftstatus) — live write paths, same reason.

Notes for Define

Decision D1. Evidence: _source/audit-report.md findings #5 (activity), #7 (notification enum), #8 (invoice), #9 (counter), #13 (status-history); per-model detail in the report. Key sites: activity.ts:45-83 + services/activity/index.ts:28-31 + activity-feed.tsx:121-123; invoice.ts:46-74; notification.ts:10-33; counter.ts:32-39; status-history.ts:33-62. touches: packages/services/src/db/models/, packages/services/src/db/services/{activity,demo-reset}/, packages/services/src/db/migrations/, apps/web/components/activity/. db-migration skill.

_done/remove-dead-user-fields.md

Stub: Remove the user model's dead cluster

  • feature-slug: remove-dead-user-fields
  • scope: demo-data-quality
  • personas: Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 4 of 10

Problem

The user model carries the repo's largest dead-field cluster: salutation, bankDetails (all four sub-fields), dialCode, termsAndConditions, master, isMaster (written as constant true, read nowhere), csm.inHouse, vendor.jobTitle, vendor.type — zero live references. user.languages (default ["gb"] — a country code as a language) is write-only. And the verification sub-document is rendered in the admin user profile but no code path ever writes it, so the panel can only ever show defaults.

Proposed change

Remove the dead fields from schema + discriminators with an $unset migration (autoIndex is off on users, so any index implication goes through the migration explicitly). For verification: remove the field AND the profile panel that renders it — it can return when a write path exists (recommended in Define; the alternative of wiring a write path is a feature, not cleanup).

Acceptance criteria (rough)

  • Dead fields gone from user.ts (base + csm/vendor discriminators), $unset migration for existing docs.
  • verification panel removed from the user profile UI along with the field.
  • isMaster constant write removed from services/users/index.ts:259.
  • The seeder's languages: ["GB"] write on users removed if user.languages is dropped (check demo-data/index.ts — note lead.languages is a different, live field: keep it).

Out of scope (this feature)

  • Expert-profile fields (skills/products/seniority/availability) — all live.
  • Clerk-mirror write-only fields on the tenant model (privateMetadata, maxAllowedMemberships, pendingInvitationsCount, slug) — mirrors of an external system; keep this stub to the user model.

Notes for Define

Decision D1. Evidence: _source/audit-report.md finding #11; user.ts:167-317 with grep proofs in the report. Careful with the three unique indexes and autoIndex(false) (user.ts:253). touches: packages/services/src/db/models/user.ts, packages/services/src/db/migrations/, apps/web/components/users/user-profile/. db-migration skill.

_done/saas-vendor-storyline.md

Stub: Author the SaaS-vendor services-arm storyline

  • feature-slug: saas-vendor-storyline
  • scope: demo-data-quality
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: storyline-full-coverage-generator
  • sequence: 10 of 10

Problem

Marketing pitches "SaaS vendors who sell licenses, not services" while the current demo world models an IT consultancy (Calderon Technology Partners) — the docs never reconcile the two (ICP ambiguity A1, _source/audit-report.md Phase 2). Jamie chose to reframe the demo world as a software vendor whose services arm delivers implementations of its own product (D6). The current world also hardcodes languages: ["GB"] on every lead (a country code as a language) and shows visible copy recycling at volume.

Proposed change

Author a new storyline module against the extended contract from stub 9 and point storyline/index.ts at it — a data change by design, zero generator edits:

  • The vendor is a fictional B2B software company; the catalogue is its own product's modules; engagements are implementations, integrations, migrations, training and optimization of that product for its customers (mirroring marketing's "Rapid Implementation / Team Training / Ongoing Optimization / Premium Support" service menu).
  • Keep what the ICP research validated: Northern-European customer base across recognisable sectors, EUR default, ~€30k–€150k fixed-price engagements, ~25 live + ~36 closed over 18 months, an anchor account, a failing-CSAT account, a declining at-risk account. Fictional names only, .example domains, RFC 2606 rule as before.
  • Fully authored per-engagement copy — blockers, action items, messages, change controls, CSAT verbatims — no visible recycling on any one screen (D8). Fix languages to real language codes.
  • The content tables go in the PR description for Jamie's explicit sign-off before merge — the same gate Calderon's tables passed (Paul's sign-off precedent, recorded in .icm/runs/demo-seed-storyline/03_define/output/spec.md).

Acceptance criteria (rough)

  • New storyline module satisfies the stub-9 contract; storyline/index.ts re-pointed; generator untouched.
  • The authored numbers keep the analytics telling a story (positive vs-prior-window delta, a real "Failing CSAT" row, a renewals/at-risk account) — same intent as the Calderon header comment documents.
  • Two resets reproduce the world exactly (determinism tests pass against the new module).
  • Jamie's sign-off on the content tables is recorded on the PR before merge.

Out of scope (this feature)

  • Deleting the Calderon module — it stays as the reference/spare storyline behind the swap point.
  • Marketing/docs copy reconciliation of the ICP framing (deferred whole-scope).

Notes for Define

Decisions D6, D7, D8. Evidence: _source/audit-report.md Phase 2 (ICP + ambiguities A1–A8); current world at demo-data/storyline/default.ts (the shape and the authored-numbers idiom to preserve); marketing evidence cited in the report (vendor-simulator, integrations wall, pricing tiers). Persona logins (demo+…@apoyar.eu) are Clerk-provisioned and must be matched by email, never created/renamed — the resolution rules in demo-data/persona-resolution.ts already handle this; the storyline just carries the same team emails. touches: packages/services/src/db/services/demo-data/storyline/.

_done/sla-stage-map-engine-alignment.md

Stub: Align the SLA stage map with the workflow engine

  • feature-slug: sla-stage-map-engine-alignment
  • scope: demo-data-quality
  • personas: CSM, Expert, Admin
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: none
  • sequence: 6 of 10

Problem

The SLA stage map predates the JSON workflow engine: of the nine canonical lead statuses, only pending resolves to an SLA stage — work_in_progress normalises to "work in progress" but the map has "in progress", delivered ≠ "delivery", and backlog, quotation_process, awaiting_confirmation, survey_sent, completed are simply absent. Tenant SLA definitions (targets, warning/breach bands, escalation intent) are inert for essentially the whole journey; overdue/at-risk/breached states are unreachable past intake on every surface that shows them (expert work queue, CSM blocker queue, SDM adherence).

Proposed change

Rewrite the stage map so every engine status resolves: map the nine statuses onto the six SLA stages (Request/BRD/Bidding/Delivery/Billing/CSAT — a Define decision per status; terminal statuses may legitimately map to none), fix the stale "statuses are tenant data" comment, and cover the mapping with a test that walks workflows.json so the next engine change fails loudly instead of silently unmapping a stage.

Acceptance criteria (rough)

  • Every non-terminal lead status in workflows.json resolves to an SLA stage; the mapping test derives the status list from the engine, not a hardcoded copy.
  • A lead in work_in_progress with a backdated status-history row past its stage's breach days computes a red band (unit-testable via timing.ts with a seeded sla_definitions row).
  • No change to band semantics (computeBand) — only to which statuses resolve.

Out of scope (this feature)

  • assertTransition enforcement for milestone/expert-evidence (deferred whole-scope).
  • Seeding the data that exercises the bands (storyline-full-coverage-generator depends on this stub for exactly that).

Notes for Define

Decisions D1 + D5 (_source/decisions.md — D5 pulled this back into scope: "fill everything + SLA fix"). Evidence: _source/audit-report.md finding #1; services/sla/stage-map.ts:23-63 (incl. stale comment :5-7), services/sla/timing.ts:72-105,135-156, workflows.json. touches: packages/services/src/db/services/sla/. No migration needed (pure code + tests).

_done/storyline-full-coverage-generator.md

Stub: Extend the storyline contract and generator to light every surface

  • feature-slug: storyline-full-coverage-generator
  • scope: demo-data-quality
  • personas: Admin, CSM, SDM, Expert, Vendor, Customer
  • initiative: Refine the bridge / objective: Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: sla-stage-map-engine-alignment
  • sequence: 9 of 10

Problem

The coverage matrix (_source/audit-report.md Phase 3) proves whole surfaces open dark on the demo tenant after every reset: the invoice approvals queue (no invoice_draft rows), disputed and rejected invoices, quote_draft/quote_rejected (and the awaiting_confirmation → quotation_process loop-back story), a blocked milestone, expert evidence in any state, every persona's notifications, SDM outreach dispositions, the CSM day-over-day delta (no backdated snapshot), and overdue action items. The generator also hardcodes content that belongs to the storyline (the platform/product/skill catalogue, copy pools), which blocks the ICP reframe.

Proposed change

Extend the Storyline contract and the generator — mechanics stay in the generator, content moves fully into the storyline module:

  • Catalogue (platforms → products → services → skills) becomes storyline content, not generator constants.
  • New authored coverage, all deterministic and idempotent under the existing deterministic.ts guarantees: one engagement each carrying invoice_draft, invoice_disputed, invoice_rejected; a quote_draft and a quote_rejected with the loop-back status history; a blocked milestone on a WIP engagement; expert evidence in all four verification states (rejected one carries a rejectionReason); per-persona notifications (unread mix, >50 for the page boundary on personas who look daily — D7); sdm_outreach_states covering all four actionStates; one backdated csm-portfolio-snapshot row so changeVsYesterday is a real delta; overdue action items and a backdated in-flight engagement whose status-history age breaches its SLA band (this is why the stub depends on the stage-map fix).
  • Per-engagement authored copy replaces the shared pools in the contract (D8 — the Calderon default module keeps working during the transition; content values may stay pool-derived until stub 10 authors the new world).
  • Reset-policy classifications and policy.test.ts stay green; demo-reset purges the newly seeded collections already (all are in the purge list — verify counts in the reset result).

Acceptance criteria (rough)

  • Every row of the coverage matrix's "confirmed seeder gaps" table has a seeded instance; the finances approvals queue, evidence review queue, SDM outreach controls, and notifications bell render populated for the relevant personas after a reset.
  • Two consecutive resets produce identical worlds (existing determinism tests extended to the new entities); re-running seed without reset does not duplicate them.
  • Every write carries tenantId; the existing cross-tenant reset test still proves the reset cannot touch another tenant.
  • An overdue engagement computes a red SLA band on the expert work queue and CSM blocker queue (depends on sla-stage-map-engine-alignment).

Out of scope (this feature)

  • The new SaaS-vendor world content itself (saas-vendor-storyline authors it against this contract).
  • Zero-state demos — deliberately via admin View-As bare-role mode, no filler personas (D11).
  • Ably/AI/email runtime surfaces (interactive by nature; canned agent mode already covers demos).

Notes for Define

Decisions D5, D7, D8, D11. Evidence: _source/audit-report.md Phase 3 (gaps table + matrix); generator at packages/services/src/db/services/demo-data/index.ts (HISTORY chain :134-174, delivery graph :1115-1177, entity helpers :1181-1345), contract at demo-data/storyline/types.ts, determinism helpers at demo-data/deterministic.ts. The seeder writes directly via models (no notifications side effects) — seeded notifications are plain rows, which is exactly what the bell/page read. This is the biggest stub in the epic; if Define finds it two-PR-sized (contract+mechanics vs new-entity seeding), flag it back to the breakdown rather than nesting. touches: packages/services/src/db/services/demo-data/, packages/services/src/db/services/demo-reset/.

_source/audit-report.md

Demo tenant data layer — audit, ICP, and coverage matrix

Phases 1–3 report · 2026-08-14 · read-only, no code changed. Phase 4 (questions → plan → approval) follows this document.

How this was researched, and one honest limitation

Everything here is derived from the codebase at main (13b00e6), the nightly CI database audit, and the migration workflow runs. This sandbox has no MongoDB credentials and blocks outbound 27017 — the repo's own db-auditor skill documents the same constraint. So "live data state" comes from the sanctioned substitute: the nightly db-audit.yaml run against sustentus-prod (2026-08-14 05:02 UTC, 155 findings across 48 collections, read-only) and the db-migrate.yaml runs for both databases. Per-collection exact counts beyond the audit's 200-doc sampling were not obtainable this session; everything below that depends on live data says so explicitly.

Verdict on the existing machinery up front: extend, don't rebuild. The seeder (storyline-driven, deterministic via FNV-1a identity hashing, idempotent by email/name upserts), the reset (deny-by-default policy with a compile-time lock and a drift test, demo-tenant hard gate, Clerk identity preserved), and the admin UI/cron plumbing are all sound. Nothing found in this audit argues for a parallel seeder; every gap is an extension point the Storyline contract was explicitly designed for.


Phase 1 — Audit

1.0 Top findings, ranked by impact

# Sev Area Finding
1 HIGH SLA subsystem The SLA stage map predates the workflow engine: of the nine lead statuses, only pending maps to an SLA stage. work_in_progress normalises to "work in progress" but the map has "in progress"; delivered ≠ "delivery"; backlog, quotation_process, awaiting_confirmation, survey_sent, completed are absent. Tenant SLA definitions (targets/warning/breach/escalation) are inert for essentially the whole journey. Overdue/at-risk/breached-SLA demo states are unreachable past intake. services/sla/stage-map.ts:23-63 vs db/workflows/workflows.json; timing.ts:72-105.
2 HIGH expert-rating, tenant-integration Two unique indexes use $exists:false inside partialFilterExpression, which MongoDB rejects — they never build. The repo diagnosed this exact bug on CSAT (csat.ts:60-64 documents it) and fixed only CSAT (migration 1782500000000). Consequence: one-rating-per-(lead,vendor) and one-integration-per-catalogueKey uniqueness are unenforced server-side. expert-rating.ts:39-47, tenant-integration.ts:65-75. Build failures are async and silent; the runtime auditor can't see a never-built index.
3 HIGH leads (hottest collection) No usable tenant-leading index. The only tenantId-leading index is partial on requestId, unusable for general tenant queries — so the main service-leads table, kanban, CSM portfolio, vendor analytics and the bid pool all run on non-tenant-leading indexes or scans, with in-memory sorts. Worse, the partial index makes the runtime auditor's tenant check pass, so CI is blind to it. lead.ts:173-192; query sites in services/leads/index.ts:165-228,300,987-1001, services/vendor/index.ts:634-1380, services/csm-portfolio/index.ts:255-309.
4 HIGH prod data Production is mostly residue of deleted tenants. Sampled orphan rates: leads 200/200, activities 200/200, milestones 200/200, statushistories 200/200, notifications 200/200, users 115/135, invoices 162/198, proposals 134/151, quotes 73/87 — and so on across ~27 collections. The demo tenant's world is a minority of rows nearly everywhere. Also 11 zombie collections with no current model: chats, experiences, skillmatrixes, settings, setup-checklists, notifiers, logs, audit_logs, status, workflows, integrations. Nightly audit run 31771710520.
5 HIGH milestone + expert-evidence Status enums duplicate the engine's sub-workflows byte-for-byte and transitions are unenforced: milestoneService.advanceStatus is a bare $set with no assertTransition (an expert can move done → upcoming); expert-evidence hand-rolls its verification transitions the same way. Every sibling flow (lead, proposal, quote, invoice) guards. milestone.ts:16-21 + services/milestone/index.ts:163-175; expert-evidence.ts:20-27 + services/expert-evidence/index.ts:200-274.
6 HIGH activity oldStatus/newStatus are ObjectId refs to the retired status collection — written by nothing, but still populated on every feed read (MissingSchemaError risk) and still rendered by the UI, so status changes never show an old→new badge. activity.ts:68-69; services/activity/index.ts:28-31; activity-feed.tsx:121-123.
7 HIGH notification The model's NOTIFICATION_TYPES enum (UPPER_SNAKE) is dead — the real notification system writes dot.case strings from notifications/*/types.ts; the column's TS type describes values that never occur. And the highest-volume collection has no TTL/archiving; only the demo reset ever deletes. notification.ts:10-33; notifications/core/create.ts.
8 HIGH invoice isSubmitted virtual tests status === "invoice_submitted" — a status that does not exist in the engine (invoice_draft/sent/disputed/paid/rejected). Dead and drifted. Plus paid/paidAt/isApproved dual-write the state machine, pdf defaults every document to a princexml sample URL, and dueDate has no write path (the milestone fallback always fires). invoice.ts:33-74.
9 HIGH counter The whole model is dead: getNextId has zero callers (requestId allocation moved to tenant.leadRequestSequence); the only consumer is the demo reset purging it. Removable with its collection. counter.ts:32-39.
10 MED lead Dead fields proven by grep: attachments (unbounded embedded array, 0 refs), expiresAt, remarks, isApproved; awaitingApproval is rendered but never written (a badge that can never appear); isActive is seeder-written, never branched on, yet anchors an index. Parallel booleans bidPool/isActiveProject/isBrdApproved/isRejected re-encode stage beside the doctrine-correct status string (desync risk, e.g. customer-quote-response.ts:103-104). lead.ts:99-192.
11 MED user Largest dead-field cluster: salutation, bankDetails (×4), dialCode, termsAndConditions, master, isMaster, csm.inHouse, vendor.jobTitle, vendor.type (0 uses); languages (default ["gb"] — a country code as a language), customer.vendor, customer.industry write-only; verification is rendered in the admin profile UI but no code path ever writes it — the panel can only ever show defaults. user.ts:167-317.
12 MED quotes/invoices indexes Hot query shapes with no supporting index: {tenantId, proposal} on quotes (accept-bid + invoice-creation path — collection scan); {tenantId, vendor, createdAt} on invoices (vendor list — customer and expert have exactly this, vendor doesn't); {tenantId, paid, paidAt} (vendor revenue windows); {tenantId, status, createdAt} (CSM approvals queue); {tenantId, expertId} on lead_expert_matches (every expert bid-pool view scans).
13 MED status-history entityType enum contains bid/finance (unpopulatable refPath targets, never written), omits milestone/verification; only "lead" is ever written. Dead metadata, write-only comment, unused bare {status} index. status-history.ts:33-62.
14 MED soft-delete × partial indexes {isDeleted:false} partial uniques (onboarding-blueprint, sla-definition) miss raw-driver docs lacking the field — which the soft-delete plugin treats as live. tenant-integration's $or form is semantically right but unbuildable (finding 2). The two bugs are mirror images; one canonical fix pattern is needed.
15 MED redundant/suspect indexes Redundant prefixes: product {tenantId,platform}, snapshot descending twin, activity single-field leadId/userId (+ userId indexes a never-written field), expert_evidence plugin {tenantId}, users clerkUserId_1. ~15 more indexes match no query in code (lead {sdm,status}/{vendor,status}/{status,isActive}, proposal {status,isActive}, quote {customer,status}/{expert,status}, user {email}, statushistory {status}, notification 3-key twin, expert_evidence {tenantId,kind} …) — nightly $indexStats agrees (0 reads) but the restart window is unknown; confirm over a longer window before dropping.
16 LOW cross-model consistency Naming drift (lead vs leadId, expert vs expertId, at vs *At, manager refs "csm" while lead.csm refs "user"); three conventions for "empty" (null / undefined / ""); model registration names mix four casing styles; quote amounttotal and proposal isDraftstatus store the same fact twice; location soft-deletes via isActive unlike every sibling; CSAT text caps 1000 (model) vs 2000 (action Zod).

1.1 Live data state (from the nightly prod audit + preview CI)

  • Prod (sustentus-prod): 48 collections; 155 findings. Orphan table and zombie-collection list as in finding #4. missing-tenant-index flagged on audit_logs, chats, skillmatrixes (all zombies) and tenant_integrations (current model — because its tenant-leading unique never builds, finding #2 — the two CI findings corroborate each other).
  • Preview (sustentus-preview): migrated + baseline-seeded green this morning; steady state "Reference data: 0 created, 13 already present; Tenant defaults: 0 created, 170 already present" → ≈5 tenants. Preview demo tenant Clerk org configured via DEMO_TENANT_CLERK_ORG_ID on the preview environment. CI never audits preview (by design).
  • Tenant-scoping leaks: no evidence of documents missing tenantId where required — the tenantPlugin hard-rejects unscoped queries at runtime, and the audit found no such violation on current-model collections. The leak-shaped problem prod actually has is the orphaned residue (finding #4).

1.2 Reset-policy coverage (demo-reset/policy.ts)

The classification itself is correct and drift-locked (policy.test.ts fails on unclassified tenant-scoped models; the purge map is a compile-time-locked Record). Purged 26 / kept 8 / global 3 — reviewed name by name; no misclassification found. Real issues live next to the policy, not in it:

  1. Purged-but-never-reseeded: notification, sdm-outreach-state, expert-evidence, onboarding-blueprint are purged on every reset and the seeder writes none of them — those surfaces open empty after every reset (see Phase 3 gaps).
  2. No reset audit trail: the confirmation dialog exists, but who-reset-when lives only in client component state; the cron logs to console. Nothing persists — a gap against the stated hard constraint. (view-as-audit is itself purged, so it can't host this record.)
  3. Nightly reset is not scheduled: apps/web/vercel.json carries only the 02:00 CSM snapshot; the demo-reset route ships ready but unscheduled, deliberately pending a hand-run + two-run diff on preview (route header, route.ts:28-39).
  4. Edge: a stand-in CSM (created persona resolution, no clerkUserId) is purged on the next reset, orphaning any csm-portfolio-snapshot rows keyed to it. Cosmetic on preview; impossible on prod while the six personas exist.

1.3 Migration state

Prod and preview are in lockstep: all 22 migrations applied on prod (run 31793674919, today 10:48 UTC, listed up: through 1786579200000-reflag-demo-tenant-preview); preview migrated + seeded green on today's PR run. No divergence. Every index-bearing model that got a tenant-index migration matches its schema — the gaps are the indexes that never got one (user.ts has autoIndex(false) and no migration creates the SDM area.* indexes or {email}; they likely don't exist in the live DB at all).


Phase 2 — ICP (from apps/marketing + apps/docs, read in full)

The tenant. The paying client is the vendor organisation — one tenant per vendor ("Vendors are the primary paying subscribers", docs/business/roles). Two overlapping framings coexist: marketing pitches SaaS vendors who sell licenses, not services (vendor-simulator: "Acme CRM Pro", $99/license/mo, services upsell) — while the product docs and the signed-off demo world model an IT services/consulting firm (Calderon Technology Partners: Benelux/UK, six named specialists in ERP/data/security/accessibility/commerce/IAM, €31k–142k fixed-price engagements). These are compatible (a software vendor's services arm) but no page reconciles them — the single biggest ICP ambiguity.

Vendors' business (stated/implied): technology services in Northern Europe (storyline accounts: NL, GB, DE, BE, IE; "Partner MSP (Benelux)" testimonial; demo logins @apoyar.eu). Service lines: ERP migration, systems integration, data platforms, commerce, IAM, accessibility, mobile field apps, billing automation. Size: not specified anywhere (no headcount/revenue bands).

Leads and projects: fixed-price only (T&M never mentioned); proposal → milestones summing to 100% → one invoice per milestone; quote currencies EUR/USD/GBP, default EUR; no platform fee (10% service fee removed 2026-08-03). Deal sizes €31k–142k in the canonical storyline. Durations not specified; SLA timers are per-stage business days; OKR "lead-to-payout <5 business days" is a transaction-validation target, not a project length. Lifecycle: the nine engine statuses; journey framing Lead In → … → Delivered with named drop-offs.

The six personas (docs voice guide + roles page): Admin = platform/ops lead ("procedural and firm"); CSM = customer-facing success manager working go-live control, blockers, revenue at risk ("Fix now"); SDM = delivery/supply manager working coverage, demand, bid economics ("Cannot quote", "Weak bid"); Expert = the specialist bidding and delivering ("Work ready for me"); Vendor = owner/commercial view (funnel, NRR, CSAT — "Revenue grows when delivery is fast"); Customer = buyer-side ops contact (plain language, no internal jargon, journey-lensed labels).

Ambiguities to not paper over (each stated plainly rather than invented around):

  • A1: SaaS-vendor vs services-firm framing (above).
  • A2: vendor lead-capability contradiction — roles page says vendors create/see all leads; the feature-role matrix says vendor = none for list/create/view/update, ✔️ import only.
  • A3: Terms page says governed by the laws of India — reads as template boilerplate against an otherwise exclusively EU footprint; no GDPR/SOC2 claim exists anywhere.
  • A4: currency mixing — EUR is the platform default; the copy guide's exemplar is "£33,600 at risk"; marketing formats USD.
  • A5: two demo worlds — canonical storyline (EU, €31–142k) vs frozen apps/demo mocks (global, 7.5–24k). Only the storyline counts.
  • A6: marketing's own tier names contradict (Self-Service/Growth/Managed/Partner vs stray "Standard and Premier" copy).
  • A8: not specified anywhere: vendor headcount, expert-bench size norms, typical project duration, lead volume/month for a real tenant.

Judgment against the current storyline: the Calderon world matches the docs-side ICP well — sectors, geography, deal sizes, persona names are coherent and were signed off. Where the current seeded world diverges from ICP is less the storyline content than (a) the generic catalogue (sap/oracle/salesforce/microsoft, lowercase, hardcoded in the generator rather than authored in the storyline), (b) languages: ["GB"] on every lead, (c) generic recycled copy pools showing repetition at volume, and (d) whole personas' surfaces empty (SDM outreach, evidence, notifications) — which reads as "unfinished product", not "thriving business".


Phase 3 — Coverage matrix

Full engine enumeration and per-widget matrix follow. Workflow engine (verbatim from workflows.json): lead pending → backlog → quotation_process → awaiting_confirmation → work_in_progress → delivered → survey_sent → completed + qualified_out (terminal; every non-terminal status can qualify out; awaiting_confirmation loops back to quotation_process on quote rejection). Sub-workflows: proposal (proposal_pending/submitted/accepted/rejected), quote (quote_draft/submitted/accepted/rejected), milestone (upcoming/in-progress/done/blocked, blocked loops), invoice (invoice_draft → invoice_sent → invoice_paid, invoice_disputed loops to draft, invoice_rejected terminal), verification (draft/submitted/approved/rejected, rejected loops). Per-persona status labels exist for all eight live statuses.

What the current seed already lights (25 live + 36 backdated engagements)

Kanban all 9 columns; workspace delivery graphs; proposals in 4 states; quotes submitted/accepted; milestones done/in-progress/upcoming; invoices sent/paid; matches with fit spread; blockers open/resolved (+escalations); action items; change controls; project messages (+CSM outreach thread); expert ratings; CSATs incl. authored failing account (Saltmarsh 6.3/10) and revenue-awaiting-CSAT; vendor funnel/NRR/cohorts/TTV via backdated status histories; CSM portfolio + blocker queue; customer dashboard health states.

Confirmed seeder gaps (surface → why dark)

Dark surface Missing data Personas
/finances/approvals queue no invoice_draft invoices (seeder writes only sent/paid) CSM, SDM
Invoice disputed/rejected chips no invoice_disputed/invoice_rejected all finance viewers
Quote draft/rejected states (+ the awaiting_confirmation → quotation_process loop story) only quote_submitted/quote_accepted written staff + customer
Milestone blocked state (customer workspace icon, health at-risk driver) seeder never writes blocked customer, expert
/expert-evidence review queue + expert my-knowledge + proposal evidence chips expert_evidence never seeded (purged on every reset) Admin, SDM, Expert
Notifications bell/page for every persona notification never seeded all six
SDM outreach dispositions sdm_outreach_states never seeded SDM
SLA config card "n/6 stages", SLA bands anywhere sla_definitions not seeded by demo seeder (only by db:seed tenant-defaults — kept on reset, so present if baseline seed ran; demo seeder itself writes none) Admin + timing everywhere
CSM changeVsYesterday no backdated csm-portfolio-snapshot row (cron-dependent) CSM
Overdue/breached-SLA states blocked twice over: no backdated dueDates in-flight, and stage-map bug (finding #1) makes bands null past intake expert, CSM
Pagination edges no column >20 leads, no >25-row activity feed, no >50 notifications various

Surfaces seed data cannot light (product code, not data)

  • Customer dashboard blockers card renders a hardcoded fixture (_customer-fixture.ts, flagged P0 in-file).
  • /customer/satisfaction "awaiting feedback" strip filters statusNames:["csat"] — not an engine status; effectively dead code.
  • SLA bands past intake — stage-map bug (finding #1).
  • Live Ably push, BRD/onboarding AI streams (canned mode covers demo), Resend email, Blob uploads — interactive/runtime by nature.
  • Any signed-in persona view requires its Clerk login to have signed in once (View-As covers presentation).

Branch note

The task text names claude/fable5-db-seeding-audit-lwmlyz; this session's designated branch is claude/demo-tenant-audit-seed-jk8eas. Session designation wins (I must not push elsewhere); all work will land on claude/demo-tenant-audit-seed-jk8eas. Flagging rather than silently substituting.

_source/decisions.md

Phase 4 decisions (Jamie, 2026-08-14, via AskUserQuestion)

  1. Cleanup scope IN RUN: (a) fix broken partial uniques (expert-rating, tenant-integration) + add missing tenant-leading indexes (lead/quote/invoice/match); (b) remove dead fields/models (counter, lead/user/invoice/activity dead clusters); (c) drop clear redundant indexes now, "unused" suspects only after longer $indexStats window. NOT selected in batch A: SLA stage-map + transition guards — but batch B answer "Fill everything + SLA fix" pulls the SLA STAGE-MAP fix back in. Transition guards (milestone/evidence assertTransition) remain OUT → intake stub.
  2. Prod residue (orphaned deleted-tenant data + 11 zombie collections): SEPARATE RUN via .icm/intake/db-audit-findings/ stubs. Not in this PR.
  3. Customer-surface code fixes IN RUN: blockers-card fixture → real data; /customer/satisfaction strip statusNames ["csat"] → survey_sent.
  4. PR shape: ONE PR, ordered separable commits, on designated branch claude/demo-tenant-audit-seed-jk8eas.
  5. Coverage: fill EVERYTHING (invoice_draft/disputed/rejected, quote_draft/rejected + loop-back lead, blocked milestone, expert evidence all 4 states, notifications per persona, SDM outreach states, backdated CSM snapshot, overdue action items) + SLA stage-map fix so overdue/at-risk/breached states are real.
  6. ICP: REFRAME storyline as SaaS-vendor services arm (product catalogue = their own product's modules; larger rewrite; content tables need sign-off in PR review).
  7. Volume: grow where personas look daily (notifications >50, activity >25); keep ~25 live leads; 18-month tail stays.
  8. Realism: fully authored per-engagement copy (no visible recycling); stand-ins stay obvious (preview-only).
  9. Reset audit trail: TENANT DOCUMENT FIELDS (lastResetAt/lastResetBy/outcome — latest reset only), rendered on admin demo-data page. Survives reset trivially (tenant doc untouched by purge).
  10. Nightly cron: SCHEDULE IN THIS PR — vercel.json {path:/api/cron/demo-reset, schedule:"0 1 * * *"}.
  11. Zero states: via admin View-As bare-role mode (already empty by design); no filler personas, no extra seeding.