Leasing Engine — Technical Designs

One place for every leasing design: touring, tour notices, staff check-ins, the prospect record, and the guest cards we write back into AppFolio. Three separate pages were folded in here on 2026-09-17.

Published 2026-09-17 · scope: Camellia (live), Western Slope (launching), Situs Group (next), Fairhaven + Willows (test) · owner: Trinity

TL;DR

Clara runs leasing end to end, and the property management system stays the system of record. A person reaches the leasing line by phone, text, email or a listing-site inquiry. Clara recognises who they are, answers, and books a tour on a real person's real calendar — sized to how much there is to show, no earlier than the company's minimum heads-up, and up to three nearby homes in one visit. The booking becomes a tour record; the leasing team gets a notice built from that booking and nothing else. About thirty minutes after the showing ends, Clara texts the agent one line asking how it went; the agent replies by text or voice note and that becomes the tour's notes and its outcome. Everything worth knowing then leaves through one write outbox onto the AppFolio guest card, so the client's own staff see it where they already work. On the prospect's side the same tour record drives the reminders, the application link an hour after the tour, and the follow-up chase.

The principles every design below obeys:

CHANNELS & IDENTITY CLARA'S LEASING ENGINE SYSTEMS OF RECORD Every channel phone · text · email · listing inquiry Identity & roles phone is the login prospect · agent · PM Touring engine hours, duration, pooled calendars Tour record the hinge: who's on the calendar Staff notice built from the booking only After-tour check-in Clara texts the agent at end plus 30 Calendars owned by real people PropFlow records reminders, application link, follow-up chase PMS write outbox one contract, one writer AppFolio the client's own system of record recognized syncs
Top to bottom: every channel reaches one identity check, identity hands off to the touring engine, and the tour record is the hinge — it drives the staff notice and the after-tour check-in above, and the calendar, PropFlow's own records, and the PMS write outbox below. Everything worth AppFolio knowing leaves through that one outbox.

The architecture in one line. Every channel funnels through one identity check into one touring engine; the tour record is the hinge, and everything after it is either a message to a person or a write through the single outbox to AppFolio.

Map

What is in this document, what state it is in, and where to jump.

SubsystemStatusSection
Touring hours vs. office hoursin review1
Weekend booking cutoff & confirm-or-cancelin review2
Backup tour host / poolproposed3, 17.11, 17.12
Observer BCC on staff noticesproposed4
Prospect card & tour noticeproposed5
The prospect notes fieldaudit — field retired6
After-tour leasing-agent check-inproposed7
Guest cards: assignment & note syncdark build in flight813
Guest card from a phone call (write outbox)proposed15
Touring engine (people, pool, duration, multi-home, lockbox)proposed17
Roles and staff toolsaudit complete18
One core prompt in codedecided20
Testing and deploying ElevenLabs agentsproposed21
Guest-card updates — audit & gapproposed22
Guest card management — architecture & all permutationsapproved27 (parent of 25, 26)
PropFlow’s opinions vs. AppFolio’s quirks — layering and refactorproposed28
The PMS record is the prospect record — deep inspection and optionsproposed29
Everything waiting on Fedeopen19

On this page

  1. Touring hours vs. office hours
  2. Weekend booking cutoff + confirm-or-cancel
  3. Backup tour host
  4. Observer BCC on staff notices
  5. Prospect card & tour notice
  6. The prospect notes field — audit and cleanup
  7. After the tour: Clara checks in with the leasing agent
  8. One guest card, three places
  9. Who a card is assigned to is the switch
  10. What we write to AppFolio today, and after
  11. Side effects to check before this goes near a real client
  12. The core-facts list
  13. Rollout: dark, then Willows, then Fede's yes
  14. Decisions for Fede — guest cards
  15. A guest card from a phone call
  16. AppFolio guest cards — appendix
  17. AppFolio guest cards — capability checklist, gaps, and the Willows harness
  18. The touring engine
    1. Architecture at a glance · the model · the resolver · the pool · lockbox · D1–D11 · appendices
  19. Roles and staff tools — audit
    1. the roles that exist · tools by role and channel · recognition per channel · gaps · four PRs
  20. One core prompt in code
  21. Testing and deploying ElevenLabs agents
  22. Guest-card updates — the write-intent audit, and closing the gap
  23. Voice verbosity audit — 2026-09-18
  24. Home identity through a scattered-homes booking — root cause + harness
  25. Guest card management — architecture and design
    1. the AppFolio model · 36 permutations · write catalogue · architecture · failure modes · decisions
    2. PropFlow’s opinions vs. AppFolio’s quirks — layering and refactor
  26. The PMS record is the prospect record — deep inspection and options
  27. Open decisions

Why this is one page

Every design here touches the same machinery — hours, calendars, the tour record, the staff notice, the outbox to AppFolio — and every one of them is written so it works the same way for whichever property turns it on next. Keeping them on three separate pages meant a session picking up leasing work had to already know which of the three to open. Now there is one place. Article III-A of the operating constitution is the reason none of these is a single-client page: a new-client feature has to work for every live property, with no regressions.

Tour designs

Sections 1–7. Absorbed from Leasing tour designs — September 2026 (2026-09-16), with section 6 and section 7 updated for Fede's 2026-09-17 rulings.

1. Touring hours vs. office hours

The problem

Clara has exactly one "hours" field per property, officeHours, and it answers two different questions at once: "is the leasing office open" and "can I offer a tour then." Western Slope's map is set to real reception hours (closes 3pm Friday, closed weekends), so any evening or weekend tour request gets refused as if the office itself were shut — which is what happened on a real call this week. Camellia only avoids this by accident: someone happened to type Saturday hours into the one field that exists.

The decision

Recommended: add a second, sibling setting, tour.hours, next to the existing office_hours key in Gera's settings registry — same per-weekday shape, org and property tiers, and if a property never sets it, tours keep using office_hours exactly as today (so Camellia needs zero changes). Only the code that decides "can I offer a tour at this time" reads the new key; the code that says "the office is open/closed" is untouched.

