0094 — Sync AppFolio guest cards with PropFlow prospects
- Status: Proposed
- Date: 2026-07-15
- Deciders: Fede
Context
JP staff are creating guest cards manually in the AppFolio CRM for leads that Clara has already touched — or that never reach Clara at all — and PropFlow is blind to them.
Two live examples from jpco.appfolio.com (2026-07-15):
- Jake Buffalo (GuestCardUuid
0a5b4ac8-6f16-11f1-a395-0a8c00852afd, InquiryID 22): called Camellia's Clara voice line twice on 06/22 (topictour, both calls transferred to the office within ~30s,general_inquiry). PropFlow created a Person + phone IdentityClaim but no ProspectInquiry — he never appears on the leasing prospects list. The next morning (06/23 9:13 AM) staff re-keyed him into AppFolio as a manual guest card (Source: No Source Recorded) and texted him from the AppFolio inbox. - Maria Rueda (GuestCardUuid
629f2262-8084-11f1-a395-0a8c00852afd, InquiryID 32): no trace anywhere in PropFlow (no person, claim, conversation, or prospect). Erika created her guest card manually on 07/15 1:35 PM and sent a rental application in the same action — a lead that reached the office directly and never touched Clara.
Today, the only AppFolio→PropFlow leasing ingestion is the rental_applications poller, which creates/updates a ProspectInquiry only after an application is submitted (matching on af.rentalApplicationId / af.inquiryId). A guest card whose application was merely sent — or never sent — is invisible to PropFlow.
The guest_cards report is available on the Data API we already use (v1 only: GET /api/v1/reports/guest_cards.json, verified live 2026-07-15; 404 on v2). It returns GuestCardId, GuestCardUuid (the CRM URL id), InquiryID, name, email, phone, unit, Source, Status, and received date. The Data API is GET-only — guest cards cannot be created through it; a write would have to go through the L4 browser agent.
Forces:
- Leads die outside PropFlow. Office-originated leads (walk-ins, direct calls, manual entry) never become prospects, so Clara can't nurture them and leasing KPIs undercount.
- Duplicate identity + lost attribution. Staff re-key Clara-touched leads into AppFolio with
No Source Recorded, severing Clara's attribution and creating a second, unlinked record of the same human. - Split-brain comms. Staff text from the AppFolio guest-card inbox while Clara texts from PropFlow — two threads, no shared history, and PropFlow's suppression/quiet-hours/TCPA logic (ADR-0092) can't see the AppFolio side.
- Voice transfers drop the lead. A leasing call that transfers to a human creates a Person but no ProspectInquiry, so the earliest Clara touchpoint is lost.
Decision
PropFlow is the CRM. Leasing prospects live and get worked in PropFlow; the PMS is a lead source, not a peer CRM we mirror into. We ingest PMS-side guest cards so no lead exists only in the PMS — we do not push prospects back.
Treat PMS guest cards as an external prospect source and sync them into PropFlow. The writer is PMS-agnostic: a neutral PMSGuestCard shape + guestCards adapter capability in src/lib/domain/pms/, with AppFolio as the first adapter that implements it (Yardi/RealPage slot in later).
Ingest (read path). Add guest_cards (AppFolio Data API v1) to the poller alongside rental_applications:
- For each guest card row, resolve identity through the existing claim system (email + phone, normalized E.164) exactly as the rental-application writer does.
- Match found → stamp AppFolio pointers on the existing prospect/person (
af.guestCardId,af.guestCardUuid,af.inquiryId) and backfill missing contact claims. Never overwrite Clara-known fields with sparser guest-card data. - No match → create a
ProspectInquiry(stageinquiry,entitySource: EXTERNAL, source mapped from the guest cardSourcecolumn;No Source Recorded→ a distinct "AppFolio manual" source so attribution gaps stay visible rather than being coalesced into a real channel). - Mirror guest-card
Status→ prospect active/inactive so cards marked cold in AppFolio don't linger as active PropFlow leads. - The existing rental-application poller keys on the same
InquiryID, so a later application submission merges onto the ingested prospect instead of creating a duplicate. - Outreach guard: ingested guest cards are created observational — no automatic Clara outreach or cadence enrollment. Express SMS consent was given to JP/AppFolio, not to Clara's number, and the outreach cadence (ADR-0061) must only be armed by an explicit PM action on these prospects. This mirrors the renewals posture (PM-approved, never autonomous).
Superseded in part (2026-09-15/18, https://docs.propflowai.co/a/syndicated-guest-card-replies — Decision section): the outreach guard above governed guest cards ingested from AppFolio's own staff-facing CRM. Fede's later ruling scopes a narrower, syndicated-listing population back in — a card AppFolio's own Actions Log says the SYSTEM created (not staff-typed), at a company that has explicitly opted in via a per-company
Organization.settings.leadSourcesetting (defaultleadSourcegate on inbound email) and the entity classification note below.
Write-back is explicitly out of scope (decision 2026-07-16): we are NOT creating guest cards in the PMS when Clara creates a prospect. PropFlow is the CRM — pushing prospects into the PMS would re-establish the split-brain this ADR exists to end. The earlier "Phase 2 — L4 write-back" idea is rejected, not deferred.
Also fix the voice-transfer gap surfaced by Jake's calls: a voice conversation classified as leasing intent (tour, availability, pricing) that ends in transferred must still produce a ProspectInquiry, not just a Person.
Entity classification
No new entities. ProspectInquiry gains no new fields beyond the existing af pointer block (guestCardId/guestCardUuid slot into the existing af pointer block on ProspectInquiry alongside rentalApplicationId/inquiryId).
Superseded in part (2026-09-15/18, https://docs.propflowai.co/a/syndicated-guest-card-replies): the first-email-reply work scoped in above by the outreach-guard note needs its own lifecycle (
pending/held/sent) that does not belong onProspectInquiry— a held draft is not a fact about the prospect, it is a fact about one outbound touch attempt. That work adds one new entity,ClaraFirstEmailIntent, keyed independently in DynamoDB, scoped strictly to the narrower syndicated-first-touch population above. This is additive to, not a reopening of, this ADR's "no new entities" ruling for the general guest-card ingestion this ADR governs.
Consequences
- Every AppFolio lead exists as a PropFlow prospect regardless of whether an application was ever submitted; the leasing funnel and settled-KPIs see office-originated leads for the first time.
- The poller gains a second report fetch per property per tick — the guest_cards report is small (tens of rows at Camellia) and the Data API rate limit (429s observed at ~5 req/burst) means the poller must keep its existing backoff.
No Source Recordedcards remain visibly unattributed instead of silently polluting channel attribution.- Split-brain messaging is explicitly not solved here — staff texts from AppFolio still don't appear in Clara's thread. That needs its own decision (guest-card communication ingestion) and is called out as a non-goal.
Alternatives considered
- Do nothing / rely on rental_applications. Rejected: both live examples show leads that PropFlow never sees (application sent-not-submitted, or no application), and staff are already building a manual parallel process.
- Write-back (create guest cards in the PMS from Clara prospects). Rejected 2026-07-16: PropFlow is the CRM; mirroring prospects into the PMS re-creates the two-system split-brain. Also mechanically ugly — the Data API can't write, so it would ride the fragile L4 browser agent.
- Auto-enroll ingested guest cards into Clara outreach. Rejected on consent grounds: TCPA consent context from listing-site/manual guest cards doesn't transfer to Clara's toll-free number, and outreach autonomy is a per-flow, PM-gated decision everywhere else in the product.
- Ingest guest cards as Conversations (message-thread sync). Rejected for now: the guest_cards report exposes lead metadata, not message bodies; thread sync is a different, larger surface.