demo-data-qualitybreakdown.md_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)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.
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).
$exists:false partial unique indexes that never build — depends-on: noneawaitingApproval badge — depends-on: noneverification panel — depends-on: nonecounter model; invoice/activity/notification/skill/status-history dead weight — depends-on: nonecsat status — depends-on: noneStubs 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.
_source/audit-report.md §1.1 / finding #4. Belongs to the
db-audit-findings triage when picked up (D2).$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).lead's parallel stage booleans (bidPool, isActiveProject, isBrdApproved,
isRejected) into the engine — load-bearing; needs its own scope.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.mdTwo 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.
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).
_customer-fixture.ts deleted; the card shows the active project's real open blockers and
an honest empty state when there are none.survey_sent for the signed-in customer appears in the "awaiting your feedback"
strip; completing the CSAT removes it.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.mdWho 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.
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.
"cron" as actor. Failed resets record the failure.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.requireDemoTenant still precedes every write.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.mdexpert-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.
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.
expert-rating unique {tenantId, lead, vendor} and tenant-integration unique
{tenantId, catalogueKey} build successfully (migration up proves it).down.{isDeleted:false}
missing raw-driver docs — audit report finding #14).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.mdleads — 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.
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).
.index() declarations and the migration agree exactly (index/migration lockstep).$indexStats
window, D1).down.remove-dead-misc-schema to avoid two PRs editing one model.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.mdThe 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.
Remove the five fields from the schema and the awaitingApproval badge from the lead header;
$unset migration for existing documents.
attachments, expiresAt, remarks, isApproved, awaitingApproval gone from
lead.ts and $unset across existing docs in the migration.apps/web/components/service-leads/lead-header/lead-header.tsx:139
removed.bidPool, isActiveProject, isBrdApproved, isRejected) and
isActive — load-bearing; deferred whole-scope (see breakdown).hot-path-tenant-indexes).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.mdProven-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.
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.
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.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.entityType enum reduced to values actually written (Define decides:
just "lead", or aligned to the engine's Workflow union minus unpopulatable names).$unset migration; symmetric downs.paid/paidAt/isApproved dual-write of the status machine — live behaviour,
needs its own scope.amount≡total, isDraft≡status) — live
write paths, same reason.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.mdThe 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.
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).
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.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).privateMetadata,
maxAllowedMemberships, pendingInvitationsCount, slug) — mirrors of an external system;
keep this stub to the user model.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.mdMarketing 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.
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:
.example domains, RFC 2606 rule as before.languages to real language
codes..icm/runs/demo-seed-storyline/03_define/output/spec.md).storyline/index.ts re-pointed;
generator untouched.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.mdThe 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).
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.
workflows.json resolves to an SLA stage; the mapping
test derives the status list from the engine, not a hardcoded copy.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).computeBand) — only to which statuses resolve.assertTransition enforcement for milestone/expert-evidence (deferred whole-scope).storyline-full-coverage-generator depends on this
stub for exactly that).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.mdThe 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.
Extend the Storyline contract and the generator — mechanics stay in the generator, content
moves fully into the storyline module:
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).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).tenantId; the existing cross-tenant reset test still proves the
reset cannot touch another tenant.sla-stage-map-engine-alignment).saas-vendor-storyline authors it against this
contract).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.mdPhases 1–3 report · 2026-08-14 · read-only, no code changed. Phase 4 (questions → plan → approval) follows this document.
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.
| # | 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 amount≡total and proposal isDraft≡status store the same fact twice; location soft-deletes via isActive unlike every sibling; CSAT text caps 1000 (model) vs 2000 (action Zod). |
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).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).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).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:
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).view-as-audit is itself purged, so it can't host this record.)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).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.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).
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):
apps/demo mocks (global, 7.5–24k). Only the storyline counts.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".
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.
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.
| 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 |
_customer-fixture.ts, flagged P0 in-file)./customer/satisfaction "awaiting feedback" strip filters statusNames:["csat"] — not an engine status; effectively dead code.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