Skip to Content

← All archived runs

Run: vendor-funnel-and-activation

run.md

Run: vendor-funnel-and-activation

  • branch: claude/metric-dictionary-claim-surface-et3hlw
  • pr: #856

02_define/output/spec.md

Spec: Service Activation Rate, with the funnel behind it, reconciling exactly

  • slug: vendor-funnel-and-activation
  • personas: Vendor, CSM
  • touches: packages/services/src/db/services/metric-dictionary, packages/services/src/db/services/vendor, packages/services/src/shared, apps/web/app/(app)/vendor/metrics, apps/web/components/vendor/metrics, apps/web/app/(app)/vendor/dashboard
  • complexity: complex

Problem

The vendor dashboard reports a funnel today, but it counts whatever is sitting in each stage right now, folds ended opportunities into those stage counts, and offers no way to check the arithmetic. A conversion percentage built that way can be right to the decimal and still describe nothing a vendor can act on — and when a vendor asks where 70% came from, there is no answer. That is the trust problem this scope exists to fix: a vendor who catches one wrong number stops believing all of them, including the ones that make the case for renewal.

Stub 1 landed the metric dictionary and the claim surface, and seeded the definition records for M1–M10, but wired no metric family to them. Nothing on the surface yet reaches a real figure, so the central promise — open any number and reach the records behind it — is a contract in place rather than a contract exercised. This stub is the first family through it, and it advances Refine the bridge / Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners: activation is the first signal that demand sourced through the platform is turning into delivered work, and it is the number a vendor opens before a renewal conversation.

Proposed change

M1 (Service Leads) and M2 (Service Activation Rate) become live claims on the claim surface, built as a cohort followed forward.

Service Activation Rate is the headline; Service Leads is its denominator. M1 keeps its full definition record and its Admin registry row, and gains no tile on the vendor grid — its group is already null, which the grid renders under "Not on the grid". The funnel and its losses live inside M2's drill-down. A count of leads says nothing about the business on its own; the share that became real work does.

The cohort. Every lead attributed to this vendor (lead.vendor) whose sourced date — createdAt — falls inside the selected period, followed to whatever end it reached, wherever that end falls in time. Cohort membership is fixed at sourcing, so a lead that reaches a further stage after the period closes still counts in that period's cohort. Both records read through vendorClaimFilter, which is the single place a vendor's figures get scoped.

The stages are the lead workflow's own. The funnel reports along the engine's forward path — pending → backlog → quotation_process → awaiting_confirmation → work_in_progress → delivered — each stage labelled with the vendor-facing label already carried in workflows.json (New Service Lead, Project Qualified, Expert Selected, Implementation Started, Customer Going Live, Customer Adopting). Every count traces to a statushistory row, so there is no mapping layer whose two halves can drift. survey_sent and completed sit past Delivered and are counted as having reached Delivered rather than shown as further funnel stages. The stage list is stated on M1's definition record, so the dictionary and the screen cannot disagree about what the funnel measures.

"Ever reached", not "sitting there now". A lead's furthest stage is the maximum forward-path index across its statushistory entries and its current status — the same technique the existing vendor funnel service already uses. The screen says the counts are cumulative, and each stage count is by construction at least as large as the one after it.

Activated is work_in_progress — the status the customer's quote acceptance transitions a lead into, and the same moment the engine sets isActiveProject. Activation reuses the platform's existing concept of an active project rather than introducing a second one. The durable signal is the status-history entry, not the mutable flag, so a lead that has moved on still counts as activated. Real activation rates will read well below the scope's illustrative 70%; that is the measurement working, not a defect.

Losses are their own counts. Every lead that reached qualified_out is ended. It is grouped by drop-off reason, and because the platform does not yet record one — qualified_out carries only a free-text qualifyOutReason — every ended lead today falls in a single explicit Unclassified bucket (BR-17). Nothing is guessed from the free text, and no loss count appears inside a stage count. When drop-off-reason-tracking (lead-lifecycle-automation, stub 1) lands the six constrained reasons, this breakdown fills in with no change to the reconciliation.

The reconciliation is on screen. Cohort size = reached Delivered + ended + still open. It is rendered in the RecordSet.reconciliation lines that stub 1's drill-down contract already carries, on every period and every filter.

