0044 — End-to-end turnover close-out: auto-dispatch, security-deposit disposition, AppFolio move-out, and re-listing

Scope note / no-code marker. This ADR is the high-level end-to-end architecture only. No implementation lands with it. It exists to be iterated on before any code is written, then executed in the small phases in §Roadmap.


Revision — 2026-06-08 (simplification + live charge KB + agentic confirm)

The original (2026-06-02) decision below is sound on the port boundary and the AppFolio-renders-the-SODA stance, but over-engineered on the disposition machinery — it sizes a multi-state SaaS future before the first real disposition exists. A prod inspection (2026-06-08) plus a live pull of Camellia's actual move-out accounting changes three things. Where this section conflicts with D0/D4 below, this section wins; the superseded parts are marked inline.

R1 — Prod reality: the loop never closes today (the bug this whole ADR is downstream of)

Verified 2026-06-08 against prod: all ~25 turnovers sit at notice_received with 0 work orders — none ever advanced. Root causes:

These are the A1 (unblock loop) / A2 (unify status) Trello cards. The fix is R3's conversational confirm, not a new website gate.

R2 — Live Camellia charge KB: what accounting actually does (replaces invented depreciation/jurisdiction)

Live AppFolio pull (Camellia = AF propertyId 7; charge_detail + tenant_directory): 28,719 charges across 508 move-outs. The empirical categorization is far simpler than D4's DeductionLineItem model assumes:

Real deduction category (AppFolio GL) Freq @ move-out Typical $ (median / p25–p75) Accounting's own phrasings
Maintenance – Tenant Reimbursement 120× $120 / $75–150 "Cleaning", "Cleaning Fee", "Paint", "Painting", item damage free-text ("Bedroom Door Damaged")
Maintenance – Non CAM Expense 81× $150 / $75–150 "Cleaning Fee", "Apartment Cleaning", "Paint", "Carpet", "Damaged Tub/ Blue Stains"
Service Charge/Late Fee 54× flat $50 "Late Fee" (carried-in unpaid)
(rare) Carpet Cleaning / Building-General / Misc forfeiture <5× ea. "Carpet", "Pet Cleaning Fee", "Forfeited deposit"

NOT deductions — AppFolio's move-out flow auto-handles them; Clara must never propose them as charges: prorated final Rent / Parking / Utilities / Pet Rent, and the deposit mechanics (Clearing Account "Transfer of Security Deposits at Move Out" 412×, negative Security Deposit entries 476×).

Base rates / patterns that make Clara precise:

Decision: the deduction model is two GL buckets + a flat late fee, amounts seeded from WO actual cost → else these medians, written in accounting's exact phrasings. This supersedes D4's usefulLifeYears/ageYears/depreciationApplied/jurisdiction/legalDeadlineAt fields and the Phase-2.5 depreciation work — none of it appears in Camellia's real data. The charge KB (categories, medians, phrasings, base rates) is the precision layer the charge-proposal step reads. (AppFolio is Camellia's only PMS, so its data is sourced from AppFolio — this does not change the rule to keep the PMS-agnostic port; see [feedback] never-appfolio-only.)

Superseded by §Revision R9 (2026-06-08): the KB does not live in a turnover/charge-kb.ts config module sourced AppFolio-only. Storage moves to PropertyKnowledge.turnoverPolicy.chargeKnowledge (per-property, DynamoDB), and sourcing is PMS-agnostic via the PMSClient read port (listHistoricalMoveOutCharges), not the AppFolio client. R9 is authoritative for KB storage + sourcing.

R3 — The agentic spine: Clara confirms after the walk (replaces the freeze/approve UI gate)

The PM's real process today is: inspect manually → decide charges on a form → call vendors → start the AppFolio move-out → accounting finishes. The target mirrors it 1:1, with Clara doing the legwork and the PM's "yes" in-channel as the only approval:

