ADR-0035 — Finish the Tenant/Prospect collapse: Person + TenantOccupancy / ProspectInquiry


TL;DR

Tenant and Prospect are overloaded god-objects — each glues human identity (which the Person spine already owns), occupancy/inquiry state (which TenantOccupancy / ProspectInquiry already model), and (for Tenant) a PMS-mirror financial snapshot (which Lease / Unit / TenantBalanceSnapshot already own) into one row. ADR-0018 began the collapse — the role-bridge entities were built and a dual-write stamps them on every save — but the Phase-3c reader cutover never shipped. The role entities are write-only shadows (zero feature readers), and ADR-0032 then hardened the old god-objects with a required personId. The result is two written representations of every tenancy/inquiry — the exact "two ways to do the same thing" the ONE-SOURCE-OF-TRUTH rule forbids.

Decision: finish the collapse the way Vendor was finished (ADR-0033). Migrate readers onto Person + the role-bridge, repoint the PMS sync writers onto the role entities, strip the duplicated identity + occupancy + PMS-mirror fields off Tenant / Prospect, and retire them. End state mirrors Vendor: Tenant = Person + TenantOccupancy, Prospect = Person + ProspectInquiry.


Context

The overload (verified against src/lib/data/types.ts, 2026-05-26)

Tenant is one row doing three jobs:

Prospect is the same anti-pattern: identity (firstName/lastName/email/phone) + leasing-pipeline state (stage, desiredBedrooms, tourId, …) + personId.

This is precisely the overload ADR-0033 already fixed for Vendor: today's Vendor glued the company + the human + the engagement into one row, and was split into VendorCompany + Person + VendorMembership. The architectural claim is symmetric: Tenant = Person + TenantOccupancy ⇒ Vendor proved the pattern works.

The migration stalled at dual-write (the evidence)

ADR-0018 §3 introduced TenantOccupancy / ProspectInquiry as the role-shape replacements, landed schema + repo + dual-write helpers (Phase 3a), and deferred the reader cutover to "Phase 3c." Phase 3c never shipped. As of 2026-05-26, on origin/main:

So Tenant / Prospect are simultaneously labeled "legacy / @deprecated / replaced" and hardened as first-class construction-invariant spine entities (ADR-0032). Both can't be true. The docs were corrected on 2026-05-26 to stop claiming the collapse is complete; this ADR is the decision to actually finish it.

Field-placement audit (2026-05-26)

A scan of every spine-adjacent entity confirms the identity-overload is contained to Tenant, Prospect, and CoTenant (co-occupants stored inline as { firstName, lastName, phone, email } in additionalTenants[], already @deprecated). Everything else — Conversation, WorkOrder, Tour, User, VendorCompany, VendorMembership, the role-bridges — is clean; the only inline identity left elsewhere is display-name caches (WorkOrder.tenantName, Tour.prospectName, Conversation.participantName). Two structural facts shape the plan:

Full table: entity-model §"Tenant/Prospect — the overload and the target shape".


Decision

Finish the collapse. Make Person + TenantOccupancy / ProspectInquiry the single representation; retire the overloaded Tenant / Prospect rows.

Target shape

The field-by-field migration map is maintained in entity-model.md §"Where each Tenant field goes" (single source of truth for the mapping — not duplicated here).

Migration plan (drain-before-flip, one-source-of-truth, one slice per PR)