Both figures open to the records. Every stage count and every loss count opens to the individual leads behind it, which add up to exactly that count: reference, customer, sourced date, furthest stage reached, and outcome. M2 opens to the leads that did not activate, with the stage each stalled at.

The activation-window caveat. The count of leads still inside the 14-day activation window is shown beside the rate, sourced from the Activation window setting already on M2's record. They stay in the denominator regardless.

Where it appears. M2 becomes a live tile in the Growth group on /vendor/metrics and a live figure on the /vendor/dashboard KPI row that already carries its KpiInfo popover. M1 stays definition-only on both, reachable through M2's drill-down and its Admin registry row. /vendor/funnel is untouched — stub 10 reconciles it.

Acceptance criteria

  • Service Leads has a definition record and an Admin registry row but no tile on the vendor grid; its funnel stage counts and loss counts are reachable only through Service Activation Rate's drill-down.
  • Cohort size = reached Delivered + ended for a stated reason + still open, exactly, on every period preset and every filter (BR-6). The equation is rendered on the drill-down, not just asserted in a test.
  • Every ended lead carries exactly one reason, and loss counts appear nowhere inside the stage counts (BR-7). With no drop-off reason recorded on the lead, that reason is the explicit Unclassified bucket — never inferred from qualifyOutReason free text, and never silently folded into another bucket (BR-17).
  • Stage counts report how many of the cohort have ever reached each stage, the screen says so, and each stage count is greater than or equal to the one after it (BR-8).
  • The funnel's stages are the lead workflow's own forward path with their vendor-facing labels, resolved from workflows.json, and the stage list is stated on M1's definition record. The claim surface introduces no vocabulary of its own. (The dashboard's existing funnel section still carries its own eight-stage path in db/services/vendor; the two are reconciled by vendor-dashboard-reconciliation, and until then they are kept under distinct names — CLAIM_FUNNEL_PATH here, LEAD_FUNNEL_PATH there.)
  • Activation counts leads that ever reached work_in_progress, read from status history rather than the current isActiveProject flag, so a lead that has since progressed or ended still counts as activated.
  • Leads that ended at qualified_out remain in both the funnel and the activation denominator.
  • The count of leads still inside the 14-day activation window is shown beside the activation rate, and those leads remain in the denominator.
  • Opening any stage count or loss count lists leads that add up to exactly that count, with reference, customer, sourced date, furthest stage and outcome (BR-2).
  • Both figures are scoped through vendorClaimFilter — a vendor reaches only their own leads by every route including the drill-down (BR-23), asserted by a test that a query built without a vendor id throws rather than returning unfiltered rows.
  • Both figures render through the existing Claim contract with a RecordSet attached, carry the as-at stamp and freshness statement sourced once per read, and honour the period selector's three presets with in-progress labelling (BR-1, BR-3, BR-20).
  • The activation rate is a whole percentage rounded at display only, and the funnel's stage conversions state which are stage-to-stage and which are overall (BR-19).
  • A vendor with no leads in the period sees zero with its record count, not an empty or illustrative figure (BR-3).
  • Test and demonstration exclusion is stated on M1's definition record rather than assumed (BR-21) — see Out of scope for what the platform can actually exclude today.

Out of scope

  • The merged-duplicate exclusion. The stub carries a criterion that a merged record's survivor keeps the earliest sourced date and the absorbed record appears in no count. The platform has no lead-merge feature and no mergedInto field — nothing can be absorbed, so there is nothing to exclude. M1's exclusions prose keeps the line; if lead merging is ever built, the exclusion must be implemented against it then. Nothing is silently dropped in the meantime.
  • A per-record test/demo flag. Demo data is a whole tenant (tenant.isDemo), and a vendor's figures are already tenant-scoped, so a live account cannot contain demo leads. No per-lead test flag exists and none is added here; the exclusion is stated on the record as tenant-level.
  • The six drop-off reasons themselves. drop-off-reason-tracking in the lead-lifecycle-automation scope adds them. This stub renders the breakdown they will populate.
  • The existing /vendor/funnel page and the dashboard's current funnel section — stub 10 (vendor-dashboard-reconciliation) brings them onto these definitions.
  • Delivery, satisfaction, money, health and retention (M3–M10) — later stubs in this batch.
  • Changing the lead workflow's statuses, its transitions, or the reasons a lead can end.
  • Period-over-period comparison of either figure. The scope defines like-for-like comparison; no stub in this batch wires it, and inventing it here would put a second comparison rule in the product.
  • The Admin write path for definitions. This pointed at admin-metric-definition-governance, retired on main by #860 — BR-24 goes unbuilt and BR-25 now holds by construction (the registry is read-only and nothing writes a figure) rather than by enforcement. Nothing in this stub depended on it.
  • A CSM route to these figures. The stub carries personas: Vendor, CSM and the scope's decision-rights table says a CSM sees the same funnel and activation figures for the vendors they support. /vendor/metrics and /vendor/dashboard are both roles: ["vendor"] today, and this stub does not widen them — a CSM-facing surface needs its own answer to "which vendors does this CSM support", which is portfolio work, not metric work. Flagged rather than buried: no stub in the vendor-metrics batch currently owns it. Stub 8 gives CSM read access to the definitions, not to the figures.

