0116 — The vendor-facing job reference is a per-property configuration
- Status: Accepted — implemented (§6 item 3 still open); status confirmed by Fede 2026-07-30
- Date: 2026-07-27
- Deciders: Fede + team
- Related: ADR-0030 (PMS owns
work-order identifiers — PropFlow never mints one); ADR-0031
(local-first work orders; PropFlow-minted
WorkOrder.id, PMS id demoted topmsId); ADR-0033 (VendorCompany / VendorMembership);0060-preferred-vendors.md(preferred-vendor roster + resolver — cited by exact filename; three ADR files share the 0060 prefix); ADR-0111 (outbound vendor-calling agent); ADR-0113 (vendor communications — inbound lane);docs/architecture/PMS_INTEGRATION.md§2 (pattern vocabulary);docs/vendor-coordination/PLAN-2026-07.md(roadmap of record);docs/vendor-coordination/camellia-vendor-corpus.md(the rated corpus).
This ADR carries both the decision (§§ Context–Decision) and the implementation specification (§§ Schema–Rollout) as a single document.
Context
Every vendor-facing surface PropFlow has — dispatch email, outbound-call dynamic variables, SMS/Telegram headlines, reply attribution, conversation labels — needs one answer to a single question: what number does this vendor recognize this job by? Today the answer is hardcoded to the work order, and at our only real production property that answer is wrong.
Two modes are observable in the wild
Mode purchase_order (Camellia / jpco). The PM issues an AppFolio purchase order — a bare
sequential integer, no prefix — and that PO is the number the vendor is given at scheduling time
and the number the vendor puts on the invoice. It is the reference that spans the whole
engagement: quote → scheduling → work → invoice → payment.
Mode work_order (today's default). The work order is the vendor-facing reference. This is
what every PropFlow surface currently emits, and it matches Sean's prior ruling: "go with whatever
the PMS uses." That ruling is the right principle — it just does not resolve to "work order" at
every property, because it did not resolve to "work order" at Camellia.
The evidence
| Signal | Value | What it says |
|---|---|---|
| POs mined from the jpco AppFolio account | 722 | The PO surface is real, populated, and operationally load-bearing. |
| Of those, POs linked to a work order | 0 | The PO and WO number spaces are not connected in the PMS. A WO-keyed vendor reference cannot be derived from a PO, or vice versa. |
| Vendor voice calls citing a WO id, across 105 days of Camellia calls | 0 | Vendors do not know our work-order numbers. Handing them one is handing them a number they cannot use. |
| Departure → PO lag | ~13 days median after the tenant's recorded move-out date (departure-po-lag.json: n=103; 19/103 precede the move-out)[^lag] |
The PO is typically issued well after the departure that triggers the turn. Any binding must tolerate a job existing for days with no PO yet, and must survive the PO arriving late. |
| PO-aware code paths in PropFlow today | 0 | Nothing reads, writes, renders, or reasons about a PO. This is greenfield, not a migration. |
Primary source, added 2026-07-28 — the PM confirmed the sequence directly. Everything above is
mined evidence; this is Camellia's PM stating her own process. Joanna Wieser (Camellia on-site PM,
jpco — surname per the Person spine, docs/planning/org-jpco-split/halt-review.md), by text to
Fede, asked whether the PO is cut before or after the job is confirmed with a vendor:
"So we get the po first because that's how we confirm the job with the vendors they need a po before we can do anything" — and, to "so see the unit, put in PO in appfolio then call?" — "Yep!"
The confirmed sequence is see the unit → mint the PO in AppFolio → call the vendor. That
confirmation upgrades an inference to a fact: PO-creation dates sat same-day or one-day-before the
call on which the number was spoken, but CreatedAt is date-granular and could not order them. It
also means the 60-min/24-h sync budget below is comfortable by construction — a PO exists
before the PM ever dials, so the read model is never racing a vendor callback. Approval timing is
not part of what she confirmed — it is inferred from PO status data; asked whether accounting's
approval comes after the final invoice or sooner, she answered "I'm not sure honestly" (SMS
2026-07-28 14:07). Full exchange and what it confirms: the Joanna spec
(joanna-po-workflow-spec.md, same audit corpus as the footnote below), §10.
The corpus behind the call statistic is docs/vendor-coordination/camellia-vendor-corpus.md (49
rated interactions; 46 genuine vendor voice calls). ADR-0113 independently measured only 13 of
Camellia's 562 work orders carrying a vendor at all — the WO is a weak spine on the vendor side
even where it exists, which is why PLAN-2026-07 already anchors vendor coordination on
property + unit + job with the work order optional. This ADR is the identifier half of that
same anchoring decision.
Why now
Phase B of PLAN-2026-07 (outbound placement, ADR-0111) is the first surface that says a number out loud to a vendor. Getting the reference wrong on an email is embarrassing; getting it wrong on a call is a vendor who cannot find the job. The chokepoints that would consume this resolver already exist and are few — this is cheap now and expensive after four more surfaces are built.
Decision
1. The vendor-facing job reference is per-property configuration
Add a property-level configuration field selecting the mode:
work_order— the vendor-facing reference is the work-order identifier (today's behavior, and the default for any property with no configuration).purchase_order— the vendor-facing reference is the PMS purchase-order number.
The field is Property.vendorJobReferenceMode?: 'work_order' | 'purchase_order', top-level on
Property, adjacent to pmsSource — not PropertyMaintenanceSettings (an extra fetch on the
call-init render path) and not PropertyKnowledge (wrong provenance class). Absent means
work_order, so no migration is needed for existing properties.
Two flags, not conflated: PMSCapability 'purchaseOrders' says whether the PMS adapter can read
POs (AppFolio only, today); vendorJobReferenceMode says what the vendor is told. Setting
purchase_order on a property whose PMS lacks the capability fails loud at save time.
Set manually at onboarding, with an auto-suggestion derived from observed PMS data. The suggestion is advisory and always PM-confirmed; PropFlow never silently flips a property's mode. jpco self-classifies cleanly on the observed shape — 722 POs, 0 PO↔WO links — which is exactly the signal the suggester keys on. The suggester's precise rule and thresholds are implementation detail, not decided here.
No third mode until one is observed in the wild. Invoice number, job number, quote number and "the vendor uses our address" are all plausible; none is evidenced. Adding a mode requires a property that demonstrably uses it.
2. One resolver: vendorFacingRef()
Every vendor-facing surface calls one function. It takes the job (property + unit + job, per PLAN-2026-07's anchor) and returns the reference to render, or nothing when none exists yet.
Consumed at the existing chokepoints — this ADR adds no new surfaces:
- vendor dispatch email (ADR-0059 lane)
- voice dynamic variables at call start (ADR-0111 §1 injection)
- SMS / Telegram headlines
- reply attribution (matching an inbound vendor reply back to a job)
- conversation labels
A vendor-facing surface that renders a job identifier without going through vendorFacingRef()
is a violation of this ADR and should be caught by a drift guard.
2a. The one bounded exception: the outbound scheduling call (added 2026-07-28)
resolveOutboundPoForCall() (src/lib/domain/vendors/outbound-po-resolution.ts) resolves a PO by
a second rule after vendorFacingRef() returns none, and speaks the result. That is a real
deviation from the "one resolver" rule above, and it is recorded here rather than left to a PR
description, because the next reader reasoning about where a spoken number came from must find the
answer in this ADR.
What it does. It calls vendorFacingRef() FIRST and always defers to a confirmed binding. Only
when that yields nothing does it look for exactly one open PO for this vendor, at this property,
on this unit. Two candidates, or zero, resolve to nothing and the call is held.
Why it is bounded rather than a fifth branch. Every guardrail that makes a binding safe is still applied, at the read-model seam rather than in a second copy of the rules:
- The 24h render budget still applies (
freshOnly). Without it this path could re-speak the very rowresolvePoForJobhad just suppressed as stale — a stale row'sisCancelled/isReceivedflags are only as current as the last successful sync, which is exactly what the budget distrusts. - The unambiguity test runs over the whole candidate set, not a fetched window. The vendor GSI is oldest-first, so matching inside a limit would mean "one match among the N oldest" and could speak a year-old PO instead of the one cut this week.
- It renders nothing itself. The
POlabel still comes frompurchaseOrderLabel(), so there is still one formatter.
Why the outbound call earns it. §2's rule was written for surfaces that reach a vendor who ALREADY holds the job and needs only to recognize it. An outbound scheduling call is the one surface that asks a vendor to commit to a date, and per the PM confirmation in §Context that ask must not happen before the PO exists. Under a binding-only rule, a PO that plainly exists in the PMS — same vendor, same unit, same property, open and fresh — would still hold the call, because nothing had yet bound it to our work-order row.
Scope is the safety. This lives in its own module used by that one lane. The dispatch email, SMS, the inbound lane, reply attribution and the PM surfaces keep binding-only behavior, unchanged.
Revisit. Fede's 2026-07-28 ruling is that the exact-match widening stands. If a binding-only
posture is preferred later, the revert is a one-line delete of step 2 in resolveOutboundPoForCall;
the bench proof then needs a seeded confirmed binding instead of the vendor+unit match.
The resolver must return "no reference yet" gracefully. With a multi-day departure→PO lag, a
purchase_order property will routinely have live jobs with no PO. The vendor-facing copy in
that state describes the job (property, unit, scope) rather than inventing or falling back to a
number the vendor does not recognize.
3. A PMS-agnostic PurchaseOrder read model
Per PMS_INTEGRATION.md §2, this is the Materialized View / Read Model pattern under CQRS:
a read-only local projection of PMS-owned data, fed by an adapter, never a source of truth.
- AppFolio: proven today via the Reports API v1
purchase_orderreport (the 722 POs were mined through it). - Yardi: a
GetPurchaseOrdersoperation exists; adapter not built, shape not validated here. - The domain sees a PropFlow-shaped
PurchaseOrder, never PMS JSON — Anti-Corruption Layer at the adapter boundary, per §2.
4. PropFlow owns the PO↔job binding
The PMS does not link POs to work orders (0 of 722). The binding is therefore ours:
- Heuristic proposal on vendor + unit + date proximity (algorithm in §Binder below).
- PM-confirmed before it is treated as truth. An unconfirmed binding never drives a
vendor-facing render. No auto-confirm in v1 — at ~23 POs/month this is roughly one confirm
card per workday, and the PO number is the highest-blast-radius artifact we hold (it is spoken
to a vendor);
Property.poBindingAutoConfirmEnabledships disarmed. - 1:N — one PO may cover several jobs (the mined Camellia shape includes multi-unit work such
as a single refinish PO spanning two units) — and 1:0: a whole-property PO with no matchable
job is a normal, observed state, recorded against a
PROPERTYsentinel rather than forced onto the wrong job.
The binding is a PropFlow-owned annotation on top of PMS-owned records — the same posture ADR-0113 item 7 takes for WO↔vendor assignment.
5. Explicitly out of scope
- Minting or writing purchase orders. There is no AppFolio API for PO creation, and the browser-automation (L4) path for it is an L — high effort, low reliability, high blast radius. More importantly, ADR-0030's principle extends directly: PropFlow never mints a PO number. ADR-0031 moved the work order to a local-first identifier for latency and PMS-outage reasons that do not apply here — a PO is a financial-control artifact the PM issues, its number carries accounting meaning we do not own, and there is no tenant-latency argument for minting one. POs are read-only in PropFlow. (Full non-goals list in §Non-goals.)
- A third mode (see §1).
- Retrofitting historical jobs with PO bindings. Forward-looking only unless a PM asks.
6. The outbound posture at purchase_order properties
Items 1 and 2 were DECIDED and SHIPPED 2026-07-28 (PR #4782), on Joanna's restatement of the rule — "they need a PO before we can do anything." Item 3 remains future work and still carries its hard dependency. The implementation notes are in §2a above and the deviation it introduces is recorded there.
Gate the dial. — SHIPPED. Before an outbound vendor call at a
purchase_orderproperty, check the PO read model. No PO on file → do not dial; prompt the PM to mint one in AppFolio first. Dialing without a PO is off-process per the PM ("they need a po before we can do anything").Lead with the number. — AMENDED 2026-07-29 (PR #4855): the number is still spoken early, but it no longer rides the opener. Fede heard the PO-leading first message on a live connected call (
conv_5401kyq7xcr2e6yvcnh6ghp8dc1a) and rejected it — "way too eager… a rant." It rendered ~33 words and also produced a duplicated-unit defect ("…in unit 201 at unit 201"). Founder ruling 1 keeps the PO spoken on the call, plainly and early, but places it in the reason-for-call turn, after the handshake, not in the first breath. The first message is now self-ID plus one business identity check — identical in both lanes — and the job moves into the prompt as a goal. Read the rest of this item as history, not as current behavior.(Superseded text: the shipped opener verified who picked up first, then handed the number over — "Hi, is this Miracle Method? It's Clara, calling for Priya at JP & Co. — I've got PO 90760 for you, the tub resurface at unit 101, hoping to get it on your schedule.")
What survives unchanged is the reason the number is spoken at all: the PO is how the vendor confirms the job and what their back office keys on (of 33 sampled vendor messages, 21 attachments carry a PO field; 17 populated with a Camellia PO —
vendor-emails/README.mdin the audit corpus[^lag]), and it never travels without its unit + trade descriptor. Identity still comes before any job detail, because the prompt forbids describing a job to an unconfirmed business. Inbound stays asymmetric: a vendor calling about a job with no PO yet is normal and "no PO yet" is an honest answer; an outbound-initiated job with no PO is a process smell to flag.Stop minting AppFolio work orders for turnover vendor work at these properties. — STILL OPEN. Turnover flows still mint AppFolio WOs unconditionally —
src/lib/maintenance/work-orders/dispatch.tscallswriter.createWorkOrder()with no reference tovendorJobReferenceMode. PR #4745 made onlyisExternallyDispatchablemode-aware (it dropped thepmsIdprecondition atpurchase_orderproperties); the minting path itself was left untouched. Those minted WOs are precisely the artifact jpco ignores, and waiting on their status is the 24-of-25 stuck-turnover wedge. Target state: the internal work item stays the tracking spine, completion arrives from vendor reply or PM confirmation, and the PM's PO is the only AppFolio artifact — which is what Joanna described. Hard dependency: cost attribution (buildWoActualCentsMap) keys on minted WOs, so the PO↔invoice lane or binder-based spend attribution must land before minting is switched off, or spend goes dark.
Entity classification
| Entity | Class | Naming | Spine trace (canonical) OR derived-from / rebuilt-by / drift-tolerance (derived) |
|---|---|---|---|
PurchaseOrder |
derived (read model) | bare name — it mirrors a PMS-owned record 1:1, and a *ReadModel suffix would leak the mechanism into a name PMs read |
from: PMS purchase-order records (AppFolio Reports API v1 purchase_order; Yardi GetPurchaseOrders when built) · Rebuilt by: the PMS sync path — fully rebuildable by blind put; nothing PropFlow-authored lives on the row beyond deterministic resolved* match-backs · Drift tolerance: split budget — 60-min discovery, 24-h row (§Staleness) |
PurchaseOrderJobBinding |
canonical (PropFlow-authored) | bare name | Spine trace: via the job's property/unit and the vendor's personId through VendorMembership (ADR-0033). PropFlow authored it — it exists nowhere in the PMS — so it is not rebuildable from any external source. |
Implementation specification
Schema (DynamoDB single-table, no new GSI)
PurchaseOrder — derived read model
PK = PROP#<propertyId>
SK = PO#<externalPurchaseOrderId>
GSI1PK = VENDORPO#<vendorCompanyId> // listOpenPosForVendor (bare VENDOR# would collide with memberships)
GSI1SK = PO#<createdAt>#<externalPurchaseOrderId>
GSI2PK = PONUM#<organizationId>#<purchaseOrderNumber> // org-scoped — a bare integer is only unique per AppFolio database
GSI2SK = PO#<propertyId>#<externalPurchaseOrderId>
Fields: externalPurchaseOrderId (opaque key, never rendered) vs purchaseOrderNumber
(vendor-facing STRING, never parsed/keyed/sorted); pmsType, pmsIdentity; ACL-translated
facts (externalVendorId, unitRefs[] — empty means whole-property, isWholeProperty, trade
from the adapter's GL classifier, instructions, createdAt, totalCents, lines[],
lineCount); deterministic match-backs resolvedVendorCompanyId / resolvedUnitIds (rebuildable,
named resolved* — precedent: syncPropertyWorkOrders vendor-id match-back); staleness stamps
cachedAt, staleAfter (+24 h), syncRunId, contentHash (sha256, 16-char slice);
removedInPms?, removedAt?.
No bindingIds[] on the row — PO→bindings is an SK-prefix Query, which keeps the row
rebuildable by blind put.
PurchaseOrderJobBinding — canonical, PropFlow-authored
PK = PROP#<propertyId>
SK = POBIND#<externalPurchaseOrderId>#<workOrderId | 'PROPERTY'>
GSI1PK = POBINDWO#<workOrderId> // resolvePoForJob hot path
GSI1SK = POBIND#<status>#<externalPurchaseOrderId>
Fields: status: proposed | confirmed | rejected | superseded; tier: high | medium (low never
writes a row); score; signals[] (per-signal breakdown — renders as the confirm card's "why");
bindingGroup? / siblingCount? (a multi-unit batch is one confirm-all card); poContentHash,
needsReconfirm?; proposed/confirmed/rejected/superseded stamps + actor; capped transitions[]
(10). Deliberate deviation from the saga pattern: an inline capped trail, no saga — a binding is
an annotation, not a workflow.
Sync watermark
PK=PROP#<id> SK=POSYNC#WATERMARK — {lastSuccessAt, lastRunId, rowCount, lastError?, consecutiveFailures}. This is what alerts the 60-min discovery budget: the absence of rows is
invisible to per-row staleness.
Staleness budgets
| Use | Max staleness | On stale |
|---|---|---|
| PO discovery (PMS→PropFlow) | 60 min | pms_po_sync_stale warn + Sentry + property-panel banner. Never silent. |
| Vendor-facing render (email / voice vars / SMS) | 24 h, cache-only | no inline refetch (it would blow the 10-second call-init budget); past 24 h suppress the number and fall back to the job description. |
| Reply attribution ("re: 4471") | 24 h, cache-only | on miss → no attribution, never a guess (posture shipped in PR #4710). |
| PM queue / PO list UI | 1 h | render stale, refresh in background. |
| Binder run | fresh by construction (same sync tick). |
Rationale for the split: the ~13-day lag is about issuance, not about how fast we must see a PO once issued. A PO cut Monday 9am must not be invisible through Monday's vendor calls. And a PO number is immutable once issued — the only failure mode is absence, so the row budget can be loose while the discovery budget stays tight.
Sync design
- Adapter ACL:
listPurchaseOrders?(params): AsyncIterable<PMSPurchaseOrder>onPMSClient— yields one row per PO (the adapter regroups line items; precedentlistLeaseStates); GL→canonical trade classification lives in the adapter; AppFolio'sWorkOrderscolumn is read and discarded (null on all observed jpco rows — a permanently-null domain field invites branch-on-which-id). NewPMSCapability 'purchaseOrders'(AppFolio only). - Windowing: full refresh of a rolling 18-month
from_datewindow (noupdated_atcursor exists in the report; ~415 POs steady-state), diffed oncontentHash— steady-state zero writes. - Runner/writer:
src/lib/pms/run-purchase-order-sync.ts+src/lib/domain/pms/writers/purchase-order.ts(dependency-injected, mirrors work-order sync). - Cadence:
rate(15 minutes)(deviation from WO sync's 60 s — a PO is issued by a human days after the job; 4× headroom on the 60-min budget at 1/15th the API cost). NewPMSSyncJob 'purchase_orders'+ EventBridge rule in deploy.sh. - Gating in the runner:
if (!isPurchaseOrderModeEnabled(property)) return null— eval, manual, and prod paths gated identically; non-Camellia properties cost zero. - Failure posture: per-property try/catch → JobResult; zero-row completeness guard (0 rows
where ≥1 existed → throw
pms_po_empty_fetch, never mass-remove); vanished-from-a-non-empty fetch →removedInPms: true, never hard delete; 4 consecutive failures → budget-breach alert. ~96 report calls/day for one property.
Binder algorithm
src/lib/domain/vendors/po-job-binder.ts, phase 2 of the sync tick. No LLM in the default
path.
- Candidates: open + recently-closed WOs with
wo.createdAt ∈ [po.createdAt − 45d, +7d](asymmetric — the PO lags the job). - Scoring: vendorMatch +50 exact / +20 when the WO has no assigned vendor (a different
vendor drops the candidate rather than penalizing it); unitMatch +30; dateProximity 0–15 peaked
at 13 days (
15·max(0, 1−|Δd−13|/21)); tradeMatch +15 (reuses theresolvePreferredVendorstrade vocabulary); instructionsMatch 0–15 (reuses theresolve-scope-vendorstoken-set matcher); competingPoPenalty −40. - Tiers on the top candidate WITH margin: high = score ≥80, margin ≥25, exactly one candidate ≥60 → proposed, one-click confirm. medium = 55–79 or thin margin → top-3 side-by-side. low <55 → no row; the PO lands in "Unbound" with a manual picker.
- Confirm loop: only
confirmeddrives vendor-facing renders. A rejection is sticky at an unchanged hash (renewal-external-events dismissal semantics). Confirmed + changed PO hash →needsReconfirm, stays confirmed — the number was already spoken to a vendor; a silent unbind mid-engagement is worse than a flagged stale one. Proposed + changed hash → rescore. - 1:N: one row per matched unit's job sharing
bindingGroup; confirm-all writes the batch; each row independently valid. 1:0: thePROPERTYsentinel; the panel shows "property-wide — not job-bound". - Unbind: → rejected + reason, never deleted. Rebind: the prior confirmed row → superseded.
Calibration note: the date-proximity peak (13 d) is the historical pooled median from
departure-po-lag.json, but the corpus shows the lag regime shifting — the per-year series
2 → 9.5 → 17 → 44 → 23 days across 2022→2026 comes from anomaly-study/q2_lag_by_year.csv, a
different derivation (see the footnote on the evidence table); the 2026 value sits below the
2025 peak, but 2026 is a partial year and a lag statistic is right-censored on one — long lags
from recent move-outs cannot have resolved yet, so the low value is as consistent with censoring
as with a real reversal. Read it as "the peak may be softening," not as a confirmed turn, and
re-derive on a complete year before re-centering. The corpus test below is what holds the binder
honest; if precision floors fail on recent-regime data, widen or re-center the date curve there —
the constant is tuning, not architecture.
[^lag]: Two non-reconcilable lag derivations exist; cite each figure by filename.
departure-po-lag.json — n=103, pooled median 13 days (move-out → PO), 19/103 negative
(PO precedes the move-out). anomaly-study/q2_turn_po_lag.csv — n=117, pooled median 11
days, with the per-year series in anomaly-study/q2_lag_by_year.csv (2022:2, 2023:9.5,
2024:17, 2025:44, 2026:23 — the 2026 value is a partial year, right-censored; see the
calibration note). The two sets do not reconcile year-over-year. This ADR's
evidence table and the binder's date-proximity peak use the departure-po-lag.json pooled 13;
the per-year series quoted in the calibration note is from the anomaly-study set and must not
be presented as the same measurement. These files — and vendor-emails/README.md, the
invoice-field sample cited in §6 — live in the vendor-calling audit corpus at
~/agents/006/po-training-data/, not in this repo.
Read API surface
dynamo/purchase-orders.ts: list/get,findPurchaseOrdersByNumber(orgId, number)→ array by design (forces callers to face ambiguity),listPurchaseOrdersByVendor, upsert + markRemoved (writer-only), watermark get/put.dynamo/purchase-order-bindings.ts: list by PO / by WO / proposed queue; propose/confirm/reject/supersede.- Domain:
resolvePoForJob(workOrderId)— returns ONLY from a confirmed binding, non-removed, within the 24-hstaleAfter, mode =purchase_order; consumed byvendorFacingRef(), never rendered directly.listOpenPosForVendor(vendorCompanyId).isPurchaseOrderModeEnabled(property). - Clara: NO new voice tool — the PO reaches the agent as a call-start dynamic variable (a
mid-call lookup is precisely the corpus's worst failure surface). The ops catalog gains a
read-only
lookup_purchase_order(dashboard-only; thevoicefield is omitted so the projection test keeps it off ElevenLabs).
UI touchpoints (arsenal primitives only)
/properties/[id]"Vendor job reference" card: mode selector + advisory auto-suggest chip + sync-health strip; a mode change is a confirm dialog.- PM confirm queue on
/maintenance/costs(~1 card/day doesn't warrant a new route): Needs-confirmation (labeled "PO 4471", the signal breakdown as the "why") / Unbound (manual picker) / Bound (unbind + needsReconfirm badge); a batch is one card confirming N rows. - WO detail: "Work order CAM-604 · PO 4471" — both numbers, both labeled (ADR-0031's surface-both-label-both posture); inline bind/unbind.
- Vendor detail: "Open POs" for call prep.
- Empty states say "No PO issued yet." — a normal state for days after job creation; never blank, never a silent WO fallback.
Test strategy
- Adapter/ACL tests on a committed scrubbed jpco fixture: regrouping counts;
WorkOrders: null→ no binding influence; multi-unit; shape assert = no AppFolio column name appears on yielded objects. - Writer: hash-stable no-op re-run; zero-row fetch throws; removal sets the flag, never deletes.
po-binder-corpus.test.ts— load-bearing: a hand-labeled fixture from the mined PO rows + Camellia WOs; pinned floors high-tier precision ≥ 0.98, coverage ≥ 0.85. Any precision claim must be a test output, not a doc claim.- Decision-stickiness tests: confirmed survives re-observe; rejected is not re-proposed at the same hash; changed hash → needsReconfirm.
- Drift guards:
po-read-only.drift.test.ts(no PO write anywhere + no PropFlow-authored field on the read-model row — aligns with, doesn't duplicate, the no-minted-PO-numbers guard); an extendedvendorFacingRefguard (no template readspurchaseOrderNumberdirectly); a lambda deploy path-filter assert. - Evals pin both directions: a
work_orderproperty never emits a PO; apurchase_orderproperty never emits a WO number; the no-PO case → "I don't have a PO number for that yet", never an invented one. - A read canary in
pms-sync-eval— but the bench property has no POs: point it at jpco or skip with an explicit logged reason (a silently-green canary is how the July nightly harness failed).
Non-goals
- Any write path, ever (no PO create/mutate/cancel/mint; no L4 route). 2. Yardi/RealPage adapters. 3. A third mode. 4. Historical binding backfill. 5. PO→invoice/AP reconciliation.
- Auto-confirm in v1. 7. LLM in the binder default path. 8. Changes to
WorkOrder.id/displayId(ADR-0031 untouched). 9. Cross-property PO-number uniqueness.
Rollout — three PRs (~7–8.5 days total)
- PR 1 (2.5–3 d): types + capability + AppFolio adapter (regroup + GL classifier) + dynamo
module + watermark + writer + runner +
vendorJobReferenceMode+ save-time validation + lambda job/rule + fixtures/writer tests + read-only drift guard. Zero behavior change until Camellia's mode flips. - PR 2 (2–2.5 d): binder + bindings module + confirm/reject/supersede APIs + phase-2 wiring + corpus test with pinned precision floors + stickiness tests. Depends on PR 1.
- PR 3 (2.5–3 d): resolvers +
vendorFacingRef()wiring +lookup_purchase_orderops tool + the four UI touchpoints + both-direction eval variants + extended drift guard. - Then: flip Camellia's mode and supervise the confirm queue for roughly half a day before any vendor-facing surface reads a PO.
Critical files: src/lib/domain/pms/client.ts · src/lib/data/dynamo/renewal-external-events.ts
· src/lib/pms/run-work-order-sync.ts · lambda/appfolio-sync/handler.ts ·
src/lib/data/dynamo/helpers.ts
Consequences
Easier
- One place to change what a vendor is told a job is called. New vendor surfaces get it for free.
- Camellia's vendors get the number they actually use, at scheduling and at invoicing.
- Onboarding a property with a different convention is a config decision, not a code change.
- The PO read model makes 722 previously invisible records queryable for reply attribution, invoice matching, and PM-facing context.
Harder
- Reply-attribution number ambiguity. Both POs and work orders can appear as bare integers. An inbound "re: 4471" is ambiguous between number spaces, and a wrong match attributes a vendor reply to the wrong job. The first fix has shipped — PR #4710: a PO number no longer binds a vendor reply to the wrong work order. Residual ambiguity is resolved conservatively: prefer no attribution over a wrong one (§Staleness, reply-attribution row).
- Two number spaces to keep straight internally. Operator UI must be explicit about which is
which; ADR-0031 already established the "surface both, label both" posture for
idvspmsId, and the same discipline applies to the PO. - A binding surface to maintain. Heuristic + confirmation UI + the 1:N and 1:0 cases, plus the "job exists, PO does not yet" state that is normal for days.
- A second PMS adapter method to build per PMS. Yardi's
GetPurchaseOrdersis unvalidated; a PMS with no PO concept at all forceswork_ordermode by construction.
Implies
- A drift guard asserting vendor-facing renders route through
vendorFacingRef(). - Vendor-lane evals gain a
purchase_order-mode variant (ADR-0113's eval discipline: pin both directions — awork_orderproperty must never emit a PO, and vice versa). - ADR-0111's dial-time context payload gains the resolver's output rather than a WO id.
- No change to ADR-0031's local-first work-order identifier. This decision governs what vendors are told, not what PropFlow stores.
Alternatives considered
Hardcode purchase_order for Camellia. Fastest path to correct behavior at the one property
that matters today. Rejected: it violates the standing rule against hardcoded per-client values,
and the second property with the same convention re-opens the same work — for less than a day's
savings.
A global switch (org-wide or product-wide mode). Rejected by the evidence and by the shape of the problem: the convention is a property-operations fact (who issues the number, when, and what the vendor invoices against), and PLAN-2026-07 explicitly designs for the hybrid reality where some properties are paper-based, some run a PMS with no work-order module, and some are institutional. A global switch is wrong at the first mixed portfolio.
A full PMS-identifier abstraction framework — a general "reference kind" registry with pluggable resolvers, precedence chains, per-channel overrides. Rejected as over-engineering: two observed modes, one resolver, five call sites. Build the framework when a third mode is observed, not before.
Keep work-order-only. Rejected by evidence, not by preference: 0 of 722 Camellia POs link to
a work order, and 0 vendor calls across 105 days cited a work-order id. Continuing to hand vendors
a WO number at a purchase_order property means handing them a number they have never used.
Auto-detect per job at render time (look for a PO; fall back to the WO). Rejected: the issuance lag makes the same job render as a WO early and a PO later — the vendor sees two different numbers for one engagement, which is precisely the "consumers branch on which id is present" failure both ADR-0030 and ADR-0031 rejected.
Open decision points
- Disclosure for self-claimed callers. When an inbound caller self-identifies against a job reference ("calling about 4471") without independent verification, how much job detail may Clara disclose before the identity is confirmed? Flagged for Fede — a trust/privacy call, not an engineering one.
- Auto-suggestion rule + thresholds for mode classification at onboarding.
- Yardi
GetPurchaseOrdersshape is unvalidated; AppFolio is the only proven adapter.
(Resolved since the draft: the reply-attribution fix is PR #4710; the staleness budget is the
60-min/24-h split in §Staleness; the mode field lives top-level on Property per Decision §1.)