A PO gets a home in Maintenance — but the way a PM actually reaches one is by typing the number into search.
Recommendation
Maintenance › Purchase Orders — one static rail row, a list, and a dual-shape detail route. But ship the number resolver first.
The PO is a first-class entity and it earns a nav row, an index and a canonical detail page. What actually closes the two real moments — a vendor on the phone reciting a number, an invoice arriving stamped "Customer PO 591" — is a purchase-order branch in the command palette's existing /api/search resolver. That is one case in a switch, not a new mechanism, and it is the cheapest, highest-value item on the board.
Step zero is not a build. Every proposal in the panel claimed runPurchaseOrderSync has no production caller and used that to defer the whole feature. It's false, and I verified it: lambda/appfolio-sync/deploy.sh:159-161 already schedules a purchase_orders job at rate(15 minutes), and handler.ts:547 already dispatches into syncPurchaseOrdersForProperty. The runner returns immediately because no property has vendorJobReferenceMode: 'purchase_order' yet — the deploy script says so in a comment. The gate is one PATCH /api/properties/<camellia>, not weeks of infrastructure.
One line in SUBNAV_SPECS.maintenance (src/components/primitives/nav-items.tsx:177-189), inserted after Costs — Work Orders / Turnovers / Routine read as one operational set and a reference document shouldn't split them:
appfolio-main-tabs-overview.md:81 puts "Purchase Orders — searchable index of all existing Purchase Orders" inside the Maintenance tab, peer to Work Orders. Its reporting taxonomy self-contradicts (Accounting Reports in one doc, Owner & Vendor Reports in two others); nobody should argue placement from it.SUBNAV_SPECS is a plain const and its only conditional precedent (staffOnly) is a synchronous boolean. Gating on purchaseOrderModeProperties makes the app shell await a maintenance-domain fetch: flicker on property switch, an active row that vanishes under you, a menu that differs per user in support screenshots. Gate at the page, where every shipped PO surface already gates via isPurchaseOrderModeEnabled.Receipt — Work Orders owns it and the rail is currently icon-unique. Use ClipboardText or FileText.nav-items.test.ts canonical-href coverage and a mirror row in palette-index.ts in the same change. That's the free half three proposals skipped while still paying for the route.Add type: 'purchase-order' to the union in src/app/api/search/route.ts. Queries 591, po 591, PO 4471 fire a single targeted findPurchaseOrdersByNumber against the existing GSI2PK = PONUM#<org>#<number> — org-wide, which is exactly right, because in the invoice moment the PM holds a number and does not yet know the property. Row: PO 591 · Ace Plumbing · Unit 4B · Camellia. Multiple hits render as multiple rows, so the collision problem three proposals wrote paragraphs about solves itself. Honor the existing GlobalSearchResponse.degraded contract: a failed source must never render as "no such purchase order" to a PM on a vendor call. This mirrors AppFolio's documented gesture exactly ("type 'po' and then the number, ex. po 491373-1"), so migrating PMs need zero training.
/maintenance/purchase-orders/[ref] MA page under (workspace), never a ?po= drawer — there is no URL-driven drawer idiom in maintenance, and the PO number is the vendor-facing reference, so these links get pasted into Slack and texts.
The number-vs-key fight is already settled in the repo and nobody cited it: resolve-work-order-ref.ts + workOrderHref() at /maintenance/[displayId] accept either a human reference or an immutable row id, detect the shape, and only ever emit the stable form. Mirror it: [ref] accepts a number or an externalPurchaseOrderId; number-shaped input 302s to the canonical id URL; ship purchaseOrderHref() as the single URL builder with a drift test mirroring work-order-href.test.ts. Without that builder, the four existing call sites will each mint their own URL shape the moment poLabel() becomes a link.
Sections, in order: Header (number large and tap-to-copy, vendor, status chips, cancelled struck through) · Job — the binding, first, because it is the only thing on this page PropFlow authors, with signalSentence() provenance and scores dropped; PROPERTY sentinel reads "Whole property — not tied to a single job," a recorded outcome, not a gap · Items (lines[], verbatim, never parsed) · Amount · Matched invoice evidence · Source (cachedAt, staleness, "Read-only mirror of AppFolio" with a deep link out).
$0.00Every modern-era Camellia PO carries a zero amount. A rendered currency zero asserts "this job cost nothing" to whoever the link is forwarded to. Add NO_PO_AMOUNT = 'No amount on the purchase order.' to po-language.ts and render that. No amount column on the list either — a column reads as summable and invites a total that would be a lie.
The section that does carry real money today is matched invoice evidence: parsed invoices whose extracted PO ref matches this number, labelled explicitly as mailbox evidence and not a bill record in PropFlow. invoice-parser.ts already extracts these; 17 of 21 sampled jpco invoices print the number; $6,688.09 of parsed 2026 spend is sitting there unattributed. Render only when ≥1 invoice cites the number — no reserved blank column. If the parse output isn't persisted, persisting it is the work, not a reason to wait.
/maintenance/purchase-orders S · ship lastColumns: PO number · Vendor · Scope (poScopeLabel()) · Issued · Status · Job. Two controls only: property filter, and a segmented Needs matching / All (plus show-cancelled). Cut the GL-account facet — GL lives on lines[], so filtering an index by it silently returns a partial answer to the one user who wants it, which is worse than no filter.
Read directly from PurchaseOrder rows via the same scoped fan-out /api/purchase-orders/bindings already performs over scopeProperties(...).filter(isPurchaseOrderModeEnabled) — not from the bindings payload. That payload is {needsConfirmation, unbound, bound}, a binding-state projection with no home for cancelled, removedInPms, rejected or superseded rows. A page titled "Purchase Orders" that silently omits records is the worst failure here: a PM who can't find 591 concludes the vendor's number is fake.
The list cut we'd eventually want, named and deferred honestly: the only real-world PO complaint in the domain library is an operator's notebook of open POs crossed off as invoices arrive — "it can take weeks for the invoice to arrive, and sometimes I have to chase them down." That's a chase list: issued, no invoice seen, N days aging. It's her axis, not our data model's, and it's the correct future default sort. It needs invoice records. Don't fake it with a blank column.
The question's premise doesn't survive the data. Of the four states asked about, only "issued" exists; "confirmed by vendor," "work scheduled" and "invoiced" have no field, no record, and no Invoice entity in DynamoDB at all. What exists is createdAt, approvalState (with a real unknown value that has no rail position), and three independent booleans — isReceived, isCompleted, isCancelled — plus removedInPms. A services PO ("Clean House 7 days") is never "received." 71% are never marked at all. Approval runs a ~15-day median behind the number already being spoken to the vendor.
So: no stepper, and specifically not WorkOrderCheckpoints — that idiom is monotonic, past-tense and green-checked, so "Cancelled ✓" reads as an achievement and every live PO renders as stalled at step one. Render a chip plus a dated fact list: Issued Jul 3 · Approved Jul 18 · Cancelled Aug 2, omitting what isn't set. Approval is a quiet metadata line, never a gate: per Joanna Weiser at Camellia, "we get the PO first because that's how we confirm the job with the vendors" — the right question is does it exist, not is it approved.
The binding trail is genuinely sequential and renders separately, collapsed: PoBindingStatus, needsReconfirm as its own amber state, the capped transitions[] array.
Confirm / reject / match exists in exactly one place: PurchaseOrderQueue on /maintenance/costs. It does not move and it is not duplicated. Attribution is money reading — coding a charge to a job happens next to the tiles, the trend and the vendor's other charges, and CostsClient.tsx:255 records the shipped reasoning. The list and detail pages show binding state and link into the queue; they host no second copy of the control. Before anything new mounts, converge /api/purchase-orders/bindings and /api/maintenance/po-bindings — two routes for one job become two UIs for one job.
| Condition | What it says |
|---|---|
| Property is in work-order mode | "This property tracks jobs by work order number," linking to its VendorJobReferenceCard. Not an empty list. No upsell. |
| PO mode on, sync never ran / stale | "Purchase orders aren't syncing from AppFolio yet," with the watermark line from PoSyncHealthStrip. Never "No purchase orders." |
| Number resolves to nothing | "No purchase order 591 in PropFlow" + the sync freshness line. Not synced and doesn't exist must never collapse into one string — mid-call, that string decides whether the PM thinks the vendor is lying. |
Rows past PO_ROW_STALE_AFTER_MS (24h) | Shown with an "as of" stamp in Mountain time. Never blanked. |
| Job has no PO | NO_PO_YET verbatim — the normal ~13-day state, never an error. |
All of it through po-language.ts. Page-local strings fork a vocabulary that identity tests currently pin.
The invoice arrives as a photo or a forward, in a truck, not as a ⌘K session at a desk. Detail is single-column with the number rendered large and tap-to-copy at the top, because the PM's real job on a phone is reading that number aloud. The list is stacked cards, never a horizontal table, with the property filter always visible.
Clara: extend handle-lookup-purchase-order to accept a bare number, not just a job. The recipient boundary must be enforced in the tool, not left as a copy convention — Clara's threads are the one surface where PMs and vendors both appear. In a PM thread: the number plus the permalink. In a vendor thread: the number only, never a link. vendorFacingRef() establishes that the number is vendor-facing; the record is not — it carries binding provenance, instructions, and other units' line items.
There is nothing to salvage — the UI already shipped. Every component fede/po-surfaces introduced is on main today and mounted: WorkOrderPurchaseOrderCard (WO detail :518), PurchaseOrderQueue (Costs :263), VendorJobReferenceCard + PoSyncHealthStrip (property detail :1306), VendorOpenPosCard (vendor detail :296). The branch's only unmerged delta is the eval/agent/CI lane that ADR-0118 already records as deliberately red.
Delete the branch and the stash. Its pattern — scatter PO fragments across existing pages — is also the thing to stop: four mount points is the ceiling. Everything further routes through the detail page. The one change those four surfaces get is that poLabel() becomes a link via purchaseOrderHref(). That one-word change is the cheapest real win in the entire exercise.
po-read-only, no-propflow-minted-po-numbers with the allowlist asserted empty). Minting is AppFolio's job and Joanna's first step. Deep-link out.WorkOrderCheckpoints reuse.?po= drawer, no root-level /po/* outside the workspace shell.| # | Work | Size | Note |
|---|---|---|---|
| 0a | aws events list-rules --name-prefix appfolio-sync — confirm the purchase-orders rule is ENABLED in prod | XS | deploy.sh is source, not deployed state. Don't let the correction become a new unverified assumption. |
| 0b | PATCH /api/properties/<camellia> → vendorJobReferenceMode: 'purchase_order', then watch one 15-min tick land real rows | S | Ops action, hours not weeks. Nothing below renders honestly over seed-willows-pos.ts. |
| 0c | Defect #29 — pass ?propertyId= through PurchaseOrderQueue's injectable load prop | XS | The route already reads it. Client wiring, not a data-contract change. |
| 1 | purchaseOrderHref() + drift test; poLabel() → link on the four shipped cards; NO_PO_AMOUNT | S | Useful the moment rows exist. |
| 2 | purchase-order branch in /api/search | S | This is the feature. Closes both real moments with no new page. |
| 3 | Detail page, dual-shape [ref] resolution, 302 to canonical | M | Converge the two binding routes first. |
| 4 | Matched invoice evidence section | M | Gated on persisting invoice-parser output / PR #4935's invoice→WO link. |
| 5 | List page + nav row + nav-items.test.ts + palette row | S | After step 0b proves the room isn't empty. |
The vendor-spend UI prototypes doc answers "where does spend display" — lifecycle-rail steps and an invoice block on the work-order page (A+B), a fourth tile on Costs (D1), actual-spend on the turnover tracker (E). This doc answers "where does the PO live." They meet at exactly one place and don't overlap anywhere else:
The existing PurchaseOrderQueue is unchanged by both, except for the property-filter fix and poLabel() becoming a link.
Five lenses argued this — information architecture, PM jobs-to-be-done, back-office accounting, existing-pattern minimalism, and market parity against AppFolio/Buildium/Yardi — then three judges ranked them. Proposal 3 (accounting) took two firsts and 258 points; Proposal 5 (market parity) took one first and 252; JTBD 238, IA 216, minimalist 214. Nearly everything converged: no lifecycle rail, no PO writes, one confirm lane on Costs, never print $0.00, no dashboard tile, all copy through po-language.ts. Four positions the panel held on entry and abandoned under cross-examination: that the PO belongs under Accounting (AppFolio's own navigation says Maintenance; its reporting taxonomy contradicts itself), that the detail page should be a drawer (one record, five addresses), that the URL should be keyed on the number (numbers recycle per property), and that the palette needed a static catalog row rather than a live resolver branch.
The sharpest disagreement was over sequencing, and it turned on a fact four of the five proposals got wrong: all of them grepped for a caller of runPurchaseOrderSync, found none, and concluded the entire feature was parked behind L-sized sync infrastructure — which is why three of them deferred the list, one deferred the nav row, and one deferred the whole thing. The accounting lens went and read lambda/appfolio-sync/deploy.sh instead of grepping src/, and found the EventBridge rule already scheduled at rate(15 minutes) with a comment stating it is inert only because no property has opted into purchase_order mode. That single correction inverted the build order for everyone: the prerequisite is one property-config flip, and the resolver that closes both real PM moments becomes shippable this week rather than next quarter. It is also the cleanest argument in the packet for reading deploy scripts and comments before concluding that shipped code is dead.