0044 — End-to-end turnover close-out: auto-dispatch, security-deposit disposition, AppFolio move-out, and re-listing
- Status: Proposed — Revised 2026-06-08 (simplified; grounded in live Camellia charge data + prod findings — see §Revision)
- Date: 2026-06-02 (revised 2026-06-08)
- Deciders: Fede (+ Sean, Gera)
- Extends: ADR-0034 (turnover orchestration spine — Slices 1–3 shipped; this ADR specifies Slices 4–5 plus the deposit-reconciliation requirement that 0034 did not cover)
- Related: ADR-0039 (AppFolio is source of truth), ADR-0030 (PMS owns WO IDs), ADR-0031 (local-first WOs), ADR-0033 (vendor model), ADR-0025 / ADR-0026 (Temporal + PII boundary), ADR-0027 (entity classification), ADR-0037 (formal NTV trigger)
- Trello: "Turnover → work-order engine: notes→child WOs, default-vendor dispatch, PM approval gate, batching" (This Week)
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:
- The voice/glasses/SMS capture path records a ConditionReport and fires
signalInspectionCompleted, which is a no-op unless the Temporal turnover gate is armed (signal-inspection-completed.ts); Camellia is disarmed. The only path that scopes WOs + advances stage is the workspaceinspect-and-scopebutton — which the PM never touches during a field walk. So the capture method the PM actually uses cannot advance a turnover in prod. - The detail page renders a capture-state stepper (Captured → Reviewing → Drafted → Approved → Sent) while the list renders the stage machine (Notice Received → Work Scheduled → …). Two orthogonal models, no bridge; the freeze/approve API exists but no UI button calls it, and freeze doesn't advance the stage anyway → "Drafted with no approve path."
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:
- Cleaning is near-universal; paint common; carpet / structural damage occasional. → Clara defaults to a cleaning charge (~$120–150) and actively hunts for paint/damage, rather than starting from zero.
- Security deposit is standardized at ~$300 (range $200–500); deductions routinely exceed it (partial/zero refunds are normal).
- Camellia posts tenant-chargeable damage to an expense GL as a contra-reimbursement ("Tenant Reimbursement" / "Non-CAM Expense") — there is no "Damage Income" account. Clara must mirror that GL choice or accounting re-codes every charge.
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.tsconfig module sourced AppFolio-only. Storage moves toPropertyKnowledge.turnoverPolicy.chargeKnowledge(per-property, DynamoDB), and sourcing is PMS-agnostic via thePMSClientread 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)
- The PMS-agnostic
PMSWriterport + AppFolio as the first adapter. Never AppFolio-only. The three generic ops stay:recordMoveOut,recordSecurityDepositDisposition(simplified payload —{amount, category, memo}lines, no GL ids),setUnitAvailability. A Yardi adapter slots in later with no domain change. - AppFolio drives its native "Move Out Tenants" workflow via L4/Browserbase, mirroring
file_notice_to_vacate— PropFlow drafts up to accounting's final Post (no trust-accounting in PropFlow). The two L4 endpoints the fleet builds: (1)recordMoveOut(6-step flow, stop at draft) and (2)setUnitAvailability(Marketing-Info availability date → AppFolio syndicates to Zillow / Apartments.com / network; the PropFlow website feed is a separate leg to confirm — likely our own listings feed, not AppFolio syndication). - The condition projection (capture + wear-vs-damage classification) as the chargeability source:
classification === 'tenant_damage'→ chargeable;normal_wear→ never. Move-in baselines (D1) materially improve this — without them classification defaults tounclear= unchargeable. - A hard human gate before money moves (now the conversational confirm) and AppFolio as accounting source-of-truth + SODA renderer.
- Disarmed-by-default two-factor Temporal gate for any autonomous send (
TURNOVER_AUTONOMOUS_SENDING+Property.autonomousTurnoverEnabled); Test-Property-first (appfolio-45).
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 |
- The capture progress (live → reviewing → projection landed) is sub-state inside "Inspected" — an animation/affordance on the detail page, not a top-level status.
deriveCaptureStatekeeps driving that animation; it stops being a parallel lifecycle. - The PM's "yes" is the Inspected→Turning transition (R3). There is no separate "Approved" or "Sent" status — the orphaned steps are deleted, not relabeled.
- Load-bearing reads to migrate (do not silently drop): the voice tool that surfaces "open turnovers" filters on
notice_received(handle-find-turnover.ts,pm-call-context.ts); the list's active/terminal counts useTURNOVER_ACTIVE_STAGES/TURNOVER_TERMINAL_STAGES; the cancel guard checks terminal. These map 1:1 onto the new names — the harness asserts each still resolves. - The created WOs are shown in the UI — the turnover detail page's existing
ChildWOssection (maintenance/turnovers/[id]/page.tsx, today gated onwork_scheduled/ready_for_listing+tasks.length > 0) re-gates to Turning / Ready to lease and populates the moment the PM's "yes" creates the WOs. It's empty today only because the loop never creates them (R1) — wiring + re-gating, no net-new component. The conversational confirm and this page stay in sync: the agentic path doesn't require the PM to open it, but it reflects reality when they do. Harness asserts: after confirm, the detail page lists the created child WOs. - Customer-facing-copy rule: these are the literal display labels; no enum casing, no "Work Scheduled"-style internal phrasing. Final wording "Ready to lease" (vs "Ready to list") — chosen 2026-06-08.
- Stored values (the DDB
stagekeys) become the new snake_case set —notice_received,inspected,turning,ready_to_lease,cancelled— replacingwork_scheduled/ready_for_listing. The load-bearing reads above filter on stored values, so Phase 0 is a constant rename + a one-time DDB migration of existing turnovers (work_scheduled → turning,ready_for_listing → ready_to_lease, andnotice_receivedrows that already have a condition report →inspected), not just a display change.inspectedis a genuine new stored stage (the capture-state stepper stops being a parallel model).
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":
- In-house handyman dispatches immediately, under the existing
aiAutoApproveThresholdcost cap (reuseautoAssignVendor/decideScopeWorkOrder). - External vendors are proposed — named, with the dollar amount, in Clara's recap — but HELD behind a new per-property flag
turnoverPolicy.autonomousExternalDispatch(defaultfalse). This sits under the existing disarmed two-factor gate (TURNOVER_AUTONOMOUS_SENDING+Property.autonomousTurnoverEnabled); it is a finer-grained hold specific to paid third parties. - Flipping the flag later is a settings toggle, not a deploy. Because the recap always shows vendor + cost, the day the flag flips the PM's "yes" is already an informed money approval — no new UX needed.
- Implementation note (CLAUDE.md exception set):
turnoverPolicy.autonomousExternalDispatchis functionally a sibling of the sanctionedProperty.autonomousRenewalEnabled/autonomousTurnoverEnabledgates (per-property, fail-closed operator control of a high-blast-radius autonomous action). The implementation PR must register it in CLAUDE.md's canonical autonomous-exception set (the Temporal env-var / per-property-gate table), alongside those rows, so it clears the no-new-flags review gate cold. - Harness asserts both halves: while the flag is off, external dispatch never fires (held for a second explicit confirm); while on, external fires only after the recap surfaced the named vendor + cost. This is the high-risk autonomous-spend gate, made reversible-by-config.
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.
- Storage (guardrail Clara reads): extend the existing
PropertyKnowledge.turnoverPolicywithchargeKnowledge— semantic categories (cleaning / paint / carpet / damage / late_fee), typical amounts, accounting's phrasings, and an explicit never-charge list (proration, deposit transfer — the things AppFolio auto-handles). Same per-property mechanism, PATCH endpoint, and decision-time injection thatdefaultTrades/ cost-cap already use. - Sourcing via the read port, NOT the AppFolio client. A generic op on the
PMSClientread port —listHistoricalMoveOutCharges({ propertyRef, lookbackMonths })— returns native-category + amount + memo rows; each adapter implements it (AppFolio →charge_detail; Buildium → REST; Yardi → SOAP/none).extractTurnoverChargeProfile(property)calls the port and aggregates. Three-tier fallback so a non-AppFolio property never blocks: (1) adapter can read history → mine the property's real categories/medians/never-charge; (2) adapter exists but no charge-read → seed a generic cross-PMS template the PM confirms; (3) no history → template + PM edits inTurnoverPolicyEditor. Mining is an enhancement, never a dependency. - PMS-specific GL stays adapter-internal. The domain stores semantic categories only (the write op already takes
category, "never a GL id"). The per-property semantic→native-GL map (AppFolio:cleaning → "Maintenance – Tenant Reimbursement") is an opaque hint the adapter sets during mining and only the adapter reads — like an opaquepmsRef. Camellia's GL names live in the AppFolio adapter, not in domain config. - Reviewer agent (the "another agent inspects the decision"): reuse the existing maintenance
judge.tscritic (thegather → judge → dispatchpattern already in prod for work orders). A turnover charge-judge inspects Clara's proposed charges against the property'schargeKnowledgebefore they commit — catches a wrong bucket, chargingnormal_wear, or proposing a never-charge item. Fail-open (ship the proposal + log) on LLM outage, mirroring the maintenance judge.
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):
- 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.
- 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.
- 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
setUnitAvailabilitywrite. The three-durable-tracks + newclosedstage 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:
- In-house handyman, under the cost cap → auto-dispatch (already works;
tech-sms-dispatch.ts). - External vendor, or over the cost cap, or finishing trade → held as
pending_dispatchbehind the PM approval gate (already exists as the cost-cap hold + freeze).
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):
- 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.)
- Move-out charges & credits — the PropFlow-computed deduction line items (D4).
- 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.
- 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
TurnoverDispositioncanonical entity,usefulLifeYears/depreciation, andjurisdiction/legalDeadlineAtare 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:
- What's chargeable =
FrozenConditionProjection.itemswhereclassification === 'tenant_damage'(the wear-vs-damage call is already made and PM-frozen —condition-report.ts:199-235).normal_wearitems are never charged. This is the legal line, and we already compute it. - How much = the child-WO actual costs (
Turnover.totalActualCost/ per-WO actuals) for the WOs that remediated those damage items, or a labeled good-faith estimate where a WO hasn't closed yet (statutes such as CA explicitly allow estimate-now + receipts-within-14-days-of-completion). - Depreciation / useful-life proration (research:
deduction = replacement × remaining_life / total_life; carpet ~5yr, flat paint ~3yr, enamel ~5yr — configurable defaults, not constants). Decided: v1 itemizes raw WO actual costs with PM edit/approval; useful-life proration is deferred to Phase 2.5. The hard PM-approval gate makes raw-cost-with-edit legally safe to ship first, and keeps v1 simple. - Refund =
depositHeld − Σ(lawful deductions)→ full / partial / none.
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:
- Listing pass (Phase 3, ungated, reversible): the generic
setUnitAvailability(unitRef, 'coming_soon', availableDate)op, fired attargetReadyDate − 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. - 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:
- REST-API adapters — Buildium (verified:
POST /leases/{id}/moveouts,/charges,/applieddeposits,/refunds) and Entrata (method names verified:onNoticeLease,sendLeaseArTransactions; exact params behind an API agreement). - Browser-automation adapters — AppFolio (no public write API) and Yardi (SOAP web services exist, but no public move-out/deposit write method — SIPP-gated) drive the native UI via L4. RealPage is gated behind RPX certification (automation until certified).
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
- A turnover runs all the way from NTV to re-listed + deposit refunded, not stopping at
ready_for_listing. - The highest-liability action (deposit) is provenance-complete and PM-gated — every dollar traces to a WO actual, invoice, or labeled estimate; nothing autonomous; jurisdiction rules in config.
- We avoid two whole integration surfaces by reusing AppFolio: no Zillow client and no GL/trust-accounting engine (AppFolio renders the SODA and owns the money).
- The entire E2E can run in shadow in prod behind the existing disarmed two-factor gate before any real write — same model that already de-risks turnover dispatch.
Harder / what this commits us to
- A new L4/Browserbase move-out write path with real money + legal blast radius. Browser automation is the most brittle integration we have; the move-out driver needs the read-back validator pattern (already used for armed dispatch) and Test-Property-first arming.
- Legal correctness rests on the PM-approval gate, not on encoded law — deliberately, to stay simple. v1 tracks one deadline per property and computes a draft; the PM owns the final call. A per-jurisdiction policy table becomes a follow-up only if we later manage deposits across many states.
- Deposit math (depreciation, refund split for multi-tenant occupancies, good-faith estimates) is genuinely new domain logic and must be covered by evals, not just unit tests.
- AppFolio UI changes can break the L4 driver; we accept this and gate behind read-back verification + shadow mode.
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:
- Phase 0 — Contracts & shadow scaffold (no behavior change). ⚠ Superseded by §Revision R2/R3/R4: the
TurnoverDisposition/DeductionLineItementity + DDB repo + ADR-0032 closure and the newclosedstage are dropped; Phase 0 instead becomes (a) the status-model unification (one PM-facing lifecycle: notice received → inspected → turning → ready to lease, replacing the diverging stage/capture-state models) and (b) the per-property charge-KB scaffold (turnoverPolicy.chargeKnowledge). Keep only: extendPMSWriterwith throwing move-out stubs, the close-out phases behind the existing gate, and the test-harness skeleton. Original (now superseded) text:TurnoverDisposition/DeductionLineItemtypes + DDB repo (mirror the condition-report repo) —TurnoverDispositionlands withpersonId: stringplus the ADR-0032 three-step closure (type flip + adapter + drain/drift-guard, mirroringspine-stamp-construction-invariant.drift.test.ts); addclosedtosrc/lib/data/statuses/turnovers.ts(updateTURNOVER_STAGE,TURNOVER_TERMINAL_STAGES, andTURNOVER_VALID_TRANSITIONS— todayready_for_listingis hard-terminal with[], so it must gain a transition toclosed); extendPMSWriterwith throwing move-out stubs; add the three close-out phases to the turnover workflow behind the existing gate —TURNOVER_AUTONOMOUS_SENDING+Property.autonomousTurnoverEnabledinsrc/lib/temporal/turnover-gate.ts(extend it, do not re-invent); a singledepositReturnDeadlineDaysfield onturnoverPolicy(not a multi-state table); test-harness skeleton. - Phase 1 — Finish auto-dispatch (B). ⚠ Per §Revision R8: external dispatch is gated by the per-property
autonomousExternalDispatchflag (default off), not a per-dispatch PM-approval surface. External-vendor turnover dispatch through the maintenance pipeline + in-house/external gate +licenseRequiredrouting; while the flag is off, externals are proposed (named + cost in the recap) and held for a second confirm; flipping the flag is a settings toggle. (The implementation PR must addturnoverPolicy.autonomousExternalDispatchto CLAUDE.md's canonical autonomous-exception set — see R8.) - Phase 2 — Proposed charges + conversational confirm (D), shadow only. ⚠ Superseded by §Revision R2/R3: there is no SODA-preview UI gate and no versioned disposition. Instead: build lightweight proposed charges on the turnover from the frozen projection + WO actual costs, seeded by the per-property charge KB (two GL buckets + flat late fee — §R2); Clara recaps them in-channel; the PM's "yes" is the approval; a reviewer agent (reuse the maintenance
judge.tscritic) inspects the proposal before it commits. AppFolio renders the SODA at close-out. No AppFolio write yet. - Phase 2.5 — Useful-life depreciation/proration. ⚠ CUT by §Revision R2: Camellia's real charge data shows no depreciation/useful-life logic — accounting charges flat amounts. Do not build this; reintroduce only if a property's real data demands it.
- Phase 3 — Listing "coming soon" (E). The lightweight, ungated availability-date write (D5 pass 1): set the unit's AppFolio Marketing-Information availability date at
targetReadyDate − lag; confirm vialistings-sync. Independent of deposit — the cheapest, reversible piece, so it ships before the gated move-out write. On the Test Property (no Zillow syndication, per O3) the pass is verified by asserting the AppFolio write + simulating syndication; real Zillow confirmation comes with a syndicating property (Phase 5). - Phase 4 — AppFolio move-out write (C). L4 driver for the full native Move-Out workflow (dates, charges/credits, deposit split/refund, disposition letter), gated on disposition approval, with read-back verification; re-sets the availability date as part of close-out (D5 pass 2). Supports both refund disbursement methods, derived from the tenant's move-out submission (online transfer if they provided transfer details at notice, else check; per O4). Arm on PropFlow Test Property only.
- Phase 5 — End-to-end arm + harden. Arm the two-factor gate on Test Property, run the full E2E harness, then graduate to a real property (sandbox-first).
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:
- Preview (Vercel preview / localhost): unit + integration tests on
JsonRepositorywith a mockedPMSWriter(no real AppFolio); workflow replay-harness for the close-out phases; promptfoo eval cases for wear-vs-damage (exists) plus new deposit-disposition evals (deduction math, refund split, single per-property deadline reminder; depreciation evals arrive with Phase 2.5); shadow-mode read-back assertions (reuse the "read-back validator for armed temporal dispatch" pattern). Classification/calculation bugs become eval cases (CLAUDE.md). - Prod against PropFlow Test Property:
clone-propertya real unit into Test Property → arm the two-factor gate on Test Property only → run the full E2E (move-out posted, disposition letter generated, availability date set, listing syndicated) → verify against AppFolio. Reuse the existingverify-turnover-prodE2E harness. Never run against a real tenant until Test Property passes a full 3-channel/E2E pass (sandbox-first). - Money/legal safety: disarmed by default; every disposition and external dispatch PM-approved; deduction provenance asserted (no number without a WO/invoice/estimate ref).
Alternatives considered
- 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.
- 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.
- 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. - 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.
- 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)
- O1 — Depreciation in v1? DECIDED (2026-06-02), then CUT (2026-06-08 §Revision R2). Originally: raw WO actual costs in v1, useful-life proration deferred to Phase 2.5. Now: depreciation/useful-life is cut entirely — Camellia's real charge data shows flat amounts in two GL buckets, no proration. Amounts seed from WO actual cost → else the per-property charge-KB medians; the PM's in-channel confirm is the gate.
- O2 — Jurisdiction rules. SIMPLIFIED (2026-06-02). No multi-state policy table in v1: one
turnoverPolicy.depositReturnDeadlineDaysper property + PM-owned legal correctness via the approval gate. A per-jurisdiction table is a follow-up only if we manage deposits across multiple states. - O3 — AppFolio→Zillow on Test Property. ANSWERED (2026-06-02): it does NOT syndicate. The listing pass is simulated in the harness (assert the AppFolio availability-date write; stub downstream syndication). Real Zillow verification is deferred to a syndicating property in Phase 5. Architecture unchanged — only the Test-Property E2E verification is simulated.
- O4 — Refund disbursement method on Test Property: printed check vs online transfer.
- O4 — Refund disbursement method. ANSWERED (2026-06-02): support both, condition-driven. The method is derived from the tenant's move-out submission — if the tenant provided online-transfer/banking details at move-out notice →
online_transfer; otherwise →check. The L4 driver drives both AppFolio paths. The PM can override at the approval gate, but the derived value is the default. - O5 — One AppFolio session or two? DECIDED (two-pass). A lightweight ungated write sets only the Marketing-Information availability date at
ready_for_listing(Phase 3, drives the "coming soon" listing); the full Move-Out workflow runs later after disposition approval (Phase 4) and re-sets the date as part of close-out. This also removes the D0/D5 listing-gate ambiguity the review flagged.
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:
- Move-out date ←
Turnover.moveOutDate - Notice date ← NTV state
- Unit availability + ready-for-showing ←
Turnover.targetReadyDate - Forwarding address ← tenant record (if present)
- Tenant ← pre-selected
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).
- "Inspected" becomes a real stage.
- The detail-page capture-stepper becomes SUB-PROGRESS within "Inspected".
- Drop the unreachable "Sent" status.
- Plain PM English everywhere; retire "Work Scheduled" and other eng-ish wording.
- Ship an old-stage → new-status mapping.
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:
- In-channel confirm — the PM's "yes" over SMS/voice, and
- 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):
- turnoverId — the ONLY thing crossing Turnover→Pipeline; WOs already carry it [R13]
- PM's "yes" (in-channel) — the hard MONEY gate (human-in-the-loop) [R16]
- autonomousMaintenanceEnabled — the ONE dispatch/sync gate (per-property; NOT a new turnover flag) [R15]
- autonomousTurnoverEnabled — NOT the dispatch gate (only matters for a no-human path we don't use) [R16]
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 input — getWorkOrdersByTurnover(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?
- Our rec: turnoverId-keyed gather. Works for both the web (PM clicks confirm) and agentic (voice walk) paths, and there's no timing/discovery hazard — the turnoverId exists on the WO at creation time.
- Considered and rejected: reusing the PM↔Clara
conversationId(voice walk + SMS confirm are one Conversation with a propertyId). Rejected because the Conversation has no turnoverId — the link is a deferredafter()thread-log, so the web path hits a timing/discovery gap. And since we're skipping the judge (item 2), the conversation context it would have carried is moot anyway. - Decision needed from Gera: does the gather accept a
{ turnoverId }discriminated input, or do we adapt on our side to hand you a synthetic batch in your existing shape? Prefer the former — it keeps the turnoverId provenance intact through dispatch.
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?
- Our rec: explicit
skipJudgefor turnover. The judge is a no-op at best on this input and an LLM cost/latency hit + nondeterminism risk at worst. - Fallback if a bypass is awkward in your flow: pass-through is acceptable if you can confirm the judge won't mutate/dedup deterministically-scoped WOs (e.g. collapse two legitimately-distinct line items). If that risk exists, we need the bypass.
- Open option: replace the judge for this path with a cheap deterministic WO validation (schema/required-fields check) instead of the LLM. Lighter than the judge, still catches malformed input. Worth a yes/no from Gera.
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.
- Our rec / locked: yes. This supersedes the old
turnoverPolicy.autonomousExternalDispatchflag from R8 — we are not adding a parallel flag. - State for the POC:
appfolio-45ON, Camellia HELD. - Clarifying note (R16):
autonomousTurnoverEnabledis not the handyman dispatch gate. In the close-out flow the PM's "yes" (human-in-the-loop) authorizes dispatch. The autonomous arm only matters for a no-human path, which this flow doesn't use. So your gate governs sync/external-dispatch; the human "yes" governs the in-house ping.
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.
- Our rec / locked: every turnover WO is net-new at sync time. (This supersedes the abandoned "skip creating external WOs" idea — we create them all; dispatch is the separate, gated step.)
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.
- In-house handyman: an immediate tech SMS ping at the moment of PM confirm (human-in-the-loop). This is our side, not your pipeline.
- AppFolio record: syncs via your pipeline, deferred — exactly like tenant-originated WOs today. No change to your timing.
Flag if either timing collides with an assumption in gather/dispatch/persist.
Decisions we need to walk out with:
- Gather accepts a
turnoverId-keyed input (R13) — yes / adapt-on-our-side. skipJudgepath for turnover (R12) — dedicated bypass / confirmed-harmless pass-through / cheap deterministic validation instead.autonomousMaintenanceEnabledis the sole gate (R15) — confirmed.- Turnover WOs handled as
needsCreatefirst-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.