ADR-0034: Turnover orchestration architecture

Context

A turnover is the process that runs when a tenant moves out and a unit must be made rent-ready for the next tenant: notice-to-vacate → move-out → inspection → scope of work → dispatch trades (paint, repairs, flooring, cleaning) → QC → ready-to-list — and, when the inspection finds tenant-caused damage, a parallel branch that deducts repair cost from the security deposit with a legally-defensible itemized statement.

Today the loop is only partly automated. An NTV auto-creates a Turnover (notice_received), computes its target ready date from turnoverPolicy.unitAvailabilityDays (handleNTVFiled, src/lib/domain/automations/handlers/ntv.ts), and books an inspection at moveOutDate + turnoverPolicy.inspectionDelayDays (scheduleInspectionEventAction, src/lib/domain/automations/actions/schedule-inspection-event.ts). When the resulting work orders complete, checkTurnoverProgress (src/lib/data/turnover-helpers.ts) already advances the turnover to ready_for_listing. So the policy is partly consumed (the availability/inspection-delay day offsets) and the open→ready loop already closes once WOs are scoped and finished. What is actually missing is: (1) auto-creating the work listturnoverPolicy.defaultTrades / defaultVendors are stored and shown in the property UI but no code turns them into WOs (a human still scopes manually); (2) the cleaning-goes-last sequencing rule; (3) automatic dispatch; and (4) any concept of security-deposit accounting or evidence retention.

Why an ADR now, before code: turnover is the next core domain after maintenance and renewals. It sits across three subsystems that already have strong, opinionated patterns (work orders, Temporal orchestration, PMS adapter) and introduces one genuinely new, high-liability subsystem (deposit dispositions). Getting the boundaries and the system-of-record decisions right once avoids the reactive churn the renewal saga suffered (nine workflows stuck 50–333h; reconciler crons silently failing — see ADR-0025). The goal is the simplest architecture that (a) reuses what already works, (b) scales to 10k+ units, (c) stays PMS-agnostic, and (d) is legally defensible for deposit money.

What the industry and the law require (research summary)

Decision

Adopt a four-layer architecture that composes the two proven platform patterns at the layer each is best at, plus one new compliance layer. Do not invent a new orchestration primitive, a new ID scheme, a new audit log, or a PMS-specific path.

graph TD
    subgraph Trigger
      NTV[NTV / move-out event
from PMS poller] end subgraph L1[Layer 1 · Orchestration · Temporal] TWF[turnover-workflow
durable timers · signals · phases] end subgraph L2[Layer 2 · Work execution · local-first + Agent Smith] WO[WorkOrders as turnover children
handleCreateWorkOrder · autoAssignVendor] SEQ[Sequencing engine
cleaning-last DAG] end subgraph L3[Layer 3 · PMS-agnostic adapter] PMSC[PMSClient reads] PMSW[PMSWriter writes
WO create · deposit disposition] end subgraph L4[Layer 4 · Compliance · NEW] DISP[TurnoverDisposition
itemized deductions] EV[Evidence store
S3 object-lock · 7y] AUD[EntityActivityEvent
append-only audit] end NTV --> TWF TWF -->|dispatch activity| WO WO --> SEQ SEQ -->|WO complete signal| TWF WO <-->|create/sync| PMSW TWF -->|damage found| DISP DISP --> EV DISP -->|post disposition| PMSW TWF & WO & DISP --> AUD PMSC -->|deposit balance · lease| TWF

Layer 1 — Orchestration: Temporal (reuse the renewal pattern)

The turnover lifecycle is long-running, timer-driven, and signal-reactive — the exact shape that drove renewals onto Temporal (ADR-0025) and that the canonical guidance says must not be rebuilt with watchdog crons. A turnover-workflow owns the durable state machine: awaiting_moveoutinspectionscopedwork_in_progress (with the sequencing gate) → qcready_for_listing, plus the deposit_disposition branch with its dispute_window.

Reused from src/lib/temporal/ (current patterns, post-2026-05-24 consolidation):

Layer 2 — Work execution: local-first + Agent Smith (reuse the maintenance pattern)

