0093 — Trending occupancy counts units, not application records
- Status: Proposed
- Date: 2026-07-15
- Deciders: Fede
- Extends: ADR-0082 (owner & portfolio reporting), ADR-0035 (Person spine / ProspectInquiry)
- Incident: Camellia dashboard + weekly owner report showed 85% trending occupancy (2026-07-15) — two approved co-applicants for ONE unit added +2.
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:
- The +2 were two people — a couple moving in together — each with their own
PERSON#…/INQUIRY#…row at stageapproved, both withtargetUnitId = 1773625953462-308(AppFolio property 7, unit 28). - They filed two separate AppFolio rental applications (ids 16 and 18), so AppFolio's own application-group link never tied them together.
- Unit 308's status was
occupied(outgoing tenant still in place), so the unit was counted three times: once inoccupied, twice inapprovedApplications→ 102/120 = 85% instead of ~83%.
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:
Dedup approved inquiries by
targetUnitId— N co-applicants (grouped or not) for one unit contribute at most 1.Exclude a target unit already counted by another term: status
occupied,notice(counts as occupied per ADR-0082/isUnitOccupied), orvacant_rented(already invacantLeased). An approved application for an occupied/notice unit is a succession, not net-new occupancy.An approved inquiry with no
targetUnitIdcounts 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
pendingApplicationsandpendingUnits, 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
- Camellia today: trending = 100/120 ≈ 83.3% (unit 308 already occupied → the couple contributes 0), matching physical occupancy until their unit frees up or their lease signs. Overstatement gone from both surfaces at once (they share the formula module).
owner-report-email.tstotalAvailableUnitsstops double-subtracting co-applicants.- Record-level
approvedApplicationsremains available for funnel/pipeline surfaces; nothing user-visible loses the people count. - Metrics that count APPLIED records (
pendingApplications,screeningPipeline,agent-metrics) are explicitly pipeline figures and are NOT changed by this ADR; if any of them is ever folded into an occupancy-shaped number, D1 applies.
Alternatives considered
- Group by PMS household/application-group id. Rejected: AppFolio's grouping is optional and was absent in the motivating incident; Yardi's is undocumented at household level; RealPage exposes none. Would still over-count an occupied target unit.
- Exclude approved applications from trending entirely. Rejected: a real customer's owner report counts "approved with deposits" toward leases in progress (domain KB), and Fede's 2026-07-13 product decision deliberately includes them; the bug is double-counting, not inclusion.
- Subtract notices / model successions explicitly. Rejected in ADR-0082 research already — no vendor formula subtracts notices; succession handling falls out naturally from D2's overlap exclusion.
- Cap the numerator at totalUnits. Cosmetic; hides double-counting instead of fixing it, and ADR-0082 already accepts >100% as honest forward demand.