vendor-funnel-and-activationrun.md02_define/output/spec.mdThe 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.
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.
Unclassified bucket — never inferred from qualifyOutReason free text, and never silently
folded into another bucket (BR-17).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.)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.qualified_out remain in both the funnel and the activation denominator.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.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).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.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.drop-off-reason-tracking in the lead-lifecycle-automation
scope adds them. This stub renders the breakdown they will populate./vendor/funnel page and the dashboard's current funnel section — stub 10
(vendor-dashboard-reconciliation) brings them onto these definitions.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.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.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:
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.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.mdfeat: vendor-funnel-and-activation — M1 and M2 as live claimspackages/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.group is already null; the funnel and loss counts are the two
breakdowns inside M2's drill-down and are reachable nowhere else.cohortReconciliation renders it on both breakdowns; unit-tested.lead.lossReason, which is set for exactly the ended leads.
Unclassified until drop-off-reason-tracking lands; nothing is inferred
from qualifyOutReason. Unit-tested.furthestIndex >= index); unit-tested.workflows.json, so no second vocabulary exists.work_in_progress, from status
history rather than isActiveProject; unit-tested against a lead that
activated and later ended.stageIndex / reason carried on the
rows, so the filter reads the classification, not a re-parsed label.vendorClaimFilter — it is the only route to
the query, and it throws without a vendor id.Claim contract with the as-at stamp sourced
once per read and the period selector's presets honoured.Decisions taken while building, that the spec did not settle:
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.mdcomplex) — 5 findings, 3 fixed on branch,
2 accepted with reasons.Agent-run (reachable without signing in, or traceable in the diff):
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)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)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:
/vendor/dashboard and /vendor/metrics (operator)/vendor/metrics, with its
period stated (operator)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.
Fixed on branch:
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).2ba7099).002c51c).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).002c51c).002c51c).002c51c).<p> inside the card's <button> wrapper — invalid HTML; now <span> (002c51c).main after #860 retired admin-metric-definition-governance, and
updated the spec's Out-of-scope pointer to it (4b3e2bb).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:
$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.{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.For Ship:
changelog-entry, as stub 1 did.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.mdThe 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.mdWho 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.md414ac17 (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/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/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"]..github/workflows/ship-note.yaml fires on this
merge. Both Dig deeper links were filled before the merge, not left as placeholders.group is already nullcohortReconciliation, unit-testedUnclassified where none is recorded
and nothing inferred from qualifyOutReason (BR-7, BR-17) — unit-testedworkflows.json
— criterion 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 twoisActiveProject, so a lead that progressed or
ended still counts — unit-tested against a lead that activated and later endedvendorClaimFilter (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)Claim contract with the as-at stamp sourced once per read and the
period presets honoured (BR-1, BR-3, BR-20)The agent has no preview credentials, so the signed-in half of the DoD smoke was never
exercised — 04_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:
/vendor/dashboard and /vendor/metricsEverything 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.
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.$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.{tenantId, vendor, createdAt} index on leads. The query seeks the tenantId prefix and
filters. Audit database is green; deliberately not added without evidence.personas: Vendor, CSM. No stub in the
vendor-metrics batch owns it. Raised at Define, still open.Context budget: within the Inputs table.