ADR-0034: Turnover orchestration architecture
- Status: Proposed
- Date: 2026-05-25
- Deciders: Fede, Sean, Jose
- Related: ADR-0025 (Temporal for renewals), ADR-0030 (PMS is source of truth for WOs), ADR-0031 (Agent Smith local-first WOs), ADR-0032 (spine-stamp construction invariant), ADR-0033 (VendorCompany + membership),
PMS_INTEGRATION.md,TEMPORAL_INFRASTRUCTURE.md
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 list — turnoverPolicy.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)
- Best-practice turnover is a sequenced pipeline; cleaning is always the last trade (you don't clean before repairs make a mess). Best-in-class turn time is 3–5 days vs ~10-day average; ~74% of delays are coordination, not labor. Cost is $1.5k–$3.5k/unit. → The product's value is compressing coordination latency, which is exactly what an event-driven orchestrator does.
- Security-deposit law is a compliance minefield (state-specific). Itemized written statement + invoices required; statutory return deadlines range 14–60 days (CA 21, TX 30, NY 14, FL 30, IL 45). Missing the deadline can forfeit all deductions; bad-faith retention triggers 2×–3× penalties + attorney fees. Deductions must distinguish normal wear & tear (not deductible) from tenant damage (deductible, limited to remaining useful life). Defensibility in small-claims rests on: move-in vs move-out condition reports, timestamped photos, vendor invoices, written itemization, and proof of delivery — retained ~7 years. → This dictates an immutable evidence store, a durable deadline timer (run against the deadline the operator enters — PropFlow reminds, the customer's counsel owns the law), and a hard human gate before money moves. (The day counts above are cited as research context only; the running system never treats them as authoritative — see Layer 4.)
- Agentic-first means the system runs the turnover from context + policy (auto-scope, auto-sequence, auto-dispatch, proactive timers) and reserves humans for the three irreducible judgment gates: damage assessment, deduction approval, final ready/listing sign-off.
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_moveout → inspection → scoped → work_in_progress (with the sequencing gate) → qc → ready_for_listing, plus the deposit_disposition branch with its dispute_window.
Reused from src/lib/temporal/ (current patterns, post-2026-05-24 consolidation):
- Durable timers (
await condition(predicate, timeout)) for the move-out date, per-vendor SLA windows, the QC follow-up, and the statutory deposit-return deadline. - Idempotent, deduped signals (
eventIdset) forinspectionCompleted,workOrderCompleted,damageAssessed,pmApproved,tenantDisputed. - Fail-closed autonomous-send gate — the
TEMPORAL_ACTIVITIES_EFFECT_MODE/TEMPORAL_LIVE_PROPERTY_ALLOWLIST/RENEWAL_OUTREACH_KILL_SWITCHtrio and the standalone shadow bridge were retired 2026-05-24 and replaced by the two-factor gate insrc/lib/temporal/autonomous-gate.ts. Mirror that exact pattern for turnover: a globalTURNOVER_AUTONOMOUS_SENDINGenv var (armed only by a human-reviewed worker deploy setting the literal tokenarmed; anything else — unset,true, typo, read error — fails closed) AND a per-propertyProperty.autonomousTurnoverEnabledflag (analogous toautonomousRenewalEnabled). A turnover activity fires IFF both are true; both are read per-invocation (env first, then one DDB read). No per-property allowlist and no effect-mode — both retired. This is the safe Camellia canary: ship the worker disarmed, then arm globally + flip the property flag. - PII-at-the-boundary (ADR-0026): activity inputs are IDs; tenant PII resolved inside activity bodies (workflow history is plaintext in Cloud). The single highest-risk new step is the condition-photo LLM projection (Slice 3): it must receive only S3 object keys (pointers), never the photo bytes and never tenant names — the activity fetches the photos and resolves any names from
tenantIdinside the activity body, so neither tenant images nor names ever enter the durable workflow history. - New task queue
propflow-turnover; workflow versioning viapatched().
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:
PMSClient:getDepositBalance?,listTurnovers?(cache with TTL; never the action path).PMSWriter: WO create already exists; addrecordSecurityDepositDisposition?(posts the itemized statement / deductions to the PMS ledger). On AppFolio this maps to its native "Move Out Tenants" workflow (see "Move-out close-out" below); the write does not exist today and is captured in Slice 4.- All source/fallback labels stay PMS-agnostic (
'pms' | 'unavailable'). PMS-specific ids stay inside the adapter.
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:
- Immutable, append-only records. Dispositions and their line items are never edited in place — corrections are new versioned events (single-table DDB,
version+ conditional writes, mirroring the renewal saga's optimistic-lock discipline). The full who/what/when chain is written to the existingEntityActivityEventappend-only audit log (entityType: 'turnover'). - Evidence store. Move-in vs move-out timestamped condition photos + vendor invoice PDFs live in S3, reusing the
WorkOrderPhotos3Key/sync-state code pattern — but in a new, dedicated evidence bucket, not the existing work-order-photo bucket. The reason is operational, not stylistic: S3 Object Lock can only be enabled at bucket creation and cannot be retrofitted onto the existing photo bucket, so evidence requires a fresh bucket with Object Lock + versioning enabled from creation (~7-year retention so the chain of custody survives litigation), plus a lifecycle policy transitioning aged objects to cheaper storage tiers over that retention window. The move-inConditionReportcapture (and its PM-approved frozen projection) is the single most decisive piece of evidence in a deposit dispute — the outgoing tenant's condition at the start of the tenancy — so it is captured at onboarding (outside the turnover lifecycle) and projected against at move-out to justify each deduction (see Slice 3 + "Condition capture is agent-native"). - Operator-entered deadline, not baked-in legal knowledge. The statutory deposit-return deadline is a value the operator enters at onboarding (per property/jurisdiction); the Temporal workflow runs a durable timer against that date and escalates to the PM well before it lapses (avoids the forfeiture/penalty failure mode). PropFlow runs the reminder; the customer and their attorney own the actual law. The system deliberately does not claim authoritative per-state day counts — getting a deposit deadline wrong is exactly the 2×–3× penalty failure mode this layer exists to avoid, and we will not put PropFlow in the middle of the customer's legal exposure with a hardcoded table. (The research summary above cites statutory ranges as research context only — the running system never treats them as ground truth.) This is a Layer 4 decision because it changes the UI: onboarding asks the operator for the deadline rather than inferring it, and the reminder copy frames it as "your deadline," not "the legal deadline." Aligns with open question #2 (deadline source).
- Hard human gate. Per the platform rule that ungating autonomous spend/send is high-risk: damage classification is LLM-assisted but PM-confirmed, and no deduction is posted or statement sent without explicit PM approval. The agent assembles the evidence-complete itemized statement; the human signs.
- Wear-and-tear logic (deductible vs not, remaining-useful-life proration) lives in per-property policy + the assessment step — never hardcoded.
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:
- Notice is inbound and stays PMS-authoritative; the financial close-out is a separate, later, manual step. The official notice to vacate is always submitted by the tenant on the PMS portal (AppFolio resident portal) — PropFlow detects it (rent-roll
tenant_ticklerpoll →pms.lease.ntv_filed) and never writes the official notice itself. The PMS remains the system of record for the legal notice. PropFlow's role at this stage is upstream: Clara may detect intent to vacate earlier via text/voice and coordinate the comms (answer questions, set expectations) while directing the tenant to submit officially on the portal — it does not record the notice on the tenant's behalf. AppFolio is explicit that "the move out flow does not automatically begin when a tenant gives notice — you must … select the Move Out Tenant task," so PropFlow must not initiate a move-out at notice time either; it opens a turnover and waits. The poller already carries amoveOutOfficialflag (true only once the PM has run AppFolio's "Move Out Tenants"), giving a clean intent / notice-given / move-out-processed signal ladder. - The PMS move-out workflow IS our disposition. AppFolio's "Move Out Tenants" task takes: the four dates (Notice, Move Out, Unit Availability, Unit-ready-for-showing), a move-out reason, per-tenant forwarding addresses, itemized charges & credits (= our
DeductionLineItems, from turnover WO actual costs), then posts the security-deposit accounting + disposition letter (SODA). Critically AppFolio lets you save the dates early and add charges later as costs confirm, then post — which is exactly capture → approve → freeze. Unit Availability / ready-for-showing dates are knowable at scope time (moveOutDate + turnoverPolicy.unitAvailabilityDays), so an optional early write can pre-fill them for marketing; the charges + deposit posting run after inspection + repairs, behind the PM-approval gate (it moves money).
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 | onNoticeLease → cancelLease |
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:
- Terminology differs, semantics don't. SODA / Final Account Statement / Deposit Disposition Form / SDR all mean itemized deductions + refund within a statutory deadline. The agnostic capability carries
{dates, reason, itemized deductions, refund split}; each adapter maps to its native artifact. This validates putting it onPMSWriter, not in domain code. - Notice is always inbound; the money close-out is always a separate, later, human-confirmed step — across all five. This validates Layer 1's "detect the notice, don't initiate it" and Layer 4's PM-gated, post-inspection posting.
- The adapter abstracts transport and workflow, not just data. AppFolio has no public write API (browser automation / L4 is the only path); Buildium (public REST + webhooks), Yardi (SIPP), Entrata, RealPage expose real APIs. Domain code calls
recordSecurityDepositDispositionand never learns whether the adapter drove a browser or a REST call — reinforcing ADR-0023/0030's adapter-registry rule. - Inspection ownership varies → declare it as a capability. Buildium has native move-out inspections, so our
ConditionReportcan sync with theirs; AppFolio has none, so PropFlow is the inspection system-of-record there. The adapter advertises this via aPMSCapabilityflag rather than the domain assuming one model.
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 → judge → WorkOrder). It does not model Camellia's paper Condition Sheet as a rigid table the PM tabs through. Three layers:
- Capture — flexible, immutable source of truth. Photos + natural-language observations + metadata, lease-anchored. Reality that doesn't fit a form (a crack behind the fridge, a non-standard room) is never discarded to fit rows.
⚠ 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, withattribution/chargeableflips 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.
- Project — structured, on demand. An LLM produces a schema-constrained, deterministic (temp 0, pinned model version) projection: room/item condition, move-in↔move-out comparison, candidate damage. The room/item taxonomy and itemization format come from property policy as the output template — not stored columns; flexible/overflow fields preserve exceptions.
- Freeze — at the money/legal boundary only. The projection is a draft until a PM approves it; approval freezes an immutable, versioned snapshot (model version + provenance back to the source photos). That frozen, approved, source-traceable artifact is the legally defensible record — never a live re-extraction (which would replay differently and is worthless in a dispute). This freeze is the existing "hard human gate before money moves."
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.
- Channels — all first-class. A voice call to Clara, SMS, async voice-notes + photos, plus the web UI. Worked example: a PM sends a voice note — "204, bedroom carpet's trashed, paint's fine, needs a deep clean" — with a few photos. Clara transcribes the voice and runs vision over the photos, then projects to a structured turnover (scoped tasks, condition findings, candidate damage) through the same capture→project→freeze spine the ADR already specs for condition reports. The web UI is an equal path for those same actions — opening/scoping a turnover, checking status, approving deductions — and additionally hosts the review/override surface where the PM inspects and edits the projection before the freeze.
- Reuse the interface stack, don't rebuild it. This applies the ADR's "reuse renewals/maintenance patterns" rule to the interface layer, not just the orchestration and WO layers:
- Conversational voice rides the existing ElevenLabs stack —
src/lib/integrations/voice/elevenlabs-client.tsplus the per-mode agent configs underagents/clara/lib/voice-agents/(e.g.leasing.config.json,maintenance.config.json); turnover adds a turnover/intake agent config rather than a new voice transport. - SMS/email multi-turn rides the same Clara conversation engine leasing and maintenance already use — the multi-turn loop in
agents/clara/lib/agent/conversation-manager.ts(context-window management, incremental persistence, tool-call iteration cap) over the channel-agnosticagents/clara/lib/messaging/dispatcher, with persona fromagents/clara/lib/agent/clara-unified.tsand channel delivery rules fromclara-delivery.ts/clara-sms.ts. Turnover intake is a new mode, not a new pipeline. - Photos → vision reuse the inbound-photo path that already pins images by S3 key —
uploadInboundPhotoToAnthropic/getCachedAnthropicFileIdinsrc/lib/integrations/anthropic/files.ts— so the projection activity receives file pointers, not bytes (see PII boundary below).
- Conversational voice rides the existing ElevenLabs stack —
- Loop closure through the channel the PM used. Clara drafts the work list, auto-dispatches under the $500 cost cap (the policy cap already in this ADR), and pushes back through the same channel only what needs PM judgment — damage classification, deduction approval, over-cap costs. The PM is not expected to remember to open a dashboard; the request comes back as the next message in the same SMS/voice thread (or as a web notification if that's where they are). This is the conversational analog of Layer 4's hard human gate — the gate is delivered, not parked.
- PII boundary (ADR-0026) holds across every channel. Voice transcripts, photo bytes, and tenant/PM names are handled inside activities; the durable workflow history carries IDs and S3/file keys only — consistent with the existing PII-at-projection guardrail (Layer 1) and the condition-photo rule that the projection activity sees object keys, never images or names. A new conversational channel must not become a new way for PII to leak into workflow history.
- The cost of parity (stated honestly). Full parity is not free: every turnover action must work — and be tested — in both the conversational channels and the web UI. That roughly doubles the interaction surface and the test matrix. We accept that cost because the alternative (a primary channel with a degraded fallback) reintroduces exactly the "open the dashboard to find out" friction this design exists to remove. This doubled surface is the explicit driver for the eval + multi-turn test strategy in the next section — parity is only safe if both channels are continuously verified.
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.
- promptfoo eval datasets per turnover AI surface. Extend the existing pattern — the
evals/promptfooconfig-*.yamlconfigs (e.g.promptfooconfig-maintenance.yaml,promptfooconfig-leasing.yaml,promptfooconfig-voice-closing.yaml) backed by datasets underevals/datasets/*.yaml. Turnover adds one suite per money-or-accuracy-critical surface:- Condition projection — photos + NL notes → structured room/item condition (the
promptfooconfig-turnover-damage.yamlfile already named in Slices 3/4). - Wear-vs-tenant-damage classification — the money-critical call that sizes each deduction; a wrong-but-plausible answer would sail past a PM's approval click.
- Scope extraction from a voice note — the Section A worked example: a transcribed voice note → the correct scoped task list + candidate damage.
- Locked config for reproducibility: temperature 0, pinned model version, pinned prompt version — the same determinism contract the condition projection already commits to in "capture → project → freeze." (⚠ superseded by ADR-0121 — under default sampling an eval assertion is a claim about a distribution, not a single draw.)
- Condition projection — photos + NL notes → structured room/item condition (the
- Multi-turn conversation evals for the Clara turnover-intake flow. A static dataset can't catch a PM and Clara drifting across a back-and-forth (a voice note, then a clarifying SMS, then a photo, then an approval). Turnover gets the turnover analog of the leasing multi-turn suites: the email/SMS/voice stress harnesses (
scripts/email-stress/,scripts/sms-stress/,scripts/voice-stress/) and the canary gauntlet (scripts/canary/+evals/gauntlet/) that already exercise leasing/maintenance conversations end-to-end. A new turnover-intake scenario set runs the same back-and-forth over SMS and voice across a turnover (scope → revise → approve), asserting the projection and the dispatch decisions stay correct as the conversation evolves. - Tied to existing observability. These suites trace through the in-stack Langfuse wiring (
src/lib/platform/observability/langfuse.ts,tracedClaudeCallintrace-llm.ts) — zero overhead when unconfigured — so eval runs and production turnover conversations are inspectable on the same traces. - Ship-gate (hard acceptance criterion). The AI slices (3/4) and the Clara-native intake surfaces (Slice 5) cannot ship until their promptfoo eval suites and multi-turn conversation suites exist and pass. No suite, no ship — same bar leasing/touring already meet. This is restated in the rollout and Consequences below.
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 0 — notice_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_moveout↔notice_received; scoped/work_in_progress/finishing/qc↔work_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
- Event-driven, zero polling crons — coordination is timers + signals inside the workflow (the renewal lesson). One workflow per active turnover; Temporal workers scale horizontally.
- Per-property DDB partitions + atomic local-id minting (proven ceiling ~1000/s) — no hot-row contention at portfolio scale.
- Adapter-only PMS access — onboarding Yardi/RealPage means implementing the adapter, not touching turnover domain code.
- Idempotent activities + optimistic locking — safe under retries/replay and concurrent PM/poller writes.
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
checkTurnoverProgressorchestration 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.)
- Slice 1 — Turnover workflow backbone (real Temporal infra). Stand up
turnover-workflow+ worker on a newpropflow-turnovertask queue, behind the fail-closed two-factor gate (TURNOVER_AUTONOMOUS_SENDING+Property.autonomousTurnoverEnabled), disarmed by default. Onpms.lease.ntv_filedthe workflow starts idempotently, records the turnover, schedules the inspection (activity), and holds on a durable move-out-date timer. Phasesawaiting_moveout → inspection. This is the backbone every later slice extends. - Slice 2 — Auto-scope + cleaning-last (activities + signals). On the
inspectionCompletedsignal, an activity auto-scopes the policy default trades (WOs via the local-first handler, vendor precedence), gating the finishing trade. The workflow awaitsworkOrderCompletedsignals, releases cleaning when prep completes (durable, signal-driven — not a fire-and-forget hook), then advances toready_for_listing. - Slice 3 — Condition report (capture → project → freeze). Lease-anchored
ConditionReport(move_in at onboarding + move_out at inspection, immutable, in the dedicated S3 Object-Lock evidence bucket); schema-constrained LLM projection (pinned model); PM-approval freeze; move-out projects against the move-in baseline. Agent-native, not a digitized form — and voice/photo-native from day 1: capture is a voice note + photos through the reused ElevenLabs / Clara conversation engine and the pointer-keyed vision path (Section A "Clara-native intake"), not a web form built first and conversational capture bolted on later. Deletes the old freeformTurnoverInspectiontype (one way to do things). Hard ship-gate (acceptance criterion, not a nice-to-have): because the projection drives money decisions and a wrong-but-plausible answer would sail past a PM clicking approve, this slice cannot ship until a promptfoo eval fileevals/promptfooconfig-turnover-damage.yamlexists and passes — with example move-in↔move-out cases (including a voice-note → scope-extraction case), AI settings locked (temperature 0, pinned model version, pinned prompt version) so the eval is reproducible. No eval, no ship. - Slice 4 — Deposit disposition + PMS move-out close-out (money/legal).
TurnoverDispositionfrom approved-projection damage + WO actual costs; itemized deductions; operator-entered deposit deadline (durable timer); hard PM-approval gate before money moves. First cut: PropFlow generates the itemized SODA and the PM posts it in the PMS (AppFolio has no write API); per-PMS write automation (AppFolio "Move Out Tenants" via L4; Buildium/Yardi via API) follows. Hard ship-gate (acceptance criterion): the wear-vs-tenant-damage classification that sizes each deduction must be covered by theevals/promptfooconfig-turnover-damage.yamlsuite (extended from Slice 3) with locked AI settings (temp 0, pinned model + prompt version); the slice cannot ship until those classification cases pass. Hard dependency on Slice 3 — and a scoping rule that bounds who deductions apply to: the move-in condition report is the most decisive deposit-dispute evidence, so PropFlow cannot defend a deduction against any tenant who lacks a move-in capture — which today is every current Camellia tenant. Therefore Slice 4 deposit deductions apply only to tenants onboarded after the Slice 3 move-in capture exists; pre-existing tenants fall back to paper / manual disposition outside the automated money path. This makes the Slice 4 → Slice 3 ordering non-negotiable. - Slice 5 — Full Clara-native parity + agentic surfaces & reporting. Bring the conversational channels to full parity with the web UI across the whole turnover lifecycle (scoping, status, approvals) per Section A — voice, SMS, async voice-notes + photos, all first-class, with PM-judgment gates pushed back through the same channel the PM used. Plus Clara intent-to-vacate detection + portal nudge (official notice stays on the PMS portal); dashboard KPIs (flagged / scheduled / in-progress / completed / avg turn time); vendor scorecards. Hard ship-gate (acceptance criterion): the parity surfaces cannot ship until their multi-turn conversation suites exist and pass — the turnover analog of the leasing email/SMS/voice stress + canary gauntlet suites (
scripts/email-stress,scripts/sms-stress,scripts/voice-stress,scripts/canary), asserting every turnover action works and stays correct across a real back-and-forth in both channels. No suite, no ship.
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
- A. Build everything on crons + the existing saga style (no Temporal). Rejected — re-creates the exact failure mode ADR-0025 fixed (silent reconciler failure, no durable timers); the platform guidance explicitly forbids new watchdog crons.
- B. Build everything on Temporal from day one, including WO mechanics. Rejected — duplicates the maintenance WO layer Jose just built (ADR-0031). Compose, don't fork: Temporal orchestrates, Agent Smith executes WOs.
- C. Treat turnover as "just a bag of work orders" (no orchestration aggregate). Rejected — loses sequencing, the deposit branch, and end-to-end status; can't compress coordination latency, which is the product's value.
- D. Defer the deposit/compliance design entirely. Rejected for the architecture — money + legal liability must be designed for now (immutable store, deadline timer, human gate) even though it's built in Slice 4, or we'd retrofit liability-bearing records into a model that can't hold them.
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 risk — evals/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)
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.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."- (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.
- 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). - 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.)*