0050 — AppFolio work-order sync: on-demand refresh over the existing discovery poll

Context

We want work orders to behave like tenants and vendors do today: (1) a change made to a work order in AppFolio should reflect on the PropFlow mirror, and (2) work orders created directly in AppFolio (no Clara involvement) should appear in PropFlow on their own. The framing when this was raised was "we have nothing for work orders."

That framing is wrong, and this ADR exists to correct it before anyone writes duplicate code. A discovery-and-mirror pipeline for work orders already exists and runs in production:

So both requested "layers" are already implemented, and the poll runs more aggressively than the "4× / day" that was requested:

Requested Reality
Layer 1 — AppFolio WO change reflects on PropFlow ✅ already done — the syncPropertyWorkOrders UPDATE path, every minute
Layer 2 — poll all properties, append new WOs ✅ already done — the same job's CREATE path, every minute

The appfolio-45 "recon-WO mirrors" (~248 rows a sync re-creates every few minutes, documented as a known bench artifact) are the output of this exact job — it has been observed in production, just not recognized as "the WO sync."

The genuine gap is the one user-facing affordance tenants and vendors have that work orders do not: an on-demand "Sync now" button. Tenants have syncTenantFromPms behind POST /api/tenants/[id]/sync + TenantPmsSync.tsx; vendors have the mirror equivalent. Work orders have no /api/work-orders/[id]/sync route, no syncWorkOrderFromPms domain function, and no button on /maintenance/[displayId]. An operator who just changed a WO in AppFolio and wants it reflected now must wait for the next 1-minute poll tick.

Building a new background poll / Temporal discovery workflow here would duplicate syncPropertyWorkOrders — a direct violation of the ONE-SOURCE-OF-TRUTH rule and exactly the "don't bandage / bootstrap a parallel thing" the work was scoped to avoid.

Decision

  1. Do not build a second WO discovery/mirror path. syncPropertyWorkOrders is the single source of truth for AppFolio → PropFlow WO reconciliation and stays the only one. The EventBridge work_orders job is the established inbound-PMS-sync pattern (sibling to the balances, lease_states, rental_applications jobs in the same lambda) and is not migrated to Temporal in this ADR (see Alternatives).

  2. Add the missing on-demand single-WO refresh, mirroring the tenant/vendor sync-button skeleton 1:1:

    • UI: a WorkOrderPmsSync button on the WO detail page (/maintenance/[displayId]), modeled on TenantPmsSync.tsx / VendorPmsSync.tsx — same composable result toast ("status updated", "vendor reassigned", "no change"). Arsenal components only.
    • Route: POST /api/work-orders/[id]/sync, gated by isPmsSyncAllowedHere() (prod-only, same gate the tenant/vendor routes use) + auth + property scope.
    • Domain: syncWorkOrderFromPms(workOrderId) in src/lib/domain/maintenance/. It resolves the WO's property + external WO id, reads the single current AppFolio WO, and runs it through the same per-WO upsert logic syncPropertyWorkOrders uses.
    • Shared upsert — no parallel implementation. Extract the per-WO match-back + merge body of syncPropertyWorkOrders into a shared upsertWorkOrderFromDraft helper that BOTH the poller (iterating all drafts) and the button (one draft) call. The button must not re-implement merge/guard logic; if a refactor is needed to share it, that refactor is part of this work, not a follow-up.
  3. Reads reuse what exists. A single-WO read should reuse the already-live appfolio.get_work_order_details tool (or a one-item path through client.listWorkOrders filtered to the WO's external id) rather than minting a new catalog tool. Only if neither yields a PMSWorkOrderDraft-shaped row do we add a read tool — and then via the full four-file tools-platform procedure (types.ts key union → appfolio.ts spec → evals/tools-fixtures.ts fixture → tool-catalog-state.test.ts direct-route set), which the drift guards enforce.

  4. Identity contract is unchanged and load-bearing. No PropFlow-minted WO ids; WorkOrder.id = AppFolio's id; displayId = <ticker>-<af.workOrderId> only after AppFolio confirms; spine-stamp via the existing saveWorkOrder writer. The no-pf-stub-drift + wo-displayid-drift-guards tests already fence this and must stay green.

This ADR proposes no new entities (the on-demand path writes existing WorkOrder rows through the existing writer), so the entity-classification table is omitted.

Consequences

Open questions (need Gera's call before implementation)

  1. Cadence. The poll runs every 1 minute; the ask was "4× / day." Is the 1-minute cadence intentional (near-real-time mirror) or should it throttle to cut AppFolio API load / cost? Changing it is a one-line EventBridge rate change, not new code. Recommendation: keep the background poll near-real-time and let the on-demand button cover the "I need it now" case; revisit cadence only if AppFolio rate limits bite.
  2. Prod enablement / scope. Confirm propflow-appfolio-sync-work-orders-schedule is enabled and actually iterating every property in prod (not a subset / not disabled). This is an AWS EventBridge + lambda-env check, to be verified before we rely on the poll as "Layer 2 done."
  3. EventBridge vs Temporal. The renewal/tour discovery walkers run on Temporal Schedules; this PMS-sync lambda runs on EventBridge. CLAUDE.md prefers Temporal for the maintenance pipeline — but that guidance targets the outbound per-conversation maintenanceWorkflow, not the inbound PMS-sync lane (which has four sibling jobs on EventBridge today). Recommendation: leave the EventBridge lambda as the established inbound-sync pattern; a Temporal migration of the whole appfolio-sync lambda is a separate, larger decision and is explicitly out of scope here.

Alternatives considered