Internal transfers get their own bucket — Camellia occupancy audit & ADR-0118
A live audit reconciled every term of Camellia's trending occupancy against the AppFolio
Reports API. The rate was correct. What it hid was that a third of the “on notice” units are
residents moving to another Camellia unit. This page carries the audit, the decision, and the
proof. The canonical ADR is docs/adr/0118-internal-transfers-as-their-own-bucket.md in PR #4816.
1. The audit — every term tied out
Pulled straight from jpco.appfolio.com/api/v1/reports (read-only) and recounted independently
of PropFlow’s own projection.
| Term | PropFlow | AppFolio independent recount | |
|---|---|---|---|
| Total units | 120 | unit_directory, 120 Camellia rows | ✓ |
| Occupied | 100 | 94 Current + 6 Notice = 100 distinct units | ✓ |
| Pre-leased | 5 | 6 Future rows → 5 distinct units | ✓ |
| Notices | 6 | 9 Notice rows → 6 distinct units | ✓ |
| Approved units | 2 | 4 open approved applications → 2 units | ✓ |
| Pending units | 0 | no application in a submitted-undecided state | ✓ |
(100 occupied + 5 pre-leased + 2 approved + 0 pending − 6 notice) / 120 = 101/120 = 84.17%
2. What the number was hiding
Two of the six notices are internal transfers — residents moving to another Camellia unit, not leaving the property:
| From (on notice) | To (pre-leased) | Residents | Joined by |
|---|---|---|---|
| 315 | 322 | Laurel Champlin | name (duplicate Person rows) |
| 120 | 208 | Kia Raab + Caleb Zuchowski | personId |
A transfer subtracts as a notice and adds back as a pre-leased unit, so the rate was never wrong — the two terms cancel. But “6 on notice” reads as six units of churn risk when only four residents are actually leaving, and “5 moving in” implies five new households when two are relocating.
3. The decision
Add LeasingStats.transferUnits — transfers counted as from→to unit pairs —
and split the display buckets off it through one shared helper every occupancy surface calls.
movingIn = pendingLeases − transferUnits
leaving = noticesGiven − transferUnits
transfers = transferUnits (rendered WITHOUT a sign)
| Occupancy card caption | |
|---|---|
| Before | 100 of 120 occupied · +5 moving in · +2 approved · −6 notice |
| After | 100 of 120 occupied · +3 moving in · +2 approved · −4 notice · 2 transferring |
vacantLeased and −1 via noticeUnits, so it already cancels. Re-terming the
formula would buy nothing and add a fourth way for the surfaces to drift. A useful property falls out: the
signed buckets now sum to the trending numerator exactly, so the caption reconciles to the percentage beside it.Why the stored counts stay whole
noticesGiven is the NAA NTV count — it drives the turnovers card and the
maintenance page, and a transfer-out unit genuinely does turn over: the resident vacates it and it must be made
ready. Netting transfers out at the source would understate real maintenance work. Same for
available = vacant + onNotice. So transferUnits is a disclosure reported
alongside, and the split happens at the display layer only.
4. Detection — unit pairs, person-or-name join
A transfer is a (fromUnit, toUnit) pair where a resident holds a live occupancy in
fromUnit (active/mtm, unit flagged NOTICE) and a future one
in toUnit (pending, unit flagged VACANT_RENTED), same property. Both
sides are bounded by unit status, so a pair can only ever name units genuinely inside the two buckets.
Two choices, each forced by live data:
- Pairs, not people. Camellia’s 120→208 move is two residents. Counting people would report 3 transfers where the units say 2.
- A name fallback behind the
personIdjoin. Champlin’s 315→322 is invisible to a personId join — the spine holds two distinct Person rows for her (pers_ccedb9f4…on 315,pers_3f6f3f4d…on 322, bothsource: backfill). A personId-only detector found 1 of the 2 live transfers. Same remedyprospect-conversion.tsalready applies to this exact duplicate-Person problem, scoped hard to NOTICE→VACANT_RENTED within one property.
Pairing is a deterministic 1:1 match — each notice unit and each pre-leased unit is
consumed at most once, so transferUnits ≤ min(#distinct from-units, #distinct to-units) holds by
construction. Person-matched candidates are matched in full before any name-matched one can claim a unit: direct
evidence must never lose a unit to an inference on iteration order. The name fallback additionally requires
exactly one arriving person by that name — two unrelated residents both normalizing to
“maria garcia” would otherwise fabricate a pair, so ambiguity degrades to a plain notice.
Asymmetric transfers — notice given, destination not yet leased — stay plain notices. From the property’s point of view that is still an unfilled vacancy.
5. Proof
The real loader, run against prod for Camellia:
total/occupied : 120 / 100
pendingLeases : 5 → moving in 3
noticesGiven : 6 → leaving 4
transferUnits : 2
trending (full terms) : 84.17%
trending (split terms): 84.17%
✓ RATE INVARIANT
Matches the independent AppFolio recount exactly — and again unchanged after the review-round rewrite. Plus 21 detector tests including a fixture reproducing this audit and a sweep asserting the numerator invariant across every over-count combination; 1628 tests pass across dashboard, leasing, components and API; typecheck clean.
6. Deliberately out of scope
The owner-report email is untouched. Its “Total on notice” comes from the
customer’s own weekly box score, and it already carries a Transfers column meaning a
renewal-cohort outcome. Adding an in-flight transfer row there would coin terminology on a surface
that mirrors a customer artifact verbatim.
7. Side finding — a stale approved card
The same audit turned up a third prospect card sitting at approved for unit 308,
whose applicants (Tamar Blue + Claire Bene) moved in on 2026-07-27 and are Current in AppFolio. It
does not touch the rate — ADR-0093’s unit dedup excludes 308 because the unit is already occupied — but it
overstates “approved — awaiting lease” by one and keeps a moved-in resident in the active-leads
funnel.
Root cause is the fail-closed evidence gate in prospect-conversion.ts: the occupancy is still
pending (lease starts 08-01) with no PMS sign date, so the promotion to
lease_signed is held and retried. It should self-heal when the occupancy flips to
active. Tracked separately from this PR.
Canonical decision record:
docs/adr/0118-internal-transfers-as-their-own-bucket.md in
PR #4816 — held for Fede’s
review, not auto-merged.