0050 — AppFolio work-order sync: on-demand refresh over the existing discovery poll
- Status: Proposed
- Date: 2026-06-07
- Deciders: Gera (Jose), Claude
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:
lambda/appfolio-sync/handler.tshas awork_ordersjob, triggered by the EventBridge rulepropflow-appfolio-sync-work-orders-scheduleatrate(1 minute)(lambda/appfolio-sync/deploy.sh:91, README §schedules).- The job calls
syncPropertyWorkOrders(src/lib/domain/pms/writers/work-order.ts:68), which for every property: lists AppFolio WOs (client.listWorkOrders, theworkOrdersPMS capability), matches back by external WO id, CREATEs a PropFlowWorkOrderfor any AppFolio WO we don't have (spine-stamped via the construction-invariantsaveWorkOrderwriter,displayIdstamped<ticker>-<af.workOrderId>— ADR-0030 / ADR-0022 / ADR-0032 compliant), and UPDATEs changed scalar fields on existing rows, with a terminal-status clobber guard (won't regress a PropFlow-completedWO back toin_progress).
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
Do not build a second WO discovery/mirror path.
syncPropertyWorkOrdersis the single source of truth for AppFolio → PropFlow WO reconciliation and stays the only one. The EventBridgework_ordersjob is the established inbound-PMS-sync pattern (sibling to thebalances,lease_states,rental_applicationsjobs in the same lambda) and is not migrated to Temporal in this ADR (see Alternatives).Add the missing on-demand single-WO refresh, mirroring the tenant/vendor sync-button skeleton 1:1:
- UI: a
WorkOrderPmsSyncbutton on the WO detail page (/maintenance/[displayId]), modeled onTenantPmsSync.tsx/VendorPmsSync.tsx— same composable result toast ("status updated", "vendor reassigned", "no change"). Arsenal components only. - Route:
POST /api/work-orders/[id]/sync, gated byisPmsSyncAllowedHere()(prod-only, same gate the tenant/vendor routes use) + auth + property scope. - Domain:
syncWorkOrderFromPms(workOrderId)insrc/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 logicsyncPropertyWorkOrdersuses. - Shared upsert — no parallel implementation. Extract the per-WO match-back +
merge body of
syncPropertyWorkOrdersinto a sharedupsertWorkOrderFromDrafthelper 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.
- UI: a
Reads reuse what exists. A single-WO read should reuse the already-
liveappfolio.get_work_order_detailstool (or a one-item path throughclient.listWorkOrdersfiltered to the WO's external id) rather than minting a new catalog tool. Only if neither yields aPMSWorkOrderDraft-shaped row do we add a read tool — and then via the full four-file tools-platform procedure (types.tskey union →appfolio.tsspec →evals/tools-fixtures.tsfixture →tool-catalog-state.test.tsdirect-route set), which the drift guards enforce.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 existingsaveWorkOrderwriter. Theno-pf-stub-drift+wo-displayid-drift-guardstests 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
- Easier: operators get an immediate per-WO refresh instead of waiting up to a minute; the existing mirror is finally documented so we stop re-litigating "does WO sync exist." The button reuses the tenant/vendor UX, so it's familiar on day one.
- Commits us to: a single shared
upsertWorkOrderFromDraftused by both the poller and the button — the moment those diverge we've reintroduced the parallel-path problem this ADR exists to prevent. - Follow-ups (real decisions, see Open questions): confirm the
work_ordersrule is armed for all properties in prod; decide whether 1-minute cadence stays or throttles; optionally surface a "last synced from AppFolio" timestamp on the WO detail so the button's effect is visible.
Open questions (need Gera's call before implementation)
- 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.
- Prod enablement / scope. Confirm
propflow-appfolio-sync-work-orders-scheduleis 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." - 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 wholeappfolio-synclambda is a separate, larger decision and is explicitly out of scope here.
Alternatives considered
- Build a fresh Temporal
workOrderDiscoveryWorkflowon a 4×/day Schedule. Rejected: it duplicatessyncPropertyWorkOrders(ONE-SOURCE-OF-TRUTH violation), and the EventBridgework_ordersjob already does discovery+mirror more frequently. A new scheduled workflow would have to either replace the lambda job (a large migration with no behavior gain) or run alongside it (two writers racing on the sameWorkOrderrows). Neither is justified by "add a Sync button." - A 4×/day Vercel cron. Rejected: CLAUDE.md explicitly bans new cron/sweeper/reconciler paths for the maintenance domain, and discovery is already covered by the lambda.
- On-demand button only, ignore the poll entirely. This is essentially the chosen decision — the button is the gap. We document the poll rather than ignore it so the next person doesn't rebuild it.
- A new
appfolio.sync_work_ordercatalog tool as the entry point. Deferred: the on-demand refresh is a domain operation behind an authed route, not a Clara/operator catalog action. We only add a catalog read tool ifget_work_order_detailscan't supply a draft-shaped row.