0076 — Transfers: label them correctly, change nothing else

Context

The incident, end to end (verified 2026-07-02)

  1. 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; no email-ntv:/turnover: idempotency keys exist; the turnover record carries none of the fields a PropFlow-written draft leaves (moveOutId, moveOutDraftUrl).
  2. 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, terminal NTV_RECEIVED saga row (ADR-0047 read-model), turnover turnover_1dea8280 created, move-out inspection scheduled on the Camellia Outlook calendar.
  3. 07/01 (Zoom, Camellia) — the PM confirmed the unit-201 tenant is a transfer to unit 324.
  4. 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]).
  5. 07/02, post-cancel — PropFlow shows the tenant as former, lease archived, saga closed ntv_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)

The one real defect

Where a transfer lands today (verified against code):

  1. The observed NTV closes the renewal saga terminal NTV_RECEIVED (src/lib/temporal/activities/renewal.ts:2430-2453).
  2. The read-model maps NTV_RECEIVEDRENEWAL_STATUS.DECLINED / workflowOutcome: 'declined' (src/lib/data/renewal-view.ts:366-375).
  3. The UI renders a "Declined" resolution badge (RenewalDetailClient.tsx hero, ADR-0055), and computeRenewalCounts counts 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:

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)

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

Verification