Internal transfers get their own bucket — Camellia occupancy audit & ADR-0118

Proposed — pending review PR #4816 audited against AppFolio 2026-07-28 supplements ADR-0099 bot review round addressed

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.

84.2%
Trending occupancy
83.3%
Current occupancy
6 → 4
On notice, after split
2
Internal transfers

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.

TermPropFlowAppFolio independent recount
Total units120unit_directory, 120 Camellia rows
Occupied10094 Current + 6 Notice = 100 distinct units
Pre-leased56 Future rows → 5 distinct units
Notices69 Notice rows → 6 distinct units
Approved units24 open approved applications → 2 units
Pending units0no 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)ResidentsJoined by
315322Laurel Champlinname (duplicate Person rows)
120208Kia Raab + Caleb ZuchowskipersonId

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.

I’d prefer we count transfers in a separate bucket.Fede, 2026-07-28

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
Before100 of 120 occupied · +5 moving in · +2 approved · −6 notice
After100 of 120 occupied · +3 moving in · +2 approved · −4 notice · 2 transferring
The ADR-0099 formula is untouched. A transfer contributes +1 to 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:

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.

PropFlow Docs