0116 — The vendor-facing job reference is a per-property configuration

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:

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:

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:

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.

4. PropFlow owns the PO↔job binding

The PMS does not link POs to work orders (0 of 722). The binding is therefore ours:

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

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.

  1. Gate the dial. — SHIPPED. Before an outbound vendor call at a purchase_order property, 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").

  2. 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.md in 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.

  3. 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.ts calls writer.createWorkOrder() with no reference to vendorJobReferenceMode. PR #4745 made only isExternallyDispatchable mode-aware (it dropped the pmsId precondition at purchase_order properties); 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

Binder algorithm

src/lib/domain/vendors/po-job-binder.ts, phase 2 of the sync tick. No LLM in the default path.

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

UI touchpoints (arsenal primitives only)

  1. /properties/[id] "Vendor job reference" card: mode selector + advisory auto-suggest chip + sync-health strip; a mode change is a confirm dialog.
  2. 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.
  3. WO detail: "Work order CAM-604 · PO 4471" — both numbers, both labeled (ADR-0031's surface-both-label-both posture); inline bind/unbind.
  4. Vendor detail: "Open POs" for call prep.
  5. Empty states say "No PO issued yet." — a normal state for days after job creation; never blank, never a silent WO fallback.

Test strategy

Non-goals

  1. 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.
  2. 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)

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

Harder

Implies

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

  1. 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.
  2. Auto-suggestion rule + thresholds for mode classification at onboarding.
  3. Yardi GetPurchaseOrders shape 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.)