0051 — Unified PMS → PropFlow reconcile: ordered tenant/vendor/work-order sync + coverage

Update (2026-06-09) — Slice 3 retired; vendors consolidated onto the lambda

Slices 1, 2, and 2b shipped and stand: the coverage detector, account-vendor discovery (syncAccountVendors reading vendor_directory via the Reports API), and drop-detection + soft-archive — all live in the writers.

Slice 3 (the Temporal ordered cohort-walker reconcile) was built, armed, and then retired the same day. Two facts found on arming undercut its premise:

  1. The ordering guarantee isn't load-bearing. syncPropertyWorkOrders already handles a WO whose vendor/tenant isn't in PropFlow gracefully — it falls back to the handyman or assignedVendorCompanyId: null, never errors, and self-heals on the next 1-min sweep. The lambda ran WO sync for months with no vendor sync and worked fine. So "vendors before WOs in one ordered pass" solves a non-problem at the incremental poller's cadence.

  2. The reconcile duplicated the lambda at a worse cadence. It ran occupancy + WO every 6h — jobs the appfolio-sync lambda already runs every 1–15 min. Its only net-new behavior was vendor discovery.

Resolution: vendor discovery moved to the lambda as a 7th job (vendors, rate(1 hour)) calling the same syncAccountVendorsForConnection runner. The whole Temporal reconcile subsystem (workflow / activity / worker / client / schedule / run-reconcile / run-occupancy-sync) was deleted — one sync home, no parallel system. The verified vendor sync (a real armed run created 7 + refreshed 94 vendors) is preserved; it just changed hosts. The sections below describe the original Slice 3 design and are kept for history.

Context

The guarantee we want is simple to state: every tenant, vendor, and work order that exists in the PMS should exist in PropFlow. Today that guarantee is partial, silent where it fails, and not ordered. Verified state (2026-06-07/08):

Entity Scheduled discovery today Mechanism
Tenants ✅ yes appfolio-sync lambda occupancies job, rate(15 min), ENABLED (syncPropertyOccupancies — rent_roll + tenant_tickler)
Work orders ✅ yes work_orders job, rate(1 min), ENABLED (syncPropertyWorkOrders, full history — see ADR-0050)
Vendors NO only the on-demand "Sync" button (syncVendorFromPms). There is no scheduled vendor discovery, no PMSClient.listVendors, no vendor_directory reader, and 'vendors' is not a PMSCapability

Three problems fall out of this:

  1. Vendors are never discovered on a schedule. A vendor created in AppFolio only reaches PropFlow if an operator clicks the button or Clara creates it. The "all vendors in PropFlow" half of the guarantee has no producer.

  2. No cross-entity ordering. The six sync jobs fire on independent EventBridge rate rules (work_orders every 1 min, occupancies every 15 min, …). Nothing sequences them. So a brand-new tenant's work order can sync (1 min) before the tenant's occupancy syncs (15 min) — the WO writer then stamps a sentinel / tenantHint "Unknown Tenant" until a later sweep reconciles it. The same applies to a WO referencing a not-yet-discovered vendor. A WorkOrder references both a tenant and a vendor, so both must exist first.

  3. Coverage failures are silent. A property with no ownerId (the Better-Auth user whose AppFolio connection owns it) or no resolvable external id is soft-skipped in the lambda with a logWarn and nothing else. That skips the property's entire sync — tenants, vendors, WOs, balances. An operator has no surface that says "property X is not syncing, and N of its tenants are missing." ownerId is a property-level field, so a miss is a whole-property hole, not a single-tenant one.

The shape we already have for "scheduled, all-properties, ordered reconcile" is the Temporal cohort-walker (renewal-cohort-walker, tour-cohort-walker): a Schedule → a workflow that walks getProperties() → per-property work with error isolation. The inbound PMS-sync lane sits on EventBridge today by historical accident, not design.

Structural nuance (load-bearing): AppFolio vendors are account-global, not per-property. The WO sync already proves this — it loads getAllVendors() once per lambda run (a run-memoized account-wide set), not per property. So vendor discovery is scoped to an AppFolio connection (per ownerId), fetched once up-front, whereas tenants and work orders are per-property. The ordering guarantee is therefore "vendors (per connection) + tenants (per property) both land before work orders", not a flat three-step per-property loop.

Decision

