0039 — AppFolio is the source of truth for vendors and tenants (no local fakes in prod)
- Status: Proposed
- Date: 2026-05-28
- Deciders: Gera
Context
PropFlow is a mirror of the PMS (AppFolio today; Yardi / RealPage / Entrata later). ADR-0030 established this for work orders (the PMS owns the identifier; PropFlow mirrors). ADR-0023 is the per-field authority registry. ADR-0033 split the vendor model into VendorCompany (counterparty) + VendorMembership (the Person-anchored, org-scoped join carrying isInHouse / role / propertyIds).
Tenants already follow the mirror model: lambda/appfolio-sync pulls occupancies/tenants on a 15-minute schedule, stamps Tenant.af.tenantId / occupancyId / pmsSource, and carries explicit field-ownership rules (e.g. a verified PropFlow phone beats the AppFolio rent-roll phone).
Vendors do not. There is no AppFolio→PropFlow vendor sync. The browser-agent has write L4s (run-create-vendor, run-vendor-phone-set) but no read L4 (get-vendor, list-vendors). So PropFlow's vendor roster is a hand-maintained island, populated only by the Add-Vendor flow, CSV import, or seed scripts.
The consequence, measured against propflow-prod (2026-05-28):
| Entity | Total | AppFolio-linked | Fake / demo residue | Sync exists? |
|---|---|---|---|---|
| Vendors | 49 | 1 | ~48 (incl. the Denver demo crew, Test Company, Test-Comp) |
No |
| Tenants | 211 | 110 | ~7 (Denver demo + renewal-gauntlet fixtures) | Yes |
Fake vendors actively confuse operators — the roster doesn't match what they see in AppFolio (e.g. "ABC Imaging" is in AppFolio, not in PropFlow). Prod should contain only real, PMS-sourced vendors and tenants.
Decision
1. AppFolio owns identity; PropFlow mirrors it and overlays its own facts.
- AppFolio-owned (sync writes, PropFlow never invents): vendor company name, contact name/phone/email, 1099/business facts, existence; tenant name/phone/email/lease facts.
- PropFlow-owned (sync NEVER touches):
VendorMembership.isInHouse/role/propertyIds/notifyOnRoutineUpdates; tenant workflow state, verified-phone overrides, action log. - This is single-writer-per-field (per ADR-0023). The sync upserting AppFolio-owned fields must not clobber the PropFlow overlay — e.g. a vendor sync must never reset
isInHouse.
2. Vendors gain a sync (build it); tenants extend the existing one. The vendor sync mirrors the tenant-sync pattern: read L4s → diff against PropFlow keyed on af.vendorId → upsert. Tenants already have the sync; the work there is residue cleanup + an audit of un-linked rows.
3. No local-fake vendors or tenants in prod. Seed/demo data does not belong in propflow-prod. Demo data lives in sandbox/isTest properties or stage. Existing prod fakes are removed (see #4).
4. Removal: archive what was once real; hard-delete pure fakes. AppFolio soft-deletes vendors/tenants (an "Inactive" flag), and PropFlow rows are referenced by work orders, quotes, dispatch history, and Property.handymanVendorIds. The rule splits by provenance:
- Once-real-in-AppFolio rows (a vendor/tenant that AppFolio later drops to Inactive) → archive, never blind delete: set an archive flag (
archivedAt+pmsStatus: 'removed_in_appfolio'), surface a "Removed in AppFolio" badge. Never DDB-delete a referenced row — the WO/quote/dispatch history must survive. - Pure-fake demo/seed rows (rows that never existed in AppFolio — the Denver demo crew,
Test Company, etc.) → hard-delete (Jose decision, 2026-05-28). These carry no real history worth preserving, and leaving them archived just clutters the roster the operator is trying to make trustworthy. The delete is still gated: the cutover runs the sync/import first (stampsaf.vendorIdon rows matching a real AppFolio vendor, sorting real from fake), then deletes only the un-matched leftovers after a reference check — a row referenced by a WorkOrder / quote /handymanVendorIdsis skipped (not orphaned) and surfaced for an explicit reassign-or-force decision. Implemented inscripts/delete-fake-vendors.ts(dry-run default, per-row reference guard).
Implications
- The operator vendor roster becomes a faithful view of the AppFolio account vendor list.
isInHouse(the per-person designation shipped in PR #1625) survives every sync — it's a PropFlow overlay AppFolio has no concept of.- A vendor/tenant in PropFlow with no
af.*pointer is either pre-sync residue or a manual add that hasn't been reconciled — visible and auditable, not silently trusted. - Staging can be a non-PII replica of prod's real (synced) data, replacing the curated seed fixtures over time.
Drift guard (to build with the sync)
- A test/assertion that prod vendor + tenant rows carry an
af.*pointer (or an explicitpmsStatusarchive flag) — i.e. every row is accounted for as real-or-archived, none are anonymous fakes. - The vendor sync's upsert path must be covered by a test asserting it does not write
isInHouse/role/propertyIds(overlay-preservation), mirroring the tenant sync's "MUST NOT clobber verified phone" rule.
Alternatives considered
Keep vendors hand-maintained; just clean the fakes once. Rejected — without a sync, the roster drifts from AppFolio again the moment a vendor is added/changed in AppFolio, and the same confusion returns. The sync is what makes "real vendors only" durable, not a one-time scrub.
Hard-delete ALL fake/dropped rows indiscriminately. Rejected — work orders, quotes, and handymanVendorIds reference vendor ids; deleting a referenced row orphans them (mirrors the WorkOrder-history concern in ADR-0030 and the tenant archivedAt precedent). The accepted rule (§4) is narrower: hard-delete only pure-fake, unreferenced rows after a reference check; archive once-real rows; skip referenced rows for an explicit decision.
Per-property vendor pull (derive from WO history). Rejected — AppFolio's vendor list is account-scoped, not property-scoped; a per-property pull silently misses vendors not yet WO'd at that property. The truthful match is a full account-list sync, with Property.handymanVendorIds reconciled from AppFolio's per-property default-vendor association downstream.