0076 — Transfers: label them correctly, change nothing else
- Status: Proposed (v2 — slimmed after design review with Fede, 2026-07-02; v1's D1/D2/D3/D5 rejected, see below)
- Date: 2026-07-02
- Deciders: Fede
- Builds on / amends: ADR-0074 (transfer detection). Keeps 0074's classifier idea and conservative default; rejects 0074's C1 turnover suppression (§Rejected below).
- Prompting incident: 2026-07-01/02 Camellia unit 201 → 324 transfer. Full timeline in Context.
Context
The incident, end to end (verified 2026-07-02)
- 07/01 ~11:09 AM — a move-out for the unit-201 tenant was started in AppFolio under the shared "Clara PropflowAI" login. This was a human entering the move-out leg of her 201→324 transfer, not automation: the handler-boundary tool audit (
TOOL_LOG#2026-06-30/07-01, 642 calls) contains zero move-out/NTV/tenant-status writes; noemail-ntv:/turnover:idempotency keys exist; the turnover record carries none of the fields a PropFlow-written draft leaves (moveOutId,moveOutDraftUrl). - 07/01 15:33 UTC — the AppFolio sync observed the notice (
senderId: automation:ntv:appfolio) and ran the designed observed-NTV path: occupancy →former, lease archived, terminalNTV_RECEIVEDsaga row (ADR-0047 read-model), turnoverturnover_1dea8280created, move-out inspection scheduled on the Camellia Outlook calendar. - 07/01 (Zoom, Camellia) — the PM confirmed the unit-201 tenant is a transfer to unit 324.
- 07/02 10:20 AM — JP's controller canceled the move-out in AppFolio. Correct call: a bare move-out routes the deposit toward disposition/refund, while a transfer's deposit carries to the new unit ([AppFolio: Tenant Transfer]).
- 07/02, post-cancel — PropFlow shows the tenant as
former, lease archived, saga closedntv_received, a live turnover, and the inspection on the calendar. Her renewal reads as Declined — false: she isn't leaving the portfolio.
What AppFolio gives us (verified against the AppFolio KB + live prod)
- AppFolio's Tenant Transfer task is itself a move-out on the old unit + a move-in on the new unit, with the deposit step offering transfer to the new unit instead of refund/disposition. A move-out record on the source unit is the shared substrate of both churn and transfer — even a correctly-entered transfer produces one. There is no transfer flag on the source occupancy (0074 verified: byte-identical to a normal move-out).
- Staff can also legitimately fall back to bare move-out + move-in (Transfer Tenant moves all tenants in an occupancy and requires a vacant destination), so we cannot assume the wrapped flow.
The one real defect
Where a transfer lands today (verified against code):
- The observed NTV closes the renewal saga terminal
NTV_RECEIVED(src/lib/temporal/activities/renewal.ts:2430-2453). - The read-model maps
NTV_RECEIVED→RENEWAL_STATUS.DECLINED/workflowOutcome: 'declined'(src/lib/data/renewal-view.ts:366-375). - The UI renders a "Declined" resolution badge (
RenewalDetailClient.tsxhero, ADR-0055), andcomputeRenewalCountscounts the tenant as a lost decision — transfers drag the renewal rate down.
NTV_RECEIVED has exactly one meaning in the system (tenant is leaving = declined), and a transfer lands in it because the source-unit move-out is indistinguishable at the record level. That mislabel — a retained tenant shown as churn — is the only defect this ADR fixes.
Everything else the incident touched is, on review, not a PropFlow defect:
- The turnover was correct. The source unit really is turning: inspection, make-ready, relist all apply. Charges/damages can apply to a transferring tenant exactly as to a departing one.
- The deposit was never at risk from us. PropFlow drafts the move-out and stops; accounting finishes it in AppFolio (deposit split, disposition letter) — see
src/lib/domain/turnover/move-out-handoff.ts. The controller caught and handled the transfer's deposit correctly, in AppFolio, with no PropFlow involvement.
Decision
Three small pieces, nothing else:
D1 — Transfer detector in the sync path. When the observed-NTV automation fires (decline-renewal-on-ntv.ts, the single entry point all three NTV_RECEIVED writers converge on), also check whether the same person (personId) holds an active/pending occupancy on a different unit at the property (detectTransferDestination, one PERSON# partition read; stricter than the notice-labeling classifier — no mtm, no recency-gated former). Conservative default per 0074 C2: no positive signal, or any detection error, → plain move-out. The detector's only output is the renewal outcome below — it does not alter the turnover, which is created and run identically for every move-out.
For the opposite ordering (destination lease signs after the NTV was observed), the lease-execution sweep (lease-execution-notices.ts) retro-upgrades: when a signed lease classifies as a transfer, the person's recent NTV_RECEIVED saga at the property is re-closed as TRANSFERRED (upgradeRecentNtvSagaToTransferred, 120-day lookback) — the one sanctioned terminal→terminal correction. Known residual: that sweep keys on ProspectInquiry rows, so a destination lease entered directly in AppFolio with no prospect record won't retro-upgrade; the NTV-time detector covers it only when the destination occupancy synced first.
D2 — TRANSFERRED renewal outcome. The renewal saga gains a terminal TRANSFERRED state (closedReason transferred, transferredToUnitNumber stamped for copy), distinct from NTV_RECEIVED. Read-model and UI render it as what it is — "Transferred to unit 324" — a retention outcome, never "Declined" (plain-English copy bar applies). In computeRenewalCounts a transfer is excluded from the renewal-rate math on both sides: the old lease wasn't renewed (counting it as a numerator yes would inflate the strict executed-only rate), and the tenant didn't churn (counting it as a decided non-renewal is the false narrative this state exists to fix). It gets its own bucket (transferred / contactedTransferred).
D3 — Accounting heads-up: shipped and always on, transfers only. PR #2823 implemented the transfer-aware accounting email: the lease-execution sweep classifies each signed lease as new-move-in vs transfer (same-person occupancy spine) and sends "Transfer lease signed — {name} (Unit 201 → Unit 324)" via notifyExecutionToAccounting when the transferring tenant signs the new lease. Per the 2026-07-03 product decision (Fede), the original rollout gates (LEASE_EXECUTION_AUTONOMOUS_SENDING env arm + per-property newLeaseExecutionNoticesEnabled) were removed and the notice is transfers-only: a plain new-move-in signing is stamped as handled but never emailed.
Amended 2026-07-15 (Fede): all lease kinds. The transfers-only narrowing is superseded — the prospect-driven sweep now emails plain new move-ins too (
leaseKind: 'new_move_in'). Trigger: a unit-201 Camellia new move-in fully executed with no email while the property-settings "Lease signed" recipients field promises one for any signed lease; a silent skip is indistinguishable from a regression. Renewals keep their Temporal D3 path. The occupancy-driven sweep (ADR-0081 G3) stays transfers-only — its non-transfer candidates are either prospect-corroborated (the sibling sweep's territory, now emailed there) or walk-ins with no application record, which remain uncovered.
Rejected (design review 2026-07-02, Fede)
- v1 D1 — transfer-typed turnover with a split unit-ops/person-money track (
turnoverType: 'transfer'suppressing charges, draft, disposition, comms). Rejected: a turnover is a turnover. The source unit turns identically, and charges/damages can apply to a transferring tenant too. Splitting tracks adds a type discriminator, plumbing across the workflow, and per-step gates — complexity with no correct behavior gained. (This also rejects 0074's C1 suppress-the-turnover-entirely, which trades a money bug for a vacancy bug.) - v1 D5 — reconcile-on-cancel drift detection (react to a notice disappearing from AppFolio: revert occupancy/lease, cancel turnover, pull calendar event, reopen saga). Rejected: not worth the machinery.
- v1 D2/D3 —
TenantTransfercanonical entity +pms.lease.transfer_detected/transfer_canceledevent pair. Dropped with the above: with the turnover untouched and no reconcile automation, the classifier has exactly one consumer (the saga outcome) — it writes that directly; an entity and event bus fan-out have nothing to fan out to. If D3 (accounting email) ships, it's called from the same classifier code path. - Manual production fix of the unit-201 tenant's record (revert occupancy/lease, cancel turnover, correct saga, pull calendar event). Explicitly rejected: do not touch anything in production. The record stays as-is.
Out of scope, tracked separately: shared-login attribution. The incident's trust damage came from a human acting under the "Clara PropflowAI" AppFolio login, making the activity feed blame automation. That is an ops/credential decision (automation-only login, humans get their own), flagged for the JP accounting follow-up call.
Consequences
- Transfers stop rendering as "Declined"; renewal-rate math stops undercounting retention. Nothing else changes.
- Turnover pipeline: zero changes. Deposit/disposition: stays where it already correctly lives (accounting, in AppFolio).
- A PM canceling a move-out in AppFolio still leaves PropFlow state stale (occupancy
former, live turnover) — accepted, per the D5 rejection. If this recurs enough to hurt, reopen that decision with data. - Cost: one correlation lookup in the sync path, one saga state + read-model mapping + copy, and a retro-upgrade branch in the existing sweep. No new entities, events, crons, or scans; no new email (D3 pre-existed).
Verification
- Unit: detector (same-person new-occupancy within window → transfer; no signal → plain move-out);
TRANSFERREDterminal state + read-model mapping + copy rendering; renewal-count math counts transfer as retained. Anonymized fixtures only. - Eval: transfer-as-churn misclassification added to the signal-intent eval set as a named regression.
- Prod: drive a transfer on the test property (appfolio-45): move-out + same-person move-in on another unit → renewal shows "Transferred to unit …", turnover unchanged from a plain move-out. Camellia stays observed-only until proven. No production data is manually edited.