Build a unified PMS reconcile system, in three slices, in this order:

Slice 1 — Coverage layer first (engine-agnostic, highest value)

You cannot fix coverage you cannot see, and "all tenants are in PropFlow" is a coverage claim. Build the observability before the new sync:

This slice is independent of the sync engine, ships first, and doubles as the verification harness for Slices 2–3.

Slice 2 — Build the missing vendor discovery capability

Net-new, mirroring the work-order path:

Slice 2b — Archive vendors dropped from the PMS (the "removed in AppFolio" path)

Goal. When a vendor is deleted (or hidden) in AppFolio, mark it archived in PropFlow — never DDB-delete (mirrors the tenant archivedAt care + ADR-0030's WO-history concern). The "all vendors in PropFlow are real PMS vendors" half of the guarantee needs this; without it, a deleted AppFolio vendor lingers as a live PropFlow row forever.

Why it's a separate, carefully-gated slice (not folded into Slice 2). It is the one vendor-sync behavior that removes signal, so a wrong call hides a real vendor. Two facts (verified read-only against the live account, 2026-06-08) drive the design:

  1. Detection is by ABSENCE only. The vendor_directory report carries no deletedAt / Hidden / Status column — its sole activity signal is DoNotUseForWorkOrder (which on the test account is "No" for all 811 rows). So a vendor deleted in AppFolio is observable only as missing from the roster on a later fetch. A partial / failed roster read therefore looks identical to a mass deletion — the failure mode that makes a blind archive-on-absence dangerous.
  2. The per-vendor detail read IS authoritative. get_vendor_details (the L4 the per-vendor "Sync" button already uses via syncVendorFromPms) DOES expose hidden / deletedAt. So a drop can be confirmed per-vendor before acting.

Design.

Open question to resolve before building. Whether AppFolio keeps a deactivated (not deleted) vendor in vendor_directory with DoNotUseForWorkOrder: "Yes" or drops it from the report — the test account has zero DoNotUse vendors so it can't distinguish. If deactivated vendors stay in the report, DoNotUseForWorkOrder becomes a second (cheaper, no per-vendor read) archive signal distinct from deletion; if they drop out, absence-plus-confirm covers both. Verify against an account with a known deactivated vendor before finalizing the rule.

Tests / verification. Unit: drop-detection set math (af-linked − seen; non-af excluded), confirm-before-archive (unconfirmed drop = no-op), reference-safety skip, soft-archive field writes, report-only vs live. Live: a read-only dry-run sweep against the test account showing the would-archive set is empty/expected (the account's 811 are all present + active).

Slice 3 — The ordered reconcile on Temporal

Build pmsReconcileWorkflow as a cohort-walker sibling (model: renewal-cohort-walker / tour-cohort-walker, per src/lib/temporal/README.md):

The fast 1-min WO tick may stay as a freshness path (Clara needs near-real-time WO state); the ordered reconcile is the correctness + coverage backstop, not necessarily its replacement. This is a deliberate two-cadence design over one set of writers — not a dual implementation. As the reconcile proves out, the scattered independent EventBridge rules are migrated under the workflow (or retired) so the fleet-wide guarantee has a single owner.

Entity classification (ADR-0027)

Entity Class Naming Trace / rebuild
PMSVendorDraft transient (not persisted) *Draft suffix Mirror of PMSWorkOrderDraft; mapped from the vendor_directory row, consumed by syncAccountVendors, never stored. Not spine-stamped (the resulting VendorCompany/VendorMembership go through their existing spine-stamped writers — ADR-0033).
PmsReconcileResult (per-property/per-account row) derived *Result from: AppFolio reports + PropFlow rows at run time · Rebuilt by: the pmsReconcileWorkflow Schedule (~6×/day) · Drift tolerance: ≤ one reconcile interval

No new spine-stamped (canonical) entity is introduced — vendors/tenants/WOs use the existing writers.

Consequences

Alternatives considered

Open questions (need Gera)

  1. Cadence — 6×/day for the full ordered reconcile? Does the 1-min WO freshness tick stay alongside it, or fold in?
  2. Missing-ownerId remediation — alert-only, or auto-attempt backfill-property-owners when a connection can be inferred?
  3. Vendor identity at scaleVendorCompany keys on af.vendorId; confirm the account-global vendor_directory dedup matches the on-demand button's expectations before fleet rollout.