0081 — Renewing into a different unit: guard the accept path, detect transfers off the occupancy spine

Context

The question

Camellia is running a lot of unit-to-unit transfers. Do renewals need to treat "transfer" as a first-class flow, or is the existing 0074 + 0076 machinery enough? Framed by a concrete concern from the pilot: if a renewing tenant is actually transferring, they can't sign the offer we prepared — it's for their current unit, not the one they're moving to.

What the renewal pipeline binds to a unit (verified from code, 2026-07-08)

The renewal saga is anchored to one occupancy for its entire lifecycle. The unit binding is fixed at prepare time and never revisited through accept or execute:

Conclusion: there is no way to represent "accept, but for a different unit." If a transferring tenant "renews," the only artifact the system can produce is a source-unit offer they cannot use — and on Camellia, where renewal outreach is autonomous, the ladder will send that wrong-unit offer with no human in the loop.

A transfer is a new lease, not a renewal

AppFolio's Tenant Transfer = a move-out on the old unit + a move-in (a brand-new lease) on the new unit, with the deposit carried (0076 Context, verified against the AppFolio KB). That is the leasing / move-in domain. The renewal primitive — a same-occupancy rent bump (the AppFolio renewal offer, or runScheduleRentIncreaseL4) — structurally cannot produce a different-unit lease. "Renewals natively execute a transfer" would mean rebuilding the move-in pipeline inside the renewal saga and duplicating what AppFolio's Tenant Transfer already orchestrates.

Two live-failing gaps on the real tenant (verified in prod, 2026-07-08)

The transferring tenant, Camellia 201 → 324:

Both of 0076's at-signing corrections live in sweepLeaseExecutionNotices, whose candidate set is only ProspectInquiry rows at LEASE_SIGNED (src/lib/domain/leasing/lease-execution-notices.ts:265-266, gate at 222-229):

  1. Relabel (upgradeNtvSagaToTransferred, lines 316-336) — never runs for this tenant → saga stays NTV_RECEIVED → "Declined." This is 0076's acknowledged residual (0076:45), now confirmed live on the motivating tenant.
  2. Transfer accounting email (notifyExecutionToAccounting, transfers-only, lines 379-398) — same candidate gate → it will NOT fire when this tenant signs the 324 lease. No prospect row, no candidate, no email. The renewal-side D3 (recordWorkflowOutcome) also won't fire — it triggers only on a same-unit renewal reaching EXECUTED, which a 201→324 transfer is not.

