0118 — Internal transfers get their own bucket (disclosure, not a rate term)
- Status: Proposed
- Date: 2026-07-28
- Deciders: Fede (requested) — pending review
Context
A live audit of Camellia's trending occupancy on 2026-07-28 reconciled every
term of the ADR-0099 formula against the AppFolio Reports API
(unit_directory, tenant_directory, rental_applications). Every number tied
out: 120 units, 100 occupied, 5 pre-leased, 6 on notice, 2 approved units,
0 pending — (100 + 5 + 2 + 0 − 6) / 120 = 84.17%.
The audit surfaced something the numbers were hiding. Of the 6 units on notice, two are internal transfers — residents moving to another Camellia unit:
| From (on notice) | To (pre-leased) | Residents |
|---|---|---|
| 315 | 322 | Lila Dummerly |
| 120 | 208 | Wren Fictor + Kester Mockridge |
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 leaving the property, and "5 moving in" implies five new households when three are new and two are relocating.
Fede, 2026-07-28: "I'd prefer we count transfers in a separate bucket."
Decision
Add LeasingStats.transferUnits — in-flight transfers, counted as
from→to unit PAIRS — and split the display buckets off it. noticesGiven and
pendingLeases keep their existing meaning and their full counts.
movingIn = pendingLeases − transferUnits
leaving = noticesGiven − transferUnits
transfers = transferUnits (rendered WITHOUT a sign)
Camellia today: 3 moving in · 4 leaving · 2 transferring, where it used to read 5 moving in · 6 notice.
The ADR-0099 formula is unchanged. trending-occupancy.ts is not touched
and does not import any of this. A transfer contributes +1 to vacantLeased and
−1 via noticeUnits; netting it out of both yields the identical rate, so
re-terming the formula would buy nothing and add a fourth way for the surfaces
to drift. Verified against prod: full terms 84.17%, split terms 84.17%.
A useful property falls out — the signed buckets now sum to the trending
numerator exactly (occupied + movingIn + approved + pending − leaving), so the
caption a PM reads reconciles to the percentage next to it.
Why the underlying 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 of the
stored count would understate real maintenance work. Same for
available = vacant + onNotice in the leasing section: a transfer-out unit does
become available. So transferUnits is a disclosure reported alongside, and
the split happens at the display layer through one shared helper
(splitTransferBuckets) that all three occupancy surfaces call.
Detection: unit pairs, person-or-name join
A transfer is a (fromUnit, toUnit) pair where a resident holds a live
occupancy in fromUnit (status active/mtm, unit flagged NOTICE) and a
future one in toUnit (status pending, unit flagged VACANT_RENTED), in the
same property. Both sides are bounded by the unit statuses, so a pair can only
ever name units genuinely inside noticesGiven / pendingLeases.
Two deliberate 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. Lila Dummerly's 315→322 transfer 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. The fallback mirrors the remedyprospect-conversion.tsalready applies to this exact duplicate-Person problem. It is scoped to NOTICE→VACANT_RENTED within one property, and it cannot move the occupancy rate because the rate does not read this module.
The detector needs the active|mtm|pending occupancy view
(getSignedOccupancyTenants); the active-only view holds no transfer-in side at
all.
Asymmetric transfers stay plain notices
A resident who gave notice but whose destination unit is not yet pre-leased leaves an unfilled vacancy from the property's point of view. Only a matched pair is a transfer. Camellia has none of these today.
Consequences
- Occupancy card (caption + breakdown rows), MetricInsight dock, and the leasing-section caption gain a transfers bucket and net it out of the other two. One helper, so the three cannot tell different stories.
loadLeasingStatsgains one parallel read (getSignedOccupancyTenants) and one name resolution (resolveTenantNameMap, name-only — no claim fan-out), both inside existingPromise.allbatches, behind the 30s stats cache.transferUnitsis 0 for callers that don't passsignedOccupancies— a documented "not measured here" rather than a wrong number.- The owner-report email is deliberately untouched. Its "Total on notice"
comes from the customer's own weekly box score, and it already has a
Transferscolumn meaning a renewal-cohort outcome. Adding an in-flight transfer row there would coin terminology on a surface that mirrors a customer artifact verbatim. matchedBy: 'name'on a detected transfer is a live signal that the spine holds duplicate Person rows for one human — worth watching as input to an identity-merge sweep.
Alternatives considered
- Net transfers out of
noticesGiven/pendingLeasesat the source. Cleanest read, but it corrupts the NTV count that turnovers and maintenance depend on. - Make transfers a term in the trending formula. Mathematically a no-op (the +1/−1 already cancel), and it would put a fourth definition into a module whose entire purpose is that there is exactly one.
- Person-level counting. Simpler, and wrong: a couple transferring together is one unit-pair, not two transfers.