0081 — Renewing into a different unit: guard the accept path, detect transfers off the occupancy spine
- Status: Proposed
- Date: 2026-07-08
- Deciders: Fede
- Builds on / amends: ADR-0074 (transfer detection + turnover suppression), ADR-0076 (transfer labeling on the decline side). This ADR extends both to two places they never touched: (a) the renewal accept / offer / execute path, and (b) at-signing transfer detection, which today rides the prospect pipeline and so misses the common Camellia transfer.
- Prompting incident: 2026-07-08 end-to-end trace of a real Camellia unit 201 → 324 transfer — the same real transfer that prompted 0074/0076. The trace surfaced one structural gap and two live-failing gaps on the real tenant. Full evidence in Context.
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:
RenewalOfferPayload.occupancyId = TenantOccupancy.pmsOccupancyRef— the tenant's current unit — set by both send routes (src/app/api/leasing/renewals/approve/route.ts,.../outreach/route.ts) fromresolveTenantOccupancy(tenant).triggerRenewalSyncrefuses to enqueue without it (src/lib/integrations/sqs/agent-jobs.ts:243-249).- The Temporal renewal workflow (
src/lib/temporal/workflows/renewal-workflow.ts, ~1,745 lines) has zero unit-aware fields — agrepforunitId|unitNumber|occupancyIdreturns nothing.RenewalWorkflowInputis{renewalId, leaseId, tenantId, propertyId, channels}. Accept (INTENT_TO_RENEW) carries no unit input; execute (recordWorkflowOutcome,src/lib/temporal/activities/renewal.ts:2418-2472) re-resolves the same occupancy. - The offer letter is AppFolio's own rendered PDF, fetched not generated, against that occupancy (
src/lib/domain/leasing/renewal-orchestration/fetch-renewal-letter-pdf.ts). PropFlow relays it; the signature happens inside AppFolio against the source occupancy, and the workflow exitsexecutedwhen AppFolio reports that lease countersigned. - The only destination-unit field in the entire renewal data model is
RenewalSaga.transferredToUnitNumber— write-only-after-the-fact label data on the decline side (src/lib/data/types.ts:4779-4785), never an accept-time input.
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:
- Her 201 renewal saga
saga_3c2bd44bis stillNTV_RECEIVED; her tenant page shows renewal "Declined." She is not leaving the portfolio — she is in 324 (active, occRef 1012, appfolio:1863; her identity is unified across appfolio:1765/1863 on onepersonId). - She has zero
ProspectInquiryrows — 0 of 737INQUIRYrows table-wide, 0 of 72 prospects at Camellia. Her 324 lease was entered directly in AppFolio, never through PropFlow's prospect/leasing pipeline.
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):
- Relabel (
upgradeNtvSagaToTransferred, lines 316-336) — never runs for this tenant → saga staysNTV_RECEIVED→ "Declined." This is 0076's acknowledged residual (0076:45), now confirmed live on the motivating tenant. - 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 reachingEXECUTED, 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 (reconcileProspectConversions → LEASE_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).
- 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 becameactivewithin a lookback window, notProspectInquiryrows. For each: runclassifyLeaseExecution(already pure, prospect-free). Ontransferand not-yet-handled → call the two functions that already exist:upgradeRecentNtvSagaToTransferred(personId, propertyId, toUnit, now)— relabel the source sagaNTV_RECEIVED → TRANSFERRED.notifyExecutionToAccounting({ leaseKind: 'transfer', transferFromUnitNumber, … })— the transfer email, unchanged.
- 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 noProspectInquiryto stamp — that missing anchor is why the current sweep can't own this case. - 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.
- 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
- A — "Existing flow is good enough" (0074 + 0076 only). Rejected. It leaves (1) the accept-path safety hole — autonomous Camellia outreach can send a wrong-unit offer the tenant can't sign — and (2)+(3) the prospect-gated detection that already fails silently on the real tenant (Declined label + no accounting email). "Good enough for reporting on prospect-driven transfers" is true; "good enough" overall is not.
- C — Native transfer orchestration inside the renewal flow (renewal selects the destination unit, generates/executes the destination lease, carries the deposit). Rejected as wrong-altitude and over-build: a transfer is a new lease (leasing/move-in domain), the renewal primitive cannot represent a different unit, and AppFolio's Tenant Transfer already orchestrates move-out + move-in + deposit-carry. Rebuilding that in the saga adds a unit-selection UX, a move-in document path, and deposit accounting for no capability AppFolio doesn't already provide. Revisit only if transfer volume through the renewal channel justifies a self-serve in-app transfer flow — measure first (Open question).
- Auto-open a renewal saga on the destination unit because of the transfer. Unnecessary: the destination lease already enters the generic 90-day
renewal-auto-startscan on its own schedule — confirmed for 324, whose own renewal is already "ready to countersign." No transfer-specific path needed. - Manually fixing the transferring tenant's prod record. Per 0076: do not touch production. G3 corrects it forward the next time the occupancy path runs; if it doesn't re-fire for an already-signed lease, it is a one-record cosmetic label, not worth a manual write.
Consequences
- The renewal flow can no longer send or execute a wrong-unit offer to a transferring tenant (G1). On Camellia's autonomous outreach this removes a real, live-reachable failure rather than a hypothetical one.
- Transfers entered directly in AppFolio (the common Camellia case) start relabeling correctly and notifying accounting, closing the two gaps that fail silently today (G3). The 201→324 shape is the regression fixture.
- No new transfer entity, no renewal-side transfer builder, no deposit logic — the actual transfer stays in AppFolio's Tenant Transfer, where deposit-carry already works.
- Cost: reuse
detectTransferDestinationat two new call sites (the pre-prepare/pre-send guard; the occupancy-sync detector), one Clara intent class, one escalation copy string. The occupancy-sync detector is the only genuinely new trigger surface — scope it to the singlePERSON#partition read 0074 already established (no table scan).
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
- Unit: G1 guard (destination occupancy present → no offer prepared/sent, saga parked, PM escalated; absent → normal renewal). G3 occupancy-driven detector (new
activeoccupancy at another unit for a person with a recentNTV_RECEIVEDsaga → sagaTRANSFERRED+ accounting email, no prospect row required). Anonymized fixtures only; the 201→324 shape (NTV observed, destination signed later, no prospect) added as a named regression for both the relabel and the accounting email. - Prod: drive a transfer on Willows (test property, appfolio-45) with the destination lease entered directly in AppFolio (no prospect) → renewal shows "Transferred to unit N", the transfer accounting email fires, and the source-unit renewal never sends an offer. Camellia stays observed-only until proven.