Leasing funnel — why the numbers disagree

Root-cause investigation of Sean's four Phase-1 data-trust items, with live production receipts. Diagnosis only — no code was changed.

Round 2, 2026-08-30 — supersedes round 1 in place · source: Sean's Aug 30 UI feedback · receipts read from propflow-prod, read-only · repo at main

The headline: the surfaces are not computing the same number badly — they are computing different numbers correctly, and none of them says which. Three independent axes vary: the window (how long, and dated off what), the population (which stages are in), and the counting unit (a person, or a household). Every documented divergence Sean found is one of those three. The fourth item is a different animal — a real data-join bug with exactly one victim in production today, and Sean found him.

What round 2 adds. Round 1 left two things open and assumed the problem had four faces. Both gaps are now closed with evidence, and the count was wrong by an order of magnitude: 61 distinct surfaces in this repository compute or display a leasing-funnel count, and only nine of them inherit the shared predicates. Sixteen are line-by-line re-implementations of a function that already exists.

One line per bug

#Sean sawHeadline root causeVerdict
1Funnel 35 prospects · prospect page 47The funnel counts prospects created in the window and drops terminal stages; the prospects page counts households whose latest activity is in the window and drops nothing. Different window basis, different population, different unit.confirmed
2Funnel 9 applications → 3 leases · elsewhere 6 applicationsNot the signed-lease exclusion. The prospects page folds co-applicants into one household: 9 application records in the same window are 5 households. Signed leads are counted on both surfaces.hypothesis refuted — real cause found
3Toured + applied, profile section emptyThe tour is subtracted by the sibling-inquiry pin filter: the person has two ProspectInquiry rows, the other one pins the tour, so the record carrying the application renders zero tours.confirmed — production render captured
4Leasing tab summary doesn't tie outThe leasing tab renders the same component as the dashboard card, but off the layout provider's 12-month payload with no window control at all, while the dashboard card carries its own per-card window (default 3M; Sean's was 1M).confirmed

The receipt — one property, one instant, four surfaces

Read from propflow-prod for the one live customer property (1773625953462), by running the repository's own predicates over real rows — loadLeasingStats, loadScopedProspects, foldProspectsToHouseholdRows, prospectPassesDateWindow, countFunnelFlow, pipeline-stage-members. Zero writes. Names, phones and emails were never read into the output; prospect ids are truncated. Re-measured in round 2 and unchanged.

StageDashboard funnel
card window = 1 month
Dashboard funnel
card default = 3 months
Leasing tab
fixed 12 months
Prospects page
30 days, households
Prospects page
60 days = its default
Prospects / Inquiries35791304673
Tours1538512235
Applications9191959
Signed leases39937

Where Sean's 47 and 6 come from

Round 1 called this "most likely a filter he had set or a moment's drift". That was a guess. It has now been settled by exhausting the page's parameter space.

The sweep

The prospects page has exactly five things a reader can change that move these numbers, and one thing it does unconditionally. All six were enumerated and crossed:

AxisValues sweptWhere it lives
Date windowAll time, plus every integer day count from 1 to 400. That is a superset of the six quick ranges (30 / 60 / 90 / 180 / 365 / all) and of every "Specific months…" custom range, because a committed custom range resolves to an integer day counttableDefaults.ts PROSPECT_WINDOW_PRESETS; custom ranges via customWindowArgs, ProspectsClient.tsx:135
Stage filternone, plus all 9 PROSPECT_STAGE valuesProspectsClient.tsx:772
Source filternone, plus all 10 PROSPECT_SOURCE valuesProspectsClient.tsx:777
Property scopeportfolio ("All properties"), Camellia, the second live propertyglobal TopBar picker → scopedProspects, :895
Counting unithouseholds (what the page prints) and raw records (what it would print without the fold):665
Which number on screenall ten readings a given control state can print — the four flow tiles, awaitingReview, the four current-stage sub-lines, and the table's own row count:1263

What moves both digits at once

46→47 and 5→6 is one difference, not two, and the mechanism is the one bug 2 is about. Folding a two-person application group removes one household row from the cohort and one application from the tile, because the row folded away is itself an application. Camellia has four application groups; two of them are two-member groups with exactly one member inside the 30-day window at an applied-or-beyond stage. Un-folding either one, at the 30-day window, on today's rows:

Camellia, 30-day window, householdsProspectsToursApplicationsSigned
as it stands today462253
with application group 3 not yet folded472263
with application group 4 not yet folded472363
with application group 2 (three members) not yet folded482273

Group ids are AppFolio's rentalApplicationGroupId, read off ProspectInquiry.af. The fold rule is prospect-households.ts; the key is (propertyId, af.rentalApplicationGroupId) and nothing else.

The bound on when

The four-tile strip Sean is reading did not exist before 2026-08-25 19:12 UTC — that is the commit that introduced DEFAULT_PROSPECT_LIST_WINDOW and the tiles-plus-funnel layout (e8d90c8514, "Prospects: the tiles stay, and clicking one opens the real funnel"). So his recording lies inside a five-day span, during which the page's default window itself changed three times and the underlying rows changed every day:

DatePage's default windowCamellia prospect recordsPending applications
2026-08-2560 days1354
2026-08-2690 days1384
2026-08-2790 days1394
2026-08-28180 days1404
2026-08-29180 days1414
2026-08-3060 days1435

Default-window column from git log on tableDefaults.ts. The two count columns are not reconstructed — they are the daily METRIC#YYYY-MM-DD snapshot rows for PROP#1773625953462 in propflow-prod (totalContacts and pendingApplicationsCount), which is real recorded history.

Bug 1 — 35 vs 47 prospects

Where each number is computed

SurfaceRendersComputed at
Dashboard funnelleasingStats.activeLeadsInWindowinsight-specs.ts:783compute-light.ts:371pipeline-stage-members.ts:105-116
Prospects page tiles + funnel barscountFunnelFlow(cohort).inquiriesProspectsClient.tsx:907 and :1265, over the cohort built at :665 + :807

The differing predicate, quoted

Dashboard — prospectsInWindow

return prospects.filter((p) => {
  if (([PROSPECT_STAGE.REJECTED, PROSPECT_STAGE.LEASE_SIGNED] as string[]).includes(p.stage)) {
    return false;
  }
  if (!p.createdAt) return false;
  return inWindow(parseDate(p.createdAt), window);
});

pipeline-stage-members.ts:105-116 — window basis createdAt (row write clock), and two stages excluded outright.

Prospects page — prospectPassesDateWindow

if (prospect.stage === PROSPECT_STAGE.APPLIED) return true;
if (prospect.stage === PROSPECT_STAGE.LEASE_SIGNED) {
  const signedAt = prospect.inquiry?.signedAt ?? prospect.signedAt;
  ...
  return elapsed >= 0 && elapsed <= Number(dateWindow);
}
return isProspectInDateWindow(prospect.lastActivityAt ?? prospect.inquiryDate, dateWindow, now);

prospect-date-window.ts:181-215three window bases in one predicate (open applications never age out · signed leads date off their signing stamp · everyone else off latest activity), no stage excluded.

So the two numbers differ on all three axes at once

  1. Window basis. createdAt versus lastActivityAt. A lead who inquired in June and is messaging today is inside the prospects page's 30 days and outside the funnel's.
  2. Population. The funnel deletes REJECTED and LEASE_SIGNED; the page keeps them. Live now: 3 Camellia rows created inside 30 days sit at those two stages and are silently absent from the funnel's 35.
  3. Counting unit. The funnel counts inquiry records; the page counts households (ProspectsClient.tsx:665). Camellia folds 143 records to 138 households; inside the 30-day window it folds 50 records to 46 rows, 4 of them inside Applications.
  4. Window length. They do not even nominally agree by default: the funnel card defaults to 3 months (DashboardHome.tsx:696) and the prospects list opens on 60 days (tableDefaults.ts, DEFAULT_PROSPECT_LIST_WINDOW = '60'). Even a reader who correctly assumed one definition would be comparing 90 days to 60.

Bug 2 — 9 applications vs 6, and Sean's hypothesis

"I think it might not be counting the three signed leases as well. So this should, if we got nine applications, this should still say nine and then three have been signed as leases." — Sean, video 1

What actually produces the smaller number

The prospects page counts households; the dashboard counts records. Measured live on Camellia over the same 30-day span:

ReadingApplications
Dashboard — records with inquiry.appliedAt in the window9
Prospects page cohort, before household folding9
Prospects page cohort, after household folding — what the tile prints5
Co-applicant records folded away inside Applications4

The repo already names this exact case, three inches from the code that does it:

cohort is HOUSEHOLDS, so these tiles count households: one grouped rental application is one Application, not one per co-signer. That is the point — Camellia read four Applications for two real ones. … SCOPED TO THIS PAGE ON PURPOSE. The shared dashboard/report computations still count RECORDS; the headline-metric decision is open with Fede.
ProspectsClient.tsx:890-899

A second, unreported defect found while checking this

Bug 3 — the tour that exists in the conversation but not on the profile

This one is a genuine join bug, not a definition difference. The prospect detail page fetches tours with an intersection and then subtracts; the conversation panel beside it fetches with a union. That asymmetry is the whole shape of the bug: the tour is narrated in the thread the page renders, on data the same loader fetched in the same Promise.all.

The failing predicate

const [scoped, siblings] = await Promise.all([
  getTours(prospect.propertyId, prospect.personId),
  loadSiblingInquiries(prospect),
]);
const mine = await recoverMergeStrandedTours(prospect, scoped.filter(...), siblings);
const siblingPinned = pinnedTourIds(siblings);
return mine.filter((t) => t.id === prospect.tourId || !siblingPinned.has(t.id));

load-prospect-detail.ts:346-359siblings is every other ProspectInquiry row on the same personId. If a sibling row pins the tour and the row you opened does not, t.id === prospect.tourId is false and siblingPinned.has(t.id) is true, so the tour is filtered out — silently, with no log line.

The prod witness

Camellia has exactly one person carrying more than one inquiry row, and exactly one row whose tour is removed by that subtraction. It is a prospect at stage applied, whose sibling row is at tour_confirmed and holds the tour. That is precisely Sean's description — "he did submit an application… he had a tour scheduled… you can see it in the conversation".

RowstagetourIdappliedAtinquiry date
c9e297eb… ← the record with the application; this is the profile that renders emptyappliedNULL2026-08-292026-08-25 21:54Z
46aba329… ← the sibling that pins the tourtour_confirmed47337c83…2026-08-25 14:32Z

Both rows resolve to the same personId (pers_e8bb…). One Tour row exists on that person: 47337c83…, date=2026-08-29, schedulingState=confirmed, not cancelled, on the correct property. Both inquiry rows carry a conversation. Personal identifiers were not read into the working output and are redacted here.

The production loader, run on both rows

Round 1 could not exercise this — loadProspectDetail refuses a session-less call. Round 2 satisfied the auth envelope with a synthesized platform-staff session and called the real loader, read-only, against propflow-prod. It is the loader itself that drops the tour:

Row passed to loadProspectDetailstagetourIdtours returnedconversations returned
c9e297eb…appliedNULL06
46aba329…tour_confirmed47337c83…1 (47337c83…, date 2026-08-29, confirmed)6

The rendered profile, captured

The detail island was then server-rendered with those exact payloads and the markup reduced to text. Same person, same six conversations, two profiles:

What the page printsc9e297eb… — the applied row
the profile Sean opened
46aba329… — the sibling
same person, same tour
Funnel position sentence"reached Inquiry and Application; not yet reached Tour and Signed.""reached Inquiry and Tour; not yet reached Application and Signed."
Progress rail — Tour nodeTour · Not yetTour · Reached Aug 25
Progress rail — other nodesInquiry Reached Aug 29 · Application Reached Aug 29 · Signed Not yetInquiry Reached Aug 29 · Application Not yet · Signed Not yet
Tour stage eventsnone emitted"Tour Requested Aug 25" · "Sat, Aug 29 at 3:15 PM" · "Tour Confirmed Aug 25"
Tour card in Prospect infoabsent"Tour — When — Sat, Aug 29 · 3:15 PM – 3:30 PM"
Follow-ups rail"Tour — Reminder sent Aug 28"
Conversation panelidentical on both — the same five call rows, Aug 25 and Aug 29, the last of them the day of the tour

One correction to round 1

Why the section comes back empty rather than showing a placeholder

  1. Tours resolve to [] (measured above), so hasTourEvidence is false at ProspectDetailClient.tsx:1013.
  2. The placeholder branch is if (!hasTourEvidence && !isPostApplication) (:1030). This prospect has applied, so neither branch fires — zero stage: 'tour' events are emitted.
  3. The rail buckets events by stage (prospect-stage-rail.ts:157), so the Tour node has no content, and it reads "Not yet" because :1588 hands hasTourEvidence: false to reachedFunnelStage.
  4. The recovery pass next door does not help: its gate is Boolean(prospect.tourId) && !tours.some(...) (load-prospect-detail.ts:460-497) and this row's tourId is null, so it returns early.

Three adjacent defects on the same page, true regardless of the data

Bug 4 — the leasing tab

There is no separate computation to find. /leasing renders the same LeasingSection component and the same buildLeasingFunnelInsight spec as the dashboard (leasing/page.tsx:32-37, :63). It inherits bugs 1 and 2 verbatim, and adds one of its own:

Dashboard cardLeasing tab
Window it counts overthe card's own per-card window — effectiveWindowFor('leasing') ?? '3M' (DashboardHome.tsx:696), re-fetched via /api/leasing/stats?period=the layout provider's fixed PAGE_STATS_PERIOD = DEFAULT_PERIOD = 12 months (page-window.ts:31)
Window label passed to the cardwindowSuffix from the card's effective window (DashboardHome.tsx:1495)none — falls back to periodTileSuffix(PAGE_STATS_PERIOD) (LeasingSection.tsx:169)
Date controlcalendar icon → DateRangePopovernone (Sean's item 3.3)
Live reading, Camellia35 / 15 / 9 / 3 at 1M · 79 / 38 / 19 / 9 at 3M130 / 51 / 19 / 9

So two visually identical "Leasing pipeline" cards print 35 and 130 for the same word, and only one of them offers a control that would let a reader discover why. The labels do carry the window ("Prospects (12 mo)"), which is the only thing keeping this honest rather than false — but Sean read past it, which is the evidence that a suffix is not enough. There is a second, quieter failure mode in the same component: when a windowed field is absent from the payload, LeasingSection.tsx:122-125 falls back to the all-time snapshots (activeLeads, upcomingTours, pendingApplications) — so the same tile can silently switch from a windowed count to an unwindowed one without changing its caption.

Every surface that computes a funnel number

Round 1 reasoned about four surfaces, because four is what Sean could see. That framed the fix as reconciling four call sites. The repository was then swept for every place a prospect, tour, application or signed-lease count is computed or displayed — by predicate name, by field name, and by hunting inline .filter() bodies that duplicate a predicate without importing it.

The canonical library — the thing surfaces either inherit or copy

The inventory

Unit is rec = inquiry record, hh = household, unit = deduped target unit, lease/tenant where the row is not a prospect at all. Source is inherits (imports the canonical predicate), own (its own rule, no canonical equivalent), or COPY (duplicates a canonical predicate inline).

#Surfacefile:lineWindow basisPopulationUnitSource
Lineage A — the dashboard / leasing-tab stack
1Dashboard leasing-pipeline cardinsight-specs.ts:783-822card period, else trailing 30dexcl. REJECTED + LEASE_SIGNEDrecinherits — except signed, which reads the inline copy
2Windowed funnel producercompute-light.ts:371-382periodRange, else trailing 30das aboverecinherits (all four)
3Leasing hub tab cardLeasingSection.tsx:122-125fixed 12 monthswindowed value, or all-time snapshot when the field is absentrecinherits, with an all-time fallback
4Dashboard Portfolio-Metrics tile rowDashboardHome.tsx:1764-1778dashboard periodcontacts=createdAt; tours=completed; leases=leaseStartrecown
5WeeklySnapshotCardWeeklySnapshotCard.tsx:237-239trailing 7d + forward booksignedThisWeek / applicationsThisWeek / upcomingToursrecown (reads the inline copies)
6Lead-source drilldowninsight-specs.ts:1004-1031; compute-light.ts:396funnel window on createdAtall stages incl. terminal — deliberately wider than #1recinherits leadsInWindow
7activeLeadscompute-light.ts:191-193none — all-timeexcl. REJECTED + LEASE_SIGNEDrecCOPY of #1's stage rule, window stripped
8pendingApplicationscompute-light.ts:195none — all-timestage === APPLIED onlyrecown
9approvedApplicationscompute-light.ts:204-20730d staleness on updatedAt ?? createdAtstage === APPROVEDrecown
10Pending / approved unitscompute-light.ts:204-232same stalenessAPPLIED / APPROVED, deduped by target unitunitown
11upcomingTourscompute-light.ts:258-262forward: date >= nownon-cancelled toursrecown — tour rule #1 of 3
12toursCompleted / toursPendingcompute-light.ts:276-289toursWindowDays on tour instant, property TZnon-cancelledrecown
13signedThisWeekcompute-light.ts:449-454trailing 7d on signedAtany prospect with a signing stamprecCOPY of signedLeasesInWindow
14signedInWindowcompute-light.ts:458-463windowStart..windowEnd on signedAtany prospect with a signing stamprecCOPY of signedLeasesInWindow
15applicationsThisWeekcompute-light.ts:463-467trailing 7d on appliedAtany prospect with an applied stamprecCOPY of applicationsInWindow
16screeningPipelinecompute.ts:1128-1134none — all-timeINQUIRY + TOUR_PENDING + TOUR_CONFIRMED + APPLIED, hand-listedrecCOPY of countFunnelStages().active
17contactsInWindowcompute.ts:1160-1162period on createdAt, ISO string compareall prospects, no stage exclusionrecCOPY of leadsInWindow
18toursInWindow / toursCompletedInWindowcompute.ts:1194-1214period on t.date + completed-instantnon-cancelled, completed onlyrecCOPY under the same name as a canonical with different semantics — tour rule #2
19tourConversioncompute.ts:1236-1246periodnumerator canonical reach; denominator = prospects with a completed tourrecinherits reachedFunnelStage; denominator own
20netLeasesInWindowcompute.ts:1247-1251period on occupancy leaseStarttenants, not prospectstenantown
Lineage B — the prospects page
21Prospects page tiles + funnel barsProspectsClient.tsx:900-907, :1263-1277lastActivityAt ?? inquiryDatereach; no stage excludedhhinherits
22Monthly funnel historyfunnelHistory.ts:102calendar month of arrivalreach, per month buckethhinherits countFunnelFlow
23Stage-outcome stripfunnel-outcomes.ts:180; ProspectsClient.tsx:941the page's windowreached-stage cohort, partitioned still / moved / stoppedhhinherits
24Prospect detail profile railProspectDetailClient.tsx:1588, :426none — per-record reachreach stagesrec (1)inherits reachedFunnelStage
Lineage C — the owner / weekly report (its own everything)
25Owner report funnelbuild-report.ts:120-161inclusive [to-(N-1), to]; leads on arrival, apps on appliedAt, signed on signedAt, tours on tour dateall stages, no terminal exclusionrecown full copy — imports nothing from the canonical library
26Owner report upcomingToursbuild-report.ts:152-158forward d >= todaynon-cancelledrecown
27Owner report activeProspectsbuild-report.ts:190-194, terminal set :59-63noneexcl. LEASE_SIGNED, REJECTED and NOT_INTERESTEDrecown — a third "active leads" population
28Owner report pendingApplicationsbuild-report.ts:203-20630d stalenessstage === APPLIEDrecown
29Owner report approvedApplicationsbuild-report.ts:213-21630d stalenessstage === APPROVEDrecown
30Owner report funnel deltasowner-report.ts:304-308this window minus prioras #25recderived
31Owner report email — HTMLowner-report-email.ts:416-439as #25quotes New leads / Tours / Applications / Leases signed + active prospects + upcoming toursrecdisplay of #25–27 — this is the funnel an owner receives by email
32Owner report email — plaintextowner-report-email.ts:687-692as #25samerecdisplay
33Ops / CLI digestbuild-digest.ts:756-76130d, via loadLeasingStatsProspects / Tours / Applications / Signed + tour conversion + screening pipelinerecinherits #2, #14, #16, #19
Lineage D — metrics platform (persisted daily)
34Metric-snapshot funnel mappingsnapshots/compute.ts:99-10212 months (no period passed)totalContacts / totalTours / tourConversion / totalNetLeasesrecpassthrough of lineage A
35Metric-snapshot screeningPipelinesnapshots/compute.ts:160all-timeas #16recpassthrough
36Metric-snapshot page gaugesagent-metrics.ts:933-937, :1163-1166none — real-time gaugecountFunnelStages → active / inquiries / applications / toursrecinherits countFunnelStages
37prospectsCreatedagent-metrics.ts:429-431one day on createdAtall stagesrecown
38leasesSignedTodayagent-metrics.ts:508-510one day on signedAtany signing stamprecCOPY of signedLeasesInWindow
39applicationsReceivedTodayagent-metrics.ts:511-513one day on appliedAtany applied stamprecCOPY of applicationsInWindow
40toursCompletedTodayagent-metrics.ts:561-563t.date === todaynon-cancelledrecown — tour rule #3
41Clara-command activity countersActivityCountersRow.tsx:66one daytours dated today, non-cancelledrecown (agent-metrics)
42Sandbox / demo snapshot fixturessandbox-builder.ts:155-158, :237fixedpins contacts 42 / tours 18 / conversion 44 / net leases 5 / pending apps 3pinned literals
43Metric catalog (the contract text)metric-catalog.ts:355-406, :1295-1340, :277descriptivestates the tie-out activeProspectsCount = inquiriesCount + upcomingToursCount + pendingApplicationsCount
44Snapshot backfill scriptbackfill-metric-snapshots.ts:122, :250-253PAR week / fixedpins pendingApplicationsCount: 3pinned literal + passthrough
Lineage E — APIs
45/api/leasing/statsstats/route.ts:33-62days (tours) + period (funnel)delegates to computeLightStatsrecinherits via #2
46/api/leasing/pipeline-memberspipeline-members/route.ts:121-160same ?period= as the badgereturns the MEMBERS behind each badge, so count == list.lengthrecinherits all six
47/api/leasing/prospects windowed listload-prospects.ts:248, :160lastActivityAt ?? inquiryDateall stages unless ?stage=recinherits prospectPassesDateWindow
48/api/property-reports/insightsinsights/route.ts:357-361the PAR report's own periodPAR leasingByUnitType sums: contacts → tours → apps → net leasesrec (PAR)own, PAR-sourced — a separate lineage entirely
49/api/report-data summaryreport-data.ts:87-91PAR week; portfolio scope returns nullPAR initialContacts / tours / applications / netLeasesrec (PAR)own — and tourConversion = netLeases / tours, a different formula from #19's applied/toured
Lineage F — insight signals (a second complete funnel)
50Signal: lead-funnel-leaklead-funnel-leak.ts:142-172, emitted :190windowDays; tours windowed on t.createdAt, not tour dateinquiries=createdAt, apps=appliedAt, signed=signedAt, tours non-cancelledrecown full copy — a whole parallel four-stage funnel
51Signal: tour-no-showtour-no-show.ts:132-138, stage set :39-4514d on tour datenon-converted = no appliedAt AND stage in its own setrecCOPY — an inverse of APPLICATION_REACHED_STAGES that omits APPROVED/REJECTED
52Signal: leasing-velocityleasing-velocity.ts:104-115trailing window vs prior on effectiveSigningAtLease entity, not prospectsleaseown
Lineage G — everything else
53Clara staff-metrics toolhandle-get-property-metrics.ts:115daily snapshot, "as of <date>"reads #16's all-time value; totalTours / totalNetLeases deliberately excludedrecpassthrough
54Trending occupancytrending-occupancy.ts:135-149caller's staleness boundapproved + pending applicationsunitown
55Cost-savings YTD tour countscost-savings/index.ts:428-434YTD on t.createdAtytd / Clara / cancelled / rescheduledrecown
56Stale-lead call-list countsbuild-call-list.ts:440-469, :540cohort staleness windowsleads that have NOT reached Applicationsrecinherits reachedFunnelStage; cohort scans own
57Owner report fixtureowner-report-fixture.ts:52,61fixedpins pendingApplications 3 / approvedApplications 2pinned literals
58Integration coverage suitemissing-coverage.ts:103-121noneasserts presence of activeLeads / toursCompleted / toursPending / pendingApplications / upcomingTourscontract check
59Synthetic report-data generatorsynthetic/report-data.ts:61-65synthetic periodtotalTours / totalNetLeases + per-agent splitsrecown
60Synthetic prospects/tours generatorsynthetic/prospects-tours.ts:10-2730d backstage weights driving prospectsCreated / screeningPipeline / toursrecown
61Admin Atlas entity exploreratlas-tree.ts:1529-1590noneraw prospect / tour row counts by property and personrecown

The sixteen inline copies

Each of these is a filter body that duplicates a function already exported from the canonical library. Each is a place the numbers can drift again independently, and none of them will fail a test when it does.

file:lineDuplicatesHow it differs
compute-light.ts:458-463signedLeasesInWindowsame body, windowStart..windowEnd — the copy round 1 found
compute-light.ts:449-454signedLeasesInWindowsame body, trailing-7d bound
compute-light.ts:463-467applicationsInWindowsame body on appliedAt, trailing-7d bound
compute-light.ts:191-193the stage rule inside prospectsInWindowidentical exclusion set, window stripped
agent-metrics.ts:508-510signedLeasesInWindowday-bounded
agent-metrics.ts:511-513applicationsInWindowday-bounded
build-report.ts:126-131applicationsInWindowstring-date inWindow
build-report.ts:132-136signedLeasesInWindowsame shape on signedAt
build-report.ts:150-159toursInWindowits own cancelled-tour and date-string test
build-report.ts:190-192 + :59-63the stage rule inside prospectsInWindowdifferent population — adds NOT_INTERESTED
lead-funnel-leak.ts:142-172countFunnelFlow + all four window predicatesa whole parallel funnel; tours windowed on createdAt, not tour date
tour-no-show.ts:39-45, :132-138inverse of reachedFunnelStage(_, APPLICATIONS)omits the APPROVED / REJECTED handling the canonical set defines
compute.ts:1128-1134countFunnelStages(...).activehand-listed four-stage set
compute.ts:1194-1214toursInWindowdifferent semantics under the same name — completed-only vs past+future
compute.ts:1160-1162leadsInWindowISO string compare on createdAt, no source guard
compute-light.ts:258-262 vs compute.ts:1194-1214 vs agent-metrics.ts:561-563each otherthree mutually different rules for "a tour counted" — forward-book · completed-instant-in-property-TZ · date === today

Two more things the sweep turned up

The one canonical definition

Round 1 recommended hoisting one function and pointed four call sites at it. That recommendation was right in kind and an order of magnitude too small in scope. With 61 surfaces and 16 inline copies on the table, "point the call sites at a shared function" is not a refactor anyone finishes in one pass, and a plan that assumes it will is the plan that leaves the next divergence in place.

WaveSurfacesWhy this order
1 — the screens Sean is looking at#1, #2, #3, #21, #45, #46, plus the non-nesting conversion rate at insight-specs.ts:848-853This is round 1's recommendation, and it closes the reported complaint. It also deletes the copies at #7, #13, #14, #15.
2 — the numbers that leave the building#25–#33: the owner report, both email renderings, the ops digestThe higher-stakes half, and it was not in round 1's scope. An owner receives these by email. That lineage imports nothing from the canonical library, carries five inline copies, and defines a third "active prospects" population by adding NOT_INTERESTED to the exclusion set. A PM who reconciles the dashboard against the owner's emailed report today is comparing two funnels that share no code.
3 — the derived and the diagnostic#16–#20, #34–#41, #50, #51, and the three tour rulesPersisted snapshots feed trend lines and the Clara metrics tool, so a definition change here rewrites history readers, not just today's screen — it needs its own decision about backfill. lead-funnel-leak.ts:142 is a second complete funnel whose tour leg is windowed on the wrong field; it should be deleted in favour of the shared one rather than corrected in place.

Three specific fixes that need no decision at all

And a guard, in the same PR as the first wave. Assert that, given one prospect set and one window, every adopted entry point returns equal counts under equal options — the guarantee pipeline-stage-members.ts already gives between a badge and its drill-down, extended across surfaces. Without it, wave 2 and wave 3 will re-diverge before they are reached: that is exactly how sixteen inline copies came to exist next to a library written to prevent them.

What a human must decide

The implementer cannot pick these. Each is being raised as its own decision block; All four are now open on the Decisions tab, batched into three blocks — (2) and (3) are one question, since the window basis settles the terminal-stage rule.

#DecisionWhat hangs on itBlock
1Household or record? Is one couple filing one rental application one application or two?The prospects page already says one; the dashboard, the owner report, the metric-snapshot gauges and the Clara metrics tool all say two. The code marks it open with Fede (application-groups-one-household-one-lead). This single change is Sean's 9-vs-6, and it is also the whole of the 46-vs-47 gap. Whichever wins, all 61 surfaces adopt it.b88117854 — open, recommends household
2Which window basis is canonical — creation, or latest activity?Sean's own 2026-07-16 call put the prospects list on latest activity ("a lead who inquired months ago but is messaging today is a live lead"); the funnel is on creation. Both are defensible; they cannot both be "the" 30 days. Everything in wave 1 and wave 2 follows from this.b88118039 — open, recommends latest activity
3Do terminal stages count?The funnel deletes rejected and signed leads from "Prospects"; the prospects page keeps them; the owner report additionally deletes not_interested — three populations, three answers. Keeping them is what makes the conversion denominators honest, so this likely follows from (2) rather than being decided separately.b88118039 — batched with (2); recommends terminals included
4Does this reopen 2026-08-29?The decision recorded that day was to disclose the three window rules in a caption and not unify them. Sean's ask is the tie-out. One of the two has to give, and that is not a call for a PR author. The 61-surface count is new information that was not in front of whoever decided on 2026-08-29.b88118197 — open, recommends retire 08-29

Bug 3 needs no decision — the sibling-pin subtraction, the Tour card that is unreachable for anyone who applied, and the unstamped appliedAt are defects on any reading and can be fixed immediately. So can the three items under "no decision at all" above.

Method, and what could not be verified

How the receipts were taken

Scratch scripts outside the repository imported the production modules directly and ran them against DYNAMODB_TABLE_NAME=propflow-prod in us-east-1. Read-only throughout: loadLeasingStats, loadScopedProspects, loadProspectDetail, getTours, getConversationsByPersonId, getPropertiesCached, a bounded query of the METRIC# snapshot rows, and the pure counting predicates. No writer was called and no row was mutated. Numbers therefore come from the code paths the pages run, not from a reimplementation. Nothing was written to the application repository — no branch, no commit, no PR.

How the rendered profiles in bug 3 were captured

loadProspectDetail refuses a session-less call, which is what blocked round 1. Round 2 satisfied the auth envelope — and only the envelope — with a synthesized platform-staff session at the module boundary, then called the real loader and server-rendered the real ProspectDetailClient with its real payload, reducing the markup to text. Nothing in the page's own logic was stubbed. The second capture (Tour node selected) forces only the rail's defaultOpenId, which is the same state a reader reaches by clicking. The prospect's name, phone and email appear in the raw render and are redacted here.

What is not established here

Round 1 published 2026-08-30; round 2 supersedes it in place at the same URL. Freeze mode: diagnosis only, no source changed. Implementation is a separate effort.

PropFlow Docs