Open questions

  • none

Notes

Three decisions were settled at Define rather than assumed, because the engine's reality differs from the prototype the scope's worked example was written against:

  1. Stage vocabulary — the engine's own statuses, not the prototype's seven invented stages (Sourced / Activated / Requirements complete / Opened to experts / Proposal received / Proposal accepted / Delivered). Two of those seven have no status-history record behind them, so reporting them would need a mapping layer that can drift from the records — the failure this scope exists to prevent.
  2. Activated = work_in_progress — matches M2's own seeded summary ("% of service opportunities that become active delivery projects") and the engine's isActiveProject. The scope's worked example puts activation far earlier, at 42 of 60; that example was written against the prototype's stage list and does not survive the mapping. Real rates will read lower.
  3. Losses ship now with an Unclassified bucket rather than taking a cross-scope dependency on drop-off-reason-tracking. BR-17 requires exactly this, and that stub's own criteria already say unclassified must render as unclassified.

Traces to scope vendor-metrics: Q1 (cohort by sourced date), Q4 (disqualified stay in the denominator), Q5 (recent leads stay in, with the window count beside the rate), Q9 (one vendor for a lead's whole life — no re-attribution logic), Q12 (thin figures shown plainly with their count).

Context budget: within the Inputs table, plus targeted greps into packages/services/src/db/models/, workflows.json and the surfaces stub 1 shipped, to confirm which signals exist before specifying behaviour against them.

03_build/output/notes.md

Build notes: vendor-funnel-and-activation

  • commits: feat: vendor-funnel-and-activation — M1 and M2 as live claims

What changed

  • packages/services/src/shared/vendor-funnel.ts — the pure cohort logic, in /shared so the surfaces and the service share one definition of it. Holds the forward path, the classification (furthest stage ever reached, outcome, loss reason, activation-window flag), the four record sets and the two claims. The reconciliation is data on the record set, not prose in a component, so a drill-down cannot render rows without the arithmetic that checks them.
  • packages/services/src/shared/vendor-funnel.test.ts — written from the acceptance criteria: the BR-6 identity, ever-reached monotonicity, activation read from history rather than the flag, one reason per ended lead, the window boundary, the empty-cohort case, and display rounding.
  • packages/services/src/db/services/metric-dictionary/vendor-funnel.ts — the read. Two queries (leads, then status history + customer names), scoped through vendorClaimFilter. Stage labels come from workflows.json via resolveStatusLabel(status, "vendor") — no second copy of them anywhere.
  • apps/web/components/vendor/metrics/claim-figure.tsx — the live figure and its drill-down. Picking a stage or loss row filters the cohort table to exactly the leads behind that count.
  • apps/web/components/vendor/metrics/metric-grid.tsx — takes optional live figures; a metric without one still reads "Not yet reported" rather than a placeholder.
  • apps/web/lib/vendor-funnel-figures.ts, /vendor/metrics/page.tsx, /vendor/dashboard/{page,vendor-dashboard-view}.tsx — the two surfaces.

Acceptance criteria status

  • Service Leads has a record and a registry row but no grid tile — its group is already null; the funnel and loss counts are the two breakdowns inside M2's drill-down and are reachable nowhere else.
  • Cohort size = delivered + ended + still open, on every preset and filter — cohortReconciliation renders it on both breakdowns; unit-tested.
  • One reason per ended lead, never inside a stage count — losses are counted off lead.lossReason, which is set for exactly the ended leads. Unclassified until drop-off-reason-tracking lands; nothing is inferred from qualifyOutReason. Unit-tested.
  • Stage counts are ever-reached, the screen says so, and each is ≥ the next — monotone by construction (furthestIndex >= index); unit-tested.
  • The stages are the workflow's own forward path with its vendor labels — resolved from workflows.json, so no second vocabulary exists.
  • Activation counts leads that ever reached work_in_progress, from status history rather than isActiveProject; unit-tested against a lead that activated and later ended.
  • Ended leads stay in the funnel and the activation denominator; unit-tested.
  • The activation-window count sits beside the rate as its caveat, and those leads stay in the denominator; unit-tested including the boundary.
  • Every stage and loss count opens to leads that add up to exactly it — the drill-down filters the cohort on stageIndex / reason carried on the rows, so the filter reads the classification, not a re-parsed label.
  • Both figures scoped through vendorClaimFilter — it is the only route to the query, and it throws without a vendor id.
  • Rendered through the existing Claim contract with the as-at stamp sourced once per read and the period selector's presets honoured.
  • Whole-percentage rounding at display only; both conversions labelled ("Of the stage before", "Of the cohort"). Unit-tested.
  • A vendor with no leads sees zero with its record count; unit-tested. The rate renders as "—" rather than "0%" — 0/0 is not a percentage, and the reconciliation still states the cohort is 0.
  • The test/demo exclusion is stated on M1's record (tenant-level — see the spec's Out of scope for what the platform can actually exclude).

