0074 — Detect tenant transfers vs move-outs (don't turn a transfer into a move-out)
- Status: Proposed
- Date: 2026-07-01
- Deciders: Fede
- Prompting incident: 2026-07-01 Camellia pilot call. Clara auto-created five move-out inspections; at least one was a unit-to-unit transfer (a tenant moving from one unit to another in the same property), not a move-out. The PM and JP ops (Kenya) confirmed transfers are still mishandled. Trello: IV6IB44U.
Context
How a move-out becomes a turnover today (verified from code, 2026-07-01).
A notice-to-vacate / move-out tenant_status change on an AppFolio occupancy is ingested by the sync and parsed (src/lib/integrations/appfolio/email-ingestion/parse-appfolio-ntv.ts, handle-appfolio-ntv.ts); the ntv / decline-renewal-on-ntv automation handlers (src/lib/domain/automations/handlers/) mark the occupancy former, and the turnover bridge auto-creates a turnover whose move-out inspection is a move_out ConditionReport (ADR-0034 slice 3, captureMoveOutInspection). There is no transfer-awareness anywhere in this path — any move-out/NTV is treated as a terminal departure.
What a transfer actually looks like in our data (verified from live prod, 2026-07-01, on two real Camellia transfers).
- Occupancies are keyed
PK=PERSON#<personId>,SK=OCCUPANCY#<occupancyId>(src/lib/data/dynamo/occupancies.ts), carry the AppFoliotenantId, and resolve to a stablepersonIdvia the identity model (ADR-0018 identity-enrichment). - AppFolio's "Transfer Tenant" issues a new AppFolio
TenantIdfor the destination-unit occupancy, and on the source unit it looks byte-identical to a normal move-out (tenant_statusmove-out fields set). AppFolio gives us no dedicated transfer flag. - But our identity model already unifies source and destination under one
personId. On a completed transfer (unit 308 → 218): the former source occupancy and the newactivedestination occupancy resolve to the samepersonId, even though their AppFolio TenantIds differ (source ≠ destination). The transfer signal therefore exists in our data even though it does not exist in AppFolio's per-unit tenant records. - The incomplete case: a transfer where only the move-out leg has synced because the PM has filed the source move-out but not yet completed the destination side in AppFolio (pending deposit/accounting). On a second transfer (unit 201 → 324), only the source move-out was present at sync time — no destination occupancy yet. This is exactly the shape Clara mis-classified into a move-out.
Root cause
The move-out → turnover path commits a move-out (spurious move-out inspection + turnover, and downstream the wrong deposit handling — a transfer's deposit carries to the new unit, it is not disposed) whenever an NTV/move-out fires, without checking whether the same person has (or is about to have) an occupancy at another unit in the same property.
Decision
Classify move-out vs transfer at the point the move-out/NTV signal is turned into a turnover, using our own personId as the correlation key.
- C1 — Transfer detection by
personIdcorrelation. When a move-out/NTV fires forpersonId Pat unit X (property Q), point-query P's occupancies (queryByPK(occupancyPK(P), 'OCCUPANCY#')— one partition read, no scan) and look for another occupancy at a different unit in the same property that isactiveorpending. If found → classify as TRANSFER: do not create the move-out inspection / turnover, and do not trigger move-out charges or deposit disposition. Stamp a source↔destination link so the reclassification is auditable. The classifier runs at the NTV handler entry point (handleNTVFiled,src/lib/domain/automations/handlers/ntv.ts) — beforecreateTurnoverActionandstartTurnoverWorkflowShadowfire — so a transfer is a short-circuit, not a cancel of an already-started workflow. - C2 — Default to move-out; only reclassify on a positive transfer signal. A false move-out (a transfer treated as a move-out) creates a spurious inspection — annoying but recoverable. A false transfer (a real move-out treated as a transfer) suppresses a needed turnover and the unit never gets turned — far worse. The classifier is therefore conservative: it stays a move-out unless it finds a concrete same-person / different-unit / same-property occupancy. When unsure, it is a move-out.
- C3 — Handle the incomplete-transfer timing gap. When the move-out is filed but the destination occupancy does not exist yet (the 201→324 shape), C1 cannot decide at that instant. Add a correlation window: defer the irreversible parts of the turnover (the move-out inspection, move-out charges, deposit disposition) for a configurable window, and retro-cancel the turnover if a same-person occupancy at another unit appears within the window. As a fallback for genuine ambiguity, a lightweight PM confirmation ("Is unit X a move-out, or a transfer to another unit?") resolves it immediately; the PM answer wins over the heuristic (and is locked per ADR-0016-style source precedence). The window timer lives in
turnoverWorkflow(src/lib/temporal/workflows/turnover-workflow.ts) as anawait condition(..., TRANSFER_CORRELATION_WINDOW)wait — retro-cancel fires on a signal, the PM prompt surfaces on timeout; no new cron or reconciler path for this. - C4 — Fail safe on identity mismatch. Correlation depends on identity-enrichment unifying both AppFolio TenantIds under one
personId(it did for 308→218). If enrichment fails to match (name typo, missing contact), we degrade to a plain move-out (C2) — never a silent drop.
Entity classification (see ADR-0027)
No new canonical entity. This adds a classification branch plus a link field on the existing TenantOccupancy.
| Entity | Class | Naming | Spine trace / derived-from |
|---|---|---|---|
TenantOccupancy.transferredToOccupancyId / .transferredFromOccupancyId (new fields) |
canonical | bare field | Spine trace: via personId directly; single writer = the occupancy sync/classifier |
TenantTransfer (optional, deferred) |
derived | * (audit record) |
from: the source+destination TenantOccupancy pair · Rebuilt by: the classifier on sync · Drift tolerance: ≤ one sync cycle. Add only if a first-class audit/reporting surface is later needed. |
Consequences
- Transfers stop generating spurious move-out inspections / turnovers and stop triggering wrong deposit disposition. The turnover pipeline reflects real vacancies only.
- A move-out is no longer committed purely from the NTV signal — it is committed after a cheap same-person occupancy check (one
PERSON#partition read). Small added latency, no new scans. - The incomplete-transfer window introduces a bounded delay before a genuine move-out's irreversible steps run. Tunable; short by default; genuine move-outs are unaffected beyond the window.
- Deposit money movement stays AppFolio-side (the PM does the transfer + deposit carry in AppFolio); our only job is to not fight it — no move-out charges on a transfer.
- One new occupancy link field and a classifier branch, gated behind the existing per-property turnover-autonomy flags so Camellia stays observed-only until explicitly opted in.
Alternatives considered
- Name / email correlation instead of
personId. Weaker and redundant — the identity model already performs this unification and hands us a stablepersonId. Re-deriving it ad hoc in the turnover path would drift from ADR-0018. - Wait for an explicit AppFolio "transfer" flag. Verified there is none — on the source unit a transfer is byte-identical to a move-out. Relying on AppFolio to tell us is a non-starter.
- Always prompt the PM ("move-out or transfer?"). Rejected as the primary mechanism — most transfers are auto-detectable (both legs synced, shared
personId); a prompt on every move-out is noise. Keep the prompt only as the C3 tie-breaker for the incomplete-timing case. - Detect only when both legs are already synced (drop the window). Simpler, but misses exactly the reported incident (201→324, destination not yet synced) — the most common real ordering, since PMs file the source move-out first.
Verification
- Unit: classifier returns move-out vs transfer for the cases (both-legs-synced / same person / different person same unit / no other occupancy / incomplete-then-appears-within-window / appears-after-window). Named regressions for the incomplete-transfer shape and the completed-transfer shape, using anonymized fixtures (no real tenant PII).
- Prod (to follow): drive a transfer on Willows (test property) end-to-end — confirm no move-out inspection/turnover is created and the transfer link is stamped; confirm a genuine Willows move-out still turns. Camellia observed-only (turnover autonomy not opted in) until the classifier is proven.