flowchart TD
    A["PM walks the unit
(voice / glasses / text)"] --> A1["Clara captures notes + photos,
classifies wear-vs-damage [BUILT]"] A1 --> B["Clara recaps in-channel:
proposed CHARGES + WORK ORDERS + VENDORS"] B --> B1["'Found A, B, C. Charge $150 cleaning + $120 paint
to the deposit? Create WOs for paint + tub,
dispatch the tub-refinisher & painter? Reply yes / edit.'"] B1 --> C["PM confirms (one reply)"] C --> C1["Clara scopes WOs, dispatches vendors,
ADVANCES STAGE"] C1 --> D["vendors / handyman do the work"] D --> D1["WOs carry real scope + actual costs"] D1 --> E["Clara drafts the AppFolio Move-Out (L4),
stops at 'Save for later' (draft)"] E --> E1["dates + 2-bucket charges + deposit split/method
pre-filled, availability date set"] E1 --> F["accounting Posts + Finishes the move-out"] F --> F1["SODA rendered by AppFolio [keep human]"] F1 --> G["unit re-listed 'coming soon'"] G --> G1["setUnitAvailability → AppFolio syndicates"]

This replaces D4's separate disposition-approval UI gate and the versioned TurnoverDisposition canonical entity. Instead of minting a compliance aggregate (spine-stamped personId, Object-Lock S3, draft→pm_approved→posted_to_pms→refund_issued lifecycle) before a single real disposition exists, we attach lightweight proposed charges to the turnover, the PM confirms conversationally, and AppFolio holds the money + renders the SODA. The conversational confirm is the hard money gate (R5). Reuse the turnover voice/SMS tools + scopeTurnoverWorkOrders. (If we later need an auditable disposition record across many properties, re-introduce TurnoverDisposition then — not now.)

R4 — Keep the one workflow, not three durable tracks

Supersedes D0. Camellia is one property, in-house crew, a handful of turns/yr — three concurrent durable Temporal tracks + a new closed stage is more machinery than the volume justifies. Reuse the single existing turnover workflow; the deposit deadline is a reminder timer, and listing is a setUnitAvailability write. Status stays coarse; we still need exactly the status-model unification from A2 (one PM-facing lifecycle that mirrors the flow above), but not three parallel clocks.

R5 — What is KEPT from the 2026-06-02 design (do not delete)

R6 — How this feeds the build (decisions → harness → fleet)

This revised ADR is the locked decision layer for the close-out work. Next: build a deterministic E2E test harness encoding the R3 flow as the green-bar target (walk → confirm → WOs+dispatch → draft move-out → unit available), then run a dynamic multi-agent workflow that implements → reviews → critiques → tests each slice against that harness until green. The simplifications above (no versioned disposition entity, no depreciation/jurisdiction, one workflow) exist specifically to keep the harness small and the fleet's target unambiguous.

Net effect on the Roadmap below: Phase 0's TurnoverDisposition entity + closed stage are dropped; Phase 2 becomes "proposed charges + conversational confirm" (no SODA-preview UI); Phase 2.5 (depreciation) is cut; Phases 3 (listing) and 4 (move-out L4 draft) stand, scoped to the two endpoints in R5. The cross-PMS port research (Buildium/Entrata/Yardi) below remains valid reference for when a 2nd PMS lands — it is not v1 work.

R7 — One PM-facing status model (replaces the two diverging ones)

Today the list speaks the stage machine (notice_received / work_scheduled / ready_for_listing / cancelled) and the detail speaks a derived capture-state stepper (live / reviewing / captured(="Drafted") / approved / sent), with no bridge, an unreachable "Sent" step, and a "Drafted" with no approve button (R1). Replace both with one plain-English lifecycle that mirrors the real flow (AppFolio's own Unit Turn board uses just two states — In Progress / Completed — so four is already generous):

Status Meaning Absorbs (today)
Notice received NTV in, unit not yet walked notice_received
Inspected walked; Clara has proposed charges + WOs + vendors; awaiting the PM's "yes" work-pre-scope + the entire capture stepper
Turning PM confirmed; WOs dispatched, make-ready underway work_scheduled
Ready to lease work done, move-out drafted, unit available ready_for_listing
Cancelled abandoned (terminal) cancelled

R8 — External-vendor dispatch is flag-gated (held now, one flip later)

External/paid vendors need weeks of shadow-testing before they send autonomously; in-house dispatch is already trusted. So on the PM's "yes":

R9 — Per-property charge KB: PMS-agnostic sourcing + a reviewer agent + onboarding capture

The R2 charge KB must be per-property and PMS-neutral, not Camellia/AppFolio hardcoded.

R10 — Notify accounting when the move-out draft is ready

PropFlow drafts the AppFolio move-out up to "Save for later"; accounting still Posts + Finishes (R5). Today that handoff is implicit — accounting discovers the draft. Make it explicit: when recordMoveOut completes the draft, fire a notification to accounting that a move-out is drafted and ready to Post + Finish, with the unit + the proposed charges/refund summary. This is a notification, not an autonomous accounting write — the money still moves only by accounting's hand. Channel reuses the existing office/accounting notification path (mirror the renewal "accounting execution notice").


Context

ADR-0034 stood up the turnover orchestration spine. Today the loop runs:

flowchart TD
    A["NTV detected
(AppFolio poll → pms.lease.ntv_filed)"] --> B["createTurnover
(stage: notice_received, computes targetReadyDate)"] B --> C["durable move-out timer"] C --> D["move-out inspection captured
→ immutable move_out ConditionReport"] D --> E["LLM condition projection
(wear-vs-damage classification)"] E --> F["PM freeze (hard human gate)"] F --> G["auto-scope: mint child WOs from turnoverPolicy.defaultTrades
(cleaning-last DAG, cost-cap holds)
→ stage: work_scheduled"] G --> H["WO completions → reconcile"] H --> I["stage: ready_for_listing
← TERMINAL TODAY"]

ready_for_listing is a terminal stage (src/lib/data/statuses/turnovers.ts:19-24). When it's reached, complete/route.ts writes the stage and a timeline note and stops. Nothing posts a listing, nothing reconciles the security deposit, nothing writes the move-out to AppFolio. That is the end of the current loop.

To make turnovers genuinely end-to-end we need five capabilities. The reuse audit (done for this ADR) shows two already exist and three are net-new:

# Capability Status today Where
A Turnover as a parent with a tree of child WOs Exists Turnover.childWorkOrderIds, WorkOrder.turnoverId, TurnoverTask, single mint path scopeTurnoverWorkOrders (scope-turnover-work-orders.ts:99-219), cleaning-last DAG
B Auto-dispatch to in-house handymen and external vendors ~90% exists autoAssignVendor (in-house-first, handle-create-work-order.ts), decideScopeWorkOrder (scope-work-order-decision.ts:64-114), gather→judge→dispatch→persist pipeline, tech-sms-dispatch.ts. External-vendor outbound send not turnover-verified; in-house/external gate is a TODO in tech-sms-dispatch.ts
C AppFolio move-out-tenant integration Missing PMSWriter has only 4 renewal ops (src/lib/domain/pms/writer.ts:133-140); no move-out write path, only NTV/move-out reads
D Security-deposit reconciliation (refund all / some / none) Missing No TurnoverDisposition / DeductionLineItem / SODA anywhere in src/. Only the substrate exists: damage classification + frozen projection + Object-Lock evidence bucket
E Post unit to Zillow as "coming soon" Missing listings-sync.ts is a read-only scraper; Unit.availableForLeasing exists; no syndication write

The new requirement beyond ADR-0034 is D — security-deposit reconciliation — which is also the highest-liability piece (money + jurisdiction-specific law). It is treated as a first-class concern here.

Guiding constraints for this design (set by Fede):

  1. Reuse-first. Lean on the existing turnover spine, the maintenance dispatch pipeline, the AppFolio L4 writer pattern, the frozen-projection damage classification, the evidence bucket, and the Temporal fail-closed gate. Build net-new only where the audit shows a genuine gap.
  2. Keep it simple to start. Flat WO model (no nested DAG beyond cleaning-last), AppFolio as the listing/accounting channel (no direct Zillow client), PM-in-the-loop on every money/legal action.
  3. This is a multi-week feature delivered in small phases, each shippable behind the disarmed gate.

Decision

D0 — Three independent close-out clocks, modeled as concurrent durable tracks (not a chain)

⚠ Superseded by §Revision R4 (2026-06-08): reuse the single existing turnover workflow; deposit deadline = a reminder timer, listing = a setUnitAvailability write. The three-durable-tracks + new closed stage model below is more machinery than Camellia's volume justifies. Kept for context.

The three net-new actions have different triggers and different deadlines, so we do not chain them:

Track Clock starts at Deadline / timing Gated on
Listing publish ("coming soon") as soon as targetReadyDate is known (at scope time) publish so it goes live targetReadyDate − syndication lag (~24–48h) nothing — a lightweight, ungated availability-date write (D5), distinct from the full gated Move-Out workflow
Deposit disposition move-out (surrender) the property's configured return deadline (depositReturnDeadlineDays) PM approval (hard gate)
AppFolio move-out write after disposition is PM-approved as soon as approved disposition approval

Modeling these as a linear sequence would be wrong: a unit can be listed "coming soon" while make-ready is still in progress, and the deposit clock can expire before or after the unit is re-listed. We therefore run them as concurrent phases inside the existing Temporal turnover workflow (turnover-workflow.ts), each with its own durable timer, consistent with ADR-0025/0034. The persisted stage machine stays coarse (ADR-0034 philosophy): we add exactly one new terminal stage, closed, reached when listing is published and move-out is posted and the refund is issued (or the deadline lapses with disposition posted). Rich sub-state lives on the TurnoverDisposition entity and the workflow phases, not on new top-level stages.

D1 — Capability A (child-WO tree): keep the flat model, reuse as-is

Keep the existing one-level parent→children model (Turnover.childWorkOrderIds[] + WorkOrder.turnoverId). We will not build a nested WO DAG. The only real dependency in a make-ready is the trade ordering (repairs → paint → flooring → cleaning last), which the existing cleaning-last sequencing + TurnoverTask.finishing flag already captures, and which the best-practices research confirms is the only hard chain. The one refinement worth noting (deferred): model paint dry-time (~24–48h) as a finishing hold so flooring/cleaning don't schedule into wet paint. Not required for v1.

D2 — Capability B (auto-dispatch): finish the external-vendor path, reuse everything else

Dispatch routing reuses decideScopeWorkOrder + autoAssignVendor:

Net-new is small: wire the external-vendor outbound send through the same maintenance gather→judge→dispatch pipeline (turnover-verified), and finish the in-house-vs-external branch flagged in tech-sms-dispatch.ts. Decision rule (from research): license/permit-required and specialty trades default to external vendors; routine/high-volume make-ready defaults to in-house. Routing inputs the dispatch agent must carry: per-trade vendor roster (turnoverPolicy.defaultVendors), isInHouse membership (ADR-0033), cost cap, and a licenseRequired flag per trade (new, on policy).

D3 — Capability C (move-out): a generic PMSWriter op; AppFolio is one adapter

The interface is PMS-agnostic — the AppFolio workflow below is adapter-internal. Move-out + disposition are added as generic operations on the existing PMSWriter port, which is already built for exactly this: "one implementation per supported PMS … AppFolio, Yardi, OneSite, RealPage, Entrata; PropFlow domain code never imports a specific PMS implementation" (src/lib/domain/pms/writer.ts). The port carries only renewal ops today; we add recordMoveOut(...) and recordSecurityDepositDisposition(...) in PMS-neutral terms. No PMS-specific field, screen, or step appears in the interface — those live only in the adapter. The generic field set is the cross-PMS superset (Yardi/RealPage/Entrata research in flight; the "Generic interface" section will be filled from it).

AppFolio adapter (one of several): AppFolio has no public write API (ADR-0034 cross-PMS table), so its adapter drives the native UI "Move Out Tenants" workflow via L4 / Browserbase (Stagehand), mirroring createWorkOrderL4 (l4-adapter.ts); a Yardi adapter would instead call Yardi's API (TBD by research). We drive the native workflow rather than hand-posting GL entries — the PMS stays the accounting source of truth (ADR-0039). The AppFolio steps the adapter automates:

We drive AppFolio's native "Move Out Tenants" workflow rather than hand-posting GL entries — AppFolio stays the accounting source of truth (ADR-0039). The workflow steps PropFlow automates (from AppFolio KB Move Out Tenants + Guide to Security Deposit Accounting):

  1. Dates & reason — Notice Date, Move Out Date, Unit Availability Date, Unit-ready-for-showing date, Move Out Reason. (Unit Availability Date is saved to the unit's Marketing Information — this is also our listing hook, see D5.)
  2. Move-out charges & credits — the PropFlow-computed deduction line items (D4).
  3. Security deposit split / refund + transfer method — the method is derived from the tenant's move-out submission (see O4), not free-chosen: if the tenant provided online-transfer/banking details at move-out notice → online transfer; otherwise → printed check. Check → unpaid bill + undeposited receipt; online → incomplete bank transfer.
  4. Generate the Disposition Letter — AppFolio's built-in SODA. We let AppFolio render the statutory itemized statement rather than minting our own.

PropFlow computes and proposes; AppFolio records and renders. This keeps PropFlow out of GL/trust-accounting (the most error-prone, jurisdiction-specific surface) and reuses AppFolio's compliance tooling.

D4 — Capability D (deposit reconciliation): a canonical TurnoverDisposition, PM-approved, never autonomous

⚠ Superseded by §Revision R2+R3 (2026-06-08): the versioned TurnoverDisposition canonical entity, usefulLifeYears/depreciation, and jurisdiction/legalDeadlineAt are dropped for v1 — Camellia's real charge data (R2) shows two GL buckets + a flat late fee, never depreciation or jurisdiction logic. Use lightweight proposed charges on the turnover, confirmed conversationally (R3); AppFolio holds the money + renders the SODA. Re-introduce a disposition record only if multi-property auditability is later needed. Kept for context.

This is the heart of the new work. The reconciliation is computed from substrate that already exists:

Hard PM-approval gate, always. A deposit disposition is money + legal liability; it is never issued autonomously. We reuse the freeze pattern (freezeConditionProjection is the model) for a parallel disposition-approval gate, and it sits behind the existing two-factor fail-closed gate (D6). Every line item carries provenance (which WO / invoice / estimate it traces to) — no invented numbers (CLAUDE.md). The PM sees a SODA preview, can edit line items, and must approve before anything posts to AppFolio.

Start simple: one deadline value per property, the PM owns legal correctness. We deliberately do not build a multi-state jurisdiction policy table in v1 — that's premature for a single-property start. The deposit-return deadline is a single configurable field on the property (turnoverPolicy.depositReturnDeadlineDays), set to that property's actual state deadline. A durable reminder timer (reuse the renewal/tour timer pattern) tracks that one date and nudges the PM before it lapses. Everything else legal — clock-start nuance, receipt thresholds, right-to-cure, useful-life schedules — is out of v1 scope; the mandatory PM-approval gate is where legal correctness lives (the PM knows their local rules and edits/approves the draft). A real per-jurisdiction policy table becomes a follow-up only if/when we actually manage deposits across multiple states. Disposition state + audit append to EntityActivityEvent; evidence (move-in vs move-out photos) reuses the Object-Lock evidence bucket.

D5 — Capability E (Zillow "coming soon"): via AppFolio's availability date, no direct Zillow client

We do not integrate with Zillow directly. AppFolio already syndicates listings to Zillow (and the Zillow Rentals Network), and our listings-sync cron already reads availableForLeasing back. So "coming soon" is achieved by setting the Unit Availability Date in AppFolio's Marketing Information to the projected ready date. AppFolio then lists it as "Available [date]," which is exactly the "coming soon" semantics on Zillow's rentals feed. listings-sync confirms it went live.

Two-pass write (resolves the D0 ungated-listing vs. D3 gated-move-out tension, and O5): the availability date is written in two independent passes, never one gated chain:

  1. Listing pass (Phase 3, ungated, reversible): the generic setUnitAvailability(unitRef, 'coming_soon', availableDate) op, fired at targetReadyDate − syndication lag (~24–48h). The AppFolio adapter writes the Marketing-Information availability date; a Yardi adapter would set the unit attribute / ILS feed. This is the "coming soon" trigger; it has no money/legal effect, so it is not gated.
  2. Close-out pass (Phase 4, gated on disposition approval): the full native Move-Out workflow (D3) runs later and re-sets the availability date as a side-effect of recording dates/charges/deposit. The two passes are idempotent on the date field; the listing pass never depends on deposit approval.

This is the biggest simplification in the design: no Zillow API client to build or maintain, consistent with ADR-0039 (AppFolio source of truth) and ADR-0030. O3 answered: the Test Property's AppFolio does not syndicate to Zillow, so Phase 3 is verified by asserting the availability-date write to AppFolio and simulating syndication; real Zillow confirmation waits for a syndicating property.

Move-out input coverage — we already capture nearly everything

A gap-check against current code (verified file:line) shows the move-out write needs almost no new data capture — the NTV email parser and PMS sync already surface it. (These are AppFolio-adapter sources today; the adapter's job is to populate the generic interface fields from them.)

Generic move-out input Captured today? Source (verified)
Notice / move-out dates NTV detection → Turnover.moveOutDate
Move-out reason parse-appfolio-ntv.ts:47, appfolio/types.ts:116
Forwarding address parse-appfolio-ntv.ts:48 (forwardingAddress)
Refund-delivery election (eCheck vs check) ✅ — satisfies the O4 condition parse-appfolio-ntv.ts:49 (securityDepositEcheck) — semantics to confirm
Deposit held amount securityDeposit on tenant/occupancy, synced (sync-tenant-from-pms.ts, types.ts:234/1000/5817)
Unit / lease / occupancy Turnover.unitId/unitNumber, occupancyId
Unit Availability Date ✅ computed targetReadyDate from turnoverPolicy.unitAvailabilityDays
Ready-for-showing date ✅ field exists l4-core.ts:726 (readyForShowingOn)
Deduction line items net-new the TurnoverDisposition (Phase 2) — from the frozen damage classification + child-WO actual costs

Implication for "do it automatically": automation is not blocked by data capture — only the deduction compute (the disposition) and the write path are net-new. The NTV email even carries the eCheck election, so O4's condition is satisfiable from data we already parse. A proven sibling write already exists: file_notice_to_vacate (appfolio.ts:1367) sets notice_date + move_out + move_out_reason via the browser agent. So end-to-end can auto-draft everything up to the PM-approval gate, then auto-post after approval.

Generic PMS port — shaped by cross-PMS research (Yardi · Buildium · RealPage · Entrata · AppFolio)

To keep the interface PMS-agnostic we sized it against the cross-PMS superset, not AppFolio's shape.

Two adapter families behind one port. Write-API capability diverges sharply:

The port therefore can't assume uniform sync/idempotency: API adapters return ids and can undo (Buildium DELETE …/moveouts); automation adapters need their own read-back confirmation + retry (the armed-dispatch pattern we already use).

Business intent only — accounting routing is adapter-internal. GL account ids, charge codes, the deposit-liability account, trust bank account, MITS enums — none appear in the interface. The caller states who moved out, what to deduct and why, how much to refund to whom, by what method; each adapter maps that to its native sequence (Buildium typed transactions with explicit Income GL; Entrata AR transactions; Yardi deposit-accounting worksheet; AppFolio move-out flow). This enforces the PMS-agnostic rule and the no-technical-language rule at once.

Three generic operations — move-out, disposition, and availability are separate concerns (availability is a unit attribute, not a move-out field): recordMoveOut, recordSecurityDepositDisposition, setUnitAvailability (signatures below). setUnitAvailability is D5's listing pass; syndication to Zillow/ILS is a downstream PMS-managed effect the adapter triggers (two-way for Buildium/Entrata/AppFolio; one-way for Yardi via ILS Guest Card; RealPage via LeaseStar→Zillow) — PropFlow never implements syndication itself.

Disbursement method is capability-gated. Check is universal; online (ACH/eCheck) depends on the PM's payment setup (AppFolio eCheck, Buildium EFT, Yardi via RentCafe). Each adapter exposes supportedDisbursementMethods, so O4's rule is precisely: tenant elected online (NTV securityDepositEcheck) and the adapter supports online → online; else check.

[UNVERIFIED — resolve before building the relevant adapter]: Yardi move-out/deposit write method (likely none public — confirm via a SIPP rep); RealPage RPX write capability; Entrata's exact onNoticeLease / disposition parameter names (method existence confirmed; literal field names not machine-readable — get them from an Entrata API agreement, don't assume).

Entity classification (per ADR-0027)

Entity Class Naming Spine trace / derivation
TurnoverDisposition canonical bare name Spine trace: carries personId: string directly as a required, writer-enforced FK per the ADR-0032 construction invariant (not the indirect turnoverId → tenantId trace of the older Turnover). The authoritative record of our computed deposit decision + PM approval + PMS-post status. Lifecycle: draft → pm_approved → posted_to_pms → refund_issued (or deadline_lapsed). The money itself lives in AppFolio (ADR-0039); this is the decision/audit record.
DeductionLineItem value object (embedded) Embedded in TurnoverDisposition.deductions[], not independently keyed. Each item carries: damage item ref (FrozenConditionProjection item id), trade, chargeBasis (wo_actual | good_faith_estimate), source WO id / invoice ref, gross cost, depreciation applied, net charge. Provenance-complete by construction.

Proposed shapes (contracts only — not implementation):

// canonical
export interface TurnoverDisposition {
  id: string;
  personId: string;            // ADR-0032 construction invariant: required FK on the Person spine, writer-enforced
  turnoverId: string;
  propertyId: string; unitId: string;
  depositHeldCents: number;    // mirrored from PMS at disposition time
  deductions: DeductionLineItem[];
  refundCents: number;         // depositHeld − Σ net deductions; ≥ 0
  refundOutcome: 'full' | 'partial' | 'none';
  jurisdiction: string;        // resolves the policy row (deadline, thresholds, useful-life)
  clockStartedAt: string;      // surrender or forwarding-address per jurisdiction
  legalDeadlineAt: string;     // computed from policy
  status: 'draft' | 'pm_approved' | 'posted_to_pms' | 'refund_issued' | 'deadline_lapsed';
  approvedByPersonId?: string; approvedAt?: string;
  pmsDispositionLetterRef?: string;   // AppFolio-generated SODA
  version?: number;
}

export interface DeductionLineItem {
  conditionItemId: string;     // → FrozenConditionProjection item (classification === 'tenant_damage')
  label: string; trade: string;
  chargeBasis: 'wo_actual' | 'good_faith_estimate';
  sourceWorkOrderId?: string; invoiceRef?: string;
  grossCents: number;
  usefulLifeYears?: number; ageYears?: number; depreciationApplied?: boolean;
  netChargeCents: number;      // after proration; what the tenant is charged
}

PMSWriter (port) gains three generic, PMS-neutral operations — business intent only, no GL ids / charge codes / bank accounts (those are adapter-internal). Two adapter families implement them: REST (Buildium, Entrata) and browser-automation (AppFolio, Yardi, RealPage-until-RPX):

recordMoveOut(p: { leaseRef; tenantRefs; moveOutDate; noticeGivenDate?; moveOutReason?; forwardingAddress? }): Promise<MoveOutResult>
recordSecurityDepositDisposition(p: {
  leaseRef; dispositionDate;
  deductions: { amount; category; memo }[];   // category — never a GL id
  refundPayees: { tenantRef; amount }[];       // co-tenant split is first-class
  disbursementMethod: 'check' | 'online';      // capability-gated; see O4
  refundMailingAddress?;
}): Promise<{ itemizedStatementRef }>           // the SODA, rendered by the PMS
setUnitAvailability(p: { unitRef; status: 'coming_soon' | 'available'; availableDate; marketRent? }): Promise<void>
readonly supportedDisbursementMethods: ('check' | 'online')[]

Consequences

Easier / what we gain

Harder / what this commits us to

Follow-up work implied: the phased roadmap below; an eval dataset for the charge proposal + conversational confirm (which GL bucket, seeded amount, what NOT to charge, reviewer-agent catch — no depreciation evals; that work is cut per §Revision R2) added to evals/datasets/turnover/; a licenseRequired flag on turnoverPolicy trades; a depositReturnDeadlineDays field on turnoverPolicy; verifying real listing syndication on a syndicating property (Phase 5).

Roadmap (small phases — each shippable behind the disarmed gate)

Ordered by net-new effort (most reuse first), so value lands early and the risky money/legal pieces come after the cheap wins:

Test-harness strategy (preview + PropFlow Test Property)

Per Fede's process, the harness runs in both preview and prod-against-Test-Property, and must exist before code:

Alternatives considered

  1. Direct Zillow syndication client (rejected). Build/maintain a Zillow rentals-feed integration. Rejected: AppFolio already syndicates to Zillow; setting the availability date in AppFolio gets us "coming soon" for free and avoids a brittle, redundant integration. Revisit only if we leave AppFolio or need listing control AppFolio can't express.
  2. PropFlow computes GL/trust-accounting entries directly (rejected). Hand-post the security-clearing and refund entries via L4. Rejected: AppFolio is the accounting source of truth (ADR-0039) and already renders the statutory SODA; replicating trust-accounting is high-risk and jurisdiction-specific. We drive AppFolio's native Move-Out workflow instead.
  3. Linear close-out chain ready_for_listing → deposit → move-out → listing (rejected). Simpler to model but wrong: listing and deposit are on independent clocks (a unit lists "coming soon" before make-ready finishes; the deposit deadline is unrelated to re-listing). We model three concurrent durable tracks.
  4. Autonomous deposit refunds under a threshold (rejected). Mirror the WO cost-cap auto-dispatch for small refunds. Rejected: deposit disposition is legal + money + tenant-trust; it stays PM-gated regardless of amount. The cost cap governs dispatch, not deposits.
  5. Nested work-order DAG for make-ready dependencies (deferred). A true WO-to-WO dependency graph. Deferred: the flat model + cleaning-last sequencing + finishing-hold (with a future paint-dry hold) covers every dependency the research surfaced. Build only if a real scheduling need exceeds cleaning-last.

Open questions (to resolve while iterating on this ADR)


Revisions — 2026-06-08 (close-out decisions, pre-Gera-alignment)

R11 — Creation ≠ dispatch

Decision: Turnover ALWAYS creates all work orders (in-house and external) at scoping time. Dispatch is a separate, downstream process.

Why: A WO is the unit of record for what the unit needs; it must exist regardless of who acts on it or when. Coupling creation to dispatch leaves external work invisible until the dispatch decision fires, breaking the detail page and charge/cost trail.

Supersedes: The abandoned "skip creating external WOs" idea. External WOs are now created like every other WO; only their dispatch is gated.

R12 — WO→AppFolio sync follows Gera's maintenance pattern

Decision: Reuse the existing maintenance gather → dispatch → persist pipeline for WO→AppFolio sync. Do NOT build a parallel L4 sync path. The LLM judge is SKIPPED for turnover.

Why: Turnover WOs are deterministically scoped from a frozen condition projection. The walk's conversational mess is already cleaned at the projection step — upstream of WO scoping. Gera's judge exists to clean conversational-WO-creation artifacts (duplicates, priority drift, photo-binding) that turnover never produces, so running it adds cost and risk with nothing to clean. Building a second sync path would fork maintenance logic.

Gera agenda item: Skip-judge vs. harmless pass-through; optionally add a cheap deterministic WO validation in place of the judge.

R13 — Close the orphan gap via a turnoverId-keyed gather input

Decision: Add a getWorkOrdersByTurnover gather input keyed on turnoverId, with propertyId resolved from the turnover. WOs already carry turnoverId.

Why: Turnover WOs carry conversationId='', so they never reach the maintenance pipeline (the orphan gap). Keying on turnoverId works for BOTH the web and agentic paths and has no timing/discovery hazard, since the link already exists on the WO at creation.

Considered and rejected: Reusing the PM↔Clara conversationId (the voice walk + SMS confirm is one Conversation with a propertyId). Elegant, but the Conversation has no turnoverId — the link is a deferred after() thread-log, creating a timing/discovery gap on the web path. The judge-skip (R12) also moots the conversation context that path would have supplied.

Gera agenda item: The gather input contract.

R14 — In-house handyman: immediate ping, deferred AppFolio sync

Decision: On PM confirm, the in-house handyman gets an IMMEDIATE tech SMS (human-in-the-loop). The WO's AppFolio record syncs via the pipeline (deferred, exactly like tenant WOs). Two distinct timings.

Why: The handyman needs to start work now; AppFolio is the system of record and can lag without blocking the physical work. Separating the two timings avoids holding the tech ping hostage to a sync round-trip.

R15 — One dispatch/sync gate: Property.autonomousMaintenanceEnabled

Decision: A single gate — Property.autonomousMaintenanceEnabled — controls turnover dispatch/sync. appfolio-45 is ON for the POC; Camellia is HELD.

Why: Turnover dispatch is maintenance dispatch; it should ride the same property-level switch rather than introduce a parallel flag that can drift out of sync. One gate, one mental model.

Supersedes: R8's separate turnoverPolicy.autonomousExternalDispatch flag. No new flag is introduced.

R16 — autonomousTurnoverEnabled is not the handyman dispatch gate

Decision: The handyman dispatch gate is the PM's "yes" (human-in-the-loop), NOT autonomousTurnoverEnabled.

Why: The autonomous arm only matters for a no-human path. The close-out flow always routes through PM confirmation, so the PM's approval is the operative gate. Conflating the two would either block a confirmed dispatch or imply an autonomy the close-out flow does not exercise.

R17 — Move-out (F1) drafts at inspection-complete; charges trail

Decision: On inspection-complete, draft the move-out with auto-filled fields:

Charges TRAIL the draft (derived from WO actualCost + condition-report tenant_damage, PM-approved). Move-out REASON is left BLANK for the PM. Drafts go up to AppFolio "Save for later"; accounting Posts and Finishes by hand.

Why: Inspection-complete is the earliest point all move-out facts are known, so drafting there removes manual re-entry. Charges depend on actual WO costs and damage findings that aren't final at inspection, so they trail. Reason isn't in our data, and money/legal stays human — so we stop at "Save for later" rather than posting.

R18 — Ready-for-marketing (G) is the terminal step

Decision: The terminal turnover step is setUnitAvailability (L4) → AppFolio availability date → syndication to Zillow / apartments.com / website.

Why: Marking the unit available is the business outcome that ends a turnover and starts re-leasing. Driving it through L4 → AppFolio ensures the availability date is authoritative and syndicates automatically to listing channels.

R19 — Unified status model

Decision: Adopt one status model: Noticed → Inspected → Turning → Ready to lease (+ Cancelled).

Why: The prior model had an unreachable status ("Sent"), engineer-shaped labels, and no first-class "inspected" state, so the detail page and the pipeline disagreed about where a turnover was. A single PM-readable model aligns the UI, the data, and the PM's mental picture.

R20 — Charge review: in-channel confirm plus a detail-page card

Decision: Charge review is BOTH:

  1. In-channel confirm — the PM's "yes" over SMS/voice, and
  2. A detail-page CHARGE-REVIEW CARD where the PM sees and edits each proposed charge (amount + what it's for) before and after confirming.

Why: The in-channel "yes" is fast but low-fidelity; the PM can't easily inspect or correct individual line items in a text thread. The detail-page card gives a reviewable, editable surface for money decisions while preserving the quick in-channel confirm.

R21 — Build as a production-ready proof-of-concept on appfolio-45

Decision: Build this as a PRODUCTION-READY proof-of-concept against the PropFlow Test Property (appfolio-45). The test harness runs END-TO-END and by DECOUPLED CHUNKS; every slice is live-verified on appfolio-45.

Why: JSON-backend unit tests do not exercise the real DynamoDB/AppFolio path and are not sufficient verification. Live-verifying each slice on a real test property catches integration breakage that unit tests mask, while the decoupled-chunk harness keeps slices independently testable.


Architecture & Flow (2026-06-08)

This section is the executable picture of the R11–R21 close-out decisions above — the same end-to-end path the test harness (R21) drives green. It supersedes the prose-only flow sketch in R3: that sketch was right in spirit; this is the wired version with the dispatch/sync seam made explicit. The load-bearing seam is: Turnover CREATES every work order; Gera's existing maintenance pipeline GATHERS → DISPATCHES → PERSISTS them to AppFolio. Turnover never builds a parallel L4 sync (R12). The two sides meet on one key: turnoverId.

Diagram 1 — End-to-end close-out sequence (NTV → posted)

sequenceDiagram
    actor PM as PM (voice/SMS)
    participant Clara as Clara (agent)
    participant TO as Turnover (domain)
    participant Gera as Gera's WO pipeline
(gather→judge→dispatch→persist) participant AF as AppFolio L4 (Browserbase) actor Acct as Accounting (human) Note over TO: NTV (AppFolio poll → pms.lease.ntv_filed) TO->>TO: Turnover.create — stage=Noticed
(moveOutDate, targetReadyDate computed) [R19] PM->>Clara: PM walks the unit (voice / glasses / text) Clara->>TO: captures notes+photos, classifies wear-vs-damage
→ move_out ConditionReport Note over Clara,TO: CONDITION PROJECTION freezes — conversational mess
cleaned HERE, upstream of WO scope [R12] Note over TO: stage=Inspected (capture-stepper = SUB-progress) [R19] Clara->>PM: RECAP in-channel (the one approval surface) [R20]:
"Found A,B,C. Charge $150 cleaning + $120 paint to the deposit?
Create WOs for paint+tub, ping handyman + the painter? Reply yes / edit." Note over PM,Clara: plus DETAIL-PAGE CHARGE-REVIEW CARD: PM sees/edits
each charge (amount + what for) [R20] PM->>TO: 'yes' (human-in-the-loop = the hard money gate —
NOT autonomousTurnoverEnabled) [R16] TO->>TO: CREATES ALL WORK ORDERS (in-house + external) [R11]
each WO: turnoverId=, status=pending_dispatch, syncState=unsynced
frozen projection → deterministic scope (no judge needed for scoping) Note over PM,Gera: TWO DISTINCT TIMINGS on "yes" [R14] Clara->>PM: (a) IN-HOUSE handyman → IMMEDIATE tech SMS (tech-sms-dispatch.ts)
"Unit 204 ready for make-ready, here's the scope" TO->>Gera: (b) ALL WOs (in-house + external) → Gera's pipeline syncs
the AppFolio record (deferred, same path tenant WOs already take) Gera->>Gera: gather(getWorkOrdersByTurnover, propertyId from turnover) [R13] Gera->>Gera: JUDGE SKIPPED for turnover WOs
(deterministic scope, nothing to clean) [R12] Gera->>AF: dispatch → AppFolio L4: create/sync WO record (Browserbase) → persist [R12] Note over Gera,AF: ONE GATE: Property.autonomousMaintenanceEnabled [R15]
(appfolio-45 = ON for POC · Camellia = HELD)
external vendors: proposed w/ name+$ in recap, same gate, no separate flag [R15→supersedes R8] Note over TO: stage=Turning (WOs dispatched, make-ready underway) [R19] TO->>AF: AT INSPECTION-COMPLETE — Move-Out DRAFT ("Save for later") [R17]
auto-filled: move-out date (Turnover.moveOutDate) · notice date (NTV state)
availability + ready-for-showing (Turnover.targetReadyDate)
forwarding address (tenant record if present) · tenant pre-selected
LEFT BLANK: move-out REASON (not in our data → PM fills) TO->>Acct: notify accounting: "move-out drafted, ready to Post + Finish" [R10] Note over TO: WOs finish — actualCost flows back → CHARGES TRAIL the WOs [R17]
charges = WO actualCost + condition-report tenant_damage, PM-approved
(two GL buckets + flat late fee — R2, written in accounting's phrasings) TO->>AF: charges appended to the move-out draft [R17] Note over TO: ALL WOs done → READY-FOR-MARKETING (terminal step) [R18] TO->>AF: setUnitAvailability (L4) → AppFolio availability date
→ syndicates to Zillow / apartments.com / website Note over TO: stage=Ready to lease [R19] Acct->>Acct: Posts + Finishes the move-out, renders the SODA [R17]
(money/legal stays human, by hand)

Diagram 2 — Component / lane diagram (the dispatch–sync seam)

The seam is vertical: Turnover owns CREATION; Gera's pipeline owns SYNC; AppFolio is the L4 surface; Accounting is the human terminal. The only thing crossing the Turnover→Pipeline seam is the turnoverId key (R13). No parallel L4 sync is built inside Turnover (R12).

flowchart TD
    subgraph TURNOVER["TURNOVER (domain)"]
        direction TB
        T1["on PM 'yes': CREATE all WOs (in-house + external)
turnoverId-keyed, pending_dispatch, unsynced"] T2["deterministic scope from FROZEN condition projection
(mess cleaned here, upstream at projection)"] T3["ALSO on 'yes': in-house handyman
IMMEDIATE tech SMS (human-in-the-loop) (R14)"] T4["Charges TRAIL on WO actualCost (R17)"] end subgraph GERA["GERA'S WO PIPELINE — src/lib/maintenance/work-orders/"] direction TB G1["gather.ts — input: getWorkOrdersByTurnover
(propertyId ← turnover) — NET-NEW gather input [R13]"] G2["judge.ts — ⨯ SKIPPED (turnover scope is deterministic;
conv. mess cleaned upstream at PROJECTION) [R12]"] G3["dispatch.ts — tech SMS (in-house = IMMEDIATE) [R14]"] G4["persist.ts — write-back"] G5["ONE GATE: Property.autonomousMaintenanceEnabled [R15]
(appfolio-45 ON · Camellia HELD; external = same gate)
supersedes R8's flag"] G1 --> G2 --> G3 --> G4 --> G5 end subgraph APPFOLIO["APPFOLIO (L4 / Browserbase — 'Save for later' drafts, never Posts)"] direction TB AF1["WO record sync (from Gera's pipeline; deferred) [R12]"] AF2["Move-Out DRAFT (at inspection-complete;
dates/notice/avail/forwarding/tenant auto-filled; REASON blank) [R17]"] AF3["setUnitAvailability (terminal;
→ Zillow / apartments.com / website) [R18]"] end subgraph ACCT["ACCOUNTING (HUMAN — by hand)"] AC1["Posts the move-out · Finishes the disposition · SODA rendered by AF [R17]
money + legal stays human; PropFlow never Posts"] end TURNOVER ==>|"turnoverId (the key) — each WO carries turnoverId"| GERA T3 -->|"immediate tech SMS path (R14)"| G3 TURNOVER -->|"move-out draft + charges + setUnitAvailability"| APPFOLIO GERA -->|"WO record sync (L4 / Browserbase)"| APPFOLIO APPFOLIO -->|"'drafted, ready to Post + Finish' [R10]"| ACCT

Gates & key (single source of truth):


Gera alignment agenda (meeting 2026-06-09)

Crisp checklist for the turnover close-out → maintenance-pipeline handoff. Each item is a question with our recommendation. The asks are narrow: confirm two contract points (R13, R12) and ratify two assumptions (single gate, first-sync). Everything else is a heads-up, not a debate.

1. R13 — Gather input contract (the one real interface change)

Question: Turnover WOs carry conversationId='', so they never reach your gather→dispatch→persist pipeline. To close that orphan gap we want a turnoverId-keyed gather inputgetWorkOrdersByTurnover(turnoverId), with propertyId resolved from the turnover. WOs already carry turnoverId. What does your gather/trigger need to accept this as an entry point alongside the conversationId path?

2. R12 — Should turnover WOs skip the LLM judge?

Question: Your maintenance judge cleans conversational-WO-creation artifacts — dupes, priority drift, photo-binding. Turnover WOs are deterministically scoped from a frozen condition projection, and the walk's conversational mess is already cleaned at the projection step (upstream of WO scoping). So turnover never produces the artifacts the judge exists to catch. Does your pipeline need a skipJudge path, or is passing turnover WOs through the judge harmless?

3. Confirm: single dispatch/sync gate = Property.autonomousMaintenanceEnabled (R15)

Question: Turnover dispatch + AppFolio sync should ride your existing autonomousMaintenanceEnabled property gate — no new turnover-specific flag. Confirm this is the one gate.

4. Confirm: turnover WOs are first-sync / needsCreate (R11)

Question: Turnover always creates all WOs (in-house + external) before dispatch — creation ≠ dispatch. These hit your pipeline as first-time AppFolio syncs (needsCreate), never as updates to an existing AppFolio record. Confirm your persist step handles them as net-new creates and nothing upstream assumes a pre-existing AppFolio WO id.

5. Heads-up (not for debate) — timing split on dispatch (R14)

Not an ask, just so your pipeline's assumptions hold: there are two distinct timings at PM confirm.

Flag if either timing collides with an assumption in gather/dispatch/persist.


Decisions we need to walk out with:

  1. Gather accepts a turnoverId-keyed input (R13) — yes / adapt-on-our-side.
  2. skipJudge path for turnover (R12) — dedicated bypass / confirmed-harmless pass-through / cheap deterministic validation instead.
  3. autonomousMaintenanceEnabled is the sole gate (R15) — confirmed.
  4. Turnover WOs handled as needsCreate first-syncs (R11) — confirmed.

Everything else (status model R19, move-out drafts R17, charge-review card R20, ready-for-marketing terminal step R18) is on our side of the line and doesn't change your contract — no sign-off needed from Gera, FYI only.