Notes for Verify

Decisions taken while building, that the spec did not settle:

  1. The dashboard figure defaults to the dictionary period and then follows the page's range selector. Build first pinned it to the dictionary default and ignored the selector, on a BR-4 reading; corrected at Verify on Jamie's direction — a tile that sits inert while the rest of the page moves is its own kind of wrong. The window it covers is stated on the figure either way, so the two surfaces never claim the same window and show different numbers.
  2. A rate over an empty cohort renders "—", not "0%". Same principle the scope applies to a zero-denominator comparison. Counts still render 0.
  3. Delivered beats ended. A lead that reached Delivered and was later qualified out counts as delivered — the funnel reports what happened. It is counted once either way, so the reconciliation holds.
  4. M1's claim is consumed on the dashboard as the denominator line beside the rate. Without that it would be an export with no consumer.

Not exercised anywhere yet: the six drop-off reasons. Every ended lead reads Unclassified until drop-off-reason-tracking (lead-lifecycle-automation) lands — that is the designed behaviour, not a gap, but it means the loss breakdown will show a single row on the preview.

Preview data: the figures read leads + statushistories scoped to the signed-in vendor user. A vendor with no leads attributed to them (lead.vendor) renders an honest empty cohort, not an error.

Index note for Verify: the cohort read filters {tenantId, vendor, createdAt}. The existing indexes are {tenantId, createdAt: -1} and {tenantId, salesOwner} — neither leads with vendor, so this seeks the tenantId prefix and filters. Fine at current volumes; a {tenantId, vendor, createdAt} index is the follow-up if it shows up in the DB audit. Deliberately not added here — adding an index without evidence is the other half of the same mistake.

Context budget: within the Inputs table, plus the stub-1 surfaces this family attaches to (metric-claim.ts, period.ts, the metric-dictionary service and the three claim components), which the spec's touches: names.

04_verify/output/verify.md

Verify: vendor-funnel-and-activation

  • production-readiness: run — 1 ❌, 5 ⚠️, 3 minor. Three blockers fixed on branch (contradicting dashboard figures, duplicate stage vocabulary, missing scoping test); changelog is Ship's to write; two deferrals recorded below.
  • code-review: high (spec complexity complex) — 5 findings, 3 fixed on branch, 2 accepted with reasons.
  • security-review: run — no HIGH or MEDIUM findings. Triggered by PII in the drill-down and the new tenant-scoped reads.
  • playwright: TODO — manual DoD smoke required, and the signed-in half is not yet performed (see below).

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

