0093 — Trending occupancy counts units, not application records

TL;DR

(occupied + vacantLeased + approvedApplications) / totalUnits sums three independently-produced scalar counts, so one unit can be counted up to three times. Occupancy is a unit-level concept: every term in the numerator must contribute distinct units. Approved applications fold into trending occupancy as units newly covered by an approved application — deduped by targetUnitId and excluded when the target unit is already occupied or pre-leased. Applicant-record counts remain a separate, pipeline-only figure. The dedup key is the unit, not any PMS household/application-group id, because the unit is the only grouping signal every PMS reliably exposes — and the incident couple defeated even AppFolio's own grouping by filing two separate applications.

Context

Camellia, 2026-07-15. The dashboard Occupancy card and the weekly owner report both showed 85.0% "occupied + leases in progress" with the subtext "+2 approved applications". Verified against prod:

Nothing dedups applications by unit anywhere (full sweep 2026-07-15): every APPLIED/APPROVED metric counts records — compute-light.ts (approvedApplications, feeds the dashboard card), build-report.ts (feeds the owner report), owner-report-email.ts (totalAvailableUnits even subtracts the record count from vacant+notice), insight-specs.ts, compute.ts (screeningPipeline), agent-metrics.ts, pipeline-stage-members.ts. targetUnitId is hydrated onto every counted object (inquiry-derivation.ts) and consulted by none of them.

Why unit, not household? Per-PMS grouping signals are unreliable or absent:

PMS Co-applicant grouping exposed Reliability
AppFolio Application group link (Applicants comma-join, InquiryID); reports still emit one row per applicant Defeated by the incident itself — separate filings never group
Yardi Guest card OtherOccupants / CoSigner; no household id documented Partial (Prospect API deferred; unexercised)
RealPage None at accessible tier — no Application API; Roommates service is student-housing-only Absent

The unit an application targets is the invariant every PMS exposes, and it is the thing occupancy actually measures. (Domain KB agrees on the failure class: "counting APPLIED/APPROVED applications as pre-leased inflates implied occupancy" — pm-domain-knowledge/owner-weekly-report-and-box-score-conventions.md; it defines no household-grouping convention, so none is invented here.)

Decision

D1 — Trending occupancy numerator is a set of distinct units. Conceptually |occupied ∪ vacantLeased ∪ approvedUnits| / totalUnits. computeTrendingOccupancy() stays a pure function of counts, but its application term is renamed approvedUnits and callers MUST pass a unit-deduped, overlap-excluded count — enforced by a shared helper, not by caller discipline.

D2 — Shared helper countApprovedUnits(approvedInquiries, units) (in trending-occupancy.ts, next to the formula) is the ONLY sanctioned producer of approvedUnits:

  1. Dedup approved inquiries by targetUnitId — N co-applicants (grouped or not) for one unit contribute at most 1.

  2. Exclude a target unit already counted by another term: status occupied, notice (counts as occupied per ADR-0082/isUnitOccupied), or vacant_rented (already in vacantLeased). An approved application for an occupied/notice unit is a succession, not net-new occupancy.

  3. An approved inquiry with no targetUnitId counts 0.

    Amended 2026-09-04 (Fede: "we should count on a unit level"). Original rule: "counts 1 record (nothing to dedup against). Known limitation: an unlinked couple still counts 2 — acceptable." A 2026-07-17 follow-up then made unit-less records dedup by AppFolio's RentalApplicationGroupId, which contradicted D4 below. Both are superseded.

    Rationale: every term in the numerator names units, so an applicant who names no unit adds nothing to it without inventing one. The group-id fallback did exactly that at Camellia — Noah Bellaire's group had one unit (316), he signed and moved in, and his co-signer's orphaned APPLIED row (no targetUnitId) minted a phantom second open unit. The dashboard read "+2 pending" against a truth of +1.

    Accepted trade-off: an application the PMS never linked to a unit is invisible to the rate until the link lands — an undercount, visible as a gap between pendingApplications and pendingUnits, versus an overcount that looks like real demand. Unit linkage is stamped by the rental-application sync for all PMS-synced applications, so unit-less rows are the rare Clara-native or orphaned-co-signer case.

D3 — Applicant-record counts survive, pipeline-only. "Approved — awaiting lease: 2" in the owner report is people-in-pipeline and stays a record count (approvedApplications). Only occupancy math (the trending rate, the dashboard "+N" occupancy subtext, and totalAvailableUnits's subtraction in the owner email) switches to approvedUnits. Two fields, two names, no overloading.

D4 — PMS grouping ids are not the mechanism. RentalApplicationId, AppFolio group links, Yardi OtherOccupants, etc. remain match-or-mint dedup keys for identity (which inquiry row an application updates — unchanged, per the 2026-07-12 removal of the email→prospect bridge). They are never the occupancy dedup key. A future household entity, if ever needed, gets its own ADR.

D5 — Printed formula stays honest (ADR-0082 D-requirement). Any surface rendering the trending figure describes it as units, e.g. "+1 unit with an approved application", never a raw applicant count.

No new entities are introduced (no ADR-0027 classification table needed): approvedUnits is a derived scalar on existing report/stats payloads, rebuilt on every compute, drift-tolerance zero.

Consequences

Alternatives considered