Two other shapes were considered and set aside: splitting office_hours itself into two renamed fields (cleaner long-term, but a much bigger, riskier rewrite touching dozens of call sites, and duplicates work Gera's settings-ladder swarm may already be planning); and a simple on/off toggle for "tour hours match office hours" (rejected — it models a business fact, not a new behavior, and Fede's 2026-09-03 ruling is that switches shouldn't be added just to model a fact).

The design

CamelliaWestern Slope
officeHours today (the only field)Mon–Fri 9–5, Sat 10–4, Sun closedMon–Thu 9–4, Fri 9–3, Sat/Sun closed
Separate tour-hours fieldnone — doesn't existnone — doesn't exist
Effect of that gapWorks by coincidence — its office hours already cover weekend touringBroke tonight — no window covers evenings or weekends
New tour.hours value (proposed)unset — falls back to office hours, unchangedMon–Fri 8–6, Sat/Sun 10–3 (once Fede approves turning it on)

Registry mechanics match the existing tour.durationMinutes precedent: org sets a default, property can override, nearest wins.

PR chain — status as of now

Everything here ships dark. Turning tour.hours on for Western Slope is a separate step, on Fede's go, per the standard rollout ladder.

No settings UI ships from this side; Gera's team owns the settings UI.

Still Fede's call

  1. Confirm the property-owned registry entry (tour.hours) is the right home, versus folding this into a bigger reshape of office_hours that Gera's settings-ladder swarm may already be planning. Recommended: sibling key, additive, no schema change to office_hours
  2. When PR 1–2 land: approve Western Slope's actual touring-hours values (Mon–Fri 8am–6pm, Sat/Sun 10am–3pm) before they're set live. Settings UI itself is Gera's to build, not this page's.
  3. Confirm this stays off for every customer, including Western Slope, until Fede explicitly turns it on — the new field ships dark by default.

2. Weekend booking cutoff + confirm-or-cancel

The problem

Western Slope asked for two things Fede believed Camellia already had: a Friday cutoff on booking weekend tours, and an automatic cancel if a prospect never confirms. Neither exists today, for either client. The only related code (a same-day booking ban) was never turned on for Camellia, and the "ask for confirmation" flow exists but currently does nothing when nobody replies — an unconfirmed tour just sits on the calendar forever.

The decision

Recommended: build both fixes for every property, not just Western Slope. The confirm-or-cancel gap (timeouts do nothing) is a real, pre-existing bug independent of weekends — fix it everywhere. Layer a weekend-only "reconfirm closer to the tour" step on top, scoped to whichever properties want it.

The weekend cutoff itself should be a fixed calendar deadline — "no new weekend bookings once Friday hits a set time" — not a rolling "N hours before the tour" rule, because a rolling rule silently drifts across Saturday vs. Sunday and can't express "cut off exactly at Friday close" cleanly. It's enforced twice: once when Clara offers slots (a blocked weekend is never shown), and again at the moment of booking, so a determined caller can't slip through by asking for an exact time the slot grid didn't show.

The design

SettingCamellia (proposed)Western Slope (proposed)
Weekend booking cutoffFriday 5:00pm → blocks Sat/SunFriday 3:00pm → blocks Sat/Sun (matches its Friday office close)
Confirm-or-cancel (existing "awaiting confirm" phase)on — fixes a real bug, timeout currently does nothingon
Near-tour weekend reconfirm ("reply YES or we release it")not requestedon, weekend tours only

On refusal, Clara never just says no — she offers the next open weekday and next weekend. On an unconfirmed tour timing out, the calendar hold is released, the team gets the existing cancellation notice, and the prospect gets a plain "we released the time, reply anytime to rebook" message rather than silence.

PR chain — status as of now

Still Fede's call

  1. Ship the cancel-on-no-reply fix for every property, or gate it to weekend tours only for now? Recommended: everyone — it's an existing bug, not a new behavior.
  2. Camellia's own "confirm or cancel" ask couldn't be found in any record — only a Situs ask for a longer pre-tour text window, and an unrelated staff no-show. Treat as Western-Slope-only for now, or is there a specific conversation to re-check? Recommended: Western-Slope-only for now; flag Camellia's version as unverified
  3. Does "no weekend bookings after Friday" mean a hard calendar cutoff regardless of how far out the weekend date is, or just more lead time on weekend requests? Recommended: hard cutoff — matches how Fede described it ("not creating a tour on Saturday").

3. Backup tour host

The problem

Western Slope has one leasing agent, Kat, connected for tours; the client wants Jonathan (maintenance) as a backup, and possibly a second backup from another company (Fusion) later. Today the tour-booking code only ever looks at the first connected agent's calendar — there's no concept of a backup or of combining more than one person's free time, and Situs Group will need a full multi-agent assignment engine on the same foundation soon after.

The decision

Recommended: build the "merged pool" version from the existing touring-engine design (people own calendars, code owns the choice) rather than a quick sequential fallback. It costs one more PR now, but it's the only version that doesn't need a rewrite the day Situs shows up with three agents needing day/area assignment.

Availability: ask the primary's calendar first; only fall to backup when the primary has nothing open in the window, or their calendar connection is dead. The prospect is offered one merged list of times and never picks a person or sees two calendars. Whoever's calendar actually had the open slot hosts the tour — the team notice says who; the prospect-facing confirmation still never names the host, per the standing rule.

The design

What ships

PR chain — status as of now

Still Fede's call

  1. Build the minimal sequential-fallback version first and redo it as a merged pool later, or build the merged pool now? Recommended: merged pool now — one more PR, avoids a rebuild.
  2. Is Ryan (Fusion) a same-company backup or a genuine cross-company partner? A cross-company backup needs an explicit membership across Gera's company "wall," not just a calendar hookup. Recommend confirming which before Ryan's calendar is added at all.
  3. Should backup's daily tour slots be capped so a maintenance person's day job isn't crowded out by tour bookings? Recommended: yes, a small daily cap.

4. Observer BCC on staff notices

The problem

PropFlow used to quietly copy hello@propflowai.co on client staff notices so the team could see what Clara was sending. It went through three rounds — added, pulled for flooding the shared inbox, added back narrower, then removed for good in September "pending a future per-client setting." This is that follow-through: a real, deliberate way to watch a new client's notices for a while, not the old blanket CC.

The decision

Recommended: one choke point, not twenty call-site edits. Add BCC support to the email sender itself (it doesn't exist today — only CC does), store the address list as an org-level setting with an optional property override, and have the sender resolve and apply it automatically to every notice. Nothing needs to change at the ~20 individual places that send a staff notice.

BCC beats the old CC approach: it's invisible to the client, so it can't collide with an address already on their own CC list the way the old version once broke Camellia's lease-signing notices. It ships with an on/off flag and an auto-expiry date, so nobody has to remember to turn it off once the team trusts the client's notices are working.

The design

ClientObserver BCC
Camelliaoff
Western Slopeon for launch (pending Fede's go)
Fairhaven (test)on for launch

PR chain — status as of now

Everything above ships dark (empty lists, no-op). Turning the BCC on for Western Slope's real mailbox is a separate step and needs Fede's own go — it's a live client-facing mailbox change even though the client never sees it.

#9001 is being reverted from main by #9035: its resolver read a company row inside settings-resolver.ts, caught by the portfolio harness probe E19-b, and will be re-landed with that company read moved to a fetch shell. #9023 (now carrying #9032's wiring and proof) waits on that re-land, with auto-merge disarmed until then.

Still Fede's call

  1. Which mailbox receives the BCC? Recommended: a new dedicated inbox (e.g. observer@propflowai.co), not hello@ — that address is now also the public contact-form inbox, and mixing traffic back in risks the same "drowned in noise" complaint that killed the original version.
  2. Default state for brand-new clients: on automatically with an off switch, or a visible onboarding toggle (defaults on)? Recommended: visible onboarding toggle — matches "give us a way to subscribe, then turn it off," and nothing sits on silently forgotten.
  3. Auto-expiry: manual off only, or a built-in expiry date (e.g. 60 days) unless renewed? Recommended: 60-day auto-expiry.

5. Prospect card & tour notice

The problem

Two related but separate pieces of work: what should be on the one-line "prospect card" a leasing agent sees before a tour (move-in timeline, budget, occupancy, bedroom count, and a short one-line summary — never anything that touches fair-housing-protected categories or feels like a background check), and a real bug where the team's tour notice email carried a hand-written note that had gone stale compared to the actual, current summary of the conversation.

The decision

Card research recommends a terse, SBAR-style structure (Situation / Background / Assessment / Recommendation — borrowed from healthcare handoffs) capped at a handful of scannable lines, action-first, no hedging, and a hard "never" list: no credit or background detail, no subjective "vibe" notes, nothing about a protected class. This shapes future card work; it did not itself require a code change. The tour-notice bug did, and it's already fixed: the note on the PM notice now always reflects the live summary instead of a copy that could drift stale.

PR chain — status as of now

Still Fede's call

  1. Adopt the SBAR-style card structure as the target shape for the prospect card, or keep the current flat field list? Recommended: SBAR structure — terser, and matches how high-performing leasing agents actually pre-qualify.
  2. Any of the "nice-to-have" fields (pet type, income indication, reason for move, amenity wishlist) worth building now, or hold all of them for a later pass once the must-have fields are live? Recommended: hold — ship the must-have set first, revisit once it's used on real calls.

6. The prospect notes field — audit and cleanup (Proposed — pending review)

Status: Audit complete 2026-09-17; nothing changed in code yet; tour-notice fix in flight separately.

TL;DR

notes is one free-text string on the prospect record with thirteen automated writers and no author, no timestamp, and no link to the conversation that produced it. It was born on 2026-03-04 as a plain field on a hand-seeded fixture, with no stated purpose and no staff form ever built to type into it. Every current writer is a machine: two Clara call tools, the email tour pipeline, the aggregator-lead settling pass, two voice post-call extractors, the duplicate-merge bookkeeper, and the AppFolio orphan sweep. They all append through mergeProspectNotes, which keeps the ten newest segments and stamps nothing. The staff tour-notice email then reads that string and renders its newest segment as short "facts" chips about the tour it is announcing — so whatever the last writer happened to leave there becomes, visually, a fact about this booking. That leak has fired three times in two weeks: Yale 2026-09-04 (a ten-day-old reschedule fragment), Western Slope 2026-09-12 (the sibling aiNotes field), Western Slope 2026-09-16 (an earlier call's maintenance mention). The 2026-09-16 guard (freshHandWrittenNoteForTour, PR #9016) is a genuine band-aid: it drops the note when it is datably older than the tour, but a row with no updatedAt still renders unconditionally, and a note written by a different module seconds earlier in the same request still renders as tour context. The real fix is that the tour notice should build from the booking's own record — Tour.notes already exists and is already written — and notes should stop being a shared dumping ground. Caution is warranted: notes is load-bearing in four non-obvious places (the voice reconcile's "is it empty?" gap check, the prospect card's unit-mention extractor, the merge audit trail, the ten-segment cap), so the cleanup has to be ordered, not blind.

Who writes it

ModuleFileExample textAppend / overwrite
Clara tool save_prospect (new lead)agents/clara/lib/agent/tools-leasing.ts:2770Raw model text, unscopedoverwrite (birth row)
Clara tool save_prospect (existing lead)agents/clara/lib/agent/tools-leasing.ts:2816Sameappend
Clara tool update_prospectagents/clara/lib/agent/tools-leasing.ts:2972-2974"touring on behalf of her father," etc.append
Email/SMS tour pipeline (new lead)src/lib/domain/leasing/tour/process-tour.ts:646extracted.notesoverwrite (birth row)
Email/SMS tour pipeline (existing lead)src/lib/domain/leasing/tour/process-tour.ts:810→876Same, mergedappend
Email tour-request extractor (success)agents/clara/lib/email/extract-tour-request.ts:109LLM prose from an untrusted inbound emailfeeds the pipeline writer above
Email tour-request extractor (failure)agents/clara/lib/email/extract-tour-request.ts:148"Tour request email — AI extraction failed. Subject: …"feeds the pipeline writer above
Aggregator-lead settling pass (Zillow / Apartments.com)src/lib/domain/leasing/apply-parsed-lead-facts.ts:431Raw, untrusted lead comment, verbatimappend (own glue, \n\n)
Voice post-call intakesrc/lib/integrations/voice/postcall-prospect-capture.ts:148LLM line; instructed to capture off-topic/maintenance remarks hereoverwrite (birth row only)
Voice post-call guest-card reconcilesrc/lib/integrations/voice/postcall-guest-card-reconcile.ts:515LLM line; tightly scoped, returns null for maintenance callsfill-only-if-blank
Duplicate-collapse bookkeepersrc/lib/domain/leasing/merge-inquiry.ts:108"Merged into inquiry <id> — person_merge_consolidation"append (\n)
Un-merge bookkeepersrc/lib/domain/leasing/merge-inquiry.ts:146"Un-merged from inquiry <id> — <reason>"append (\n)
AppFolio sync orphan pass (ADR-0097)src/lib/domain/pms/writers/rental-application.ts:2792"Auto-closed: no matching application in APPFOLIO (ADR-0097 orphan pass)"append (\n)

None of the thirteen writers stamps who wrote a segment or when. No dashboard writer of notes exists — the prospect edit API only writes aiNotes. AppFolio guest-card sync does not copy the card's own comments into notes (that leak does not exist).

Who reads it

SurfaceFileWhat it assumes the text means
Staff tour-notice email (chip line, no label)send-pm-tour-confirmation.ts:297A current, relevant fact about this person and this visit
Staff tour-cancellation email, same chip pathsend-pm-tour-cancellation.ts:162Same
Dashboard "units shown" chipsProspectDetailClient.tsx:1542 (extractMentionedUnits)Unit numbers in the note are units shown to this person
Dashboard move-in value fallbackProspectDetailClient.tsx:1543parse-notes.ts:6A move-in date in the note is this lead's move-in date
Server loader / API responseload-prospect-detail.ts:227Ships the raw string to the browser
Dashboard-chat LLM tool resultsrc/lib/domain/dashboard/chat/tools.ts:1249A field the assistant may quote back to a staff user
Voice guest-card reconcile gap checkpostcall-guest-card-reconcile.ts:119Non-empty ⇒ context already captured, don't backfill (load-bearing, not a display)
"New Tour Request" staff email, literal Notes: labeltour-request-template.ts:27,59A remark about this tour request (dormant — preview route only)

notes is never injected into any Clara prompt — both the voice and SMS/email personalization paths read aiNotes instead. No AppFolio push of notes or aiNotes exists.

Cross-module leak map

WriterReaderWhat the reader impliesRating
Voice post-call intake (captures off-topic/maintenance remarks by design)Staff tour notice chipsA fact about this bookingwrong-to-customer-facing-staff — the 2026-09-16 Ember Lane incident
Clara call tools ("any additional notes from the conversation")Staff tour notice chipsA fact about this bookingmisleading, escalating to wrong when the note is from an earlier interaction
Email tour extractor ("Tour requested: …")Staff tour notice chipsFacts about this bookingmisleading — the Yale 2026-09-04 shape
Email extractor failure path ("AI extraction failed. Subject: …")Staff tour notice chipsA fact about the prospectwrong-to-staff — an engineering error string rendered as lead context
Aggregator-lead settling pass (raw, untrusted lead comment)Staff tour notice chipsA fact about this bookingmisleading, plus an untrusted-text-into-staff-email path
Duplicate-merge bookkeeperStaff tour notice chips; dashboard chat toolA fact about the prospectharmless today (merged rows refused by resolver); misleading if a reader ever lands on a tombstone
AppFolio orphan sweepStaff tour notice chips; prospect cardA fact about the prospectmisleading — internal sync bookkeeping presented as lead context
Any notes writerDashboard move-in fallback"This lead's move-in date"misleading — any date mentioned anywhere becomes the displayed move-in
Any notes writerDashboard "units shown""Units we showed this person"misleading — any unit number anywhere becomes a shown-unit chip
Any notes writerVoice guest-card reconcile gap check"Notes non-empty ⇒ context already captured"harmless but load-bearing — removing writers will open this gap and cause new model calls
generateProspectSummary (rolling person summary)Calendar invite "AI Notes:" line (ungated)"Notes about this tour"misleading — the Redcliff Cir failure mode, still live
generateProspectSummaryTour notice heading "On this call"Asserts it is this callmisleading, held honest only by the sender-side freshness gate
generateProspectSummaryOutbound follow-up SMS/email to the prospect"A genuinely relevant personal detail to weave in"wrong-to-customer if the summary carries another module's or interaction's content
generateProspectSummaryClara's live voice + SMS prompts"Previous interactions — pick up where you left off"wrong-to-customer on the same condition
update_prospect's ai_summary arg + dashboard PATCHEvery aiNotes readerA dated summarymisleading — both write without aiNotesAt, silently disarming the #8083 freshness gate

Why the 2026-09-16 fix is a band-aid

Target design

The field is retired, not repurposed (Fede, 2026-09-17). An earlier draft said notes would become "staff-only remarks" with a dashboard editor. That is wrong: staff never type notes into PropFlow's website — "that's what every other app does." A leasing agent's notes come through Clara, by text, by voice note, or by call; they land on the tour or interaction record they are about, and they sync out to the AppFolio guest card through the write outbox. There is no notes box to build, because there is no typing UI.

notes therefore ends with no writers at all and is deleted. Every current automated writer moves to a per-interaction record: Tour.notes (already written, already passed to the tour email as visitNotes) for anything about a booking; ProspectInquiry.aiNotes + aiNotesAt for conversation content; and typed fields (leadFacts, preferences, desiredBedrooms, desiredMoveIn, moveInNote, targetUnitId, targetHomeAddress) for facts that already have a typed home. The tour notice itself builds from the booking's own record — Tour.notes/visitNotes plus the typed facts strip — never the person-level append log.

One writer, one contract

The reason thirteen writers happened is that anything could write the field. The replacement is a single chokepoint. One write function takes every note, wherever it came from, and refuses it outright unless it carries four things: the interaction id it belongs to, the channel it arrived on (text, voice note, call, email, sync), the author (a person id or a named system module), and a timestamp. A note with no interaction id is a bug, not a note, and the function throws rather than storing it somewhere general. It writes to the interaction or tour record — never to a person-level string — and readers ask for notes by interaction, so no surface can ever pick up a note that belongs to a different conversation. A lint rule blocks any new write to the old field, so the class of bug cannot come back through a module written next month by someone who never read this page.

Safe migration order

  1. Add notesWrittenBy + notesWrittenAt metadata (or convert to a ProspectNote[] shape with author/time/conversationId, mirroring WorkOrderNote). Pure add, low risk.
  2. Risky — tour notice stops reading notes; reads Tour.notes/visitNotes + typed facts only. This is the actual fix and ends the leak class outright. Risk: bookings with no Tour.notes lose their chip line — measure coverage in prod before merging.
  3. Gate the calendar invite's ungated "AI Notes:" line the same way the email path is gated. Low risk, self-contained.
  4. Make aiNotesAt mandatory on every aiNotes write. Low risk, closes the "unknown age renders" bypass.
  5. Delete the extractor failure text; log it instead. Low risk, no legitimate reader.
  6. Retire the dashboard's derived readers (units-shown, move-in fallback) onto the typed fields that already exist. Medium risk — measure typed-field coverage first.
  7. Retarget the voice reconcile gap check off notes onto leadFacts/preferences presence. Do this before step 8 — leaving it pointed at notes while writers are removed makes every leasing call pay for a model call it used to skip.
  8. Stop the automated writers, one PR per module, easiest first: email pipeline → aggregator → voice intake → reconcile → Clara call tools. Only safe once steps 2, 6 and 7 have landed.
  9. Move the bookkeeping lines (merge/un-merge/orphan-sweep) off notes onto mergedIntoId/closedReason/a sync-status field; update the one script that reads them today.
  10. Delete the notes field and its type. No dashboard editor is built — staff notes arrive through Clara and land on the interaction/tour record.
  11. Risky — clean existing rows. Irreversible data deletion; last step, and only after a dry run printing counts and ids, and only once earlier steps have run long enough to prove nothing still reads the old content.

Still Fede's call

  1. Land step 2 (tour notice reads Tour.notes instead of notes) now that it's designed, or wait for a measured Tour.notes coverage number from prod first? Recommended: measure coverage first — it's the step that changes what a customer's leasing team sees on every tour email.
  2. Order of stopping the automated writers (step 8): all five together, or one PR at a time with a bake period between each? Recommended: one at a time — each is only safe once steps 2, 6 and 7 have landed, and staging them makes a regression easy to attribute.
  3. Timing on step 11 (deleting existing row content): schedule it now for a fixed future date, or leave it open-ended until the earlier steps have baked? Recommended: leave it open-ended — it's irreversible, and 872 Western Slope rows alone carry notes today with other orgs still unmeasured.

7. After the tour: Clara checks in with the leasing agent (Proposed — pending review, not for today)

TL;DR. Today everything after a tour points at the prospect and nothing points at the person who ran it. The proposal: about 30 minutes after a tour is due to end, Clara texts the hosting agent one line — "how did the showing at 2367 go, any notes?" — on the agent's own mobile. The agent answers like a coworker, in any form: a text, a voice note, or (later) a call from the car. Clara already knows which tour she is asking about, files the free text as the tour's notes, records the outcome (toured / no-show / applying / not interested / reschedule), and pushes a note onto the AppFolio guest card through the outbox. The agent never opens a computer. Anyone with a leasing-agent role or above can file notes for a tour, not just the person on the calendar; when it is not obvious which tour they mean, Clara asks one short question. Every piece of plumbing this needs already exists for the property-manager and handyman personas — staff recognition by phone number, a tool set per role, voice-note transcription, an outbound staff text — except three things: a tour has no outcome record, a conversation cannot be pinned to a tour, and there is no leasing-agent tool set. There is no host field and there will not be one — who is scheduled to show is the calendar the tour points at, and we assume whoever had the calendar event is who showed up. If somebody covered, that is a sentence in the notes, not a field. Ships dark, Fairhaven and Willows first.

Whole design written down once so the shape is settled; we build it as small dark pieces, and none of it reaches a customer without Fede turning it on.

The picture

Tour ends, Clara opens a pinned staff thread, the agent answers by text, voice note or call, and the answer lands on the tour record and the AppFolio outbox Tour ends +30 min timer Staff thread, pinned "tour notes for Ana, 2367 Elm, ended 2:30" SMS to the agent's mobile Text reply Voice note (MMS) Call from the car later Tour-notes intent one open record channel is just input Tour record notes + outcome AppFolio outbox guest-card note

Solid lines are the first build; the dashed call-in leg is the later vision, and the design is shaped so it plugs into the same intent record rather than needing its own path.

a. What the client asked for

From the Western Slope onboarding call on 2026-09-16, raw transcript, with Kat (their only leasing agent) in the room.

Jason Fish: "We're… we always try to get you to do your showing notes. And so she's been doing showing notes in her guest cards when she's done with the showing. I really like the getting showing notes. Is there a world in which she could text Clara, and talk to her phone, and give Clara her showing notes, and Clara adds it to the guest card?"

Jason Fish: "text Clara that number, she can talk into her phone, hey, I just finished my showing at this, this, and this, it was really good, and that's where you can maybe even say, you know, they're gonna hopefully apply for this property."

Jason Fish: "Can [Clara] remind Kat to give the showing notes? I'm good, I'm better at it." … "Yeah, so they'll text you, hey, Kat, how was your showing at this? Did you want to leave me some notes? Yes."

Jason Fish: "She's getting stood up on a lot of showings recently. Today." Gera: "Yeah, people do that." Fede: "Yeah, we're seeing that too."

Fede: "Because we do send application links after the tour." Jason Fish: "Does she automatically send the app after a showing?" Fede: "An hour after, yeah." Sean: "One hour after it's settled."

Fede: "Yeah, and if you have notes, we could, like, customize the message with the notes, right?"

Fede: "So the magic here is that Clara is… you can text her or call her, right? And that's the UI, that's the interface. There's no app, there's no login." Gera: "Your login is your phone number, so you couldn't do this from a different phone, so that's… the way we authenticate, like, your permissions is the fact that it's your phone number."

Fede: "Yeah, you could either text her the notes or call her. Like, this is not built right now, but we can do it."

So the ask is confirmed by the client in his own words, and Jason also volunteered the two things that make it worth building: his agent forgets to write showing notes, and she is being stood up more than usual. Both of those are answered by the same one-line text.

b. What happens after a tour today

Every automated step after a tour is aimed at the prospect. Nothing is aimed at the person who ran the showing.

WhenWhat happensWho gets itWhere it lives
24 h beforeTour reminder textProspecttour-workflow.ts:944, lead :304; activity activities/tour.ts:227
1 h beforeSecond reminder (the "no-show catcher")Prospecttour-workflow.ts:1035, lead :306; activity activities/tour.ts:524
Tour happensNothing is recorded — no attendance, no host, no notes
60 min after tour startApplication link textProspectDEFAULT_APP_LINK_DELAY_MS tour-workflow.ts:305; timer :1104; activity activities/tour.ts:801
+1 d, +3 d, +7 dApplication chase, up to three touchesProspectoutreach-cadence-schedule.ts:201; activity post-tour-chase.ts:583

Three gaps matter here. There is no no-show state at all — the workflow's outcomes are only completed / cancelled / awaiting-confirm-timeout / activity-failed (tour-workflow.ts:141-145) and a tour's scheduling state is only proposed / confirmed / cancelled / declined (statuses/tours.ts:5-20); "no-show" today is a statistical guess derived from "confirmed, date passed, never applied" (insights/signals/tour-no-show.ts:7-12). Who is scheduled to show is the calendar, and that is deliberateTour.calendarNode (types.ts:11664, shape at :11677) points at the calendar that took the booking, and at Western Slope and Camellia every connected calendar belongs to exactly one person, so the pointer already answers the question. No host field is being added, now or later (Fede, 2026-09-17: "we're over engineering. Let's just assume that whoever had the calendar event showed up"). A stored copy would have to be kept in sync with the calendar and would drift the first time somebody moved an event. When the assignment changes, we move the calendar event and the pointer follows. There is no "actual host" field either — a cover is a sentence in the notes. Tour.notes exists but is only ever written at booking time (types.ts:11643, written once at apply-tour-intent.ts:657 from Clara's schedule_tour argument) and is what the staff tour email renders as visitNotes (send-pm-tour-confirmation.ts:303). Nothing writes it after the tour — which is exactly the slot the agent's notes belong in, and the reason they must never go near the person-level notes field that section 6 is cleaning up.

c. The proposed flow

Recommended trigger: 30 minutes after the tour is due to end (scheduled start plus the property's tour duration), only for tours that were confirmed and not cancelled. Thirty minutes is enough for the showing to run long and for the agent to get back to the car, and it still lands comfortably inside the 30-minute gap before the prospect's application link fires at start+60, so an answer can shape that message instead of arriving after it.

PieceDesign
TriggerNew timer on the existing tour workflow, fired at tour end + 30 min. Skipped when the tour was cancelled or declined, or when the tour has no resolvable host.
Who gets itThe hosting agent, derived at send time — tour → calendar pointer → the person who owns that connected calendar → their mobile — resolved from Tour.calendarNode when it names a person, else the property's single connected leasing agent (tour-calendar-pool.ts:56-79). If neither resolves, no text is sent — silently, no fallback to a generic inbox.
ChannelSMS to that person's own mobile, from the same Clara number the client already uses. No app, no login — the phone number is the identity, exactly as Gera described it on the call.
Message intentOne line, coworker register: name the property and the prospect's first name, ask how it went and whether she wants to leave notes. Example: "Hey Kat — how'd the 2:30 at 2367 Elm with Ana go? Any notes?" Nothing structured, no menu, no numbered options.
Recognising the replyThe inbound router already resolves a texter's phone to a Person and their active staff roles (staff/inbound-resolution.ts:1-90) and hands the result to the single identity chokepoint resolveActor (identity/resolve-actor.ts, ADR-0054), which is what sets the staff persona today. Nothing new is needed to know it is Kat; what is new is the leasing-agent tool set she gets. The full picture of what each channel can and cannot recognise today is section 18.4, and the four PRs that fix it are 18.6. Recognition is a role check, not a host check (Fede, 2026-09-17): the proactive text goes to the person whose calendar the tour sits on, but anyone in the org with a leasing-agent role or above — an assistant, a second agent, the property manager — may text or call Clara to add notes or an outcome for a tour. Clara does not refuse a note because the sender was not the one on the calendar.
Reply styleClara answers like a coworker who already knows the thread: "got it, added." She never re-asks what the notes are about, never asks the agent to restate the property or the prospect, and never renders a form.

Where each outcome lands

Agent saysTour recordProspect sideAppFolio
"went well, they loved it" + free textTour.notes appended with author + time; outcome touredNo change; application link fires as usual, optionally personalised with the notesGuest-card note intent
"they're applying"outcome toured, interest applyingStage advanced; chase cadence keeps running until the application landsGuest-card note intent
"not interested"outcome toured, interest not_interestedApplication link and the +1/+3/+7 chase are suppressedGuest-card note + mark-lost intent
"they never showed"outcome no_showApplication link suppressed; a single "sorry we missed you, want to rebook?" text insteadGuest-card note intent
"we're rescheduling for Thursday"outcome rescheduledRouted into the existing reschedule path, not a new oneGuest-card note intent
Nothing, everoutcome stays unset; intent expires quietlyEverything runs exactly as it does todayNothing

Outcome and interest are new typed fields on the tour and the inquiry. The agent's free text goes to Tour.notes — never to the person-level notes field, which section 6 is in the middle of retiring as a cross-module leak.

Quiet hours, nudges, and silence

d. Pinned context: Clara knows what the thread is about

Fede's requirement is that the agent can answer "oh these are the notes: …" with no structure at all and Clara already knows what she means — and that Clara never loses the thread the way she has before. That is a data problem, not a prompt problem.

A conversation today is one row per person per property, found on the inbound side by resolving identity then looking up by Person id (conversation-manager.ts:3560, GSI6 lookup at :3636-3644). It carries derived topic sets and one narrow "working object" pointer, Conversation.activeContext (types.ts:10237, shape at :9354-9370), which today can only bind to a turnover or a work order, is stamped from an inbound turn (conversation-manager.ts:7870-7935, via active-context-binding.ts:145), and is cleared by events rather than a clock. Nothing today stamps that pointer when the system initiates a thread — that is the missing piece.

The pin. A new TourNotesIntent record is written when the check-in text is sent: the tour id, the host person, the prospect's first name, the property, the tour's end time, and a state of open. The same write stamps Conversation.activeContext with a new kind: 'tour_notes' binding pointing at it. The outbound text and the pin are one operation — there is no window where Clara has texted someone and does not know why.

When a reply arrives on that agent's thread, the router reads the binding, loads the intent, and the model is given the tour in its context before it reads the agent's words. It is the same mechanism the turnover intake already uses to resume a walk mid-conversation, extended to one more kind of working object.

e. Late, ambiguous, and second tours — and notes from someone other than the host

Kat may answer four hours later, from the couch, having shown three homes in between. The rule has to work then.

f. Voice notes and, later, calling Clara

Voice notes already work. The Twilio inbound webhook handles media today: it reads NumMedia / MediaUrl{n} / MediaContentType{n}, classifies each attachment, and for audio downloads it and transcribes it through ElevenLabs Scribe before the message reaches the agent loop (src/app/api/twilio/webhook/route.ts:341-424; transcription at integrations/voice/scribe-transcribe.ts:76). An agent talking into her phone and sending it as an MMS is, as far as Clara is concerned, just text. Nothing is missing on this path — it needs no build, only the tool set and the pin above.

Calling Clara comes later, and it works like a turnover walk. Fede's shape for it: the agent calls, says "okay, this is what happened", and Clara listens and takes notes without interrogating — then files it when the agent is done. That voice UX already exists as the turnover intake persona, getClaraTurnoverWorkflow in agents/clara/lib/agent/clara-turnover.ts:1-217, which is explicitly built as an ambient scribe on voice: reactive, backchannel-gated, it does not drive the conversation. That is the module to reuse — the leasing check-in is the same pattern pointed at a tour instead of a unit walk. What is missing is upstream: the voice personalization webhook (src/app/api/voice/personalization/route.ts) classifies a caller as tenant, prospect, vendor, denied applicant or unknown and injects context as ElevenLabs dynamic variables (:3047 onward), but it has no staff branch at all — a leasing agent calling in today is treated as an unknown caller.

Because the intent record holds the state and the tour, not the channel, all three inputs converge: a text, a transcribed voice note, and a call are three ways of filling in the same open TourNotesIntent. Adding the call later adds a branch to the voice webhook and reuses the identical filing code.

g. Roles: personas are tool sets, and property manager is a superset

Fede's rule: the leasing agent is the minimum staff role and sees leasing only; the property manager has everything the leasing agent has plus maintenance, turnovers and vendor work. Built as a strict superset, a PM who runs a showing herself and texts Clara afterwards gets the identical tour-notes flow with no special case.

RoleTool setHow the role is recognised
Leasing agent
the minimum staff role
New: add tour notes · mark outcome (toured / no-show / rescheduled) · mark prospect interest (applying / not interested) · block my calendar · who's touring today. The note and outcome tools are scoped to the org's tours, not only the sender's own — a leasing agent or above may file notes for any tour in the org (the non-host path ships later; see section 7e). Plus the always-on forward_to_property_manager (capabilities/escalate-pm.ts:8-11).Inbound phone → Person → active PersonRole of type leasing_agent (types.ts:16809), matched by resolveInboundStaffMembership (staff/inbound-resolution.ts:1-90), which already lists leasing_agent in its staff tiers.
Property manager
and org admin, regional manager, assistant PM
Everything above, plus the 12 turnover tools (capabilities/turnover.ts:1-33, catalog tools-turnover.ts:31-363) and the work-order set the handyman persona uses (capabilities/handyman.ts:43-53). Vendor invoicing and the rest of the PM surface hang off the same list as they are built.Same resolution, then isPmTier / PM_TIER_ROLES in the same module. The PM tier is defined as the leasing set plus its own — one list that includes the other, not two parallel lists.
In-house handyman / vendor
unchanged
Work orders only, as today.vendors/inbound-resolution.ts:1-90, precedence resolved by resolveActor.

How this sits with Gera's portfolio model. The current direction on that page (16 September) is that roles describe and route work; they do not restrict ordinary in-scope reads or edits, and platform Admin is a separate three-person boundary. This design honours that: the role decides which tools Clara offers a texter, which is a routing and usefulness decision, not an authorization gate — and the existing code says the same thing, that staff tiers are a persona signal only, with guardrails living on the tool handler. org_admin, property owner and PMS credential holder are the ownership roles from that model and are orthogonal to this; the two that matter here, leasing_agent and the PM tier, already exist as PersonRole types.

What is missing on the people side. A Person carries no phone number directly — contact points live on separate identity-claim rows reached through primaryClaim (types.ts:16596-16680), so onboarding has to capture the agent's mobile as a claim for any of this to reach her. Leasing-agent assignment is org-wide, not per property: the calendar pool looks up every leasing_agent role in the org and the first connected one wins for every property, a gap the file itself documents (tour-calendar-pool.ts:33-40). And Western Slope's own test fixtures record Kat as org_admin rather than leasing_agent, which would put her in the PM tier — a data-quality item to fix before this is switched on anywhere.

h. What this extends, and what has to be built

Most of the machinery exists for other staff personas. The honest list:

PieceStateModule
Staff recognised by phone numberexistsstaff/inbound-resolution.ts:1-90identity/resolve-actor.ts; router at inbound-router.ts:469
Per-persona tool setsexistsagents/clara/lib/agent/capabilities/* — add a leasing-agent capability beside turnover and handyman
Clara texts a staff member firstexistsTurnover recap append-turnover-recap-message.ts:1-40; Temporal staff SMS activities/turnover.ts:1988; vendor-stall page notify-pm-stale-dial.ts:229-267. Send through messaging/dispatcher.ts, not raw sendSms, so policy and consent gates apply.
Staff turns kept out of the prospect's viewexistspm-internal-kinds.ts:29-42 and the pm_query turn scope (conversation-manager.ts:5088-5091) — the pattern for an internal turn that never renders to a customer
Voice notes transcribed on SMSexistsapi/twilio/webhook/route.ts:341-424, scribe-transcribe.ts:76
Listening voice UX ("just take my notes")existsclara-turnover.ts:1-217 — the ambient-scribe persona to reuse
A tour knows who hosted itexistsTour.calendarNode (types.ts:11664) is the single source, and we simply assume whoever had the calendar event showed up. One connected calendar = one person at every live property today. Nothing to build, nothing to store. If somebody covered, it is a sentence in the notes.
A tour has an outcomemissingNo attendance state anywhere (tour-workflow.ts:141-145, statuses/tours.ts:5-20); no-show is a derived guess (tour-no-show.ts:7-12)
A conversation pinned to a tourmissingActiveContextBinding (types.ts:9354-9370) only knows turnover and work order, and is only ever stamped from an inbound turn
A leasing-agent tool setmissingNo capability, no tools, no handlers
Writing a note to an AppFolio guest cardmissingGuest cards are read-only sync today (pms/writers/guest-card.ts); only work-order write intents exist (typed-wrapper-registry.ts:220-244, 420-424). This is section 15's outbox.
Staff branch on inbound voicemissingapi/voice/personalization/route.ts knows tenant / prospect / vendor / denied / unknown — no staff persona

i. Rollout

Standard ladder, no exceptions. Everything below ships to production dark behind a per-org switch that defaults off, so no agent at any live property is texted by anything that merges. Proving runs at Fairhaven (test) and Willows first, with a real tour booked on a test agent's own calendar and a real reply from a test handset — text first, then a voice note. Western Slope is the first candidate customer, with Kat's mobile captured during onboarding, and turning it on there is Fede's call and nobody else's. Camellia inherits the same switch, off, and turns on the same way — this is a feature for every property, not a Western Slope branch.

PR chain sketch

Each PR is one concern and dark on its own. The first customer-visible moment is the switch being turned on, which is a separate step and Fede's.

Still Fede's call

  1. How long after the tour does Clara text the agent? 15 minutes, 30, or 60? Recommended: 30 minutes after the tour is due to end — long enough for a showing that runs over, and still ahead of the prospect's application link at start+60 so the answer can shape it.
  2. Does the prospect's application link wait for the agent's answer? Recommended: no — it goes at 60 minutes as it does today, personalised with the notes if they have arrived, and suppressed entirely if the agent said no-show or not interested. Making a prospect's message wait on a staff reply risks it never going out.
  3. Text only to start, or text plus a tour-notes box in the dashboard? Recommended: text only — "there's no app, there's no login" is the point, and a second surface splits where notes live before the first one has proven itself.
  4. If the agent never answers: one nudge the next morning then let it go, no nudge at all, or keep asking? Recommended: one nudge, next morning, then silence — Jason asked for the reminder, but a second nag is how staff start ignoring Clara.

Clara inside AppFolio — guest cards

Sections 8–16. Absorbed from Clara inside AppFolio (2026-09-12 night, extended 2026-09-17). The tabs are gone; the content, decision lists and PR/switch tables are unchanged.

TL;DR — Clara inside AppFolio

Today Clara can only send a message inside an AppFolio guest card. She cannot be handed a card, and PropFlow's own notes about a prospect never show up on AppFolio's copy of that same person.

Two things are being added, both switched off everywhere: AppFolio's own "assign to a person" button becomes how a leasing agent hands Clara a card and how she hands it back, and a short plain-English note is written to AppFolio whenever PropFlow learns something worth knowing — a tour booked, a budget, a move-in date. Section 15 extends the same outbox to create a card from a phone call.

Rollout: dark, proven on the Willows test property, then a real client only on Fede's yes — never automatic.

8. One guest card, three places

Like a shared customer file that a sales rep, a call-center CRM, and an email thread all claim to have their own copy of — until someone keeps them in sync.

PropFlow, AppFolio, and email all hold a copy of the same prospect; core facts sit in the middle PropFlow guest card AppFolio guest card Email thread Core facts tour, budget, move-in...
Figure G1. The same prospect and the same handful of facts exist in three places today; only PropFlow reads all three.

Proves: code read — toGuestCard(), pms-adapter.ts, confirms AppFolio's report is read-only today (ADR-0094).

9. Who a card is assigned to is the switch

Like a help-desk ticket: whoever it's assigned to owns it, and handing it to someone else is the same click either direction.

A card's assignee flips who works it, in either direction Human leasing agent Clara's AppFolio seat assign reassign (hand-off)
Figure G2. Assigning the card to Clara starts her on it; reassigning it away is how she hands it back — the same button a human already uses.

Proves: research doc — AppFolio's own AssignedUser/AssignedUserID fields, confirmed in both the report and the live message feed.

Deeper → Appendix: PRs and switches

10. What we write to AppFolio today, and after

Like a coworker who can only reply to messages today — this teaches her to also update the ticket's owner and leave a sticky note.

Today Clara only sends messages; after, she can also be assigned and leave notes Today: send a message only After: + accept/hand off assignment, + short notes
Figure G3. One proven write today (messaging); two new ones, both dark behind their own switch.

Proves: code read — l4-core.ts, the one file every AppFolio write goes through.

11. Side effects to check before this goes near a real client

Like moving a ticket to your name in a shared help desk — it can email the old owner, drop off their dashboard, or just quietly reassign.

Four open questions about what reassigning a card actually triggers in AppFolio Does AppFolio email the assignee? Does it move team leaderboards? Does a human lose their task list? Does the next sync re-trigger us?
Figure G4. None of these are answered yet — the live look at AppFolio's real assign screen that would answer them was blocked tonight (see section 13).

Proves: appfolio-browser-agent service, live call — 503 LOGIN_BUDGET_EXCEEDED (8/8 used), tonight.

12. The core-facts list

Like a single index card every department reads from, instead of three people copying the same facts into three different notebooks.

One shared facts file feeds PropFlow's card, the email guest card, and the AppFolio note Core facts file PropFlow card Email guest card AppFolio note
Figure G5. Tour, application link, move-in, budget, bedrooms, pets, contact channel, and an end-of-conversation summary — one list, three renderings.

Proves: coordination note tonight — the shared file lands as its own first PR; this program imports it, doesn't fork it.

13. Rollout: dark, then Willows, then Fede's yes

Like a new hire who shadows quietly for a week before touching a real customer's account.

Three-step rollout: dark, Willows sandbox, then a real org on Fede's word only 1. Dark, switch OFF 2. Willows sandbox 3. Real org Fede's yes only
Figure G6. Every step ships dark; step 3 is never automatic, per the standard rollout rule.

Proves: Western Slope go-live tracker, rows gca-assign-clara-a1 and gca-assign-clara-chain-blocked.

Deeper → Appendix: PRs and switches

14. Decisions for Fede — guest cards

Three forks in the road — pick one per question, or say "keep thinking."

Three open decisions, each with a recommended option 1. AppFolio as a seat (ADR-0094 consent) 2. Assign on ingest, or only cards Clara already touched? 3. Note verbosity
Figure G7. Each is a multiple-choice pick, not an open question — full options in the appendix.

Proves: nothing decided here binds anything — every option ships dark either way.

Deeper → Appendix: full options

15. A guest card from a phone call

Someone calls the leasing line; today that lead lives only in PropFlow. This teaches Clara to also put a guest card where the client's own staff already work.

15.1 What we are building

A leasing call becomes a guest card in the client's AppFolio, not just a PropFlow lead. Four rules, all from Fede: the call gets no slower (the write happens after we answer the webhook, never during the call); PropFlow keeps being written exactly as today, AppFolio is a second, later, separate write; failed writes retry on their own; and the machinery must extend later to a repeat call, a cancelled tour, and "no longer interested."

15.2 The prior decision we are reversing

Status: Reversed by Fede 2026-09-17. docs/adr/0094-appfolio-guest-card-sync.md rejected write-back on 2026-07-16. Two of its three fears no longer apply — we already converse through AppFolio's own guest-card panel (live since 2026-09-02), and duplicates are handled link-first (§15.8). The fragile-browser-robot fear is real and answered by an outbox: a failed write just stays unsent. The ADR gets an amendment note pointing here, not a new ADR (ADRs are deprecated).

15.3 What already exists

Mostly wiring, not new machinery. The call path already extracts what a guest card needs — reconcileGuestCardFromCall recovers name, email, desired bedrooms, move-in from the transcript, and generateProspectSummary writes a per-call summary. The AppFolio side already has one door for every write (callL4), a live, proven guest-card-message send/read pair, and account resolution from the property's pmsAppfolioAccount (fails closed, never a default). The queue is SQS FIFO per AppFolio account (one browser operation at a time, existing fix for a real incident) into an agent-runtime Lambda, with idempotency and DLQ already built. Temporal is not the answer here — every Temporal workflow in the product is a durable wait; this job never waits, it only retries.

15.4 How the write leaves the call

sequenceDiagram
    autonumber
    participant C as Caller
    participant V as Voice line (Clara)
    participant R as call-ended route
    participant P as PropFlow store
    participant Q as SQS FIFO per account
    participant L as agent-runtime Lambda
    participant B as Browser runner
    participant A as AppFolio

    C->>V: calls the leasing line
    V->>R: call ended (webhook)
    R->>P: prospect + inquiry + tour written (today's behaviour, unchanged)
    R-->>R: HTTP 200 returned here — nothing below adds latency
    Note over R: everything below runs in after()
    R->>P: reconcile guest-card facts from transcript
    R->>P: write the call's own summary record
    R->>P: append intent create_card to the PMS outbox
    R->>Q: publishAgentJob(appfolio.guest_card.write, idempotencyKey = intent id)
    Q->>L: one job at a time per AppFolio account
    L->>B: POST /api/guest-card/create (accountId, property, facts, envelopeId)
    B->>A: fills the guest-card form in the staff portal
    A-->>B: card created, uuid + party id
    B-->>L: { guestCardUuid, partyId }
    L->>P: stamp prospect.af.guestCardUuid, mark intent sent
    Note over Q,L: failure → SQS redelivers → DLQ + alarm, intent stays unsent

15.5 Where the call notes live

Two records. Record 1 — the link (one per prospect): the existing af pointer block on ProspectInquiry gains guestCardUuid, guestCardId/partyId, guestCardAccountId, guestCardCreatedBy (clara vs pms), guestCardSyncedAt. Record 2 — the outbox (append-only, one row per intent): id (deterministic idempotency key), inquiryId/personId/propertyId/organizationId, kind, sourceId, payload, state, dependsOn (a note waits on its create), plus an audit trail. The id is derived, not random, so a duplicated webhook can never mint two cards.

15.6 The four write intents

IntentTriggerWhat it does in AppFolioIdempotency key
create_cardA leasing call ends, a prospect exists, no af.guestCardUuid yetCreates the card: name, phone, email, property, desired move-in, bedroomsgc:create:<inquiryId>
append_noteThe same person calls againAdds an internal note carrying this call's own summary recordgc:note:<inquiryId>:<conversationId>
cancel_tourA scheduled tour moves to cancelledNote only — does not change statusgc:tourcancel:<tourId>
close_cardProspect says they're no longer interestedMarks Inactive, with reason and commentgc:close:<inquiryId>:<stageAt>

The note text always comes from the call's own summary record (§15.5), never the person-level free-text field.

15.7 Status mapping, grounded in AppFolio's own docs

AppFolio's documented statuses are Active, Waitlisted, Pre-Qualified, Application Completed, Inactive, and Cold — there is no "Lost." Cards cannot be deleted. Marking Inactive needs a Reason from a dropdown whose values are not in AppFolio's documentation.

PropFlow stateAppFolio statusReason / comment
Active lead, any stage up to appliedActive (leave as created)
Tour cancelledunchangedNote only: "Tour for Thu 2pm cancelled by the caller."
Prospect said not interestedInactiveReason from AppFolio's own dropdown (values not yet read live); comment carries the one-line why
Prospect went cold, no answerLeave alone for nowPolicy call, not a code one — Decision 6

Form field names, required fields, and the Inactive reason list are being captured live at the Willows today; this table is updated when that recon lands.

Willows form recon result (2026-09-17) — Corrected by live proof 2026-09-17

Live proof on JP&Co test property 45, guest card 104, corrected three facts from the earlier read-only recon:

  1. AppFolio requires four fields, not two: first name, last name, guest_card[detail][source], and an interest (p_<propertyId> or u_<unitId>). Without the interest the form re-renders with HTTP 200 and nothing is created.
  2. Create redirects to /crm/leasing/prospects/<uuid>, not /guest_cards/<id>. The numeric id is read from the property-filtered guest-card index, matched by that uuid.
  3. Notes are POST /notes with note[body] (form at /notes/new?parent_id=…&parent_type=LeadToLease::GuestCards::GuestCard). inquiry_to_update[comments] belongs to the mark-as-status modal, not notes. POST /guest_cards/<id> is 404; PUT without a status is 500.
IntentRequestFields
create_cardPOST /guest_cardscontact_info[first_name], contact_info[last_name], guest_card[detail][source], interest (p_<propertyId> or u_<unitId>) — all four required; optional phone, email, desired rent, move-in, income, pets, assigned agent, comments
append_notePOST /notesnote[body], parent_id, parent_type=LeadToLease::GuestCards::GuestCard
assignPOST /guest_cards/<id>inquiry[assigned_user]
mark_inactivePOST /guest_cards/<id>inquiry_to_update[inactive_status_reason] (+ optional inquiry_to_update[comments]); reversible to Active

CSRF authenticity_token is read from the page first for every write. Live proof: JP&Co test property 45, guest card 104, created via POST /api/run-create-guest-card (appfolio-browser-agent PR #379). Earlier read-only recon artifacts and scripts remain in ~/.claude/appfolio-browser-agent/artifacts/guest-card-new-form/ and guest-card-assign-notes/.

15.8 When the card already exists

Link-first, always: check the prospect for af.guestCardUuid before queueing. No pointer → the runner looks up by normalised E.164 phone then email (the same claims the ingest writer already matches on). A hit stamps the found uuid and skips the create entirely — no second card is ever made, because AppFolio cards cannot be deleted.

15.9 Gates and rollout

Dark by default means nothing reaches AppFolio at all: the switch is checked before the intent is even queued, so with it off the outbox row is written skipped: gated and no job is published — the best possible dry run, at zero AppFolio side effects. Switch: CONFIG / GUEST_CARD_WRITE_FROM_CALLS#<orgId>, default OFF, per-org (matches the org-as-the-wall model). Rollout: (1) prod, switch OFF everywhere — ships under the standing grant; (2) Willows / Test Property 45 in jpco — the only database we may write to; (3) a real customer property — Fede's explicit call, a separate day. Never, in this design: Camellia's real properties, Situs Group, and Western Slope (read-only today). Stage 3 at any non-jpco client is additionally blocked on the browser runner's single-account hardcoding (N29-B3) until per-account routing lands.

15.10 The PR chain

PRWhatToday?
PR1 — #9105Link fields on the inquiry's af block + write-intent outbox (create_card, append_note, cancel_tour, mark_inactive), per-org switch pms.guestCards.writeFromCalls default OFF. No AppFolio call. SQS enqueue deferred to PR2.Open, CI running, auto-merge armed
Browser-agent route POST /api/run-create-guest-card — appfolio-browser-agent PR #379, proven live on test property 45 (card 104), auto-merge armed; PropFlow adapter being aligned to the four-field contract in #9105.
PR2createGuestCardL4 + the write handler, link-first lookup. Form recon resolved, see 15.7 result.Only if the form findings land today
PR3append_note on a repeat call, plus the dependsOn flush.No — after PR2
PR4Status intents: cancel_tour, close_card.No
PR5Ingest writer recognises cards we created — no second prospect, no attribution reset.No

15.11 Decisions for Fede

Decided (Fede, 2026-09-17): "Clara never writes prospects back into AppFolio" is reversed — Clara can now create a guest card after a phone call ("I said write guest card on phone call"). Also decided: guest-card notes stay short — the call's short summary, the same one already shown in PropFlow, never a long transcript-style note. And once a company is live for inquiries, a new card from AppFolio auto-assigns to Clara automatically; before that, cards are just recorded and left alone — no per-card human hand-off step.

Three questions remain. Reply with the numbers and A/B.

  1. When Clara creates that record, where should the call notes go?
    • A. A separate, clearly-labeled "Clara — phone call" note, distinct from staff notes.
    • B. Blended in with the client's regular notes, unlabeled.
    Recommended: A
  2. When a prospect tells Clara on a call that they're no longer interested, what should Clara do to that record?
    • A. Mark it inactive right then, since the person said so directly.
    • B. Leave it as-is and let a staff member close it out.
    Recommended: A
  3. Should this whole feature be one on/off switch for the entire company, or can individual properties be turned on or off separately?
    • A. One switch per company only — simplest, matches how the rest of the system is organized.
    • B. Per-company, with the ability to turn it off for one specific property if needed.
    Recommended: A
Engineering detail

Four short questions, recommendation is A each time: (4) create a card at a client whose staff already make cards — A: yes, but link-first; (5) the card's Source field — A: a distinct "Clara — phone call" source; (6) mark a card Inactive on our own when a prospect goes quiet — A: no, only when the person actually says so; (7) note verbosity — DECIDED (Fede, 2026-09-17): start with short — one note per call, the same short summary already shown in PropFlow, never a long transcript-style note.

Proves: everything above is dark. Nothing on this page has touched a customer's AppFolio.

16. AppFolio guest cards — appendix: PRs, switches, and the decisions in full

Full record for whoever picks this up next.

PRs and switches

StepWhatPRSwitchStatus
A1Read AppFolio's guest-card assignee into PropFlowpropflowai#8121none — read-onlyOpen, CI green
B1addGuestCardNoteL4 robot capabilitynot yet openednone — capability onlyBlocked on live capture
A2assignGuestCardL4 robot capabilitynot yet openednone — capability onlyBlocked on live capture
A3Ingest trigger, Clara's user id per orgnot yet openedCONFIG / GUEST_CARD_ASSIGN_CLARA#<orgId> (default OFF)Sequenced after A2
B2Core-facts note sync triggernot yet openedCONFIG / GUEST_CARD_NOTES_SYNC#<orgId> (default OFF)Sequenced after B1; imports the shared guest-card-core-facts.ts once merged

Why the live capture is blocked

The appfolio-browser-agent service's saved AppFolio session for our jpco/Willows test account had gone stale. Refreshing it needs a fresh login, and jpco's autonomous login budget (a hard, deliberate safety limit — not something to work around) was already at 8 of 8 for the trailing 24 hours. POST /api/debug/force-mfa-refresh answered 503 LOGIN_BUDGET_EXCEEDED with roughly a 12.5-hour reset. A read-only recon script (scripts/recon-guest-card-assign-notes-forms.ts in that repo) is written and ready to run the moment the budget clears — it will capture AppFolio's real "assign user" and "comments" form field names against Willows test card 57, screenshot included, before any write code is built.

Already known without a live capture: AppFolio's own internal API documents guest_card[detail][assigned_user_id] on the card-create form, and Clara's own jpco AppFolio user id is 250 ("Clara PropflowAI"), read off previously-captured message payloads.

Decision 1 — Clara messaging through AppFolio as a seat

Question: ADR-0094 blocked auto-enrolling AppFolio-sourced leads into Clara's own SMS/email outreach, because consent given to the PMS doesn't transfer to Clara's number. Messaging through AppFolio's own guest-card panel — as an AppFolio staff seat, not Clara's number — may be a different channel that sidesteps that specific objection. This has never been ruled on.

Decision 2 — assign on ingest, or only cards Clara already touched — DECIDED 2026-09-17

Question: when a new guest card arrives from AppFolio, should the sync auto-assign it to Clara, or should assignment stay a human's first move (a leasing agent hands her the card)?

Decided (Fede, 2026-09-17): once a company is live for inquiries, every new card auto-assigns to Clara on ingest — no per-card human hand-off step. Before a company is live for inquiries, cards are simply recorded and left alone.

Decision 3 — note verbosity — DECIDED 2026-09-17

Question: when PropFlow writes a note to AppFolio, how much should it say?

Decided (Fede, 2026-09-17): start with short — one short line per core fact, as it's learned (e.g. "Tour booked Thu 2pm"). No long transcript-style notes.

17. The touring engine

One visit, a list of people, more than one home.

TL;DR. A calendar belongs to a person, not to a building and not to a company. Everything else in touring — several leasing people, several homes in one visit, a minimum heads-up, a visit whose length grows with how much there is to show — falls out of that one change. The core loop that runs Camellia, Yale 25 Station and ConAm today does not change at all; a new optional layer sits between "here are the open slots" and "book it", and decides who goes, for a company running one pool of people across scattered homes. Off by default, on per company, Western Slope first. It buys real coverage math (two part-timers add up to full-time), a visit sized to what is actually being shown instead of a fixed hour, and up to three nearby homes booked as one visit instead of three. Nothing here is built. Every step ships dark and is proven at a test property before it is ever turned on for a customer, which stays Fede's explicit call. No host is stored — a re-host moves the calendar event and the tour's pointer follows it (Fede, 2026-09-17).

Written overnight 2026-09-10 → 11, extended 2026-09-13 with the centralized-team layer (people and roles, availability pool, proximity, staff-text reassignment, lockbox self-showing, presence). The implementation half belongs to Gera's portfolio architecture, step 8. Companion: the voice agent architecture decision. Subsections 17.1–17.20 are the design; 17.A1–17.A7 are the appendices, including all eleven decisions in full and the raw client transcripts.

17.1 Architecture at a glance — core vs. the centralized layer

The kitchen never changes. A host stand only gets added for a restaurant big enough that someone has to decide which server takes which table.

The core loop on top, unmodified; the centralized layer beneath it feeding into booking; a dashed future presence input CORE — untouched, every company Calendar→ slots Injectinto Clara OfferAM / PM Book Confirm Remind CENTRALIZED LAYER — off by default, on per company (Western Slope first) People &roles (org) Availabilitypool Preferencerules Proximityscorer Assigner Reassignmenthandler — staff text Presence Provider(Orca) — future
Figure T1. Core loop (top) unmodified. Centralized layer (middle) picks a host before booking; amber can re-enter later. Dashed = not built.

Proves: everything below is either the unchanged core or one box in this diagram.

17.2 The invariant, and the five questions it hides

Clara is a vending machine: she asks for times and takes what drops out. She never opens the machine to see how it's stocked.

Clara asks a separate availability service for slots and never sees the calendar behind it Clara Availabilityservice (opaque) Times only
Figure T2. Clara asks for slots, books one. She never sees a calendar, a rota, or a name.

Proves: today's code answers all five by accident. File map in A4.

17.3 Today, end to end — and where it is single-by-construction

A single-lane bridge: plenty of stages, but two of them only fit one car through at a time.

Today's tour path in seven stages, with the single-calendar chokepoint highlighted Clara's toolschedule_tour Webhooktools-leasing applyTourIntentthe one author One calendarbuilding's, elsethe company's Temporalreminders only One emailper building PMSguest card Same chokepoint answers "what slots?"one read per call, injected as plain text
Figure T3. Every stage is sound; red boxes can only ever hold one answer. Dashed = a separate write, not part of the chain.

Proves: sync-tour.ts, provider/resolve.ts, notification-recipient.ts — line-by-line map in A4.

17.4 The model: people own calendars, code owns choice

A taxi dispatcher doesn't own the cars — the drivers do. Dispatch only decides who takes the fare.

The company holds leasing people, each with her own calendar; a resolver folds them into slots that already name a host The companythe wall, and the hall hook: shared calendar (optional) hook: tour policy — heads-up · length Dana — leasingher own calendarMon–Fri · Grand Junction Situs, three peopleno calendar yet → ours The resolvermerge free timeapply heads-uppick the hostno randomness What Clara gets"Thursday 2:00, Friday 10:00"times only — no names, no calendars
Figure T4. People own calendars; one pure function turns them into times with a host attached.

Proves: matches Gera's portfolio design; needs his confirm on placement (D1, A1).

17.5 Western Slope, the first case

The company opens the front door once; each agent still carries her own house key.

The company mailbox and calendar held in reserve beside Kat's own calendar, which is what booking should read but today does not Company — leasing@mailbox + calendar, admin-approved onceheld in reserve — may go unused Kat — leasing agenther own connected calendarwhat Clara should book off Today's booking codereads Property.leasingCalendar, then the company hook — never a person
Figure T5. The company calendar is a reserve, admin-approved once. Kat's own calendar is what a tour should book — the dashed line shows today's code does not read it yet.

Proves: go-live tracker row te-people-calendars turning to tested, with a real tour landing on Kat's calendar.

17.6 The schema, in Gera's shapes

Like adding new labels to file folders that already exist — no new cabinets, no new filing system.

Four new attachment hooks on nodes that already exist, plus four optional fields on the tour Existing nodecompany · building · person ATTACH#calendar#primary ATTACH#assignment / #schedule ATTACH#setting#tour.* The tour4 new optional fieldsold tours read unchanged
Figure T5. Every new row is an attachment hook on a node that already exists. No new tables, no migration.

Proves: row shapes follow Gera's solution section; full row-by-row table in A4.

17.7 The resolver — one pure function, a fixed fold order

A recipe followed the same way every time: same ingredients in, same dish out, no guessing.

Eight-step fold: walk, policy, pool, drop, size, merge, cut, rank 1 walk 2 policy 3 pool 4 drop 5 size 6 merge 7 cut edge 8 rank → host
Figure T6. One function, eight steps, same order every time. Step 8 is the only place the three pooling choices in §17.10 differ.

Proves: unit-testable to the minute; two runs on the same data give the same answer.

17.8 The heads-up rule — one hour, per company

A restaurant reservation minimum: you can't book a table five minutes before you walk in.

A call at 1:15pm with a one-hour heads-up window shaded, earliest bookable slot at 2:30 1:15 callheads-up window (1hr) — nothing offered 2:30 first slot
Figure T7. The near edge is cut against the call time, not the start of the day — the caller sees 2:30, not 1:30.

Proves: Western Slope call transcript, 2026-09-09 — quotes in A2; no competitor ships a sensible default (A6).

17.9 The duration rule — the visit grows with what there is to see

Packing a suitcase: one shirt takes a minute to fold, five shirts take five.

One home is a 15-minute bar, two homes is a 30-minute bar 1 home = 15 min 2 homes = 30 min
Figure T8. Length = minutes-per-home × homes shown, computed before free time is merged.

Proves: Jason's own bug report on the client call — quote in A2; competitor survey in A6.

17.10 Two homes, one visit

Like one doctor's appointment that covers two check-ups back to back, not two separate visits.

One calendar event containing up to three home icons, capped at three One tour event Home A Home B cap: 3
Figure T9. One visit, one calendar event, a list of homes on it — not three tours that happen to be adjacent.

Proves: Jason's request on the client call (A2); only Rently batches units today (A6).

17.11 The pool: merge, share, or rotate

Three doctors sharing one waiting room beats three doctors each locked to their own — merging free time beats either extreme.

Three options — merge, share, rotate — with merge recommended Mergerecommended Shareone calendar Rotateby rule
Figure T10. The three honest answers to "combine their availability, or just one?" They differ only in the fold's last step.

Proves: Fede's original question; full comparison table in A4; competitor survey in A6.

17.12 Cover, reassignment, and the confirmation

A relay race baton pass: the runner who's out hands off automatically, nobody stops to ask who's next.

Trigger — out, decline, role end — flows into the engine, which reassigns and tells the new host Triggerout · declined · role end Engine re-hostsno buttons New host told
Figure T11. Out, decline, or role-end triggers a re-host inside the engine — never a human queue.

Proves: the Situs incident, 2026-09-01 transcript (A2) — the exact gap this closes.

17.13 Lockbox / self-guided showing

A hotel keeps its front-desk process but hands over a keypad code instead of a bellhop.

Two lockbox access tiers side by side: smart lock with a one-time code, and a static code with PropFlow-side controls Tier 1 — smart lock (later) Booking mints a one-time codescoped to the slot Code expires automatically after the visit Tier 2 — static code (start here) Booking → identity check → release codeno hardware change Code sent ~10 min before; can't auto-expire
Figure T12. Tier 2 needs no vendor deal — every control lives on PropFlow's side. Tier 1 is the later upgrade once a lock vendor is chosen.

Proves: Situs's leasing director's own request; six-product comparison in A3.

17.14 The double-book race, and what we already have

Two people reaching for the last parking spot at once — one has to lose cleanly, and be told why.

Two callers offered the same slot; a hold keyed by calendar decides who wins Caller A Caller B Hold (by calendar) First writer wins
Figure T13. A short-lived hold keyed by the calendar, not the building, is the missing piece.

Proves: existing durable-workflow-per-tour and single-author pattern; competitor behavior in A6.

17.15 Writing the visit back to the PMS

Updating your own calendar but not the shared family one — miss it and someone shows up confused.

PropFlow's database and AppFolio, currently only synced one direction for guest cards PropFlow DB AppFolio
Figure T14. Guest cards already write across; the showing record itself, with the host on it, does not yet.

Proves: every competitor treats PMS sync as table stakes — sources in A6.

17.16 What Camellia keeps, unchanged

A renovation that never touches the load-bearing wall a tenant already lives behind.

Single-building customers stop at step 1 of the walk and never reach the pool Building has its own calendar? Yes → stops here Pool / engine — never reached
Figure T15. Camellia and every single-building customer stop at step 1 of the walk and behave exactly as today.

Proves: replay test against production tour history.

17.17 Presence and Orca

A rideshare app assigning the nearest driver instead of a fixed queue.

A dashed presence provider box feeding into the proximity scorer, not built Presence Provider (Orca) — future Proximity scorer
Figure T16. A future presence signal is one more input to an existing function — nothing about the assigner's shape changes to accept it.

Proves: Situs transcript — Orca mentioned only for maintenance (A2); full detail on the Orca Staff Location page.

17.18 The change ladder

Climbing a ladder where every rung is tested for weight before the next one is trusted.

Ten rungs, first two serve Western Slope, next six serve Situs, last two are product 1–2 Western Slope: walk returns a list, calendar on a person 3–8 Situs: merge, policy, assignment, notify, holds, cover 9–10 Product: two homes one visit, PMS write-back
Figure T17. Ten steps, each dark, each with one exit test before the next opens.

Proves: full ten-step table with exit tests and owners in A7.

17.19 Decisions for Fede

A menu with the chef's recommendation starred — pick a number, or trust the star.

Eleven decisions grouped into five bullets, each with a recommendation already starred 11 decisions, D1–D11 — every one has a recommendation Open with Gera: D1 Fede's call: D2–D11
Figure T18. Every decision already carries a recommendation; nothing here waits on a blank question.

Proves: full options and reasoning for all eleven decisions in A1.

17.20 Risks

Checking the weather before the climb, not after the ladder's already up.

Five risk boxes, each pointing at its mitigation Reopens the 9/10 one-calendar ruling Read/write split failed once already Latency: pool of 3 ≠ 3× call time Unreadable calendar must never look free Situs arrives before the program ladder
Figure T19. Top five risks; two more (multi-home shape, rota-edit rewrite) are in the full table.

Proves: full seven-risk table with mitigations in A5.

17.A1 · Appendix — decisions in full

Every option considered for all eleven decisions, with the recommendation and why.

DecisionOptionsRecommendation
D1 · Where a calendar lives(A) On people only; the company keeps the mailbox for sending and has no calendar.
(B) On the company only — what shipped tonight, and what the dated ruling says.
(C) Both: the walk collects a pool of the company's leasing people plus the company's shared calendar if it has one.
C. A throws away a row that already works and that some companies genuinely want — a shared leasing desk is a real way to run an office. B cannot express "her calendar is the availability", which is exactly Western Slope. C is one word in the walk (collect, not stop), needs no migration, and makes the pool-of-one and the pool-of-three the same code path. Needs Gera: this refines his dock row from "a calendar attached to the org" to "attached to the org, a building, or a person", and reopens the 2026-09-10 ruling that said per-org, not per-officer. DECIDED by Fede, 2026-09-14: C — people's calendars; the company (leasing@) calendar stays as reserve/fallback.
D2 · What "available" means with several people(A) Merge everyone's free time, pick the host at booking.
(B) One shared calendar, nobody assigned.
(C) Rotate by rule, offer only whoever is up.
A. It is the only option where two part-timers add up to full coverage, and it is the only one where a pool of one is not a special case. B stays available as a company setting. C is a fairness mechanism wearing an availability costume — it makes us book fewer tours to solve an internal problem.
D3 · Reschedule keeps the person, or re-picks(A) Keep the original person if she is free at the new time.
(B) Re-pick from the rule every time.
A. The prospect has already spoken to someone; handing the visit to a stranger because the time moved by an hour is worse for the lease. Calendly ships exactly this as a toggle, defaulting the other way — we should default to keeping the person.
D4 · The heads-up default(A) 60 minutes, set on the company, overridable per building.
(B) One number for everyone.
(C) Per tour type — zero for a self-guided tour, longer for an agent-led one.
A now, C when self-guided tours exist. Jason asked for "at least an hour notice" and Fede promised configurability in the same breath; A is literally what was said on the call.
D5 · How travel time is counted(A) A flat buffer: 30 minutes between addresses, zero at the same address.
(B) Real distances from a table of minutes between properties.
A first. B needs a filled-in distance table nobody has, and Western Slope's spread — Grand Junction to Ridgway is not a 30-minute hop — will tell us fast whether the flat number is wrong. Ship the number, watch it fail, then buy the table.
D6 · The multi-home cap(A) Three.
(B) Two.
(C) No cap.
A. The only real-world number available says 2.6 units per batched tour. Three covers it; no cap turns one no-show into a wasted afternoon.
D7 · Who is told(A) The invite to the assignee; the company mailbox keeps sending the prospect's confirmation.
(B) Everyone in the pool.
(C) The company address only, as today.
A. B is how a pod learns to ignore notifications. C is the current bug.
D8 · Situs's calendars at launch(A) Give all three a PropFlow-hosted calendar on day one; swap in their real ones whenever they connect.
(B) Wait until they connect Outlook or Google.
A. They have no calendars today, and B makes their launch wait on an IT task at the client. The row is the same row either way.
D9 · Lockbox self-showing default(A) Open access, no schedule.
(B) Scheduled, unassigned — slot booked on the pool calendar, no host.
(C) Hybrid — self by default, agent-led on request or for lease signing.
B. Reuses the existing slot/booking loop and skips only the assigner step. C matches Situs staff's own wish to be present for signings, worth revisiting once B ships.
D10 · Lockbox access tier to build first(A) Tier 1, smart lock — one-time codes, auto-expiry, vendor audit trail.
(B) Tier 2, static code — existing lockbox, controls live on PropFlow's side.
B. Western Slope's and Situs's homes already have lockboxes; no hardware purchase or vendor deal needed. Tier 1 is the later upgrade.
D11 · Two new per-company settingsTour clustering gap (Situs's hand rule: 15 minutes) and the pre-showing confirm-text window (Camellia: 60 minutes today; Situs wants 120).Ship both as company-level settings, overridable per building, following the settings cleanup pattern — not hardcoded constants.

17.A2 · Appendix — Situs and Western Slope evidence, raw transcripts

Quotes are verbatim from the underlying per-speaker transcripts, not the meeting recap.

Western Slope call, 2026-09-09 — the heads-up rule

Sean: "Yeah, you can say, like, for this location, we need a minimum 1 hour notice, or, you know, like…"

Fede: "Yeah, I can't be like, hey, I need a showing right now. It's like, no, we need… so you guys can tell me if, you know, you want 30 minutes, or an hour, or whatever, we can configure it that way."

Jason (Western Slope): "if we told Clara, yeah, you can go ahead and book a showing during these hours with at least an hour notice."

#transcripts, thread on the 2026-09-09 client-call recap. Settled as a rule to configure per company, not as one universal number.

Same call — why a fixed hour is wrong (duration)

Jason: "I scheduled two showings … one at 10 and one at 11, and I assumed it's assuming drive time, but those are… those are next door to each other, you know what I mean? So there's obviously some logic that we can put in there, where, like, yeah, I need, like, 15-20 minutes in one, and then we'll walk…"

Same call — two buildings, one visit

Jason: "even like, if you have a giant apartment building, and across the street is another giant apartment building that you own. You could — same logic would apply — like you can show both of those properties in the same showing with a little bit of incremental time as opposed to like a whole hour block."

Situs Group, raw transcript, 2026-09-01

17.A3 · Appendix — self-showing / lockbox product comparison

Full research: self-showing-competitors-2026-09-13.md.

The three shapes considered (§17.12)

OptionShapeNote
(A) Open accessNo schedule at all — the prospect gets a code any time inside the property's showing window.Fastest for the prospect, but no record of who is on-site when, and nothing stops two prospects colliding.
(B) Scheduled, unassignedThe slot is booked on the pool calendar exactly as an agent-led tour would be — so nobody double-books it — but no host is assigned. Clara handles the code and the follow-up.Recommended Keeps the existing slot/booking loop unchanged; only the assigner step is skipped.
(C) HybridSelf-showing by default; agent-led on request, or always for the lease signing.Matches Situs's own staff pushback but is two modes to build and test, not one.

Required sub-decision: identity verification before the code is released. Self-showing has no person present to catch a mismatch, so the code cannot go out until the prospect's identity is checked against who booked the slot — a phone-number match at minimum, ID capture as a stronger option. This gates the release step in both access tiers, not just option B.

Six-product comparison, 2026-09-13

ProductSettingHost assigned?ID verificationCode deliveryCoexists w/ agent tours?
EliseAIPer-propertyNo — AI guides via SMSID scan + CC hold ($0.99) + likely liveness selfieSMS, time-scoped, smart-lock partnersUnverified in public sources
RentlyPer-unit (subscription)No; staff may pre/post-checkID (front+back) + liveness selfie + CC hold — strictestSMS, one-time, serial-number gatedYes — 64% of showings self-guided, rest agent-led
Tenant TurnerPer-propertyNoGovernment IDSMS, time-scopedYes
ShowMojoPer-property/accountNo (self) / Yes (agent)ID + phone (unverified detail)SMS, varies by lock (MojoBox, CodeBox, Vault eLock)Yes
Knock (RealPage)Per-propertyNo — AI-routedID + selfie (CC unverified)UnverifiedYes
FunnelPer-propertyNoUnverifiedUnverifiedYes
AppFolioPer-property, via integrationsVia integrationsVia integrations (ShowMojo, Tenant Turner)Via integrationsYes (integrations)

Industry convergence: time-scoped one-time codes on smart locks are standard; ID + liveness selfie + a small credit-card hold before code release is near-universal (Rently strictest); AppFolio has no native self-showing — it delegates to ShowMojo, Tenant Turner, RemoteLock or CodeBox; scattered-site single-family operators (Western Slope, Situs's shape) are the heaviest self-showing users because there is no on-site staff. Several cells above are marked unverified in the source research — not confirmed in public documentation, not a design assumption.

17.A4 · Appendix — file map and schema

Every place in the code that can only hold one answer today, and every new row the model needs.

The ten places that can only hold one answer

WhereWhat is hard-codedWhy it matters once there are two people
The calendar chokepoint
sync-tour.ts:159-200
Exactly two rungs: the building's own connected calendar, then the company's. There is no rung for a person.This is the whole refactor in one function. Every other row below is downstream of it.
The provider resolver
provider/resolve.ts:56-188
Returns one calendar, or nothing. No list, no merge.A pool needs a list. The return type is the first thing that changes.
The provider interface
provider/types.ts:198-233
One adapter, one timezone, per calendar. No type exists for "merge these sources".Merging free time is a new, small type — not a rewrite of the adapter.
Availability
check-availability.ts:758, :833
One calendar read per day, one event list filtered.Becomes one read per person per day. Latency is the real cost; see A5.
The dated ruling
org-calendar.ts:1-56
In the file header, as a decision: "one calendar per operator org … not one per leasing officer and not one per building."This is the one thing on this page that contradicts Fede's refinement. Decision D1.
The three voice tools
src/lib/tools/leasing.ts:513-826
Book, reschedule and cancel carry no host, agent or staff parameter — only which building and who the prospect is.Good news. Nothing in Clara's contract has to change. The signatures are already right.
The booking handler
agents/clara/.../tools-leasing.ts
Zero references to any assignee concept when booking.Assignment is new code, not a change to old code.
The tour record
src/lib/data/types.ts:11004-11124
No field for who is showing it. The only "who" on a tour is the prospect.Three optional fields added; an old tour without them reads exactly as it does today.
The notification
notification-recipient.ts:34-50
Resolves to exactly one address: the building's email, else the calendar's own mailbox.The person who is actually going is not told. Smallest user-visible bug in the list.
The slot injection
leasing-context-injection.ts:976-1054, :1418
One property, one window, formatted as a plain-text block of times and handed to the prompt. Never cached.The shape is already right — a list of times, no calendar ids. It just needs a better list behind it.

Two more facts. The durable workflow keyed on the tour (workflowId = tour.id) owns only the reminder timers and reacts to three signals carrying a date and a time — it has no opinion about who hosts. And the tour decider is not a slot picker at all: it classifies what an inbound message wants (confirm, reschedule, cancel). Neither has to learn about people.

The schema, in Gera's shapes

No new tables, no partition moves, no migration. Every row below is an attachment hook on a node that already exists — a company, a building, or a person — written dated, read by the one resolver.

The rowLives onWhat it carries, and why
ATTACH#calendar#primarya person
or a building, or the company
Which provider, which calendar, and a pointer to one credential in the vault. This is the only new placement — the same row already exists on buildings and on the company today. Situs's three people get ours (a PropFlow-hosted calendar) because they have none; the row takes their real Outlook or Google the day they connect one.
ATTACH#assignment#<person>#leasingthe company
or one building
Who shows homes here. Carries the days she covers, the areas or buildings she covers, which calendars her visits are written to, and a priority. Placing it on a building instead of the company is how "she only covers Fruita" is said.
ATTACH#schedule#leasing#<rule>the company
or one building
The rule that picks between the people: whoever owns the calendar, first-and-backup, or rotate. A sick day or a swap is a second schedule row on a higher layer with a date window, which masks the one below it for those days only. No row is edited to take someone out for a week.
ATTACH#setting#tour.*the company
overridable per building
The three numbers from the client call: minimum heads-up (default 60 minutes), minutes per home (default 15), and the cap on homes in one visit (3). Read by the resolver, never by a prompt.
ATTACH#preference#<person>#leasinga personStatic per-person settings: which days she tours, morning/afternoon preference, a daily tour cap. Feeds the ranking step (fold step 8), never reasoned about live. Not built until a client asks — named here so the row shape is reserved.
ATTACH#setting#tour.gapMinutesthe company
overridable per building
The clustering gap between two back-to-back showings — Situs's own hand rule is 15 minutes. Same settings-row pattern as the settings cleanup; default 15.
ATTACH#setting#tour.confirmWindowMinutesthe company
overridable per building
How long before a showing the auto-confirm text fires. Camellia runs 60 minutes today; Situs wants 120. A per-company number, not a constant.
HOLD#<calendar>#<start>keyed by the calendarA short-lived claim on a slot while a caller is deciding. Keyed by the calendar, not the building — otherwise two buildings sharing one person's calendar each think the 10:00 is free.
LRB#<node>#leasinga personLeast-recently-booked counter, advanced inside the booking write. This is all a fair rotation needs — it is how Calendly, HubSpot and Cal.com all break ties.
Four fields on the tourthe tourassignedPersonId, the calendars the event was written to, which rule decided it, and the length. All optional: a tour written before this exists reads exactly as it does today.

The pool: full comparison table (§17.10)

Merge everyone's free timeOne shared calendarRotate, with rules
In one lineOffer any time anyone is free; decide who goes at the moment of booking.Everyone writes to one team calendar; busy is busy, nobody owns a visit.Offer the free time of whoever is up next under the rule, by day and by area.
What the caller getsMost times The union. Two people who each work half the week look like full coverage.Fewest times Anything anyone blocks removes the slot for everybody.Fewer times Only the person whose turn it is, so a busy person hides a free colleague.
Who goesDecided at booking, from the people free at that time. Fair over the month via the counter.Nobody. The team sorts it out in the morning.Decided before the slot was ever offered, which is why it offers fewer.
RescheduleKeep the same person if she is free at the new time; otherwise re-pick.Nothing to decide. The event moves.Re-pick from the rule, which can hand the visit to someone who never heard of it.
CancelRelease the slot, step the counter back so she is still next in line.Delete the event.Same, plus the rotation is now off by one unless corrected.
Double-book riskReal and must be handled.Real, and worse across buildings sharing the calendar.Lower — fewer people are ever offered the same slot.
VerdictRecommended It is the only one that answers "two people, Monday and Wednesday" without losing coverage.Keep as an option Right for a company that genuinely runs one desk.Not first Trades the caller's choice for internal fairness.

The Situs question, concretely: two or three people show apartments there. Under the recommendation they keep their own calendars — ours to start with, since they have none — availability is the merge of all three, and the rotation counter only breaks ties between people who are both free. Days and areas are rows on the assignment.

17.A5 · Appendix — open bugs and risks, in full

RiskWhy it is realWhat removes it
It contradicts a ruling made the same nightThe decision record for 2026-09-10 says, in as many words, one calendar per operator company — "not one per leasing officer and not one per building" — and the code that shipped carries that sentence in its header. D1 reopens it.Gera confirms the widened placement before step 2 starts. The collect-walk framing means nothing already built is discarded.
The read/write splitIt has already happened once: the previous attempt moved availability to a person's calendar and left every write on the building's, which would have offered slots and booked them somewhere else.Step 2's exit test requires book, reschedule and cancel all read back from the person's own calendar. One step, both halves, or the step is not done.
Latency on the voice pathSlots are computed live at the start of every call and are deliberately never cached. One calendar read becomes one per person per day.Measure a pool of three against the current single read before step 3 ships, fetch the pool's free/busy in one provider call where the API allows it.
A calendar we cannot read looks freeThis is a shipped failure mode in a real product: HubSpot treats a member with a disconnected calendar as always available. Our own previous attempt had the same shape.Unreadable means dropped from the pool with a named reason, and an empty pool says "no calendar connected" rather than offering the whole week.
Situs arrives before the engine doesTwo weeks versus a November slot on the program ladder.Either the thin slice is pulled forward, or Situs launches with three people on one shared calendar. Decide it now rather than in the week of their launch.
The multi-home change is the only non-additive oneEvery other step adds a row or a field. A visit covering several homes changes the relationship between a tour and a home, which every existing reader of a tour touches.Keep the primary home exactly where it is and carry the extra homes beside it, so old readers keep working. Also why it is last in the ladder.
Pooled people and owner consentA leasing person attached to the company, showing a home whose owner never agreed to a shared leasing operation, is a case Gera's design flags as undecided and which fires on ordinary stock at go-live.The consent filter is a step in the fold and must be asserted by name in the tests.
A rota edit rewrites the weekIf a booked visit re-derives its host from current rows, changing the rota on Wednesday moves Thursday's visits.The tour keeps a snapshot of the rule it was booked under. Already in Gera's design; must be in the test.

17.A6 · Appendix — what every other product does

Two research passes, 2026-09-10: general-purpose schedulers for the mechanics, leasing tools for the domain. The mechanics are a solved problem with well-known names; the domain-specific parts we need — a visit sized by its contents, several homes in one visit — are almost entirely unsolved in the market.

How the pool picks a person

Minimum notice, buffers, duration

The leasing tools

17.A7 · Appendix — the change ladder, in full

Ten steps. Every one ships dark — deployed with the behaviour off, proven at a test property, turned on for a customer only as a separate decision. Every one has a single test that has to be green before the next opens.

#StepWhat changesThe exit test
1The walk returns a list in flight, dark, PR pending (2026-09-14) for Western SlopeThe one chokepoint collects instead of stopping at the first hit: building's own calendar, else the company's people and the company's shared calendar, else none. Callers take the first entry.A replay of real Camellia tours is byte-identical, and a building with no calendar of its own still books on the company's exactly as it does tonight.
2A calendar on a person in flight, dark, PR pending (2026-09-14) for Western SlopeSigning in writes the calendar hook on the person who signed in. Read and every write — book, reschedule, cancel, both conflict checks — move together.At a test property: book, reschedule and cancel a real event on that person's own calendar and read all three back from the provider. This is the test PR #7615 could not have passed.
3Merge the free time next, supported by design; not needed while Western Slope has one agentAvailability becomes the union of the pool's free time minus active holds.Two calendars with different busy blocks produce the union; a calendar we cannot read is dropped with a named reason, never counted as free.
4The policy rowsMinimum heads-up and minutes-per-home as settings on the company, overridable per building.A 1:15 pm request with a one-hour heads-up offers 2:30 first; a two-home request offers 30-minute slots; a company with no rows behaves exactly as today.
5Assignment and the ruleDays, areas and priority as rows; the rule picks the host; the tour records who, which calendars, which rule, how long.Three people with different days and areas produce the same host for the same input every time, and the fairness counter advances exactly once per booking.
6The right person is toldThe invite goes to the assignee with the prospect's context in it. The company mailbox keeps sending the prospect's email.On a pool of two, the assignee receives the invite and the other person does not; the prospect's confirmation is unchanged.
7Holds, keyed by calendarA short-lived claim on the slot plus a read-back after every write.Two bookings for the same time on one shared calendar, arriving from two different buildings at once: the second is refused with a reason, not written.
8Cover, in the engineA dated row masks someone; a declined invite re-hosts; an ended role re-hosts. No buttons anywhere.Decline an invite: the visit is re-hosted, the prospect's time does not move, and the slot is never silently freed.
9Two homes, one visitA list of homes beside the tour's primary home, capped at three, one calendar event, length from the rule.Two adjacent homes book as one 30-minute visit; cancelling it cancels the whole visit; every existing reader of a tour still works.
10The PMS learnsThe showing written back to the client's PMS with the assignee on it.A booked visit appears on the guest card in AppFolio, read back from their API, with the host named.

Owners. The pure functions — the fold, the merge, the ranking — are Gera's, as step 8 of his ladder already says. The rota facts and the Situs calendars are Fede's. Steps 1, 2 and 6 are touring-side work that does not wait on the rest of his program, because they change one function, one sign-in write and one recipient.

Nothing on this page is built. Every step in the ladder ships with the behaviour off; turning any of it on at a real property is a separate step and Fede's explicit call. Open with Gera: the calendar placement in D1, and whether the fold in §17.6 is the shape he wants for step 8 of his program.

18. Roles and staff tools — audit

Everyone in the company already has a job title on file. Clara just forgets it the moment they pick up the phone.

Audit date 2026-09-17, read-only, against origin/main = ca109e0a14. Cross-checked against Gera's portfolio architecture.

TL;DR. The role words Fede wants already exist in code — property_manager and leasing_agent are shipped role values, offered at invite, with his exact ladder already written in plain English. The dashboard half is basically done: roles gate screens and carry a per-building scope. The conversation half is not. When someone calls, texts or emails, every staff role collapses into one flat word, "pm" — the code says so out loud. A leasing agent is the worst-served person on the system: on the phone she is not recognised as staff at all, and on text she is recognised and then explicitly denied the leasing tools. There is no tour-notes tool on any channel; the turnover walk-through is the working template for one. (Two other findings — the ~60 AppFolio actions being admin-runner only, and vendor invoicing not existing — are out of scope for now, not gaps.) Nothing here conflicts with Gera's model. So the build is not "add two roles" — it is: recognise the role the same way on every channel, then give each role its tools. Four small PRs.

flowchart TD
  P["Phone number"] --> RA["resolveActor and pm-call-context"]
  E["Email address"] --> RA
  L["Dashboard login"] --> AU["Session, then spine lookup"]
  RA --> PR[("PersonRole rows
org- or building-scoped")] AU --> PR PR --> V{"Channel"} V -->|Voice| VV["ONE true/false flag: pm_is_pm
leasing agent NOT seen as staff"] V -->|SMS and Email| VS["staff tiers kept,
then flattened to 'pm'"] V -->|Dashboard| VD["full role kept"] VV --> TV["Tools: the 4 turnover ones only"] VS --> TS["Tools: turnover + metrics + blast text
LEASING TOOLS EXPLICITLY REMOVED"] VD --> TD["Screens by permission matrix"]

The whole audit in one picture. Three doors into the same role rows. Only the dashboard door keeps the role intact; the two doors staff actually use throw it away.

18.1 The roles that exist

Login-side roles

RoleDefinedWhat it can doOffered at invite?
platform_admintypes.ts:13871PropFlow staff, crosses every companyNo — staff only
org_admintypes.ts:13872Everything in their company, invites anyoneYes
property_managertypes.ts:13873Day to day: leasing, maintenance, money, residents, vendorsYes
leasing_agenttypes.ts:13874Leasing end to end; reads residents and buildingsYes
maintenancetypes.ts:13875Work orders and vendorsYes
viewertypes.ts:13876Read onlyYes
accounting, regional_manager, assistant_property_manager, leasing_assistanttypes.ts:13888-13891All equivalent to property manager (owner ruling); regional outranks for invitesNo longer offered

Fede's ladder is already written there, nearly word for word (role-grants.ts:95-103): a leasing agent can "run leasing end to end: prospects, tours and renewals", and cannot "open maintenance or vendors at all"; a property manager runs "the day to day: leasing, maintenance, collections, residents and vendors." Supporting files: the invite menu role-catalog.ts:83-110, screen permissions permissions-source.ts, the invite ranking permissions.ts (ROLE_HIERARCHY).

Conversation-side roles (the spine)

A second, longer list used by Clara — same jobs, one different spelling. types.ts:16798-16815: tenant, prospect, pm, org_admin, leasing_agent, maintenance, viewer, platform_admin, vendor_contact, plus four team labels. The role row carries person + role + scope + active (types.ts:16853-16884), and scope is one company or one building (:16817-16828). Which count as staff: inbound-resolution.ts:45-56. Which count as "a PM": :68-74 — and that list excludes leasing_agent, maintenance and org_admin, which is the root of most of what follows.

Name mismatch worth knowing: the login says property_manager, the conversation side says pm. They are translated in exactly one place (link-user-to-spine.ts:118-146). Not a bug — but it is why searching for one word misses half the system.

Against Gera's model

Gera's wordIn code today?Where
org_adminyestypes.ts:13872, :16802
Property owner (the landlord)no, deliberatelyHolds no rung; would arrive as a grant on an ownership row. Not built.
PMS credential holderyes, but a different thingProperty.ownerId is a login id for the AppFolio credentials, never a landlord. Gera flags it must never appear in a roles table.
Roles attach at company or buildingyestypes.ts:16817-16828
Building-scoped staff read on an inbound callnoinbound-resolution.ts:175 drops any role that is not company-wide
Tour candidates chosen from leasing agentspartlytour-calendar-pool.ts:62-63 picks connected leasing-agent calendars; Gera's findTourCandidates is design-only

No conflict found between this audit and Gera's model. The one place the code is narrower than his model is building-scoped staff on inbound (gap 7 below).

People records carry no role. A person record holds identity only (types.ts:16590-16668) — no role, title, isStaff or teamMember field; role always lives on a separate row. Phone and email are separate claim rows (:16751-16775), and lookup deliberately searches across all companies because an inbound text carries nothing but a number. Staff people come from the invite flow or a roster script — AppFolio sync never creates staff people. Escalation owner is an email string on the building (types.ts:3113), not a person. And there is no "tour host" concept: a tour resolves to a calendar, never to a named person — which is exactly the shape section 7 keeps.

18.2 Personas

Voice picks a persona from the number dialled and who the caller turns out to be; text picks a workflow body from capabilities, not from a persona name. The full list: Triage (front door, voice-agents/triage.ts:51), Leasing, Lease & billing, Maintenance-tenant, Maintenance-handyman, Renewal in/out, Unknown caller, Vendor outbound, Emergency relay, Escalation callback — plus Turnover intake, the one and only staff persona on voice (turnover-intake.ts:37, chosen when pm_is_pm == "true" at triage.ts:450). On text there is one PM overlay (clara-pm.ts:42-71) chosen by senderRole === 'pm' and nothing finer. There is no owner persona, and no tenant-vs-prospect persona split on text.

18.3 Tools, by role and by channel

The catalogue holds 151 tools (132 live, 19 planned) in src/lib/tools/; who may call what is declared once in role-matrix.ts:196-495. Two facts shape everything below:

Prospect and resident tools — what actually runs today

PersonaVoiceSMSEmailDashboard
Prospect19 leasing tools on the Leasing agent — save_prospect, schedule_tour, reschedule_tour, cancel_tour, get_available_unitsSame list via the LEASING capability (capabilities/leasing.ts:81-84)Same as SMS — email runs the same pipeline (process-email-record.ts:622)Prospect and tour screens
Tenantcreate_work_order, close_work_order, add_note_to_work_order, get_unit_appliances, search_appliance_manual, get_troubleshooting_steps, list_my_work_orders, get_lease_terms, get_tenant_balanceMAINTENANCE + RESIDENT_SERVICES, same names minus five staff-only onesSame as SMSResident screens
Vendor / handymanTransfer + end onlyHANDYMAN allowlist (capabilities/handyman.ts:43): notes, close, edit, list mine, create, set_my_quiet_hoursSame as SMSVendor screens
Renewal (tenant)renewal_accepted, renewal_declined, renewal_escalate, change_renewal_termSame + send_offer_sms, send_portal_link_smsSame as SMSRenewals screens
Unknown callerTransfer + end; capture_unknown_caller_note is open to everyoneEscalate + close onlySame

Staff-initiated tools — a staff member telling Clara to do something

This is the short table, and it is the point of the audit.

RoleVoiceSMSEmailDashboard
Property managerfind_turnover, start_inspection, append_notes, revise_finding. That is all. future reassign_tour — see below.The 13 turnover tools, draft_mass_message, get_property_metricsSame as SMS — identical pipelineEvery screen in the permission matrix
Leasing agentnothing — not recognised as staff at all (pm-call-context.ts:169 uses isPmTier, which excludes leasing_agent)get_property_metrics only. The leasing tools are explicitly removed for any resolved staff sendercapabilities/index.ts:242Same as SMSLeasing screens (full)
Maintenance technot recognisedget_property_metrics; handyman tools only with a vendor membershipSame as SMSMaintenance + vendor screens
Org adminnot recognisedTurnover tools + metricsSameEverything
Owner / landlordDoes not exist on any channel.

Voice is narrower than text everywhere. Turnovers: 12 catalogue tools, 4 on voice, 13 names on text. Renewals: 16 catalogue, 6 conversational. Two declarations of the same thing sit side by side (the catalogue's voice field and each agent's own config), so the per-channel surface has to be read off both. Also worth naming: MASS_COMMS composes only for a PM tier; the analyst-clarify write gate strips ~20 mutating tools when a PM asks about a resident; appfolio.notify_owner / notify_tenant are marked do-not-use; turnover tools are deliberately company-grade only; Telegram is a real channel type with no inbound webhook.

Two things that look like tools and are not. A tour outcome is never logged by a tool — it is inferred after the call from the transcript (voice/post-transfer.ts), which is exactly the gap section 7 closes. And vendor assignment on an ordinary work order is automatic (auto-assign-vendor.ts), not a Clara tool.

Future, not built — reassign_tour A manager tells Clara "Kat can't make the 2 PM, reassign it to Sam", by text or by call. Clara moves the calendar event onto Sam's calendar; the tour's pointer follows, so the host is Sam with nothing stored. The staff notice is re-sent, the prospect's confirmation is untouched (it never names a team member), and the after-tour check-in goes to Sam. Authority is a role check: the host themselves, or anyone with a role above the host — a leasing agent may hand off her own tours; a property or regional manager may move anyone's. Design detail in section 17.12; it depends on PR 2 below carrying the real tier instead of a flat 'pm'.

Where the tool gate is actually enforced

ChannelEnforcementFile
SMS / EmailFull role-matrix check on every callconversation-manager.ts:10076
VoiceNo role check. Two narrow gates only — "is a verified tenant", "is a verified PM". The real scoping is which agent the number rings.voice/tools/[tool]/route.ts:497-498
DashboardPermission matrix + property scopepermissions-source.ts, scope.ts

18.4 How each channel decides "this is staff member X"

ChannelThe pathWhere it falls down
Inbound callNumber dialled → building and company (voice/personalization/route.ts:349) → caller's number → people across all companies (pm-call-context.ts) → keep a role only if isPmTier and the scope is a whole company (:169) → narrow to assigned buildings (:195-222) → emit one variable, pm_is_pm (route.ts:3394) → triage sends a true to turnover intake (triage.ts:450).A leasing agent, maintenance tech or org admin is never seen as staff on a call. A building-scoped manager is never seen either. The only thing the system can conclude about a staff caller is "PM, probably walking a unit". A PM whose buildings are recorded as "all" gets an empty list — known, documented, fail-closed.
Inbound textNumber texted → building and company (phone-lookup.ts:550) → sender's number → role rows (resolve-actor.tsinbound-resolution.ts:218) → keep every active staff tier, company-scoped (:171-183) — a leasing agent IS recognised here → then it is thrown away at inbound-router.ts:498, which flattens any staff tier to 'pm'.The role survives to the tool gate but not to the prompt, so Clara talks to every staff member in the same voice. A leasing agent loses the leasing tools outright. Staff texting from a personal number work only if that number is a claim on their person record, and nothing creates that automatically. A person at two companies gets a single company id.
Inbound emailSame resolution by address (inbound-resolution.ts:236). Then coworker containment: if the sender is staff, or writes from the building's own mail domain, and the mail was not addressed to a clara@ address, Clara stays silent (process-email-record.ts:473-487).Aliases are not resolved beyond exact address and domain match. A leasing agent emailing Clara her tour notes would be ignored by design. That floor has no override — the per-property switch was deleted on Fede's 2026-08-20 ruling — so reversing it is a decision, not a task.

18.5 Gaps against the target model

Target: leasing agent ⊂ property manager ⊂ org admin; the role recognised per company from the person record; the same tools on every channel.

#GapWhat is missingSize
1Voice cannot see a leasing agentWiden the call-time check from isPmTier to STAFF_TIERS and carry the tier list, not one boolean — pm-call-context.ts:169S
2One flat "pm" word on text and emailsenderRole is 'tenant' | 'pm'; it needs to carry the real tier — inbound-router.ts:498, clara-pm.ts:80M
3Staff are denied the leasing toolsDrop or invert !isResolvedStaff so a leasing agent gets the leasing surface — capabilities/index.ts:242M
4No leasing-agent persona promptOne prompt block beside CLARA_PM_PERSONAclara-pm.ts:42S
5No tour-notes tool on any channelA new tool pair modelled on start_inspection / append_notes. Today a tour outcome is only guessed from the transcript after the call — an agent cannot tell Clara what happened. This is section 7's dependency.M
5bClara's walkthrough capture takes no photosThe dashboard inspect route stores structured photos; Clara's append_notes is narration onlyM
6No voice persona for a leasing agentA staff-leasing agent beside turnover-intake, plus a triage ruleM
7Building-scoped staff invisible on every inbound channelInbound resolution drops non-company-scoped roles — inbound-resolution.ts:175. The one place the code is narrower than Gera's model.M
8Voice applies no role gate at allVoice checks two predicates, never the matrix — voice/tools/[tool]/route.ts:497L
9Email ignores staff senders by designThe containment floor would swallow tour notes sent by mail — process-email-record.ts:473. This is Fede's 2026-08-20 ruling, so a carve-out is a decision to take, not work to schedule.decision
11A person at two companies gets oneSingle company id on staff resolution — inbound-router.ts:483M
12Escalation owner is an email, not a personNo link from the building to a staff person record — types.ts:3113M
13Two spellings for one jobproperty_manager on the login, pm in conversation. Leave it — translated in one place.S
14Tour calendars only read leasing agentsA property manager's own calendar is not in the pool — tour-calendar-pool.ts:62S

Out of scope for now

Two findings from the audit are observations, not work (Fede, 2026-09-17: "we don't want to over scope"). They are recorded here so nobody re-discovers them as bugs, and they are deliberately absent from the gaps table and from every PR chain on this page.

18.6 Recommendation — four small PRs, in this order

Do not add new role words. property_manager and leasing_agent already exist, are offered at invite, and already carry Fede's ladder. The work is recognition and tools, not vocabulary.

Migration: none needed. Western Slope and Camellia staff already hold a role row from their invite; nothing is re-stamped. Anyone invited as property_manager keeps the PM superset; anyone invited as leasing_agent gains a surface they should always have had. Each of the four is its own dark, independently mergeable PR, in that order.

18.7 Honest limits of this audit

19. Open decisions

Every call on this page that is still Fede's, in one list. Each row links back to the section that explains it, where the full options and the reasoning live. Recommendations are ours; nothing here is decided until Fede says so.

39 open. One decision on this page is already answered and is listed at the bottom for the record.

Touring hours, cutoffs and hosts (sections 1–5)

#The callRecommendationSection
1Is a sibling tour.hours registry entry the right home, versus folding it into a bigger reshape of office_hours that Gera's settings-ladder swarm may already be planning?Sibling key, additive, no schema change to office_hours1
2Approve Western Slope's actual touring-hours values (Mon–Fri 8am–6pm, Sat/Sun 10am–3pm) before they are set live.Values as listed; the settings UI itself is Gera's to build1
3Confirm the new field stays off for every customer, Western Slope included, until Fede turns it on.Ships dark by default1
4Ship the cancel-on-no-reply fix for every property, or gate it to weekend tours only?Everyone — it is an existing bug, not a new behavior2
5Camellia's own "confirm or cancel" ask could not be found in any record. Treat as Western-Slope-only, or is there a conversation to re-check?Western-Slope-only for now; flag Camellia's version as unverified2
6Does "no weekend bookings after Friday" mean a hard calendar cutoff regardless of how far out the weekend date is, or just more lead time?Hard cutoff — matches how Fede described it2
7Build the minimal sequential-fallback backup first and redo it as a merged pool later, or build the merged pool now?Merged pool now — one more PR, avoids a rebuild3
8Is Ryan (Fusion) a same-company backup or a genuine cross-company partner? A cross-company backup needs an explicit membership across the company wall.Confirm which before Ryan's calendar is added at all3
9Should a backup's daily tour slots be capped so a maintenance person's day job is not crowded out?Yes, a small daily cap3
10Which mailbox receives the observer BCC?A new dedicated inbox (e.g. observer@propflowai.co), not hello@4
11Default state for brand-new clients: on automatically with an off switch, or a visible onboarding toggle defaulting on?Visible onboarding toggle4
12Observer BCC auto-expiry: manual off only, or a built-in expiry unless renewed?60-day auto-expiry4
13Adopt the SBAR-style card structure as the target shape for the prospect card, or keep the current flat field list?SBAR structure — terser, matches how good leasing agents pre-qualify5
14Build any of the nice-to-have card fields (pet type, income indication, reason for move, amenity wishlist) now, or hold?Hold — ship the must-have set first5

The prospect notes field (section 6)

#The callRecommendationSection
15Land step 2 (the tour notice reads Tour.notes instead of notes) now, or wait for a measured Tour.notes coverage number from prod first?Measure coverage first — it changes what every leasing team sees on every tour email6
16Order of stopping the automated writers: all five together, or one PR at a time with a bake period?One at a time — makes a regression easy to attribute6
17Timing on deleting existing row content: schedule a fixed future date now, or leave it open-ended until the earlier steps have baked?Leave it open-ended — it is irreversible6

After-tour check-in (section 7)

#The callRecommendationSection
18How long after the tour does Clara text the agent — 15, 30 or 60 minutes?30 minutes after the tour is due to end7
19Does the prospect's application link wait for the agent's answer?No — it goes at 60 minutes as today, personalised if the notes have arrived7
20Text only to start, or text plus a tour-notes box in the dashboard?Text only — "there's no app, there's no login" is the point7
21If the agent never answers: one nudge the next morning then let it go, no nudge, or keep asking?One nudge, next morning, then silence7

Guest cards (sections 14, 15.11 and the appendix)

#The callRecommendationSection
22Clara messaging through AppFolio as a seat, versus her own number.Seat14, 16
23Assign every new card to Clara on ingest, or only cards Clara has already touched?DECIDED 2026-09-17: auto-assign once live for inquiries; before that, recorded and left alone14, 16
24Note verbosity on the card: one line per fact, or one rolled-up note per conversation?DECIDED 2026-09-17: start with short — one line per fact14, 16
25Create a card at a client whose staff already make their own cards?Yes, but link-first15.11
26What goes in the card's Source field?A distinct "Clara — phone call" source15.11
27Mark a card Inactive on our own when a prospect goes quiet?No — only when the person actually says so15.11
28Note verbosity for a card created from a call?DECIDED 2026-09-17: one note per call, the same short summary already shown in PropFlow — never a long transcript-style note15.11

Touring engine, D2–D11 (section 17)

#The callRecommendationSection
29D2 · What "available" means with several people: merge everyone's free time, one shared calendar with nobody assigned, or rotate by rule?Merge everyone's free time, pick the host at booking17.A1
30D3 · On reschedule, keep the original person or re-pick from the rule?Keep the original person if she is free at the new time17.A1
31D4 · The minimum heads-up default.60 minutes, set on the company, overridable per building17.8
32D5 · How travel time is counted: a flat buffer, or real distances from a table?Flat 30-minute buffer first; buy the distance table when it visibly fails17.A1
33D6 · The multi-home cap per visit.Three17.10
34D7 · Who is told about a booking: the assignee, everyone in the pool, or the company address as today?The invite goes to the assignee; the company mailbox still sends the prospect's confirmation17.12
35D8 · Situs's calendars at launch: give all three a PropFlow-hosted calendar day one, or wait until they connect their own?Give them day one; swap in their real ones whenever they connect17.A1
36D9 · Lockbox self-showing default: open access, scheduled-unassigned, or hybrid?Scheduled, unassigned17.13
37D10 · Which lockbox access tier to build first: smart lock, or static code?Static code — no hardware purchase, the homes already have lockboxes17.13
38D11 · Two new per-company settings: the tour clustering gap and the pre-showing confirm-text window.Ship both as company-level settings, overridable per building — not hardcoded constants17.A1

One core prompt in code (section 20)

#The callRecommendationSection
39How to migrate to one core prompt: full refactor now, one rule family per week starting with human handoff, or keep per-file prompts with a duplication lint?One rule family per week, starting with human handoff20

Already answered, kept for the record

D1 · Where a calendar livesdecided by Fede, 2026-09-14: people's calendars; the company (leasing@) calendar stays as reserve and fallback. Full options in 17.A1.

20. One core prompt in code (Decided — option B, Fede 2026-09-18)

Fede's ruling tonight: voice agents share ONE core rule set in code, assembled per agent by the server. Duplicated rendered text inside ElevenLabs is fine — that's just where the finished words end up. What's not fine is fixing a rule by hand-editing a per-agent prompt file, because the same rule is sitting in nine other files nobody touched.

Today's shape — an audit of every hand-copied rule

Two passes over agents/clara/lib/voice-agents/*.ts, agents/clara/lib/agent/*.ts, after-hours-message-desk.ts and the voice personalization route, grepping for 15 rule families a Clara agent has to obey. Eleven of the fifteen already show up as a hand-typed sentence in more than one file. Four (move-in-first, no team-member names, end-call etiquette, knowledge-free-greeter/escalation) weren't run through this pass and need a follow-up grep before anyone rules on them — listed as unconfirmed below rather than guessed at.

Ranked worst first: a family with wording that has already drifted apart between files is the strongest case for a core rule, because it's proof two engineers each wrote the same rule slightly differently and nobody would ever notice until a call went wrong.

Rule familyAgents carrying a hand copyAlready drifted?Existing shared mechanismRecommended home
No unit-count disclosure3+Yes clara-leasing.ts: “NEVER VOLUNTEER HOW MANY UNITS ARE AVAILABLE” vs. leasing.ts: “only answer with detail if asked” — same intent, different rule, same failure mode as the earlier raw-availableCount leakNoneCore prompt
Language switching3Yes leasing.ts and triage.ts carry byte-identical wording (“one trigger, and it is a word they say”); maintenance-handyman.ts states the opposite (“you do not switch the language, ever”) because that agent has no language tool — a real difference wearing identical prompt languageNoneInjected slot (must stay capability-aware)
Tour-times phrasing & slot rules3–4Partial leasing.ts covers three edge cases (unlisted day inside the window, after the window, weekend with no confirmed hours) that many-homes-front-door-slot.ts doesn't visibly carryMANY_HOMES_FRONT_DOOR_DIRECTIVE (partial)Injected slot
Hours & after-hours6–7 (plus the WS prototype outside the system entirely)Yes — tonight's incident the shared slot is real and used by 7 agents, but the Western Slope prototype prompt was written outside it, with office hours and the office number typed as constants inside the prompt file — a third, silently different storeOFFICE_CLOSED_DIRECTIVE_SLOT / renderOfficeClosedDirective()Core prompt + a build check that refuses any literal day/time range inside a prompt file
Name handling & correction2Partial leasing.ts mirrors leasing-many-homes.ts by hand; the WS prototype had no version of the rule at all, which is exactly why it refused to correct a prospect's own name on a real call tonightNoneCore prompt
Brevity & one idea per turn6–8Likely — six-plus independent copies of “2–3 sentences, no markdown”, never diffed against each otherNoneCore prompt
No phone read-back / last four2–3Not confirmed — wording differs (“digit by digit” vs. opt-in read-back) but not enough samples to call it drift yetNoneCore prompt
Human handoff / office number / transfer9No — already centralized, and it has its own drift-guard test. The gap is different: the WS prototype simply never called the shared constant, so it dialled the client's own front desk tonightHUMAN_TRANSFER_CONNECT_LINE (+ drift test)Promote into the core prompt so a new agent can't skip it by omission
Note / callback capture promises2Yesafter-hours-message-desk.ts carries a “delivered” standard; escalation-callback.ts doesn't, which is the direct mechanism behind tonight's defect 6 (a callback nobody was told about)None canon; after-hours-message-desk.ts is the strongest draftInjected slot, canonical source = after-hours-message-desk.ts
Identity greeting4Likely — each of maintenance-tenant, emergency-relay, leasing, triage hand-rolls its own opening lineNoneInjected slot (channel legitimately varies here)
Fair housingNo — already one shared importPROTECTED_CLASSES_INLINEKeep as injected constant; eligible for core prompt since it's non-negotiable everywhere
Move-in date asked/stated firstNot audited this passUnknownFollow-up grep before ruling
No team-member namesNot audited this passUnknownFollow-up grep before ruling
End-call etiquetteNot audited this passUnknownFollow-up grep before ruling
Knowledge-free greeter / escalation / emergencyNot audited this passUnknownFollow-up grep before ruling

Found only in one place but should hold everywhere: the office-hours-and-number ban (only Camellia's agents ever went through the shared slot — nothing stopped a new prompt file from skipping it), the stronger no-unit-count wording (only in the voice leasing agent, not in text/email leasing paths), and the “delivered” standard for callback promises (only in the after-hours desk, not in escalation-callback). Each is a rule that already works somewhere and simply never got copied to its neighbors.

The proposal

Core prompt (rules every agent obeys) + a per-agent role section, assembled server-side. The personalization route already assembles per-agent context today; it grows one more step — prepend the core rule set, then the agent's own role section, then the existing injected slots (office-closed, tour-times, many-homes front door, human-handoff vars) exactly as now. The finished text still gets pushed to ElevenLabs and rendered per agent, same as today — duplication in the rendered output is fine, because it now all traces back to one source in code instead of nine hand-maintained copies.

Migration ships in small PRs, one rule family at a time — matching the standing rule that a risky change is one concern per PR. Each PR proves one of two things against a real Camellia call replay: byte-identical output (the rule moved, nothing changed) or an intended, named diff (the rule was actually different somewhere and this PR is the decision to make it consistent). No PR ships without one or the other.

What it prevents

This is not a hypothetical. Tonight's RCA (western-slope-go-live, root causes 2026-09-17) is the bill for exactly this pattern: office hours ended up in three places (the org-level setting, the knowledge-base text, and a third set hand-typed as constants inside the Western Slope prototype prompt) with no code path comparing them; the company-live email hold was patched five times in eight days (#8977, #8999, #9148, #9174, #9199), each fix touching one sender and leaving the others, because the hold logic wasn't one place to fix; and the human-transfer line already lives in one shared constant consumed by nine prompts — which is proof the pattern works when it's followed, and proof of the failure mode when a tenth prompt (the WS prototype) is written outside it and dials the client's own front desk instead.

Fede's decision (2026-09-18): option B

OptionWhat it meansRecommendation
(A) Full core+role refactor nowStop everything else and build the core prompt + assembler in one push, migrating all 15 rule families at once.Fastest fix on paper, but it's the exact shape of PR the small-PRs rule exists to prevent — one big diff nobody can review in a day, and every family gets the same rushed parity check instead of its own.
(B) One rule family per week, starting with human handoffBuild the assembler once (small PR), then migrate one family per week in ranked order — handoff first since it's already centralized and just needs promoting into the core prompt, then no-unit-counts, hours, language switching, name handling, brevity, in that order. Each PR carries its own Camellia parity or diff proof.Chosen by Fede, 2026-09-18. Matches the standing small-PRs rule, gives each family its own regression window, and starts with the family that's cheapest to prove (it already has a drift-guard test) before touching the risky, already-divergent ones.
(C) Keep per-file prompts, add a duplication lintLeave the prompts as they are; add a CI check that fails when the same rule sentence (or a near-duplicate) appears in more than one prompt file.Catches new duplication but does nothing about the eleven families already duplicated tonight, and a lint that flags real per-agent differences (like the maintenance agent's language rule) as false positives will get muted rather than fixed.

Execution starts 2026-09-18 after the Western Slope overnight sweep: first PR is the assembler, then human handoff, in the ranked order above.

21. Testing and deploying ElevenLabs agents (Proposed — pending Fede, 2026-09-18)

Fede's framing: iteration on a voice prompt feels slow. Tonight's numbers back that up — not because the deploy step is slow, but because bugs surface for the first time during PR review instead of before the PR exists. This section maps today's path, cites the file that does (or doesn't do) each job, and ranks fixes by how much they shorten "idea to a call I can hear" and how many of tonight's 26 defects each would have caught.

Cycle time today, measured

A merge itself is fast: merge is the production deploy in this repo, and team notes put the Vercel/CI promote step at roughly 13 minutes (not independently re-verified this pass — reported figure). The EL sync piggybacks on that same CI job (scripts/sync-specialist-agent.ts, gated by scripts/lib/elevenlabs-deploy-lane.ts so it can only run inside CI on merge — confirmed by research this session). The slow part is everything before merge. Tonight's own voice PRs, pulled from GitHub just now:

PRWhat it fixedReview roundsOpen→merge time
#9198Weekend tour slots survive when hours are set18 minutes
#9199Callback on a leasing line notifies the office419 minutes
#9177Company-live email hold, one seam10~3h20m
#9203Full tour-time list, no context narration13still open at time of writing

The pattern: fixes that were right the first time merge in under 20 minutes; fixes that needed the bot (and Fede) to keep finding new problems by reading a diff run 10–13 rounds and hours. None of those four PRs' bodies cite a call anyone actually heard — the review rounds are standing in for a live test that never happened before the PR was opened.

Q1 & Q2 — how a change reaches, and is checked against, a live agent today

Prompts live as TypeScript in agents/clara/lib/voice-agents/*.ts (triage, leasing, maintenance, renewal, lease-and-billing, turnover, many-homes, unknown-caller). One file sits outside that shared set: agents/clara/lib/voice-agents/prototypes/western-slope-portfolio-triage.ts, provisioned by its own script (scripts/provision-western-slope-proto.ts) — this is the file the RCA traces the hardcoded office hours and phone number to. scripts/sync-specialist-agent.ts and the WS provisioning script are the only two writers of live EL agent config, and both are hard-blocked from running outside the on-merge CI job (scripts/check-elevenlabs-deploy-lane.ts, plus a local pre-tool-use hook, scripts/elevenlabs-guard.sh, that blocks a hand-run PATCH). There is no staging EL agent that mirrors a customer's real shape and no diff-before-push review step — the CI job read-modify-writes the live agent directly on merge. Rollback exists as a snapshot-restore (scripts/elevenlabs-snapshot.sh --restore), not a one-command "go back one version."

Before merge: the byte-identity check (test:harness:portfolio:byte-identity:checkscripts/portfolio-harness/byte-identity/camellia-replay-byte-identical.ts) only proves a specific known-broken portfolio behavior hasn't silently changed — it is not a general prompt-content guard. The "prompt change needs an eval diff" CI check (require-eval-diff.yml) is satisfied by a text eval even for a voice prompt change, and has an escape-hatch comment. src/__tests__/voice-agents-context-wiring.drift.test.ts is the real fence for the emitted-vs-consumed dynamic-variable gap and does include the WS prototype — but it is a structural, string-level check ("is the var referenced"), not a check that the var's runtime value is non-empty, which is exactly what crashed EL on 2026-09-16. After merge: src/__tests__/el-config-drift-live.drift.test.ts does fetch the live EL agent and diff it against the repo — but only tool schemas, only nightly, never as a merge gate, and it says nothing about prompt text or dynamic-variable values. voice-nightly.yml phones a simulated Clara nightly but is text-only, all tools mocked, and its fixtures are pinned to the Willows test property — Western Slope and Camellia never run through it. Real-call testing (the robocall skill) works and is exactly the right tool, but nothing wires it into a required gate; it only runs when someone remembers to run it.

RCA root causes → what would have caught them

Root cause (tonight's RCA)Existing checkWould it have caught it?What's missing
Scenario coverage — 14/26 defects in shared code Camellia never exercisesvoice-nightly.ymlNoFixtures cover Willows only; Western Slope/Camellia shapes aren't in the matrix
Correct data, wrong renderer (tour-hours shape bug)None foundNoNo contract test on the rendered slot output, only on the underlying data
Hours/facts in 3 places incl. literals in the WS prototype promptel-config-drift-live.drift.test.ts (tool schema only)NoDoesn't diff prompt text; no lint refusing literal days/times/numbers in a prompt file
Company-live email hold patched 5×None foundNoNo single-seam ownership test (fixed by section 20's core-prompt direction, not a testing gap)
0 of 13 fix PRs had a real-line proofrequire-eval-diff.yml (text eval only)NoA text eval satisfies the check; nothing requires a heard call
Sept 16 outage — empty dynamic variable crashed EL for every customerNone foundNoContext-wiring drift test checks the var is referenced, not that its runtime value is non-empty

Ranked options

Ranked by how much of the review-round grind above it removes, per unit of build effort — the "fast lane" first, because it is the one change that turns tonight's 10–13-round PRs into something closer to the 8-minute one.

#ProposalEffortWhat breaks todayProposalWhat it proves / type
1Fast lane: staging call before a PR exists Merged 2026-09-18, #9285.Svoice:push-to-staging (script scripts/push-to-staging.ts, shipped 2026-09-11 as PR #7844) already pushes a branch's prompt to the staging fleet in under a minute — but nothing then places or grades a call, so it goes unused and bugs surface in review instead.Chain it to the existing robocall skill and the Fable judge on the subscription account: one command pushes to staging, places a scripted call, and prints a transcript + verdict. Run it before opening the PR; paste the transcript into the PR body as proof.Pre-PR, real call, minutes not hours. Cuts review rounds directly — the bot stops finding things a human could have heard.
2Empty-dynamic-var guardSvoice-agents-context-wiring.drift.test.ts checks a var is referenced, not that it has a value; an empty post_dial_digits reached EL live and crashed every call on 2026-09-16.Extend the personalization route's response validator to fail loud (never call EL) if any dynamic variable it is about to emit is empty/undefined, with a unit test pinning it.CI unit test + a runtime fail-closed check. Prevents an outage-class defect outright.
3No-literals-in-prompt-files lintSThe WS prototype hardcoded office hours and a phone number directly in its prompt file; nothing flags that pattern.A CI lint over voice-agents/**/*.ts refusing day-of-week names, time ranges, and phone-number-shaped strings outside an approved constants file.CI check, pre-merge. Directly prevents the 3-places-for-hours root cause.
4Per-PR rendered config diff commentMReviewers (bot and human) read a TypeScript diff and have to imagine the rendered prompt; that's most of what 10–13 review rounds are spent re-deriving.A CI step renders the full prompt + tool list + dynamic-variable block for each touched agent, branch vs. main, and posts it as a PR comment.CI artifact, pre-merge. Shortens review rounds by giving reviewers the actual rendered text instead of a diff of code that produces it.
5Post-deploy contract test, widened and moved off nightly-onlyM/Lel-config-drift-live.drift.test.ts only compares tool schemas, and only nightly — there is no real "EL sync == served commit" check for prompt text or dynamic-var content.Widen the same test to diff rendered prompt text too, and run it immediately after every merge that touches a voice-agent file, not just nightly.Post-deploy verification. Gives the demo go-gate's "EL sync == served commit" claim a real, automatic proof.
6Nightly real-line bench, widened past WillowsMvoice-nightly.yml is text-only, all tools mocked, Willows-only fixtures; Western Slope and Camellia never run through it.Add Western Slope and Camellia caller shapes to the nightly matrix, and use the subscription robocall + Fable judge (which Fede has already asked for) for a real-line pass, not just the simulated one.Nightly, post-deploy. Ongoing regression net across the two live customers, not just Willows.
7Commit-tagged one-command rollbackSRollback today is elevenlabs-snapshot.sh --restore <file> — a snapshot has to be found and named by hand.Tag each on-merge snapshot with the deployed commit sha and add npm run voice:rollback -- --to <sha>.S. Doesn't prevent a defect; cuts MTTR when one ships (this week's outage revert would have been one command instead of a hand-built PR).

Three options for Fede

OptionWhat it meansRecommendation
(A) Fast lane onlyShip items 1–3: the staging-call-before-PR chain, the empty-var guard, and the literal-hours lint. Smallest footprint, targets tonight's defects and the "iteration feels slow" complaint directly.Cheapest and fastest to land; leaves the review-round grind for anything that doesn't fit a scripted call (the email-hold thrash, the tour-hours shape bug) uncovered.
(B) Fast lane + real deploy proofItems 1–5: the fast lane, plus the rendered-diff PR comment and the widened post-deploy contract test, so review rounds shrink AND "EL sync == served commit" becomes a real, automatic check instead of a claim.Recommended. Matches "every live property benefits, no regressions" — it's still all dark, code-only work, no new customer-facing switch, and it closes the specific gaps tonight's RCA named without building nightly infrastructure the two live customers don't need yet.
(C) Full pipeline, items 1–7Everything above, including the widened nightly real-line bench and commit-tagged rollback.Most thorough, but the nightly bench and rollback tooling are ongoing-operations investments, not fixes for tonight's specific defects — worth doing, but not urgent enough to hold the fast lane on.

The fast lane merged 2026-09-18 (#9285). A proven run took 3 minutes 22 seconds end to end — clone the production agent, stand up a disposable copy, place a real scripted call, grade it with the Fable judge, restore the number, and delete the copy — against a median of 51 minutes open-to-merge plus roughly 15 minutes to deploy before it (1 to 2.5 hours to hear a change land). Run it with npm run voice:fastlane or the /fastlane skill; --reap clears any throwaway agents left behind by an interrupted run. Options A/B/C for the remaining items stay pending.

Scope check: this is sized for Western Slope and Camellia specifically, using tooling that mostly already exists (push-to-staging, robocall, the context-wiring drift test, the nightly config-drift test) — not a new platform.

22. Guest-card updates — the write-intent audit, and closing the gap (Proposed — pending review)

Fede, 2026-09-18: "have we also wired in updates to guest cards? if not let's do a design first for that." Section 15 designed creating a card from a call and named four intents (15.6). This section audits which of those actually deliver today — code read, no test calls, no writes anywhere — and designs the rest. Short version: only the first call's create is wired end to end. Everything else is either queued and stuck, or never queued at all.

22.1 What's wired today, by event

EventIntentStatusWhere, and why
First call from a leadcreate_cardWIREDTwo paths both create it: the direct call-ended writer (postcall-guest-card-write.ts:224) and the durable outbox (postcall-guest-card-outbox.ts:101 → drained by guest-card-outbox-drain.ts), both from call-ended/route.ts:2270/2321. Gated by org+property pms.guestCards.writeFromCalls (default OFF). Proven live once: Willows card 104, 2026-09-17.
Second call, same personappend_notePARTIAL — queued, never sentQueued correctly at postcall-guest-card-outbox.ts:135. The drain defers every intent that isn't create_card, forever (guest-card-outbox-drain.ts:411, comment: "PR3 owns notes... this pass only creates"). No PR3 exists. Even if it ran, PMSGuestCardWriter (guest-card-writer.ts:79) declares only createGuestCard — there is no method to call. The robot side has no note route either; only /api/run-create-guest-card and /api/guest-card/message (a message to the prospect, not an internal note) exist in l4-core.ts. Section 15.7a's own live recon already found the real AppFolio route (POST /notes) — it just isn't wired to anything yet.
Tour bookedNOT WIREDNo showing/tour-date field exists anywhere in the write path: not on CreateGuestCardRequest (guest-card-writer.ts:32), not on createGuestCardL4's input (l4-core.ts:3746). A booked tour is invisible to the card beyond whatever the creation-time note happened to say.
Tour rescheduledcancel_tour (declared, unused)NOT WIRED#9213 added refreshInquiryHomeForTour (tools-leasing.ts, called from handleRescheduleTourImpl ~line 7211) — but it only patches PropFlow's own inquiry row (targetUnitId/targetHomeAddress) via updateInquiryFieldsForProspect. It never touches the outbox and never calls AppFolio. The regression tests in leasing-tools.test.ts assert the inquiry field, not any AppFolio effect.
Tour cancelledcancel_tour (declared, unused)NOT WIREDSame #9213 fix, same inquiry-only scope (handleCancelTourImpl ~line 7512). The cancel_tour kind is a real enum value in guest-card-outbox.ts but is never constructed anywhere in the repo — git grep "kind: 'cancel_tour'" returns zero hits outside its own type declaration.
Caller says not interestedmark_inactive (declared, unused)NOT WIRED — not even detectedNo voice tool captures this as a signal at all; "no longer interested" appears only as prose in the cancel_tour tool's description, describing why a caller might cancel, not as a separate outcome. mark_inactive is, like cancel_tour, a type with zero constructors anywhere.
Contact facts learned later (email, move-in, bedrooms) — budget/pets never at allNOT WIRED past creationreconcileGuestCardFromCall (postcall-guest-card-reconcile.ts) fill-only backfills PropFlow's own Person/inquiry rows, and only helps the card because it's chained before the create write on the same call (call-ended/route.ts:2264). Once a card exists there is no writer method to patch a field on it — and budget/pets are never sent to AppFolio even at creation; CreateGuestCardRequest has no such fields.
Email or text conversation (no call)NOT WIREDenqueueGuestCardIntentFromCall and writeGuestCardFromCall have exactly one call site each, both inside src/app/api/voice/call-ended/route.ts. No SendGrid or Twilio inbound path calls either function — a lead who only ever emails or texts Clara gets no AppFolio card at all, regardless of the switch.
Application submittedn/aOUT OF SCOPE, by directionApplications happen inside AppFolio's own portal. The existing guest-card sync (writers/guest-card.ts:538) already reads AppFolio's "Application Completed" status back into PropFlow. There's no PropFlow→AppFolio write needed here, and none is proposed.

Read-only audit: no writes anywhere, no test calls at Western Slope. Every line above is a file read against origin/main, not a claim from memory.

22.2 Design principles (unchanged from section 15)

PMS stays the system of record, Clara the system of work. One write-intent outbox for every PMS write — no second door. Idempotent per event. Never a status change for a cancelled tour. "No longer interested" is the only status write (mark_inactive). Notes are short, one per event, this call's own summary only — never a rolled-up transcript. Nothing here needs a human click. The existing per-property switch, pms.guestCards.writeFromCalls, gates all of it — no new switch. All channels (voice, text, email) feed the same intents.

22.3 Proposed: event → intent → AppFolio effect

AppFolio's own write surface is thin — section 15.7a's live recon found exactly four routes: create, note, assign, mark-inactive. There is no field-patch route and no showing/tour-date route. That fact drives the design: everything that isn't a brand-new card or a "no longer interested" status becomes a short note, using the one route AppFolio actually gives us.

EventIntentAppFolio effect
Second call, same personappend_noteNote: this call's own summary. (Already queued today — just needs the drain's second pass wired to POST /notes.)
Tour bookedappend_noteNote: "Tour booked Thu 2pm, 372 Ember Lane." No new field — there is no AppFolio route to write a structured showing date to.
Tour rescheduledappend_noteNote: "Tour moved to Fri 4pm." Never a status change.
Tour cancelledcancel_tourNote only: "Tour for Thu 2pm cancelled by the caller." Status untouched — a cancelled tour is not a dead lead.
Caller says not interestedmark_inactiveThe only status write in this design: Inactive, with an AppFolio reason code and a one-line comment.
Contact fact learned later (email, move-in, bedrooms)append_noteNote per changed fact: "Move-in date now 11/1." (Matches the note-verbosity ruling already made 2026-09-17 — one short line per fact, as it's learned.)
Email or text conversationsame four intentsThe channel that produced the event stops mattering once it reaches the outbox — a text-only lead's first message becomes create_card exactly like a first call does today.
flowchart LR
    V["Voice call ends"] --> O["Write-intent outbox\n(one per PMS write)"]
    T["Text conversation"] --> O
    E["Email conversation"] --> O
    O -->|create_card| C["POST /guest_cards"]
    O -->|append_note| N["POST /notes\n(booked, rescheduled, fact learned, repeat call)"]
    O -->|cancel_tour| N
    O -->|mark_inactive| S["POST /guest_cards/<id>\ninactive_status_reason"]

22.4 Decisions for Fede

Three choices, each with a recommendation. Reply with the numbers and A/B.

  1. Contact facts learned after a card already exists (a corrected email, a later move-in date) — AppFolio has no field-patch route, only notes.
    • A. A short note per changed fact ("Move-in date now 11/1"), never touching the card's actual fields.
    • B. Hold this event type until a field-patch capability is built and proven live, so the card's fields stay authoritative rather than scattered across notes.
    Recommended: A — matches the note-verbosity ruling already made, and ships with zero new AppFolio capability.
  2. Should email and text conversations feed the same outbox as voice calls?
    • A. Yes — one small PR routes the existing email/text post-processing paths through enqueueGuestCardIntentFromCall, so every channel produces the same four intents.
    • B. Voice only for now; email/text guest-card writes wait for a separate audit of those paths first.
    Recommended: A — the outbox is already channel-agnostic by shape; the gap is purely that only one caller wires into it.
  3. Build order for the four broken paths above.
    • A. Ship the append_note drain pass first (it unblocks repeat-call notes, tour booked/rescheduled, and fact updates all at once, since they're all the same intent), then mark_inactive for "not interested," then the channel unification.
    • B. Ship mark_inactive first, since a caller explicitly saying "not interested" is the highest-stakes miss.
    Recommended: A — one PR (the drain's note pass) closes five of the six broken rows in the audit table above; mark_inactive is one further PR once that pass exists.

Proves: read-only audit against origin/main; no code changed, no PMS written, no test call placed anywhere, including Western Slope.

23. Voice verbosity audit — 2026-09-18 (Proposed — pending review)

Fede, 2026-09-18: “audit for more unnecessary repetitions and verbosity in voice prompts” and “it just seems like overall it's a lot of talking and not enough listening at the beginning.” This is a read-only audit of every voice prompt on origin/main measured against the four real Western Slope calls that happened today. No code changed, no call placed, nothing written to production.

23.1 What the real calls measure

Four voice calls exist for Western Slope in production, all on 2026-09-18. There are none on 09-16 or 09-17 — those tests ran on the Fairhaven test company, so today's four are the whole real-line record. Caller numbers are shown by their last four digits only.

CallTime (UTC)Clara words in the first 4 turnsCaller words in the same spanClara questions before the first home factHow it ended
…5047, leasing14:135993 — first rent at 51 s“Oh, no, thank you.”
…4630, leasing16:3261103 — first rent at 43 s“Can I speak to a person?”
…9427, maintenance15:284110transfer failed, call dropped
…8088, Spanish15:39112“¿Qué dice?”, then hung up

The headline number: on both leasing calls Clara spoke roughly six words for every one the caller spoke, and neither caller heard a price, a size or an address until after the 40-second mark. Both callers then left — one asked for a human, one declined. That is the shape Fede described.

Separate bug found while reading the records, not a prompt problem: the transcript writer (voice.call-ended-transcript) is storing some Clara lines cut off mid-sentence at a fixed length — e.g. call …4630 turn 7 is stored as the 68-character string “Great choice! When are you hoping to move in — and is that firm, …” and no other attribute on the item holds the rest. Anyone reading call records to judge quality is reading truncated text. Worth its own issue.

23.2 The ranked defects

Ranked by how much each one costs the opening of a leasing call. Line numbers are against origin/main at 69dcf93. Two files carry the same portfolio text: src/lib/domain/leasing/portfolio-intake-rules.ts is the source, and agents/clara/lib/voice-agents/leasing-many-homes.ts carries its rendered output 28 lines lower; citations give the source file.

Greeting
#DefectThe instruction causing itEvidenceProposedWords saved
1The Spanish prompt sits in the middle of the English greeting, so the caller hears a language change before they hear the question.prototypes/western-slope-portfolio-triage.ts:534“Thanks for calling Western Slope Property Management. Para español, diga 'español'. Are you calling about leasing, maintenance, or something else?”Two of today's four calls broke here. Call …8088 turn 2: “¿Qué dice?” and the caller hung up. Call …9427 turn 2: “Sí”, forcing Clara to re-ask the whole branch question at turn 3.Put the question first and the Spanish line last: “Thanks for calling Western Slope Property Management — leasing, maintenance, or something else? Para español, diga 'español'.”4 always; ~20 on the calls it derails

The rule that a language switch replays the whole greeting (triage.ts:197) is Fede's own from 2026-09-14 and stays. Fixing defect 1 simply makes it fire far less often.

First exchange
#DefectThe instruction causing itEvidenceProposedWords saved
2The sorting question takes two turns to learn one fact. A caller who answers “a specific home” has told Clara nothing yet, so a second question is needed to get the home.portfolio-intake-rules.ts:305–307“OTHERWISE ASK IT, AND IT IS YOUR FIRST LEASING QUESTION: 'are you calling about a specific home you saw, or are you looking around in general?' Ask it before any other leasing question, before any home is named…”Call …5047 turns 5–8: “Are you calling about a specific home you saw, or are you looking around in general?” → “Um, spec- specific home.” → “Which home caught your eye?” → “Pomona Park Townhomes.” Two Clara turns and 21 seconds for one fact.One open question that gets the fact directly: “Sure — which home, or what are you looking for?” The block already says a caller who names a home is sorted, so sorting comes from their answer.~11, and one whole turn
3The caller is asked a closed choice twice in a row — once by the greeting, once by the specialist — before anything is answered.Greeting at western-slope-portfolio-triage.ts:534 plus the sort question at portfolio-intake-rules.ts:305. Nothing tells the second question it already has the answer to the first.Calls …5047 and …4630, turns 1–5 of each: “leasing, maintenance, or something else?” → “Leasing.” → transfer → “Are you calling about a specific home you saw, or are you looking around in general?” Two closed questions, 36 words, zero facts delivered.Once the caller has said “leasing”, the leasing side opens with the open question from defect 2, never a second menu.~16
Pitch
#DefectThe instruction causing itEvidenceProposedWords saved
4The move-in special is forced onto the same turn as the first price, which is the turn that is already carrying the address, the rent, the bedrooms, the bathrooms and a question.leasing.ts:262“it is welcome exactly ONCE per call, woven in naturally… on whichever turn FIRST touches pricing, units, or fees”Call …5047 turn 11 is 52 words carrying seven facts and two questions, and ends with the special. The caller's next words: “How many bedrooms is this?” — a fact that was in that same turn. Call …4630 turn 11 has the same shape; the caller's next words were “Can I speak to a person?”Move the special to the turn after the caller shows interest — once they ask a question about the home or accept a visit.~15 on the highest-stakes turn
5Facts get said twice because they were buried the first time. This is repetition caused by the overload above, not by a missing anti-repeat rule.Consequence of defect 4 and of western-slope-portfolio-triage.ts:821–827 (“EVERY HOME YOU NAME CARRIES ITS RENT AND ITS BEDS AND BATHS…”) landing in the same breath as everything else.Call …5047: turn 11 says “three bed, two and a half bath with a one-car garage”; turn 13 says “Three bedrooms, two and a half baths — and there's a one-car garage included.”Nothing new to write — fixing defect 4 removes the re-ask that causes it.~14

The already-decided intent-first change on fede/leasing-intent-before-qualifying fixes the rest of this turn for a caller who named a home (“ONE SHORT LINE MEANS ONE SHORT LINE”). Defect 4 is the piece it does not reach, because the concession rule lives in the shared leasing prompt.

Qualifying
#DefectThe instruction causing itEvidenceProposedWords saved
6The prompt orders up to six questions before a home is named, and in the same file tells Clara to name a home by turn three. The rules contradict each other, so the model walks the list.portfolio-intake-rules.ts:435–451 — the ordered asks: when they need it, which part of town, what range, bedrooms, “THEN ONE OPEN QUESTION, AND THEN LISTEN”. Against :338 “TWO FACTS ARE ENOUGH TO NAME A HOME — DO NOT COMPLETE THE SET” and :475 “NAME HOMES BY YOUR THIRD OR FOURTH TURN”.Call …4630: the caller named the home at turn 6 and still got the move-in question (turn 7) and the name question (turn 9) before any rent at turn 11. Five Clara turns, 61 words, 43 seconds, then “Can I speak to a person?”Delete the enumerated ask list. Keep the two-facts rule and the single open question: one question, then name homes, then pick the rest up while talking.~30 and 2–3 turns
7The name is demanded before the caller has heard a single fact, as its own turn.portfolio-intake-rules.ts:412–418“TAKE THE NAME EARLY — RIGHT AFTER YOU HAVE SORTED THEM AND ASKED WHEN THEY NEED IT, AND BEFORE YOU NAME A SINGLE HOME… Seven minutes in is too late.”Call …4630 turn 9: “No worries at all! And who am I speaking with?” — a whole turn spent on a form field, two turns before the caller learns what the home costs.The name rides the turn that names homes, or the booking turn. The no-nameless-call backstop already exists at :598–610 and needs no help.~9 and one turn
8The move-in question has a second question welded to its tail, so callers answer one half or neither.portfolio-intake-rules.ts:357“When are you hoping to move — and is that firm, or is there some give?”Call …4630 turn 8: “I don't, I don't know.” Call …5047 turn 10: “Give.” Two one-word answers to an eighteen-word question.“When are you hoping to move?” Flexibility comes from how they answer; ask the follow-up only if the date actually rules a home out.~8
Confirmation and close
#DefectThe instruction causing itEvidenceProposedWords saved
9A plain “no thank you” is answered with a sign-off and then a fresh question, which re-opens a call the caller just closed.western-slope-portfolio-triage.ts:1288–1307 — step 3 “Invite them to keep going… 'Anything else I can help with?'” fires on every completed action, including a decline.Call …5047 turn 15: “Totally understand — thanks for considering us. Reach back if anything changes! Is there anything else I can help…” after the caller said “Oh, no, thank you.”A decline gets one warm line and a beat, never a re-invite question. Keep the invite for a completed action.~10
10Two banned sentences are being spoken on the live line. Not verbosity, but they were the most damaging lines in today's calls and both have an owner already.The “cannot transfer” shape is banned at western-slope-portfolio-triage.ts:1206–1212; “can't match a caller” was removed by Fede's ruling of 2026-09-18.Call …4630 turn 13: “I'm not able to put you through to someone right now…”. Call …9427 turn 10: “I'm not able to match your phone number to a resident on file here…” — and that call's transfer then fired with an empty destination number and dropped.Route to whoever owns those two rules; do not fold into a verbosity PR.

23.3 Listen more — where the prompt tells Clara to talk over the caller

Four places where an instruction makes Clara volunteer something, or ask a second thing, before the caller has answered the first.

23.4 The five to ship first

OrderChangeFiles and sizeAgents affected
1One open question instead of the two-step sort (defects 2 and 3)portfolio-intake-rules.ts ~8 lines, mirrored in leasing-many-homes.ts; western-slope-portfolio-triage.ts:750–751 2 linesWS portfolio line, many-homes leasing
2Delete the enumerated qualifying list; keep two-facts plus one open question (defect 6)portfolio-intake-rules.ts:435–451, ~15 lines removedWS portfolio line, many-homes leasing
3Move the name ask off the before-any-home slot (defect 7)portfolio-intake-rules.ts:412–425, ~14 lines removedWS portfolio line, many-homes leasing
4One question per turn, and stop talking after it (listen-more items 1 and 2)2 lines removed in portfolio-intake-rules.ts; 1 line added to VOICE STYLE in each of leasing.ts, leasing-many-homes.ts, triage.ts, western-slope-portfolio-triage.tsEvery voice agent
5Greeting order, and drop the flexibility tail from the move-in question (defects 1 and 8)western-slope-portfolio-triage.ts:534 1 line; portfolio-intake-rules.ts:357 1 lineWS portfolio line, many-homes leasing

Each is its own small PR, dark, with a fast-lane call as the proof rather than a unit test. Defects 4, 5, 9 and 10 follow as separate PRs; the named-home overload is already handled by fede/leasing-intent-before-qualifying and is not re-proposed here.

23.5 The first thirty seconds, before and after

Today: the caller hears a 20-word greeting with a Spanish instruction buried in it, answers “Leasing”, and is asked a second closed question about whether they saw a specific home. They say “a specific home”, and are asked which one. They name it — and instead of hearing anything about it, they are asked when they are moving and whether that is firm. At second thirty Clara has spoken about 59 words, the caller has spoken 9, and nothing has been said about any home. The price arrives somewhere past forty seconds, stacked into one 52-word turn with the address, bedrooms, bathrooms, garage, a move-in special and two questions — at which point one of today's callers asked what she had just been told, and the other asked for a human.

After: the caller hears the question first and the Spanish line last, answers “Leasing”, and Clara asks one open thing: “Sure — which home, or what are you looking for?” They name Pomona Park. Clara's next turn is the home, its size and its rent in one short line, then one question: come see it, or shall I answer questions first? By second twenty-five the caller has a price, a size and a choice, and Clara has stopped talking. The move-in date, the name and the special all still get said — on the turns where the caller has already shown they want the home.

Proves: read-only. Prompts read at origin/main commit 69dcf93; the four Western Slope calls read from the production conversation table by query only. No prompt edited, no call placed, no production write, nothing touched on the Western Slope org.

24. Home identity through a scattered-homes booking — root cause + harness (2026-09-18)

Real Western Slope call today, conv_voice_21abd768…: the caller named “Ember Estates” (the marketing name on the listing for 367 Ember Lane). Clara's schedule_tour call carried home_ref: "Ember Estates" and an empty unit_number; the tool refused with home_not_resolved before ever looking at home_ref; Clara repeated the “let me get that booked for you!” line and retried with the literal street address, which booked — but onto the org's front-door shell property, as free text. The guest-card outbox found no PMS-mapped property along tour → inquiry → conversation and enqueued nothing. Calendar and SMS worked because they only need text, never identity. Issue #9532.

24.1 One root cause, all six symptoms

Clara's home-matching for an org-headed scattered line runs entirely off the org's public-listing rows (OrgListing, read by org-offerable-homes.ts / resolveOrgHomeForLine), a feed built only to render a home's card and deliberately given no stable id (“OrgHome.id is always '' — no Unit row backs a company home”, org-home-booking.ts) — and that feed has never been joined to the org's real, PMS-mapped Property roster, the 69 AppFolio-synced rows (of 70 on the roster; pmsSource=appfolio, af.propertyId, account westernslopepm — e.g. appfolio-408 = 367 Ember Lane) that the guest-card outbox already knows how to write to. Every symptom traces to that one missing join:

Separate, NOT part of this fix: the org-level guest-card switch (isGuestCardWriteFromCallsEnabled) is ON for this org, but the PROPERTY-level switch each of those 69 mapped rows also needs (PropertyLeasingSettings.guestCardWriteFromCalls, registry key pms.guestCards.writeFromCalls, default OFF, read by the direct-write path writeGuestCardFromCall) is OFF on all 69. Even a perfectly-identified tour writes no card at Western Slope until that switch is turned on, property by property — filed separately as #9538 so it never gets folded into the identity fix or mistaken for the same bug.

24.2 Rejected fixes, and what would still break

RejectedWhy it doesn't reach the root cause
Promote home_ref into unit_number when unit_number is emptyThe resolver still only understands literal OrgListing address text. A marketing name still can't match anything by name; an ambiguous name still gets silently guessed at; the Tour is still stamped on the shell because the roster join still doesn't exist. Fixes the empty field, not the missing identity.
Prompt wording telling Clara which field to useSame ceiling: a model can only pass what the schema and resolver already understand. It cannot manufacture a PMS-mapped property id that appears nowhere in the data it was handed, however precisely it's told which field to fill.
A per-property switch/flagThis is a data-model gap shared by every scattered-site customer (Western Slope today, the next one tomorrow), not a per-property behavior difference to gate. A flag leaves the guest card permanently empty at every scattered-site property and is exactly the "temporary" gate the no-engineering-flags rule (2026-09-17) forbids.

24.3 Decided (Fede, 2026-09-18) and shipped as PR A: a stable key per home, name matched server-side

Fede's call, once the design above was in front of him: every home in the AVAILABLE HOMES block gets a stable key — not a re-parse of speech, not a second field for the model to guess between. Rejected explicitly, same table as 24.2: promoting home_ref into unit_number, prompt wording about which field to use, any per-property switch. Shipped in the ONE canonical resolver, no second matcher:

What this PR deliberately does NOT yet do — and 24.1's deeper root cause is still open: bookedUnit for a company home still stamps id: '', so Tour.unitId/ProspectInquiry.targetUnitId still carry no structured identity, the booking still lands on the org's front-door shell property, and the guest-card outbox still has nothing PMS-mapped to write to. Tried stamping the new key there directly; it silently broke resolveListingCardForReply (#8590), the only reader of targetHomeAddress for a company home's next-call/email context, because refreshInquiryHomeForTour treats any non-empty unitId as authoritative and stops writing the address. That is exactly the kind of unaudited, one-reader-breaks-another hack Fede's “no hacks” ruled out — so it is split out, not shipped half-done behind a flag.

24.4 PR B — open, not yet built: structured identity all the way to the booking

Needs its OWN field on Tour/ProspectInquiry for a company home's identity (never a repurposed unitId/targetUnitId), a full audit of every existing reader of those fields for the new shape, an update to resolveListingCardForReply, and — to actually close 24.1's guest-card gap — the join from OrgListing to the org's PMS-mapped Property roster this section originally proposed (addressKey/homeKey, both sides already write it) so property for the rest of a booking becomes the home's own mapped row instead of the shell. This is the acceptance contract written as the one pending scenario (structured-id-persists-to-tour-and-guest-card) in the harness below — red on purpose until PR B lands, at which point it flips to UNBLOCKED and must be promoted to today in that same PR.

24.5 The proof harness (shipped with PR A)

An OFFLINE domain-level harness, scripts/home-identity-harness (npm run test:harness:home-identity), following the same five-outcome contract as identity-harness/household-harness (PASS/FAIL/PENDING/UNBLOCKED/BROKEN). No network, no DynamoDB — every scenario drives the real orgHomeRows/pickOrgHome/homeNotResolvedRefusal exports directly against fixtures shaped on Western Slope's real, read-only-verified listing text. Eight scenarios: address-exact (control), address-ambiguous (two real Redcliff Cir units, no unit #), name-unique (“Orchard Mesa Ridge”), the real “Ember Estates” three-way name ambiguity, a single-generic-word guard, an unnamed-reference clean refusal, a per-building byte-identical control, and the one pending PR-B contract above. All eight green (seven PASS, one honestly PENDING) as of this PR.

Proves: a separate lane (issue #9535) is building a LIVE fastlane voice anchor for this same defect against Fairhaven Residential (TEST) — the four-scenario plan this section originally sketched in 24.4 (informal name / address / ambiguous / no-home-named) lives there now, not duplicated here. This harness is the offline, always-run, CI-gating complement to that live anchor, not a replacement for it. Full run output and the red-before/green-after regression tests (listing-resolution.test.ts, leasing-tools.test.ts) are in the PR body's ## Proof section.

25. Guest-card notes: how the teams write them (2026-09-18)

Goal: make Clara's guest-card note read like a real leasing agent's, not an AI call-summary paragraph. Studied read-only, headless browser, no writes: Situs Group's AppFolio (situsgroup.appfolio.com), sampling guest cards with note/activity history. Western Slope sampling was interrupted mid-session by a browser-automation bug (see 25.4) and is not yet complete — the rules below are drawn from the Situs Group sample plus Fede's own rulings on today's live Western Slope call, and will be extended once Western Slope reading resumes.

25.1 The style rules

RuleAnonymized example
Short. One to three clauses, never a paragraph.“9/18 – Asked about Ember Estates, 3 bed. Showing booked at 367 Ember Lane, Sat 9/19, 10:00 AM. Move-in flexible, within the next 30 days. Confirmation email sent.” (Fede's accepted example, today's call)
The note is what happened on THIS call, not a running biography of the prospect.A second note on the same card reads as its own short line for that day's contact, not a rewrite of the first note.
Move-in first when known, and verbatim when flexible — never converted into a guessed date.“Move-in flexible, within the next 30 days.” never “Move-in 10/18” for a caller who only said “sometime in the next month.”
Never repeats contact fields already on the card (name, phone, email) — the card already has them.A Situs Group note goes straight to the ask/interest, never “[Prospect] called about…”
Never a source/channel boilerplate line. No “Called the leasing line (Clara)” or its equivalent for a human agent.Situs Group staff notes never restate how the person reached them — the Source field on the card already carries that.
A tour/showing gets its own short line with the address and the exact day/time, not folded into the interest sentence.“Showing booked at [Street Address], Sat, 10:00 AM.”
A team text follow-up is short and direct, first person, one question.“Hi [Prospect]! Thanks for your interest in our property at [Street Address]! When are you free to tour?”
The activity/note-type taxonomy is used deliberately — a phone conversation is logged as its own type, not folded into a generic Note.Situs Group's guest-card timeline shows distinct entries: Showing (Scheduled / Auto Followup Email Sent), Text Sent, Call, and system-templated Email — interleaved with the agent's own short human-written texts.

25.2 What Clara's composer got wrong before this fix

The composer (resolveNoteText, src/lib/integrations/voice/postcall-guest-card-outbox.ts) was a raw passthrough of ElevenLabs' generic third-person call-summary field — there was no team-style formatting at all. Five real (anonymized) examples pulled read-only from propflow-prod:

Proves / fix shipped: PR #9575 (issue #9574) adds composeGuestCardNoteText and wires it into resolveNoteText: date-stamp prefix, source-boilerplate stripped, a move-in line that preserves the caller's own flexible wording verbatim or falls back to a parsed date only when no verbatim wording exists, and no duplicate move-in mention. Fixture test with 8 before/after cases drawn from these anonymized examples, all green. Dark change — reshapes only what an already double-gated, default-off write path says, no new switch. Deliberately not yet in scope: the upstream ElevenLabs summary prompt itself, and a threaded showing/tour line at this call site (tracked as follow-up in #9574).

25.3 Anonymization note

Every example on this page is paraphrased or has names/phones/emails/addresses replaced with placeholders ([Prospect], [Street Address], [Property Name]). No real prospect PII from either database appears here.

25.4 Open: Western Slope sampling incomplete

The research subagent hit an unrelated blank-render bug on AppFolio guest-card detail pages and, while debugging it, repeatedly wiped and recreated its browser profile — each recreation forced a fresh login + MFA instead of reusing a session, burning 3 verification codes on the shared situsgroup.appfolio.com credential in a 6-minute window (20:40:41–20:42:48Z) and flagging as a possible lockout risk. It was halted immediately; zero logins were attempted on westernslopepm.appfolio.com, and no writes were made anywhere. The crawl script has since been fixed (single persistent session, longer render wait, no profile churn) and is parked pending clearance to log back in. This section will be extended with Western Slope's own examples and any style differences once that resumes. Fede, 2026-09-18: no fresh AppFolio login tonight (lockout risk). The Western Slope sample is pending and will be taken through the existing production browser session, never a new direct login.

26. AppFolio guest cards: what a card can do, what we do, and the harness (2026-09-18)

Fede, 2026-09-18: “i think we need a separate test harness and build for all of this, we have lots of gaps in appfolio guest cards” — done at the Willows. This section is the checklist of everything an AppFolio guest card supports (from AppFolio's own help articles), the gap table against what Clara does today, and the harness that closes the loop by reading every card back.

26.1 The one thing to understand first

AppFolio's guest card is not a flat record. A card holds Inquiries, which are property-level, and each inquiry holds Interests, which are unit-level (“Guest Card Inquiries and Interests”). A card only appears in a property's guest-card list if it carries an inquiry for that property, and the Latest Interest column is that inquiry's date.

That is the whole story of the 18 September repeat caller. Her card had existed since July. She called about one home; her card still listed only the August inquiry for a DIFFERENT home, so she did not appear under the home she called about at all, and Most Recent Activity only moved when Fede changed her status by hand. Appending a note could never have fixed this — the write has to add an inquiry for the home she actually asked about. Fede: “besides notes and marking it active we need to figure out how to make it appear in the list here with latest activity.”

Second thing worth knowing before anything is tested: AppFolio cannot delete a guest card, ever (“FAQs — Guest Cards”). Marking it Inactive with a reason is the only cleanup that exists. That shapes both the harness and the cleanup rule.

26.2 The checklist and the gaps

Sixteen capabilities. Clara does one of them.

CapabilityWhat AppFolio supports (article)What Clara does todayIncidentCovered by
Create a cardFirst name, last name, source and an interest — all four required (Manually Enter a Guest Card)Works. createGuestCardL4 → the browser agent's create route.Harness scenario 1
Full name enforcedA card cannot exist without both names (Manually Enter a Guest Card)Refuses one-word and placeholder names, parks the write, tells the team.9/18 caller — first name only, no card, nobody told#9511, harness scenario 2
Note on an existing cardInternal notes; AppFolio stamps the date itself (Track Internal Notes About a Prospect)Gap. A note only rides along with a create.Repeat caller — no note; added by hand#9581, agent #383
ReactivateChange a card back to Active; every status change takes a reason (Change a Guest Card Back to Active Status)Gap. No status route at all.Repeat caller — card stayed inactive after a fresh callagent #383
Add the interest for the home discussedInquiries are property-level, Interests unit-level; the card lists under the inquiry's property (Guest Card Inquiries and Interests)Gap. Interest is set once, at create.Repeat caller — never appeared under the home she called about
Latest Activity movesLast Activity Date / Type = the most recent activity record (Guest Card Inquiries Report Definitions)Gap. Only a create writes an activity.Repeat caller — moved only on a manual status change
Showing loggedSchedule a showing on the card — unit, time, optional prospect confirmation; its own log entry (Schedule Showings)Gap. Tours live in PropFlow and on the staff calendar only.#9579
Showing cancelledCancel Showing / Mark as No Show (Schedule Showings)Gap. The intent kind is declared, unimplemented.
Mark lostNo status named “lost” — AppFolio recommends Inactive with a reason (Mark a Guest Card as Cold or Inactive)Gap. Also the only cleanup our own test cards can get.
DedupManual entry warns on a matching email or phone; online submissions auto-merge on email and flip an inactive card active (Merge Duplicate Online Guest Cards)Gap. We check our own stored link, never AppFolio — a card made outside PropFlow is invisible to us.Repeat caller — a card already existed; we would have made a second
Lead sourceFree text with reusable custom values; reports key off the first inquiry (Prospect Source Tracking Definitions)Every card we make is stamped “Phone Call”. Never verified on a real card.
Assigned agentAssigned per inquiry, not per card; carries into a submitted application (Assign Users to Guest Cards)Gap. Decided: auto-assign to Clara once a company is live for inquiries.
Move-in dateDesired Move In Date, editable after create (Edit a Submitted Guest Card)Gap. Carried on the payload but the form build does not send it, and only ever at create.
Card on the real home from a portfolio lineThe interest names the property and unit asked about (Guest Card Inquiries and Interests)Needs the home resolved before the write. Never proven on a real org line.#9579

26.3 The harness

npm run test:harness:appfolio-guest-cards, at scripts/appfolio-guest-card-harness/. One scenario per capability above.

Two more capabilities added 2026-09-18 evening (issues #9598, #9599), taking the list to 16: a caller who asks for a person still gets a card whose note records the callback request and number; and a first-name-only caller gets a card with a placeholder last name. Fede decided that second one the same afternoon: the card gets written with the placeholder surname Unknown and the note “Caller gave first name only.” — a lead we can still reach beats a lead lost silently. It overrides the morning behavior, where Clara refused to create a card at all. The cost is recorded rather than glossed: AppFolio can never delete a guest card, so every placeholder surname is permanent in the client's own prospect list and reports, which is why the note has to say plainly that the name is incomplete, so a leasing agent can fix it on the first reply. The old refusal scenario goes red the day the placeholder writer lands — that is the signal to delete it, not a regression to chase.

The hole, and what is closing it. There is no authenticated route today that reads a card's status, interests, Latest Interest, Most Recent Activity, source or assigned user. The only authenticated guest-card read the service has is the message-thread route, which proves a card exists. The verifier's field-level read therefore throws and names the missing route rather than returning something a scenario could pass on. Ten of the remaining scenarios are blocked on that one route — the highest-leverage thing to build for this test environment, and it is now being built (POST /api/guest-card-read, returning status, interests, Latest Interest, Most Recent Activity, source and assigned user). The verifier stops throwing the day its client lands. The one after it is mark-inactive: until that exists, every live run leaves permanent litter the harness can only ledger, never clean.

26.4 The chain

Shipped: PR #9585 (issue #9584) — the capability spec, the read-back verifier, and scenarios 1 and 2. Dark: scripts/ only, no switch, no channel behavior changed. In flight elsewhere and deliberately not duplicated here: #9581 and appfolio-browser-agent #383 (note + reactivate), #9579 (tour carries the real home), #9511 (full name). Next: one issue per remaining scenario on #9584, and the browser-agent card-read route that unblocks most of them.

The read-back hole is closed, two ways (2026-09-18 evening). The missing route is built and merged: appfolio-browser-agent #385 adds POST /api/guest-card/read, returning status, inquiries/interests, Latest Interest, Most Recent Activity, lead source and assigned user — read-only, never logs in. Separately, Fede approved a design change the same evening: the AppFolio Plus API is now the primary way to read a card back; the robot route above is the fallback for a company without a Plus API credential. JP&Co / the Willows already has a live Plus credential, confirmed live. PR #9646 adds the Plus API client (readGuestCardViaAppfolioApi), proven against a real Willows test card; PR #9648 adds the matching robot-route client (readGuestCardL4) behind the same interface. All three are armed to merge.

26.5 Clara now sees what the renter wrote (19 Sep 2026)

27. Guest card management — architecture and design (Approved 2026-09-18)

Fede, 2026-09-18: “can we come up with an architecture and design doc for guest card management? I think that's a big beefy initiative, we gotta think through all the use cases and permutations.” This section is the parent for everything guest-card on this page. It sets the goal and the principles, states AppFolio's real data model, walks every permutation we can think of, catalogues every write, draws the machine, names the failure modes, and ends with the calls that are Fede's. The three sections that already exist stay where they are and are linked, not repeated: 25, how a note should read; 26, the capability checklist and the Willows harness; 15.11 and 22.4, the decisions already put to Fede.

In this section

  1. 27.1 Goal and principles
  2. 27.2 What an AppFolio guest card actually is
  3. 27.3 The permutations — 36 situations, end to end
  4. 27.4 The write catalogue
  5. 27.5 Architecture
  6. 27.6 Failure modes
  7. 27.7 Testing
  8. 27.8 Rollout — the PR chain
  9. 27.9 Decisions for Fede
  10. 27.9a First touch and escalation
  11. 27.10 Questions for Gera
  12. 27.11 Sending the application
  13. 27.12 Reading staff activity on the card

27.1 Goal and principles

Goal. Every conversation a prospect has with Clara — on any channel, at any point in their life as a lead — lands on the right AppFolio guest card, in the client's own prospect list, looking like their own leasing agent wrote it. The client never has to open PropFlow to know what happened.

PrincipleWhat it means here
The PMS is the system of record; Clara is the system of work.AppFolio holds the prospect. We never build a rival prospect list. Every fact worth keeping leaves through one write path onto their card.
The engine decides.No screen ever asks a human to link, merge, confirm or dedupe a card. A wrong match is fixed in the matcher with a reproduced case, never with a button.
Event-driven, never scheduled.The write is queued when the call ends or the message arrives, with bounded retries on that same path. A sweep may alert on stuck work; it is never the writer.
One outbox, one writer.Every AppFolio write is an intent row with an idempotency key. No second door, no direct call from a tool handler.
Read back or it did not happen.AppFolio answers HTTP 200 and creates nothing when a required field is missing. A write is proven by re-reading the card, never by the response code.
Permanent by default.A guest card can never be deleted. Anything we write into a client's database is there forever, which is why placeholder data and test cards are policy questions, not conveniences.
Property setting, default off; every live customer benefits.The company switch pms.guestCards.writeFromCalls and the property toggle guestCardWriteFromCalls on the property settings page. Both must be on, both fail closed, and no new switch is added by anything in this design. No engineering feature flags, no code allowlists.
Notes come from a conversation, never a typing UI.Staff add notes by talking or texting to Clara. There is no notes box in the dashboard and none is proposed.

27.2 What an AppFolio guest card actually is

Western Slope's AppFolio serves the new CRM prospects page (/crm/leasing/prospects/<uuid>); the robot's selectors were built against the classic guest-card page and must handle both.

The single most consequential fact: a card is not flat. It holds inquiries, which are property-level, and each inquiry holds interests, which are unit-level. A card shows up in a property's guest-card list only if it carries an inquiry for that property. Everything else — who it's assigned to, what shows in Latest Interest, whether the lead looks alive — hangs off the inquiry, not the card.

ThingWhat it isConsequence for us
CardThe person: first name, last name, phone, email, desired move-in, desired rent, income, pets, comments. First name, last name, source and an interest are all required at creation (Manually Enter a Guest Card).Whether the form truly refuses an empty last name is being verified live; the decision is to create the card either way. A caller with no home discussed still cannot be written — the interest is mandatory.
InquiryProperty-level — “Inquiries are used to track prospects' preferred properties.” Carries the assigned user and the source, and is the row the guest-card reports key off. The assigned user carries over into a rental application if one is submitted; nobody is notified automatically (Assign Users to Guest Cards).Assignment is per inquiry, not per card. A second home at a second property needs a second inquiry, and the way to make one is to add an interest for that property.
InterestUnit- and campaign-level, inside an inquiry. Sent on the form as p_<propertyId> or u_<unitId>. “Interests are used to track prospects' preferred units and/or marketing campaigns within the properties listed under Inquiries” (Guest Card Inquiries & Interests). Adding an interest for a new property creates a new inquiry; a new interest for a property already inquired about folds into the existing one.The home resolution has to be finished before the write, not after — and adding an interest is the documented way to create the second inquiry a cross-property repeat caller needs.
Latest InterestThe inquiry's date, shown as a column in the property's guest-card list.A note alone never moves it. Only a new inquiry/interest does.
Most Recent ActivityLast Activity Date / Type — the most recent activity record on the card (Guest Card Inquiries Report Definitions).Today only a create writes an activity, which is why the 18 September repeat caller's card looked dead until Fede touched it by hand.
StatusActive, Waitlisted, Pre-Qualified, Application Completed, Inactive, Cold — there is no “Lost” (Using Guest Cards). Inquiries carry their own status too. Going inactive takes a reason; going back to Active is supported (Mark a Guest Card as Cold or Inactive, Change a Guest Card Back to Active Status). AppFolio itself marks every inquiry Inactive with reason “Prospect Converted to Tenant” when a move-in is finalised, and flips an inactive card back to New + Active on its own when a further online inquiry arrives.“Lost” in our language maps to Inactive + reason. Reactivation is a first-class, supported move. The documented reason list is not published — only “Prospect Converted to Tenant” is named anywhere — so the dropdown values still have to be read live at the Willows.
SourceFree text with reusable custom values. The first inquiry's source is the primary one; sources on later interests are recorded but explicitly “for record keeping only, they do not factor into aggregate reports” (Guest Card Inquiries & Interests).We stamp every card we make. On a card someone else created we can never change what the client's source reports say — worth knowing before anyone asks for Clara attribution on inherited leads.
ShowingsScheduled on the card against a unit and a time, under their own Showings tab. Statuses: Scheduled, Prospect Confirmed, Prospect Canceled, auto-cancelled (unconfirmed), Completed, Completed (Unconfirmed), No Show. Both the scheduling day and the showing day are separate Actions Log entries (Schedule Showings).A booked tour has a real home and a real lifecycle in AppFolio's model, including a prospect-confirmation text of its own. Writing it as a note is a workaround, not the model — and AppFolio's own confirmation texts are a collision to check before we turn this on.
ApplicationsSubmitted through AppFolio's own portal; the inquiry's assigned user carries into the application. One application to one card, strictly. AppFolio auto-creates a card when an application arrives without one, and auto-links cards to later applications or tenants on matching phone or email (Using Guest Cards).One-directional: we read “Application Completed” back, we never write applications. AppFolio's own phone/email auto-linking is the closest thing to a merge it does — another reason to match before we create.
DuplicatesCards cannot be merged by hand at all. Online submissions auto-merge on matching email into the first card received, adding the new property to Interests and an Actions Log entry, and flipping an inactive card active. The documented manual workaround is to mark the duplicate inactive and note a link to the original (FAQs — Guest Cards, Using Guest Cards).AppFolio already does the thing we must not build a button for — but only for cards that arrive through its online path. A card the robot creates through the manual form gets no auto-merge, so a duplicate we make is permanent and unmergeable. Match before create.
DeletionNot possible, ever (FAQs — Guest Cards).Shapes testing (Willows only), cleanup (mark inactive is the only cleanup), and the placeholder-surname question.

27.3 The permutations — 36 situations, end to end

Five tables, one per dimension: how they reached us, who they are, what card already exists, which home, what happened on the call, and when. Each row says what Clara does, what is written to AppFolio, what the leasing team receives, what we keep, and what state it is in.

covered works on origin/main today, behind the default-off setting  ·  in flight an open PR does it  ·  gap nothing does it.

27.3a Channel

ChannelWhat Clara doesAppFolio writeTeam receivesWe storeState
Inbound voice callAnswers, gathers, bookscreate_card at call end; note + interest on a repeatTour/lead email built from the bookingPerson, inquiry, tour, call summarycovered (create only)
Text (SMS) to the leasing lineAnswers and books todayTreated as the phone line: create_card when there is none, notes after. assumed, confirmTour email if a tour is bookedPerson, inquiry, tourgap
EmailReplies only to a new AppFolio guest card (Fede, 2026-09-15); never Zillow relay mailNotes only, never a create. The card already exists by definition. No linked card found → park and email the team.Reply threads in the leasing mailboxConversation, inquirygap
Website / listing formNot a distinct intake today; syndicated leads arrive as AppFolio cardsNotes only, never a create — same rule as email.gap
AppFolio-originated inquiry (their own card)Reads it in through the sync; auto-assigns to Clara once the company is live for inquiriesNotes only, never a create, plus assign once the company is live. No linked card found → park and email the team.Whatever the conversation producesPerson + af.guestCardUuid linkgap (assign route exists, unwired)

27.3b Who the caller is

IdentityWhat Clara doesAppFolio writeTeam receivesWe storeState
Full name, phone, home discussedAsks for the full name outrightcreate_card: first, last, source, interest, phone, email, move-inLead/tour emailPerson + card linkcovered
First name onlyAsks once for the last name, then carries on either waycreate_card — decided 2026-09-18: a first name is enough. Placeholder surname only if AppFolio's form refuses an empty last name.Lead email as normalPerson + card linkin flight #9511, #9599
No name at all (asked for a person, hung up)Offers a callback, takes the number, confirms the last fourNo card — AppFolio rejects itEmail: they asked for a person, here is the number, call them backPerson keyed on phone, parked intentin flight #9598
Known person in our spine, card link storedGreets by name, picks up the open contextappend_note + reactivate + add the interest for the home discussedEmail if anything changedExisting person, new interactionin flight #9581, agent #383
Known to AppFolio, not linked with usSame, if we can find the cardLook up by normalised phone, then email, stamp the uuid, then note — no second cardEmail as usualLink stamped on the persongap — no authenticated card-read route exists
Card made outside PropFlow (their staff, a syndication feed)Same conversationMust find and use it; today we would create a duplicate the client can never deleteEmail as usualLink stampedgap
Two or more cards already exist for the personSame conversationWrite to the one with the most recent activity; never surface a merge promptEmail as usualOne link, the chosen cardgap

27.3c What card state they are in

Existing cardAppFolio writeWhyState
Nonecreate_cardThe only path proven live (Willows card 104, 2026-09-17).covered
Activeappend_note + add interest for the home discussedThe note records the call; the interest is what makes the card appear under that home with a fresh Latest Interest.in flight #9581
Inactive — not interested / lostReactivate to Active, then note, then interest, returning statusBefore/statusAfterFede, 2026-09-18: a fresh call means a live lead again; a note alone leaves it buried in the inactive list.in flight agent #383
Inactive — leased elsewhere, or Application CompletedNote only; do not reactivateReactivating a card that already converted misstates the pipeline. Proposed, not decided.gap

27.3d Which home

Home contextAppFolio writeNotesState
One specific unitInterest u_<unitId>The clean case.covered
A community with several homes, none chosenInquiry at p_<propertyId>Property-level interest is legitimate in AppFolio's model; the unit follows when they pick one.in flight #9579
No home named at all (general enquiry)Cannot create — the interest is required. Falls back to the front-door property if one is configured, else parks.The fallback is a design choice, not a given; a wrong property is worse than no card.gap
Front-door shell / no PMS property behind the linePark with reason no_pms_propertyScattered-homes orgs answer on a company line whose row is not an AppFolio property.gap
Home at a different property in the same orgA second inquiry on the same cardExactly the 18 September repeat-caller case: her card's only inquiry was for a different home.gap
Home outside the orgNever writtenThe org is the wall. We refuse rather than guess, and tell the team.covered by construction

27.3e What happened during the interaction

EventWhat Clara doesAppFolio writeTeam receivesState
Questions only, no tourAnswers from knowledge, gathers the card details at the endcreate_card, note = the askLead emailcovered
Tour bookedTwo options max, part of day firstShould be a showing on the card (unit + time). Proposed interim: a note line.Tour email using guest-card labelsgap
Tour rescheduledRebooks, re-confirmsMove the showing; interim, a note. Never a status change.Updated tour emailgap
Tour cancelledOffers to rebookCancel the showing; interim, a note. Status untouched — a cancelled tour is not a dead lead.Cancellation noticegap
Tour happenedTexts the agent ~30 min after, takes the answer by text or voiceNote carrying the agent's own words + the showing marked shown— (the agent is the source)gap
No-showFollows up with the prospectMark the showing No ShowDaily digest linegap
Application startedSends the application link an hour after the tourNothing — AppFolio owns applications and we read the status backout of scope
Asked for a person / callbackNever “can't transfer”; offers a callback, takes and confirms the numbercreate_card with a note recording the callback requestEmail: they asked for a person, call them backin flight #9598
Left a messageTakes it verbatimNote carrying the messageEmail with the messagegap
Said not interestedAcknowledges, does not chasePhase 1: a note saying how the call ended; the team decides. Phase 2, once dispositions are proven reliable: mark_inactive quoting the caller's own words as the reason.Digest linegap — not even detected today
Wrong number, vendor, or current residentRoutes by intent; leasing takes a message, everyone else transfersNo card, everRouted to the right inboxcovered

Every row above also carries one plain “how the call ended” line in the note (phase 1 of the Lost decision) — a hang-up, a dropped call and a polite goodbye read differently to a leasing team, and none of them closes a card.

27.3f When

TimingAppFolio writeWhy it mattersState
First touchcreate_cardThe baseline.covered
Re-engagement after 30 or 90 daysReactivate if inactive, note, add the interest for the home now discussedAll three are needed together: without the interest the card still never appears under the home she called about.in flight #9581, agent #383
Same-day repeat callOne note per call, keyed by conversation id; no second card, no duplicate noteIdempotency is per conversation, not per day.in flight #9581

Where that leaves us: 36 rows — 8 covered, 9 in flight, 18 gaps, 1 deliberately out of scope. Nearly every gap is downstream of one missing capability: there is no authenticated route that reads a card's status, interests, source or assigned agent back out of AppFolio. That single route unblocks dedup, reactivation verification, and ten of the harness's twelve blocked scenarios.

27.4 The write catalogue

Twelve writes. The mechanism column is live recon (15.7a), not documentation guesswork. One finding settles the API-vs-robot question before it is asked: AppFolio's Data API v1 is Plus-tier and read-only — GET reporting requests only, with a guest_cards endpoint among them. No write, update or delete operation on a guest card is documented anywhere in AppFolio's own knowledge base. So the robot stays the writer; what the Plus keys buy us is the read we are missing (D1).

WriteMechanismIdempotency keyRead-back checkState
Create cardRobot POST /guest_cards — source and an interest are required, and the form's handling of an empty last name is being verified live; redirects to /crm/leasing/prospects/<uuid>gc:create_card:<inquiryId> — one per prospect, everFind the uuid in the property-filtered index; a 200 alone proves nothingcovered
Append noteRobot POST /notesnote[body], parent_type=LeadToLease::GuestCards::GuestCardgc:append_note:<inquiryId>:<conversationId>Re-read the note list for the bodyin flight #9581
ReactivateRobot POST /guest_cards/<id> status change, reason requiredgc:reactivate:<inquiryId>:<conversationId>statusBefore / statusAfter returned by the routein flight agent #383
Add inquiry / interestNot yet recon'd — the create form's interest control is the closest known surfacegc:add_interest:<inquiryId>:<unitOrPropertyId>Card appears in that property's list; Latest Interest movedgap
Move Latest ActivityNo direct route — a by-product of an activity (note, interest, showing, status)n/aMost Recent Activity columngap
Log showingNot recon'd. AppFolio schedules showings against a unit and a time.gc:showing:<tourId>Showing appears on the card timelinegap
Cancel showing / no-showNot recon'd (Cancel Showing, Mark as No Show)gc:showingcancel:<tourId>Timeline entrygap
Mark lostRobot POST /guest_cards/<id> with inquiry_to_update[inactive_status_reason] + comment carrying the caller's own words; reversible. Phase 2 only.gc:mark_inactive:<inquiryId>:<stageAt>Status reads Inactive with the reasongap
Set lead sourceSet at create, on the inquiry; free text with reusable valuespart of createNever verified on a real cardunverified
Set assigned agentRobot POST /guest_cards/<id> with inquiry[assigned_user] — per inquirygc:assign:<inquiryId>Assigned column in the indexgap (route known, unwired)
Set move-in dateOptional field on create; editable after (Edit a Submitted Guest Card) but no recon'd patch routepart of createDesired Move In Date on the cardgap — carried on the payload, not sent by the form build
Dedup / mergeAppFolio auto-merges online submissions on email and flips inactive cards active. Manual entry only warns.n/a — we match before we createCard count for the phone/emailgap — blocked on the card-read route

Permanence and cleanup. No write in this table can be undone by deletion. The only cleanup that exists is marking a card Inactive with a reason, which is why the mark-lost write is the unblocker for routine live runs at Willows rather than a nice-to-have, and why placeholder data is a permanent decision.

27.5 Architecture

One path, whatever the channel. An event produces an intent; the intent goes in the outbox; a drain takes it to the PMS adapter; the adapter writes through whichever back end that PMS has; a verifier reads the record back; the team notice goes out either way, including when the write parked.

flowchart TB
    subgraph SRC["Event sources"]
      V["Voice call ends"]
      T["Text conversation"]
      E["Email / listing inquiry"]
      TR["Tour booked · moved · cancelled · shown"]
      ST["Staff reply to Clara"]
    end
    SRC --> ID["Identity + home resolution
person · org · property · unit"] ID --> P["Intent producer
create · note · reactivate · interest
showing · assign · mark_inactive"] P --> OB["Write-intent outbox
pending · gated · in_flight · done · parked
idempotency key per event"] OB -->|"setting off"| G["gated — no AppFolio call"] OB --> D["Drain (event-triggered,
bounded retries, no cron)"] D --> A["PMS adapter — one interface"] A --> API["AppFolio API (Plus plan)"] A --> BOT["Browser robot
login budget 3/h, 8/24h"] A --> FUT["Yardi · RealPage — later"] API --> RB["Read-back verifier"] BOT --> RB RB -->|"verified"| DONE["Card link stamped on the person"] RB -->|"failed"| PARK["Parked with a reason"] DONE --> N["Team notice"] PARK --> N
PieceWhat it owns
Intent producerTurns one event into zero or more intents, resolving the home first — the tour this call produced, then the prospect's inquiry, then the conversation's property, first one with a real PMS mapping wins, and no mapping means nothing is enqueued. Channel-agnostic by shape; today it has exactly one call site, inside the voice call-ended route, which is the whole reason text and email write nothing.
OutboxThe durable record, four states: pendingwriting (a conditional claim, so two drains can't double-send) → sent or failed. Carries kind, payload, idempotency key, attempts, dependsOn so a repeat call's note queues behind its own create. The switch is checked before anything is enqueued, so an off property never even produces a row — the best dry run available, at zero AppFolio side effects.
DrainFires from the event (the call-ended path triggers it inline) with exponential backoff, capped at five minutes per attempt and one hour of wall-clock budget from the first attempt, after which the row parks. A PermanentRefusal — no such property, no usable name, a test property pointed at a real PMS — parks immediately without spending budget. Honest state: a 15-minute cron still re-drains alongside the event trigger. Under Fede's 2026-09-18 ruling that cron becomes an alert on stuck rows only, and stops being a writer.
PMS adapterOne interface. Writes go through the browser robot, because AppFolio's API cannot write a guest card. Reads should go through the Plus API where the client has keys, and through a robot read route where they do not. Nothing above this line knows which was used, so Yardi and RealPage slot in here later without touching the producer.
Read-back verifierRe-reads the card through the same service that wrote it. Blocked on the missing authenticated card-read route for everything except “does this card exist”.
Team noticeGoes out whether the write succeeded or parked. A parked card still emails the team with the phone at minimum.

Where this sits in Gera's portfolio model. The org is the wall: identity resolution is company-wide, and a card is never written for a home outside the org that owns the line. The PMS credential holder is an org-level attachment — one AppFolio database per org — which is what makes the adapter's account routing an org concern rather than a property one. The property is the finer attachment: the guest-card write setting lives on the company with a per-property override, nearest wins, the same shape Fede decided for the reply mailbox. One conflict to name: a scattered-homes front door is an org-level row with no AppFolio property behind it, so the resolver can hand the writer a home that has no p_ or u_ id at all. The design parks rather than guessing; whether those homes should become real PMS-backed properties is Gera's question (27.10).

27.6 Failure modes

FailureWhat happensHow it is handled
No PMS property behind the recordThe interest cannot be built, so the card cannot be created at allPark with no_pms_property; email the team the lead with the phone. Never invent a property.
Robot login or MFA lockThe shared identity has a hard budget: 3 logins/hour, 8/24h against AppFolio's own lockout counterThe breaker answers 503 and the adapter treats it as terminal for that attempt — retrying would feed another login into an open breaker. A stale session gets exactly one forced refresh and one retry. Fix session staleness, never raise the cap. One profile, one persistent session, never a profile wipe (a debugging session burned three codes in six minutes on 18 September doing exactly that).
Timeout after the write landsThe worst case — a card may exist and we do not know, and it can never be deletedThe idempotency key plus a read-back-before-retry: on a retry, look the card up by phone/email first and stamp the uuid rather than creating again. Blocked on the card-read route.
AppFolio validation (single name, bad phone)The form re-renders with HTTP 200 and creates nothingNever trust the status code. Ask for the full name up front; on a rejection, park with the field named and email the team what was gathered.
Rate limitsAPI throttling, or the robot's login budgetBackoff inside the drain, bounded; the outbox holds the work. Never a retry loop.
A card exists and we do not know itWe create a second, permanent card in the client's listLink-first (the card pointer on the inquiry, re-read at send time, not frozen at enqueue), then a lookup by normalised phone then email before any create. The lookup does not exist yet — this is the top gap. Our inbound sync already resolves people by phone and email; the missing half is asking AppFolio the same question outbound.
We match the wrong cardA note lands on somebody else's prospectMatch on phone or email only, never on name similarity. Ambiguity parks. The fix for a bad match is a reproduced case in the matcher, never a merge button.
Drain runs during the client's working hoursRobot sessions compete with staff; notes appear while someone is looking at the cardAcceptable and intended — the whole point is the team sees the call now. What must not collide is a bulk backfill, which stays off the event path.
Partial write (card created, note failed)A card with no context, and a team that thinks nothing happenedEach write is its own intent with its own key. The card's link is stamped as soon as the create verifies, so the note retries independently and can never re-create the card.

27.7 Testing

LayerWhat it provesState
Willows guest-card harness
npm run test:harness:appfolio-guest-cards
One scenario per capability, every assertion re-reading the card out of AppFolio. Hard-guarded to the JP&Co database, property 45.Scenarios 1–2 live (#9585). Ten of the remaining twelve blocked on the card-read route.
Fairhaven fast laneThe call side: a throwaway agent on a throwaway line, a real scripted call, judged, deleted — 3m22s per iteration instead of 1–2.5h.Live (#9285). Every voice-side guest-card change reproduces here first.
Replay of real Western Slope callsThe permutations above as fixtures: the repeat caller, the first-name-only caller, the person-ask hang-up, the flexible move-in window.Transcripts exist; the fixture set is not built.
Acceptance per rowEvery row in 27.3 needs one harness scenario or one fast-lane script, red before the fix and green after.8 of 36 have one. The first-name-only scenario is now the create path, not the refuse path (decided 2026-09-18).

27.8 Rollout — the PR chain

One concern each, roughly 300 lines or less, each dark and mergeable on its own. The setting exists already and no new one is added. The order below is the decided one: the read first, then dedup, then the other channels.

#One concernWhy it is first / nextDark?
1Card lookup and read-back through the Plus API — status, interests, Latest Interest, source, assigned agentDecision 1. Unblocks dedup, read-back, reactivation proof and ten harness scenarios, without spending the robot's login budget.read-only
2Lookup-before-create: match by phone then email before any create; ambiguity parks and emails the teamDecision 2. Stops the permanent, unmergeable duplicate. Needs PR 1.setting off
3Channel unification: text, email and inquiry conversations reach the same outbox — email and inquiries note-only, never createDecision 6. One PR wiring existing paths into the producer, with the create path reserved for the phone line. Safe only once PR 2 stops it creating duplicates.setting off
4Drain's second pass: append_note + reactivate (already queued, never sent)Closes the repeat-caller miss Fede hit by hand on 18 September. Already open as #9581.setting off
5Add inquiry/interest for the home actually discussedThe only thing that moves Latest Interest and makes the card appear under the right home.setting off
6aA plain “how the call ended” line on every note, plus the labelled call-disposition fixture setDecision 3, phase 1. Clara marks nothing Lost; the team decides, and the fixtures are what will later prove the signal.setting off
6bmark_inactive quoting the caller's own words, on an explicit “not interested” or “already signed” onlyDecision 3, phase 2 — blocked on 6a's fixtures showing the disposition is reliable. Hang-ups and dropped calls never qualify. Also the only cleanup our own Willows test cards can ever get.setting off
7Assignment logic: assign the inquiry to Clara, built and shipped switched offDecision 4. Route is known and the behaviour is decided; it turns on only when Fede declares a company fully live for inquiries.setting off
8Showing write: booked, moved, cancelled, no-showNeeds its own form recon first.setting off
9Retire the 15-minute drain cron as a writer; keep it as a stuck-row alertFede, 2026-09-18: the writer fires at the event, never on a schedule. Last, so the event path is proven first.no behaviour change

Dark, then a test property, then Fede. Every PR ships to production with the setting off, is proven at the Willows or Fairhaven, and turning it on at a real property is Fede's separate call on a separate day.

27.9 Decisions — all settled

Fede approved the section and every recommendation on 2026-09-18. Nothing here is open.

  1. What the Plus API keys are for. Decided 2026-09-18 (Fede): use the Plus API for the card lookup and read-back, and keep the robot for every write. AppFolio's API is read-only, so it cannot replace the robot as writer. It is cheaper, it does not spend the robot's login budget, and it unblocks dedup immediately. A robot read route is still needed for clients without Plus, and comes after.
  2. Cards made outside PropFlow. Decided 2026-09-18 (Fede): look the person up before any create. By phone then email; we write to the card we find, and ambiguity parks and emails the team. A duplicate card can never be deleted or merged — AppFolio auto-merges only cards that arrive through its own online path, and a card the robot creates through the manual form is not one of those.
  3. What “lost” means. Decided 2026-09-18 (Fede), in two phases.
    • Phase 1 — Clara never marks a card Lost. Every call ends with one plain line in the note saying how it ended, and the team decides what that means. For example: “Asked for a person, call ended before a number was taken.”
    • Phase 2 — only after a labelled call-disposition set shows the signal is reliable (fixtures PR in progress). Then Clara marks Lost only when the caller explicitly says they are not interested or have already signed somewhere else, quoting their own words as the reason. Hang-ups and dropped calls are never Lost, in either phase.
    Silence never closes a card, at any age. A wrongly closed card is invisible to the team, and the client's own staff already manage their inactive list.
  4. Assigned agent. Decided 2026-09-18 (Fede): build the logic now, leave it off. Auto-assignment to Clara turns on only when Fede declares a company fully live for inquiries. Until he does, the card is recorded and left alone. Never a per-card human hand-off.
  5. First-name-only callers — DECIDED 2026-09-18 (Fede): create the card. A first name is enough; we never lose the lead over a missing surname. A placeholder surname is used only if AppFolio's own form refuses an empty last name, which is being verified now.
    Note: whatever surname we write is permanent — cards can never be deleted or merged by hand — so the placeholder, if the form forces one, should be the plainest possible marker and the note should say the caller gave one name.
  6. Email- and text-originated conversations. Decided 2026-09-18 (Fede): every channel goes through the same outbox, but only the phone line may create a card. Fede, verbatim: “we should only create guest cards for phone line. for inquiries and emails there should already be a guest card created so we write notes back to it.”
    • Phone line — creates the card when there is none, and appends notes after that.
    • Email and AppFolio-originated inquiries — notes only, never a create. Those leads already have a card by definition. If we cannot find the linked card, the intent parks and the team is emailed; we never invent a second card.
    • Texts to the leasing line — treated as the phone line, so a text-only lead can create a card. assumed, confirm — this is our reading, not something Fede said.
  7. First touch of a new syndicated guest card. Decided 2026-09-18 (Fede evening): Clara assigns the card to herself first, then replies and writes the note. If the assignment fails or a person already holds the card, no reply is sent.
  8. Escalation to a human. Decided 2026-09-18 (Fede evening): re-assigns the card to the primary leasing officer (Kat for Western Slope today) and Clara stops replying. Clara works a card only while it is assigned to her. Settings: pms.guestCards.assignToUser, pms.guestCards.primaryLeasingOfficer.
  9. Sending the rental application. Decided 2026-09-18 (Fede evening), design pending: AppFolio's own send from the card, driven by the robot, targeting the property (unit optional), text first and email as backup, gated by a per-property application-link setting (fixed URL vs PMS-generated link), never by the call-write switch. Built and proven 2026-09-19, see §27.11.
  10. Guest cards from phone calls only. Decided 2026-09-18 (Fede evening): guest cards are created from phone calls only; email and text conversations write notes to the card that already exists.
  11. Read-back before success. Decided 2026-09-18 (Fede evening): a robot write may report success only after it reads the change back from the card (incident 2026-09-18: a note write to a Western Slope card returned “added” with nothing written).

27.9a First touch and escalation (decided 2026-09-18)

Fede, verbatim: “new guest card comes in, clara assigns herself and replies / takes notes” and “if something is escalated to a human, we re-assign the card to the primary leasing officer (just kat for now) and we should stop replying.”

The rule in one sentence: Clara works a guest card only while the card is assigned to her.

Order on a new card:

  1. Read the card.
  2. Check the fixed conditions all pass: the card was created by the syndication feed; its oldest row is within 24 hours of when we first saw it; it is not part of a cold-start backfill; it was first seen after the lead source was turned on for that company; and it is assigned to nobody, or already assigned to Clara.
  3. Clara assigns herself. This is the claim.
  4. She replies.
  5. She writes her note.

If the assignment write fails, or someone else is already assigned, Clara does not reply.

On escalation: Clara reassigns the card to the company's primary leasing officer, writes the handoff note, and then stops every reply and follow-up on that card, including sending the application.

Settings: two per company, each one naming an AppFolio user — Clara's user (already exists) and the primary leasing officer (new setting; for Western Slope this is Kat Barker). No other switch controls this.

An “any staff row already on the card blocks Clara” check was considered and set aside on 2026-09-18, pending Fede. Assignment alone carries the rule for now.

27.10 Questions for Gera

Not sent — Fede reviews first.

  1. A scattered-homes front door is an org-level row with no AppFolio property behind it. Should those homes become real PMS-backed properties, or does the resolver get to return “no PMS home” as a first-class answer the writer parks on?
  2. The PMS credential holder is attached to the org, but the write setting lives company-then-property. Is nearest-wins the right shape here too, or should a PMS write setting only ever be org-level?
  3. Identity is company-wide, but an AppFolio card belongs to one database. If a person exists in two orgs we manage, is that two unrelated people to the writer, by the wall?

27.11 Sending the application (Built — decisions 1 to 6 decided 2026-09-18; proven at the Willows 2026-09-19)

State 2026-09-19. Built and proven end to end on the real path: property setting applicationLinkMode (#9779), the outbox intent and its gate (#9780), the drain, adapter and read-back verifier (#9784), the callers (#9783), and the robot route (browser-agent #395, #404, #405). Proof: Willows card 112, drained by the production 15-minute sweep at 15:30Z; AppFolio sent the text and the email itself, and the Actions Log shows the four send rows by Clara with the generated link appfol.io/6G5kK5MX. Two facts the proof forced into the design: AppFolio's send form requires an email on the card even for a text (a card with a phone only parks as no-contact-on-card), and the form's “Unit or Campaign” field never accepts a property-level interest, so the robot posts the tour's unit when one was named and otherwise the first vacant unit at the property; the target stays the property and the card's own interest is not changed. Still open: the drain runs on the call-ended trigger plus the 15-minute sweep, not Temporal (#9778); staff notice on a parked send (#9856).

Fede, 2026-09-18: send the application by triggering AppFolio's own “send rental application” action on the card through the robot — never by pasting a URL ourselves. This replaces row 27.3e's “application started” write, today marked out of scope, and the prospect-facing app-link text in section 7, which sends our own generated link at tour start +60. Western Slope's own pattern: Kat sends 126 SMS and 151 email applications by hand, 90% of them, always per unit, mostly right after a showing — each one is AppFolio itself handing the prospect an appfol.io/<token> link. Lower priority than the rest of this design; needed for tomorrow's 10 am Western Slope tour.

DecisionStateAnswer
1. When Clara sendsDecidedAutomatically about an hour after a tour is marked shown, unless the leasing agent's post-tour check-in says no-show or not interested; and always when the prospect asks.
2. ChannelDecidedText first; email only when the card has no phone or the text fails.
3. Which unitDecidedApply to the property. The unit is passed when known and never waited on; the leasing team can change the unit during application review. The goal is to get the documents and the application in.
4. SettingsDecided, replacedNot tied to the write-from-calls switches. Instead, one per-property setting picks how the application link is produced (see decision 6).
5. Delay settingDecidedNo new per-property delay setting. The existing post-tour delay setting keeps its default of one hour.
6. Link sourceDecidedOne per-property setting with two choices: a fixed application URL the property gives us (how Camellia and Yale 25 Station work today), or a unique link per prospect generated by the property management system from the guest card (Western Slope, sent by the robot through AppFolio's own send action). Properties that already have a URL on file stay on the fixed-URL choice with no change. A property in the generated-link choice needs a PMS write connection.

What is written. A send_application intent, key gc:send_application:<inquiryId>:<unitOrPropertyId>, on the same outbox as every other guest-card write (27.4–27.5). The robot drives the card's own send-application form, then reads back the Actions Log “Rental Application sent” entry and the generated link as proof — the same read-back-or-it-didn't-happen rule as every other write. The note on the card is AppFolio's own; Clara adds nothing extra. Idempotent, once per inquiry+unit — a re-send needs an explicit agent ask, never an automatic retry of a send that already landed. On the reply side, onOutcome({ outcome: 'application_link_sent', … }) is where this stamps the tour/inquiry record and stands down the +1/+3/+7 chase cadence from section 7.

Settings impact. This is not gated by the write-from-calls switches (company pms.guestCards.writeFromCalls, property guestCardWriteFromCalls); those keep gating the other card writes in this section. Instead, one per-property setting (decision 6) picks the link source: fixed URL, or generated per prospect through a PMS write connection. Properties already on a fixed URL need no change. The post-tour delay stays the single existing setting, default one hour, with no separate delay setting for this send (decision 5).

Architecture. The post-tour send already exists for the fixed-URL choice: the application-link processor waits the post-tour delay after a confirmed tour, then texts or emails the link. The generated-link choice plugs into that same processor, and into the prospect-request paths (post-call link send; Clara's inline link on text and email), by calling the guest-card outbox intent send_application instead of pasting a URL. Two gaps to close in the same build: the leasing agent's post-tour check-in answer (no-show / not interested) is not yet an input to that processor; and Clara's prompt must not be handed a URL to paste for properties in the generated-link choice. Before the send is queued, it checks that the card is still assigned to Clara.

LayerWhat it proves
Willows harnessnpm run test:harness:appfolio-guest-cards, JP&Co property 45, card 107 or a fresh Willows card — asserts the Actions Log entry and the link, re-read from AppFolio.
Fairhaven fast laneThe voice side of the post-tour check-in that triggers option A — the agent's “toured” / “no-show” reply.
Replay fixtureA real Western Slope post-tour thread, so the trigger and the gating logic run against a real transcript, not a synthetic one.

Rollout: dark, then Willows, then Fede's yes — the same ladder as every other write in this section (27.8).

Failure modes. The card's send-application control changes shape or is not scriptable → park, tell the agent, never guess a workaround. AppFolio's own SMS consent language on the form is AppFolio's, not ours, and is never re-raised as a PropFlow consent question. A duplicate send is blocked by the idempotency key, not by re-checking the card. A prospect with neither email nor phone on the card cannot receive either channel → park with reason and tell the agent, same as any other card with no contact point.

Proves: written from AppFolio's own help articles, live form recon at the Willows (card 104, 2026-09-17), a read of the guest-card write path on origin/main, and Fede's dated rulings of 15–18 September. No code changed, no PMS written, no test call placed.

27.12 Reading staff activity on the card (Proposed — shape decided by Fede 2026-09-18 (staff rows become entries in the prospect's conversation, Clara collaborating with teammates); internal-only handling decided (exclude entirely); build order pending)

The problem. Clara's picture of a prospect is only the card's fields plus her own conversation with them; she never sees the notes, calls, texts, and application sends the leasing team logs on the same card. Diligent clients are working a lead in parallel, and after Clara's first reply she has no way to know it.

Fede, 2026-09-18:

What exists to build on.

Options

OptionWhat it doesRiskState
1. Stand down when staff is activeBefore Clara acts on her own (a follow-up, a reminder, sending the application), she checks the card. If a staff person has touched the card more recently than she has, she holds off until the prospect writes to her again.Low — a simple timing check, no reading of what anyone actually said.Recommended first
2. Notes join the conversationEach staff note, call, text, or application send becomes an entry in the prospect's conversation history, credited to the teammate who wrote it, so everything Clara already does (follow-ups, reminders, replies) can see it.Medium — needs proof it never makes a reply worse before it touches a real client.Later, behind a replay
3. A queue for staff to approve Clara's next moveAsk a staff member whether Clara should act before she does.Adds a manual step to work the system should do on its own.Rejected in advance

Notes as conversation entries. Option 2's shape: each staff touch becomes a third kind of entry in the timeline, alongside the prospect and Clara — a named teammate, not either of those two. Every place that already reads the conversation (follow-ups, re-engagement, reminders, replies after a human takes over) picks this up for free, with no new plumbing. Before it can run for real, it needs: a cheap way to notice a card has new activity so only changed cards get re-read; a way to import each row exactly once; team touches rendered as observations Clara can act on, never as the prospect's own words or as instructions to follow; Clara never repeating a teammate's note back to the prospect; and option 1's stand-down rule staying in force underneath it, so a note being present is never by itself a reason to act.

Never leak internal notes

Decided 2026-09-18: a note classed internal-only is not written to the conversation at all. No pointer row, no hidden copy. Fede, on the objection that Clara then cannot tell a teammate touched the card: “this is not a real concern.”

This is a requirement, not a nice-to-have. Three kinds of staff writing must never influence or appear in anything Clara says to a prospect: approval or screening opinions (whether someone will likely be approved, remarks about background, credit, or income), anything unprofessional (judgments about a person's appearance, family, pets, or attitude), and anything that could be illegal to act on (remarks touching a protected class, steering, or a person's source of income). The plan is a three-part mechanism: every imported row is sorted, at the point it is read in, by rules plus a Haiku model stage, into internal-only (excluded entirely, never written to the conversation) or safe-to-use (shown, and usable only in Clara's own words, never quoted); Clara is only ever given the safe-to-use rows, with an explicit instruction never to quote or attribute a teammate to a prospect; and every client's real history gets replayed through the rule before it ships, looking for even one reply that leaks or implies an internal row. Situs Group cards are being added to the corpus alongside Western Slope.

Examples mined from the client dumps: pending, to be added below.

Examples mined from the Western Slope card dump (2026-09-18)

10,499 notes: 7,723 safe; 2,395 ambiguous fragments; 381 internal-only, of which 98 fair-housing/protected-class, 29 approval or screening opinions, 48 unprofessional.

CategoryAuthorDateText
Fair housingKat Barker11/21/2025Are they both ESA"s?
Fair housingKat Barker10/20/2025She and 2 others are looking to rent 11/1. She has 2 ESA's .
Fair housingKat Barker05/20/2026It went great, they are 4 college kids and looking for mid June.
Fair housingKat Barker10/11/2025Voucher, they loved it. Moving here from Montrose.
Approval/screening opinionMichelle Pan12/17/2025Hi, we only do a background check. As long as you don’t have money judgements, collections or evictions, you should be fine.
Approval/screening opinionKat Barker03/30/2026Actually, I just got your background check back and it appears that you have an eviction from Dallas? Did you know that? -Kat
Approval/screening opinionKat Barker08/13/2026Good morning, low credit scores aren't usually an issue as long as there are no evictions or judgements in your background. If not then feel free to message me here to set up a time to see the house. -Kat
Approval/screening opinionAssistant PM04/08/2026I appreciate it! I'm just waiting for your background check to come in the email, and this should be ready for final review. Thanks! -Bev
UnprofessionalKat Barker01/14/2026No worries, just a crazy day.
UnprofessionalKat Barker04/14/2026Just be safe! Weather is crazy! -Kat
UnprofessionalKat Barker01/18/2026That's so weird? There was a tenant before you? Apologies for the delay.
UnprofessionalKat Barker06/12/2026Of course, I love the shade those trees provide but darn those annoying bugs. -Kat
AuthorDateText
Kat Barker09/01/2026Good morning, feel free to message me here to set up a time to see one of our upcoming properties. Thank you, -Kat
Kat Barker06/19/2026Had a showing today and they liked it. Looking for an Aug 1st move in.
Michelle Pan01/23/2026Perfect. Can we schedule it next week Monday at 10am?
Kat Barker07/27/2026Sure, I will email it to you right now. -Kat
Kat Barker07/01/2026Also the crisper drawer has been ordered and should be in soon. -Kat

Keyword matching works for screening/credit/income vocabulary and staff chatter, but the three named categories need a model reading each note in context; keywords only nominate candidates. Three quarters of "notes" are the prospect conversation duplicated into the log, so dedupe first.

Classifier

Built and measured 2026-09-18. The classifier is a pure module with no caller yet: it sorts one activity row into internal-only, safe to use, duplicate of a message, or fragment. Two stages. Deterministic rules go first and are deliberately timid — they may wave a row through only when it is a duplicate, a fragment, or plain tour logistics, and they may block a row on their own only for screening data. Everything else, including all three of the categories that matter, is handed to a Haiku model with the taxonomy and a strict output shape. Any model error, timeout or malformed answer returns internal-only, so a failure hides a note rather than leaking one.

Two things the build changed about the picture above. First, the keyword triage's 29 approval or screening opinions are almost all prospects writing about their own eviction or denial, not staff opinions — genuine staff verdicts, and owner pricing notes, are close to absent in this corpus. Second, the real leak surface is fair housing, and it does not look dangerous: the leasing agent routinely records who would live in the home (“3 kids and himself”, “her, her husband and 3 kids”) in rows that read like ordinary tour notes. That shape was five of the seven misses on the first eval run.

Measured against a 170-row gold set — 150 hand-picked from the scrubbed Western Slope dump, 20 written to be adversarial (prompt injections inside a note, benign traps like “family room” and “denied the pet request”):

MeasureFirst runAfter the fix
Internal-only recall (the number that matters — share of internal notes correctly withheld)89.1%98.4% (gate: 98%)
Fair-housing recall80.8%100% (26 of 26)
Internal-only precision83.8%80.8%
Safe notes wrongly withheld3.2%7.9% (5 of 63)
Exact class and category77.1%80.0%

The trade in the last two rows is the design, not a regression: withholding a safe note costs Clara one observation, and missing an internal one is a fair-housing complaint, so the eval gates on recall and only reports the false-block rate. One known miss remains — an automated “inquiry assigned to” row is treated as a duplicated message rather than an internal action; it is the single row between 98.4% and 100% and is tracked as a follow-up.

Over the full 10,499-row dump, the rules alone settle a quarter of rows for free (13.3% duplicates, 7.8% fragments, 2.3% tour logistics, 1.3% screening data) and hand the remaining 75.3% to the model. The model prompt does not state the protected classes itself — it renders the same single list Clara's own prompts render, so a class added once reaches the classifier too.

Still dark. Nothing calls it, and the Situs Group cards are added to the gold set in a follow-up. Shipped in #9664 (rules, model stage, gold set and eval; #9666 and #9690 merged into it), open finding #9668. Run it with npm run eval:guest-card-notes.

What the cards actually contain

Typed notes are rare at Western Slope; the team works prospects mostly by texting and emailing from the card.

Real notes on the cardCards
01,021
189
248
3 to 513
61

234 notes in total across 1,171 cards; the leasing agent wrote 61 percent, the assistant PM account 33 percent.

Build order once decided

  1. A function that checks whether a staff person has touched a card more recently than Clara has, tested against real card rows.
  2. A matcher that turns specific staff actions (application sent, a showing logged, a card closed) into facts the system can act on, also tested against real rows.
  3. Wiring both of the above into the follow-up scheduler and the application-send step, as a guard, with no new setting.
  4. A test scenario at the Willows: a staff note newer than Clara's last touch stops her from following up or sending the application; a new message from the prospect resumes her.
  5. A tracked row on the go-live page for this piece of work.
  6. For option 2 only, once option 1 is proven: the prompt changes to reference staff touches safely, a replay run across every client's real history, and a graded results table published alongside this section.

Recommendation

Ship option 1 first: a simple, deterministic check that never reads what anyone wrote, only when they wrote it. Bring option 2, notes joining the conversation, in later and only read-only at first, proven against a replay of every client's real history before it touches a live client. Once that is proven, the same notes become the input for proactively re-engaging a prospect the team is already talking to.

28. PropFlow's opinions vs. AppFolio's quirks — where the line sits today, and the refactor (Proposed — pending review, 2026-09-19)

Fede, 2026-09-19, after the Western Slope Prospects page showed duplicate names, a person listed as her own co-applicant and one record shared by two people: “PropFlow has strong opinions on the data and their entities and relationships and groupings at a generic interface level. Every row is a household. Each human is a person. At the AppFolio level, if they have quirks, the adapter handles all that and hides it. If that's not how it is we have an opportunity to refactor.” This section answers whether it is set up that way. Read-only code assessment; no code changed.

In this section

  1. 28.1 The short answer
  2. 28.2 What PropFlow's opinions are today, and how hard they are
  3. 28.3 Where AppFolio leaks past the adapter
  4. 28.4 Resilience to a messy PMS: what holds, what does not
  5. 28.5 Options and recommendation

28.1 The short answer

No, it is not set up that cleanly. The read side is: one canonical lookup per question, and every entity that stands for a human carries its person. The write side is not: PropFlow's rules about one person, one household, one lead per card exist as type shapes and intent, and are enforced only by cleanup after the fact, never at the moment a PMS row is written. And the AppFolio vocabulary reaches into what should be generic code: the lead record carries a field literally named af, the writers match AppFolio's status strings inline, and the co-signer name convention is parsed in domain code. A second PMS today has nowhere to put its ids except inside the AppFolio-shaped field.

28.2 What PropFlow's opinions are today, and how hard they are

OpinionWhere it livesHow hard it is
A person is one human, found by a phone or an email claimPerson + claim rows; one lookup function per questionreads yes, writes no — duplicate people are created routinely and folded only when a later merge runs
A household is the unit of a deal; members carry a role and the evidence for itHousehold + member rows, property-scopedby cleanup — a lead's household pointer is optional and “absent is permanent”
One lead record per guest card per buildingDeterministic id from building + card idnot by construction — the writer and the application writer use two different id seeds for one deal and reconcile by pointer-matching later
Every household member points at a live lead recordMember rowpartial — the person link is required, the lead link is optional and never audited (16 dangling at Western Slope)
No person without a contactPerson rownot enforced — skeleton people are allowed by design (7 at Western Slope)
Applications group under the householdMember evidence carries the PMS group referenceonly when the PMS says so — an ungrouped application silently reads as a household of one

The four write-time invariants that do exist and run nightly cover tenants, conversations and renewals. None covers people, households or leads.

28.3 Where AppFolio leaks past the adapter

WhatWhereVerdict
The af pointer block on every lead (card id, inquiry id, application id, group id, co-signer flag, merged card ids, sync hash)lead type; written by both PMS writersleak — AppFolio's two-id scheme is the record's shape
“(Co-signer for X)” parsed out of the applicant's namedomain application-group helper, used by both writersleak — correctly treated as a hint, but it is AppFolio's string convention living in domain code
Status strings (“Application Completed”, “Decision Pending”, “lost”, “cold”…) mapped to our stages inside the writersboth writersleak — the work-order adapter already does this mapping in the adapter; the leasing writers do not
Two id seeds for one deal (card id vs application id)both writersmixed — dedup by natural key is a fine generic idea; reconciling two seeds by pointer order is AppFolio-specific fragility. It is why Katie's household member pointed at a record nobody wrote.
The adapter labels every returned card with the building it asked forAppFolio adaptermissing translation — AppFolio returned 23 Western Slope cards under more than one building. Fixed 2026-09-19: the adapter keeps only cards whose own building matches.
A card or application AppFolio moves to another buildingnowherenot handled — it reappears as a new lead under the new building
Duplicate cards for one human; the same human as card + application + tenant; placeholder contacts (“no email on file”)handled downstream by adoption rules and household folding, or not at alldownstream — the adapter passes them through raw

What the adapter does translate well: names, phones to one format, timestamps in the building's zone, money, pets. Yardi and RealPage have no lead or application surface yet; Yardi already keeps its own private status tables, so the duplication has started.

28.4 Resilience to a messy PMS: what holds, what does not

Every rule in 28.2 that says “by cleanup” means: the mess gets in, and a later merge may fix it. That is the opposite of what Fede asked for. The Western Slope page is the proof: a foreign card, a relay email address, a duplicate card and a placeholder contact each got through, and nothing failed loudly. The guest-card sync harness (section 26) is where each of those becomes a fixture that must stay green; a census over every live company's rows that fails on any duplicate, dangling link or contactless person is the missing standing check.

28.5 Options and recommendation

OptionWhat it deletes or foldsSize
AFix the bugs, add the census, leave the layering. Adapter keeps only its own building's cards (done), one member per person, voice mark, relay handling, contactless refusal; a nightly census over people, households and leads that fails on non-zero.Nothing structural. Leaks stay; the next PMS copies the AppFolio shape.S per fix
BA, plus push the vocabulary down into the adapter. The adapter emits a clean stage (as the work-order adapter already does), a co-signer hint instead of an annotated name, and one PMS-neutral pointer block (primary id, secondary id, group id) in place of af. One id seed per deal, shared by both writers. Writers become PMS-agnostic.~15 lines of AppFolio string-matching in two writers; the co-signer regex in domain code; the af-shaped field (renamed, migrated in place).M, four PRs
CB, plus write-time invariants. The writers refuse to create a second live lead for one person on one building, a person without a claim, or a member without a live lead; refusals are counted and land in the census. Cleanup becomes the exception, not the mechanism.Most of the post-merge consolidation path over time; the manual repair scripts.L, six to eight PRs behind B

Recommendation: B now, C right behind it, in that order. A is happening regardless (the fixes are in flight). B is the refactor Fede named and it is contained: two writers, one type, one adapter, each PR under 300 lines and dark. C is what makes the engine resilient rather than tidy, and it needs B first so the invariants are written against PropFlow's entities, not AppFolio's. Situs Group's data goes through the harness after B lands and before C starts, so C is designed against two companies' real mess, not one.

Ask for Fede (one line): B then C, as recommended, or A only for now?

29. The AppFolio record is the prospect record (Decided by Fede, 19 Sep 2026 — built dark, sandbox proof gates turn-on)

Decision 2026-09-19: Fede adopted the recommendation (Option D) and answered the five questions below with their recommendations. Turn-on at any customer waits on a sandbox proof that nothing breaks; that proof is the deliverable and is tracked as lane 8.

Session 004, 2026-09-19. Read-only deep inspection, no code changed, no PR opened. Companion to the Western Slope QA RCA (defects D1–D9); this section explains where the model came from, why PMS rows broke it, and what to do. Full source: ~/.claude/handoffs/appfolio-sync-deep-inspection-2026-09-19.md.

In this section

  1. 29.1 Today vs. the better way — in one picture
  2. 29.2 Verdict
  3. 29.3 Two origins, one table
  4. 29.4 The root patterns, P0–P6
  5. 29.5 What Propexo-style mirrors do that we don't · what's fine
  6. 29.6 What we missed, and why
  7. 29.7 The line above and inside the adapter
  8. 29.8 Options A–D
  9. 29.9 Recommendation & open questions
  10. 29.10 Sources

29.0 In plain words

The decision. The guest card in AppFolio is the record. PropFlow keeps a copy of it, adds who the person is and who they live with, and writes back what Clara does. Nothing is hard-coded to AppFolio, because Yardi is next.

Why. Today PropFlow keeps its own prospect and then tries to reconcile it with AppFolio. That is where the bugs came from: a card on the wrong property, the same person twice, a household with a phantom co-applicant.

The steps, all off by default:

  1. Keep a plain copy of every AppFolio record, keyed by AppFolio's own id. Done.
  2. Read AppFolio statuses through one explicit table instead of guessing from text. Done.
  3. Map each AppFolio record to one person, permanently, so no id ever changes. Done.
  4. Group people into households from the copy, not from our own rows. Open, armed to merge.
  5. Prove in a sandbox that nothing breaks, and publish the proof here. Owed. This gates everything below.
  6. Switch companies over one at a time (Western Slope, then Situs, then Camellia), each on Fede's go, then delete the old reconcile code.

The four options we weighed, in one line each.

What you can skip. Sections 29.1 and 29.2 are the picture and the verdict. Everything from 29.3 on is the analysis behind the decision and is folded away.

29.0a Sandbox proof — first run (19 Sep 2026, Proposed — pending Fede)

Step 5 ran for the first time against seeded copies of all three companies' AppFolio data (JP&Co, Situs, Western Slope; sandbox only, nothing real touched). 17 of 24 checks pass, 7 fail. The failures are the point of the proof: they are listed below with owners. Nothing switches on until this table is all PASS.

CheckJP&CoSitusWestern Slope
Every row is one dealPASSPASSPASS
No duplicate person on a dealPASSPASSPASS
Every human is one personPASSFAIL (1 of 1,890 split)PASS
Co-applicants grouped togetherPASSFAIL (1 household split)PASS
Mirror never over-merges vs. the referencePASSPASSPASS
Mirror households equal the referenceFAILFAILPASS (vacuous, see finding 1)
Every vendor status maps through the tablePASS (7 distinct, 0 unmapped)PASS (22, 0)PASS (26, 0)
Person ids unchanged on a second identical pollFAIL (2 of 308 lost)FAIL (34 of 2,236 lost)FAIL (40 of 1,081 lost)

Findings, in plain words.

  1. The copy forgot the grouping facts. The seeder did not carry AppFolio's application-group and cosigner fields into the copy, so households built from the copy are all singletons. Fix in the seeder (PR 7); the equivalence check is meaningless until then.
  2. A second identical poll loses some person links. Nothing changes, but a few guest cards lose their person id on the re-run. Root cause being traced; a re-poll over unchanged data must be a no-op.
  3. Situs splits one human and one household. One case out of 1,890. Being traced; the question is whether today's production path splits the same person.
  4. Tenant statuses have no table yet. Recorded, not graded; guest cards and applications map 100%.

PRs: seeder #9957, runner #9962 (both dark, tooling only). The runner re-runs unchanged after each fix; this table is replaced, not appended.

29.1 Today vs. the better way — in one picture

AppFolio guest cards Our prospect table FIGHT both sides own the id and status Write-back queue dark · voice calls only a note, sometimes Person Household Clara's conversations notes & stage Prospects page
Today. AppFolio's guest cards and our own prospect table each believe they own the same record's id and status — that fight is where most of the bugs come from. Only a thin, dark slice of what Clara does (create a card, add a note, and only from phone calls) ever makes it back to AppFolio.
text version
  APPFOLIO guest cards  <==FIGHT: both sides own the id and status==>  OUR PROSPECT TABLE
        ^                                                                      |
        |  (a note, sometimes, dark, voice calls only)                        v
   WRITE-BACK QUEUE  <--------------------------------------------  Person, Household, Clara's conversations
                                                                              |
                                                                              v
                                                                       PROSPECTS PAGE
AppFolio guest cards Our mirror of the record a copy, not a second opinion pull: cards + status, every 15 min Clara's conversations calls, texts, tours write-back: notes at milestones Person Household Prospects page
The better way. AppFolio's card stays the one real record. PropFlow pulls it in as a mirror (one arrow in), decides who the person is and groups the household on top of it — still ours — and Clara's conversations write real milestones back through one queue (one arrow out). No second opinion, no fight.
text version
  APPFOLIO guest cards --(pull: cards + status, every 15 min)--> OUR MIRROR OF THE RECORD
        ^                                                              |
        |                                                              v
        +--(write-back: notes at milestones)-- Clara's conversations   Person --> Household
                                                                              \      /
                                                                             PROSPECTS PAGE
TodayAfter (Option D)
Who owns the recordBoth — we mint our own id and argue with AppFolio'sAppFolio (or Yardi, or any PMS) — we just mirror it
Who decides who is a personPropFlow, from contact info only — a guest card gives us no anchorPropFlow still, but anchored on the PMS's own id, not just contact info
Who groups householdsPropFlow, by hand-written rules that pile up and never re-check themselvesPropFlow still, recomputed automatically, never stored as a stale guess
Where notes liveSplit — mostly ours, only a dark trickle reaches AppFolio, only from callsOn the PMS record, written back at every milestone (call, text, email, tour)
How Yardi gets addedRebuild most of the prospect model again, full costAdd one adapter; person, household and notes already work

29.2 Verdict

PropFlow's prospect table was born on 2026-03-04 as a standalone CRM's own entity, four and a half months before any AppFolio guest card touched it. Everything the model assumes — PropFlow mints the id, PropFlow owns the stage, one lead is one row, the person always has contact details because Clara just spoke to them — is true of a lead Clara created and false of a card AppFolio hands us. On 2026-07-16 we started pouring PMS rows into that same table anyway, and the last two months are the sound of the two shapes grinding. Every defect in the QA handoff is that grinding. The right answer is already written down and approved: “The PMS is the system of record; Clara is the system of work… We never build a rival prospect list” (§27.1, Approved by Fede 2026-09-18). That principle governs the write side, which is being built correctly. It has never been applied to the read side, which still is the rival prospect list. Close that gap and the bug class goes with it. Person identity and household grouping are PropFlow capabilities and stay PropFlow capabilities (Fede, 2026-09-19) — the fight is narrower: who owns the deal's id, status, assignment and notes. That is the PMS.

Today two ledgers own the same prospect and argue every fifteen minutes. In the better state the PMS owns the record, PropFlow owns the person, the grouping and the conversation, and exactly one arrow carries facts back.

Full analysis (background, 29.3–29.10)

29.3 Two origins, one table

PropFlow's prospect model and AppFolio's guest card were built four and a half months apart, for different jobs, then merged into one table.

WhenWhat
2026-03-04Prospect is born — a standalone CRM entity, no PMS anywhere.
2026-03-07Clara gets save_prospect — she creates leads from her own conversations.
2026-05-04ADR-0018, the Person spine, first flags that AppFolio sync “compounds duplicates.”
2026-05-04/05First PMS row writer (rental-application.ts); ProspectInquiry splits out as its own type.
2026-06-23The old Prospect type is deleted; ProspectInquiry becomes canonical for both origins.
2026-07-15/16ADR-0094 and the guest-card sync writer — the big graft of PMS rows into the same table.
2026-08-04 – 08-30Household record ships; person merge starts physically moving rows; the merge-architecture decision page predicts D1 and is never closed.
2026-09-17Fede reverses ADR-0094's no-write-back ruling; the write-back outbox ships, dark.
2026-09-18Guest card management — approved: “we never build a rival prospect list.”
2026-09-19The QA pass that produced defects D1–D9.

The schema still carries the seam: ENTITY_SOURCE = {CLARA, MANUAL, EXTERNAL} — one table, three origins, one set of rules written for the first of them.

Each assumption below was true of a Clara-born lead and is false of a PMS-born card:

#Assumption (true for a Clara lead)What a PMS card actually isDefect
A1PropFlow mints the idAppFolio's GuestCardId already exists and is the only stable identifier; we derive our own uuid and demote theirs to a pointerD1, D5
A2A lead belongs to one propertyA card is a person; it holds property-level inquiries and unit-level interests. The property gets baked into our row's key as whichever property the job requested, not the card's ownD1, D5
A3PropFlow owns the stageAppFolio owns Status per card and per inquiry; we reconcile by substring-sniffing into a one-way ratchet that can never walk back when they reactivate a cardD8
A4Contact is always presentCards carry “no email on file,” unparseable phones, or relay addresses; our drop guard is shallower than our real validators7 contactless persons
A5Contact identifies the humanA card's “email” can be a portal relay that identifies a thread, not a person, and we mint it as a permanent claim anywayD2
A6One human is one lead is one rowAppFolio has duplicate cards by design and can't merge by hand; we built a merge engine that physically relocates our own rows and never propagates backD1, 11 tombstones, 26 abandoned households
A7Notes and activity live in PropFlowFede: “Everything is on the guest card in the PMS” — we wrote notes onto our own row and, until 2026-09-17, sent none of it backthe overbuild Fede named
A8A tour is a PropFlow recordAppFolio has first-class Showings with their own lifecycle and confirmation texts; we have no read or write access to them at alltour/PMS drift
A9A person is a PropFlow conceptAppFolio already auto-links cards to applications/tenants on phone or email; we rebuilt that as a six-rung cascade plus merge, and it disagrees with theirsD2, D3, A3

The grain mismatch, stated once. AppFolio: Card → Inquiry (property) → Interest (unit) — three levels. PropFlow's ProspectInquiry is one row trying to be all three:

There is no consistent answer to “what is one row” because the row is four AppFolio objects flattened. PropFlow-born leads and PMS-born cards should be two sources feeding one projection, not two writers into one mutable table.

29.4 The root patterns

PatternWhat it isEvidenceBugs explained
P0 — two origins, one mutable tableThe parent pattern; every pattern below is a consequence of it.§29.3all
P1 — we keep a copy of the PMS's record and reconcile it field-by-fieldProspectInquiry is a local replica of a PMS card plus our fields, one owner for both; a 14-branch diff decides every tick whether its copy still matches.writers/guest-card.ts:745-1010D1, D5, D8's write amplification (versions 16,486 / 13,856)
P2 — the primary key is a function of the request, not the factOur id is derived from which property the job asked for. AppFolio doesn't honour that filter, so one card yields N ids.writers/guest-card.ts:2185; id.ts:22-45D1, D5/A4, half of D3
P3 — identity decided over a stale snapshot, PMS id not an anchorThe snapshot goes stale mid-run; the guest card's own dedup key contributes no identity claim — only rental applications get a Tier-0 PMS claim.writers/guest-card.ts:1583-1589; person-stamp.ts:1130; prospect-spine-stamp.ts:164D2, contactless persons, 26 abandoned households
P4 — the household engine accumulates instead of derivingThe read half is a pure function; the write half is 2,556 lines that create, merge, move and never recompute. Duplicate rows were accepted as normal and the renderer asked to hide one.household/writers.ts, sync-bridge.ts, resolve.ts:389-437D3, 26 households, 16 dangling members
P5 — self-healing writes and fail-soft catches hide the drift they createHeal versions and content hashes exist so the reconciler can stop rewriting itself; a failed delete during merge just logs and moves on — that catch is D1's second copy.merge-move-references.ts:285-295; apply-tour-intent.ts:841-847D1, D4's silent no-op on voice
P6 — an identifier treated as globally uniqueOutbound touches are keyed on person/phone/email with org used only as a ceiling, never a filter — an org-scoped identifier modelled as global.touch-ledger.ts:11-12; load-contact-load.ts:92-107D9

29.5 What Propexo-style mirrors do that we don't · what's actually fine

Propexo (a unified PMS API, researched 2026-04-23, not bought) keeps its own id and the PMS's id side by side and never owns the record. Five of their seven patterns map one-to-one onto a defect in the QA handoff:

Their patternWhat we do insteadBug it would have prevented
Dual id: id (theirs) + x_id (the PMS's), on every recordWe derive our id from the PMS id plus the requested propertyD1, D5
last_seen, no hard deletes, consumers decideTombstones plus a non-transactional physical moveD1's two live copies, the 11 tombstones
Cache-first: pull into a cache, never upsert-mergeUpsert-reconcile with a hand-written field diff and a heal-version machineThe whole P1 class, write amplification
status_raw + status_normalized, side by sideOne PropFlow-vocabulary stage, filled by one-way substring sniffingD8's stuck rows, missing status field
Per-vendor enum tables, inspectableSubstring matching inline in the domain writernot yet a defect — the Yardi/RealPage landmine
custom_data escape hatch per recordThe af:{} block — the one Propexo pattern we already have— already fine

What is actually fine and should not be touched or re-argued:

29.6 What we missed, and why

29.7 The line above and inside the adapter

Above the adapter — PropFlow's opinions, PMS-agnosticInside the adapter — the vendor's quirks
Person identity and claims — one human, one personId, across prospect/tenant/renewal/maintenanceThe vendor's object graph (AppFolio card→inquiry→interest; Yardi SIPP prospect; Knock has no household)
Household grouping — the pure function and how it rendersWhich write mechanism exists (AppFolio: robot only, Data API read-only; Yardi: SIPP; Knock: REST)
Clara's voice, disclosure, cadence, escalationEnum mapping: status_rawstatus_normalized, one table per vendor
Tour policy: hours, cutoffs, confirm-or-cancel, host poolFilter and pagination quirks (property filter not honoured, date bounds advisory)
The write-intent catalogue, the outbox, idempotency, read-back-or-it-didn't-happenWhich catalogue intents that vendor can actually perform, and which have no route
Which milestones are worth writing back at allDeletion semantics (AppFolio: never delete, never hand-merge)
Date handling: every written-back date goes through the property's local calendar day, CI-ratchetedWire format for that date

Today the vendor's object graph has leaked above the line — ProspectInquiry is AppFolio's shape, flattened. That is the rot Fede is pointing at, and it is what makes Yardi expensive.

The canonical example (Fede, 2026-09-19): after a tour, Clara texts the property manager “how was it, any notes?” and writes their answer as a note on the guest card in the PMS. The tour and the note itself are the PMS's; Clara's text and the PM's reply are ours, in the agent activity ledger. The PM opens AppFolio tomorrow and sees the note — never has to open PropFlow. Nothing in that flow needs a PropFlow-owned prospect id, stage, or merge.

29.8 Options

Plain-words version of these four options is in 29.0 above.

A — hardenB — anchor on the PMS idD — the PMS record is the record
Bug classes eliminatedthe 9 known defects+ key derivation + accumulation+ reconciliation + local merge + two-vocabulary status
Net code+400−1,500 / +600−4,800 / +900
Hacks removed (of 11)0311
Effort8–10 PRs, most open+6–7 PRs~25–30 PRs over 8 dark steps
Risknear zerolowmoderate, front-loaded into additive steps; the irreversible step is last, per customer
Yardi costfull pricehalfmostly paid — a new vendor is an adapter + an enum table
Matches Fede's framingno — still a rival prospect listpartlyyes — §27.1 applied to the read side

Option A — keep and harden (the fix wave already in flight). Ship #9864, #9866–9869, #9860, #9859, harness #9771; add the missing status field to the mismatch diff; refuse writes to merged-away rows; fix the relay domain and the touch-ledger org filter. Adds ~400 lines of guards around the hacks; deletes none of them. Near-zero risk, nothing for Clara's UX to lose, full Yardi cost later.

Option B — anchor identity on the PMS id. A, plus: key the row on the PMS record id instead of the requested property; make the PMS's own id a Tier-0 identity claim for guest cards the way it already is for applications; read the property from the record, never from the request; derive households instead of accumulating them. Deletes the two id-seed call sites and most of the household write layer (~1,500 lines); removes 3 of the 11 hacks. Low risk, concentrated in the key change; half the Yardi cost paid.

Option C — full re-platform in one program. Immutable snapshot replaced wholesale, pure projection, everything derived, done as a single 20+ PR lump. High risk — touches the primary key of every prospect row while competing with the Western Slope go-live. Not recommended as a shape: it's Option D's destination without D's dark, per-customer route.

Option D — the PMS record is the prospect record; we are its mirror (recommended)

Option B's structural work, plus the decision that stops PropFlow holding a second opinion about a prospect deal. Mostly deletion. Person and Household are untouched as capabilities — they get better inputs.

Three stores, all PMS-agnostic:

  1. PmsRecord mirror — one row per vendor record, keyed (orgId, pmsType, pmsAccount, entityType, x_id), carrying x_id, status_raw/status_normalized, the existing af-style custom-data escape hatch, and last_seen. Replaced wholesale each pull — never field-merged, never healed, never hard-deleted.
  2. Agent activity ledger — ours: conversations, calls, texts, emails, tours, AI summaries, cadence state. Keyed by x_id once a record exists, by our conversation id before it does. Fede: “what the agents do is reflected in the PropFlow conversation view, we keep that because it gives the agent context.”
  3. Write-back ledger (the outbox) — already exists, already right: milestone, vendor record, idempotency key, read-back proof.

How Person identity is anchored when the PMS owns the record — this must not break, so it is specified precisely:

  • personId stays ours, stable and permanent. Nothing in Option D reissues one. Tenant occupancy, renewals and maintenance keep resolving exactly as today.
  • The PMS record id becomes the strongest claim, not a replacement for the person — populated for every vendor record type (card, application, tenant), not just applications as today, so a human's card, application and tenancy all resolve to the same personId by the vendor's own id rather than by contact-string matching.
  • Claims are derived per pull, by a pure function, from the mirror's contacts plus our own conversation evidence. Claims remain permanent; what becomes pure is the decision, not the storage.
  • “Merge” becomes a recompute. Two vendor records resolving to the same personId simply are the same person on the next pull. AppFolio's duplicate cards become a display-grouping problem, not a write problem — no tombstones, no resurrection, no un-merge problem.
  • Existing personIds are preserved for all three customers by a one-time crosswalk: (pmsType, pmsAccount, x_id) → existing personId, seeded from today's pointers. The recompute reads the crosswalk first and only mints a new id when nothing matches. No customer's identity graph is recomputed from scratch, ever.
  • What remains genuinely ours to decide: who a caller is when there is no vendor record yet — ensurePersonForSignals keeps doing exactly that job.

Household stays a PropFlow capability, becomes a pure function over the mirror plus conversation evidence, recomputed per pull, never stored as mutable rows. household/resolve.ts survives and gets promoted; the ~3,250-line mutable write layer becomes derived. AppFolio and Knock have no household concept, so this is the clearest case of a PropFlow opinion living above the adapter.

A PropFlow-born lead with no vendor record yet lives as agent activity with a conversation id and no x_id, and becomes a record at the first milestone worth recording — the existing voice-call card-create path, generalised to text, email and listing inquiry. Once the create returns, its x_id is stamped on the activity and the vendor record is the record from then on. We still match before we create, and must: a robot-created card gets no auto-merge from AppFolio and can never be hand-merged or deleted there.

The outbox keeps its shape (session 003's own recommendation, adopted verbatim): intents re-key from gc:kind:inquiryId to (pmsAccount, x_id, kind, sourceId); the inquiry→card resolver disappears; the drain, robot and read-back layers survive unchanged; the card read API becomes the source for status and assignment instead of the synced copy.

Deleted or reduced to a projectionLinesWhy it goes
Field-diff + content hash + heal version + heal set + runHeal~270nothing to reconcile when the snapshot is replaced
The six-rung match cascade~200match on x_id; contact rungs survive only for the no-record-yet case
merge-persons, merge-move-references, post-merge-row-consolidation~1,000+merge becomes a recompute; no relocation, no tombstone, no resurrection
Household write layer (writers.ts, sync-bridge.ts, sync-tick.ts)~3,250grouping becomes a function
deterministicId('inquiry', …) seeds2 call sitesx_id is the key
The inquiry→card resolver in the outbox drainsmall, load-bearingintents key on x_id
The one-way stage ratchet~70status_raw + status_normalized replace it

The 11 hacks removed (constraint (d) — “delete code, simplify, remove the workarounds we built to fit the model”):

  1. Property stamped from the request parameter, not the record — same root as the Western Slope tour-reminder bug where a phone tour ran under the org catch-all property.
  2. Substring sniffing of vendor status strings.
  3. The one-way stage ratchet that can never follow a reactivation.
  4. The heal-version number whose only job is to stop our own writer rewriting its own rows.
  5. The merged-away widening match rung, which exists only because merges relocate rows.
  6. “Both rows stay, the renderer hides one” — duplicate rows accepted as normal.
  7. cancel_tour written as a note — “a workaround, not the model.”
  8. Clara engagement fields stored on the inquiry and merge-carried — become an activity log attached to the record id.
  9. The first-email-intent flag keyed by our inquiry id — keys on x_id instead.
  10. The “System guest card” rule evaluated from our mirror of the activity log — reads the card directly.
  11. Duplicate inquiries minted for shell-property callers, and one person with three Willows cards before the person-level pointer landed — both are mint-on-miss, which Option D removes.

What Clara's UX loses, honestly:

  • Latency — reads serve a cache, so a record created 30 seconds ago may not be groupable yet; mitigated by read-through on the detail page and the 60-second feed.
  • Fields the vendor has no home for (desired bedrooms, move-in note, AI summaries) stay in the activity ledger, joined at read time — they render the same, they stop pretending to be vendor facts.
  • PropFlow-side merging as an action — two duplicate cards stay two cards in AppFolio and become one row on our page through grouping. Strictly better for the client.

Migration — dark, small PRs, per customer:

StepWhatCustomer risk
1PmsRecord mirror written alongside today's rows, read by nothingnone — pure addition
2status_raw/status_normalized; enum table moves out of the writer into the adapternone
3Identity crosswalk built from today's pointers, for all three orgs; read by nothingnone — guarantees no personId ever changes
4Grouping function reads the mirror; diffed nightly against today's stored householdsnone — nothing rendered
5Prospects page reads the mirror behind a per-company setting, default off; Willows then Western Slopeper-company, reversible
6Write-back producer generalised beyond voice: text, email, listing inquiry, post-tour note; dark, per-property, Willows firstper-property, Fede's go
7Outbox intents re-key; inquiry→card resolver deletedlow — idempotency keys change once, ledgered
8Stop writing the legacy reconciled rows; delete the code above; crosswalk kept foreverthe irreversible one — last

Per-customer order: Western Slope first (not live, the only org with the collision shape, its 70 properties exposed the bug). Situs Group second (already works the guest-card way §27 was written for, no legacy history to migrate). JP&Co / Camellia last (live, historic; Yale is clean and must never be tested on).

29.9 Recommendation & open questions

Ship A now, adopt D as the architecture, and treat B as D's first three PRs rather than a destination. A is in flight and every piece of it is correct; stopping it to argue architecture leaves Western Slope broken. But A alone leaves the model that produced these bugs intact, and the next customer — or Yardi, in weeks — pays for it again. D is not a re-platform in disguise: it is the read side finally held to the standard the write side was approved to on 2026-09-18, and most of its work is deleting things built to compensate for owning a copy of somebody else's records. Person, claims, spine-stamp, resolve.ts and the outbox are the good parts, and D keeps all five. One thing happens regardless of the option picked: write the ADR-0094 amendment recording Fede's 2026-09-17 reversal — it is still owed, and pms-adapter.ts:768 still tells the next session the old, superseded doctrine.

Open questions for Fede — one line each, with a recommendation. Proposed — pending Fede on all five.

  1. Option D — the PMS record becomes the prospect record, migrated in the eight dark steps above, Western Slope first, Camellia last? Person and Household stay ours. Recommended: yes, with Option A's fix wave shipping unchanged in parallel. Proposed — pending Fede.
  2. Is the Prospects page one row per person, or one row per deal? Decides whether one person's two properties are one row or two, and step 3 of Option D needs the answer. Recommended: one row per deal/household, matching the existing invariant. Proposed — pending Fede.
  3. Write the ADR-0094 amendment note recording the 2026-09-17 reversal? Recommended: yes — doc-only, no code, stops the next session building to the old doctrine. Proposed — pending Fede.
  4. Close the 2026-08-30 merge-architecture decision page — approve, reject, or fold into Option D? Recommended: fold into D and mark it Decided. Proposed — pending Fede.
  5. Do we want AppFolio Showings (read and write) in the write catalogue? Today tours are a PropFlow-only record and cancel_tour is designed to become a note — “a workaround, not the model.” Recommended: yes, as its own recon + PR chain after migration step 5. Proposed — pending Fede.

29.10 Sources

Every claim above traces to the worktree ~/.claude/propflowai-office-line-forward (commit bce4fe32e3), the ADRs, this hub's §27 and §15.2, and three peer sessions on 2026-09-19: session 000 (merge-carried Clara fields #9860, first-email-intent keyed by our id, System-card rule, shell-property duplicate mint #9731→#9767/#9809, three Willows cards for one person), session 003 (outbox ownership, robot routes, #9743/#9745/#9778, the re-key recommendation adopted in §29.8), session 010 (the Western Slope tour-reminder bug under the org catch-all property), and session 011 (the property-local-date CI ratchet, check:tz-day-key). Key file:lines: pms-adapter.ts:768-771,800,819,1921,1985; types.ts:734,738,741,6515-6519,18320+; writers/guest-card.ts:677-1010,1246,1432-1605,2185; writers/rental-application.ts:2371; id.ts:22-45; identity/person-stamp.ts:1130-1195; identity/prospect-spine-stamp.ts:80-82,164; identity/merge-move-references.ts:9-15,268-295; household/resolve.ts:324-449; household/sync-tick.ts:28-33; leasing/load-prospects.ts:126,179,332-333; domain/pms/guest-card-outbox.ts:29-60,315-325; domain/pms/guest-card-outbox-drain.ts:11-16; domain/pms/registry.ts:67,107; domain/pms/types.ts:17; data/dynamo/touch-ledger.ts:11-12; domain/tenants/load-contact-load.ts:92-107; docs/adr/0018…:216-247; docs/adr/0094…:44. The 2026-03–07 design transcripts for the original CRM model were searched for and not found — a grep of every session transcript for the era's key terms returned nothing under docs/; the ADRs, git log, docs-site pages, and dated code comments are the durable record instead.

PropFlow Docs