Agent-run (reachable without signing in, or traceable in the diff):

  • The web preview builds and deploys on the branch head — Vercel web Ready on 8MzymQZ7Gkn6erx44eX48kfmReQE, with Quality Project green beside it (head 11aede9). Not true of every head: ca998ae failed the web build, and the fix is recorded below (agent)
  • Cohort reconciliation, ever-reached monotonicity, one-reason-per-ended-lead, activation from history not the flag, the window boundary, empty-cohort rendering and display rounding — all pinned by unit tests that CI's Quality Project runs (agent)
  • vendorClaimFilter throws rather than returning an unfiltered query — vendor-claim-filter.test.ts, added at Verify because the criterion claimed a test that did not exist (agent)
  • Stage names and vendor labels resolve from workflows.json and match the six forward-path statuses exactly (agent, traced in diff)

Operator-demonstrated — NOT YET DONE. The agent has no preview credentials, so every signed-in criterion below is unverified. This gate must not pass on these lines until they are exercised:

  • auth: a vendor signs in and reaches /vendor/dashboard and /vendor/metrics (operator)
  • M2 renders a live rate in the Growth group on /vendor/metrics, with its period stated (operator)
  • Opening the drill-down shows the funnel and loss tables with the reconciliation, and picking a stage or loss row lists leads that add up to exactly that count (operator)
  • The dashboard range selector moves the M2 figure, and the figure states the window it moved to (operator)
  • A vendor with no leads in the period sees the honest empty state, not an error (operator)
  • payments: not touched (n/a)
  • notifications: none added (n/a)

Preview note: the metric definitions come from the seed migration. The Migrate preview database job runs on this PR, so the ten records should be present; if the pages render empty, check that job before assuming a UI defect.

Findings & cleanup

Fixed on branch:

  • Server Component read RANGE_LABELS off a "use client" module — React's client-reference proxy throws at request time, so /vendor/dashboard would have 500'd on any ?range=. Moved to lib/vendor-range-labels.ts, one home for both sides (2ba7099).
  • inActivationWindow counted leads that had already activated, so a vendor could see 100% activation beside "10 of the 10 are still inside their window". Now excludes activated leads; unit-tested (2ba7099).
  • Drill-down rows were mouse-only — the count-to-records path is the evidence this scope exists to provide, so it is now keyboard-reachable and announced (2ba7099).
  • Two contradicting "leads sourced" figures on one dashboard. The M1 denominator line sat directly above the headline strip's tenant-wide, 30-day leads tile; they disagree by construction whenever a tenant has more than one vendor. Line removed — the cohort size is still stated in every reconciliation line inside the drill-down (002c51c).
  • Duplicate LEAD_FUNNEL_PATH export. db/services/vendor already exports that name with eight stages for the existing funnel section. Renamed the new one to CLAIM_FUNNEL_PATH, and the acceptance criterion that claimed "no second stage vocabulary exists anywhere in the product" was corrected in spec.md — it was false as written (002c51c).
  • The AC-required scoping test did not exist despite the box being ticked. Written (002c51c).
  • The activation figure now takes the window the page already resolved instead of deriving its own, and says when that window is UTC rather than tenant-local (002c51c).
  • An empty record set said "no records" even when the truthful reading was "every lead activated, so nothing stalled" (002c51c).
  • <p> inside the card's <button> wrapper — invalid HTML; now <span> (002c51c).
  • Merged main after #860 retired admin-metric-definition-governance, and updated the spec's Out-of-scope pointer to it (4b3e2bb).
  • The web preview build failed on ca998ae. apps/web imported VendorPeriodWindow from @sustentus/services/server, but the services barrel re-exported only its siblings — so the type resolved while type-checking against the workspace source and vanished against the built package. Added to the export block; web Ready again on 11aede9. Worth naming because the failure mode is invisible to a local typecheck (11aede9).

Accepted, with reasons:

  • Unbounded cohort read and unbounded $in. Capping the read would break BR-2 — the records must add up to the count or the figure lies. Fine at current volumes; belongs in intake as pagination work for the drill-down, not a silent cap here.
  • No {tenantId, vendor, createdAt} index on leads. The query seeks the tenantId prefix and filters. Deliberately not added without evidence; CI's Audit database check is green. Follow-up if it surfaces in a DB audit.
  • The dashboard default period differs from the rest of the page. With no range chosen, M2 covers the dictionary's 90 days while other sections cover 30. This is Jamie's explicit instruction at Verify ("default to the dictionary period but include the range selector"), and the figure states its window. Recorded as a knowing trade, not an oversight.

