0051 — Unified PMS → PropFlow reconcile: ordered tenant/vendor/work-order sync + coverage
- Status: Partially superseded — Slices 1/2/2b shipped; Slice 3 (Temporal ordered reconcile) retired 2026-06-09, vendor discovery consolidated onto the
appfolio-synclambda (see Update below). - Date: 2026-06-08
- Deciders: Gera (Jose), Claude
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:
The ordering guarantee isn't load-bearing.
syncPropertyWorkOrdersalready handles a WO whose vendor/tenant isn't in PropFlow gracefully — it falls back to the handyman orassignedVendorCompanyId: 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.The reconcile duplicated the lambda at a worse cadence. It ran occupancy + WO every 6h — jobs the
appfolio-synclambda 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:
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.
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.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 alogWarnand 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."ownerIdis 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:
- Skipped-property signal. Promote the lambda's silent
logWarn(missingownerId/ external id / PMS credential) into a surfaced metric on/admin/dev/status+ a daily alert, listing every property excluded from sync and why. Remediation pointer:scripts/backfill-property-owners.ts. - Per-property reconcile counts. A read-only report comparing PMS truth to
PropFlow per property + per account: tenants (AppFolio occupancies vs PropFlow
tenants), vendors (account
vendor_directoryvs PropFlow VendorCompanies), work orders (AppFoliowork_ordervs PropFlow WOs). Surfaces drift ("AppFolio 40 tenants, PropFlow 37") an operator can act on.
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:
- Add
'vendors'toPMSCapability(src/lib/domain/pms/types.ts) + an optionallistVendors(): AsyncIterable<PMSVendorDraft>onPMSClient(src/lib/domain/pms/client.ts), implemented on the AppFolio adapter via avendor_directoryreport reader (account-scoped, nopropertiesfilter).- Resolved 2026-06-08 (was the slice's biggest unknown):
vendor_directoryis a real AppFolio Reports API report — the sibling oftenant_directory, read by the sameAppfolioClient.listAll('vendor_directory', …)pure-HTTP path. No Browserbase capture is needed. The earliervendor-pms-sync/plan.md(ADR-0039) assumed the only vendor read was the browser-L4/api/vendorsand flagged the capture as the foundation blocker — it never checked the Reports API. Verified read-only against the test account (811 vendor rows). Real columns:VendorId/CompanyName/Name/FirstName/LastName/Email/PhoneNumbers/VendorType/VendorTrades/DoNotUseForWorkOrder.
- Resolved 2026-06-08 (was the slice's biggest unknown):
- A
syncAccountVendorswriter (sibling ofsyncPropertyWorkOrders) that match-backs byaf.vendorId, creates theVendorCompany+ ownerVendorMembershipfor unknown vendors (through the existing spine-stamped writers — ADR-0033,spineStampSource: 'pms_import'). It must NOT clobber PropFlow overlays (isInHouse,role,propertyIds— ADR-0039), exactly as the on-demandsyncVendorFromPmsdoesn't — on a known vendor it refreshes ONLY the AppFolio- ownedVendorCompanydisplay fields, never the membership.- Scoped out of Slice 2 (deliberate): archiving vendors dropped from the PMS
(→ Slice 2b below) and deep owner-contact reconcile / phone-email
collision handling (the existing per-vendor
syncVendorFromPmsengine). Slice 2 is discovery + display-refresh only.
- Scoped out of Slice 2 (deliberate): archiving vendors dropped from the PMS
(→ Slice 2b below) and deep owner-contact reconcile / phone-email
collision handling (the existing per-vendor
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:
- Detection is by ABSENCE only. The
vendor_directoryreport carries nodeletedAt/Hidden/Statuscolumn — its sole activity signal isDoNotUseForWorkOrder(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. - The per-vendor detail read IS authoritative.
get_vendor_details(the L4 the per-vendor "Sync" button already uses viasyncVendorFromPms) DOES exposehidden/deletedAt. So a drop can be confirmed per-vendor before acting.
Design.
- Candidate set =
af-linked VendorCompanies absent from the latest roster. Only rows withaf.vendorIdare candidates — a VendorCompany with noafpointer was never AppFolio-sourced, so "absent from the AppFolio roster" is meaningless for it. This automatically excludes the ~48 PropFlow-only / fake vendors from archive-on-drop, which bounds the "first-run nuke" risk to genuine af-linked drops.syncAccountVendorsalready iterates the roster — collect the set of seenaf.vendorIds; after the loop,drops = af-linked existing − seen. - Confirm before archiving. For each drop candidate, re-read
get_vendor_details; archive only when AppFolio confirmsdeletedAtset orhidden: true. A candidate that still resolves (transient roster miss) is left untouched and logged. This is the guard against fact (1) — and it's cheap, because drops are rare (per-DROP read, not per-vendor). - Reference-safety before archiving. Skip (and surface for operator review) any
vendor still pointed at by an open WorkOrder (
assignedVendorCompanyId) or by aProperty.handymanVendorIdsentry. Archive a referenced vendor only after the reference is cleared. - Archive = soft + reversible. New PMS-agnostic fields on
VendorCompany:pmsStatus?: 'active' | 'removed_in_pms'(defaultactive) +archivedAt?: string. Optionally end the owner membership's active window (VendorMembership.endedAt) to preserve history. UI treatment (amended 2026-07-25, Fede): archived vendors are hidden from the vendors list and excluded from new-WO vendor pickers — shipped in PR #4525. The originally specified "Removed in AppFolio" badge was never built; hiding won because the list stays clean and the soft-archive fields keep the record fully recoverable in DDB. Revisit the badge (behind a "show removed" toggle) only if PMs ask where a vendor went. - First-run gate (reconciliation, not nuke). The archive sweep ships report-only by default (lists the would-archive set with reasons); a live sweep requires an explicit operator opt-in for the first run. Steady-state runs (post-baseline) can auto-archive confirmed drops.
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):
- A Temporal Schedule (default ~6×/day, tunable) starts the parent workflow.
- Parent: per AppFolio connection →
syncAccountVendors(once, account-global) → then fan out one child workflow per property under that connection. - Each per-property child runs ordered activities:
syncOccupancies(tenants) →syncWorkOrders. Vendors already landed at the account level, so a WO's vendor + tenant references both resolve. - Each child returns a per-property result row (counts + skip reason); the parent aggregates into the Slice-1 coverage report and pages on real drift.
- Reuse the existing writers —
syncWorkOrdersForProperty(ADR-0050),syncPropertyOccupancies, and the newsyncAccountVendorsare the single source; the workflow activities are thin wrappers, not reimplementations (ONE SOURCE OF TRUTH).
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
- Easier: vendors are finally discovered fleet-wide; new-entity ordering is
guaranteed (no more "Unknown Tenant" WOs on first sight); coverage gaps (missing
ownerId, count drift) become visible and actionable; the sync scales past the 15-min Lambda ceiling via per-property child workflows; six scattered EventBridge rules consolidate into one observable system. - Commits us to: building the vendor capability + the Temporal workflow; keeping the writers as the single source shared by every cadence; a migration to fold the independent EventBridge jobs under the workflow rather than running both unbounded.
- Follow-ups: decide the reconcile cadence; decide whether the 1-min WO tick stays
or folds in; auto-remediation vs. alert-only for missing-
ownerIdproperties; PII discipline on activity boundaries (ADR-0026) — vendor/tenant names ride the writers, not workflow history.
Alternatives considered
- Consolidate on EventBridge (the cheap interim). One ordered rule (~6×/day) with
jobs: ['vendors','occupancies','work_orders']— the lambda already runs jobs in array order per property, so this gets ordering almost for free, plus the new vendor job. Rejected as the primary because a single lambda doing full-history tenants+vendors+WOs for the whole fleet sequentially will blow the 15-min Lambda timeout as properties grow (WOs alone = 573 on one test property), and it gives no per-property failure isolation, retries, or "which property/stage failed" visibility. Retained as the fast interim if speed-to-ordering must precede the Temporal build — it shares the same writers, so it's not throwaway. - Do nothing / keep independent rules. Rejected: the vendor gap and the ordering bug both persist, and coverage stays invisible.
- Make the WO writer fully resilient to missing tenants/vendors instead of ordering. A partial mitigation (the writer already sentinels a missing tenant), but it doesn't discover vendors and doesn't give the coverage guarantee — it just hides the symptom.
Open questions (need Gera)
- Cadence — 6×/day for the full ordered reconcile? Does the 1-min WO freshness tick stay alongside it, or fold in?
- Missing-
ownerIdremediation — alert-only, or auto-attemptbackfill-property-ownerswhen a connection can be inferred? - Vendor identity at scale —
VendorCompanykeys onaf.vendorId; confirm the account-globalvendor_directorydedup matches the on-demand button's expectations before fleet rollout.