Every PropFlow vendor surface — voice, email, SMS, reply-routing — presents a work-order ID as the job reference. Mining every jpco purchase order, 105 days of call transcripts, and the full codebase shows the actual vendor-facing reference at Camellia is an AppFolio PO number, issued by the PM, required at scheduling and invoicing. This is the evidence, the blast radius, and the proposed pivot.
From the full corpus — 722 POs, verbatim from AppFolio.
The PO is a vendor + trade + batch authorization token, not a per-job ticket. It's a bare sequential integer (currently in the 600s), issued by the PM (92% by Erika Wieser since 2023), and — since the Sep 2023 cutover — carrying no dollars at all: every one of the 381 modern-era POs is $0.00, unapproved-or-approved-but-never-received, and has zero bills attached (all 137 bill-linked POs are legacy backfill). The modern PO is a pure requisition/reference token — "Erika wants a dishwasher from A&K" — and the money flows somewhere outside the PO record entirely. It's what the vendor needs to submit an invoice, which is why they chase it; but nothing in AppFolio closes the loop.
| Trade | PO shape | Example (verbatim) |
|---|---|---|
| Cleaning / turnover labor | Whole-property, by duration | "Clean House 7 days" — Metropolitan, PO 682 |
| Tub & tile refinishing | Per-unit (78 of 80 unit-specific) | "Resurface Tub" unit 308 — Miracle Method, PO 671 |
| Painting | Multi-unit batches | "Paint units/patch work" units 115, 120, 224 — HD Painting, PO 681 |
| Appliances | Per-unit | "New Dishwasher" unit 302 — A & K, PO 670 |
| Blinds | Per-unit | "New Blinds" unit 308 — Royal Textile, PO 684 |
| Supplies | Property-level runs | "Maintenance Supplies" — Home Depot, PO 685 |
Lifecycle is barely maintained — 618 Approved / 104 Pending; 71% never marked Received or Completed; the Instructions field holds the responsible person's first name ("Erika", "Joanna") and sometimes a check number. It's a paper process wearing software: on calls, the PM reads POs off a physical list. Since 2024, zero POs have bills recorded against them in AppFolio — invoice reconciliation happens outside the system too.
105 days of post-transfer transcript capture (Apr 13 – Jul 27), 58 vendor voice calls, all searched.
Every PO cited on a call exists in the mined corpus with matching vendor and job (613, 618, 644, 667, 682). The reverse is also clean: no vendor has ever cited a work-order ID in any recorded conversation. When Clara asks "what work order are you calling about?", vendors answer with a delivery, a unit, or a person's name. The mismatch is a measurable driver of the 66% transfer rate.
231 unit turns (AppFolio unit_turn_detail) joined against the PO corpus.
The PO lands mid-turn — issued around scheduling time, consistent with the calls. The exact medians are window-sensitive (13–17d overall depending on match window), but one year-effect is robust under every check: 2025 was a real operational slowdown — median turn duration tripled (15–17d → 46d; 44% of turns blew past 60 days against a 10-day target) on AppFolio's own turn records, independent of POs, with the same staff and PO volume. 2026 has recovered, though the figure is right-censored (25 of 38 turns still open). Why 2025 happened is a question for JP, not the data. Three honest caveats: 128 turns matched no unit-specific PO — partly attribution limits (multi-unit paint batches), partly because not all PO work is turnover work; the binder study found Metropolitan's day-count "Clean House" POs are a recurring building-janitorial contract (GL: Janitorial – Service Contract), not per-turnover cleans — they belong to a service period, never a turnover; and inspection lag is not computable — AppFolio's inspection report stores only each unit's latest inspection date, and our condition reports only reach back a few months. Third confirmation of the WO disconnect: AppFolio's own Unit Turn feature has a PurchaseOrdersFromWorkOrders column — null on all 238 rows.
Full-code audit. Class: what the vendor sees/says. Effort: S/M/L to carry a PO instead.
| Surface | Today | Under the PO reality | Effort |
|---|---|---|---|
| Outbound vendor voice (ADR-0111) | Carries no job number by design; scripted to refuse the PO question (vendor-outbound.ts:208) and an eval fails it for stating any PO | Add po_number dynamic var + prompt section; flip the eval to "answer with the supplied PO, never invent one". The single highest-value change | S–M |
| Vendor dispatch email | "Work order" row renders displayId; a code comment already calls the AppFolio PO # "the ideal future state" | One vendorFacingRef() resolver: PO when present, canonical WO format otherwise | M |
| Quote flow | QR-<displayId> is the live reply key; invoice parser discards the PO printed on vendor invoices | Keep the QR key (quotes precede POs); capture poNumber in parsing — free binding evidence | S |
| SMS / Telegram dispatch | "NEW WORK ORDER - CAM-604" headlines | Reference swap in headlines (mind the SMS truncation cap) | S–M |
| Inbound reply attribution | A bare number in vendor text binds as a work-order match at the strongest tier, high confidence | Live bug: 8 of 13 sampled CAM-xxx WO numbers collide with real PO numbers. "PO 598, when can we get in?" binds to WO CAM-598 and can relay to the wrong tenant. Fix ships now, pivot or not | S · ships first |
| Vendor chase email | Falls back to raw internal wo_… id in subject lines when displayId is missing | Live bug: internal ID leak to vendor inboxes; also breaks its own reply threading | S · ships first |
| Inbound voice ("which job?") | Phone-keyed session match — already reference-agnostic; triage phrase list only anticipates "work order X" | Add "calling about a PO / purchase order" as a vendor routing phrase + eval row (real corpus utterance) | S |
| Conversation labels / PM UI | regardingLabel = displayId everywhere | Append, don't replace: "CAM-604 · PO 512" | M |
Camellia proves POs; another property may genuinely run on work orders — AppFolio supports both and operators differ. The pivot is therefore not "switch WO→PO". It's:
The vendor-facing job reference is a per-property concept, resolved by one function at the existing chokepoints (dispatch email, voice dynamic vars, SMS headline, reply matching, conversation label). Two modes, because two have been observed in the wild:
purchase_order — Camellia's reality. Read model from the AppFolio purchase_order report (proven path, no browser automation), PropFlow-owned PO↔job binding, Clara answers "what's the PO?"work_order — today's behavior, unchanged, as the default.The over-engineering guard: the mode is data (a property-config field, inferable from observed PMS usage — jpco classifies itself: 722 POs, zero PO↔WO links), the flexibility lives in config, and no third mode is built until a third pattern is observed. Yardi has PO APIs too (GetPurchaseOrders), so the read model is PMS-agnostic in naming from day one, per the existing adapter rules.
| Tier | What | Nature |
|---|---|---|
| 0 — now | Fix the bare-number attribution collision · stop the wo_ ID leak in chase emails · route all vendor handlers through the one attribution function · capture PO numbers from parsed invoices/quotes | Bug fixes + free evidence, independent of the pivot |
| 1 — read model | PMS-agnostic listPurchaseOrders capability + DDB read-model rows + sync job + ADR-0116 (holds for Fede's review; PR #4720) | Additive, no live behavior change |
| 2 — binder | PO↔job binding on (vendor, unit, date) with PM confirmation, modeled 1:N | The novel piece — L effort |
| 3 — display | PO in review drawer, PM notices, vendor detail, conversation labels, dispatch surfaces | Additive, cheap, gated on 1–2 |
| 4 — payoff | Clara answers the PO question on calls; PO history feeds preferred-vendor ranking | Behavior change, gated on a disclosure decision (below) |
Full inspection of the turnover flow vs the PO-per-trade reality, verified against prod.
work_scheduled (unit 116) minted two real AppFolio WOs on Jun 29 — CAM-1259 (paint) and CAM-1260 (cleaning). Both are still open a month later: jpco staff run on POs and never touch AppFolio WOs. Because turnover completion is driven by AppFolio WO status (polled every 60s), the completion signal can never arrive — the cleaning-last hold never releases and the turnover is permanently stuck. The other 24 of 25 Camellia turnovers sit at notice_received with zero child WOs.
| Stage | Current behavior | Camellia PO reality | Effort |
|---|---|---|---|
| Scope vocabulary | 16 task types — no blinds, no whole-property work item | Royal Textile blinds = 4th-largest trade (49 POs); Metropolitan "Clean House N days" = whole-property, inexpressible per-unit | S / M |
| Work-item cardinality | One WO per scope line per unit, parallel fan-out, no batching primitive | One PO per vendor per batch — 59 POs span multiple units; PO 82 mixes per-unit + whole-property lines on one PO | L |
| Vendor reference | Dispatch email says "please reference CAM-1259… on your invoice"; the AppFolio WO id is a hard precondition for emailing a vendor at all | Vendors invoice against the PO; the WO number means nothing to them | S–M |
| Completion signal | AppFolio WO status is authoritative (ADR-0030), polled every 60s | jpco never closes AppFolio WOs (or marks POs Received) — signal must come from vendor reply / PM confirm instead | M |
| Actual cost | Reads the selected vendor quote | Modern POs are $0; the true dollar lands on the bill — no bill ingestion exists anywhere in the codebase | M |
| GL coding | None on the WO | Every PO line carries a GL; the vendor→GL map is ~95% deterministic (Miracle Method→Plumbing Repairs, HD Painting→Painting, Metropolitan→Janitorial Contract…) | S |
Minimal first slice (no write path): sync the purchase_order report into a read-model → auto-link PO lines to open turnover jobs on (vendor, unit, GL-trade, ±14d) with a manual override → show a PO chip on the turnover detail and return it from Clara's job-list tool so she can answer "what's the PO?" on a call → swap the dispatch-email reference to the PO when present. Erika keeps creating POs by hand; PropFlow just stops being blind to them. What genuinely needs the (deferred) browser write path: minting PO numbers and true batch POs.
Full inventory of every vendor/turnover harness, eval, and drift guard.
The structural irony: Clara currently has no sayable job identifier at all on vendor calls. The eval jargon-ban forbids her from saying "work order" to a vendor; the PO doesn't exist in the domain model. Meanwhile the shipped prompt scripts a punt for the exact question ("Do you have a PO number?" → "you don't carry one… flag it to the PM"), and the graders pass the punt: transferring a PO question to a human is currently rated GOOD. The pivot flips nine sites (prompt clause, two battery rules, three eval assertions, the dynamic-var key set, the judge's source-of-truth, reply attribution) and re-rates two corpus rows from GOOD to INSUFFICIENT — while every fabrication guard stays: Clara must never invent a PO; she must retrieve the real one, and the guard moves from regex-matching to provenance-checking.
The mis-binding bug is proven, not theoretical. A live probe against the real attribution code: "Okay, and I have the PO 644" → binds work order CAM-644 at high confidence; "Got it, the PO is 618" → binds unit 618's job at high confidence (PO 618 is actually unit 207's tub refinish). 59 integers are simultaneously a real Camellia unit number and a real PO number. An existing unit test encodes the bare-number binding as intended behavior, and four prior wrong-ticket incidents came from this exact shape on the outbound side. The fix + regression suite is ~half a day and is shipping now, independent of the pivot.
Sequencing rule worth keeping: the "PropFlow never mints a PO number" drift guard lands before the po_number variable exists — otherwise the cheapest way to green a red test is to synthesize a plausible integer, exactly the fabrication the guards exist to prevent. Rebuild plan: ~11 engineer-days across 10 ordered steps; scenario personas come from the three real PO vendors (Sylvia/Metropolitan, Anna/Miracle Method, Maddie/A&K) plus cross-cutting ambiguity cases (the 618 unit-vs-PO collision, ASR manglings like "$6.18" = PO 618). The honest baseline: the vendor-recognition replay across 64 real calls measured identified 3% · greeted 0% · bound 0% — recognition is arguably a bigger gap than the reference system, and both are needed.
Deep-dive results that refine the design. Full studies in ~/agents/006/po-training-data/.
On the modern era (2024+, the only slice with real creation dates — the 2022 series turned out to be a bulk backfill entered on four days in Oct 2022): auto-binding on (vendor, unit, ±7 days) has zero collisions across 252 unit-claims; the turnover anchor (unit + move-out window) is 99%+ unambiguous when a candidate exists. Widening to ±30d breaks real cases (same vendor, same unit, dishwasher then stove two weeks apart). The AppFolio work-order report is a dead end as a join target — 374 of 389 Camellia WOs carry no vendor and 96% are resident requests; ceiling ~5% coverage. After auto-suppressing supply runs with no unit and the Metropolitan janitorial-contract POs, the genuine PM-confirmation residue is roughly 1–2 POs per month. Honest limit: there is no ground-truth label anywhere (every PO↔WO field in AppFolio is null), so these are ambiguity measurements, not accuracy against labels.
Erika creates 99% of modern POs in AppFolio; Joanna reads them to vendors off a paper list; the Instructions field records the requester (Joanna 176 / Erika 173). Two rules the design must honor: the PO never precedes the booking (twice it didn't exist yet at booking time), and it gates the invoice, not the work — vendors start jobs without it, which is exactly why it silently rots until an invoice is blocked (PO 618 sat 9 days until the vendor chased it). The office has never once volunteered a PO unprompted in any recorded call — so proactive delivery of a fresh PO to the waiting vendor would beat human parity, not just match it. Twelve parity behaviors were specified, dependency-tagged: five buildable today (intent recognition, digit-by-digit read-back — ASR already renders PO 618 as "$6.18" — tracked promises, scheduling provenance, post-transfer silence), four blocked on the read-model, three on issuance. One corpus correction: the 05/06 call rated "fabricated unit 207 + PO promise" is partially corroborated by AppFolio (PO 618, unit 207, created that day) — re-examine before it trains anything.
All 32 PO vendors resolve cleanly to prod VendorCompany rows (zero name drift — never build name matching). But 26 of 32, covering 77% of PO volume, have no phone or email on file — including Metropolitan, A&K, Home Depot, Royal Textile, Bomar. Worse: the real caller-ids of Miracle Method and A&K exist in prod as orphan Persons with no vendor membership — two disconnected halves of the same vendor. Six of Camellia's 13 preferred-vendor trades are unconfirmed email_mining entries that resolve to empty — despite the mined vendor being right 9 times out of 10 against revealed PO preference. One PM confirmation pass is the single highest-leverage unblock. Also: GL defaults must key on vendor, not trade (Miracle Method is refinishing but books 90% to Plumbing Repairs), and the trade vocabulary lacks glass, signage, doors, and any way to say "supply house, don't dispatch" — which is 31% of POs.
The per-property mode is a capability, not a preference: AppFolio has POs read-only (Plus/Max API tier only); Yardi has read and write but behind a separately-licensed module (~$25K/yr) and with no PO↔WO link at all (the join only materializes on bill lines after invoicing); RealPage's AppPartner surface and Buildium have no PO API whatsoever. Consequences already reflected in the design: the mode selector needs a per-module capability probe; PropFlow must own the PO↔job binding on every PMS (it's optional-and-unused on AppFolio, impossible on Yardi); purchaseOrderNumber is a mutable, human-facing string (AppFolio allows manual renumbering; Yardi has three number fields) and storage must key on externalPurchaseOrderId. And resolution on a live call needs a PO-indexed cache, not an on-demand report scan.
There was no 2023 volume dip — throughput has been flat ~10–11 POs/month since 2021. What changed in a clean 3-day handoff in Sep 2023: the PO stopped being a financial document (legacy: 100% approved, priced, 62% received, 40% billed) and became a live requisition log (modern: $0, never received, never billed, 27% still Pending approval). Ten questions the data can't answer are queued for JP — the top one: is the modern $0/no-bill pattern deliberate ("PO = notification token") or process drift? The answer decides whether PropFlow should fill those fields or ignore them.
Not just phases: a Mon→Fri plan that finishes with Clara answering PO questions on real Camellia calls. Effort figures come from the code audits; the fleet's Tier-0 PRs are already in flight, so the week starts from a fixed foundation.
| Day | Build | Exit criteria (verified, not assumed) |
|---|---|---|
| Mon | PO read-model: listPurchaseOrders capability + DDB entity + sync job (piggybacks the 60s appfolio-sync lambda) + the reference-mode property field. ADR review with Fede (30 min: disclosure bar, mode-field home, staleness budget) — unblocks everything downstream. | All 5 transcript POs (613/618/644/667/682) resolve from prod via the new read path; sync survives a full Camellia backfill; ADR status agreed. |
| Tue | PO↔job binder (vendor+unit+GL-trade+±14d, PM-confirm residue in the review queue) + turnover detail PO chip + blinds trade + vendor→GL default table. | Binder precision on the historical corpus ≥ the measured baseline; unit-116's real POs bind to the right jobs; PM can confirm/override in the UI. |
| Wed | Clara surfaces: po_number dynamic var + vendor-outbound prompt flip (answer, never invent) + inbound "calling about a PO" triage phrase + ElevenLabs sync + dispatch-email reference swap (PO when present). | Eval suite green with flipped assertions; bench call: robo-vendor asks "do you have the PO?" and Clara answers the real number with provenance in the trace. |
| Thu | Harness rebuild: three real personas + ambiguity/ASR scenarios + golden-dataset regression set (~15 core rows) + provenance-based fabrication guard. Camellia completion-signal decouple (vendor-reply/PM-confirm drives workOrderCompleted) so the unit-116 wedge class dies. | Two consecutive clean battery sweeps; the 618 unit-vs-PO collision scenario passes; unit-116 turnover unwedges in prod. |
| Fri | Proof loop + arm: prod E2E (real inbound test call from a fresh number asking for a PO), Sentry sweep on the window, prod QA screenshots, then flip Property.vendorReferenceMode = purchase_order for Camellia and monitor live calls. | Two clean E2E sweeps; Sentry window clean; the next real Sylvia/Anna/Maddie call gets a correct PO answer or a correct "not issued yet — I'll flag it" — measured from the live transcript. |
Deliberately NOT in the week: PO minting (needs the unbuilt browser write path — Clara relays existing POs only), batch-PO turnover restructuring (the L), and AppFolio bill ingestion (empty for the current era). The scope is: PropFlow stops being blind to POs, and Clara reaches parity with Joanna's read-and-relay job.
A 19-agent fleet is extending this document. Landing here as they complete:
Corpus + intermediates: ~/agents/006/po-training-data/ (722 POs, transcripts, lag joins, golden dataset). Investigation date: 2026-07-27.