For Ship:

  • No changelog entry yet. This is user-visible — a vendor now sees a live activation rate and a working drill-down where "Not yet reported" stood — so Ship must write one via changelog-entry, as stub 1 did.
  • Rollback is clean: no migration, no schema change, no new env var. A revert commit is sufficient.

Context budget: within the Inputs table, plus the three review skills the contract mandates and the main merge those findings required.

05_ship/output/changelog.md


title: See how many of your sourced leads turn into real work date: 2026-08-19T12:00:00Z personas: [vendor] slug: vendor-funnel-and-activation pr: https://github.com/sustentus/sustentus/pull/856

See how many of your sourced leads turn into real work

The last release gave every KPI a definition. This one puts the first real number behind one of them: service activation rate is now live on your dashboard and in the dictionary, where it previously read "not yet reported".

It answers one question — of the leads sourced in a period, how many went on to become active delivery work. The leads counted are fixed at the moment they were sourced, so a lead that progresses after the period closes still counts in the period it came from, and the figure for a past period stops moving.

Open the figure and you get the group behind it. The funnel shows how far the whole cohort got, stage by stage, using the same stage names you already see on a lead. Those counts are cumulative — how many have ever reached that stage, not how many are sitting there now — so a lead that has moved on is still counted at every stage it passed through. Beside the funnel is where the rest were lost. And the arithmetic is on the screen: the number of leads that reached delivery, plus those that ended, plus those still open, adds up to the size of the group. If it doesn't, the figure is wrong and you can see that it is.

Every count opens further. Pick a stage or a loss and you get the individual leads behind it — reference, customer, when it was sourced, how far it got, and how it ended — and they add up to exactly that count.

Two things worth knowing. Leads sourced in the last 14 days have not had a fair chance to activate yet, so the count of leads still inside that window is shown next to the rate; they stay in the calculation either way, and the caveat tells you how much of the shortfall is simply too early to call. And while every lost lead is counted, the reason it was lost is recorded as unclassified for now — the platform does not yet capture a structured drop-off reason. That breakdown fills in on its own once it does, with no change to the totals.

05_ship/output/investor-update.md

Vendors can now see how many sourced leads become real work

Who it's for: Vendors What shipped: Service activation rate is live, opening to the funnel, the losses, and the individual leads behind every count. Why it matters: Refine the Bridge — Q2-2026 Objective 1: Establish Product-Market Fit with Vendor Partners. Activation is the first proof sourced demand converts.

Dig deeper: https://github.com/sustentus/sustentus/pull/856 · https://help.sustentus.com/changelog/2026-08-19-vendor-funnel-and-activation

05_ship/output/release.md

