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.
| # | Sean saw | Headline root cause | Verdict |
|---|---|---|---|
| 1 | Funnel 35 prospects · prospect page 47 | The 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 |
| 2 | Funnel 9 applications → 3 leases · elsewhere 6 applications | Not 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 |
| 3 | Toured + applied, profile section empty | The 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 |
| 4 | Leasing tab summary doesn't tie out | The 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 |
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.
| Stage | Dashboard 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 / Inquiries | 35 | 79 | 130 | 46 | 73 |
| Tours | 15 | 38 | 51 | 22 | 35 |
| Applications | 9 | 19 | 19 | 5 | 9 |
| Signed leases | 3 | 9 | 9 | 3 | 7 |
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 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:
| Axis | Values swept | Where it lives |
|---|---|---|
| Date window | All 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 count | tableDefaults.ts PROSPECT_WINDOW_PRESETS; custom ranges via customWindowArgs, ProspectsClient.tsx:135 |
| Stage filter | none, plus all 9 PROSPECT_STAGE values | ProspectsClient.tsx:772 |
| Source filter | none, plus all 10 PROSPECT_SOURCE values | ProspectsClient.tsx:777 |
| Property scope | portfolio ("All properties"), Camellia, the second live property | global TopBar picker → scopedProspects, :895 |
| Counting unit | households (what the page prints) and raw records (what it would print without the fold) | :665 |
| Which number on screen | all 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 |
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, households | Prospects | Tours | Applications | Signed |
|---|---|---|---|---|
| as it stands today | 46 | 22 | 5 | 3 |
| with application group 3 not yet folded | 47 | 22 | 6 | 3 |
| with application group 4 not yet folded | 47 | 23 | 6 | 3 |
| with application group 2 (three members) not yet folded | 48 | 22 | 7 | 3 |
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 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:
| Date | Page's default window | Camellia prospect records | Pending applications |
|---|---|---|---|
| 2026-08-25 | 60 days | 135 | 4 |
| 2026-08-26 | 90 days | 138 | 4 |
| 2026-08-27 | 90 days | 139 | 4 |
| 2026-08-28 | 180 days | 140 | 4 |
| 2026-08-29 | 180 days | 141 | 4 |
| 2026-08-30 | 60 days | 143 | 5 |
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.
| Surface | Renders | Computed at |
|---|---|---|
| Dashboard funnel | leasingStats.activeLeadsInWindow | insight-specs.ts:783 → compute-light.ts:371 → pipeline-stage-members.ts:105-116 |
| Prospects page tiles + funnel bars | countFunnelFlow(cohort).inquiries | ProspectsClient.tsx:907 and :1265, over the cohort built at :665 + :807 |
prospectsInWindowreturn 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.
prospectPassesDateWindowif (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-215 — three 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.
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.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.DEFAULT_PROSPECT_LIST_WINDOW = '60'). Even a reader who correctly assumed one definition would be comparing 90 days to 60."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
The prospects page counts households; the dashboard counts records. Measured live on Camellia over the same 30-day span:
| Reading | Applications |
|---|---|
Dashboard — records with inquiry.appliedAt in the window | 9 |
| Prospects page cohort, before household folding | 9 |
| Prospects page cohort, after household folding — what the tile prints | 5 |
| Co-applicant records folded away inside Applications | 4 |
The repo already names this exact case, three inches from the code that does it:
cohortis 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
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.
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-359 — siblings 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.
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".
| Row | stage | tourId | appliedAt | inquiry date |
|---|---|---|---|---|
c9e297eb… ← the record with the application; this is the profile that renders empty | applied | NULL | 2026-08-29 | 2026-08-25 21:54Z |
46aba329… ← the sibling that pins the tour | tour_confirmed | 47337c83… | — | 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.
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 loadProspectDetail | stage | tourId | tours returned | conversations returned |
|---|---|---|---|---|
c9e297eb… | applied | NULL | 0 | 6 |
46aba329… | tour_confirmed | 47337c83… | 1 (47337c83…, date 2026-08-29, confirmed) | 6 |
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 prints | c9e297eb… — the applied rowthe profile Sean opened | 46aba329… — the siblingsame 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 node | Tour · Not yet | Tour · Reached Aug 25 |
| Progress rail — other nodes | Inquiry Reached Aug 29 · Application Reached Aug 29 · Signed Not yet | Inquiry Reached Aug 29 · Application Not yet · Signed Not yet |
| Tour stage events | none emitted | "Tour Requested Aug 25" · "Sat, Aug 29 at 3:15 PM" · "Tour Confirmed Aug 25" |
| Tour card in Prospect info | absent | "Tour — When — Sat, Aug 29 · 3:15 PM – 3:30 PM" |
| Follow-ups rail | — | "Tour — Reminder sent Aug 28" |
| Conversation panel | identical on both — the same five call rows, Aug 25 and Aug 29, the last of them the day of the tour | |
[] (measured above), so hasTourEvidence is false at ProspectDetailClient.tsx:1013.if (!hasTourEvidence && !isPostApplication) (:1030). This prospect has applied, so neither branch fires — zero stage: 'tour' events are emitted.hasTourEvidence: false to reachedFunnelStage.Boolean(prospect.tourId) && !tours.some(...) (load-prospect-detail.ts:460-497) and this row's tourId is null, so it returns early.'application' before the tour branch is evaluated. So even with healthy tour data, a toured-then-applied prospect can never show the Tour card. The two renders above are the demonstration: the sibling does get a Tour card, and it is the one that has not applied.lease_signed and rejected. stageHeadlines has entries only for APPLIED and APPROVED; every other content block is separately gated off.prospect.inquiry?.appliedAt only (:1049-1059), and appliedAt is stamped in exactly two places — the PATCH route (route.ts:82-84, which only fires on a move to APPLIED, never a jump straight to approved/lease_signed) and the AppFolio rental-application writer. Live count of Camellia rows at applied/approved/signed/rejected with no stamp: 1.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 card | Leasing tab | |
|---|---|---|
| Window it counts over | the 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 card | windowSuffix from the card's effective window (DashboardHome.tsx:1495) | none — falls back to periodTileSuffix(PAGE_STATS_PERIOD) (LeasingSection.tsx:169) |
| Date control | calendar icon → DateRangePopover | none (Sean's item 3.3) |
| Live reading, Camellia | 35 / 15 / 9 / 3 at 1M · 79 / 38 / 19 / 9 at 3M | 130 / 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.
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.
prospectsInWindow · :127 applicationsInWindow · :150 applicationsAwaitingInWindow · :173 signedLeasesInWindow · :188 toursInWindow · :205 leadsInWindowAPPLICATION_REACHED_STAGES · :186 reachedFunnelStagecountFunnelStages · :246 countFunnelFlow · funnel-outcomes.ts:180 countStageOutcomesisProspectInDateWindow · :181 prospectPassesDateWindow · prospect-households.ts:181 foldProspectsToHouseholdRowsUnit 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).
| # | Surface | file:line | Window basis | Population | Unit | Source |
|---|---|---|---|---|---|---|
| Lineage A — the dashboard / leasing-tab stack | ||||||
| 1 | Dashboard leasing-pipeline card | insight-specs.ts:783-822 | card period, else trailing 30d | excl. REJECTED + LEASE_SIGNED | rec | inherits — except signed, which reads the inline copy |
| 2 | Windowed funnel producer | compute-light.ts:371-382 | periodRange, else trailing 30d | as above | rec | inherits (all four) |
| 3 | Leasing hub tab card | LeasingSection.tsx:122-125 | fixed 12 months | windowed value, or all-time snapshot when the field is absent | rec | inherits, with an all-time fallback |
| 4 | Dashboard Portfolio-Metrics tile row | DashboardHome.tsx:1764-1778 | dashboard period | contacts=createdAt; tours=completed; leases=leaseStart | rec | own |
| 5 | WeeklySnapshotCard | WeeklySnapshotCard.tsx:237-239 | trailing 7d + forward book | signedThisWeek / applicationsThisWeek / upcomingTours | rec | own (reads the inline copies) |
| 6 | Lead-source drilldown | insight-specs.ts:1004-1031; compute-light.ts:396 | funnel window on createdAt | all stages incl. terminal — deliberately wider than #1 | rec | inherits leadsInWindow |
| 7 | activeLeads | compute-light.ts:191-193 | none — all-time | excl. REJECTED + LEASE_SIGNED | rec | COPY of #1's stage rule, window stripped |
| 8 | pendingApplications | compute-light.ts:195 | none — all-time | stage === APPLIED only | rec | own |
| 9 | approvedApplications | compute-light.ts:204-207 | 30d staleness on updatedAt ?? createdAt | stage === APPROVED | rec | own |
| 10 | Pending / approved units | compute-light.ts:204-232 | same staleness | APPLIED / APPROVED, deduped by target unit | unit | own |
| 11 | upcomingTours | compute-light.ts:258-262 | forward: date >= now | non-cancelled tours | rec | own — tour rule #1 of 3 |
| 12 | toursCompleted / toursPending | compute-light.ts:276-289 | toursWindowDays on tour instant, property TZ | non-cancelled | rec | own |
| 13 | signedThisWeek | compute-light.ts:449-454 | trailing 7d on signedAt | any prospect with a signing stamp | rec | COPY of signedLeasesInWindow |
| 14 | signedInWindow | compute-light.ts:458-463 | windowStart..windowEnd on signedAt | any prospect with a signing stamp | rec | COPY of signedLeasesInWindow |
| 15 | applicationsThisWeek | compute-light.ts:463-467 | trailing 7d on appliedAt | any prospect with an applied stamp | rec | COPY of applicationsInWindow |
| 16 | screeningPipeline | compute.ts:1128-1134 | none — all-time | INQUIRY + TOUR_PENDING + TOUR_CONFIRMED + APPLIED, hand-listed | rec | COPY of countFunnelStages().active |
| 17 | contactsInWindow | compute.ts:1160-1162 | period on createdAt, ISO string compare | all prospects, no stage exclusion | rec | COPY of leadsInWindow |
| 18 | toursInWindow / toursCompletedInWindow | compute.ts:1194-1214 | period on t.date + completed-instant | non-cancelled, completed only | rec | COPY under the same name as a canonical with different semantics — tour rule #2 |
| 19 | tourConversion | compute.ts:1236-1246 | period | numerator canonical reach; denominator = prospects with a completed tour | rec | inherits reachedFunnelStage; denominator own |
| 20 | netLeasesInWindow | compute.ts:1247-1251 | period on occupancy leaseStart | tenants, not prospects | tenant | own |
| Lineage B — the prospects page | ||||||
| 21 | Prospects page tiles + funnel bars | ProspectsClient.tsx:900-907, :1263-1277 | lastActivityAt ?? inquiryDate | reach; no stage excluded | hh | inherits |
| 22 | Monthly funnel history | funnelHistory.ts:102 | calendar month of arrival | reach, per month bucket | hh | inherits countFunnelFlow |
| 23 | Stage-outcome strip | funnel-outcomes.ts:180; ProspectsClient.tsx:941 | the page's window | reached-stage cohort, partitioned still / moved / stopped | hh | inherits |
| 24 | Prospect detail profile rail | ProspectDetailClient.tsx:1588, :426 | none — per-record reach | reach stages | rec (1) | inherits reachedFunnelStage |
| Lineage C — the owner / weekly report (its own everything) | ||||||
| 25 | Owner report funnel | build-report.ts:120-161 | inclusive [to-(N-1), to]; leads on arrival, apps on appliedAt, signed on signedAt, tours on tour date | all stages, no terminal exclusion | rec | own full copy — imports nothing from the canonical library |
| 26 | Owner report upcomingTours | build-report.ts:152-158 | forward d >= today | non-cancelled | rec | own |
| 27 | Owner report activeProspects | build-report.ts:190-194, terminal set :59-63 | none | excl. LEASE_SIGNED, REJECTED and NOT_INTERESTED | rec | own — a third "active leads" population |
| 28 | Owner report pendingApplications | build-report.ts:203-206 | 30d staleness | stage === APPLIED | rec | own |
| 29 | Owner report approvedApplications | build-report.ts:213-216 | 30d staleness | stage === APPROVED | rec | own |
| 30 | Owner report funnel deltas | owner-report.ts:304-308 | this window minus prior | as #25 | rec | derived |
| 31 | Owner report email — HTML | owner-report-email.ts:416-439 | as #25 | quotes New leads / Tours / Applications / Leases signed + active prospects + upcoming tours | rec | display of #25–27 — this is the funnel an owner receives by email |
| 32 | Owner report email — plaintext | owner-report-email.ts:687-692 | as #25 | same | rec | display |
| 33 | Ops / CLI digest | build-digest.ts:756-761 | 30d, via loadLeasingStats | Prospects / Tours / Applications / Signed + tour conversion + screening pipeline | rec | inherits #2, #14, #16, #19 |
| Lineage D — metrics platform (persisted daily) | ||||||
| 34 | Metric-snapshot funnel mapping | snapshots/compute.ts:99-102 | 12 months (no period passed) | totalContacts / totalTours / tourConversion / totalNetLeases | rec | passthrough of lineage A |
| 35 | Metric-snapshot screeningPipeline | snapshots/compute.ts:160 | all-time | as #16 | rec | passthrough |
| 36 | Metric-snapshot page gauges | agent-metrics.ts:933-937, :1163-1166 | none — real-time gauge | countFunnelStages → active / inquiries / applications / tours | rec | inherits countFunnelStages |
| 37 | prospectsCreated | agent-metrics.ts:429-431 | one day on createdAt | all stages | rec | own |
| 38 | leasesSignedToday | agent-metrics.ts:508-510 | one day on signedAt | any signing stamp | rec | COPY of signedLeasesInWindow |
| 39 | applicationsReceivedToday | agent-metrics.ts:511-513 | one day on appliedAt | any applied stamp | rec | COPY of applicationsInWindow |
| 40 | toursCompletedToday | agent-metrics.ts:561-563 | t.date === today | non-cancelled | rec | own — tour rule #3 |
| 41 | Clara-command activity counters | ActivityCountersRow.tsx:66 | one day | tours dated today, non-cancelled | rec | own (agent-metrics) |
| 42 | Sandbox / demo snapshot fixtures | sandbox-builder.ts:155-158, :237 | fixed | pins contacts 42 / tours 18 / conversion 44 / net leases 5 / pending apps 3 | — | pinned literals |
| 43 | Metric catalog (the contract text) | metric-catalog.ts:355-406, :1295-1340, :277 | descriptive | states the tie-out activeProspectsCount = inquiriesCount + upcomingToursCount + pendingApplicationsCount | — | — |
| 44 | Snapshot backfill script | backfill-metric-snapshots.ts:122, :250-253 | PAR week / fixed | pins pendingApplicationsCount: 3 | — | pinned literal + passthrough |
| Lineage E — APIs | ||||||
| 45 | /api/leasing/stats | stats/route.ts:33-62 | days (tours) + period (funnel) | delegates to computeLightStats | rec | inherits via #2 |
| 46 | /api/leasing/pipeline-members | pipeline-members/route.ts:121-160 | same ?period= as the badge | returns the MEMBERS behind each badge, so count == list.length | rec | inherits all six |
| 47 | /api/leasing/prospects windowed list | load-prospects.ts:248, :160 | lastActivityAt ?? inquiryDate | all stages unless ?stage= | rec | inherits prospectPassesDateWindow |
| 48 | /api/property-reports/insights | insights/route.ts:357-361 | the PAR report's own period | PAR leasingByUnitType sums: contacts → tours → apps → net leases | rec (PAR) | own, PAR-sourced — a separate lineage entirely |
| 49 | /api/report-data summary | report-data.ts:87-91 | PAR week; portfolio scope returns null | PAR initialContacts / tours / applications / netLeases | rec (PAR) | own — and tourConversion = netLeases / tours, a different formula from #19's applied/toured |
| Lineage F — insight signals (a second complete funnel) | ||||||
| 50 | Signal: lead-funnel-leak | lead-funnel-leak.ts:142-172, emitted :190 | windowDays; tours windowed on t.createdAt, not tour date | inquiries=createdAt, apps=appliedAt, signed=signedAt, tours non-cancelled | rec | own full copy — a whole parallel four-stage funnel |
| 51 | Signal: tour-no-show | tour-no-show.ts:132-138, stage set :39-45 | 14d on tour date | non-converted = no appliedAt AND stage in its own set | rec | COPY — an inverse of APPLICATION_REACHED_STAGES that omits APPROVED/REJECTED |
| 52 | Signal: leasing-velocity | leasing-velocity.ts:104-115 | trailing window vs prior on effectiveSigningAt | Lease entity, not prospects | lease | own |
| Lineage G — everything else | ||||||
| 53 | Clara staff-metrics tool | handle-get-property-metrics.ts:115 | daily snapshot, "as of <date>" | reads #16's all-time value; totalTours / totalNetLeases deliberately excluded | rec | passthrough |
| 54 | Trending occupancy | trending-occupancy.ts:135-149 | caller's staleness bound | approved + pending applications | unit | own |
| 55 | Cost-savings YTD tour counts | cost-savings/index.ts:428-434 | YTD on t.createdAt | ytd / Clara / cancelled / rescheduled | rec | own |
| 56 | Stale-lead call-list counts | build-call-list.ts:440-469, :540 | cohort staleness windows | leads that have NOT reached Applications | rec | inherits reachedFunnelStage; cohort scans own |
| 57 | Owner report fixture | owner-report-fixture.ts:52,61 | fixed | pins pendingApplications 3 / approvedApplications 2 | — | pinned literals |
| 58 | Integration coverage suite | missing-coverage.ts:103-121 | none | asserts presence of activeLeads / toursCompleted / toursPending / pendingApplications / upcomingTours | — | contract check |
| 59 | Synthetic report-data generator | synthetic/report-data.ts:61-65 | synthetic period | totalTours / totalNetLeases + per-agent splits | rec | own |
| 60 | Synthetic prospects/tours generator | synthetic/prospects-tours.ts:10-27 | 30d back | stage weights driving prospectsCreated / screeningPipeline / tours | rec | own |
| 61 | Admin Atlas entity explorer | atlas-tree.ts:1529-1590 | none | raw prospect / tour row counts by property and person | rec | own |
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:line | Duplicates | How it differs |
|---|---|---|
| compute-light.ts:458-463 | signedLeasesInWindow | same body, windowStart..windowEnd — the copy round 1 found |
| compute-light.ts:449-454 | signedLeasesInWindow | same body, trailing-7d bound |
| compute-light.ts:463-467 | applicationsInWindow | same body on appliedAt, trailing-7d bound |
| compute-light.ts:191-193 | the stage rule inside prospectsInWindow | identical exclusion set, window stripped |
| agent-metrics.ts:508-510 | signedLeasesInWindow | day-bounded |
| agent-metrics.ts:511-513 | applicationsInWindow | day-bounded |
| build-report.ts:126-131 | applicationsInWindow | string-date inWindow |
| build-report.ts:132-136 | signedLeasesInWindow | same shape on signedAt |
| build-report.ts:150-159 | toursInWindow | its own cancelled-tour and date-string test |
| build-report.ts:190-192 + :59-63 | the stage rule inside prospectsInWindow | different population — adds NOT_INTERESTED |
| lead-funnel-leak.ts:142-172 | countFunnelFlow + all four window predicates | a whole parallel funnel; tours windowed on createdAt, not tour date |
| tour-no-show.ts:39-45, :132-138 | inverse of reachedFunnelStage(_, APPLICATIONS) | omits the APPROVED / REJECTED handling the canonical set defines |
| compute.ts:1128-1134 | countFunnelStages(...).active | hand-listed four-stage set |
| compute.ts:1194-1214 | toursInWindow | different semantics under the same name — completed-only vs past+future |
| compute.ts:1160-1162 | leadsInWindow | ISO string compare on createdAt, no source guard |
| compute-light.ts:258-262 vs compute.ts:1194-1214 vs agent-metrics.ts:561-563 | each other | three mutually different rules for "a tour counted" — forward-book · completed-instant-in-property-TZ · date === today |
applicationsReceived (metric-catalog.ts:1596), funnelContactToTour :1610, funnelTourToApp :1617, funnelAppToSign :1624, funnelOverallConversion :1631, claraToursScheduled :1834, claraLeasesSigned :1841. The names for the conversion rates this whole investigation is about are reserved and empty.lambda/.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.
| Wave | Surfaces | Why 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-853 | This 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 digest | The 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 rules | Persisted 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. |
signedAt-windowed numerator by an appliedAt-windowed denominator. Either count Signed as "applied and signed in the window" so the populations nest, or drop the third percentage the way the first two already were.toursInWindow means past-and-future in pipeline-stage-members.ts:188 and completed-only in compute.ts:1194. One of them must be renamed whatever else is decided.createdAt (when the row was written) while every other surface uses the tour's own date. That is a defect on any reading of the decisions below.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.
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.
| # | Decision | What hangs on it | Block |
|---|---|---|---|
| 1 | Household 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 |
| 2 | Which 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 |
| 3 | Do 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 |
| 4 | Does 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.
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.
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.
prospectPassesDateWindow branches on ProspectInquiry.stage and stage keeps no history. One was built anyway; its positive control failed, so its output is discarded rather than quoted. This is the one open item, and it is open about a video, not about the code.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.