Each turnover task is a WorkOrder created through the existing handleCreateWorkOrder local-first handler (ADR-0031): mint a local TICKER-NNNNNN id, return in ~100ms, defer PMS sync to Agent Smith (gather → judge → dispatch). autoAssignVendor is reused for vendor routing (in-house-first), now fed by turnoverPolicy.defaultVendors. The Temporal workflow dispatches WO creation as an activity and waits on a workOrderCompleted signal — it does not re-implement WO mechanics.

New and unique to turnover (not in maintenance): a sequencing engine. Maintenance is single-WO; renewals orchestrate channels; a turnover orchestrates a dependency DAG of WOs with a hard finishing constraint (cleaning runs only after all other WOs complete). Modeled as a small pure module: a per-property ordered DAG in policy (finishingTrades, default ['housekeeping']), with the workflow releasing the next stage when predecessors report complete. This is the one new orchestration concept the domain adds.

Layer 3 — PMS-agnostic adapter (reuse + extend)

All PMS interaction flows through the adapter registry (getPMSClient / PMSWriter); domain code never imports integrations/appfolio directly. Extend the interfaces with optional turnover capabilities so Yardi/RealPage/Entrata can implement later:

Layer 4 — Compliance & money (NEW — the genuinely greenfield part)

When the inspection flags tenant damage, the repair WO is tagged chargeable; its actual vendor-invoice cost flows into a DeductionLineItem on a new immutable TurnoverDisposition aggregate. This is where legal defensibility lives:

Move-out close-out maps to the PMS's native move-out workflow

The disposition write is not a bespoke ledger poke — it drives the PMS's own move-out close-out. Two findings shape the design:

recordSecurityDepositDisposition? therefore takes PMS-agnostic inputs (dates, reason, itemized deductions, refund split) and each adapter maps them to its native flow. The cross-PMS comparison below confirms this generalizes.

Cross-PMS validation: the move-out close-out pattern is universal

Web research across the PMSes this ADR targets confirms the "notice (inbound/detected) → turn the unit → separate, post-inspection, human-gated financial close-out (itemized deductions + deposit statement)" pattern is industry-universal — which is exactly why the disposition belongs on the PMS-agnostic interface, not in AppFolio-specific code.

PMS Move-out close-out Notice separate? Deposit artifact Inspection-driven? Write surface
AppFolio "Move Out Tenants" task Yes (Notice vs Move Out date) Disposition letter (SODA) charges manual / post-turn none public → browser automation (L4)
Yardi "On Notice" → Move-Out → SDR Yes Deposit Disposition (SDR) / final acct stmt post-move-out SOAP/REST via SIPP (partner)
RealPage OneSite "Process a Move Out" implied Final Account Statement (+photos) photos post-inspection REST (developer portal)
Entrata onNoticeLeasecancelLease implied trust-account reconciliation post-close GL REST (signed agreement)
Buildium Move-out workflow + native pre/post inspection Yes itemized refund statement yes — built-in inspection public REST + webhooks

Implications for the design:

Condition capture is agent-native, not form-native (capture → project → freeze)

The turnover's condition record follows the Agent Smith pattern (ADR-0031) — the same capture-NL-then-LLM-structure spine maintenance already uses (tenant conversation → judgeWorkOrder). It does not model Camellia's paper Condition Sheet as a rigid table the PM tabs through. Three layers:

The determinism half of this contract is superseded by ADR-0121 (2026-07-30). PR #4688 flipped the projection to claude-opus-5, which rejects an explicit temperature, so no temperature has been sent since 2026-07-27; PR #4948 made provenance honest (condition-projection-v14@claude-opus-5) but left the policy open. Measured: 0 of 3 scenarios reproducible across 3 runs, with attribution/chargeable flips in 2 of 3. The freeze and provenance halves below stand unchanged; "temp 0" and the audit-replay claim do not, superseded by ADR-0121.

The paper form's real value is its coverage checklist (so the agent doesn't skip a room) and its legal output format — both become context/policy, not schema. This is also what keeps the design PMS-agnostic and scalable to 10k+ units: there is no rigid per-property/per-PMS form schema to migrate.

Clara-native, multi-modal, async intake (the interface decision)

The capture→project→freeze spine above answers how condition data becomes structured. This section answers a separate question: how does the PM interact with the turnover at all? The decision: full parity between Clara's conversational channels and the web UI. Both are first-class for the entire turnover lifecycle — scoping, status, approvals — and the PM picks whichever fits the moment. This is the natural completion of the ADR's "capture → project → freeze, not a digitized form" thesis, extended from condition-capture out to the whole turnover interaction. A PM walking a unit shouldn't have to find a desk; a PM at a desk shouldn't be forced into a phone call. Neither channel is the "real" one with the other as a fallback.