Ship: vendor-funnel-and-activation

  • pr: #856 · merge: authorised — Ready to merge ticked by Jamie; this record rides the squash, so it carries no post-merge SHA. The PR's own merged state is the record of the merge.
  • CI: green. On 414ac17 (docs + changelog + ship note): Audit database, Migrate preview database, Project run labels, Spec structure, Intake structure, Ship completeness and Vercel Preview Comments ✅, Quality Project ✅, Vercel web Ready; Migrate production database skipped (merge-only — it runs on this merge) and Review diff against CONVENTIONS.md skipped (flag off). Two rounds failed earlier in the run and both were fixed rather than re-run: 5a53255 (ESLint 46 warnings against apps/web's ceiling of 45 — a named parameter in a function type annotation counts as an unused arg) and 11aede9 (VendorPeriodWindow imported from @sustentus/services/server but absent from the services barrel — it resolved against the workspace source and vanished against the built package, so no local typecheck would have caught it). This record's own commit re-runs the same set; the merge was taken only on that round reporting green, checked once, never on red.
  • technical docs: technical/packages/services — a Metric families bullet in the Metric dictionary section, naming shared/vendor-funnel.ts (the pure cohort logic, with the reconciliation carried as data on the record set) and vendorFunnelService, the first caller of vendorClaimFilter. That section previously described the scoping guard as protection for a metric family that did not yet exist; it now names the one that does.
  • business docs: business/roles — a Service activation rate line under what a vendor sees, stating that the figure opens to the leads behind every count. feature-role-matrix deliberately unchanged: this stub adds no permission or role capability beyond the Metric definitions read-only row stub 1 already recorded, and /vendor/metrics and /vendor/dashboard stay roles: ["vendor"].
  • release notes: both
  • sent: ship note queued for #product-update — .github/workflows/ship-note.yaml fires on this merge. Both Dig deeper links were filled before the merge, not left as placeholders.

Acceptance check (vs spec)

  • Service Leads has a record and a registry row but no grid tile; its funnel and loss counts are reachable only through M2's drill-down — group is already null
  • Cohort size = delivered + ended + still open, rendered on the drill-down rather than only asserted (BR-6) — cohortReconciliation, unit-tested
  • One reason per ended lead, never inside a stage count, Unclassified where none is recorded and nothing inferred from qualifyOutReason (BR-7, BR-17) — unit-tested
  • Stage counts are ever-reached, the screen says so, each ≥ the next (BR-8) — monotone by construction, unit-tested
  • The stages are the workflow's own forward path with its vendor labels from workflows.jsoncriterion corrected at Verify. As written it claimed "no second stage vocabulary exists anywhere in the product", which was false: db/services/vendor still carries its own eight-stage LEAD_FUNNEL_PATH for the existing dashboard funnel. The new one is CLAIM_FUNNEL_PATH; vendor-dashboard-reconciliation (stub 10) reconciles the two
  • Activation reads from status history, not isActiveProject, so a lead that progressed or ended still counts — unit-tested against a lead that activated and later ended
  • Ended leads stay in the funnel and the activation denominator — unit-tested
  • The activation-window count sits beside the rate as its caveat, and those leads stay in the denominator — unit-tested including the boundary, and excluding already-activated leads, which the first implementation wrongly counted (fixed at Verify)
  • Every stage and loss count opens to leads that add up to exactly it (BR-2) — the drill-down filters on the classification carried on the rows, not on a re-parsed label
  • Both figures scoped through vendorClaimFilter (BR-23), asserted by a test that a query without a vendor id throws — the test did not exist when this box was first ticked; written at Verify (vendor-claim-filter.test.ts)
  • Rendered through the Claim contract with the as-at stamp sourced once per read and the period presets honoured (BR-1, BR-3, BR-20)
  • Whole-percentage rounding at display only, both conversions labelled (BR-19) — unit-tested
  • A vendor with no leads sees zero with its record count (BR-3). The rate renders as "—" rather than "0%": 0/0 is not a percentage, and the reconciliation still states the cohort is 0. Confirmed correct by Jamie at Verify
  • Test/demo exclusion stated on M1's record as tenant-level (BR-21) — see the spec's Out of scope for what the platform can actually exclude

Still the operator's, carried from Verify unperformed

The agent has no preview credentials, so the signed-in half of the DoD smoke was never exercised04_verify/output/verify.md leaves all five lines unticked and says so. Shipping went ahead on Jamie's instruction; these are unchecked checks, not passed ones:

  • a vendor signs in and reaches /vendor/dashboard and /vendor/metrics
  • M2 renders a live rate in the Growth group with its period stated
  • the drill-down's stage and loss rows list leads that add up to exactly that count
  • the dashboard range selector moves the figure and the figure states the window
  • a vendor with no leads in the period sees the honest empty state

Everything reachable without signing in was verified: the unit tests CI runs, the scoping test, the stage labels traced through workflows.json, and the preview build on every head.

Carried forward

  • report.serviceLeads has no consumer. M1's claim is built by the service and returned, but the dashboard line that used to render it was removed at Verify (it contradicted the headline strip's tenant-wide 30-day leads tile). Kept because it is M1's only Claim and stub 10 needs it; flagged here rather than quietly deleted or quietly left.
  • Unbounded cohort read and unbounded $in. Capping would break BR-2 — the records must add up to the count or the figure lies. Fine at current volumes; drill-down pagination belongs in intake.
  • No {tenantId, vendor, createdAt} index on leads. The query seeks the tenantId prefix and filters. Audit database is green; deliberately not added without evidence.
  • The dashboard default period differs from the rest of the page — with no range chosen, M2 covers the dictionary's 90 days while other sections cover 30. Jamie's explicit instruction at Verify; the figure states its window.
  • No CSM route to these figures, though the spec carries personas: Vendor, CSM. No stub in the vendor-metrics batch owns it. Raised at Define, still open.

Context budget: within the Inputs table.