Both gaps share one root cause, and it is a category error, not a residual edge case: at-signing transfer detection is gated on the prospect pipeline. A transfer is an existing tenant moving units — not an inbound lead. The prospect/leasing funnel (reconcileProspectConversionsLEASE_SIGNED) exists to convert new leads into residents; a current resident who transfers has no reason to appear in it. So a transferring tenant carrying a ProspectInquiry row is the exception — it only happens if the office happens to route the transfer through the application funnel — not the rule. This tenant is the ordinary case (0 prospect rows), and the prospect-gated sweep drops her by construction. The forward-ordering NTV-time detector (detectTransferForNtv, decline-renewal-on-ntv.ts) is the one part that gets this right — it is occupancy-based and prospect-free — but it fires only when the destination occupancy was already active/pending at the moment the NTV was observed (not this tenant's ordering: NTV observed 07/01 15:33, 324 came active afterward), so she fell through to the prospect-gated sweep and was dropped.

Reproduced in prod (read-only, 2026-07-08)

Harness: a local read-only repro (scripts/_repro-transfer-email-miss.ts, _-prefixed → gitignored per repo convention) — runs the real sweepLeaseExecutionNotices against live prod (propflow-prod) with every write/send/external dep stubbed (zero DynamoDB writes, zero email, no L4 driver), then runs the pure spine detectors over the same data. Output:

PART A — occupancies @ Camellia: 201 former (occRef 936), 324 active (occRef 1012)
         sagas: saga_3c2bd44b NTV_RECEIVED (201)  |  324's lease → EXECUTED
PART B — real sweep (Camellia): candidates=0, sent=0, retroUpgradedSagas=0
         tenant among the sweep's transfer actions? NO   ← reproduced
PART C — detectTransferDestination(vacating 201) => {"toUnitNumber":"324"}
         classifyLeaseExecution(signed 324)       => {"kind":"transfer","fromUnitNumber":"201"}
VERDICT: BUG reproduced ✅   FIX signal present ✅

The candidate source is prospects, and she has zero prospect rows anywhere, so she is excluded by construction — not by a timing race. Meanwhile the occupancy spine already carries the full 201→324 signal. Note the second finding: 324 was processed as its own plain renewal (EXECUTED) while the 201 saga stayed NTV_RECEIVED — so at best accounting could have received a generic renewal-executed notice for 324, never a transfer notice, and the 201→324 link is invisible system-wide.

Decision

Support transfers in renewals as a guard-and-detect concern, not a build-a-transfer concern. Three pieces:

G1 — Halt the wrong-unit offer (the accept-path safety fix). Before OFFER_PREPARED and before each ladder send on a renewal saga, run the same-person / different-unit / same-property occupancy check (detectTransferDestination — reuse 0074's detector; no new detection logic). If a live/pending destination occupancy exists for the tenant, do not prepare or send a source-unit offer. Park the saga (reuse HUMAN_REVIEW_PENDING, closedReason rendered as "tenant is transferring — handle the new lease in AppFolio") and escalate to the PM. The guard escalates, it does not silently cancel — a tenant who legitimately holds two leases at the property is a human decision, not a dropped renewal. This is the piece that directly closes the concern raised.

G2 — Recognize transfer intent in the renewal conversation. Give Clara a transfer-intent classification, parallel to INTENT_TO_VACATE: a tenant who says "I want a bigger unit / to move within the building / a different apartment" is neither renewing-in-place nor churning. On transfer-intent, trip the same G1 halt + PM escalation even before any AppFolio destination occupancy exists — this catches the pure "I want to move" case at the moment it's expressed, before the ladder pushes a source-unit offer. Value is conditional on transfers actually surfacing through the renewal channel (see Open question) — ship G1 + G3 first.

G3 — Detect at-signing transfers off the occupancy spine (fixes both live gaps). Stop treating transfer detection as a prospect-funnel concern — a transferring tenant is a resident, not a lead. Add an occupancy-sync-driven path for the reverse ordering (destination lease appears after the NTV was observed) that does not depend on a ProspectInquiry: when a new active occupancy appears for a personId that holds a recent NTV_RECEIVED saga at a different unit in the same property, (a) retro-upgrade that saga to TRANSFERRED, and (b) fire the transfer accounting email — both keyed on the same personId correlation detectTransferDestination already performs. The prospect-gated sweep stays only as the incidental path for the rare transfer that did go through the application funnel; the occupancy-spine detector is the primary trigger. This single change makes this tenant's case — and every future direct-AppFolio transfer — relabel correctly and notify accounting.

The clean fix (implementation sketch for G3)

The defect is that the trigger for at-signing transfer handling is the prospect funnel. The fix is to make the trigger the occupancy spine, reusing the machinery that already exists and is already proven correct (Part C above).

  1. New occupancy-driven sweep (Vercel cron, same runtime as lease-execution-notices.ts — that runtime already has email + PDF transport; the AppFolio-sync Lambda deliberately does not, which is why this stays a sweep, not an inline sync hook). Candidate source = occupancies whose status became active within a lookback window, not ProspectInquiry rows. For each: run classifyLeaseExecution (already pure, prospect-free). On transfer and not-yet-handled → call the two functions that already exist:
    • upgradeRecentNtvSagaToTransferred(personId, propertyId, toUnit, now) — relabel the source saga NTV_RECEIVED → TRANSFERRED.
    • notifyExecutionToAccounting({ leaseKind: 'transfer', transferFromUnitNumber, … }) — the transfer email, unchanged.
  2. Idempotency anchored on the occupancy, not the prospect: add TenantOccupancy.transferNoticeSentAt (or reuse a saga-level marker), stamped before send, recipient-gate-before-stamp exactly as the existing sweep does (file header steps 3+5). A prospect-less transfer has no ProspectInquiry to stamp — that missing anchor is why the current sweep can't own this case.
  3. Retire the transfer branch from the prospect-gated sweep. Once the occupancy sweep owns transfers, the prospect sweep keeps stamping new-move-ins (a new move-in genuinely is a lead) and stops emitting transfer notices — removing the double-fire risk and the category error in one move.
  4. The forward-ordering NTV-time detector (detectTransferForNtv) stays as-is — it already catches the "destination synced before the NTV was observed" ordering off the same spine. The new sweep covers the reverse ordering (this tenant's), which is the currently-unhandled half.

Net: no new detection logic (reuse detectTransferDestination / classifyLeaseExecution / upgradeRecentNtvSagaToTransferred / notifyExecutionToAccounting), one new candidate enumeration, one occupancy-anchored idempotency field. This change sends real accounting email on an autonomous trigger, so it ships gated + prod-verified on Willows first, and is held for explicit sign-off (see Verification).

Rejected

Consequences

Open question (measure before G2; before ever reconsidering C)

How do Camellia transfers originate — through the renewal conversation, or out-of-band (walk-in / office / PM enters it directly in AppFolio)? G1 + G3 cover the out-of-band path (the 201→324 shape) regardless. G2's value, and any future case for C, depends on transfers surfacing through Clara's renewal channel. Instrument transfer-intent occurrences before investing further.

Verification