Eval & multi-turn test strategy

AI quality is a first-class, cross-cutting workstream for turnover — mirroring how leasing/touring are gated today — not a per-slice afterthought. This generalizes the damage-eval ship-gate (already in Slices 3/4) into a strategy that also covers the intake surfaces Section A introduces. The repo already runs this exact discipline for the conversational domains; turnover extends the same machinery rather than inventing a parallel one.

What we reuse vs. what is unique (the core of this decision)

Capability Source pattern Reuse / New
Long-running timers, signals, replay, shadow cutover Renewals / Temporal (ADR-0025) Reuse — new turnover-workflow + propflow-turnover queue
Autonomous-send gating (fail-closed armed gate) Renewals / Temporal (autonomous-gate.ts) Reuse pattern → TURNOVER_AUTONOMOUS_SENDING + Property.autonomousTurnoverEnabled
WO creation, local id mint, vendor auto-assign, PMS sync Maintenance / Agent Smith (ADR-0031) Reuse handleCreateWorkOrder, autoAssignVendor, mint-id, Agent Smith
NL capture → LLM-judged structured projection Maintenance / Agent Smith (ADR-0031) Reuse the capture→project→freeze pattern for ConditionReport
Conversational voice intake/approvals ElevenLabs stack (integrations/voice, voice-agents/*.config.json) Reuse — new turnover/intake voice agent config, not a new transport
SMS/email multi-turn intake/approvals Clara conversation engine (conversation-manager.ts, messaging/) Reuse — turnover is a new mode, not a new pipeline
Inbound photos → vision (pointer-keyed) integrations/anthropic/files.ts (uploadInboundPhotoToAnthropic) Reuse — projection gets file keys, not bytes
promptfoo evals + multi-turn stress/gauntlet suites Leasing/maintenance (evals/, scripts/*-stress, scripts/canary) Reuse + extend — turnover eval & multi-turn suites as a ship-gate
PMS-agnostic reads/writes PMS adapter registry Reuse + extend (deposit balance, disposition write)
Append-only audit, timeline, S3 photo+sync EntityActivityEvent, WorkOrderPhoto Reuse
Identity / Person spine on WOs Spine-stamp (ADR-0032) Reuse
Multi-WO sequencing DAG (cleaning-last) NEW — small pure module
Inspection → damage-assessment fork NEW
Security-deposit disposition + evidence + deadline + compliance gate NEW — Layer 4

Lifecycle state machine

stateDiagram-v2
    [*] --> awaiting_moveout: NTV filed
    awaiting_moveout --> inspection: move-out date timer fires
(inspection auto-scheduled) inspection --> scoped: inspectionCompleted signal
auto-scope from policy scoped --> work_in_progress: non-finishing WOs dispatched work_in_progress --> work_in_progress: workOrderCompleted
(release next DAG stage) work_in_progress --> finishing: all non-finishing WOs done
dispatch cleaning (LAST) finishing --> qc: cleaning complete qc --> ready_for_listing: PM sign-off ready_for_listing --> [*] inspection --> deposit_branch: tenant damage found state deposit_branch { [*] --> assessing: LLM-assisted classification assessing --> pending_pm_approval: itemized + evidence assembled pending_pm_approval --> dispute_window: PM approves (HARD GATE) dispute_window --> posted: deadline timer / no dispute posted --> [*] }

Workflow phases vs. persisted stage (resolves a real mismatch). The 7 states above are the Temporal workflow's internal phases (Phase 1+) — exactly as the renewal workflow's phases are distinct from the saga's persisted states. The entity's persisted TurnoverStage (src/lib/data/statuses/turnovers.ts) stays the existing 4 values in Phase 0notice_received, work_scheduled, ready_for_listing, cancelled — with the intermediate phases (inspection, scoped, finishing, qc) tracked via inspection + task fields, not new enum values. Mapping: awaiting_moveoutnotice_received; scoped/work_in_progress/finishing/qcwork_scheduled; ready_for_listing/cancelled unchanged. Phase 0 needs no enum migration; if Phase 1 wants the finer phases persisted, it migrates the enum + TURNOVER_VALID_TRANSITIONS then.

Data model

erDiagram
    PROPERTY ||--o{ TURNOVER : has
    PROPERTY ||--|| TURNOVER_POLICY : configures
    LEASE ||--o| CONDITION_REPORT : "move-in capture"
    UNIT ||--o{ CONDITION_REPORT : documents
    CONDITION_REPORT ||--o{ EVIDENCE : "NL notes + timestamped photos (S3)"
    CONDITION_REPORT ||--o| CONDITION_PROJECTION : "LLM-derived, frozen on PM approval"
    TURNOVER ||--o{ TURNOVER_TASK : contains
    TURNOVER ||--o| CONDITION_REPORT : "move-out capture"
    TURNOVER ||--o{ WORK_ORDER : "child WOs"
    TURNOVER_TASK ||--o| WORK_ORDER : materializes
    TURNOVER ||--o| TURNOVER_DISPOSITION : "if damage"
    TURNOVER_DISPOSITION ||--o{ DEDUCTION_LINE_ITEM : itemizes
    DEDUCTION_LINE_ITEM ||--o| WORK_ORDER : "actual cost from"
    DEDUCTION_LINE_ITEM ||--o| CONDITION_PROJECTION : "damage evidenced by"
    TURNOVER_DISPOSITION ||--o{ EVIDENCE : "+ invoices (S3)"
    TURNOVER ||--o{ ENTITY_ACTIVITY_EVENT : "append-only audit"

ConditionReport is the capture — natural-language notes + timestamped photos, immutable, lease-anchored, type: 'move_in' | 'move_out'. ConditionProjection is the LLM-derived structured view (room/item condition + move-in↔move-out comparison + candidate damage) — a draft until a PM approves it, at which point it freezes into an immutable versioned snapshot (model version + provenance to source media). TurnoverDisposition / DeductionLineItem / Evidence are the immutable money/legal types on new partitions — e.g. disposition rows keyed PK=DISPOSITION#<turnoverId>, SK=v<N>#<ISO-timestamp> (append-only versioned, never updated in place), with line items + evidence as child SKs under the same PK. The move-in capture happens at onboarding and persists for the whole tenancy; the move-out capture is the turnover's inspection (it replaces the freeform TurnoverInspection, which is deleted when ConditionReport ships in Slice 3 — one way to do things, no parallel inspection type kept alongside), and its projection runs against the move-in baseline to drive deductions. See "Condition capture is agent-native" above. turnoverPolicy extends with finishingTrades, sequencing DAG, the auto-dispatch cost cap (starting value $500, matching the existing maintenance aiAutoApproveThreshold default in src/lib/platform/settings-resolver.ts so turnover and maintenance share one human-approval bar; raise per-property as trust grows), the room/item coverage taxonomy + itemization template, and the operator-entered deposit-return deadline. Everything property-specific is DDB config, never source constants (multi-tenant-by-construction rule).

Agentic-first: automated vs. gated

Step Automated (context + policy) Human gate
NTV detection, inspection scheduling
Auto-scope default trades, vendor assignment, dispatch ✅ (auto-dispatch when estimate ≤ $500, the policy cost cap) cost > $500 → PM approves
Sequencing (cleaning last), WO follow-up timers
Damage classification (wear vs tenant) LLM-assisted draft ✅ PM confirms
Deduction posting / statement to tenant evidence assembled automatically ✅ PM approves before money moves
Final ready-for-listing QC photos compared ✅ PM sign-off

Scale (10k+ units) & PMS-agnosticism

Phased rollout — correct architecture from day 1, vertical slices

Principle (non-negotiable): every slice is a correct, end-to-end piece of the target architecture — never a throwaway or handler-path/cron shim that a later slice rips out. Orchestration lives in Temporal from Slice 1 (the renewal lesson: don't build long-running coordination in the request/cron path and migrate later). Each slice is verified autonomously in preview AND against the production test property (test-harness-first) before the next begins.

Supersedes the earlier "Phase 0 in the handler path" framing. A handler-path checkTurnoverProgress orchestration would be exactly the interim-then-override anti-pattern this principle forbids. The data/policy groundwork (turnoverPolicy.finishingTrades, TurnoverTask.finishing, vendor precedence, the PMS-adapter capabilities) is correct regardless of host and is reused as-is by the workflow's activities — only the orchestration trigger belongs in the workflow, not a request handler. (PR #1408 contributed that groundwork; its handler-path orchestration is replaced by Slice 1, not promoted to the foundation.)

Each slice ships the right architecture for its scope; nothing is rebuilt later. Slices 1–2 deliver Sean's end-to-end ask on the correct backbone; 3–5 layer evidence, money, and intelligence onto the same workflow.

Alternatives considered

Consequences

Positive: reuses two battle-tested patterns — and the interface stack too (ElevenLabs voice, the Clara conversation engine, vision), so multi-modal intake is reuse, not new transport; the closed loop ships in Slice 1 on the correct Temporal backbone (no interim handler-path orchestration to rip out later); scales and stays PMS-agnostic by construction; the high-liability deposit flow has a defensible, immutable, human-gated design from the start; the PM meets the turnover wherever they are (in the field by voice/photo, at a desk on the web) with PM-judgment gates delivered through the channel rather than parked in a dashboard.

Negative / risks: a second Temporal task queue + worker to operate; the damage-assessment LLM is a money-deciding surface whose eval coverage is a hard ship-gate, not a residual riskevals/promptfooconfig-turnover-damage.yaml (locked AI settings: temp 0, pinned model + prompt version) must exist and pass before Slices 3/4 ship, because a wrong-but-plausible classification would pass a human approval click unnoticed; the sequencing DAG is a new orchestration surface; the deposit deadline is operator-entered (no maintained per-state legal table — that exposure stays with the customer's counsel by design); spine-stamp (ADR-0032) is a prerequisite for WO identity. The cost of full channel parity (Section A): every turnover action must work and be tested in both the conversational channels and the web UI — roughly double the interaction surface and test matrix. We accept it because a primary-channel-plus-degraded-fallback design reintroduces the "open the dashboard to find out" friction this design removes — but it makes the eval + multi-turn test strategy mandatory, not optional: AI quality is a cross-cutting ship-gate workstream (promptfoo per-surface evals + the turnover analog of the leasing email/SMS/voice stress + canary gauntlet suites), and the AI slices (3/4) and the parity surfaces (5) cannot ship until those suites exist and pass. Each risk is called out in the slice that owns it.

Open questions (to resolve in review)

  1. Confirm Temporal-from-Phase-1 vs. staying handler-orchestrated longer. Resolved — orchestration lives in Temporal from Slice 1 (see the phased-rollout principle); no handler-path interim.
  2. Jurisdiction deadline source: maintained table vs. per-property value. Resolved — the deadline is an operator-entered per-property value at onboarding (see Layer 4 "Operator-entered deadline"). PropFlow runs the reminder timer against that value and does not maintain an authoritative per-state legal table; the customer and their attorney own the actual law. Remaining sub-question: the onboarding UI copy that frames it as "your deadline" rather than "the legal deadline."
  3. (largely resolved — see "Move-out close-out") AppFolio requires the manual "Move Out Tenants" workflow (no auto-start on notice); no L4 write exists today. Remaining decision: build the full L4 automation that posts charges + deposit accounting, vs. Slice-4-initial = PropFlow generates the itemized disposition and the PM posts it in AppFolio (lower risk for the money step). Capture the endpoint via the Browserbase Stagehand workflow either way.
  4. Move-in capture mechanism (dependency, not just an open question — see Slice 3/4): a new lightweight onboarding touchpoint that captures the ConditionReport (photos + NL) at lease start, vs. digitizing the existing paper sheet, vs. Clara-assisted intake. Camellia uses a paper two-column Condition Sheet today; Sean wants to retire paper. This is a must-do-first prerequisite for Slice 4 deposit deductions — deductions only apply to tenants onboarded after this capture exists; every current Camellia tenant falls back to manual/paper disposition until then. Open part is the mechanism, not whether it gates Slice 4 (it does).
  5. Confirm the room/item coverage taxonomy + itemization format live as per-property policy context (the projection's output template) rather than a fixed enum — the agent-native stance (see "Condition capture is agent-native"). And pin the projection's determinism contract (model version + seed + prompt version frozen on the approved snapshot) for audit replay. *(⚠ taken up by ADR-0121, which also drops the seed clause: no seed is implemented and none is available on this API, so leaving it here implies a reproducibility route that does not exist.)*