ADR-0035 — Finish the Tenant/Prospect collapse: Person + TenantOccupancy / ProspectInquiry
- Status: Accepted — de-overload COMPLETE + FROZEN (2026-06-03). Steps 3b-1 (reader cutover), 4 (FK repoint), 5 (strip + drain), the field-placement audit (F1–F4, F7, F9), and the Step-6 freeze guard all merged: identity / occupancy / money / co-tenant overload is off
Tenant+Prospectand on thePersonspine + role rows, fenced by both-tree drift guards (tsc 0 errors, 333/333 guard tests — verified by the 2026-06-03 phase A–F audit). The ONLY remaining spine work is full type retirement (Step 6 — replacingTenantwithTenantOccupancy), now UNBLOCKED — D2 is DONE (#2427, saga re-keyed topersonId/GSI4) and the renewal-state residual is cleared (2026-06-19; see the Phase-F line above + §22 oftenant-af-retirement-handoff.md). Scope:type-collapse-handoff.md. See the Status / sequencing section below + live log in collapse-progress.md. - D2 update (2026-06-16): the renewal→Temporal gate cleared (ADR-0047 retains the saga; #2415 drained the stuck cohort). D2's
RenewalSagahalf is now SHIPPED — re-keyedtenantId→personIdonto the new GSI4renewal-saga-person-index, prod-backfilled (85/85), drift-guarded, real-harness-verified. TheRenewalView/RenewalSnapshot+TenantBalanceSnapshothalf is deferred to a follow-up (saga-only first). On merge, Phase F / full type retirement is unblocked. Plan:d2-saga-rekey-handoff.md. - Phase F field de-overload — COMPLETE + SHIPPED (2026-06-19): every overloaded field is off
Tenant/Prospect. Slices 3a/3b/3c (#2503)/3d (#2508) + thespine-god-object-frozenallowlist prunes merged; the renewal-state strips have begun (timestamps#2517,evictionPending→Lease#2518). A multi-agent audit verified zero residual gaps. Step-6 (deleting the types) remains, gated on the last renewal-state field —reasonForNonRenewal(rides the Tenant→TenantOccupancy type-collapse; NOT a Lease strip — see §22).renewalSourcestripped in #2523;renewalOutreachStage→ Lease in #2521; renewal timestamps → RenewalSaga in #2517. Map:tenant-af-retirement-handoff.md§20–22. - Date: 2026-05-26 (proposed); 2026-05-27 (in progress)
- Deciders: Gera + spine-redesign audit (see HANDOFF)
- Supersedes: none
- Extends: ADR-0018 (Person identity model), ADR-0020 (Person as universal human spine), ADR-0032 (Spine-stamp as construction invariant), ADR-0033 (VendorCompany + VendorMembership decoupling)
- Companion docs: entity-model.md §Tenant/Prospect — the overload and the target shape · spine-migration-plan.md
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:
- ① Human identity —
firstName,lastName,email,phone,emergencyContact,phoneVerifiedAt,emailVerifiedAt. This duplicatesPerson+IdentityClaim, which exist and are the canonical identity store. - ② Occupancy / lease —
unitId,unitNumber,leaseStart,leaseEnd,additionalTenants[],archivedAt,leaseMoveOutDate,lastObservedNoticeAt, therenewal*cluster.TenantOccupancyalready models this (it even carriesrentAmount,status,securityDeposit,leaseId). - ③ PMS-mirror financial snapshot —
balance,rent,marketRent,version(all already@deprecatedin the type). Canonical homes:Lease.rentAmount(rent),Unit.marketRent(market rent),TenantBalanceSnapshotread viagetCurrentBalance()(balance). (The@deprecatedcomments point at aLedgerEntryaggregate — but noLedgerEntrytype exists; it was never built. Balance's real home today is the snapshot.)
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:
- Dual-write is live —
dynamo/property.ts:saveTenants→dualWriteOccupancyForTenant;dynamo/leasing.ts:saveProspect→dualWriteInquiryForProspect(4 call sites). Every save writes BOTH the god-object and the role row. - The role entities are write-only shadows — a repo-wide grep finds zero feature / domain / API / agent callers of
getOccupancy/getInquiry. Nothing reads them. - 187 call sites read identity off the god-object —
tenant.firstName / lastName / email / phoneare read directly in feature code instead of resolving throughPerson. - The natural-key FK columns are still live —
Tour.prospectId,WorkOrder.tenantId,Conversation.{tenantId,prospectId}still exist with 134.tenantId+ 9.prospectIdfeature reads; theirlegacy-field-elimination.drift.test.tspatterns are commented out (pre-staged), not active.
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:
- (a) The
tenantIdFK web.tenantIdis an FK to the Tenant god-object read by 8 entities —Lease,Unit,ConditionReport,Turnover,Renewal,RenewalSaga,RenewalSnapshot,TenantBalanceSnapshot. That web is the real blast radius, alongside the 187 identity reads. - (b) Three fields →
Lease(decided in Step 0).evictionPending,lastObservedNoticeAt,leaseMoveOutDateare lease-lifecycle facts living only onTenant; their writer is alreadywriters/lease-state.ts, soLeaseis the home (lease-level, not per-co-tenant).
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
- Identity lives once, on
Person+IdentityClaim(already true for resolution; make it true for reads too). - The tenancy is
TenantOccupancy(Person + unit + lease window + role + status). The inquiry isProspectInquiry(Person + property + stage + channel). - PMS-mirror money lives on
Lease.rentAmount/Unit.marketRent/TenantBalanceSnapshot(balance, derived viagetCurrentBalance()) — the existing homes for the@deprecatedTenant fields. (NoLedgerEntrytype exists; if a transactional ledger is wanted later, that's a separate effort.) Tenant/Prospecteither disappear (readers composePerson+ role-bridge) or shrink to a thin PMS-id pointer if a denormalized read cache proves necessary — decided per-reader during the migration, defaulting to disappear.
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.
- Reconcile field homes — ✅ DONE (decisions only; no risky code). (2026-05-26.) (a) The three homeless lease-lifecycle fields —
evictionPending,lastObservedNoticeAt,leaseMoveOutDate— go onLease(their writer is alreadysrc/lib/domain/pms/writers/lease-state.ts; they're lease-level, one per lease, not per co-tenant). They are added toLeasewith their writer in Step 2 — not as empty fields now. (b) Money homes verified:Lease.rentAmount(rent) ✅,Unit.marketRent(market rent) ✅; balance lives onTenantBalanceSnapshotbut thegetCurrentBalance()accessor the@deprecatedcomment references was never built — Step 1 adds it. See the field map. - Reader inventory + reshape API. Catalog the 187 identity-read sites + the
getTenantByPersonId/ occupancy read paths. Land a spine-rooted read API (resolveTenantContext(personId, propertyId)returningPerson+ activeTenantOccupancy) so callers have a single function to migrate onto. Also add thegetCurrentBalance(propertyId, personId)accessor (overTenantBalanceSnapshot+ the source-precedence merge) that Step 0 found missing. (Additive — no behavior change.) - Add the 3
Leasefields + repoint the PMS sync writers. First addevictionPending/lastObservedNoticeAt/leaseMoveOutDatetoLeaseand repointsrc/lib/domain/pms/writers/lease-state.ts(+ the NTV handler) to write them there (field + writer land together). Thenlambda/appfolio-syncwritesTenantOccupancy/ProspectInquiry(+TenantBalanceSnapshotfor balances) as the source of truth, not theTenantsnapshot. This is the prerequisite the entity-model overlay already calls out ("retirement requires Phase 6 to first re-point the sync writers"). - 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. - Repoint + drop the
tenantId/prospectIdFK web.tenantId(FK to the Tenant god-object) is read by 8 entities —Lease,Unit,ConditionReport,Turnover,Renewal,RenewalSaga,RenewalSnapshot,TenantBalanceSnapshot. Per entity, repoint the reference topersonId(when it wants the human) orTenantOccupancy/leaseId(when it wants the tenancy), then remove the column and activate the matching pre-staged pattern inlegacy-field-elimination.drift.test.ts(uncomment one line per column). Same for the already-pre-stagedTour.prospectId/Conversation.{tenantId,prospectId}. - 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(mirrorscripts/drain-*-orphans.ts), then delete the identity + occupancy + PMS-mirror fields fromTenant/Prospect. CollapseCoTenantin the same pass: the inlineadditionalTenants[]identities become co-tenantTenantOccupancyrows (Person+role: 'co_tenant'); delete theCoTenantinterface +additionalTenants[]. Retire the dual-write (it becomes a plain write to the now-canonical role entity). - 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)
- Activate the four pre-staged FK patterns in
legacy-field-elimination.drift.test.tsas each column drops. - New patterns pinning the stripped
Tenant/Prospectidentity + occupancy fields dead, so they can't crawl back. - A guard asserting the role entities now have feature readers (the inverse of today's "write-only shadow" state) — the cutover's success signal.
Alternatives considered
- Rejected alt 1 — Reverse the dual-write (delete
TenantOccupancy/ProspectInquiry, blessTenant/Prospectas canonical). Cheap (kills the write-only shadow immediately) but throws away real migration progress and permanently enshrines the overloaded god-object the whole spine refactor exists to kill. It also leaves identity duplicated forever (Person + Tenant both hold name/phone/email). Rejected — violates the spirit of ADR-0018/0020 and the one-source-of-truth rule. - Rejected alt 2 — Document the hybrid as "canonical, keep both." This was the pre-audit lean. Rejected — calling a god-object with a write-only shadow "canonical" is documenting an anti-pattern as if it were a design. The 2026-05-26 doc fixes (entity-model §Known gaps, corrected FK claims) are the honest version of this: document the gap as a gap, then close it via the chosen path below.
- ✅ Chosen — Finish the collapse. Only option that fully realizes the spine and reaches one source of truth.
Consequences
Positive
- One representation per tenancy/inquiry; identity owned once. The ONE-SOURCE-OF-TRUTH rule holds for humans end to end.
- The dual-write disappears (becomes a single write to the canonical role entity) — less write amplification, no lock-step drift risk.
- Unblocks the repo-wide legacy/dead-code sweep (HANDOFF R7), which is gated on this decision: until the collapse is scoped, "what is legacy" is ambiguous.
Costs / risks
- Large. 187 identity-read sites + the
tenantIdFK web (134 reads across 8 entities) + theCoTenantcollapse + thelambda/appfolio-syncrepoint. This is multiple PRs over time, not one change. - PMS-sync coupling. Step 2 touches the rent-roll/NTV writers — the highest-blast-radius part. Must follow the PMS-is-source-of-truth discipline (ADR-0030) and drain-before-flip.
- Sequencing. Phase-6-gated per the existing migration overlay. Do not start the field strip (steps 4–6) before the reader cutover (step 3) is complete for that surface, or production loses data a reader still needs.
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+Prospectand on thePersonspine + role rows, fenced by six per-category drift guards. The field-placement audit (F1–F4 + F7 + F9) shipped too (F7 — droppingWorkOrder.pmsExternalId— merged 2026-06-03). Step 6 capstone landed:spine-god-object-frozen.drift.test.tsis a compile-time field allowlist that freezes theTenant+Prospectkey 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 (replacingTenantwithTenantOccupancy) — the residualTenantfields are renewal-workflow state (owned by the renewal→Temporal migration; seerenewal-saga-retirement-handoff.md) and lease-lifecycle facts (target-data-model §3 moves them toLease). 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.