This mirrors the ADR-0032 / ADR-0033 playbook that already worked: small reviewable PRs, the new path replaces the old path in the same PR (no parked dead code), each elimination pinned by a drift guard.

  1. Reconcile field homes — ✅ DONE (decisions only; no risky code). (2026-05-26.) (a) The three homeless lease-lifecycle fields — evictionPending, lastObservedNoticeAt, leaseMoveOutDatego on Lease (their writer is already src/lib/domain/pms/writers/lease-state.ts; they're lease-level, one per lease, not per co-tenant). They are added to Lease with their writer in Step 2 — not as empty fields now. (b) Money homes verified: Lease.rentAmount (rent) ✅, Unit.marketRent (market rent) ✅; balance lives on TenantBalanceSnapshot but the getCurrentBalance() accessor the @deprecated comment references was never built — Step 1 adds it. See the field map.
  2. Reader inventory + reshape API. Catalog the 187 identity-read sites + the getTenantByPersonId / occupancy read paths. Land a spine-rooted read API (resolveTenantContext(personId, propertyId) returning Person + active TenantOccupancy) so callers have a single function to migrate onto. Also add the getCurrentBalance(propertyId, personId) accessor (over TenantBalanceSnapshot + the source-precedence merge) that Step 0 found missing. (Additive — no behavior change.)
  3. Add the 3 Lease fields + repoint the PMS sync writers. First add evictionPending / lastObservedNoticeAt / leaseMoveOutDate to Lease and repoint src/lib/domain/pms/writers/lease-state.ts (+ the NTV handler) to write them there (field + writer land together). Then lambda/appfolio-sync writes TenantOccupancy / ProspectInquiry (+ TenantBalanceSnapshot for balances) as the source of truth, not the Tenant snapshot. This is the prerequisite the entity-model overlay already calls out ("retirement requires Phase 6 to first re-point the sync writers").
  4. Cut readers over, slice by slice. Migrate feature surfaces (tenants list/detail, leasing, maintenance, renewals, Clara tools) off tenant.* identity/occupancy reads onto the spine-rooted API. One subsystem per PR; each PR deletes the old read in the same diff.
  5. Repoint + drop the tenantId / prospectId FK web. tenantId (FK to the Tenant god-object) is read by 8 entitiesLease, Unit, ConditionReport, Turnover, Renewal, RenewalSaga, RenewalSnapshot, TenantBalanceSnapshot. Per entity, repoint the reference to personId (when it wants the human) or TenantOccupancy / leaseId (when it wants the tenancy), then remove the column and activate the matching pre-staged pattern in legacy-field-elimination.drift.test.ts (uncomment one line per column). Same for the already-pre-staged Tour.prospectId / Conversation.{tenantId,prospectId}.
  6. Strip the god-object fields + collapse CoTenant + drain. With zero readers of the duplicated fields, drain/soft-archive any prod state still only-on-Tenant (mirror scripts/drain-*-orphans.ts), then delete the identity + occupancy + PMS-mirror fields from Tenant / Prospect. Collapse CoTenant in the same pass: the inline additionalTenants[] identities become co-tenant TenantOccupancy rows (Person + role: 'co_tenant'); delete the CoTenant interface + additionalTenants[]. Retire the dual-write (it becomes a plain write to the now-canonical role entity).
  7. Retire Tenant / Prospect (or shrink to the thin PMS-id pointer if step 3 surfaced a genuine read-cache need). Add a drift guard pinning the deleted fields dead.

Drift guards (every elimination gets one)


Alternatives considered


Consequences

Positive

Costs / risks


Status / sequencing

UPDATE 2026-06-02 — the de-overload is COMPLETE + FROZEN; Step 6 retirement is gated. Steps 3b-1 (reader cutover), 4 (FK repoint), and 5 (strip + drain) all merged — identity / occupancy / money / co-tenant overload is off Tenant + Prospect and on the Person spine + role rows, fenced by six per-category drift guards. The field-placement audit (F1–F4 + F7 + F9) shipped too (F7 — dropping WorkOrder.pmsExternalId — merged 2026-06-03). Step 6 capstone landed: spine-god-object-frozen.drift.test.ts is a compile-time field allowlist that freezes the Tenant + Prospect key sets — the god-objects can't grow a new identity/money/occupancy field under any name without a documented review. What's NOT done (and is GATED, not forgotten): full type retirement (replacing Tenant with TenantOccupancy) — the residual Tenant fields are renewal-workflow state (owned by the renewal→Temporal migration; see renewal-saga-retirement-handoff.md) and lease-lifecycle facts (target-data-model §3 moves them to Lease). Those migrations own the residual; retirement waits on them. The original in-flight description below is preserved for history.

Accepted, in progress. The low-risk doc reconciliation (R1) is done (2026-05-26). Steps 0–3b-0b done: field-home decisions + getCurrentBalance() accessor + Lease mirror-writes + Person.firstName/lastName + the 3b-0b name backfill (applied to prod 2026-05-26, 294 rows). Step 3b-1 reader cutover is in flight — 12 slices merged as of 2026-05-27: operator-UI (1–5), all live API routes (6–10), and the first two Clara slices (11 renewal handlers + the clara/Lambda getPersonsByIds mirror, 12 inbound agent loop + identify_caller). Remaining 3b-1: contact-read send-targets (needs listClaimsForPerson mirrored), remaining display reads, domain/*, identity helpers — then Step 4 (FK repoint), Step 5 (strip + drain — prod hard-stop), Step 6 (retire). Live status is collapse-progress.md; per-slice pickup is reader-cutover-handoff.md. The HANDOFF R-roadmap orders this after the (independent) agents/clara mirror collapse (R3) and before the repo-wide legacy sweep (R7), which this unblocks.