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
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:
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.
What is in this document, what state it is in, and where to jump.
| Subsystem | Status | Section |
|---|---|---|
| Touring hours vs. office hours | in review | 1 |
| Weekend booking cutoff & confirm-or-cancel | in review | 2 |
| Backup tour host / pool | proposed | 3, 17.11, 17.12 |
| Observer BCC on staff notices | proposed | 4 |
| Prospect card & tour notice | proposed | 5 |
The prospect notes field | audit — field retired | 6 |
| After-tour leasing-agent check-in | proposed | 7 |
| Guest cards: assignment & note sync | dark build in flight | 8–13 |
| Guest card from a phone call (write outbox) | proposed | 15 |
| Touring engine (people, pool, duration, multi-home, lockbox) | proposed | 17 |
| Roles and staff tools | audit complete | 18 |
| One core prompt in code | decided | 20 |
| Testing and deploying ElevenLabs agents | proposed | 21 |
| Guest-card updates — audit & gap | proposed | 22 |
| Guest card management — architecture & all permutations | approved | 27 (parent of 25, 26) |
| PropFlow’s opinions vs. AppFolio’s quirks — layering and refactor | proposed | 28 |
| The PMS record is the prospect record — deep inspection and options | proposed | 29 |
| Everything waiting on Fede | open | 19 |
On this 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.
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.
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.
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).
| Camellia | Western Slope | |
|---|---|---|
officeHours today (the only field) | Mon–Fri 9–5, Sat 10–4, Sun closed | Mon–Thu 9–4, Fri 9–3, Sat/Sun closed |
| Separate tour-hours field | none — doesn't exist | none — doesn't exist |
| Effect of that gap | Works by coincidence — its office hours already cover weekend touring | Broke tonight — no window covers evenings or weekends |
New tour.hours value (proposed) | unset — falls back to office hours, unchanged | Mon–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.
tour.hours registry key, dark, no reads wired yetopentour.hours into the slot-generation and booking gates (PR 2/4)mergedscripts/settings/set-tour-hours.ts + Willows/Fairhaven end-to-end proof + Western Slope dry runnot startedEverything 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.
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_hoursWestern 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.
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.
| Setting | Camellia (proposed) | Western Slope (proposed) |
|---|---|---|
| Weekend booking cutoff | Friday 5:00pm → blocks Sat/Sun | Friday 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 nothing | on |
| Near-tour weekend reconfirm ("reply YES or we release it") | not requested | on, 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.
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.
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.
What ships
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.
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.
| Client | Observer BCC |
|---|---|
| Camellia | off |
| Western Slope | on for launch (pending Fede's go) |
| Fairhaven (test) | on for launch |
observerBcc setting + resolver (PR 2/4)mergedsendEmail so all ~18 staff notices pick it up automatically -- now also carries the #9032 wiring + proof, waiting on the #9001 re-landopenEverything 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.
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.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.
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.
Status: Audit complete 2026-09-17; nothing changed in code yet; tour-notice fix in flight separately.
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.
| Module | File | Example text | Append / overwrite |
|---|---|---|---|
Clara tool save_prospect (new lead) | agents/clara/lib/agent/tools-leasing.ts:2770 | Raw model text, unscoped | overwrite (birth row) |
Clara tool save_prospect (existing lead) | agents/clara/lib/agent/tools-leasing.ts:2816 | Same | append |
Clara tool update_prospect | agents/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:646 | extracted.notes | overwrite (birth row) |
| Email/SMS tour pipeline (existing lead) | src/lib/domain/leasing/tour/process-tour.ts:810→876 | Same, merged | append |
| Email tour-request extractor (success) | agents/clara/lib/email/extract-tour-request.ts:109 | LLM prose from an untrusted inbound email | feeds 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:431 | Raw, untrusted lead comment, verbatim | append (own glue, \n\n) |
| Voice post-call intake | src/lib/integrations/voice/postcall-prospect-capture.ts:148 | LLM line; instructed to capture off-topic/maintenance remarks here | overwrite (birth row only) |
| Voice post-call guest-card reconcile | src/lib/integrations/voice/postcall-guest-card-reconcile.ts:515 | LLM line; tightly scoped, returns null for maintenance calls | fill-only-if-blank |
| Duplicate-collapse bookkeeper | src/lib/domain/leasing/merge-inquiry.ts:108 | "Merged into inquiry <id> — person_merge_consolidation" | append (\n) |
| Un-merge bookkeeper | src/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).
| Surface | File | What it assumes the text means |
|---|---|---|
| Staff tour-notice email (chip line, no label) | send-pm-tour-confirmation.ts:297 | A current, relevant fact about this person and this visit |
| Staff tour-cancellation email, same chip path | send-pm-tour-cancellation.ts:162 | Same |
| Dashboard "units shown" chips | ProspectDetailClient.tsx:1542 (extractMentionedUnits) | Unit numbers in the note are units shown to this person |
| Dashboard move-in value fallback | ProspectDetailClient.tsx:1543 → parse-notes.ts:6 | A move-in date in the note is this lead's move-in date |
| Server loader / API response | load-prospect-detail.ts:227 | Ships the raw string to the browser |
| Dashboard-chat LLM tool result | src/lib/domain/dashboard/chat/tools.ts:1249 | A field the assistant may quote back to a staff user |
| Voice guest-card reconcile gap check | postcall-guest-card-reconcile.ts:119 | Non-empty ⇒ context already captured, don't backfill (load-bearing, not a display) |
"New Tour Request" staff email, literal Notes: label | tour-request-template.ts:27,59 | A 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.
| Writer | Reader | What the reader implies | Rating |
|---|---|---|---|
| Voice post-call intake (captures off-topic/maintenance remarks by design) | Staff tour notice chips | A fact about this booking | wrong-to-customer-facing-staff — the 2026-09-16 Ember Lane incident |
| Clara call tools ("any additional notes from the conversation") | Staff tour notice chips | A fact about this booking | misleading, escalating to wrong when the note is from an earlier interaction |
| Email tour extractor ("Tour requested: …") | Staff tour notice chips | Facts about this booking | misleading — the Yale 2026-09-04 shape |
| Email extractor failure path ("AI extraction failed. Subject: …") | Staff tour notice chips | A fact about the prospect | wrong-to-staff — an engineering error string rendered as lead context |
| Aggregator-lead settling pass (raw, untrusted lead comment) | Staff tour notice chips | A fact about this booking | misleading, plus an untrusted-text-into-staff-email path |
| Duplicate-merge bookkeeper | Staff tour notice chips; dashboard chat tool | A fact about the prospect | harmless today (merged rows refused by resolver); misleading if a reader ever lands on a tombstone |
| AppFolio orphan sweep | Staff tour notice chips; prospect card | A fact about the prospect | misleading — internal sync bookkeeping presented as lead context |
Any notes writer | Dashboard move-in fallback | "This lead's move-in date" | misleading — any date mentioned anywhere becomes the displayed move-in |
Any notes writer | Dashboard "units shown" | "Units we showed this person" | misleading — any unit number anywhere becomes a shown-unit chip |
Any notes writer | Voice 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 |
generateProspectSummary | Tour notice heading "On this call" | Asserts it is this call | misleading, held honest only by the sender-side freshness gate |
generateProspectSummary | Outbound 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 |
generateProspectSummary | Clara'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 PATCH | Every aiNotes reader | A dated summary | misleading — both write without aiNotesAt, silently disarming the #8083 freshness gate |
updatedAt still renders unconditionally, and the freshness check is row-level, not field-level — any unrelated write to the row (a preference, a cadence stamp) refreshes a months-old note enough to pass the gate.aiNotes writers set no aiNotesAt at all, which puts those rows into the same "unknown age renders" carve-out and quietly disarms the sibling PR #8083 gate too.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.
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.
notesWrittenBy + notesWrittenAt metadata (or convert to a ProspectNote[] shape with author/time/conversationId, mirroring WorkOrderNote). Pure add, low risk.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.aiNotesAt mandatory on every aiNotes write. Low risk, closes the "unknown age renders" bypass.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.notes onto mergedIntoId/closedReason/a sync-status field; update the one script that reads them today.notes field and its type. No dashboard editor is built — staff notes arrive through Clara and land on the interaction/tour record.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.notes today with other orgs still unmeasured.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.
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.
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.
Every automated step after a tour is aimed at the prospect. Nothing is aimed at the person who ran the showing.
| When | What happens | Who gets it | Where it lives |
|---|---|---|---|
| 24 h before | Tour reminder text | Prospect | tour-workflow.ts:944, lead :304; activity activities/tour.ts:227 |
| 1 h before | Second reminder (the "no-show catcher") | Prospect | tour-workflow.ts:1035, lead :306; activity activities/tour.ts:524 |
| Tour happens | Nothing is recorded — no attendance, no host, no notes | — | — |
| 60 min after tour start | Application link text | Prospect | DEFAULT_APP_LINK_DELAY_MS tour-workflow.ts:305; timer :1104; activity activities/tour.ts:801 |
| +1 d, +3 d, +7 d | Application chase, up to three touches | Prospect | outreach-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 deliberate — Tour.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.
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.
| Piece | Design |
|---|---|
| Trigger | New 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 it | The 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. |
| Channel | SMS 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 intent | One 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 reply | The 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 style | Clara 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. |
| Agent says | Tour record | Prospect side | AppFolio |
|---|---|---|---|
| "went well, they loved it" + free text | Tour.notes appended with author + time; outcome toured | No change; application link fires as usual, optionally personalised with the notes | Guest-card note intent |
| "they're applying" | outcome toured, interest applying | Stage advanced; chase cadence keeps running until the application lands | Guest-card note intent |
| "not interested" | outcome toured, interest not_interested | Application link and the +1/+3/+7 chase are suppressed | Guest-card note + mark-lost intent |
| "they never showed" | outcome no_show | Application link suppressed; a single "sorry we missed you, want to rebook?" text instead | Guest-card note intent |
| "we're rescheduling for Thursday" | outcome rescheduled | Routed into the existing reschedule path, not a new one | Guest-card note intent |
| Nothing, ever | outcome stays unset; intent expires quietly | Everything runs exactly as it does today | Nothing |
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.
set_my_quiet_hours (capabilities/handyman.ts:43-53) — and the same field is reused rather than a new one invented. A tour that ends after hours gets its check-in the next morning at the start of the window.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.
Kat may answer four hours later, from the couch, having shown three homes in between. The rule has to work then.
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.
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.
| Role | Tool set | How 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.
Most of the machinery exists for other staff personas. The honest list:
| Piece | State | Module |
|---|---|---|
| Staff recognised by phone number | exists | staff/inbound-resolution.ts:1-90 → identity/resolve-actor.ts; router at inbound-router.ts:469 |
| Per-persona tool sets | exists | agents/clara/lib/agent/capabilities/* — add a leasing-agent capability beside turnover and handyman |
| Clara texts a staff member first | exists | Turnover 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 view | exists | pm-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 SMS | exists | api/twilio/webhook/route.ts:341-424, scribe-transcribe.ts:76 |
| Listening voice UX ("just take my notes") | exists | clara-turnover.ts:1-217 — the ambient-scribe persona to reuse |
| A tour knows who hosted it | exists | Tour.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 outcome | missing | No 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 tour | missing | ActiveContextBinding (types.ts:9354-9370) only knows turnover and work order, and is only ever stamped from an inbound turn |
| A leasing-agent tool set | missing | No capability, no tools, no handlers |
| Writing a note to an AppFolio guest card | missing | Guest 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 voice | missing | api/voice/personalization/route.ts knows tenant / prospect / vendor / denied / unknown — no staff persona |
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.
TourNotesIntent record and its writer/reader, dark~200 linesActiveContextBinding with a tour_notes kind; stamp it on an outbound-initiated thread~180 linesleasing-agent capability + the first two tools (add tour notes, mark outcome) and their handlers~280 linesEach 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.
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.
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.
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.
Proves: code read — toGuestCard(), pms-adapter.ts, confirms AppFolio's report is read-only today (ADR-0094).
Like a help-desk ticket: whoever it's assigned to owns it, and handing it to someone else is the same click either direction.
Proves: research doc — AppFolio's own AssignedUser/AssignedUserID fields, confirmed in both the report and the live message feed.
Deeper → Appendix: PRs and switches
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.
sendGuestCardMessageL4 — live-proven 2026-09-02.assignGuestCardL4 — accept or hand off a card.addGuestCardNoteL4 — one short note per core fact.Proves: code read — l4-core.ts, the one file every AppFolio write goes through.
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.
Proves: appfolio-browser-agent service, live call — 503 LOGIN_BUDGET_EXCEEDED (8/8 used), tonight.
Like a single index card every department reads from, instead of three people copying the same facts into three different notebooks.
guest-card-core-facts.ts — being built by a sibling PR tonight.Proves: coordination note tonight — the shared file lands as its own first PR; this program imports it, doesn't fork it.
Like a new hire who shadows quietly for a week before touching a real customer's account.
Proves: Western Slope go-live tracker, rows gca-assign-clara-a1 and gca-assign-clara-chain-blocked.
Deeper → Appendix: PRs and switches
Three forks in the road — pick one per question, or say "keep thinking."
Proves: nothing decided here binds anything — every option ships dark either way.
Deeper → Appendix: full options
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.
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."
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).
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.
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
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.
| Intent | Trigger | What it does in AppFolio | Idempotency key |
|---|---|---|---|
create_card | A leasing call ends, a prospect exists, no af.guestCardUuid yet | Creates the card: name, phone, email, property, desired move-in, bedrooms | gc:create:<inquiryId> |
append_note | The same person calls again | Adds an internal note carrying this call's own summary record | gc:note:<inquiryId>:<conversationId> |
cancel_tour | A scheduled tour moves to cancelled | Note only — does not change status | gc:tourcancel:<tourId> |
close_card | Prospect says they're no longer interested | Marks Inactive, with reason and comment | gc:close:<inquiryId>:<stageAt> |
The note text always comes from the call's own summary record (§15.5), never the person-level free-text field.
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 state | AppFolio status | Reason / comment |
|---|---|---|
| Active lead, any stage up to applied | Active (leave as created) | — |
| Tour cancelled | unchanged | Note only: "Tour for Thu 2pm cancelled by the caller." |
| Prospect said not interested | Inactive | Reason from AppFolio's own dropdown (values not yet read live); comment carries the one-line why |
| Prospect went cold, no answer | Leave alone for now | Policy 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.
Live proof on JP&Co test property 45, guest card 104, corrected three facts from the earlier read-only recon:
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./crm/leasing/prospects/<uuid>, not /guest_cards/<id>. The numeric id is read from the property-filtered guest-card index, matched by that uuid.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.| Intent | Request | Fields |
|---|---|---|
create_card | POST /guest_cards | contact_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_note | POST /notes | note[body], parent_id, parent_type=LeadToLease::GuestCards::GuestCard |
assign | POST /guest_cards/<id> | inquiry[assigned_user] |
mark_inactive | POST /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/.
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.
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.
| PR | What | Today? |
|---|---|---|
| PR1 — #9105 | Link 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. | ||
| PR2 | createGuestCardL4 + the write handler, link-first lookup. Form recon resolved, see 15.7 result. | Only if the form findings land today |
| PR3 | append_note on a repeat call, plus the dependsOn flush. | No — after PR2 |
| PR4 | Status intents: cancel_tour, close_card. | No |
| PR5 | Ingest writer recognises cards we created — no second prospect, no attribution reset. | No |
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.
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.
Full record for whoever picks this up next.
| Step | What | PR | Switch | Status |
|---|---|---|---|---|
| A1 | Read AppFolio's guest-card assignee into PropFlow | propflowai#8121 | none — read-only | Open, CI green |
| B1 | addGuestCardNoteL4 robot capability | not yet opened | none — capability only | Blocked on live capture |
| A2 | assignGuestCardL4 robot capability | not yet opened | none — capability only | Blocked on live capture |
| A3 | Ingest trigger, Clara's user id per org | not yet opened | CONFIG / GUEST_CARD_ASSIGN_CLARA#<orgId> (default OFF) | Sequenced after A2 |
| B2 | Core-facts note sync trigger | not yet opened | CONFIG / GUEST_CARD_NOTES_SYNC#<orgId> (default OFF) | Sequenced after B1; imports the shared guest-card-core-facts.ts once merged |
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.
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.
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.
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.
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.
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.
Proves: everything below is either the unchanged core or one box in this diagram.
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.
Proves: today's code answers all five by accident. File map in A4.
A single-lane bridge: plenty of stages, but two of them only fit one car through at a time.
Proves: sync-tour.ts, provider/resolve.ts, notification-recipient.ts — line-by-line map in A4.
A taxi dispatcher doesn't own the cars — the drivers do. Dispatch only decides who takes the fare.
Proves: matches Gera's portfolio design; needs his confirm on placement (D1, A1).
The company opens the front door once; each agent still carries her own house key.
Proves: go-live tracker row te-people-calendars turning to tested, with a real tour landing on Kat's calendar.
Like adding new labels to file folders that already exist — no new cabinets, no new filing system.
Proves: row shapes follow Gera's solution section; full row-by-row table in A4.
A recipe followed the same way every time: same ingredients in, same dish out, no guessing.
Proves: unit-testable to the minute; two runs on the same data give the same answer.
A restaurant reservation minimum: you can't book a table five minutes before you walk in.
Proves: Western Slope call transcript, 2026-09-09 — quotes in A2; no competitor ships a sensible default (A6).
Packing a suitcase: one shirt takes a minute to fold, five shirts take five.
Proves: Jason's own bug report on the client call — quote in A2; competitor survey in A6.
Like one doctor's appointment that covers two check-ups back to back, not two separate visits.
Proves: Jason's request on the client call (A2); only Rently batches units today (A6).
Three doctors sharing one waiting room beats three doctors each locked to their own — merging free time beats either extreme.
Proves: Fede's original question; full comparison table in A4; competitor survey in A6.
A relay race baton pass: the runner who's out hands off automatically, nobody stops to ask who's next.
reassign_tour (section 18.3).Proves: the Situs incident, 2026-09-01 transcript (A2) — the exact gap this closes.
A hotel keeps its front-desk process but hands over a keypad code instead of a bellhop.
Proves: Situs's leasing director's own request; six-product comparison in A3.
Two people reaching for the last parking spot at once — one has to lose cleanly, and be told why.
Proves: existing durable-workflow-per-tour and single-author pattern; competitor behavior in A6.
Updating your own calendar but not the shared family one — miss it and someone shows up confused.
Proves: every competitor treats PMS sync as table stakes — sources in A6.
A renovation that never touches the load-bearing wall a tenant already lives behind.
Proves: replay test against production tour history.
A rideshare app assigning the nearest driver instead of a fixed queue.
Proves: Situs transcript — Orca mentioned only for maintenance (A2); full detail on the Orca Staff Location page.
Climbing a ladder where every rung is tested for weight before the next one is trusted.
Proves: full ten-step table with exit tests and owners in A7.
A menu with the chef's recommendation starred — pick a number, or trust the star.
Proves: full options and reasoning for all eleven decisions in A1.
Checking the weather before the climb, not after the ladder's already up.
Proves: full seven-risk table with mitigations in A5.
Every option considered for all eleven decisions, with the recommendation and why.
| Decision | Options | Recommendation |
|---|---|---|
| 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 settings | Tour 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. |
Quotes are verbatim from the underlying per-speaker transcripts, not the meeting recap.
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.
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…"
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."
Full research: self-showing-competitors-2026-09-13.md.
| Option | Shape | Note |
|---|---|---|
| (A) Open access | No 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, unassigned | The 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) Hybrid | Self-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.
| Product | Setting | Host assigned? | ID verification | Code delivery | Coexists w/ agent tours? |
|---|---|---|---|---|---|
| EliseAI | Per-property | No — AI guides via SMS | ID scan + CC hold ($0.99) + likely liveness selfie | SMS, time-scoped, smart-lock partners | Unverified in public sources |
| Rently | Per-unit (subscription) | No; staff may pre/post-check | ID (front+back) + liveness selfie + CC hold — strictest | SMS, one-time, serial-number gated | Yes — 64% of showings self-guided, rest agent-led |
| Tenant Turner | Per-property | No | Government ID | SMS, time-scoped | Yes |
| ShowMojo | Per-property/account | No (self) / Yes (agent) | ID + phone (unverified detail) | SMS, varies by lock (MojoBox, CodeBox, Vault eLock) | Yes |
| Knock (RealPage) | Per-property | No — AI-routed | ID + selfie (CC unverified) | Unverified | Yes |
| Funnel | Per-property | No | Unverified | Unverified | Yes |
| AppFolio | Per-property, via integrations | Via integrations | Via integrations (ShowMojo, Tenant Turner) | Via integrations | Yes (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.
Every place in the code that can only hold one answer today, and every new row the model needs.
| Where | What is hard-coded | Why 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.
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 row | Lives on | What it carries, and why |
|---|---|---|
ATTACH#calendar#primary | a 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>#leasing | the 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>#leasing | a person | Static 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.gapMinutes | the 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.confirmWindowMinutes | the 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 calendar | A 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>#leasing | a person | Least-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 tour | the tour | assignedPersonId, 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. |
| Merge everyone's free time | One shared calendar | Rotate, with rules | |
|---|---|---|---|
| In one line | Offer 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 gets | Most 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 goes | Decided 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. |
| Reschedule | Keep 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. |
| Cancel | Release 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 risk | Real and must be handled. | Real, and worse across buildings sharing the calendar. | Lower — fewer people are ever offered the same slot. |
| Verdict | Recommended 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.
| Risk | Why it is real | What removes it |
|---|---|---|
| It contradicts a ruling made the same night | The 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 split | It 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 path | Slots 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 free | This 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 does | Two 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 one | Every 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 consent | A 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 week | If 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. |
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.
freeBusy merges overlapping intervals, and Microsoft Graph's getSchedule returns merged availability for up to 20 entities in one request. learn.microsoft.com/en-us/graph/outlook-get-free-busy-scheduleTen 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.
| # | Step | What changes | The exit test |
|---|---|---|---|
| 1 | The walk returns a list in flight, dark, PR pending (2026-09-14) for Western Slope | The 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. |
| 2 | A calendar on a person in flight, dark, PR pending (2026-09-14) for Western Slope | Signing 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. |
| 3 | Merge the free time next, supported by design; not needed while Western Slope has one agent | Availability 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. |
| 4 | The policy rows | Minimum 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. |
| 5 | Assignment and the rule | Days, 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. |
| 6 | The right person is told | The 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. |
| 7 | Holds, keyed by calendar | A 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. |
| 8 | Cover, in the engine | A 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. |
| 9 | Two homes, one visit | A 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. |
| 10 | The PMS learns | The 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.
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.
| Role | Defined | What it can do | Offered at invite? |
|---|---|---|---|
platform_admin | types.ts:13871 | PropFlow staff, crosses every company | No — staff only |
org_admin | types.ts:13872 | Everything in their company, invites anyone | Yes |
property_manager | types.ts:13873 | Day to day: leasing, maintenance, money, residents, vendors | Yes |
leasing_agent | types.ts:13874 | Leasing end to end; reads residents and buildings | Yes |
maintenance | types.ts:13875 | Work orders and vendors | Yes |
viewer | types.ts:13876 | Read only | Yes |
accounting, regional_manager, assistant_property_manager, leasing_assistant | types.ts:13888-13891 | All equivalent to property manager (owner ruling); regional outranks for invites | No 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).
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.
| Gera's word | In code today? | Where |
|---|---|---|
org_admin | yes | types.ts:13872, :16802 |
| Property owner (the landlord) | no, deliberately | Holds no rung; would arrive as a grant on an ownership row. Not built. |
| PMS credential holder | yes, but a different thing | Property.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 building | yes | types.ts:16817-16828 |
| Building-scoped staff read on an inbound call | no | inbound-resolution.ts:175 drops any role that is not company-wide |
| Tour candidates chosen from leasing agents | partly | tour-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.
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.
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:
voice field (tools/types.ts:595). By domain: maintenance 19, leasing 19, turnovers 10, renewals 6, lease-and-billing 4, platform 2, comms 1. The other 90 are dashboard and background work.voice field and none appears in any of Clara's text tool files. Every action a manager would want — "mark it rented", "post the vacancy", "send the lease" (appfolio.post_vacancy, set_unit_availability, send_new_lease) — is real, is permissioned, and is not reachable by talking to Clara. That is deliberate and out of scope for now — see below. The single exception is maintenance.create_work_order, which writes to AppFolio live.| Persona | Voice | SMS | Dashboard | |
|---|---|---|---|---|
| Prospect | 19 leasing tools on the Leasing agent — save_prospect, schedule_tour, reschedule_tour, cancel_tour, get_available_units… | Same 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 |
| Tenant | create_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_balance | MAINTENANCE + RESIDENT_SERVICES, same names minus five staff-only ones | Same as SMS | Resident screens |
| Vendor / handyman | Transfer + end only | HANDYMAN allowlist (capabilities/handyman.ts:43): notes, close, edit, list mine, create, set_my_quiet_hours | Same as SMS | Vendor screens |
| Renewal (tenant) | renewal_accepted, renewal_declined, renewal_escalate, change_renewal_term | Same + send_offer_sms, send_portal_link_sms | Same as SMS | Renewals screens |
| Unknown caller | Transfer + end; capture_unknown_caller_note is open to everyone | Escalate + close only | Same | — |
This is the short table, and it is the point of the audit.
| Role | Voice | SMS | Dashboard | |
|---|---|---|---|---|
| Property manager | find_turnover, start_inspection, append_notes, revise_finding. That is all. future reassign_tour — see below. | The 13 turnover tools, draft_mass_message, get_property_metrics | Same as SMS — identical pipeline | Every screen in the permission matrix |
| Leasing agent | nothing — 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 sender — capabilities/index.ts:242 | Same as SMS | Leasing screens (full) |
| Maintenance tech | not recognised | get_property_metrics; handyman tools only with a vendor membership | Same as SMS | Maintenance + vendor screens |
| Org admin | not recognised | Turnover tools + metrics | Same | Everything |
| Owner / landlord | Does 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'.
| Channel | Enforcement | File |
|---|---|---|
| SMS / Email | Full role-matrix check on every call | conversation-manager.ts:10076 |
| Voice | No 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 |
| Dashboard | Permission matrix + property scope | permissions-source.ts, scope.ts |
| Channel | The path | Where it falls down |
|---|---|---|
| Inbound call | Number 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 text | Number texted → building and company (phone-lookup.ts:550) → sender's number → role rows (resolve-actor.ts → inbound-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 email | Same 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. |
Target: leasing agent ⊂ property manager ⊂ org admin; the role recognised per company from the person record; the same tools on every channel.
| # | Gap | What is missing | Size |
|---|---|---|---|
| 1 | Voice cannot see a leasing agent | Widen the call-time check from isPmTier to STAFF_TIERS and carry the tier list, not one boolean — pm-call-context.ts:169 | S |
| 2 | One flat "pm" word on text and email | senderRole is 'tenant' | 'pm'; it needs to carry the real tier — inbound-router.ts:498, clara-pm.ts:80 | M |
| 3 | Staff are denied the leasing tools | Drop or invert !isResolvedStaff so a leasing agent gets the leasing surface — capabilities/index.ts:242 | M |
| 4 | No leasing-agent persona prompt | One prompt block beside CLARA_PM_PERSONA — clara-pm.ts:42 | S |
| 5 | No tour-notes tool on any channel | A 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 |
| 5b | Clara's walkthrough capture takes no photos | The dashboard inspect route stores structured photos; Clara's append_notes is narration only | M |
| 6 | No voice persona for a leasing agent | A staff-leasing agent beside turnover-intake, plus a triage rule | M |
| 7 | Building-scoped staff invisible on every inbound channel | Inbound resolution drops non-company-scoped roles — inbound-resolution.ts:175. The one place the code is narrower than Gera's model. | M |
| 8 | Voice applies no role gate at all | Voice checks two predicates, never the matrix — voice/tools/[tool]/route.ts:497 | L |
| 9 | Email ignores staff senders by design | The 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 |
| 11 | A person at two companies gets one | Single company id on staff resolution — inbound-router.ts:483 | M |
| 12 | Escalation owner is an email, not a person | No link from the building to a staff person record — types.ts:3113 | M |
| 13 | Two spellings for one job | property_manager on the login, pm in conversation. Leave it — translated in one place. | S |
| 14 | Tour calendars only read leasing agents | A property manager's own calendar is not in the pool — tour-calendar-pool.ts:62 | S |
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.
voice field. That is fine; nobody is asking to run the PMS by text.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.
isPmTier to STAFF_TIERS, so a leasing agent, maintenance tech and org admin are seen as staff on the phone. One file: pm-call-context.ts.small'pm' through senderRole, and add a leasing-agent prompt block beside the PM one.smallcapabilities/index.ts:242), so a leasing agent can actually use the leasing surface she already owns on the dashboard.mediumMigration: 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.
Person.category exists in the type but was not confirmed written by any live path — forward-looking only.applicant or guarantor role value was found anywhere; reported as a negative, not proven absent.leasing-many-homes.ts is partly live; which of its sections reach a real call was traced through the slot files, not confirmed against a deployed agent.voice field was consistent across both reads.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.
| # | The call | Recommendation | Section |
|---|---|---|---|
| 1 | Is 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_hours | 1 |
| 2 | Approve 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 build | 1 |
| 3 | Confirm the new field stays off for every customer, Western Slope included, until Fede turns it on. | Ships dark by default | 1 |
| 4 | Ship 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 behavior | 2 |
| 5 | Camellia'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 unverified | 2 |
| 6 | 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? | Hard cutoff — matches how Fede described it | 2 |
| 7 | Build 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 rebuild | 3 |
| 8 | Is 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 all | 3 |
| 9 | Should a backup's daily tour slots be capped so a maintenance person's day job is not crowded out? | Yes, a small daily cap | 3 |
| 10 | Which mailbox receives the observer BCC? | A new dedicated inbox (e.g. observer@propflowai.co), not hello@ | 4 |
| 11 | Default state for brand-new clients: on automatically with an off switch, or a visible onboarding toggle defaulting on? | Visible onboarding toggle | 4 |
| 12 | Observer BCC auto-expiry: manual off only, or a built-in expiry unless renewed? | 60-day auto-expiry | 4 |
| 13 | Adopt 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-qualify | 5 |
| 14 | Build 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 first | 5 |
| # | The call | Recommendation | Section |
|---|---|---|---|
| 15 | Land 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 email | 6 |
| 16 | Order 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 attribute | 6 |
| 17 | Timing 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 irreversible | 6 |
| # | The call | Recommendation | Section |
|---|---|---|---|
| 18 | How long after the tour does Clara text the agent — 15, 30 or 60 minutes? | 30 minutes after the tour is due to end | 7 |
| 19 | Does the prospect's application link wait for the agent's answer? | No — it goes at 60 minutes as today, personalised if the notes have arrived | 7 |
| 20 | Text 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 point | 7 |
| 21 | If the agent never answers: one nudge the next morning then let it go, no nudge, or keep asking? | One nudge, next morning, then silence | 7 |
| # | The call | Recommendation | Section |
|---|---|---|---|
| 22 | Clara messaging through AppFolio as a seat, versus her own number. | Seat | 14, 16 |
| 23 | Assign 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 alone | 14, 16 |
| 24 | Note 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 fact | 14, 16 |
| 25 | Create a card at a client whose staff already make their own cards? | Yes, but link-first | 15.11 |
| 26 | What goes in the card's Source field? | A distinct "Clara — phone call" source | 15.11 |
| 27 | Mark a card Inactive on our own when a prospect goes quiet? | No — only when the person actually says so | 15.11 |
| 28 | Note 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 note | 15.11 |
| # | The call | Recommendation | Section |
|---|---|---|---|
| 29 | D2 · 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 booking | 17.A1 |
| 30 | D3 · On reschedule, keep the original person or re-pick from the rule? | Keep the original person if she is free at the new time | 17.A1 |
| 31 | D4 · The minimum heads-up default. | 60 minutes, set on the company, overridable per building | 17.8 |
| 32 | D5 · 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 fails | 17.A1 |
| 33 | D6 · The multi-home cap per visit. | Three | 17.10 |
| 34 | D7 · 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 confirmation | 17.12 |
| 35 | D8 · 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 connect | 17.A1 |
| 36 | D9 · Lockbox self-showing default: open access, scheduled-unassigned, or hybrid? | Scheduled, unassigned | 17.13 |
| 37 | D10 · Which lockbox access tier to build first: smart lock, or static code? | Static code — no hardware purchase, the homes already have lockboxes | 17.13 |
| 38 | D11 · 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 constants | 17.A1 |
| # | The call | Recommendation | Section |
|---|---|---|---|
| 39 | How 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 handoff | 20 |
D1 · Where a calendar lives — decided by Fede, 2026-09-14: people's calendars; the company (leasing@) calendar stays as reserve and fallback. Full options in 17.A1.
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.
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 family | Agents carrying a hand copy | Already drifted? | Existing shared mechanism | Recommended home |
|---|---|---|---|---|
| No unit-count disclosure | 3+ | 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 leak | None | Core prompt |
| Language switching | 3 | Yes 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 language | None | Injected slot (must stay capability-aware) |
| Tour-times phrasing & slot rules | 3–4 | Partial 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 carry | MANY_HOMES_FRONT_DOOR_DIRECTIVE (partial) | Injected slot |
| Hours & after-hours | 6–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 store | OFFICE_CLOSED_DIRECTIVE_SLOT / renderOfficeClosedDirective() | Core prompt + a build check that refuses any literal day/time range inside a prompt file |
| Name handling & correction | 2 | Partial 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 tonight | None | Core prompt |
| Brevity & one idea per turn | 6–8 | Likely — six-plus independent copies of “2–3 sentences, no markdown”, never diffed against each other | None | Core prompt |
| No phone read-back / last four | 2–3 | Not confirmed — wording differs (“digit by digit” vs. opt-in read-back) but not enough samples to call it drift yet | None | Core prompt |
| Human handoff / office number / transfer | 9 | No — 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 tonight | HUMAN_TRANSFER_CONNECT_LINE (+ drift test) | Promote into the core prompt so a new agent can't skip it by omission |
| Note / callback capture promises | 2 | Yes — after-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 draft | Injected slot, canonical source = after-hours-message-desk.ts |
| Identity greeting | 4 | Likely — each of maintenance-tenant, emergency-relay, leasing, triage hand-rolls its own opening line | None | Injected slot (channel legitimately varies here) |
| Fair housing | — | No — already one shared import | PROTECTED_CLASSES_INLINE | Keep as injected constant; eligible for core prompt since it's non-negotiable everywhere |
| Move-in date asked/stated first | — | Not audited this pass | Unknown | Follow-up grep before ruling |
| No team-member names | — | Not audited this pass | Unknown | Follow-up grep before ruling |
| End-call etiquette | — | Not audited this pass | Unknown | Follow-up grep before ruling |
| Knowledge-free greeter / escalation / emergency | — | Not audited this pass | Unknown | Follow-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.
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.
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.
| Option | What it means | Recommendation |
|---|---|---|
| (A) Full core+role refactor now | Stop 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 handoff | Build 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 lint | Leave 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.
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.
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:
| PR | What it fixed | Review rounds | Open→merge time |
|---|---|---|---|
| #9198 | Weekend tour slots survive when hours are set | 1 | 8 minutes |
| #9199 | Callback on a leasing line notifies the office | 4 | 19 minutes |
| #9177 | Company-live email hold, one seam | 10 | ~3h20m |
| #9203 | Full tour-time list, no context narration | 13 | still 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.
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:check → scripts/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.
| Root cause (tonight's RCA) | Existing check | Would it have caught it? | What's missing |
|---|---|---|---|
| Scenario coverage — 14/26 defects in shared code Camellia never exercises | voice-nightly.yml | No | Fixtures cover Willows only; Western Slope/Camellia shapes aren't in the matrix |
| Correct data, wrong renderer (tour-hours shape bug) | None found | No | No contract test on the rendered slot output, only on the underlying data |
| Hours/facts in 3 places incl. literals in the WS prototype prompt | el-config-drift-live.drift.test.ts (tool schema only) | No | Doesn't diff prompt text; no lint refusing literal days/times/numbers in a prompt file |
| Company-live email hold patched 5× | None found | No | No 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 proof | require-eval-diff.yml (text eval only) | No | A text eval satisfies the check; nothing requires a heard call |
| Sept 16 outage — empty dynamic variable crashed EL for every customer | None found | No | Context-wiring drift test checks the var is referenced, not that its runtime value is non-empty |
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.
| # | Proposal | Effort | What breaks today | Proposal | What it proves / type |
|---|---|---|---|---|---|
| 1 | Fast lane: staging call before a PR exists Merged 2026-09-18, #9285. | S | voice: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. |
| 2 | Empty-dynamic-var guard | S | voice-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. |
| 3 | No-literals-in-prompt-files lint | S | The 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. |
| 4 | Per-PR rendered config diff comment | M | Reviewers (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. |
| 5 | Post-deploy contract test, widened and moved off nightly-only | M/L | el-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. |
| 6 | Nightly real-line bench, widened past Willows | M | voice-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. |
| 7 | Commit-tagged one-command rollback | S | Rollback 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). |
| Option | What it means | Recommendation |
|---|---|---|
| (A) Fast lane only | Ship 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 proof | Items 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–7 | Everything 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.
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.
| Event | Intent | Status | Where, and why |
|---|---|---|---|
| First call from a lead | create_card | WIRED | Two 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 person | append_note | PARTIAL — queued, never sent | Queued 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 booked | — | NOT WIRED | No 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 rescheduled | cancel_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 cancelled | cancel_tour (declared, unused) | NOT WIRED | Same #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 interested | mark_inactive (declared, unused) | NOT WIRED — not even detected | No 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 all | — | NOT WIRED past creation | reconcileGuestCardFromCall (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 WIRED | enqueueGuestCardIntentFromCall 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 submitted | n/a | OUT OF SCOPE, by direction | Applications 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.
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.
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.
| Event | Intent | AppFolio effect |
|---|---|---|
| Second call, same person | append_note | Note: this call's own summary. (Already queued today — just needs the drain's second pass wired to POST /notes.) |
| Tour booked | append_note | Note: "Tour booked Thu 2pm, 372 Ember Lane." No new field — there is no AppFolio route to write a structured showing date to. |
| Tour rescheduled | append_note | Note: "Tour moved to Fri 4pm." Never a status change. |
| Tour cancelled | cancel_tour | Note only: "Tour for Thu 2pm cancelled by the caller." Status untouched — a cancelled tour is not a dead lead. |
| Caller says not interested | mark_inactive | The 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_note | Note 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 conversation | same four intents | The 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"]
Three choices, each with a recommendation. Reply with the numbers and A/B.
enqueueGuestCardIntentFromCall, so every channel produces the same four intents.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.mark_inactive first, since a caller explicitly saying "not interested" is the highest-stakes miss.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.
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.
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.
| Call | Time (UTC) | Clara words in the first 4 turns | Caller words in the same span | Clara questions before the first home fact | How it ended |
|---|---|---|---|---|---|
| …5047, leasing | 14:13 | 59 | 9 | 3 — first rent at 51 s | “Oh, no, thank you.” |
| …4630, leasing | 16:32 | 61 | 10 | 3 — first rent at 43 s | “Can I speak to a person?” |
| …9427, maintenance | 15:28 | 41 | 10 | — | transfer failed, call dropped |
| …8088, Spanish | 15:39 | 11 | 2 | — | “¿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.
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.
| # | Defect | The instruction causing it | Evidence | Proposed | Words saved |
|---|---|---|---|---|---|
| 1 | The 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.
| # | Defect | The instruction causing it | Evidence | Proposed | Words saved |
|---|---|---|---|---|---|
| 2 | The 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 |
| 3 | The 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 |
| # | Defect | The instruction causing it | Evidence | Proposed | Words saved |
|---|---|---|---|---|---|
| 4 | The 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 |
| 5 | Facts 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.
| # | Defect | The instruction causing it | Evidence | Proposed | Words saved |
|---|---|---|---|---|---|
| 6 | The 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 |
| 7 | The 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 |
| 8 | The 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 |
| # | Defect | The instruction causing it | Evidence | Proposed | Words saved |
|---|---|---|---|---|---|
| 9 | A 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 |
| 10 | Two 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. | — |
Four places where an instruction makes Clara volunteer something, or ask a second thing, before the caller has answered the first.
portfolio-intake-rules.ts:402–403: “You MAY put two asks in one breath, the way the sections above already do.” The same stack says the opposite twice — western-slope-portfolio-triage.ts:730 (“One question at a time — never stack two questions”) and portfolio-intake-rules.ts:422–425 (the name ask is never welded to the move-in ask). Call …5047 turn 11 asked “Want to come see it? And who am I speaking with?” and got neither answer. Delete the permission.| Order | Change | Files and size | Agents affected |
|---|---|---|---|
| 1 | One 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 lines | WS portfolio line, many-homes leasing |
| 2 | Delete the enumerated qualifying list; keep two-facts plus one open question (defect 6) | portfolio-intake-rules.ts:435–451, ~15 lines removed | WS portfolio line, many-homes leasing |
| 3 | Move the name ask off the before-any-home slot (defect 7) | portfolio-intake-rules.ts:412–425, ~14 lines removed | WS portfolio line, many-homes leasing |
| 4 | One 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.ts | Every voice agent |
| 5 | Greeting 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 line | WS 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.
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.
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.
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:
unit_number. The tool schema gives the model two free-text fields for a home — home_ref (a name) and unit_number (the literal address line from the AVAILABLE HOMES block, tools-leasing.ts:566) — and nothing in the data the model was handed carries an id it could put in either field with confidence. Told a name that isn't the literal address string, the model reaches for the field that seems to fit and leaves the other blank.tools-leasing.ts (~line 4936) refuses with home_not_resolved the instant unit_number is empty on a scattered line — a check written when the literal address in unit_number was the ONLY key a scattered line had, so it never had a reason to try home_ref first.unit_number”) is a second attempt Clara narrates the same way she narrated the first, with nothing suppressing the repeat (separate issue #9533, another worker's lane).orgHome.home found) stamps bookedUnit = { id: '', number: orgHome.home.number } by explicit design (“the address is the home's name; unitId stays empty”), and property itself never becomes that home's own mapped Property row — it stays the org's front-door shell (western-slope-front-door) for the whole call, because nothing in this path ever looks the mapped roster up. The Tour is written with a real address string and no property identity behind it.postcall-guest-card-outbox.ts) is doing exactly its job: it walks tour.propertyId → prospect.propertyId → conversation.propertyId and asks each for a PMS writer. Every candidate is the shell, which has none, so it logs no-pms-property and never enqueues — correctly, given what it was handed.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.
| Rejected | Why it doesn't reach the root cause |
|---|---|
Promote home_ref into unit_number when unit_number is empty | The 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 use | Same 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/flag | This 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. |
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:
OrgHome (org-home-booking.ts) gains key — the listing's OWN id (OrgListing.externalId, e.g. the AppFolio listing uuid for 367 Ember Lane's current listing) — and aliasText, the listing's title + full description concatenated, threaded from org-offerable-homes.ts.listing-resolution.ts (the one matcher every line already shares) gains a NAME tier, tried ONLY after both address tests find nothing, and ONLY for a 2+ word phrase: a caller's exact words are looked up against aliasText — never written into a booking field, never treated as canonical. A row with no aliasText (every building's Unit[], live today) can never match here — byte-identical to before this existed.home_not_resolved-with-candidates shape the address tier already used for a partial street match, never a guess.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.
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.
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.
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.
| Rule | Anonymized 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. |
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).
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.
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.
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.
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.
Sixteen capabilities. Clara does one of them.
| Capability | What AppFolio supports (article) | What Clara does today | Incident | Covered by |
|---|---|---|---|---|
| Create a card | First 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 enforced | A 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 card | Internal 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 |
| Reactivate | Change 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 call | agent #383 |
| Add the interest for the home discussed | Inquiries 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 moves | Last 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 logged | Schedule 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 cancelled | Cancel Showing / Mark as No Show (Schedule Showings) | Gap. The intent kind is declared, unimplemented. | — | — |
| Mark lost | No 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. | — | — |
| Dedup | Manual 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 source | Free 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 agent | Assigned 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 date | Desired 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 line | The 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 |
npm run test:harness:appfolio-guest-cards, at scripts/appfolio-guest-card-harness/. One scenario per capability above.
--live. --list prints the checklist and the gaps.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.
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.
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
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.
| Principle | What 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. |
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.
| Thing | What it is | Consequence for us |
|---|---|---|
| Card | The 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. |
| Inquiry | Property-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. |
| Interest | Unit- 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 Interest | The 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 Activity | Last 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. |
| Status | Active, 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. |
| Source | Free 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. |
| Showings | Scheduled 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. |
| Applications | Submitted 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. |
| Duplicates | Cards 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. |
| Deletion | Not possible, ever (FAQs — Guest Cards). | Shapes testing (Willows only), cleanup (mark inactive is the only cleanup), and the placeholder-surname question. |
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.
| Channel | What Clara does | AppFolio write | Team receives | We store | State |
|---|---|---|---|---|---|
| Inbound voice call | Answers, gathers, books | create_card at call end; note + interest on a repeat | Tour/lead email built from the booking | Person, inquiry, tour, call summary | covered (create only) |
| Text (SMS) to the leasing line | Answers and books today | Treated as the phone line: create_card when there is none, notes after. assumed, confirm | Tour email if a tour is booked | Person, inquiry, tour | gap |
| Replies only to a new AppFolio guest card (Fede, 2026-09-15); never Zillow relay mail | Notes only, never a create. The card already exists by definition. No linked card found → park and email the team. | Reply threads in the leasing mailbox | Conversation, inquiry | gap | |
| Website / listing form | Not a distinct intake today; syndicated leads arrive as AppFolio cards | Notes 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 inquiries | Notes only, never a create, plus assign once the company is live. No linked card found → park and email the team. | Whatever the conversation produces | Person + af.guestCardUuid link | gap (assign route exists, unwired) |
| Identity | What Clara does | AppFolio write | Team receives | We store | State |
|---|---|---|---|---|---|
| Full name, phone, home discussed | Asks for the full name outright | create_card: first, last, source, interest, phone, email, move-in | Lead/tour email | Person + card link | covered |
| First name only | Asks once for the last name, then carries on either way | create_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 normal | Person + card link | in flight #9511, #9599 |
| No name at all (asked for a person, hung up) | Offers a callback, takes the number, confirms the last four | No card — AppFolio rejects it | Email: they asked for a person, here is the number, call them back | Person keyed on phone, parked intent | in flight #9598 |
| Known person in our spine, card link stored | Greets by name, picks up the open context | append_note + reactivate + add the interest for the home discussed | Email if anything changed | Existing person, new interaction | in flight #9581, agent #383 |
| Known to AppFolio, not linked with us | Same, if we can find the card | Look up by normalised phone, then email, stamp the uuid, then note — no second card | Email as usual | Link stamped on the person | gap — no authenticated card-read route exists |
| Card made outside PropFlow (their staff, a syndication feed) | Same conversation | Must find and use it; today we would create a duplicate the client can never delete | Email as usual | Link stamped | gap |
| Two or more cards already exist for the person | Same conversation | Write to the one with the most recent activity; never surface a merge prompt | Email as usual | One link, the chosen card | gap |
| Existing card | AppFolio write | Why | State |
|---|---|---|---|
| None | create_card | The only path proven live (Willows card 104, 2026-09-17). | covered |
| Active | append_note + add interest for the home discussed | The 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 / lost | Reactivate to Active, then note, then interest, returning statusBefore/statusAfter | Fede, 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 Completed | Note only; do not reactivate | Reactivating a card that already converted misstates the pipeline. Proposed, not decided. | gap |
| Home context | AppFolio write | Notes | State |
|---|---|---|---|
| One specific unit | Interest u_<unitId> | The clean case. | covered |
| A community with several homes, none chosen | Inquiry 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 line | Park with reason no_pms_property | Scattered-homes orgs answer on a company line whose row is not an AppFolio property. | gap |
| Home at a different property in the same org | A second inquiry on the same card | Exactly the 18 September repeat-caller case: her card's only inquiry was for a different home. | gap |
| Home outside the org | Never written | The org is the wall. We refuse rather than guess, and tell the team. | covered by construction |
| Event | What Clara does | AppFolio write | Team receives | State |
|---|---|---|---|---|
| Questions only, no tour | Answers from knowledge, gathers the card details at the end | create_card, note = the ask | Lead email | covered |
| Tour booked | Two options max, part of day first | Should be a showing on the card (unit + time). Proposed interim: a note line. | Tour email using guest-card labels | gap |
| Tour rescheduled | Rebooks, re-confirms | Move the showing; interim, a note. Never a status change. | Updated tour email | gap |
| Tour cancelled | Offers to rebook | Cancel the showing; interim, a note. Status untouched — a cancelled tour is not a dead lead. | Cancellation notice | gap |
| Tour happened | Texts the agent ~30 min after, takes the answer by text or voice | Note carrying the agent's own words + the showing marked shown | — (the agent is the source) | gap |
| No-show | Follows up with the prospect | Mark the showing No Show | Daily digest line | gap |
| Application started | Sends the application link an hour after the tour | Nothing — AppFolio owns applications and we read the status back | — | out of scope |
| Asked for a person / callback | Never “can't transfer”; offers a callback, takes and confirms the number | create_card with a note recording the callback request | Email: they asked for a person, call them back | in flight #9598 |
| Left a message | Takes it verbatim | Note carrying the message | Email with the message | gap |
| Said not interested | Acknowledges, does not chase | Phase 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 line | gap — not even detected today |
| Wrong number, vendor, or current resident | Routes by intent; leasing takes a message, everyone else transfers | No card, ever | Routed to the right inbox | covered |
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.
| Timing | AppFolio write | Why it matters | State |
|---|---|---|---|
| First touch | create_card | The baseline. | covered |
| Re-engagement after 30 or 90 days | Reactivate if inactive, note, add the interest for the home now discussed | All 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 call | One note per call, keyed by conversation id; no second card, no duplicate note | Idempotency 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.
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).
| Write | Mechanism | Idempotency key | Read-back check | State |
|---|---|---|---|---|
| Create card | Robot 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, ever | Find the uuid in the property-filtered index; a 200 alone proves nothing | covered |
| Append note | Robot POST /notes — note[body], parent_type=LeadToLease::GuestCards::GuestCard | gc:append_note:<inquiryId>:<conversationId> | Re-read the note list for the body | in flight #9581 |
| Reactivate | Robot POST /guest_cards/<id> status change, reason required | gc:reactivate:<inquiryId>:<conversationId> | statusBefore / statusAfter returned by the route | in flight agent #383 |
| Add inquiry / interest | Not yet recon'd — the create form's interest control is the closest known surface | gc:add_interest:<inquiryId>:<unitOrPropertyId> | Card appears in that property's list; Latest Interest moved | gap |
| Move Latest Activity | No direct route — a by-product of an activity (note, interest, showing, status) | n/a | Most Recent Activity column | gap |
| Log showing | Not recon'd. AppFolio schedules showings against a unit and a time. | gc:showing:<tourId> | Showing appears on the card timeline | gap |
| Cancel showing / no-show | Not recon'd (Cancel Showing, Mark as No Show) | gc:showingcancel:<tourId> | Timeline entry | gap |
| Mark lost | Robot 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 reason | gap |
| Set lead source | Set at create, on the inquiry; free text with reusable values | part of create | Never verified on a real card | unverified |
| Set assigned agent | Robot POST /guest_cards/<id> with inquiry[assigned_user] — per inquiry | gc:assign:<inquiryId> | Assigned column in the index | gap (route known, unwired) |
| Set move-in date | Optional field on create; editable after (Edit a Submitted Guest Card) but no recon'd patch route | part of create | Desired Move In Date on the card | gap — carried on the payload, not sent by the form build |
| Dedup / merge | AppFolio auto-merges online submissions on email and flips inactive cards active. Manual entry only warns. | n/a — we match before we create | Card count for the phone/email | gap — 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.
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
| Piece | What it owns |
|---|---|
| Intent producer | Turns 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. |
| Outbox | The durable record, four states: pending → writing (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. |
| Drain | Fires 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 adapter | One 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 verifier | Re-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 notice | Goes 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).
| Failure | What happens | How it is handled |
|---|---|---|
| No PMS property behind the record | The interest cannot be built, so the card cannot be created at all | Park with no_pms_property; email the team the lead with the phone. Never invent a property. |
| Robot login or MFA lock | The shared identity has a hard budget: 3 logins/hour, 8/24h against AppFolio's own lockout counter | The 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 lands | The worst case — a card may exist and we do not know, and it can never be deleted | The 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 nothing | Never 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 limits | API throttling, or the robot's login budget | Backoff inside the drain, bounded; the outbox holds the work. Never a retry loop. |
| A card exists and we do not know it | We create a second, permanent card in the client's list | Link-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 card | A note lands on somebody else's prospect | Match 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 hours | Robot sessions compete with staff; notes appear while someone is looking at the card | Acceptable 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 happened | Each 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. |
| Layer | What it proves | State |
|---|---|---|
Willows guest-card harnessnpm 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 lane | The 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 calls | The 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 row | Every 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). |
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 concern | Why it is first / next | Dark? |
|---|---|---|---|
| 1 | Card lookup and read-back through the Plus API — status, interests, Latest Interest, source, assigned agent | Decision 1. Unblocks dedup, read-back, reactivation proof and ten harness scenarios, without spending the robot's login budget. | read-only |
| 2 | Lookup-before-create: match by phone then email before any create; ambiguity parks and emails the team | Decision 2. Stops the permanent, unmergeable duplicate. Needs PR 1. | setting off |
| 3 | Channel unification: text, email and inquiry conversations reach the same outbox — email and inquiries note-only, never create | Decision 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 |
| 4 | Drain'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 |
| 5 | Add inquiry/interest for the home actually discussed | The only thing that moves Latest Interest and makes the card appear under the right home. | setting off |
| 6a | A plain “how the call ended” line on every note, plus the labelled call-disposition fixture set | Decision 3, phase 1. Clara marks nothing Lost; the team decides, and the fixtures are what will later prove the signal. | setting off |
| 6b | mark_inactive quoting the caller's own words, on an explicit “not interested” or “already signed” only | Decision 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 |
| 7 | Assignment logic: assign the inquiry to Clara, built and shipped switched off | Decision 4. Route is known and the behaviour is decided; it turns on only when Fede declares a company fully live for inquiries. | setting off |
| 8 | Showing write: booked, moved, cancelled, no-show | Needs its own form recon first. | setting off |
| 9 | Retire the 15-minute drain cron as a writer; keep it as a stuck-row alert | Fede, 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.
Fede approved the section and every recommendation on 2026-09-18. Nothing here is open.
pms.guestCards.assignToUser, pms.guestCards.primaryLeasingOfficer.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:
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.
Not sent — Fede reviews first.
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.
| Decision | State | Answer |
|---|---|---|
| 1. When Clara sends | Decided | Automatically 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. Channel | Decided | Text first; email only when the card has no phone or the text fails. |
| 3. Which unit | Decided | Apply 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. Settings | Decided, replaced | Not tied to the write-from-calls switches. Instead, one per-property setting picks how the application link is produced (see decision 6). |
| 5. Delay setting | Decided | No new per-property delay setting. The existing post-tour delay setting keeps its default of one hour. |
| 6. Link source | Decided | One 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.
| Layer | What it proves |
|---|---|
| Willows harness | npm 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 lane | The voice side of the post-tour check-in that triggers option A — the agent's “toured” / “no-show” reply. |
| Replay fixture | A 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.
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.
| Option | What it does | Risk | State |
|---|---|---|---|
| 1. Stand down when staff is active | Before 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 conversation | Each 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 move | Ask 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.
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.
| Category | Author | Date | Text |
|---|---|---|---|
| Fair housing | Kat Barker | 11/21/2025 | Are they both ESA"s? |
| Fair housing | Kat Barker | 10/20/2025 | She and 2 others are looking to rent 11/1. She has 2 ESA's . |
| Fair housing | Kat Barker | 05/20/2026 | It went great, they are 4 college kids and looking for mid June. |
| Fair housing | Kat Barker | 10/11/2025 | Voucher, they loved it. Moving here from Montrose. |
| Approval/screening opinion | Michelle Pan | 12/17/2025 | Hi, we only do a background check. As long as you don’t have money judgements, collections or evictions, you should be fine. |
| Approval/screening opinion | Kat Barker | 03/30/2026 | Actually, I just got your background check back and it appears that you have an eviction from Dallas? Did you know that? -Kat |
| Approval/screening opinion | Kat Barker | 08/13/2026 | Good 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 opinion | Assistant PM | 04/08/2026 | I 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 |
| Unprofessional | Kat Barker | 01/14/2026 | No worries, just a crazy day. |
| Unprofessional | Kat Barker | 04/14/2026 | Just be safe! Weather is crazy! -Kat |
| Unprofessional | Kat Barker | 01/18/2026 | That's so weird? There was a tenant before you? Apologies for the delay. |
| Unprofessional | Kat Barker | 06/12/2026 | Of course, I love the shade those trees provide but darn those annoying bugs. -Kat |
| Author | Date | Text |
|---|---|---|
| Kat Barker | 09/01/2026 | Good morning, feel free to message me here to set up a time to see one of our upcoming properties. Thank you, -Kat |
| Kat Barker | 06/19/2026 | Had a showing today and they liked it. Looking for an Aug 1st move in. |
| Michelle Pan | 01/23/2026 | Perfect. Can we schedule it next week Monday at 10am? |
| Kat Barker | 07/27/2026 | Sure, I will email it to you right now. -Kat |
| Kat Barker | 07/01/2026 | Also 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.
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”):
| Measure | First run | After the fix |
|---|---|---|
| Internal-only recall (the number that matters — share of internal notes correctly withheld) | 89.1% | 98.4% (gate: 98%) |
| Fair-housing recall | 80.8% | 100% (26 of 26) |
| Internal-only precision | 83.8% | 80.8% |
| Safe notes wrongly withheld | 3.2% | 7.9% (5 of 63) |
| Exact class and category | 77.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.
Typed notes are rare at Western Slope; the team works prospects mostly by texting and emailing from the card.
| Real notes on the card | Cards |
|---|---|
| 0 | 1,021 |
| 1 | 89 |
| 2 | 48 |
| 3 to 5 | 13 |
| 6 | 1 |
234 notes in total across 1,171 cards; the leasing agent wrote 61 percent, the assistant PM account 33 percent.
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.
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
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.
| Opinion | Where it lives | How hard it is |
|---|---|---|
| A person is one human, found by a phone or an email claim | Person + claim rows; one lookup function per question | reads 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 it | Household + member rows, property-scoped | by cleanup — a lead's household pointer is optional and “absent is permanent” |
| One lead record per guest card per building | Deterministic id from building + card id | not 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 record | Member row | partial — the person link is required, the lead link is optional and never audited (16 dangling at Western Slope) |
| No person without a contact | Person row | not enforced — skeleton people are allowed by design (7 at Western Slope) |
| Applications group under the household | Member evidence carries the PMS group reference | only 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.
| What | Where | Verdict |
|---|---|---|
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 writers | leak — AppFolio's two-id scheme is the record's shape |
| “(Co-signer for X)” parsed out of the applicant's name | domain application-group helper, used by both writers | leak — 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 writers | both writers | leak — 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 writers | mixed — 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 for | AppFolio adapter | missing 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 building | nowhere | not 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 all | downstream — 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.
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.
| Option | What it deletes or folds | Size | |
|---|---|---|---|
| A | Fix 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 |
| B | A, 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 |
| C | B, 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?
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
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:
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.
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.
| Check | JP&Co | Situs | Western Slope |
|---|---|---|---|
| Every row is one deal | PASS | PASS | PASS |
| No duplicate person on a deal | PASS | PASS | PASS |
| Every human is one person | PASS | FAIL (1 of 1,890 split) | PASS |
| Co-applicants grouped together | PASS | FAIL (1 household split) | PASS |
| Mirror never over-merges vs. the reference | PASS | PASS | PASS |
| Mirror households equal the reference | FAIL | FAIL | PASS (vacuous, see finding 1) |
| Every vendor status maps through the table | PASS (7 distinct, 0 unmapped) | PASS (22, 0) | PASS (26, 0) |
| Person ids unchanged on a second identical poll | FAIL (2 of 308 lost) | FAIL (34 of 2,236 lost) | FAIL (40 of 1,081 lost) |
Findings, in plain words.
PRs: seeder #9957, runner #9962 (both dark, tooling only). The runner re-runs unchanged after each fix; this table is replaced, not appended.
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 --(pull: cards + status, every 15 min)--> OUR MIRROR OF THE RECORD
^ |
| v
+--(write-back: notes at milestones)-- Clara's conversations Person --> Household
\ /
PROSPECTS PAGE
| Today | After (Option D) | |
|---|---|---|
| Who owns the record | Both — we mint our own id and argue with AppFolio's | AppFolio (or Yardi, or any PMS) — we just mirror it |
| Who decides who is a person | PropFlow, from contact info only — a guest card gives us no anchor | PropFlow still, but anchored on the PMS's own id, not just contact info |
| Who groups households | PropFlow, by hand-written rules that pile up and never re-check themselves | PropFlow still, recomputed automatically, never stored as a stale guess |
| Where notes live | Split — mostly ours, only a dark trickle reaches AppFolio, only from calls | On the PMS record, written back at every milestone (call, text, email, tour) |
| How Yardi gets added | Rebuild most of the prospect model again, full cost | Add one adapter; person, household and notes already work |
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.
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.
| When | What |
|---|---|
| 2026-03-04 | Prospect is born — a standalone CRM entity, no PMS anywhere. |
| 2026-03-07 | Clara gets save_prospect — she creates leads from her own conversations. |
| 2026-05-04 | ADR-0018, the Person spine, first flags that AppFolio sync “compounds duplicates.” |
| 2026-05-04/05 | First PMS row writer (rental-application.ts); ProspectInquiry splits out as its own type. |
| 2026-06-23 | The old Prospect type is deleted; ProspectInquiry becomes canonical for both origins. |
| 2026-07-15/16 | ADR-0094 and the guest-card sync writer — the big graft of PMS rows into the same table. |
| 2026-08-04 – 08-30 | Household record ships; person merge starts physically moving rows; the merge-architecture decision page predicts D1 and is never closed. |
| 2026-09-17 | Fede reverses ADR-0094's no-write-back ruling; the write-back outbox ships, dark. |
| 2026-09-18 | Guest card management — approved: “we never build a rival prospect list.” |
| 2026-09-19 | The 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 is | Defect |
|---|---|---|---|
| A1 | PropFlow mints the id | AppFolio's GuestCardId already exists and is the only stable identifier; we derive our own uuid and demote theirs to a pointer | D1, D5 |
| A2 | A lead belongs to one property | A 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 own | D1, D5 |
| A3 | PropFlow owns the stage | AppFolio 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 card | D8 |
| A4 | Contact is always present | Cards carry “no email on file,” unparseable phones, or relay addresses; our drop guard is shallower than our real validators | 7 contactless persons |
| A5 | Contact identifies the human | A card's “email” can be a portal relay that identifies a thread, not a person, and we mint it as a permanent claim anyway | D2 |
| A6 | One human is one lead is one row | AppFolio 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 back | D1, 11 tombstones, 26 abandoned households |
| A7 | Notes and activity live in PropFlow | Fede: “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 back | the overbuild Fede named |
| A8 | A tour is a PropFlow record | AppFolio has first-class Showings with their own lifecycle and confirmation texts; we have no read or write access to them at all | tour/PMS drift |
| A9 | A person is a PropFlow concept | AppFolio 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 theirs | D2, 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:
targetUnitId — the interest grain,Person is a fourth thing on the side AppFolio has no concept of.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.
| Pattern | What it is | Evidence | Bugs explained |
|---|---|---|---|
| P0 — two origins, one mutable table | The parent pattern; every pattern below is a consequence of it. | §29.3 | all |
| P1 — we keep a copy of the PMS's record and reconcile it field-by-field | ProspectInquiry 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-1010 | D1, D5, D8's write amplification (versions 16,486 / 13,856) |
| P2 — the primary key is a function of the request, not the fact | Our 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-45 | D1, D5/A4, half of D3 |
| P3 — identity decided over a stale snapshot, PMS id not an anchor | The 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:164 | D2, contactless persons, 26 abandoned households |
| P4 — the household engine accumulates instead of deriving | The 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-437 | D3, 26 households, 16 dangling members |
| P5 — self-healing writes and fail-soft catches hide the drift they create | Heal 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-847 | D1, D4's silent no-op on voice |
| P6 — an identifier treated as globally unique | Outbound 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-107 | D9 |
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 pattern | What we do instead | Bug it would have prevented |
|---|---|---|
Dual id: id (theirs) + x_id (the PMS's), on every record | We derive our id from the PMS id plus the requested property | D1, D5 |
last_seen, no hard deletes, consumers decide | Tombstones plus a non-transactional physical move | D1's two live copies, the 11 tombstones |
| Cache-first: pull into a cache, never upsert-merge | Upsert-reconcile with a hand-written field diff and a heal-version machine | The whole P1 class, write amplification |
status_raw + status_normalized, side by side | One PropFlow-vocabulary stage, filled by one-way substring sniffing | D8's stuck rows, missing status field |
| Per-vendor enum tables, inspectable | Substring matching inline in the domain writer | not yet a defect — the Yardi/RealPage landmine |
custom_data escape hatch per record | The af:{} block — the one Propexo pattern we already have | — already fine |
What is actually fine and should not be touched or re-argued:
household/resolve.ts is a pure function; it's the shape the rest of the system should have.status:'all' finding (6 rows vs. 21) and the malformed-phone sink are both excellent, legible work.getPMSClient, compile-time exhaustiveness, a vendor-faithful mock service) is real and correct — the sync just doesn't honour its own abstraction's spirit.af.inquiryId) was assumed to cover the adjacent one (af.guestCardId) and didn't.| Above the adapter — PropFlow's opinions, PMS-agnostic | Inside the adapter — the vendor's quirks |
|---|---|
Person identity and claims — one human, one personId, across prospect/tenant/renewal/maintenance | The vendor's object graph (AppFolio card→inquiry→interest; Yardi SIPP prospect; Knock has no household) |
| Household grouping — the pure function and how it renders | Which write mechanism exists (AppFolio: robot only, Data API read-only; Yardi: SIPP; Knock: REST) |
| Clara's voice, disclosure, cadence, escalation | Enum mapping: status_raw ↔ status_normalized, one table per vendor |
| Tour policy: hours, cutoffs, confirm-or-cancel, host pool | Filter and pagination quirks (property filter not honoured, date bounds advisory) |
| The write-intent catalogue, the outbox, idempotency, read-back-or-it-didn't-happen | Which catalogue intents that vendor can actually perform, and which have no route |
| Which milestones are worth writing back at all | Deletion semantics (AppFolio: never delete, never hand-merge) |
| Date handling: every written-back date goes through the property's local calendar day, CI-ratcheted | Wire 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.
Plain-words version of these four options is in 29.0 above.
| A — harden | B — anchor on the PMS id | D — the PMS record is the record | |
|---|---|---|---|
| Bug classes eliminated | the 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) | 0 | 3 | 11 |
| Effort | 8–10 PRs, most open | +6–7 PRs | ~25–30 PRs over 8 dark steps |
| Risk | near zero | low | moderate, front-loaded into additive steps; the irreversible step is last, per customer |
| Yardi cost | full price | half | mostly paid — a new vendor is an adapter + an enum table |
| Matches Fede's framing | no — still a rival prospect list | partly | yes — §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:
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.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.”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.personId by the vendor's own id rather than by contact-string matching.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.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.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 projection | Lines | Why it goes |
|---|---|---|
Field-diff + content hash + heal version + heal set + runHeal | ~270 | nothing to reconcile when the snapshot is replaced |
| The six-rung match cascade | ~200 | match 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,250 | grouping becomes a function |
deterministicId('inquiry', …) seeds | 2 call sites | x_id is the key |
| The inquiry→card resolver in the outbox drain | small, load-bearing | intents key on x_id |
| The one-way stage ratchet | ~70 | status_raw + status_normalized replace it |
The 11 hacks removed (constraint (d) — “delete code, simplify, remove the workarounds we built to fit the model”):
cancel_tour written as a note — “a workaround, not the model.”x_id instead.What Clara's UX loses, honestly:
Migration — dark, small PRs, per customer:
| Step | What | Customer risk |
|---|---|---|
| 1 | PmsRecord mirror written alongside today's rows, read by nothing | none — pure addition |
| 2 | status_raw/status_normalized; enum table moves out of the writer into the adapter | none |
| 3 | Identity crosswalk built from today's pointers, for all three orgs; read by nothing | none — guarantees no personId ever changes |
| 4 | Grouping function reads the mirror; diffed nightly against today's stored households | none — nothing rendered |
| 5 | Prospects page reads the mirror behind a per-company setting, default off; Willows then Western Slope | per-company, reversible |
| 6 | Write-back producer generalised beyond voice: text, email, listing inquiry, post-tour note; dark, per-property, Willows first | per-property, Fede's go |
| 7 | Outbox intents re-key; inquiry→card resolver deleted | low — idempotency keys change once, ledgered |
| 8 | Stop writing the legacy reconciled rows; delete the code above; crosswalk kept forever | the 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).
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.
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.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.