Updated Sep 8, 2026. This is the one page for the initiative: roadmap and progress, the shared-line decision, the audit, and the plan. There is no separate tracker.
Who does what, in what order, what is decided, what still needs a call. Fede: leasing. Gera: maintenance. Rest by who built the module.
Refreshed 4:29 AM MT 2026-09-10 (every pull-request state re-read from GitHub) · Compact top rebuilt 2026-09-08 evening · Status: in active use — this is the page the team works from, not a proposal awaiting review (it was written 2026-09-05 as one, and carried that label until 2026-09-09) · owners Fede + Gera · supersedes nothing; links to the Situs hub and the client intake drafts below
Live tracker (derived from the pull requests, one group per epic): Western Slope go-live tracker. Status on every row there is re-derived from GitHub, not typed by hand — this page no longer carries its own copy of "% complete."
| Area | Done | Left |
|---|---|---|
| Company and property model | Western Slope company exists; Fede's login works | Import their 14 properties; merge the two Western Slope companies |
| Keeping companies' data apart | Stopgap live, proven with an outside login | Step one: every read names the company, enforced in CI; step two: database keys |
| Sign-in | Preview second-factor skip, typed code and passkey-or-text all merged | Nothing outstanding |
| Customer-facing UI | Wizard, modules-only view, property page, and the Customers back office all merged (#7313, 8:53 PM MT) | Nothing outstanding — the two Settings fixes were settled during the day: company-name prefill merged, billing card hidden rather than trimmed |
| Shared phone line across 14 properties | Test line live on one placeholder property; directory transfers built dark | Decide where a shared line binds a property (X1); fallback number; missed-call email switch. Merge plan for the prototype agent onto the shared fleet: voice decision page §11 (proposed 2026-09-08) |
| Listings for a portfolio | Single-property scraper live | 14-property importer not started; API ingest when the key arrives |
| Shared mailbox across 14 properties | Per-property mailbox connect exists; calendar connected | Route each lead email to the right property by address, not started |
| Knowledge base | Pulled; review page built; save-wipe bug fixed | Retired-answers fix held; per-property vs portfolio knowledge shape |
| Guest cards and AppFolio | Reports-feed ingest built; write-back built, on at Willows only | First-contact guest-card creation not built; write-back not in this launch |
| Tours | Booking works; two-home fixes shipped; cancel-with-two-booked merged 2:40 PM MT (#7369); the stated-name rule is pinned by tests (#7382, merged 3:14 PM MT — tests only, no behavior change); a home is confirmed only when its own booking succeeded (#7381, merged 4:08 PM MT, prompt half live on the line) | Two homes on one street still cannot both be booked — the script and the calendar contradict each other, and one has to change; a live two-home call to prove the receipt text end to end — the receipt fix itself merged at 6:56 PM MT and is in production (#7406), but tonight's robot budget is spent, so the proof is owed tomorrow. It was the same shape as today's other two: what Clara says and what reaches the person disagree. Both halves of the confirmed-over-a-clash fix are live as of 5:57 PM MT (#7381, open); Jay's second tour missing |
| Testing | Portfolio bench built, 22 bugs found | One Twilio test number to unblock six scenarios |
Situs: not started. Company record, API access, test property.
Gera: maintenance lane status unknown; 31 other open pull requests (re-counted 1:20 AM MT Sep 9).
Everything below is detail. Open a heading only when you want the why.
What is on the critical path and unblocked right now — for Western Slope's go-live in the week of Sep 21, and for getting Situs started at all. Row-by-row status is on the go-live tracker; the week view further down is the plan.
1. Get Western Slope's leasing mailbox connected (Jason/Kat one-time sign-in) owner: Fede
Done looks like: Jason or Kat has clicked through the one-time Microsoft sign-in on leasing@westernslopepm.com, and mail starts routing through it automatically — no code change needed on our side.
Next action: per the 2026-09-08 audit, this is the single blocking item left for a guest-cards-only go-live — everything else Fede thought was outstanding (the tour calendar) is already connected. Get Jason or Kat to click the sign-in link.
2. Turn on the phone number Western Slope will actually use owner: Fede
Done looks like: their main number forwards to Clara, staff keep their own lines, and the team directory is in her hands.
Next action: send the reply already drafted and waiting in Gmail to Jason.
3. Create the Situs company record owner: Fede
Done looks like: Situs has a company of its own instead of sitting under JP&Co, with the modules they bought and their admin invited — the first thing on the Situs side to move all week.
Next action: add it through the back-office Customers screen, which merged Sep 8 at 8:53 PM MT (#7313) and was the only thing this was waiting on. (This card previously asked you to approve the company-data-isolation plan. That was answered on Sep 8 — approved, Gera owns it, not yet scheduled — so it is no longer an open ask.)
4. Decide the voice merge plan and start the bench owner: Fede added 2026-09-09
Done looks like: the six choices on voice decision page §11.5 are picked (what "one agent" means, the bench, cutover and rollback, how the mode is chosen, the cutover gate, the sequence), and the go is given to import the portfolio harness number into ElevenLabs so the bench line exists.
Next action: read the drawn version (five minutes), pick the six, say go on the harness-number import. Nothing touches Camellia's line or the 970 line until the cutover step, which is a separate go.
Also outstanding whenever you get a minute, not urgent enough to bump the three above: paste the drafted Stripe setup email to Sean; forward Jason's team directory (names, direct numbers, roles) the moment it lands — the in-hours "talk to a person" transfer is built and waiting on the leasing desk's direct line.
One line per epic. This is the bar each area is measured against; week-by-week progress is in the timeline above, and row-level status on the Western Slope go-live tracker.
| Epic | Owner | Definition of done |
|---|---|---|
| Company and property model | Fede | All 14 properties under one company; Jay/Jason sign in and see only Western Slope |
| Data isolation | Fede | No company ever sees another company's rows, on any path, enforced in CI |
| Sign-in | Fede | Typed code is the default; passkey or text is the second factor; clean on real inboxes |
| Customer UI | Fede | A customer admin finishes the wizard and sees only their own modules and properties |
| Shared phone line | Fede | One number answers all 14 properties; transfers by name; no dead air on outage |
| Listings for a portfolio | Fede | Units and availability refresh automatically for all 14 properties, no manual entry |
| Shared mailbox routing | Fede | Every lead email lands on the right property's guest card automatically |
| Knowledge base | Fede | Reviewed by Western Slope; every leasing question answered from it; retired answers never spoken |
| Guest cards and AppFolio | Fede | Out of this launch — write-back rollout and first-contact creation are withdrawn |
| AppFolio writes per company | Fede | Every write Clara can make into AppFolio — guest cards, renewals, work orders, new-lease send, move-outs, month-to-month, units — carries the company and is proven to land only in that company's database; the browser robot has no JP&Co default left; each client has its own Clara login, saved session, and text-code number; a nightly login-and-isolation check is green for JP&Co and Situs; Camellia unchanged throughout. This gate is met BEFORE the first write for Situs Group or Western Slope. First feature on top of it: a phone call from a caller not already in AppFolio creates the guest card; email and web leads are untouched. |
| Tours | Fede | Any number of homes booked, rescheduled, or cancelled correctly on a real call |
| Testing bench | Fede | All portfolio scenarios run green on the bench before any property change |
| Maintenance intake | Gera | Every inbound maintenance scenario handled end to end, two clean bench sweeps |
What "done" means: Jay is signed in and sees only Western Slope; their calendar and mailbox are connected; they've reviewed the knowledge base; and Clara is answering the leasing line live on their forwarded number with the missed-call email switched on. The table below is the week view: what each area should land this week, next week, and by go-live week. It is a plan, not a status read — for status, use the go-live tracker, which derives each row from GitHub.
| Area | This week (Sep 8–12) | Next week (Sep 15–19) | Go-live week (Sep 22) |
|---|---|---|---|
| Company and property model | Merge the two Western Slope companies; Jason's properties imported Tuesday | 14 properties under one company | Done |
| Data isolation | Approved Sep 8, not scheduled (Gera owns it) | Stopgap holds | Stopgap holds |
| Sign-in | Done — typed code and passkey-or-text both merged Sep 8, 2:02 and 2:15 PM MT | Done | Done |
| Customer UI | Done — back office merged 8:53 PM MT, billing hidden | Done | Done |
| Shared phone line | Fallback number; missed-call email on; directory transfers live once directory arrives | Shared-line binding decision (X1) | Live on forwarded number |
| Listings | Scraper live | 14-property importer | Live |
| Shared mailbox | Mailbox connected | Route leads to the right property by address | Live |
| Knowledge base | Their review | Gaps filled | Live |
| Guest cards and AppFolio | Reports ingest once key arrives | Not in launch | Not in launch |
| Tours | Cancel-both, #7381 and its eval #7395 merged and deployed; same-street booking proven on a live call (two visits written back to back); #7406 fixes the receipt naming one home; stated-name switch; Jay's tour fixed by hand | Proven on real calls | Live |
| Testing | Bench runs without a dedicated number | Portfolio scenarios green | Soak |
Situs: not started. Company record, API access, test property.
Gera: maintenance lane status unknown; 31 other open pull requests (re-counted 1:20 AM MT Sep 9).
Only things that genuinely need your call. Scheduling and go-aheads are taken by default and reported under Parked and FYI below. Row-level progress lives on the Western Slope go-live tracker.
| Decision | Recommended answer | Why | |
|---|---|---|---|
| Turn the stated-name switch on for the Western Slope prototype line? | Yes — it is an activation, not a build | The capability is built and now pinned by tests (#7382, merged 3:14 PM MT, tests only — it changed no behavior). It is gated by a per-property setting that is off here, so today a repeat caller's new name is not written. One dry-runnable script line, recorded in the handoff file. Caveat: even switched on, the greeting in the very first sentence still reads the record before any tool runs, so that one line keeps the old name. | session 011 |
| Spend money on the CASA Tier 2 security lab, so PropFlow can read a shared Gmail inbox (needed for a Situs-style leasing@ address)? | Proposed, pending Fede's pick: see Google Workspace readiness for Situs Group | Reading Gmail needs Google's restricted-scope verification plus a paid CASA lab (roughly $540-$1,000, 4-12 weeks, redone yearly). Everything up to the purchase can start now; the lab itself and the annual reverification are Fede-only. | 2026-09-15 |
| Otherwise nothing open, as of 4:30 PM MT. Answered Sep 8: the retired-answers-on-calls fix — no, closed unmerged at 2:24 PM MT ("not carrying retired-answer logic on calls right now"; branch kept, reopen if wanted). The billing card — no, #7330 closed at 1:35 PM MT once Fede saw the screenshots, and the card is instead hidden from customers altogether (#7376, merged 2:14 PM MT). Parked as "not now": the Vercel preview test variable and a Twilio number for the test bench. | |||
Status: Sealed 2026-09-10 — all permutations passing, switch removed
This section is about grouping people into households once a lead has already arrived. For how the lead gets to Clara in the first place, and who Clara replies as, see Integration map: how a lead reaches Clara, and who Clara replies as. For the redesign that puts this grouping logic inside one shared intake pipeline instead of two separate lead writers, see Lead-intake engine: where we are, where we're going.
A prospect Clara already knew applied again through Zillow, which opened a brand-new inquiry in AppFolio. The sync only checks one "primary" email or phone per person, so instead of recognizing the returning prospect it opened a second, disconnected record for them. Their cosigner's application arrived separately, with no AppFolio group id linking it to the first — so nothing tied the two together. A feature added the day before only wrote a hidden "suggest a link" note that no one ever saw. On top of that, AppFolio's own "(Co-signer for [name])" text got saved directly into the cosigner's stored name. Net result: three separate rows for one household — a Camellia cosigner pair plus the returning prospect — and Clara's whole history with this lead was invisible on the row anyone actually looked at.
| Change # | What it does | Live or dark? | Switch |
|---|---|---|---|
| #7417 | Co-applicants on the same unit link into one household automatically during sync; the old hidden "suggest a link" note is deleted for good. | Dark | autoLinkCoApplicants, off everywhere |
| #7420 | A new application from someone Clara already knows now updates their existing record instead of creating a second one; a first-and-last-name check guards against fusing two different people together; also fixed a bug that could let a household claim two "primary" people at once. | Dark | same switch |
| #7424 | AppFolio's cosigner annotation is stripped down to a role tag and never saved into anyone's name. | Live | none |
| #7418 | Read-only, scrubbed data-mining tool: pulls real records from JP&Co (live API), Situs Group, and Western Slope (existing read-only dumps). | Read-only, no writes | n/a |
| #7419, #7423, #7425, #7426 | Test harness that streams those real records through the actual grouping engine and grades the result against 8 known failure types; four bugs found and fixed in the harness itself. | Test-only | n/a |
| #7427 | A clean, reversible way to test at the Willows without leaving anything behind. | In review | n/a |
After the four harness bugs above were fixed, replaying real records from all three companies through the grouping engine found:
Size of the corpus mined, for reference — guest cards: 81 / 883 / 835; applications: 48 / 90 / 111; tenant records: 188 / 1,341 / 260; people: 216 / 1,890 / 941 (JP&Co / Situs Group / Western Slope).
The eight permutations were run as real AppFolio applications at the Willows test property and checked in the production database. AppFolio's public application form blocks automated submission at the final step, so applications were created from the staff side; two cases that only the public form can produce (the cosigner name leak and a co-applicant invite) are queued for a human to submit.
| Permutation | Result | Note |
|---|---|---|
| Single applicant | Fail | Its record was taken over by the shared-phone case below and pulled into a stranger's household. |
| Cosigner grouped by AppFolio | Pass | One card, one household, names clean. |
| Cosigner not grouped by AppFolio | Pass for the pair | The pair grouped correctly, but the household also swallowed unrelated applicants on the same unit. |
| Known lead applies later with a slightly different email and new phone | Pass | Same person, original record advanced to applied, Clara's conversation kept, no second record. |
| Family member sharing a phone | Fail | Matched by phone before names were compared; two people fused into one. |
| Two strangers with the same name | Pass | Separate people, separate households. |
| Withdraw then re-apply | Partial | New application syncs cleanly; the withdraw step needs a human click in AppFolio. |
| Roommates grouped by AppFolio | Pass | One card, one household. |
Engine bugs found (none has reached a customer through the new grouping, which is only on at the Willows; the phone match is older code and is live):
Next: fixes deploy dark, the same permutations re-run at the Willows, then the per-property switch is deleted so the behavior is global.
Engine at commit 114b08f:
| Permutation | Result | Note |
|---|---|---|
| Single applicant | Pass | |
| Cosigner grouped by AppFolio | Pass | |
| Cosigner not grouped | Pass for isolation | Strangers no longer swallowed, but the pair itself not linked (the "(Co-signer)" text is not used as a signal). |
| Known lead applies later | Pass | |
| Family member sharing a phone | Pass | Two people, correct; not placed in one household. |
| Same-name strangers | Pass | |
| Withdraw then re-apply | Pass for isolation | Cancel click needs a human. |
| Roommates grouped by AppFolio | Pass |
Side findings: Nine applications, nine households, no fusion anywhere. Email leads stored a blank unit before the application; one old Willows row kept colliding in the guest-card sync.
Engine at commit a9353b39, after PR #7464:
| Permutation | Result | Note |
|---|---|---|
| Cosigner grouped by AppFolio | Pass | |
| Roommates grouped | Pass | |
| Same-name strangers | Pass | |
| Withdraw/re-apply isolation | Pass | |
| Known lead applies later | Pass on identity | Unit still blank at intake. |
| Family member sharing a phone | FAIL, regression | Same surname with a different first name fused back into one person (round 2 had this passing). |
| Cosigner not grouped | FAIL | The marker still never links the pair. |
Status: Guest-card sync job now runs clean; no household absorbs a stranger. Fix in progress for all three, then round 4; the per-property switch stays until a round passes clean.
| Permutation | Result | Note |
|---|---|---|
| Single applicant | Pass | |
| Cosigner grouped by AppFolio | Pass | |
| Cosigner not grouped | Pass | Linked as cosigner by the "(Co-signer)" marker, nothing else absorbed. |
| Known lead applies later | Pass on identity | The unit is still blank at intake for a free-text "Willows Test - 101" body (fix in progress). |
| Family member sharing a phone | Pass | Separate person, same household. |
| Two strangers, same name | Pass | |
| Withdraw then re-apply | Pass for isolation | Cancel click needs a human. |
| Roommates grouped by AppFolio | Pass |
Every identity and household case passes on the fixed engine; the guest-card sync runs clean. The per-property switch is being deleted (PR #7483), so the behavior is global.
Leftovers being fixed: the email-lead unit at intake for free-text unit mentions; an empty ghost household left behind after a merge. Then a final round and the Willows cleanup.
All eight permutations pass. The lead email's unit — a free-text "Willows Test - 101" — now resolves at intake, before any application arrives. Left: empty household shells after a person is moved into another household, and one live duplicate membership.
No person holds two live memberships any more. The emptied shells from round 5 were retired, but not closed — they still showed as open.
Found a new, bigger hole: two sync runs for the same property overlapped — a manual trigger landed on top of the scheduled run — and every applicant got a duplicate live prospect row. The shell-close timestamp was also wrong. This is the duplicate-prospect bug reproduced through concurrency, not through a single bad run.
Per-property lease in the sync job: if a second run starts while one is already in progress, it skips and logs it instead of racing. Prospect rows are keyed on the AppFolio application id, so a concurrent duplicate lands on the same record instead of minting a new one. Empty shells close with a real timestamp, plus a sweep that catches any left behind. Result: all eight permutations pass, a deliberate overlap probe (triggering the sync twice on purpose) produced no duplicates, and the corpus replay below shows zero new ghost households against the baseline.
| PR | What it fixed |
|---|---|
| #7495 | Resolves the email lead's unit at intake, and the first ghost-fold rule. |
| #7546 | Retires the prior membership when someone is re-householded, and closes the emptied shells — gated by role so it never merges into someone else's deal. |
| #7576 | Fixes the close step to match the real row shape (it was matching the wrong one). |
| #7582 | The concurrency seal: a per-property lease on the sync job, and prospect rows keyed by a natural id so a concurrent duplicate lands on the same row. |
| #7583 | Close timestamp is now the real close time, plus a per-property sweep and report categories. |
| #7483 | Deletes the per-property switch — the fixed behavior is global, not opt-in. |
| #7470 | Regression harness fixes. |
| #7493, #7565, #7584 | Follow-ups that kept main green while this landed. |
Every real record from all three companies replayed through the sealed engine. Every failure class checked — duplicate person, duplicate row, split household, overmerge, household overmerge, a name leaking from one person's record into another's, a stale primary, a lost Clara conversation stamp, a dropped record — came back zero for every client.
| Client | Records | Households | Failures |
|---|---|---|---|
| JP&Co (Camellia) | 317 | 77 | 0 |
| Situs Group | 2,314 | 810 | 0 |
| Western Slope | 1,206 | 721 | 0 |
Command: npm run household:regress -- --client <name>, one client at a time — concurrent runs on the same machine exhaust memory and take each other down.
Proven: the eight permutations at the Willows on real AppFolio applications and guest cards; the concurrency overlap probe; the full corpus replay above.
Not exercised by a machine — three AppFolio actions only a human can click, so they still need a person to run them: an online cosigner submitting through AppFolio's own invite (this is the pattern that showed the "(Co-signer for X)" name-leak shape); a public-form co-applicant invite; and cancelling an application from AppFolio's own UI, for the withdraw/re-apply case.
Deferred to its own pull request: deriving the household id from AppFolio's application-group reference instead of today's signals. That changes what counts as a merge, so it needs its own baseline argument rather than riding this one.
Cleanup: the PropFlow side is cleaned — test people and their rows are parked, reversibly. Listings 101 and 102 stay up. The eleven test applications and their guest cards at the Willows (JP&Co database, property 45) are left in place, by Fede's decision on 2026-09-10 — not a pending cleanup step. Now that the test property sits in its own portfolio (see the portfolio move, above), client staff can't see them, and they carry no ongoing cost. Delete them only if the Willows is ever re-parented to a different portfolio or property; until then, leaving them is the decision, not a gap.
Merged the same day from this proof: Orphan dedup-sentinel self-heal and per-record isolation (#7439), unit-id consistency and sync stamping (#7441), email-lead unit id and guest-card job isolation (#7455), sync write-conflict retry (#7462), guest-card contact re-stamp (#7463), co-pendency signals (#7464), review-reminder duplicates (#7466), claim-without-sentinel self-heal (#7468), regression command and skill (#7446), prospect-candidate name guard (#7475), name agreement in the shared person mint (#7476), post-merge row consolidation (#7478), round-3 fixes (#7480), bot findings on the name guard (#7479), a Person now holding more than one live value per claim (#7485), (#7486).
Trigger: Fede saw two "Application Review" rows at Camellia for the same unit (unit 120 — AppFolio applications 51 and 52, received 2026-09-04) still sitting separate on 2026-09-11, plus the Miller cosigner pair, and asked how that slipped through after the round-8 seal above. Three different things, found separately, below. Applicants below are identified by their AppFolio application id or role, not by name, except where the customer name (Camellia, Western Slope) is the useful label.
AppFolio did not report a RentalApplicationGroupId (its own field for "these applications belong together") for either application on 2026-09-04 — so each one minted its own row and its own household, correctly, given what AppFolio was saying at the time. AppFolio only started reporting group id 8 on both applications around 2026-09-11, when it approved them. The sync picked the new group id up and stamped it on both rows correctly, but three separate rules then stopped the fold from happening: (a) the card-fold step refuses to fold a row that already owns a live card, (b) rows that already carry a group id are excluded from the co-pendency matching that would otherwise have caught them, and (c) the merge planner treated a bare, newly-arrived group id as uncorroborated and held rather than merging. Net effect: the pair was stuck split with no sync path left to reconcile it.
Why the corpus replay above showed zero: the harness's same-unit rule was retired on 2026-09-10 (#7470), and its ground truth for "should these be one household" is AppFolio's own grouping — so a pair AppFolio had not yet grouped could never register as a failure. Every other Camellia group (1 through 5) had folded correctly by the time of the replay; group 8 was the only one that arrived late.
Fix: PR #7868 (merged 1becfb437) — two different people's own rows independently carrying the same group id now counts as corroboration on its own, so the households merge on the next sync tick; both rows are kept. Residual risk, noted but not a blocker: a reused group id that joins two unrelated applicants at the same property can't be caught by the harness today, because its own ground truth assumes the merge is correct — a live check against AppFolio's own reports is the follow-up.
The identity layer had already resolved both rows to one person correctly. The second application, filed 2026-09-08, still minted a second household, because the repair function that's supposed to fix a dead household pointer (ensureHouseholdOfOne) only checked whether the person already had some household — not whether that household was still live. The fix for that exact gap (#7420) landed 2026-09-09, one day after this pair was already broken.
Fixes: PR #7862 (merged 00de2e3ae) — a new inquiry row now joins the person's existing live household at the property where they already hold the primary seat, instead of minting a new one. PR #7864 (merged 5c0905a24) — a one-time repair script, scripts/household-repair/same-person.ts, dry-run by default, one property per run; running it for real needs --execute --property --confirm. Camellia's dry run found nothing left to repair — the Miller pair had already come together on its own once #7862 deployed. The Willows' dry run shows 12 test people and no action needed. Open from review, not started: a harness scenario for this exact "second application arrives a day after the fix" shape, and the edge case where a person co-signs someone else's deal and later applies on their own.
One person, one household, three inquiry rows, all three labeled primary. This is a design choice from 2026-08-05 — duplicate rows were kept on purpose ("link, don't delete") because they mirror the separate guest cards AppFolio itself creates for each call. Fede, 2026-09-11: that's not a reason to show one person three times — one record per person per property is the target, and phone and web intake need the same "a known person advances their own row" behavior the sync already has, not a fresh row per contact. This is a stated initiative, not started.
| Item | State |
|---|---|
| 3 Camellia people whose stored name still carries AppFolio's literal "(Co-signer for X)" text | Cleanup identified; not run |
| 5 people with two live primary phone claims | Script built 2026-09-09; execution waits on Fede's go |
| Western Slope's 33 split households | Not checked by hand |
| Reused group id joining unrelated applicants | No live check yet; harness can't catch it (see above) |
| Repeat-inquiry and co-sign-then-apply harness scenarios | Not started |
| One record per person per property (repeat-caller fold) | Own initiative; not started |
A duplicate check now runs on a loop against the live prospects list at every customer property, checking for repeat rows, split households, an unfolded group id, and identity splits by email/phone/name — and reports only new findings, so it doesn't repeat itself every run. Western Slope's first 69 imported rows: zero findings as of 2026-09-11.
Written 2026-09-13. One night on the Western Slope prototype phone line: 50 robot calls placed against the live line, every verdict read back off the real rows (the call transcript, the tour row, the calendar event, the phone carrier's own sent log) rather than off the test driver's opinion, and ten fixes built and merged against the reproduced cases. Callers are never named here. Sources: the night's test ledger and the defect list in ~/westernslope-data.
The short version. Ten things a caller could hit on this line are fixed and merged. Nine of them are switched off until Fede turns them on — one line each. Two safety guards went live the same night. Nothing a caller hears changed: the line was re-provisioned three times and read back byte-identical every time.
| Fix | Before, and the call that proves it | After | Proof | Status | Evidence |
|---|---|---|---|---|---|
| 1. Message desk | After hours, a caller who asked for a person was told their message would be waiting — and nothing was filed anywhere. conv_5901m24m26pqe5zsr4m8megm1psn: "they'll have your message waiting when they're back." The line carried no message-taking tool at all, so there was nothing for the message to land in. Four calls, same outcome, including a genuine first-time caller (conv_7701m24n92nze9arz910jzcrv6a4, conv_4001m24nmb28e6nr4hn7hjjkvs8b, conv_8801m24rjpshfw8ab1a7416t0b0s). One of them closed with "Make sure someone actually calls me." |
The caller's message is captured and emailed to the leasing inbox. | Landed with the switch off, and the off-state pinned by hash to the bytes already on the line. Merge re-provisioned the line; live read-back was byte-identical — same four tools, same three built-ins, same transfer numbers, prompt 59,198 characters, delta 0. Canary call conv_0401m24ny379fs1b9a6cd13kvcbq passed and booked through a real tool call. 119 tests on the provisioning shape, 162 on the wiring fence — which was itself rebuilt after review, because it matched text that survives inside the off-branches and so passed in both states. |
merged · dark Turn-on: flip the switch, then point the address at the leasing inbox — today it resolves to a PropFlow address, not the customer's. Both are Fede's word. | #7497 |
| 2. "Three or four bedrooms" offers both | A caller asking for three or four bedrooms under $2,600 got two three-bedrooms, then a two-bedroom — she flagged it herself mid-sentence ("aunque esa tiene dos recámaras"). Both four-bedrooms only surfaced after he asked twice more. conv_5901m24m26q3f4arqfmjm7a36h3f. |
A range is read as a range: homes from both bedroom counts are offered in the first answer. | Re-run on a clean number reversed the failure — conv_4401m24pzn9bezpr17s6wqs70x79 named a three-bed and a four-bed in the very first answer and surfaced both $2,350 four-bedrooms. That re-run also established the original failure's real cause: fix 4 below, not the qualifying logic. 13 tests pin the gate shut for this line and for both apartment properties. The bench scenario is written but not yet run — it waits on the bench release phrase. |
merged · dark No change to this line's script, so no re-provision. | #7507 |
| 3. Question order, name, counts, and honest notes | Three separate faults in the same script. Name never asked on two fully qualified calls that reached named homes and rents — 99 seconds and 157 seconds (conv_4501m24k1t0vfajvfd4h846c29rz, conv_4801m24k1t07ecyrzrw8d4xgwebp). Inventory counts leaked five times, always in the same shape: "Those are the two that fit on bedrooms and budget in Grand Junction", "There are two three-bedrooms on Ember Lane" (conv_9901m24kvcfafe2tgmaydy9sxr10, conv_8601m24mazq8f8erp9bpd2289ag4, conv_3301m24ne5t5fzzttfskgt0st0ze) — telling a caller exactly how many homes the portfolio holds. Claimed a record that was never written: "Thanks — I've got that noted" about a volunteered email address, on a call where no tool fired at all and no guest card exists (conv_3301m24mazwdehyagxkybnjs6gse). |
One shared question order for portfolio callers, plus three standing rules: always ask the name, never count homes in any phrasing, never say something was recorded unless it was. | Three review rounds. Round one caught that the byte-identity guard only checked about 54% of the script; it was rebuilt to cover the whole 758-line fixture with section tripwires. With the switch off the render is byte-for-byte what was already on the line. Merge re-provisioned; read-back identical at 59,198 characters with no new template slots. Canary conv_9801m24qqzhyetttmq6r1rrghmjm passed. The order itself is researched, not invented: five full vendor call transcripts and three published mystery-shop rubrics, with each finding written next to the rule it justifies — one question per turn was unanimous across all five. |
merged · dark Gera armed the slot on 9/10 — the block is now physically in the live script but renders empty, so what a caller hears is unchanged, and the pre-arming version is pinned as the proven revert. His second pull request removes the last condition and makes the researched order audible on the line. | #7500 · #7627 · #7645 |
| 4. One Spanish call no longer flips a number to Spanish forever | Once a number had spoken Spanish once, every later call from it was answered in Spanish regardless of what the caller spoke. An English speaker was opened with "Hola, soy Clara de Western Slope PM — PROTOTYPE — ¿en qué puedo ayudarle?" and, on one call, answered in Spanish from the first word to the last for 138 seconds (conv_6601m24ke8t2eb4vq42xsdqpdq1g, conv_5901m24m26q3f4arqfmjm7a36h3f, conv_8601m24n0sh3exc8qpby42x9srzv). The bilingual "para español" opening never played, so the caller had no way to signal. It also sent Spanish-speaking maintenance callers to the English call centre — the Spanish leg only fires when detection switches mid-call, which never happens on a call already served in Spanish. The tool's own reason text read "Maintenance call in Spanish, transferring to English maintenance line" (conv_0601m24n2dp5fqnbq314944zc28y, conv_2201m24q5dvsfv69w6erk63zah3w). |
A stored language preference only decides which language leads the greeting. It never pins the call, and the other language is always offered. | Mechanism read off each call's own variables, not inferred: the stored preference is applied whenever live detection is low-confidence, and once set it was never revisited. Reproduced on two different numbers across five calls. 16 tests, including one that pins the old behaviour so the before-and-after is readable. The write side, which was already correct, was left untouched. | merged · dark | #7510 |
| 5. A caller on a shared number can cancel their own tour | A prospect booked a tour by phone, got the text saying "Reply here if you need to reschedule" — and then could not. The cancel tool answered {"error":"No prospect found for this phone number"} for the same number the confirmation text had just been delivered to (tour 53bc3ce0, conv_2101m24k1szrf0mryvfw7tkq5cqg). Cause verified against production rows: someone else had used that number first, so the new person was minted correctly but with no phone of their own attached. Everyday shapes — a family phone, a roommate, a number that used to belong to a former tenant — while the tour and its calendar hold stayed standing. |
The tour is found from two facts that are already proven: the calling number matches the number the booking conversation ran on, and the caller names the tour. Anything less fails closed. No identities are merged. | Cause traced to real rows, not code reading alone; the guard that refuses to fuse two people on one number is correct and was kept. 9 tests, 6 of them red before the change, including three that pin what must not happen — a stranger naming the right tour cancels nothing, and a housemate cannot reach the other's booking. The clean counter-case is already proven live: cancel by voice works perfectly for a caller whose number is their own (conv_5401m24rc0pzfefayzfg2vev0776). |
merged · dark One part — letting the caller name the tour — is outside the switch and applies everywhere, fail-closed. | #7511 |
| 6. A caller who hangs up mid-message still reaches the team | Nothing at all. Row-level forensics on a call cut at 53 seconds while Clara was mid-sentence (conv_8901m24mmaw0epx8k5hzje71qczn): a conversation marked "resolved" with no summary and no topic, two log lines, one guest-card touch — and no email even attempted (zero rows in that day's email ledger for the call, zero dead letters, while the same property delivered six tour emails in the same hour), no text, no task, no escalation. Worse on the message-taking version, cut at "…and I also wanted to ask whether you allow—" (conv_8501m24rpvyjfbmr6z55fww7e2zg): because Clara never reached her commitment sentence, not even a promise row exists. A qualified lead disappears and nothing anywhere records that someone was left hanging. |
A caller who hangs up mid-message reaches the team by email. | Three review rounds, two of them rejecting a version that could not tell a cut-off caller from a satisfied one. The trigger is a positive, fail-closed signal: a real hang-up reason, the caller asked for a person or to leave a message, no tool of any family succeeded, no goodbye, inbound calls only. 45 tests, and each new condition was proven to fail with its own guard deleted. | merged · dark Bench end-to-end proof owed once the bench property is released. | #7508 |
| 7. When the team can't be paged, the promise goes to email, not to silence | The system knew a human was owed a callback, tried to page one, failed, and marked the obligation broken inside a second — then did nothing. promise_04bac8e0 on conv_8801m24rjpshfw8ab1a7416t0b0s, off the sentence "Perfect, I'll get that to the team — they'll be in Thursday morning at nine and your message will be waiting for them": broken, reason "cannot reach a person", resolved 618 ms after it was created. Same on the Spanish call, 178 ms (promise_caaef675). No email, text, task or escalation followed either one. |
A failed page falls through to the team-note email instead of to silence. | Root cause traced to the recipient guard refusing both page routes for this property. The fix is additive — a fourth door into the team-note sender that already exists, with two opt-in fields — rather than loosening the guard. 8 tests replaying the real call end to end with only the vendor edge mocked, each guard proven red with itself removed. | merged · dark | #7535 |
| 8. The post-call summary can't invent what a caller "wanted" | A caller was cut off at "…and I also wanted to ask whether you allow—". The summary written onto her guest card said she "wanted to ask about pet or other living-situation policies." Nobody said that. And this is not a staff-only field: it is read back into the live script as "previous interactions", so the invented line would have been spoken to her on her next call. | A severed sentence is recorded as unfinished. Anything the caller did not say is either dropped or marked as inferred, and the only evidence allowed is the caller's own turns. | Cause found in the summary instructions themselves — their own checklist line about "pets, roommates or other living-situation preferences" seeded the guess. 29 tests. Three review rounds, including one that caught the first draft fabricating inside its own truncation detector. Stated honestly in the fix itself: on this exact call the cut-off detector correctly stays silent, because Clara answered the severed turn — what protects that record is the second half, which marks any claim the caller's own words don't support. | merged · dark | #7532 |
| 9. The reminders question is answered with what we actually send | A real caller asked "am I gonna get any reminders or text messages about this?" and was told "The leasing team will follow up with you on that." His confirmation text went out 40 seconds later, naming the address, and his reminder timers were already armed — confirmed running in the live workflow with both the day-before and one-hour timers on it. He was told to expect nothing from us. | The answer is built from what this property actually sends: the confirmation now, the day-before and one-hour reminders when the timing allows them, and plainly "nothing is sent" when that is the truth. | The cadence is fenced against the real timer constants and the confirmation template, so the spoken answer cannot drift from the code. Three review rounds tightened it to conditional — no day-before for a booking under 24 hours out, neither reminder under an hour, receipt only for a visit that is not yet confirmed. 12 tests. | merged · dark | #7537 |
| 10. Never claim a visit with nothing behind it | On Sep 7 a caller was told twice that his tour request was in — "I've put in your tour request… the team will confirm shortly" — with zero tool calls behind it, and the post-call step then wrote a tour row with no home on it (conv_0101m1yq1j…, tour b26cc13d). Cause confirmed, not guessed: at that hour the line had no tools bound and no call data arriving at all — the state left by an earlier revert — and the script live that hour told her to say a request was placed. |
Live, not dark. A call that arrives with no data behind it pages us instead of failing quietly, and the post-call writer refuses to create a tour with no home on it. Ruled side effect: the misleading "request received" text for a vague ask that names no home is gone. | Eval pack 7/7 on the production script and 0/1 on the commit before the fix, so the test can actually fail. The evals also caught and banned Clara narrating the empty data out loud ("the available homes block appears to be empty"). Merge re-provisioned the line; the script read back byte-identical with no new template slots. | merged · live Two guards live. The call-integrity wording and the post-call contact capture that ship with it stay dark. | #7515 |
conv_3001m24r12frfqkbmj01kbqqepcc), with real alternatives offered instead. On another call gas was refused as "included" three separate ways — "I just don't have the utility breakdown for those homes."| Calls placed on the line | Tours we created | Humans reached | Pull requests | Left open |
|---|---|---|---|---|
| 50 | 18, all 18 cancelled | 0 | 10 merged | 0 |
Closed out 2026-09-13: the Sep 7 phantom tour — the row with no home on it, from the fake booking above — was cancelled under Fede's standing grant. It is an internal record on a test property; nothing was sent to anyone, and the close-out was verified against the email ledger, the phone carrier's log and the reminder workflow.
One tour was deliberately left standing — a real prospect reached the line mid-run and booked for the next morning; his call was never touched and his booking is correct. Our own cleanup is now scoped to the exact tours a run created, after an earlier unscoped sweep cancelled the customer's own past-dated test tour (no message reached him; the only notification was an internal email to PropFlow).
Written 2026-09-10, after the Willows proof above sealed. This is the playbook for building a fake but realistic customer inside a real client's AppFolio database — the same shape as the Willows, but for a client we haven't onboarded yet, or a client where we want to rehearse a change before it touches real prospects. It borrows the Willows method directly: a real staff-side test property, real applications, a reversible reset, a same-day cleanup. Where a step needs Fede's word to actually flip a switch on a customer's data or account, that's called out — this page never does that step for him.
Every customer we onboard is one organization — the wall everything else hangs off. Gera's portfolio model (the architecture page) calls this the "nameplate": one row that turns a phone number, a mailbox, or a login into "this is customer X." A simulated customer needs the same shape, or the simulation proves nothing.
leasing@westernslopepm.com) and a shared calendar for tours. Clara replies from that shared mailbox, not from a mailbox we invent per building. See the Western Slope mailbox decision, above, for how this was decided.clara+<company>@propflowai.co — one AppFolio user for all of that client's properties, so a lockout, a password reset, or a revoked session only ever touches one client, never two. This is why the two AppFolio databases (jpco.appfolio.com and situsgroup.appfolio.com) each need their own login, never a shared one.None of this is new invention — it's the shape Camellia, Situs Group, and Western Slope already answer, laid out as a checklist so a new client gets asked the same six questions in the same order every time.
property_directory report. Per AppFolio's own help text: a portfolio groups properties and lets company settings be customized per portfolio; every property belongs to exactly one, and the default for a property nobody has moved is the "Management Company" portfolio. Steps, in order — order matters here, one step out of sequence and the next one has nothing to pick from: Clara PropflowAI, in JP's database) — otherwise the automation login loses the ability to see or work the property it just moved.property_directory report shows the test property filed under the new PortfolioId, and every one of the client's real properties unchanged; the robot's HTML-door probe against the test property still returns 200; the sync Lambda processes both the test property and the client's real properties with the same row counts as before the move. Side effects to expect, not to worry about: any user limited to the client's own portfolio stops seeing the test property (that's the point); portfolio-level reports drop it from their totals; the property's own settings, units, listings, ownership and ledgers are untouched by the move.What is explicitly NOT a settings write in the customer's system, and needs no go from Fede: anything that only touches PropFlow's own database — creating the org and property record on our side, running the reset script, reading reports once the key exists. The line is: if it changes something a client's own staff would see inside AppFolio, it's Fede's call; if it only changes what PropFlow itself stores, it isn't.
/admin/companies, built in #7313) — click "New customer," fill the one flat form (company name, PMS, which modules they bought, the admin's name and email), and it creates the organization and sends the admin invite in one step. The property: there is no equivalent screen yet for a from-scratch property with no AppFolio import behind it — the normal customer path is the AppFolio-connected onboarding wizard, which imports a client's real portfolio rather than creating one fake property. For a simulated customer, the session path is a provisioning script written the way scripts/provision-western-slope-proto-property.ts was: dry-run by default, nothing written until --apply, and it creates the organization row, the property row (flagged isTest, so it never counts in real metrics or notification jobs), its units, and a starter knowledge block in one pass — reusing the same shape rather than hand-writing DynamoDB rows. Once the property exists, run npx tsx scripts/set-appfolio-account.ts --property <id> --account <jpco|situsgroup> --apply to tell it which AppFolio database its writes and reads resolve against — dry-run by default here too, shows current → intended before anything is written. Who does it: Fede or a session, through the Customers screen, for the org; a session, running the provisioning script and set-appfolio-account.ts, for the property. How to check it worked: the org appears in the back-office Customers list with its admin invited; the property appears under it with its company link set (never optional) and Property.pmsAppfolioAccount pointing at the right database.config/phone-registry.json — the one map of every number PropFlow owns or recognizes). How to check it worked: a test call/text to the client's forwarded line rings through to Clara's number, and the registry entry resolves that number back to the right property.leasing@ mailbox, with room for a per-property override if a client ever wants one. This is Property.emailIntegration — the same field that decides both which mailbox we read leads from and which mailbox we send from, so getting this step right the first time avoids a lead landing somewhere nobody's watching. Who does it: someone with admin rights on the client's Microsoft/Google tenant (a client contact, usually) grants consent once; a session wires the connection. How to check it worked: a test lead email lands and is read within the connection's normal polling window, and a test reply goes out from the shared mailbox address, not a propflowai.co fallback.PMSCRED#<userId> for a new client. That per-user credential path is for a person's own PMS login, not a client-level one. For a new client (simulated or real) the credential belongs to the client's own clara+<company>@propflowai.co login (section A above), stored the way the PMS-agnostic adapter registry (src/lib/domain/pms/registry.ts) expects it — resolved per property through getPMSClient, never hand-typed into domain code. Who does it: a session, wiring the credential once it exists. How to check it worked: getPMSClient for the test property returns a working client, not null.This reuses the Willows method exactly — see the Willows end-to-end proof above for the full worked example. The rules that made that proof trustworthy:
mergedIntoHouseholdId) or closed with a real closedAt timestamp later than its createdAt; and zero new "ghost" household rows in report-shell-households.ts's categories, compared against the count taken right before the round started.willows-leasing-reset.ts) pattern: park the round's people and rows rather than deleting them, so every round is reversible and nothing real gets destroyed by accident. Run a dry pass first, confirm the plan names only the rows this round touched, then apply.Command reference from the Willows: npm run household:regress -- --client <name> for the corpus replay, one client at a time — concurrent runs on the same machine exhaust memory and take each other down.
Situs is behind Western Slope by design — it's on the slower, "other patterns allowed" track. It also has no company record of its own yet: Yale 25 Station currently sits under JP&Co's company record.
| Piece | Status | Owner | Next action | Depends on | Needs Fede? |
|---|---|---|---|---|---|
| A Situs company record of its own | not started | Fede | Create one — today Yale sits under JP&Co's company. The path it was waiting on now exists: the back-office Customers screen merged at 8:53 PM MT (#7313), so a Situs company can be added there with the modules they bought and their admin invited. Nothing blocks this any more. | — | Yes — his go to create it |
| Reports API access | blocked | Fede | Request it now that terms are being finalized. | — | Yes |
| Maintenance-line design decided (Clara inside their phone tree, or a plain forward to her) | not started | Gera | Decide which shape it takes. | — | Yes |
| A test property to rehearse on | not started | Fede | Ask Situs for one, plus their policy spreadsheets and emergency rotation. | Reports API access | Yes |
Definition of done: bench-tested at Willows, then rolled out to Situs. Re-checked 1:00 PM MT against Gera's pull requests from the last 7 days (merged and open): one maintenance fix (#7264, a number-word inside a reply no longer hijacks a stale work-order rating) and nothing on intake itself.
Gera's other open pull requests, outside the maintenance lane: 31 open, oldest from Aug 21 (re-counted 1:20 AM MT Sep 9). Grouped by what they touch — not judged; Fede will ask Gera which of these are still alive and worth keeping open.
| Group | Count | What it covers |
|---|---|---|
| Reliability and alerts | 12 | Keeping the alerting and guard-rail pipeline honest — fixing pages that fired wrong, downgrading noisy ones, deduping backfill runs. |
| Review pipeline | 2 | The bot that reviews pull requests before they merge. |
| Evals | 3 | Automated grading of Clara's conversations and renewal handling. |
| Docs | 2 | Status write-ups for the channel-adapter work and a code-comment correction. |
| Other engineering cleanup | 13 | A grab-bag: demo tooling, the ops feed, renewal wording, CI guard fixes, a chart, a lambda env var. |
Gera merged 35 pull requests across Sep 8 in Mountain time (midnight to midnight; the figure of 19 that stood here was a count taken at 1:00 PM, before the afternoon and evening) — most were fixes keeping the shared build pipeline green after a red spell on main, plus the Settings work: company admins manage their own team (#7339) and the five platform-wide switches are staff-only on write (#7365, so a customer role can no longer silence every customer's texting from a browser console).
A note on "merged" in this table. GitHub can show a pull request as merged when it never reached main — #7428 merged into a feature branch that is not an ancestor of main, and was redone as #7433, which did reach main at 2:47 AM. Every row here that says merged was checked against main itself, not against GitHub's label — most recently at 3:20 AM MT Sep 9.
| PR | What it does | Author | State | Owner | Next step | Needs Fede? |
|---|---|---|---|---|---|---|
| #7330 | Shows only the unit count on the customer's billing card, nothing else. | Fede | Closed unmerged, 1:35 PM MT — Fede saw the screenshots and does not want it | session 006 | Superseded: the Billing card is now hidden from customers altogether (#7376, merged 2:14 PM MT) | No |
| #7329 | Fills in the company name automatically on the settings page. | Fede | Merged 2:02 PM MT | session 006 | Done. Its review landed after the merge; the two findings are applied in a follow-up (#7378, merged 2:24 PM MT) — staff can no longer pick up another company's legacy name, and the page never blocks rendering on it. | No |
| #7325 | Preview copies of the app currently skip the second sign-in step entirely. | Fede | Merged 12:24 PM MT (both bots approved; previews skip the second factor entirely, production unchanged) | session 007 | Needs Fede's go on one Vercel Preview env var (the MFA test account id) so the preview sign-in test suite can run | Yes — a config write |
| #7313 | Lets the back office add a customer, choose what they bought, and invite their admin. | Fede | Merged 8:53 PM MT, after eleven review rounds | session 007 | Done. Follow-up polish merged 9:13 PM MT (#7415): the Customer details view lines up and the New customer modal stops moving. | No |
| #7311 | Cleans up the property page so it only shows real units. | Fede | Merged today, 9:57 AM MT | session 007 | Done. | No |
| #7310 | Makes sure a customer only sees the features their company actually bought. | Fede | Merged 12:03 PM MT | session 007 | Done | No |
| #7309 | Switches the second sign-in step to a passkey or a text code; retires the old authenticator app. | Fede | Merged 2:15 PM MT | session 006 | Done. | No |
| #7304 [HOLD] | Stops the setup wizard asking questions the contract already answered. | Fede | Merged today, 9:23 AM MT | session 007 | Done. | No |
| #7300 | Makes typing a 6-digit code the default way to sign in. | Fede | Merged 2:02 PM MT | session 006 | Done. Note for the record: delivery was proven at the send-API level and by reading the code out of the auth table, never from a real inbox. | No |
| #7370 | Stops saving charges or turnover settings from wiping the pet policy and other fields the form doesn't own | Fede (coworker session) | Merged 12:35 PM MT | — | Done | No |
| #7371 | Stops answers staff have retired from still being spoken on calls (text already stops) | — | Closed unmerged, 2:24 PM MT | Clara as Coworker session | Fede's call: not carrying retired-answer logic on calls right now. Branch kept; reopen if wanted. | No |
| #7369 | "Cancel my tour" with two tours booked asks which one, or both, instead of silently cancelling one. | Fede | Merged 2:40 PM MT | session 011 | Done. The two live proof calls (two-home booking, cancel both) run against this. | No |
| #7376, #7378 | Hides the Billing card from customers; and the company-name prefill review follow-up. | Fede | Merged 2:14 and 2:24 PM MT | session 006 | Done. | No |
| #7382 | Pins the stated-name rule with tests. Two test files only — it changed no behavior and did not touch the prototype prompt; the rule was already the code's semantics, gated by a per-property setting that is off here. | Fede | Merged 3:14 PM MT | session 011 | Done as a merge. The behavior still needs the per-property switch turned on — see Needs you. | Yes — the activation |
| #7381 | A home counts as confirmed only when its own booking succeeded — no more "you're all set" over a clash. | Fede | Merged 4:08 PM MT — while it still carried hold-for-review with auto-merge disarmed, per #7395 | session 011 | Prompt half is live on the line (the merge-only provision lane ran green on the merge commit). The server-side half — the booking handler and listing resolution — is not in production yet: the last production deploy was 3:53 PM MT and main has been red since. A live proof call is the remaining step. | No |
| #7395 | Lands the same-visit-tour eval that #7381 described but did not carry, and corrects a number it recorded wrong. | Fede | Merged 6:15 PM MT | session 011 | Done. It closed the reviewer's last open finding on #7381 — that PR changed a prompt and cited eval runs its own tree did not contain, so CI never ran them. | No |
| #7397 | Greens main: keeps the new shadow-tenant script's helpers import legal, and stops the F7 guard reporting two sites for one import. | Gera | Merged 5:34 PM MT | Gera | Done. With #7403 it greened main at 5:46, and production deployed at 5:57 carrying #7381's server-side half. | No |
| #7398 | Pins the review action to v1.0.217 — v1.0.218's installer leaves no binary. | Fede | Merged 5:27 PM MT | session 007 | Done. Its follow-up #7403 (merged 5:46) made the reviewer-action locator survive the SHA pin. | No |
| #7406 | The tour receipt text names every home booked on the call, not just one. | Fede | Merged 6:56 PM MT; in production 7:06 PM MT | session 011 | Done and live — the production deploy at 7:06 carries it. It fixed tonight's live-call finding: two homes were booked correctly but the renter's text named only the later one. A live two-home call to prove the text end to end is still owed. | No |
| #7403 | Makes the reviewer-action locator survive a SHA pin. | Gera | Merged 5:46 PM MT | Gera | Done — this is the merge that actually turned main green. | No |
| #7402 | Drops the "less secure" warning from the text-code sign-in option. | Fede | Merged 5:40 PM MT | session 006 | Done. | No |
| #7405 | Every browser-agent call now says which client it is for (shipped dark; every current company still resolves to JP&Co). | Fede | Merged 7:22 PM MT | Fede | Done. It is step 2 of the tenancy program above — the JP&Co fallback it replaces is what gets switched off later. | No |
| #7409 | A playground for the portfolio-architecture options: pass, fail and why for every case. | Gera | Open since 7:25 PM MT | Gera | Review, then merge. It feeds decision X1 further down this page — where a shared line binds the property — which is still open. | No |
| #7408 | A terms-acceptance step for new customers (shipped dark, with an exempt-org list). | Fede | Merged 8:56 PM MT Sep 9 | session 007 | Done. This cell read "still a draft" until 4:30 AM Sep 10. | No |
| #7415 | Back-office polish: the Customer details view lines up, and the New customer modal stops moving. | Fede | Merged 9:13 PM MT | session 007 | Done. | No |
| #7416 | Stops a guest-card test-bench flake paging Sentry, by exempting test properties from the AppFolio sync check. | Fede | Merged 9:20 PM MT | — | Done. | No |
| #7417 | Co-applicants on one unit are linked automatically, so the suggest-a-link step is skipped. | Fede | Merged 10:01 PM MT; in production 10:11 | — | Done. General leasing, not Western Slope-specific. | No |
| #7418 | Mines a 2026 corpus for the household harness — JP&Co over the API, Situs and Western Slope from dumps, scrubbed. Read-only. | Fede | Merged 11:01 PM MT — and it is what reddened main | — | Needs a follow-up for the drifting table default its new script introduced. | No |
| #7419, #7420 | Household harness runs guest cards and applications through the real engine; and a known person's new application advances their live row instead of minting a second one. | Fede | Merged 10:54 and 11:13 PM MT | — | Done. General leasing, not Western Slope-specific. | No |
| #7384 | Grading v2: a null grade for turns with nothing to check, plus per-turn latency. | Gera | Merged 11:04 PM MT | Gera | Done. | No |
| #7422 | Removes a hardcoded production-table default from the new corpus miner. | Gera | Merged 11:34 PM MT | Gera | Done — this is the fix that greened main. | No |
| #7423, #7424, #7425, #7426 | Household harness follow-ups: match the real corpus shapes, get the dump-sourced corpora reaching the engine, treat a cosigner's name as their own name rather than AppFolio's "(Co-signer for X)" label, and name the seed scenario for its shape. | Fede | Merged 11:53 PM – 1:15 AM MT | — | Done. General leasing, not Western Slope-specific. | No |
| #7427 | A read-only leasing census at the Willows, plus a reversible reset, for the household proof. | Fede | Merged 1:41 AM MT — and it is what reddened main | — | Needs a follow-up putting its new script inside the F7 exception table. | No |
| #7429, #7430, #7431 | Household harness: JP&Co corpus re-mined with unit ids, date parsing fixed for API-sourced companies, and the date reader pinned by a red/green test. | Fede | Merged 1:37–1:59 AM MT | — | Done. General leasing, not Western Slope-specific. | No |
| #7434 | Pins the new Willows reset script into the F7 exception table. | Fede | Merged 2:36 AM MT | — | Done — this is the fix that greened main. | No |
| #7433 | The JSON person reader folds Gmail keys the way Dynamo does, so the harness stops seeing false duplicates. | Fede | Merged 2:47 AM MT | — | Done. It replaces #7428, which GitHub showed as merged but which never reached main. | No |
| #7435 | A unit added after onboarding now gets its AppFolio id from the rent roll, so applications on it stop being dropped. | Fede | Merged 10:32 AM MT; in production 10:44 | — | Done. Found during the Willows proof — vacant units were being created without their AppFolio id, so anything applying to them had nothing to match against. | No |
| #7436 | Makes an unnamed AppFolio property or browser-agent request fatal instead of silently falling back to JP&Co. | Fede | Merged 12:10 PM MT, by Fede, as his own body said he would | Fede | Answered by the afternoon results above, not by the pull request. At 12:25 this row flagged that the body says "merge only once the production audit shows every AppFolio-backed property carries a pmsAppfolioAccount", with no sign-off recorded on the PR. The tenancy section now reports the change live in production and proved with a real call at the Willows, unnamed calls failing loudly rather than guessing, and zero errors at Camellia — which is the property that would have broken first if the account rows were not in place. So the hazard did not materialize. What is still not written down anywhere is the property-wide audit itself; the evidence is behavioural rather than an enumeration. | Only if you want the enumeration on record |
| #7445 | Stops co-pendency fusing unrelated applicants who are merely competing for the same unit. | Fede | Merged 12:20 PM MT | — | Done. General leasing, out of the household-identity work. | No |
| #7442 | Refuses to clear, park, reset, wipe or seed any property that is not marked as a test property. | Fede | Merged 12:59 PM MT | — | Done. It is the guard rail under the reset scripts the tenancy program uses — and the change that tripped F7 for the third time. | No |
| #7450 | Pins that new script into the F7 exception table. | Gera | Merged 1:12 PM MT | Gera | Done — greened main, 13 minutes after it went red. | No |
| #7438, #7439, #7447 | Availability-source heartbeat alerting; self-healing orphaned claim sentinels; and a build fix stopping a canary tab pulling node internals into the bundle. | Fede, Gera | Merged 12:25–12:53 PM MT | — | Done. Platform work, not Western Slope-specific. | No |
| #7440, #7441 | Cross-checks every listing surface against the others, read-only; and closes two data-correctness gaps the Willows end-to-end run found, including unit-id consistency. | Fede | Merged 1:27 and 1:33 PM MT | — | Done. Both came out of the Willows proof written up further down this page. | No |
| #7444 | Stops household overmerge — a shared phone or email no longer glues unrelated people into one household. | Fede | Merged 1:44 PM MT | — | Done. | No |
| #7443, #7451 | One interface for availability sources; and CI moves to ARM runners with one job per push. | Fede | Merged 2:21 and 2:04 PM MT | — | Done. | No |
| #7458 | Voice lookups are scoped to the company behind the dialled number. | Fede | Merged 3:21 PM MT | — | Done. This is the cross-company isolation work landing on the voice path — the block-2 shape the roadmap below calls "the front door resolves the company first". | No |
| #7455, #7456 | Fixes the email-lead unit id and fault-isolates the guest-card path; and makes the context-health probe name its account. | Fede, Gera | Merged 2:58 and 2:52 PM MT | — | Done. | No |
| #7446, #7454 | A household regression command with a real-run skill; and CI collapsed to one job per pull-request push. | Fede | Merged 2:36 and 3:17 PM MT | — | Done — though #7454 is what reddened main at 3:18. | No |
| #7457, #7469 | Isolates the PMS sync tick per customer, and gates guest-card and rental-application sync on the test flag. | Fede, Gera | Merged 3:40 and 4:18 PM MT | — | Done. Both are the per-customer isolation shape landing in the sync path. | No |
| #7462, #7465 | Retries AppFolio sync writes that lose the version race; and adds the missing @sensor pragmas that reddened main at 3:18. | Fede | Merged 3:38 and 3:30 PM MT | — | Done. | No |
| #7461, #7472 | Moves the full twelve-part test suite to main only, never on pull requests; and finalizes which unit-test check is required on a PR. | Fede | Merged 4:27 and 5:09 PM MT | Fede | Done. Cost-driven — the full suite on every PR was near a quarter of the Actions bill. See the note in Parked about what it means for main going red. | No |
| #7459 | AppFolio credentials belong to the company, held in a vault rather than per property. | Fede | Merged 5:00 PM MT | — | Done. It is the credential half of the company-tier work block 1 of the roadmap describes. | No |
| #7463, #7464, #7466, #7468, #7471 | Guest-card re-sync restamps corrected contact details; two household co-pendency gaps; three application-review reminder fixes; self-healing claim orphans; and a per-connection health fingerprint. | Fede | Merged 4:34–5:11 PM MT | — | Done. Leasing and sync correctness, out of the Willows proof. | No |
| #7467 | Removes the daily tenant PMS sweep from the AppFolio sync. | Fede | Merged 5:11 PM MT | — | Done. | No |
| #7475, #7476, #7478 | Name-guard fixes on prospect candidate picking and Person mint, and a consolidation of prospect rows and households afterwards. | Fede | Merged 5:42, 5:56 and 6:13 PM MT | — | Done. The identity and household work from today's Willows run. | No |
| #7474 | Mocks queryGSI for the session-claim self-heal fallback. | Gera | Merged 5:30 PM MT | Gera | Done — the fix that greened main after the 5:00 PM red. | No |
| #7479, #7485, #7486 | Identity work: bot findings on the name guard, a test proving the Person-mint guard holds no placeholder, and a Person now holding more than one live value per claim. | Fede | Merged 6:24–6:32 PM MT | — | Done. The identity thread out of the Willows household proof. | No |
| #7482, #7484 | Conversation highlight furniture and segment heads; and follow-ups on the architecture record, including phantom citations. | Gera | Merged 6:32 and 6:42 PM MT | — | Done. | No |
| #7460 | A replay corpus and runner built from real call history, deploying on merge only. | Fede | Merged 8:20 PM MT | — | Done. It is the bench the voice merge plan needs — the fourth Focus card above asks for the go to import the harness number so that bench has a line. | No |
| #7480, #7483 | Three household regressions from Willows round 3; and the auto-link-co-applicants switch deleted now that household linking is unconditional. | Fede | Merged 7:58 and 8:15 PM MT | — | Done. | No |
| #7490 | Copy a link to a single message from its details pane. | Gera | Merged 8:15 PM MT | — | Done — and it is the merge the 8:15 red landed on. | No |
| #7491 | Imports the portfolio harness test line (+1 720-807-1724) into ElevenLabs — import only, no agent bound. | Fede | Merged 8:58 PM MT | — | Done, on Fede's go. It closes the last piece of decision X7: the number was bought Sep 7 and had sat unimported since. Binding an agent is a separate one-line decision, and the harness's six robot-call scenarios wait on that. | Yes — which agent it rings |
| #7408 | The invite entity and customer terms acceptance. | Fede | Merged 8:56 PM MT | session 007 | Done — it was the draft this page listed as "nothing to do until it is marked ready". | No |
| #7481, #7488, #7493, #7503 | Second-factor satisfaction set only by the real check; the transfer player's waveform; the main-red test fix; and #7490's corrected evidence spec. | Fede, Gera | Merged 8:40–9:18 PM MT | — | Done. | No |
| #7497 | The message Clara takes on the Western Slope line is actually emailed to the property's contact address — it previously reached nobody. | Fede | Merged 9:29 PM MT, shipped dark | Fede | Must stay dark until Fede says otherwise; merging it re-provisions the live 970 agent automatically. It also drops the in-hours transfer into Western Slope's own leasing tree, per his ruling. Maintenance transfer untouched. | Yes — the go to switch it on |
| #7492, #7499 | A portfolio bench property behind the harness, and the scattered-home scenarios that run against it. | Fede | Merged 9:46 and 9:32 PM MT | — | Done. With the test line imported (#7491) the harness now has a bench and scenarios; binding an agent to the line is the remaining step. | No |
| #7506, #7507, #7510 | A house number is not an inventory count; "three or four bedrooms" is understood as a range; and a stored language is a soft prior on the ring rather than a fixed setting. | Fede | Merged 9:27–10:02 PM MT | — | Done. All three change what a caller hears on the prototype line. | No |
| #7508 | A caller who hangs up part-way through leaving a message still reaches the team. | Fede | Merged 10:28 PM MT, shipped dark | Fede | The second dark Western Slope change tonight, alongside #7497. Both need his go before a caller sees any difference. | Yes — the go to switch it on |
| #7511, #7515 | A prospect on a shared phone can cancel and reschedule; and a call with no data behind it never claims otherwise. | Fede | Merged 10:24 and 10:48 PM MT | — | Done. Both are shared-line behaviours the portfolio work needs. | No |
| #7512, #7521 | The tool-promotion gate's side-effects, and retiring the portfolio-bench placeholder in the voice harness. | Fede, Gera | Merged 10:47 and 10:28 PM MT | — | Done — continued harness work on the bench #7492 added. | No |
| #7514, #7525 | A customer and its admin are created as one thing; and an account that already works cannot be re-invited. | Fede | Merged 10:54 and 11:16 PM MT | session 007 | Done. Both tighten the back-office Customers screen that merged Sep 8. | No |
| #7535 | An after-hours promise that the team will get back now reaches them by email instead of failing silently. | Fede | Merged 11:29 PM MT, shipped dark | Fede | The third dark Western Slope change, with #7497 and #7508. All three need his go. | Yes — the go to switch it on |
| #7533, #7537 | The after-hours message desk's phrasing list; and the reminders question for portfolio intake. | Fede, Gera | Merged 11:25 and 11:53 PM MT | — | Done. | No |
| #7523, #7527 | Says what happened when a Microsoft connect fails; and a made-up AppFolio portfolio so onboarding can be rehearsed. | Fede | Merged 11:27 and 11:50 PM MT | — | Done. The rehearsal portfolio is what lets the onboarding path be walked without a real customer. | No |
| #7542 | Fixes the three guards #7527 tripped on landing — including a real one: an auth-table purge inside the domain table's module, querying an index that table does not have. | Fede | Merged 12:24 AM MT | — | Done — it ended the night's red spell. | No |
| #7544, #7547 | The platform logger no longer reaches node internals; and the renewals hook stops crashing the AppFolio sync. | Gera | Merged 1:09 and 1:08 AM MT | Gera | Done. | No |
| #7539 | Records the founder's 2026-09-10 rulings. | Gera | Merged 12:46 AM MT | — | Done. | No |
| #7517 | The AppFolio fingerprint fence was 168 seconds of table scan; it is now a keyed lookup. | Fede | Merged 2:05 AM MT | — | Done. Worth knowing for the portfolio work: that fence runs on the sync path every customer shares. | No |
| #7546, #7549, #7558 | Household shells closed and retired; a finalize that must not mark an unstarted job done; and the Household canonical reunited. | Fede, Gera | Merged 1:49–2:20 AM MT | — | Half of this was not true. The retire worked; the close did not — #7576 (4:06 AM, its own row below) found it failing silently on every emptied shell under load. Recorded here because this row read "Done" from 2:24 AM (when this page added it) until 4:29 AM, while the shells stayed open. | No |
| #7545, #7548, #7552, #7555 | Operator-migration phase work: four blocking rows decided, an executable guard on the rename, superseded persisted values, and stress cases whose rules no longer exist. | Gera | Merged 1:40–2:21 AM MT | Gera | Done. Separate from this initiative — listed because it is most of tonight's merge volume. | No |
| #7564 | A hard timeout on Outlook and Graph fetches, after the inbound processor hit its own five-minute limit and tripped a CloudWatch alarm. | Gera | Merged 3:18 AM MT | Gera | Done. Relevant here: Outlook/Graph is the path Western Slope's tour calendar and, once connected, their mailbox both run on — an unbounded fetch there stalls the processor for every customer sharing it. | No |
| #7576 | The emptied household shell was never actually closing. #7546 called the close right after retiring the last member, but the close re-read the members through a secondary index — which in DynamoDB cannot be read consistently — so under concurrent writes it still saw the membership it had just retired, returned false before the conditional write was ever attempted, and logged nothing. The shell stayed open forever. | Fede | Merged 4:06 AM MT | — | Done, and it corrects its own earlier diagnosis: the row shapes were fine and the write condition was never reached. Evidence is two named households from the Willows round-6 proof (org_sandbox / appfolio-45) whose memberships retired but whose shells never closed — the bench, not a customer. It matters here because it is the AppFolio sync path every Western Slope application will run through, and because a swallowed failure in it now logs a WARN naming the household. | No |
| #7565, #7566 | The appfolio-sync household tick's own tests: carry the close through the wiring, and name the throw the tick was swallowing instead of just counting one. | Fede, Gera | Merged 2:56 and 3:17 AM MT | — | Done. Test-only, but the same thread — naming the swallowed throw is what made the silent failure above findable. | No |
| The other fourteen merges between 2:30 and 4:20 AM: twelve are operator-migration and alert-noise work — eleven Gera's, one Fede's: renames, superseded persisted values, paging suppression. None of those touches Western Slope or Situs. The other two are #7565 and #7566, which belong to the household thread above and now have their own row; an earlier version of this line swept them in with the rest, which was wrong. Listed so the gap in this table is not mistaken for a quiet night. | ||||||
| #7073, #6861, #6801, #6800, #6799, #6798, #6797 | Seven routine dependency-version bumps. | Dependabot | Open, checks failing (stale base) | Gera (dependency policy) | None from us. | No |
| Pull request | Owner | Waiting on |
|---|---|---|
| Portfolio-architecture playground (#7409) | Gera | reviewer — it feeds decision X1 |
| Customer terms acceptance (#7408) | session 007 | merged 8:56 PM MT Sep 9 — nothing waiting |
| Lambda IAM grant (#7432) | — | Ready to merge since 1:54 AM MT on Sep 9 — opened straight out of draft, no hold label, every check green. Re-checked at 4:30 AM MT on Sep 10: still open, still mergeable, still clean, which is twenty-six and a half hours untouched. (An earlier version of this cell gave the elapsed time as twenty-five hours against a check timestamp that did not support it; the anchor is now read from the pull request itself rather than carried forward by hand.) It is the fix for a vendor-contact sweep that has been paging four times a day since Sep 7. |
| Since this list was written, the same-visit-tour eval, green-main, review-action pin and the tour receipt fix all merged (6:15, 5:34, 5:27 and 6:56 PM MT). Everything else closed out during Sep 8: retired answers on calls and the billing-card unit count were closed unmerged on Fede's call; company-name prefill, the passkey-or-text second factor, the typed 6-digit sign-in and cancel-a-tour-with-two-booked all merged. The table above carries each one with its time. | ||
scripts/ that imports the raw Dynamo helpers; each import was legitimate (parking and restoring rows, reading a property to check it is a test property); each was fixed by pinning the file into the F7 exception table — #7397, #7434, #7450 — never by changing the code. Recovery was 13 to 55 minutes each time and nothing Western Slope needs was ever stuck behind one. The pattern is the point: Guard pins runs only after a merge, so the author cannot see it fail, and the guard treats a normal thing for a script to do as a violation by default. The third case is the sharpest — #7442 is itself a safety change, refusing to wipe or reset a property that is not marked as a test property, and it was the guard that broke on it. Main is green now; production deployed 1:21 PM MT.Ground-truth check of Camellia against Western Slope, read straight from the code — not from the brief's assumptions. Paths are relative to ~/.claude/propflowai unless marked as a docs page. Correction to the brief: there is no Prisma schema in this repo — Property and Organization are plain TypeScript interfaces in src/lib/data/types.ts, backed by DynamoDB (table propflow-prod).
| Camellia | Western Slope | |
|---|---|---|
| How it works today | Two systems, both live: (a) the Reports API rent-roll report is the real system of record for units/tenants/leases, run every 5 minutes by a standalone job; (b) the public listings page is scraped every 15 minutes and only flips the "available" flag on units that already exist from (a), matched by unit number. | Only the public listings page scraper. There is no AppFolio account or API key for this customer at all. |
| Data source | AppFolio's Reports API v1 (rent-roll report), with a real client id/secret — this needs an API key. Plus the same public-page scraper layered on top. | The public, no-login westernslopepm.appfolio.com/listings page, fetched by public-listings-sync.ts — no login, no API key. |
| Cron / cadence | Rent roll: a Lambda job (lambda/appfolio-sync/) every 5 minutes. Scraper: a Vercel cron every 15 minutes (/api/cron/listings-sync), hardcoded to Camellia's URL and property id. | The same 15-minute Vercel cron, through the generic path that scrapes any property flagged for it — Western Slope is currently the only property on that path. |
| What it needs | Already has both; nothing new required. | Nothing — this can be, and already is, the go-live source with no API key. Confirms Fede's belief. |
| Gap | None for listings. | The scraper's output is deliberately coarse: unit "number" is the street address (scattered-site portfolio), and status is always inferred as vacant rather than confirmed — not urgent for go-live. |
Key files: src/lib/domain/leasing/public-listings-sync.ts, src/lib/platform/scrapers/appfolio-public-listings.ts, src/lib/domain/leasing/listings-sync.ts, vercel.json, lambda/appfolio-sync/schedules.json, src/lib/integrations/appfolio/pms-adapter.ts.
| Path | Camellia | Western Slope | State |
|---|---|---|---|
| A. AppFolio lead-notification emails (Zillow, Apartments.com, website-via-AppFolio) parsed by Clara | Live — parsed by the shared inbound-email pipeline, over the connected mailbox camelliaapts@jp-co.com. | The code path is generic and not hardcoded to any property, but Western Slope's mailbox isn't connected yet (see §5) — so this path isn't live there today. | Built, works for any property with a connected mailbox. |
| B. Direct AppFolio read of the guest-cards report | Live, via AppFolio's official Data API — a normal REST report call, not browser automation, needs the API key. Read-only by design: PropFlow never auto-enrolls these prospects in outreach — a PM has to act. | Not available — no AppFolio account or API key for this customer. | Built and running broadly (confirmed live at Camellia). |
| C. Direct calls/texts to the Clara line | Live — the normal call/SMS webhook, independent of email. | Live — this is exactly what the 970 line already does. | Built, works everywhere. |
| D. A standalone web-form or ILS webhook | Not built. No contact-form endpoint exists anywhere in the app. | Same — not applicable without an ILS presence. | Not built anywhere. |
Key files: agents/clara/lib/email/parse-lead-source.ts, agents/clara/lib/email/process-inbound-message.ts, src/lib/integrations/appfolio/pms-adapter.ts, src/lib/domain/pms/writers/guest-card.ts, docs/adr/0094-appfolio-guest-card-sync.md.
A constitutional fact, confirmed repeatedly in the code: AppFolio's official partner API has no write endpoint for guest cards, notes, tasks, or messages — it only writes leads, work orders, showings, and a few billing objects. The only path for anything guest-card-shaped is browser automation that drives AppFolio's own screen, because PropFlow's plan with AppFolio has no API access for this at all.
| Capability | State at Camellia | State overall | Owner / doc |
|---|---|---|---|
| Sending Clara's reply through an existing AppFolio guest card | Not live. Gated by a property setting that, per the database, is set on exactly one property in production — the Willows — no customer property, Camellia included. | Built and merged (Sep 3–4), off by default everywhere. Proven live at the Willows: a text answered through AppFolio in 1 minute 38 seconds, an email in 54 seconds. | Fede-owned, per situs-group-initiative.html. |
| Creating a brand-new guest card on first contact (a number/caller AppFolio has never seen) | Not built anywhere, Camellia included. | Confirmed net-new, not a regression — found because texting the Willows from an unknown number got no reply: on a PMS-delivered property, AppFolio silently skips texting any number it has no guest card for. A stopgap lets registered teammates get answered over Twilio regardless, as a workaround, not the fix. | Owner: Gera, added 2026-09-07. |
| Reading guest cards (§2, path B) | Live, read-only, via the Data API report — not browser automation. | Broad and live. | ADR-0094. |
| Reading messages/replies inside an AppFolio guest card | Not on — only the Willows has this turned on. Confirmed by an inline code comment: Camellia and Yale are unset. | Built, opt-in, one property. | — |
Bottom line for Camellia: zero AppFolio guest-card writes happen today. Camellia's guest-card interaction with AppFolio is 100% read (the Data API report). The write-back capability exists in code and is proven, but is switched on nowhere except the internal Willows test property.
| Camellia | Western Slope | |
|---|---|---|
| Reading tour availability | A per-property connected calendar, read over Microsoft Graph (delegated OAuth) — this is the live, write-capable path. A Google Calendar option exists but is read-only by design. | Same mechanism. Per the provisioning script, the Outlook calendar was already connected on 2026-09-06 through the standard per-property connect flow — this is not outstanding, contrary to a "calendar connect" framing, unless a different calendar (specifically Kat's) needs to replace whichever mailbox is connected now. Nothing in the repo names "Kat" — that's outside-the-repo context to confirm. |
| Writing a booking | Dual-write: a PropFlow tour record is the source of truth, and an Outlook calendar event is a best-effort mirror (fire-and-forget, retries 3x then pages on failure). No AppFolio "showing" object is ever written. | Same code path — the same tour tools, bound to Western Slope's prototype agent. |
| What Western Slope needs | N/A | Nothing further as far as the repo shows — the calendar is already connected and the tour tools are already bound. The only open question is whether the currently-connected mailbox is the one the client wants as their leasing calendar — a confirmation with Fede/Jason, not a build task. |
Key files: src/lib/domain/calendar/provider/{types,resolve,outlook,google}.ts, src/lib/domain/calendar/sync-tour.ts, agents/clara/lib/agent/tools-leasing.ts, src/app/api/integrations/outlook/{authorize,callback}/route.ts.
| Camellia | Western Slope | |
|---|---|---|
| Outbound email | Sent as camelliaapts@jp-co.com through the connected Microsoft Graph mailbox — the primary leg. SendGrid is only a fallback if Graph is unreachable. | The mailbox is not connected yet — nothing in the provisioning scripts or seed data references a mailbox connection for this property. What's needed: someone with access to Western Slope's own mailbox runs the same generic one-time connect flow. No code change needed — once connected, outbound email automatically routes through it exactly like Camellia. |
| What "connecting the mailbox" grants | Delegated Graph scopes to read and send mail; inbound arrives via a Graph subscription/webhook, not IMAP. | Same mechanism once connected. |
| M365 admin consent | No evidence anywhere in the repo of a tenant-wide admin-consent step — this is standard per-user delegated consent. | Same — unknown whether Western Slope's own Microsoft tenant forces an admin approval prompt; that's tenant-side config on Microsoft's end, not visible from this repo. |
| SMS provider | Twilio, from Camellia's own number, through PropFlow's own Twilio integration — not through AppFolio. | Twilio, from +19708220641 (the 970 line) — the phone registry states explicitly that its SMS webhook points at the same production Twilio handler Camellia uses. Already wired identically to Camellia; no work needed. |
| A2P 10DLC status | Not noted in the phone registry — live data, not in the repo. | The registry states the number is on a verified 10DLC campaign, but that's a static note from provisioning time, not a live check. The actual current campaign status is live data, not in the repo — worth confirming with the Twilio skill before go-live if it matters. |
Key files: src/lib/integrations/email/property-graph-sender.ts, src/lib/integrations/email/client.ts, agents/clara/lib/email/inbox-client.ts, agents/clara/lib/messaging/{dispatcher,resolve-carrier}.ts, agents/clara/lib/messaging/adapters/twilio-sms.ts, config/phone-registry.json.
Model: a PropertyKnowledge record (website/tour links, amenities, concessions, pricing details, office hours, holiday policy, neighborhood, utilities, plus free-text notes for anything the structured fields can't hold). Filled in through an admin screen. There is no "overnight onboarding fleet" in this repo — that claim doesn't hold up; the closest things are a generic onboarding wizard and an AppFolio-specific importer, neither used for Western Slope. Western Slope's knowledge was hand-written directly into the provisioning script.
There's no hard schema gate for "minimum knowledge to go live" — the real mechanism is a per-field readiness check run on every call. If a field isn't ready, Clara doesn't proactively state it and falls back to a live lookup instead of guessing.
Western Slope's current gaps, called out directly in the code (not on a tracker or Trello card — none found): no structured pet policy, empty amenities list, security deposit left as prose because it varies per unit, and office hours that can't represent the Monday lunch closure so it's described in free text instead.
Field reference: the Property/Organization interfaces in src/lib/data/types.ts, DynamoDB-backed, not Prisma.
| Flag | Meaning | Camellia | Western Slope |
|---|---|---|---|
| Test property flag | Excludes from production metrics/some jobs | False/absent — live data, not in the repo | True |
| Owning company record | org_jpco | org_western_slope | |
| Number-to-property routing | Resolves via a hardcoded fallback map instead | +19708220641 | |
| PM-notification fallback email | camelliaapts@jp-co.com | fede@propflowai.co — deliberate, never the client | |
| Missed-call/staff-page recipient | Live data, not in the repo | fede@propflowai.co | |
| Email shadow mode (process but never send) | Live data, not in the repo (presumably off) | True — email is shadowed even though voice/SMS are live | |
| SMS shadow mode | Live data, not in the repo | False — deliberately the one channel left live | |
| Handoff mode (hold for human vs. autonomous) | Live data, not in the repo | Not set → defaults to holding for a human | |
| Per-domain capability stage (leasing/renewals/maintenance) | Absent = fully live by omission (existing properties are never gated) | Leasing live; renewals and maintenance off | |
| Real PMS vs. scraper-only | Real AppFolio account | Public-listings scraper only, no AppFolio account | |
| Messaging delivery (AppFolio write-back vs. own lines) | Unset (own lines) | Not set (own lines) | |
| Company marked as a throwaway/sandbox org | False / "customer" — a real billed customer | True / "sandbox" | |
| Company-level SMS default | Live data, not in the repo | True — this org exists so a person can text the prototype line and get an answer | |
| Which product modules are on | Live data, not in the repo | Only leasing/prospects on; everything else starts off |
How Western Slope was provisioned: two separate mechanisms — a merge-triggered workflow that provisions the ElevenLabs voice agent only (prompt, knowledge base, tool bindings, phone assignment), and a separate hand-run script that sets the actual Property/Organization database row. These are not the same pipeline.
What Western Slope explicitly does not have, by design: still marked test/sandbox, escalation and PM email routed to Fede not the client, no PMS write-back, no autonomous renewal/turnover/listing-publish, and not in the shared production ElevenLabs fleet file — it deploys only through its own dedicated workflow.
fede@propflowai.co for Western Slope; Camellia's point at its own mailbox.Two distinct mechanisms exist, and they're commonly conflated:
Ownership: the whole read/write browser-automation strategy is attributed to Fede in the source document, not Gera. The "first contact writes a guest card" gap (§3 above) — which is owned by Gera — is a different, currently not-built capability: creating a brand-new guest card, not reading an existing one's report or messages. Don't conflate the two.
Contrast with today's email path: the email-notification parser (§2, path A) is what Camellia actually relies on for its ILS leads today; the Data API guest-card report runs in parallel as a second, independent, non-email lead source that's already live — it needs an API key but not browser-automation credentials. Western Slope has neither, since it has no AppFolio account at all.
| Hop | State | Property scope | What's needed to extend |
|---|---|---|---|
| 1. Guest card arrives (lead ingest) | Built, live, broad | Camellia and other AppFolio properties | Nothing — already running |
| 2. Clara replies through AppFolio's own guest-card messaging | Built | Willows only — proven live: a text answered in 1 minute 38 seconds, an email in 54 seconds | Turn on the property setting plus account config per property; two open policy decisions still pending: whether to amend the "never write to AppFolio" decision, and which AppFolio login Clara sends as |
| 3. Detect the prospect's reply inside AppFolio | Built, opt-in | Willows only | Turn the setting on; AppFolio's own "two-way texting" toggle must also be on for that account, or replies never show up to read |
| 4. Clara replies again | Built (same code path) | Willows only | Same as hop 2/3 |
Important caveat: for email specifically, AppFolio does not file a prospect's emailed reply on the guest card — it forwards it to the sending mailbox. So for email, the connected mailbox is the real inbound door, and the guest-card feed only matters for texts. This is account-config-dependent, not universal.
Gaps, plainly: the entire write-and-detect loop is real, tested, working code — but it's switched on at exactly one property in all of production, the internal Willows test line. Nothing in AppFolio's official partner API supports any of this at all; every bit of it depends on holding a logged-in AppFolio browser session for that account.
Is this already how Camellia works today? Yes.
camelliaapts@jp-co.com through its own connected mailbox — SendGrid is only a break-glass fallback if that fails. Not through AppFolio.So: Camellia is already living in exactly the mode Fede is proposing for Western Slope — reply via its own mailbox and its own Twilio number, AppFolio read-only. This isn't a new mode to build; it's the existing default for every property that isn't explicitly flipped to AppFolio-delivery (today, that's nobody except the Willows).
What Western Slope is missing to reach that same state:
In order. Plain English. "Needs the AppFolio key" flags anything that requires the AppFolio credential Fede is still waiting on this week.
| # | Step | Owner | Needs the AppFolio key? | Needs Jason? | Needs Fede? |
|---|---|---|---|---|---|
| 1 | Connect Western Slope's email inbox to Clara (one-time Microsoft sign-in through the standard connect link) | Whoever holds the mailbox login (likely Jason/Kat) | No | Likely yes — someone with mailbox access has to click through it | No, unless he's the one with access |
| 2 | Confirm the connected Outlook calendar (already done 2026-09-06) is the right one for tour bookings — if it's not Kat's, reconnect it to hers | Fede/Jason to confirm which mailbox should own it | No | Possibly, if reconnecting | Yes — confirm intent |
| 3 | Leave the listings source as-is: the public-listings scraper (every 15 minutes, no login) is already the live source and needs nothing further | No one — already running | No | No | No |
| 4 | Leave guest-card creation/writing to AppFolio off — it isn't built (first-contact case) and isn't enabled anywhere except an internal test line; going live on guest-cards-only doesn't require it | N/A | N/A | No | No — nothing to approve, it's simply not part of this launch |
| 5 | Confirm Western Slope's Twilio number's actual A2P/10DLC campaign is still verified (the repo only has a static "verified" note from provisioning, not a live check) | Fede or whoever checks the Twilio console | No | No | Optional confirmation |
| 6 | Fill in the remaining knowledge-base gaps that matter for live calls (pet policy, per-unit deposits, any pricing prose) — no hard gate exists, but thinner knowledge means more fallback-tool answers instead of proactive ones | Fede (content) | No | No | Yes |
| 7 | When the AppFolio API key arrives later this week, that's a separate, optional upgrade — it would add the Reports-API rent roll and the read-only guest-card-lead ingest on top of the scraper, but is not required for "guest cards only, working leasing calls" to go live now | Fede/Jason (getting the key) | Yes, for this step only | Possibly | Yes — his explicit go once available |
| 8 | Nothing about turning this on for real customers is needed here since Western Slope forwards only to fede@propflowai.co today by design — going live still means Fede is the one who sees escalations, not the client, until he explicitly changes that | — | No | No | Yes — his call when to point escalations at the client |
Bottom line: the only concrete blocking task for "just working the guest cards" next week is step 1 — connecting the email inbox. Everything else Fede listed as outstanding (the calendar) already appears done in the repo, and the AppFolio API key isn't actually required for this narrower guest-cards-only launch — it only unlocks a second, redundant listings source and an additional (still write-free) lead-ingest path.
Top rule for this work (Fede, Sep 8): do not break Camellia. Every step below is designed as a no-change for Camellia, proven on the Willows first, soaked overnight, and Camellia moves last.
The short version. Everything Clara writes into AppFolio goes through one browser robot that is built for one company. It knows one web address (JP&Co's), one login (a person's, fede@), one phone number for text-message codes, and one saved browser session. The rest of PropFlow already knows which company a property belongs to and passes that along correctly — right up to the robot's door, where it is dropped. Camellia works today only because the one robot happens to be JP&Co's. Neither Situs Group nor Western Slope can receive a single write until the robot learns to look up "which company, which database, which login" per request. There is no official AppFolio API alternative for guest cards at any plan tier; the browser is the only write path.
Verified facts, each with where to check (queries and code read Sep 8; nothing here is inferred):
| Claim | Evidence | Where to check |
|---|---|---|
| Three AppFolio databases, two login identities | JP&Co and Situs Group both have fede@propflowai.co as the user (one login opens both); Western Slope added clara@propflowai.co | Fede, Sep 8 |
| The robot has one login and no database setting | Its production settings hold one email/password pair plus a same-database fallback pair; no variable names the database at all | Vercel project settings for the browser robot (names only were read) |
| JP&Co's address is typed into the robot's code | 115 occurrences of "jpco" across 66 live-code files; one resolver defaults to jpco; two files hardcode the full JP&Co web address with no override | Robot repo, today's main branch (a1cc6ee): lib/apiClient.ts line 315; lib/appfolioGroundTruth.ts line 13; lib/fetchLeasePdf.ts line 52 |
| Only guest-card messaging and work-order enrichment take the company per request | Those routes require an account id and use it to pick the database; the other ~40 write routes do not | Robot repo: app/api/guest-card/message/route.ts line 131; lib/runWorkOrderEnrich.ts line 612 |
| PropFlow resolves the company correctly, then drops it | The queue that dispatches AppFolio jobs reads the company off the property and refuses if missing; the Lambda that runs the job logs that company but never passes it to the robot | src/lib/integrations/sqs/agent-jobs.ts line 573; lambda/agent-runtime/src/handlers/renewal-l4.ts; src/lib/integrations/appfolio-browser-agent/l4-core.ts lines 125–127 and 3180–3207 (the code's own "STILL NOT MULTI-ACCOUNT" note) |
| Camellia's property record does name its company | pmsAppfolioAccount = {accountId: jpco, subdomain: jpco}; the Willows has the same. The Sep 8 handoff's claim that no property has it was wrong | propflow-prod table, PROP#1773625953462 / META |
| Production has 5 property records; no Situs Group company exists yet; Western Slope is an unconfigured prototype | Full-table query, Count=5; organization list has no Situs row; western-slope-proto has no PMS, owner, or company fields | propflow-prod, entityType index |
| Zero "company not resolved" errors and zero Western Slope write attempts in 30 days | CloudWatch Logs Insights over every lambda and the renewal worker (4.65M records); Sentry, 30-day search | Query text in the session ledger |
| Camellia's real AppFolio writes are few | Last 30 days: 16 renewal-offer runs (last one Sep 3; one error line shows jpco.appfolio.com), 13 renewal-letter fetches, 5 work-order notes. All 372 work-order creates were Willows test traffic. No move-outs, month-to-month, or guest-card writes | Log groups /aws/ecs/propflow-renewal-worker-prod and /aws/lambda/propflow-agent-runtime-prod, tool-log lines |
| Write logs do not say which database they hit | Success lines carry no account or database field | Same log groups |
| Text-message codes are the only staff MFA AppFolio offers | No authenticator app for staff, email codes refused, device remembered 30 days except for users with bank-account access | appfolio.com/help/appfolio-property-manager-login |
| No official API creates guest cards | Not at any plan tier; ILS lead feeds are one-way into AppFolio | AppFolio knowledge base search; PropFlow testing-harness/scenarios/pms-conformance.ts line 121 |
| What Clara writes | Path | Who picks the database | A Situs or Western Slope property today |
|---|---|---|---|
| Renewal offer send / cancel / change term / resend for signature | Temporal renewal workflow → queue → Lambda → robot | Queue resolves the company and refuses if missing; the robot ignores it and uses its one login | Refused if the property has no company tag; if tagged, the write lands in JP&Co's database |
| Work order create / cancel / note | Same queue path (cancel also has a direct path) | Same | Same |
| New lease send (the approve-flow path) | Direct call to the robot | Nobody — no company on the call | Lands in JP&Co's database; also blocked by a hardcoded list of allowed property IDs (45, 7) |
| Move-out record, month-to-month, unit create, move-in | Direct call to the robot | Nobody | Lands in JP&Co's database |
| Guest-card message (SMS/email from the card) | Direct call, company required | The call names the company and the robot uses it | Correct database — but the robot still has only JP&Co's login/session, so it cannot actually open Situs's or Western Slope's; not live anywhere (no property has messaging delivery set) |
| Guest card create / note | Not implemented as a write anywhere | — | Kat's team writes them from Clara's lead task until the robot is per-company |
| Purchase orders | Banned: PropFlow never creates or changes one, enforced by a build-failing test | — | Not an issue |
| Piece | Verdict | The one thing that proves it |
|---|---|---|
| Browser robot (all AppFolio writes) | One company assumed | One login, one saved session, one MFA phone, jpco as the hostname default, 115 jpco literals in live code |
| Job queue that dispatches AppFolio work | Per company, from data | Reads the property's company, fails closed, serializes jobs per company |
| Temporal (renewals, tours, turnovers, collections, and 30 more workflows) | Mixed | One namespace, one worker fleet, workflow IDs and queues never name the company; only 2 of 36 workflows carry the company on their input; 9 activities re-derive it from the property row, 6 have no visible company scoping; every fleet-wide daily scan runs across all companies with only per-property flags as the boundary; no isolation test exists |
| Lambdas (11), the 38 scheduled jobs, email mailboxes, sender identities | Per company, from data | Property and company are read from the event or the property row; a fence test tracks 19 known exceptions |
| Voice / phone | One company assumed (separate lane, listed here only) | One phone-agent id env variable across five call flows; a literal Camellia entry in the phone→property map |
| Ops scripts | Camellia-only guard | The saga migration script refuses Camellia rows unless forced; there is no equivalent refusal for Situs or Western Slope |
clara+<company>@propflowai.co (one mailbox behind them; one real mailbox each if AppFolio rejects plus-addressing — unknown until the Situs invite). Standard onboarding step. JP&Co and Situs stay on fede@ as temporary rows until swapped; Camellia swaps last.The robot already contains a designed-but-unwired per-company config (database, saved browser session, default renewal template, per-property overrides, a per-company kill switch) and its session store, refresh locks, and caches are already keyed by company. Add login, MFA phone, allowed property IDs, category map, and email copy to that config; back it with a real store; seed JP&Co's row from today's exact live values; then route by route make each call take a company and resolve (database, login, session) from the row, keeping jpco as the default until the last caller is converted. On the PropFlow side, carry the company from the queue through the Lambda into every robot call, and put the company on every write log line. Effort: roughly 10 ordered steps, each shippable dark; the mechanical route conversion is the bulk. Risk to Camellia: lowest of the three — each step is a no-op when JP&Co's row equals today's values, and the diff proves it.
Deploy a second and third copy of the robot with their own login, database, MFA number, and session; PropFlow picks the copy by company. Effort: smaller to start, but every JP&Co-shaped behavior (templates, categories, allowlists, email copy) still has to be parameterized or forked per copy, three deployments drift, and the fixes above still have to happen inside each one. Risk: medium — nothing changes for Camellia's copy, but three code lines to keep in step is how the Aug 24 phone-script drift happened. Also violates the "no per-customer deploy path" rule (Constitution IX-A).
Today's default for Western Slope. Effort: none. Risk: none to Camellia; the cost is that Clara is not the system of work for guest cards, notices, renewals, or work orders at either new client, and the audit above still stands unfixed. Acceptable only as the interim while A ships.
jpco, captured from today's live values — not retyped.What it proves, per client, every night: the client's Clara login opens the client's database (reusing the remembered session; a fresh text code only when the session has expired — and if a code is demanded every night, that client's role was given banking access and the night is flagged); the test property reads back with the right company name; a record ID that exists only in a different client's database reads back "not found"; one dry-run write to the test property returns the expected refusal-or-success shape; and every log line from the run names the company. Any cross-talk, or any write log without a company, fails the night.
Built on what exists, not new: the robot's heartbeat already takes a company parameter (defaulting to jpco) and its session-health module has the multi-company extension point written as a TODO; the robot's existing nightly renewal smoke (GitHub Actions, dry-run only, 12:00 UTC) supplies the schedule, secrets, and reporting shape; the portfolio isolation gauntlet in PropFlow supplies the fixture format and the red-before/green-after discipline; the atlas gets one new row. Harness-skill rules apply: seen red first, two clean sweeps before "done," test properties only.
Order: JP&Co first (the Willows exists, proves the harness against the current robot before any refactor, and catches regressions from steps 1–10 the next morning); Situs Group when its database has a test property and a Clara login; Western Slope last, only after Fede's go.
Not started. Building the POC and the harness is a separate ask; nothing in this section changed code.
clara+<company>@propflowai.co with leasing + applications + maintenance + renewals permissions and no accounting/banking; each client creates a "ZZ TEST – PropFlow" property; one text-message number is bought per client login at setup.Side findings, filed to their lane owners, not fixed here: 1,074 failed lease-PDF fetches vs 15 successes in 30 days (Camellia lane); the voice stack's single phone-agent id and hardcoded Camellia phone-map entry (voice lane); the API read/sync path is a separate inspection per Fede.
PR #330 only built the per-account config data model — it doesn't yet route any request by company. So this ran the proof directly against all three live AppFolio databases (JP&Co, Situs Group, and Western Slope), testing every pair, both directions, ahead of the robot being able to do it itself.
What was asserted, and the result:
| # | Check | Result |
|---|---|---|
| 1 | A JP&Co Willows unit does not resolve as JP&Co data on Situs Group | PASS — Situs Group returns "Unit not found." |
| 2 | A JP&Co vendor id does not resolve as JP&Co data on Situs Group | PASS — same numeric id (657) is a completely unrelated real vendor on each database. |
| 3 | A real Situs Group work order resolves on Situs Group (positive control) | PASS — real record, real property name, confirms the session is genuinely live. |
| 4 | That same Situs Group work order does not resolve as Situs data on JP&Co | PASS — JP&Co returns "Service request not found." and falls back to JP&Co's own list. |
| 5 | A JP&Co Willows unit does not resolve as JP&Co data on Western Slope | PASS — the unit's id belongs to a completely different real Western Slope property. |
| 6 | A JP&Co vendor id does not resolve as JP&Co data on Western Slope | PASS — Western Slope has no vendor with that id. |
| 7 | A real Situs Group work order does not resolve as Situs data on Western Slope | PASS — Western Slope returns "Work order not found." |
| 8 | A real Western Slope work order loads on Western Slope (positive control) | PASS — real record, real property name, confirms the session is genuinely live. |
| 9 | That same Western Slope work order does not resolve on Situs Group or JP&Co | PASS — both other databases return "Work order not found." |
9 of 9 checks passed. Three facts worth keeping:
What did not run: the read-only config-drift script (scripts/account-config-diff.ts) — no local Upstash/KV credentials in this environment. A local-tooling gap, not a live-session issue.
The JP&Co robot session stayed warm throughout (heartbeat checked at the start and end of the run).
Program complete for tonight — follow-ups listed.
Status as of tonight:
Plan running tonight — each step ships dark and merges once it's green:
Groundwork done 2026-09-09: the robot can now write a client's record into production from inside production, dry-run by default, and create the client's own browser workspace; a real-property allow-list is accepted only while the client is read-only. The Western Slope dry proof has since run and stopped safely before anything reached AppFolio — the outcome, and the two bugs it found, are in step 3b above. Fede-only, tonight and after: turning anything on for a real customer.
Clara's Western Slope session warmed up by the robot on its own — it logged in and handled the text code by itself, and the session is stored under Western Slope's own key. JP&Co's session was untouched throughout.
19 write flows attempted. All 19 were blocked. Nothing was saved. 11 of the 19 got far enough to reach a real Save button, and every one of those 11 was refused:
Across all 19 flows, the lock refused 107 individual save attempts. Every record checked before and after — the unit, the occupancy, both tenants, the work order, the total unit count — came back byte-identical. Camellia's robot session was checked warm before, midway, and after the run.
15 flows were skipped, grouped by reason: some would contact a human directly (email or text a tenant or prospect); some only exist for JP&Co's test property; the rest couldn't meet a precondition the flow needs — no vacant unit to advertise, no renewal offer on file to cancel, lease template names still placeholders.
Found and being fixed now:
Decision (Fede, 2026-09-09): Western Slope stays read-only. No writes there. The lock stays on.
Follow-ups:
Third client tested read-only, live, with zero damage. The sign-in system got two real safety upgrades today, plus a written playbook so the team stops re-learning the same lessons.
| Item | Status |
|---|---|
| Situs Group rehearsal (read-only) | PASSED — 21 jobs tried, 0 records changed, all 3 real save attempts refused |
| PropFlow names the client on every AppFolio call | Live in production, proved with a real call at the Willows test property |
| Camellia impact | None — zero errors, session stayed healthy throughout |
| Sign-in rate limit + circuit breaker | Merged and live |
| Robot rejects calls with no named client | Live — unnamed or unrecognized calls now fail loudly instead of guessing |
| Login & session playbook | Merged — required reading before touching this area |
| Unexplained JP&Co sign-in (~11:56 AM MT) | Investigated — most likely a routine restart after a deploy, not a break; a safeguard is being added |
The Situs rehearsal. We pointed the robot at Situs Group's real AppFolio database — the third client on this system, after JP&Co and Western Slope — and told it to try 21 different kinds of changes on one property. Every single one was stopped before it reached AppFolio. We read every record before and after: nothing changed, on either Situs's side or JP&Co's. Situs and JP&Co happen to share the same AppFolio login and the same phone number for text codes — this was also the first live test of that shared setup, and it worked as designed: signing into Situs didn't knock JP&Co's session out, and JP&Co double-checked itself instead of assuming its old session was still good.
Why Situs had to sign in so many times today. JP&Co's sign-in stays warm for a full day or more because something keeps knocking on the door every couple of minutes to keep it awake, and its "keep me signed in" cookie gets saved and reused. Situs has neither of those yet — nothing keeps its sign-in warm, and each fresh browser session used today closed before its "remember this device" cookie could be saved, so the next call had to sign in and read a real text code from scratch. That's the root cause of today's repeated sign-ins, not a security problem — a fix (saving and reusing Situs's session the same way JP&Co's is handled) is underway.
What we now know about AppFolio's own sign-in behavior, tested live:
Why AppFolio sometimes "silently" lets you back in and sometimes doesn't. There are three different cookies involved: one that remembers your device so you can skip the text code, one short-lived one tied to a single client's database, and one longer-lived one tied to your overall login. The team tried, back in May, to reuse that longer-lived login cookie to slip back in without a full sign-in — it looked signed-in on screen, but the actual data calls underneath kept failing, so that approach was pulled the same week. Today's finding confirms that call was correct: a full sign-in is still the reliable path.
Two new safety mechanisms, both live:
Fede's decisions today:
Still open:
Engineering references: PR #341 (Situs seed builder), PR #339 (session cookie reuse), PR #336 (shared-login serialization), PR #7436 + #7447 (client-name header, build fix), PR #342 (robot-side hard reject on unnamed/unknown client), PR #346 (sign-in budget + circuit breaker), PR #344 (login & session playbook).
Gera's portfolio architecture design (Sep 6/8) proposes "the customer" (org) as the wall in PropFlow's own database, with a phone line, calendar, or credential hanging on a hook at the org or at one building, found by walking building → org through one resolver. We checked tonight's robot/PropFlow work against it.
No hard conflict, but one gap and one shape mismatch worth a decision:
Who decides: whether the AppFolio login moves into PropFlow's org model or stays a robot-side exception is Fede's call, alongside the rest of the org-model program — not something to resolve by default while building it.
| Item | Status |
|---|---|
| PropFlow names the client on every robot call, refuses loudly if it can't | Live |
| Robot's old "unnamed client = assume JP&Co" fallback | Removed — verified live |
| Sign-in budget + circuit breaker | Live — 3/hour, 8/day per login; 30-min pause on failure, 6-hour pause if it happens again same day; manual stop; fails closed if its own safety storage is unreachable |
| Alerts into Slack | Proven end to end on production |
| Keeping every client's sign-in warm | Live — was JP&Co only, now all of them, every 2 minutes |
| Saving a session right after signing in | Live |
| "Remember this device" (skip the text code) | Fixed for JP&Co — was being dropped, forcing a fresh text code every time |
| Sign-in and session playbook | Merged — required reading |
The incident, caught in testing before it touched a customer. A change made the night of 2026-09-08 had the robot reuse a copied sign-in session that worked for reading data but not for the staff-facing pages writes go through — so writes would have failed. Nobody noticed at first because the automatic health check only ever tested the data side, never the write side. A planned test at the Willows practice property caught it.
Fixed the same night:
Proof it's fixed: a real work order was created and then cancelled at the Willows practice property through the actual write path, end to end, with the fix in place.
Still open:
Engineering references: propflowai #7436, #7447; appfolio-browser-agent #342, #343, #339, #347, #350, #351.
Western Slope goes live on leasing first, on infrastructure that already works. Situs follows on the maintenance line and may use other patterns. Turning anything on at a customer is the team's call.
UI packages, session 007 (2026-09-08, refreshed 1:05 PM MT): four of the five held drafts are merged and on main — the onboarding wizard (#7304, 9:23 AM MT), property page cleanup (#7311, 9:57 AM MT), customers see only their company's modules with Admin staff-only (#7310, 12:03 PM MT), and previews skipping the second sign-in factor (#7325, 12:24 PM MT). The back-office "Customers" screen (#7313) is the one left: re-greening on current main, then held for Fede's word. Fede's remaining calls: #7313's merge word; where the read-only renewal Autonomy line lives; when to add Situs Group. Done: Ulysses and Riverbend deleted; the two Western Slope companies have a written plan awaiting Gera's and session 006's sign-off. Full state, proof, and test plan: the onboarding experience page, section "Where the UI packages stand".
One page. Eight blocks of work, the dependency between them, and every row of work under its block with a status. Statuses come from merged changes and decisions, not from hand edits. Click a bar to jump to its block; use the filters to see only what is open or waiting on a decision, or only Fede's or Gera's rows. Each row names who owns it and what blocks it, so both engineers work from the same list. Sequencing is a proposal; the team sets the order together.
Revert self-booking, robot calls, corrective post. Texting returns when block 5's open-hole count hits zero.
| Status | Who | What, why, and what blocks it |
|---|---|---|
| merged | Fede | Prototype line answers again: revert self-booking, re-provision, robot calls before Fede gets itSep 6, 7:33 AM: the line hung up in one second. The booking tools attached at 11:13 PM need a property id and caller phone at pickup, and the per-call lookup that supplies them was off on this agent. No call was placed after the provisioning run. Fix for today: revert the booking change, re-provision to the 9:59 PM state that had nine real calls, then the robot caller runs six scenarios and every one must pass before Fede calls.added 2026-09-06 · change 7142 · Plan G14 |
| withdrawn | Fede | Per-call lookup switched on for the prototype agent (stopgap, held open today)Turns on the ring-time lookup so the agent gets property id and caller phone like Camellia's agent does. Would make booking work against the umbrella prototype record. Held open on purpose at the time; closed unmerged Sep 6 — superseded by the real-data change (#7297), which turned the call-start hook on for good.added 2026-09-06 · change 7153 |
| open | Fede | Texting on the prototype line returns when the cross-company inventory reaches zero open sitesThe drift guard merged Sep 6 pins 20 open raw cross-company lookups on inbound paths and states texting on +1 970-822-0641 stays off while any remains. Depends on block 2. blocked by block 5 (open-hole count to zero) added 2026-09-06 · Drift guard (merged, inventory only) |
A property belongs to a company. A conversation knows its company. One record says which company or property a number, mailbox or calendar belongs to. Settings resolve property, then company, then default. Company-level knowledge. Almost nothing below can be built before this. Shape open: decision X1 on the architecture page (org, group, property rungs and one binding record) decides how these rows are built.
| Status | Who | What, why, and what blocks it |
|---|---|---|
| open | Gera | Every property belongs to a company, enforcedThe company link on a property is optional today. The roster lookup and the isolation rail need it required so they have a denominator. Every organization that exists was made by a hand-run script; nothing in the product can create one.added 2026-09-06 · Plan G1 |
| open | Gera | Conversations carry the companyA conversation cannot exist without a property (it is half the partition key) and carries no company field at all. Adding the field is small; it is what makes company-first routing and the isolation harness assertable, and it is the one fact that decides between binding at the edge and binding mid-call.added 2026-09-06 |
| open | Gera | A channel binding record: a number, mailbox or calendar points at a company or a propertyInbound is a per-property number map typed as one-number-one-property; outbound is that map flipped, which cannot express a shared number. Mailbox and calendar hang off the property row. Vendors already solved this in May: moved onto the organization with an empty property list meaning all of them. Copy that.added 2026-09-06 |
| open | Gera | Settings resolve property, then company, then defaultThe company settings type exists and nothing running reads it; its field names are duplicated by a platform-wide singleton, so enabling a module for one customer enables it for all. The only shipped three-tier chain is vendor preferences. Gera's Sep 1 design doc has the settings store and precedence rules to reuse.added 2026-09-06 · Plan G1 |
| open | Fede | A company-level knowledge record, and a ruling on what learning may cross homesHours, contact, screening and fees are company-wide and have no record to live in. Knowledge isolation between buildings is a stated invariant in the code, which conflicts with the compounding-learning promise in the vision; the company tier must decide this explicitly.added 2026-09-06 · Plan G9 |
| open | Fede | Decision: where a shared line binds the property (A recommended)Options, pros and cons, effort and the recommendation are on the onboarding plan under 'Decision: where a shared line binds the property'. Four asks for Fede, two answered: confirm A or pick B (open); one Twilio number for the portfolio test line (done — bought Sep 7 under his standing approval, #7285); nightly slot for the harness red view (open); the knowledge-sharing ruling (answered Sep 8: separate companies, separate knowledge, no cross-reads).added 2026-09-06 · Decision on the plan |
Voice pickup, text webhook and mailbox routing bind the company, then the person, then the home. Outbound number from the record.
| Status | Who | What, why, and what blocks it |
|---|---|---|
| open | Fede | Voice ring-time binds the company, not a buildingThe pickup webhook resolves the dialed number to one property and stamps it on the call; slots, units, upcoming tour, after-hours flag and transfer numbers are built from that one property. A company line has nothing to stamp. Half exists: the company id is already stamped on every call, and there is a path that adopts the caller's own property when the number maps to nothing. blocked by block 1 added 2026-09-06 · Plan G14 · Architecture options |
| open | Gera | Inbound text resolves the company first, then the person, then the homeThe Twilio webhook turns the dialed number into one building and derives the company from it. Two properties listing the same number collapse silently to whichever the database returned last, with no log. blocked by block 1 added 2026-09-06 · Plan G3 |
| open | Fede | A company mailbox routes leads by the address in the AppFolio lead email instead of dropping themTwo properties claiming one mailbox means every inbound email is dropped on purpose. The AppFolio guest card names the home in its subject; that line is never parsed, and the one hint that is parsed has no reader. Cheapest high-value fix in the audit. blocked by block 1 added 2026-09-06 · Plan G2 |
| open | Gera | Outbound texts pick the sending number from the record, never from the inverted inbound map54 non-test call sites use the old number lookup, which falls back to a test property's number; 11 use the hardened resolver. Prospect cadence, tour confirmations and post-call receipts are among the 54. blocked by block 1 added 2026-09-06 |
| open | Fede | A first-time texter or caller on a PMS-delivered line gets a prospect and a guest card, so the PMS can answerFound Sep 7: a brand-new number texted the Willows line and got nothing back. The line hands every outgoing text to AppFolio (PMS delivery, live there since Sep 3), and AppFolio skips any number it has no guest card for — no error, no reply, no conversation. A first-time contact on a PMS-delivered property is unreachable by construction, and that is exactly who a leasing line exists for. The fix: when a number the PMS has never heard of texts or calls, create the prospect and write the guest card, then let the PMS reply. Fede confirmed the intent same day, and that it was never built: "i didnt add this new prospect -> new guest card logic" and "same would apply to a new phone call, we should write a guest card." Not blocked by block 1 — the Willows is a single property; this is the PMS-delivery lane itself. Stopgap already up: bench codes answer straight over Twilio for registered teammates.added 2026-09-07 · change 7274 (stopgap) · the find, in Slack |
Voice tools take the home from Clara. Pickup context per home. The work-order tool gets a property. Thread matching survives a mid-call choice.
| Status | Who | What, why, and what blocks it |
|---|---|---|
| open | Fede | Voice tools take the home from Clara, not from the pickup stampNine tool behaviors break on a shared line, all for one reason: property_id bound to the pickup stamp. The text-channel tools already have a field Clara fills by talking, backed by a resolver that matches a home against the company's roster and refuses without a company; 27 of 89 workspace tools already take the property as a normal field. Voice schemas expose neither that field nor the portfolio search tool. One hole to close: the resolver accepts a model-supplied id with no ownership check. blocked by block 2 added 2026-09-06 · Plan G14 |
| open | Gera | Injected prompt context becomes company-level plus per-home on demandSlots, units, pricing, hours and transfer numbers are computed once at pickup and cannot follow a later choice of home; after a mid-call bind the tools return the right home but the prompt still describes the umbrella. Needs a refresh path or per-home tool reads. blocked by block 2 added 2026-09-06 |
| open | Gera | The work-order voice tool gets a property fieldcreate_work_order takes unit, title, category and priority and no property; a resident of the 'wrong' house is deliberately discarded as an unknown caller at pickup. Both must be relaxed to company-equality. blocked by block 2 added 2026-09-06 |
| open | Gera | Thread matching on a shared line survives the home being chosen mid-conversationCanonical thread matching hard-filters on the destination property, and the tool dispatcher explicitly refuses to re-point a conversation's property mid-call; both were added after real leaks. Re-pointing is a partition move because the property is half the key. Needs its own rule and a repository primitive. blocked by block 2 added 2026-09-06 |
Listings importer, one company calendar with one token, the home's address on every message, travel gap, knowledge in two tiers, leads under the person.
| Status | Who | What, why, and what blocks it |
|---|---|---|
| open | Fede | Portfolio listings importer: one feed, homes attributed by address, units createdListings sync is a hard-coded array with only Camellia's feed; it never creates a unit. Western Slope's feed covers every home; nothing imports it. blocked by block 1 for the importer, calendar and knowledge added 2026-09-06 · Plan G4 |
| open | Fede | One calendar serves many homes without the token going stale on siblingsThe calendar token lives on each property row and every refresh writes a new token onto only one of them; a shared calendar copied onto many rows silently invalidates the others within about an hour. Calendar at the company level, one token. blocked by block 1 for the importer, calendar and knowledge added 2026-09-06 · Plan G5 |
| open | Fede | Every message prints the home's address, not the management officeOnly the tour confirmation knows a scattered-site home has its own address. Calendar invites, the voice prompt, application emails and outreach footers print the office. A unit has no address field, so the umbrella stopgap stuffs the address into the door-number field.added 2026-09-06 |
| open | Fede | Tours 30 miles apart are not booked back to backNothing knows two homes are far apart. Week one: a fixed minimum gap between tours; later, distance.added 2026-09-06 · Plan G5 |
| open | Fede | Finish the lead migration: leads under the person, indexed by company, property optionalAlready true for the inquiry record and the dashboard, funnel and tours roll-ups. The remaining leasing paths still require the property up front. blocked by block 1 for the importer, calendar and knowledge added 2026-09-06 |
The portfolio harness (built Sep 6, 22 red cases). Open cross-company lookups to zero. The structural rail.
| Status | Who | What, why, and what blocks it |
|---|---|---|
| merged | Fede | Portfolio harness: a seeded company on the real data layer that reproduces every one-channel-one-property failure as a red caseBuilt Sep 6 on the shared skeleton: a seeded fake company on the real data layer (8 scattered-site homes, one shared line, mailbox, calendar, hours) plus a second company whose people share phone digits. 22 cases red on today's code, each for its declared reason, two identical sweeps. Strongest finding: a person who exists at both companies calling the unmapped front door gets bound to the OTHER company's home. Registered in the Harness Atlas. Merged Sep 6 (a bench, not a fix). The portfolio test line the six robot-call scenarios needed was bought Sep 7 (+1 720-807-1724, #7285, on Fede's standing pre-approval for numbers); its ElevenLabs import landed Sep 9 via #7491 (import only, no agent bound); the six runs wait on an agent being bound to that line. robot scenarios: number bought, registered and imported; waiting on an agent bound to the line added 2026-09-06 · change 7164 · Plan G13 |
| in progress | Gera | Structural multi-tenancy: the company resolved once at the edge; the drift inventory to zeroStrict isolation between customers (Fede, Sep 6 thread: "we can get in trouble if we leak information between clients") and Constitution VII.2. The Sep 6 drift guard is the countable inventory: 20 open sites. The first conversion was backed out because failing closed disabled the gas-leak page, so this is not mechanical. Depends on block 2. blocked by block 2 added 2026-09-06 · Plan G12 · Drift guard (merged, inventory only) |
| withdrawn | Fede | Proof of concept: bind the company at the edge, the property when the caller picks a homeBranch closed unmerged, never provisioned (kept for the X1 decision). Ring-time returns the company with no property and does not fail; tools accept a home resolved within the company; Camellia unchanged.added 2026-09-06 · change 7160 |
| withdrawn | Fede | Proof of concept: bind the property mid-call once the caller picks a homeBranch closed unmerged, never provisioned (kept for the X1 decision). A bind-property tool resolves a home within the company and re-points the conversation; 113 tests green. Found that re-pointing is a partition move, that pickup context cannot follow the bind, and that the voice platform sets call variables only through per-tool assignments, first use for us.added 2026-09-06 · change 7159 |
Work orders land on the right home. AppFolio account routing fixed, its own fix outside the options.
| Status | Who | What, why, and what blocks it |
|---|---|---|
| open | Gera | Work orders can be written into the wrong customer's AppFolioThe queue gate demands an account id, the handler drops it, the runner payload has no field for it, and the browser runner is one global address. Must be fixed before any AppFolio work-order write is armed for Situs or Western Slope. Outside all three options. blocked by blocks 1 and 3 added 2026-09-06 |
| open | Fede | Maintenance intake across channels lands on the right homeDepends on the work-order property field (block 3) and company-first pickup (block 2). Transfer numbers and after-hours rules at the company level. blocked by blocks 1 and 3 added 2026-09-06 · Plan G8 |
Voice tool workspace cleanup; provisioning reuses by name.
| Status | Who | What, why, and what blocks it |
|---|---|---|
| open | Gera | Workspace tool cleanup: 50 of 89 voice tools are attached to no agentCounted Sep 6 from the voice workspace: 89 tools, 39 attached to one of 26 agents, 50 orphaned; the leasing tool set exists three times. One reviewed sweep, then look-up-by-name in the provisioning script.added 2026-09-06 |
Owner: Fede (leasing) (reassigned 2026-09-08: Fede owns leasing, Gera owns maintenance) · added 2026-09-07 · confirmed intent with Fede (Slack, Sep 7) · tracked as an open Fede row in block 2 above
Found Sep 7 by texting the Willows line from an unknown number: nothing came back. On a PMS-delivered property, every outgoing text is handed to AppFolio, and AppFolio silently skips any number it has no guest card for. So a first-time texter — or a first-time caller — is unreachable by construction, and that is exactly the person a leasing line exists for.
The rule this section pins: a number the PMS has never heard of that texts or calls a PMS-delivered property becomes a new prospect, and we write the guest card, so the PMS can text them back. Fede (leasing) owns building it.
Confirmed gap, not a regression. Fede, same day (Sep 7): "i didnt add this new prospect -> new guest card logic" — so this logic never existed; it is net-new work, not something that broke. And "same would apply to a new phone call, we should write a guest card" — a first-time caller gets the same treatment as a first-time texter. Context: the find, in Slack · #7274 (stopgap: bench codes answer over Twilio for registered teammates, whatever the delivery setting).
Lane: Clara sends and reads texts and emails through the PMS · session 002 · updated 2026-09-08 · nothing here is on at a customer
Why this lane exists. Situs asked that Clara's texts and emails be written back into AppFolio, and agreed Clara can act as a named AppFolio user. So a property can be set to hand Clara's messages to the PMS instead of sending them from our own lines. Western Slope does not need this in week one: its leasing v1 runs from the mailbox (section 8).
Live today: only at the Willows test property. Checked on the production database this morning: exactly one property carries the setting, and it is the Willows. No customer property sends or reads through AppFolio.
Built and merged Sep 3–4, off by default at every property:
Pending, no decision needed:
Needs Fede (recorded, not being re-asked):
Related from the same session, live at Camellia since Sep 5: AppFolio rental applications with no phone and no email now import instead of being silently dropped, and their contact details attach later when the office adds them. It matters for any AppFolio-synced client. Still open: the guest-card importer drops contact-less cards, and there is no reader for AppFolio's application or screening emails.
This section is the audit's evidence and a recommendation for the binding-mechanism half of X1. The architecture itself, including the org, group and property rungs, is being decided on the portfolio architecture options page. Nothing here is decided.
Proven Sep 6, 7:33 AM: the prototype line died in one second because its booking tools required a property at pickup and a company line has none. Three options were evaluated the same day with five audits (470 cited rows), two proofs of concept on branches (never merged, never provisioned; pull requests closed pending X1), and a reproduction harness. Reports: voice · text and email · leasing · renewals, maintenance, turnovers · data model and settings · Gera's portfolio work · proof A · proof B · harness · how Situs is set up.
| Option | What it is | For | Against | Effort (estimate) |
|---|---|---|---|---|
| A. Company at the edge ★ recommended | Pickup binds the company; the property is bound when a resident is recognized or a prospect names a home. Property-scoped tools take the home as a field Clara fills, resolved against that company's homes only. | Half already exists: the company id is stamped on every call, the text-channel tools already take a spoken home and refuse without a company, leads are already stored under the person by company, vendors moved to the company in May. One shared resolver gave all ten leasing tools company scoping in one edit. Per-property prompt blocks blank themselves with no property. Proof: 570 tests green, red on old code. Reuses Gera's Sep 1 design almost wholesale. | A model-filled property id is new trust surface: every property-taking tool needs an ownership check it never had (one was missing and is now added in the proof). The conversation record has no company field; the durable fix touches every channel. The real remaining work is the prompt: teaching Clara to ask which home. Thread matching filters on property and must be relaxed to company-equality. | 6–9 engineer-days, most of it prompt work (proof A's estimate). |
| B. Bind mid-call | Pickup stamps an umbrella property so the call connects; a bind-property tool re-points the conversation once the caller names a home; existing tools stay unchanged. | Small at the tool layer (two files, no edits to the shared dispatcher). One record, no second store. Rollback is un-provisioning one tool. Fails closed by construction. | Re-pointing a conversation is a partition move (the property is half the key) and leaves a duplicate row behind; nothing in the repo does this today. The first turns are spoken from the umbrella's context, and injected context cannot follow the bind. A known resident is unrecognized until they name their home. Any tool called before the bind answers about the umbrella. Setting a call variable mid-call is first use of a platform feature nobody has exercised; whether a start-time variable can be overwritten is unconfirmed. | 5.5–10.5 days for the sizable parts, excluding mid-call context refresh, unsized and could dominate (proof B's estimate). |
| C. Umbrella property (today's stopgap) | One property record holds every home as a unit. | No code. Collapses 45 configuration rows to one. One knowledge row, one daily digest, an honest partition. Right for a demo. | A unit has no address, so the home's address lives in the door-number field; scattered-site homes are defined by their addresses. Metrics collapse to one meaningless building. Every lead written this way lands in the umbrella partition and costs more to re-key the longer it runs. Entrenches the wrong-tier pattern the new drift guard was written to catch. | 0 now; a migration later. |
This audit's recommendation into X1: A, with C kept only for the prototype demo, and B's one good idea folded in (the org-scoped address resolver, which A already has). Reason in one line: A finishes a migration the codebase is already halfway through, and B invents a storage primitive to avoid finishing it. Out of scope for now: renewals and turnovers. The audit covered them for completeness (39 rows, zero critical for the first version) and they are not on the roadmap. Two things are outside all three options and need their own rows on the roadmap: work orders can be written into the wrong customer's AppFolio (account id dropped between the queue gate and the runner), and nothing knows two homes are far apart.
What was proven today, and what was not. Proven: both shapes work at the code layer with red-before/green-after tests; Camellia's single-property path is unchanged in both; the harness reproduces 22 assumptions red on today's code, including a real cross-company bind at pickup for a person who exists at two companies. Not proven: any live call on either shape; the prompt that asks "which home?"; mid-call context refresh on the voice platform. The harness's six robot-call scenarios wait on a portfolio test line, which is a purchase.
Decisions for the team are listed once, in section 12 (X1, X7, X8, X9), not here. In short: (1) the binding shape, inside X1. (2) Approve one Twilio voice number for the portfolio test line — done Sep 7: +1 720-807-1724 bought and registered under Fede's standing approval for numbers (#7285); the ElevenLabs import and the six robot runs are the remaining work, not a decision. (3) Should the harness's red view get a nightly slot? (4) Ruling on knowledge: today the code guarantees what Clara learns at one building never surfaces at another; the vision promises compounding. Company-level knowledge needs one of: company facts shared, home facts isolated (recommended); everything isolated; everything shared.
Line: +1 970-822-0641 · updated Tuesday 2026-09-08, 8:40 AM, refreshed 1:05 PM MT from the merged pull requests · verified against the live agent this morning, not from memory · rows tracked in block 0 above
Live now (read back from the agent at 8:30 AM). Since Monday afternoon the line runs on real AppFolio data instead of Saturday's hand-captured sheet: a live listings lookup plus the three production tour tools (book, reschedule, cancel), so a tour request writes a real tour and a real guest card against the home the prospect asked about; the call-start hook that feeds those tools is on. Kept from the weekend's rounds and still in the prompt: fast qualification (answer once bedrooms, area, and budget are known), at most two homes per answer, no counts or bedroom-mix recitals, neighborhoods only as labels, caller ID used silently, no volunteered missing fields, Spanish end to end, maintenance transferred to Western Slope's 24/7 maintenance line (English or Spanish leg), closed-hours "talk to a person" takes a message, in-hours "talk to a person" transferred to their leasing desk through our bridge that dials the main line and presses 2 (probe-verified after hours) — superseded at 12:40 PM MT, see "Built and shipped dark" below. Overnight, session 008 shipped and confirmed on the live agent: the "re-read the caller when a turn times out" setting the fleet got on Sep 4 (root cause of Jay's 9-second silences and repeats), two same-street homes booked as one visit instead of two an hour apart, a booking bug that silently dropped the second home, and the expired Labor Day reminder removed.
Not on, by decision. Inbound texting on the line (off until the remaining cross-company lookups are closed: 20 open sites, counted by a guard that fails the build if one is added).
Needs Fede: nothing. Per today's rule (go-aheads and scheduling are defaults, not decisions), the four follow-ups above are in progress. The in-hours leasing-transfer test is cancelled and the leasing bridge is retired at forwarding go-live: once Western Slope forwards their main line to Clara, a bridge that dials that main line and presses 2 would loop back into Clara. The replacement is direct lines from the team directory Jason is sending (names, direct numbers, roles): "talk to a person" during office hours will transfer to a direct number read from the property record, shipped dark, with message-taking as the behavior until the record carries a number. No customer contact from us.
Built and shipped dark, 2026-09-08 — the replacement transfer. The change described in the paragraph above is now in the code, switched off. During office hours, a caller who asks for a person on a leasing or general matter is put through to a direct desk number kept on the property's own record, instead of through our number that dials their main line and presses 2. That old bridge is gone from the phone agent, because it stops working the day Western Slope forwards their main line to Clara: dialing that line would hand the caller straight back to Clara. The number itself is still set up at our phone provider and nothing points at it, so no caller can reach it; switching it off for good is a separate step we will record when we take it. The desk number is deliberately left blank right now, and [Corrected 2026-09-09, 10:25 PM MT: until tonight that message reached nobody. #7497, merged 9:29 PM, binds it to the existing missed-call email path so it goes to the address the property's contact setting names — the same path Camellia uses, no new tool or sender. It is shipped dark and must stay dark until Fede says otherwise; merging it re-provisions the live 970 agent automatically. And it was not the only such path: #7535, merged 11:29 PM, found the same hole after hours — a caller asks for a person, Clara promises the team will get back, the system tries to page a human, the page fails, and the promise reaches nobody. That one is now emailed too, also dark. So on both sides of the office day, "we'll get your message to the team" was a promise the system could not keep, on two independent paths, found four hours apart. The same change also removes the in-hours transfer into Western Slope's own leasing tree, per Fede's ruling. The maintenance transfer is untouched.] blank is a safe, designed state rather than a gap: with no number on the record the transfer cannot happen, and a caller who asks for a person during office hours is told the true, warm thing — "I'll get your message to the team right now, they're in the office until four" (until three on a Friday) — and Clara takes the message. She never says she is unable to transfer them, and she never mentions a desk line or a number to a caller. Everything else is unchanged: maintenance still transfers around the clock, and outside office hours it is still a message. How the number gets set once Jason's directory arrives: we pick the leasing desk's direct line out of the team directory (names, direct numbers, roles) and write it onto the Western Slope property record with the existing setup script — it prints what it would do first and only writes when told to, and it refuses anything that is not a properly formatted phone number. It must be a direct line that rings a desk, never their published main line, or the loop comes back. Writing that number changes what a real caller hears, so it is a switch-on that waits for Fede's explicit go and gets its own line on this page — not part of shipping the code. Verified on the live phone agent the same afternoon: the only two numbers it dials are the two maintenance lines, the third destination is now the property record's desk number, the old bridge number is gone from the agent entirely, and the greeting is word for word what it was. Two things to check before the number is switched on, not after: first, make one test call once a number is on the record and confirm the transfer actually happens — this is the first time we have made a transfer depend on a value read at call time, and if the phone vendor does not fill that value in the way we expect, the transfer would quietly never fire (today that failure is harmless, because it means a message gets taken). Second, Clara still reads their main number, 970-434-7000, out loud in three places as a fallback when a transfer fails; once that line is forwarded to her, reading it out sends the caller back to her, so we need a decision on what she says instead. Done the same day (2026-09-08, #7374 merged 12:56 PM MT): Clara no longer reads their main number out loud anywhere — when a transfer fails she takes the message and says the team will get it, adding when the office is next open if it is closed, verified on the live agent with the greeting and all three transfer destinations unchanged.
Tuesday 2026-09-08, 1:15 PM — follow-ups, and one finding Fede should see. Shipped to the line: streets from the neighborhood list can no longer be quoted as homes (the same hazard was found in six more places and removed; #7368, merged 12:40 PM MT), and the second home a caller books now actually gets booked (#7372, merged 12:31 PM MT); before today the platform remembered "a tour was already booked on this call" without recording which home, so a second booking never reached the database while Clara told the caller both were confirmed. Found by the robot call meant to prove two-home booking. Jay's two Tuesday tour requests from Monday: only one exists in our records, and it carries no address. His call ran before the booking tools were live, so nothing was written during the call; the after-call sweep creates one tour per call and could not match the spoken address. Nobody at Western Slope can see it: no logins for their company yet, the property is flagged as a test property, no calendar event, no AppFolio record. If those had been real prospects, both would have been dropped. Still in review (#7369, reviewer asked for changes, round 4 answered): "cancel my tour" when two tours are booked now asks which one, or both, by a name that belongs to the tour (its street or unit), not its position in a list. The two live proofs (two-home booking, cancel both) run after that merges.
Tuesday 2026-09-08, 3:00 PM. "Cancel my tour" with two tours booked: behavior complete and covered (asks which one, or both; a tour is named by its own identifier plus date and time, the address is only a spoken label) but still in the review queue after eleven rounds, most of them about how the tour is named; merges automatically on a clean verdict. The two live proofs (a two-home booking writes two tour rows; cancel both) run right after that merge, or tomorrow if the review runs on. Also live today: Clara never hands out a callback number on a failed transfer (their main line would loop back to her once forwarded), and in-hours "talk to a person" dials a leasing desk number from the property record, which is unset until Jason's directory arrives. New defect logged, not fixed: a repeat caller who gives a new name still gets the old stored name in their confirmation text.
Tuesday 2026-09-08, evening — the four follow-ups, and what the test calls found. Three of the four are done and live on the line. Clara no longer offers a street as if it were a home: the neighbourhood list she reads now maps an area to street names only, with no house numbers and no rents, and one rule says plainly that a home is real only if it is in the live listings. Six other places in her script were teaching her an answer with a hand-typed address or rent from the deleted 5 September snapshot, including the Spanish version — all gone. (On "372 Ember Lane" specifically: it is genuinely available today at $2,825, so quoting it is now correct; the bug was that she was reading it off a geography list back when it was not.) A caller who books two homes on one call now gets two bookings. A check meant to stop Clara re-booking the same tour was remembering only "a tour was booked on this call" and not which home, so the second booking was silently dropped — and it answered "success", so she told the caller both were confirmed. That was found by the first test call, not by reading code. "Cancel my tour" when two are booked now asks which one. It used to cancel whichever was soonest and say "your tour is cancelled", leaving the other on the calendar with nobody aware. She now names both visits back with home, day and time and asks which one or both; "both" cancels both, and she does not say they are cancelled until the second one actually is. Proven on a live call: two homes booked, "actually, cancel my tour", she asked, the caller said "both", both were cancelled, and production shows both rows cancelled with their calendar holds released and the guest card cleared. Nothing was left behind.
Tuesday 2026-09-08, late — the same-street fix is proven on a live call, and it uncovered one more gap. Booking two homes on one street now works the way it should: Clara names both by house number, books the first at the time the caller picked, and the second one moves itself to the next opening — the caller is asked once, not twice, and she reads each home back with the time it actually holds. Two visits were written for real, back to back, nine thirty and nine forty-five, one trip. But the text the caller receives names only one of the two homes — the later one — so the home they arrive at first is the one they have nothing in writing about. The office gets a correct email listing both; only the message to the renter is short. That is now the top item on this line, because it is the same shape as the two bugs fixed today: what she says and what actually reaches the person do not match. (The wrong-name greeting is still there too — three calls running, the caller gives a name and is greeted by an older one already on file.)
What the calls found that is still open, and needs a decision. (1) Two homes on the same street still cannot both be booked. Her script says two homes on one street are one visit at one time — but the calendar refuses the second booking as a clash with the first, every time. The two rules contradict each other, so one of them has to change: either same-street pairs get two times, or the calendar stops treating one visitor's two homes as a conflict. (2) She said "confirmed" over a booking that had failed. When that clash came back, she was given real alternative times to offer and instead said "you're all set for both homes" and added that the team would confirm the second one. That is the same class of problem as the two fixes above — telling a caller something that is not true. Fix merged 4:08 PM MT (#7381), and only half of it is live: a home counts as confirmed only when its own booking succeeded, so a clash is offered as alternative times instead of being announced as booked. The prompt half reached the line on merge through its own provision lane; the booking handler and listing-resolution half needs a production deploy, and production has not deployed since 3:53 PM MT because main is red. That window closed at 5:57 PM MT: main went green at 5:46 and production deployed at 5:57 on a commit that contains this fix, so both halves are live. The red was not this change's fault. Main went red at 3:47 PM MT on #7380, one commit earlier: a new script, scripts/merge-shadow-tenant-rows.ts, imports the raw Dynamo helpers module outside the F7 exception table (the ADR-0079 eslint-ban evasion surface). Those two checks are push-only, so #7380 was green on its own pull request and could not have caught it. Fixed by #7397 (merged 5:34 PM MT — it also stopped the guard over-reporting, since only one of the two lines it flagged was a real violation) together with #7403 at 5:46, which greened main. (3) She greets a caller by the name already on file rather than the one they just gave. Both test callers gave a name and were greeted by an older one from a previous test on the same number, and the confirmation text used the old name too. Not a bug — a switch that is off. The product already replaces the stored name with the one given on the call, behind a per-property setting that is off by default and off here; #7382 (merged 3:14 PM MT) added two test files pinning that rule and changed no behavior. Turning it on for this property is an activation, one dry-runnable script line, and it is on Fede's list above. Even then the very first greeting still reads the record before any tool has run, so that one sentence keeps the old name. (4) Jay's two Tuesday tour requests: only one exists, and it has no home attached; the second exists nowhere. His call ran before the tools were switched on, so nothing could have been written during it, and the after-the-call sweep that rescued the first one only ever creates a single tour and leaves the address blank when it cannot match what was said. Nobody at Western Slope can see any of this yet — they have no logins, the property is still marked as a test property, unconfirmed tours never reach a calendar, and nothing is written into their AppFolio.
Tuesday 2026-09-08, 5:00 PM — two items for Fede, one incident. (1) The confirmation text greeting a repeat caller by an old name is not a bug but a switch: the product already lets a name given on the call replace the stored prospect name, behind a per-property setting that is off by default and off here. Turning it on is an activation: one dry-runnable script line, recorded in the handoff file. (2) The false "confirmed for both homes" from today's proof call is fixed in a pull request going through review; until it lands, a caller asking for two homes at one time can be told a tour is confirmed when it is not. Incident, not this lane's: the main branch has been red since an unrelated afternoon merge, and production has not deployed since the cancel-both change; that fix will wait behind it.
Tuesday 2026-09-08, 11:59 PM — day closed. The last gap from the proof calls is merged: the caller's confirmation text now lists every home booked on the call in time order ("367 Ember Lane at 9:30, then 374 Ember Lane at 9:45"), virtual stops keep their call-you wording, homes at other properties never enter the text, and every single-home text, Camellia and Yale included, is unchanged character for character. Live in production as of 7:06 PM MT — confirmed: it merged at 6:56 and the 7:06 deploy is on a commit that contains it. A live two-home call to prove the text end to end is owed tomorrow (today's robot budget is spent). Open for Fede: the per-property switch that lets a name given on the call replace a stale stored name (one script line in the handoff). Open for a card: the test-harness cancel helper needs the property id or it silently leaks test tours under the new fail-closed cancel rule.
Wednesday 2026-09-09, 12:45 AM — checked against Gera's portfolio design. The design's nameplate (a phone number names the customer, the home is chosen by talking) is the same shape as the shared-agent decision and the isolation rule, so no conflict there. Two of this lane's stopgaps do conflict and are marked on the go-live tracker: the leasing-desk number lives as a field on the building row and should instead hang on the company as a hook; and the 970 number pointing at a stand-in building is exactly what the nameplate replaces, along with the copied test calendar and per-property switches on that stand-in. Nothing in the tours, cancel, or receipt work is affected.
Records: ~/westernslope-data/HANDOFF-2026-09-05-night.md (weekend state; superseded Monday by the real-data change), proto-defects-2026-09-05.md (26 weekend defects, all fixed or decided), robocalls-2026-09-06.md (every robot call with quoted grades). Monday's change and 008's fixes are described in their pull requests on main.
Owner: Fede · updated 2026-09-08 · three property-manager reviews applied; knowledge review gate live on propflowai.co
Live: Clara activated in Western Slope's AppFolio; full read-only pull done Sep 7; operations read + coverage on the new-customer-onboarding-experience page; reusable AppFolio onboarding tool + Claude Code skill merged and handed to Gera; password-protected knowledge review page live on propflowai.co (same gate as demo/proposals), seeded for Western Slope; three independent property-manager reviews applied; link not yet sent.
Pending: Jason's API upgrade + key (promised Sep 8); tool's later phases to be run on a test database; six overstated catalog endpoints to clean.
Needs Fede: send the review link + password to Jason, Jay, Kat; his drafted reply to Jason is unsent in Gmail.
Decided: knowledge sharing — separate companies, nothing shared (Fede, 2026-09-08); knowledge review = table/page reviewed with the customer, no product write yet (Fede, 2026-09-07).
Owner: this lane, session 006 · updated Tuesday 2026-09-08
Live on production. The stopgap that stops one company from seeing another company's data: a company admin now sees only their own company, proven by logging in as an outside customer. Settings only returns the caller's own company. The Settings page now renders correctly for a company that has no properties yet (Gera's rewrite). The button on the sign-in link now lands inside the app — the broken-page error is gone.
Pending, on us. Two Settings changes are built and waiting on Fede to look at them: the Company Name field comes prefilled from the company's own record with no error shown on load, and the customer-facing Billing card shows only the unit count. A typed 6-digit sign-in code is drafted; its sign-in-then-bounced-back bug is fixed (12:05 PM MT) and it still needs a real inbox read to prove the code lands. A second sign-in factor using a passkey or a text code, with no recovery codes, is drafted and its live walkthrough is posted (12:06 PM MT, two screenshots) — it now waits only on Fede's word. Also on main since this morning: company admins manage their own team from Settings (#7339), and the five platform-wide switches (texting, email, shadow modes, vendor mails) refuse a write from any customer role (#7365) — a customer could previously silence every customer's texting from a browser console. Work on isolation step one starts this week. Before Tuesday we still need to check how Camellia's AppFolio writes resolve to a database, so the Western Slope import instructions are right.
Found overnight. In Settings, "Connect AppFolio" saves the key but never imports the properties behind it — so Tuesday's AppFolio connection for Western Slope will go through the onboarding wizard instead. Situs Group has no company record of its own yet (Yale 25 Station currently sits under JP&Co's company), and it needs one before any Situs person can be given a login.
Needs Fede. Approve the two Settings changes above. Approve the two-step isolation plan and say who builds step one — our recommendation is Gera, handed the entry-point inventory and test suite this lane already built. Decide when Jason gets his invite — our recommendation is Tuesday, after his properties are imported. Paste the Stripe setup email to Sean (it's drafted and ready).
Full detail: the onboarding experience page (billing decision, Tuesday AppFolio runbook, backlog) and the isolation architecture page (the isolation plan).
| Western Slope Property Management | Situs Group | |
|---|---|---|
| Who | Jay Taylor (PM, Grand Junction), Jason Fish (partner, Vail). Sister company of Situs; ~3 staff. Whether a virtual assistant works leads is unconfirmed (the VA notes we have are Situs's). | Hugo Weinberger (President), Noam Ashter (Managing Partner), Eileen O'Malley (VP Ops, day-to-day). |
| Size | ~250 doors (Jason, Sep 4 call). Scattered-site: single-family, townhomes, new duplexes, small apartment buildings across Grand Junction, Fruita, Clifton, Ridgway. Some commercial/industrial/medical out of scope. | 645 active residential units (Fede). ~30 small buildings in Lakewood, Denver, Arvada. ~70% Spanish-speaking families. |
| Commercial status | Proposal sent Sep 4; Jason Sep 5: "all looks good," signs on a realistic timeline. Leasing first, then maintenance, then renewals. Setup $3,500. | Proposal sent Sep 4; agreement in progress. Maintenance line (replace MCC) first, then leasing, then renewals. Setup $4,000. |
| Microsoft 365 — the path Camellia runs on today. Works. | Google Workspace — sign-in exists, sending and inbox reading do not. Use the Yale pattern (forward to a propflowai.co address) for Phase 2. | |
| AppFolio | Upgraded off Core on 2026-09-09 — tier still to confirm (Plus or Max), Reports API key arriving this week. Until the key lands the rail is unchanged: clara@propflowai.co user + logged-in session + public listings feed, which is enough for leasing. Once the tier is confirmed, the guest-card pull over the Reports API opens up, and decision W1 below (which is written as a choice between running on Core now or waiting for the API) stops being a live question. | Plus plan; our user has no API yet. Rail today = logged-in session (read-only, hard rule). Ask for Reports API credentials after terms. |
| Phone | Auto-attendant on 970-434-7000, mapped Sep 5 (section 6b): leasing = press 2 → rings ~21s → shared front-desk voicemail; maintenance = press 3 → AppFolio's 24/7 call center (English/Spanish, text or phone, human in ~15s). Carrier lookup: fixed VoIP on Level 3, consistent with RingCentral; confirm. Prototype: forward after-hours/no-answer to +1 970-822-0641. 2026-09-08: Decision for go-live week: forward the main number from RingCentral to Clara's 970 line, staff lines stay on RingCentral, no porting. Hand-off improvements (original caller ID on the team phone, voicemail rescue, name/extension routing, Twilio fallback) are staged; research and staging on Phone line and hand-off architecture. Needs Fede: send the drafted reply to Jason (in Gmail). | New Crexendo tree; MCC for maintenance. Variant A (Clara in their tree) vs B (permanent forward) undecided. |
| Leads | Zillow, Apartments.com, Realtor, Homes.com, Redfin — all AppFolio syndication, land as guest cards + emails. Who works guest cards today is unconfirmed. Their website shows no listings (bounces to Zillow). | Guest cards; applications mostly in outside tools (TurboTenant, Tenant Turner). |
| Public-data audit | 18 listings today (2 true duplicates: 372 and 374 Ember Ln/Lane). Pomona Park site says "no vacancies" while AppFolio lists two; concession wording differs across channels; one Ember special omits gas; a third-party site says "15 months free". Contact phone/email consistent everywhere (970-434-7000, leasing@). | 29 listings; 24 carry a "FREE Rent Special"; Depew/Everett address typos inside AppFolio keep some units off the public site. |
From merged-PR authorship (PR author, not commit author; squash commits credit Gera for everything). Directional — and counted once, on 2026-09-05, and not re-derived since (checked against this page's own history: the figures have not been edited since the commit that created the page). Several hundred pull requests have merged in the days after, so read the numbers as the shape of who built what, not as a current tally. The ownership column is the part that is still live: it records a decision, not a count.
| Area | Built mostly by | Owner for onboarding | Why |
|---|---|---|---|
| Leasing: prospects, tours, follow-up, applications, listings | Fede (46 vs 8 PRs) | Fede | Fede's stated focus; built the module |
| Maintenance: work orders, triage, dispatch, turnovers | Fede (215 vs 90) | Gera | Fede's stated split; Gera has 90 PRs here and 53 in the last 60 days |
| Microsoft mailbox + calendar, inbound email/lead parsing | Fede (13 vs 5; 16 vs 7) | Fede | Western Slope's Phase 1 rail |
| Gmail / Google OAuth | Fede 6 vs Gera 4; Gera built the onboarding piece | Gera (paused) | Fede's instruction ("Gera originally worked on Gmail"); resumes when un-paused |
| Voice agents / ElevenLabs / phone routing | Fede (37 vs 7) | Fede designs, Gera runs the CI provisioning | EL config changes must go through CI; Gera owns CI |
| Twilio SMS / after-hours desk | Fede 18 vs Gera 14 | Gera | Close split; pairs with maintenance line |
| AppFolio integration (API, browser runner, writers) | Fede 154 vs Gera 114 | Fede reads/leasing, Gera writes/work orders | Split by phase |
| Onboarding wizard, client setup, org creation | Gera (13 vs 8) | Gera | Gera built it |
| Org scoping / multi-property / cross-property leaks | Gera built scoping migration; Fede permissions | Gera | The leak P0 gate lives here |
| Knowledge base / policies / settings | Fede (7 vs 1) | Fede | Leasing needs it first |
| Renewals | Fede (25 vs 13) | Fede (Phase 3, later) | — |
| Agent Smith / Slack / CI / review bot | Gera (129 vs 22) | Gera | — |
| Docs site | Gera (~4:1 commits) | Gera publishes, both write | — |
What it does. Fixed first line with the Spanish offer; full switch to Spanish on the caller's first Spanish word. Leasing: specific property or general → area, beds/baths, budget, move-in, pets → one or two matches, never a count → name and callback read back → "the team will follow up." Maintenance: property and unit first, emergency screen (gas or fire = leave and call 911), details, callback. Else: take a message. Read-back on every call. No transfers, no booking, no tools.
Where the draft lives. Uncommitted in worktree propflowai-portfolio-triage-proto, file agents/clara/lib/voice-agents/prototypes/western-slope-portfolio-triage.ts (prompt + 16-home knowledge text with the nearby-alternative map).
Runbook (Gera, Monday).
scripts/provision-western-slope-proto.ts (idempotent, dry-run default, --apply to write: find-or-create knowledge doc; find-or-create agent named exactly "Portfolio Triage — PROTOTYPE (Western Slope)"; patch prompt, first message, description, the Willows leasing voice/LLM/ASR/turn settings, the Spanish preset, the knowledge attachment; find-or-import +19708220641; assign). Never send an empty tool list — omit the field..github/workflows/provision-western-slope-proto.yml: manual dispatch only, confirm string western-slope-proto, secrets ELEVENLABS_API_KEY / TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN (all exist).config/elevenlabs-phone-numbers.json so the nightly drift check guards it.Isolation: not in the specialist registry, sync lists, or config files, so no fleet dispatch can reach it. Zero tools.
Same method as the July vendor recon: dial, send no audio, record what plays, hang up. Caller ID was the throwaway 720 number (first three calls went from the 970 number before the switch). Cost $0.55. Full tree, verbatim scripts, voicemail table, and call log: ~/westernslope-data/phone-tree.md.
| Key | Announced as | What actually happens after hours |
|---|---|---|
| Greeting | English only. Names the website and leasing@westernslopepm.com, offers extension dialing, then the menu. | No Spanish anywhere on the front door. No input loops the greeting; 0 does nothing. |
| 2 | Leasing: apartments, single-family homes, townhomes, "the brand-new Pomona Park townhomes" | "Please hold while I try to connect you" → rings ~21 seconds → shared front-desk voicemail, not a leasing box. 0 during the greeting returns to the main menu. This is the gap. |
| 3 | Maintenance, current residents or neighbors | AppFolio's 24/7 maintenance call center. Language menu (1 English / 2 Spanish, the only Spanish on the line) → text or phone. Text: hangs up and a real SMS arrives ~40s later from 970-695-8684. Phone: new request / follow-up / repeat → a live agent in ~15 seconds on a Saturday evening. |
| 4 | Hours and location | 1133 North 18th Street; 9–4 Mon–Thu, 9–3 Fri, closed noon–1 Mondays. (Website says by appointment 9–5; reconcile in the knowledge base.) |
| 5 | All other calls | Same shared front-desk voicemail as 2. |
| 1 | Not announced | A separate "Prospective Tenant" voicemail: "We are currently closed." |
| Extensions | Offered | None published; 100 and 999 invalid. Not brute-forced. |
What it means for the design. Maintenance is already covered around the clock by a real service, so Clara does not need to take maintenance calls for Western Slope at all; leave press 3 alone. After-hours leasing dies in a voicemail nobody is named for, so the clean insertion is one forwarding rule on their side: press 2 no-answer → +1 970-822-0641. Clara answers as leasing, bilingual, takes the lead, books the tour. Business-hours leasing stays with staff until they choose otherwise. This narrows the v1 voice agent to leasing plus message-taking, which matches "leasing only". Decision W8 updated.
Not verified: real extensions, what "#" does after the prospective-tenant beep, where a no-input call ends past 90 seconds, and the "follow up on a previous request" branch (inferred, not heard). Disclosure: on the "new maintenance request" probe a live agent answered 66 seconds in and heard ~9 seconds of silence before the call's time limit ended it; we never spoke and never left a message on any box. Remaining agent-bound probes were shortened to hang up before pickup.
Full decision study: voice-agent-architecture-decision
The fork already exists in code as a safety freeze (Aug 23), never as a product decision.
Everything it captures reaches the team as a message. Nothing is booked or written.
ElevenLabs already supports per-call overrides of prompt, first message, language and voice, dynamic variables including the dialed number, transfer with history, and language detection. "Modes" are ours to define as data; there is no native mode switch.
v1 for Western Slope = Clara answers leads, answers questions, and books, moves, or cancels tours. She does not touch applications or move-in; those go to a person. The table maps each funnel stage to what actually controls it in the code today (checked against main, Sep 5).
| Funnel stage | What Clara can do | What controls it today | Default if we set nothing | v1 setting |
|---|---|---|---|---|
| Inquiry and questions (email, text, phone) | Reply, answer property questions, save the prospect | Per-property leasing stage (off / shadow / live); email and SMS shadow modes (Clara drafts, nothing sends) | Live. A property with nothing set is fully on, not dark. | Shadow for 2–3 days, then live on one property |
| Tours: schedule, reschedule, cancel | Offer slots, book on the named calendar, move or cancel | Tour scheduling chokepoint (deploy-level) and tour window-matching flag (per property) | Window matching off per property | On, once their calendar is connected |
| Application | None in conversation. The old "send application link" tool was retired Aug 19; a background job sends the link only if a link is configured. | Application-link field in leasing settings | Blank = nothing sent. | Leave blank. Clara says a team member will follow up and forwards. |
| Approved → lease prep | Create a "next steps" review item for a human | New-lease pipeline arm (script) + per-property flag | Disarmed. No item, no send. | Leave disarmed |
| Move-in / lease send | Draft the lease in AppFolio and stop | Autonomous lease-sending flag per property | Off. | Leave off |
| Hand to a human, any stage | Forward to the property manager; once a staff member replies on a thread, Clara stops sending on it (shipped Sep 3) | Property manager email on the property record | Silent failure if blank: Clara tells the prospect someone will follow up and nobody is notified | Set Jay's address on day one |
Camellia runs on a Microsoft 365 mailbox connection: read new mail, reply from the property's address. AppFolio relays every syndicated lead into that mailbox as a "New Lead" email, and the parser already reads the name, email, phone, beds, move-in date, source, and the two property hints: the first line ("New Lead for 1235 Grant Street") and "Interested in: Camellia Apartments". Zillow's own emails also carry the listing address. So yes: mailbox read and write is enough to do for Western Slope what we do at Camellia.
The gap: today one mailbox connection belongs to exactly one property. The property is decided by which mailbox the email arrived in; the "Interested in" line is stored as a display hint and never used to pick the property. Western Slope has one mailbox, leasing@westernslopepm.com, for about 14 properties. Before go-live, the inbound path needs one addition: when a mailbox is shared, resolve the property from the "New Lead for <address>" / "Interested in" line (and from the Zillow listing address), and fall back to a human when it cannot. For scattered-site homes the "property" in AppFolio is the address itself, so the match is exact, not fuzzy. Decision W11.
Today the controls above are a mix of a settings field, per-property flags set by script, deploy-level arms, and hard-coded lists. There is no single place to say "for this property Clara owns inquiries and tours, and hands off applications and move-in". Onboarding a property should become one screen per flow: off / shadow / live / hand to human at stage X, with the safe default being off. That rework is the platform item for week 2 onward; for week 1 we set the switches by hand in the order above and record each one on this page.
Everything below was checked against main on Sep 5. The theme: the product assumes one property per channel (one mailbox, one number, one calendar, one listings URL, one settings row per property). Western Slope is one company with ~14 properties behind one mailbox, one number, and one listings feed. The portfolio level has to exist first; the rest hangs off it. Sizes are estimates.
| # | Gap | What breaks for Western Slope today | Work | Owner | Size |
|---|---|---|---|---|---|
| G1 | Portfolio hierarchy first. An Organization type exists with settings declared, but almost nothing reads it at runtime. Settings resolve property → platform default, with no company tier in between. Contact email, escalation owner, shadow modes, calendar, mail connection, phone numbers, and the whole leasing-settings row are per property. | Every setting entered 14 times; one mailbox and one calendar connected 14 times; no single place that says "this is Western Slope". | Create the Western Slope organization; make the settings resolver read property → organization → default for the fields week one needs: PM/escalation email, shadow modes, mail connection, calendar, phone number, office hours. Import the 14 properties under it. | Gera | M–L |
| G2 | Shared mailbox is unroutable. Inbound mail is matched by which property owns the mailbox subscription; if more than one property claims it the email is dropped on purpose. The AppFolio "Interested in" / "New Lead for <address>" line is stored as a hint, never used to pick the property. | leasing@westernslopepm.com cannot be connected to 14 properties; connected to one, every lead lands there. | Mail connection at the organization level; resolve the property from the AppFolio address line (exact match on the scattered-site address) or the Zillow listing address; unmatched → human. Decision W11. | Gera (routing) + Fede (parser, tests on real Western Slope lead emails) | M |
| G3 | One phone number = one property, in code. The number→property map is built last-writer-wins and falls back to a hard-coded table; the outbound number map is source code only and ignores the property record. | +1 970-822-0641 can belong to one property only; texting from it for 14 properties has no property context; adding it at all is a code edit and deploy. | Organization-level number: inbound resolves to the organization, property chosen from the conversation (which home they asked about); outbound reads the record, not code. Until then: hard-code the number to a Western Slope "portfolio" property as a stopgap. | Gera | M |
| G4 | Listings sync is one URL → one property and never creates units. Hard-coded array with only Camellia's feed; it parses unit numbers only and flips availability on units that already exist. | westernslopepm.appfolio.com/listings covers 14 properties; nothing imports them, and unit numbers collide across buildings. | Portfolio listings importer: read the feed, attribute each listing to a property by address, create properties and units that do not exist, update rent, specials, beds, move-in, photos. Config on the organization, not in code. | Fede | M |
| G5 | Tours: calendar per property, no travel time. One staff Outlook calendar can serve many properties, but each property needs its own connection, and there is no notion of distance between Grand Junction, Fruita, Clifton, and Ridgway. | 14 calendar connections to the same calendar; back-to-back tours 30 miles apart. | Calendar at the organization level; per-property or per-town travel buffer setting. Week one: buffer as a fixed minimum between tours, no distance logic. | Fede | S–M |
| G6 | Defaults are live, not off. A property with nothing configured sends. Forward-to-manager with no PM email set tells the prospect someone will follow up and notifies nobody. | A half-configured Western Slope property would email real prospects; handoffs vanish. | Week one: set switches by hand in order (PM email → shadow on → knowledge → shadow off) and record each on this page. Week two+: onboarding screen per flow (off / shadow / live / hand to human at stage X), default off. Make forward-to-manager fail loudly. | Fede (setup, loud failure) · Gera (screen) | S now · L later |
| G7 | Cross-property leak. Caller-ID matching across properties, property search across organizations, voice-message replay. Live repro Aug 31. | A Western Slope caller could be matched to a JP resident record, or the reverse. | Close the three paths before a second customer shares Clara. Gate X3. | Gera | M |
| G8 | Voice prototype and transfers. Agent being provisioned today on the 970 number (no tools, no transfers). Transfers by intent need their leasing and maintenance direct numbers or extensions; transferring to the published line would loop. The nightly drift check has no ElevenLabs key in CI. | Callers get a message taken, never a person, until transfers exist. | Get direct numbers from Jay; add transfer rules per intent; fix the drift check credentials. | Gera (CI, drift) · Fede (script, transfer rules) | S–M |
| G9 | Knowledge per property and per company. Public data covers ~27% of what people ask; contact, hours, screening, and fees are company-wide, features and utilities are per home. | Clara cannot answer tours, fees, pets, parking, utilities for most homes. | Company-level facts once, per-property facts from the listings importer plus their answers. Section 10 lists the questions. | Fede | M, mostly their answers |
| G10 | AppFolio account field. Per-property account resolution fails closed. Verified: the plain leasing path (email reply, tour booking, prospect save) never calls it; only renewal sends, work orders, and PMS-delivered messaging do. | Nothing for leasing v1, provided the properties are not marked as AppFolio-sourced. | Import Western Slope properties without the AppFolio source flag until they add API access. No work. | — | 0 |
| G11 | Double reply risk. If staff answer guest cards inside AppFolio while Clara answers from the mailbox, prospects get two replies. | Depends on their habit; unknown until Monday's question is answered. | Ask. If they reply inside AppFolio: they stop for the pilot property, or Clara reads the guest card via the clara@ login before replying. | Fede | S |
| G12 | Structural multi-tenancy: leaking must be impossible, not discouraged (strict isolation is Fede's stated requirement, Sep 6 thread; Constitution VII.2). Tonight the prototype text line answered Fede in the manager persona he holds at JP, and answered a Willows-resident test number as "Clara with The Willows," because the router looked the sender up across every company and filtered afterwards. | A person or company's data reaching another client. Blocks any shared line at a real portfolio. | Not a rule for engineers to remember; a shape of the system that cannot be misused: (1) the company is resolved once, at the edge, from the channel binding, and becomes a required tenant context that every data call must carry; a store function with no tenant context does not compile or does not exist. (2) Rows are partitioned by company; person, tenant, prospect, conversation, and knowledge reads are keyed inside the company partition; there is no global lookup by phone or email in application code, only in explicit admin tooling. (3) One human can exist in two companies as two rows linked by an admin action, never by a shared read. (4) Fallbacks that adopt the caller's company when the channel is unmapped are deleted; unmapped fails closed. The G13 harness proves it; it does not provide it. | Gera | L, starts week 1; design first, migration in stages | Update, Sat night 11 PM: two fences shipped (role, thread) and a Willows-resident probe still received its work order on the Western Slope text line through a third path, tenant lookups by bare phone in the inbound dispatcher. Inventory found about 20 inbound call sites that reach a cross-company lookup, several open, and the email lane has nothing to scope to at all. Texting on the prototype number is switched off until the open sites are closed and a probe is clean. A drift guard with an explicit allowlist lands tonight so the set is countable; the rest is daylight work against that inventory (see the handoff file). Voice is unaffected.
| G13 | Cross-company isolation harness, all channels (Fede, Sep 5 night). A standing harness, built through the /harness skill, that proves a request on one company's channel can never read or write another company's rows. Channels: voice (ring-time personalization, every voice tool webhook, post-call filing), text (inbound router, follow-up cadence, receipts), email (inbound, replies, forwards), and the browser/API writers. Fixtures: a manager, a resident, a prospect, and a vendor who exist at company A, contacting company B's line, mailbox, and number; shared numbers; the cold-cache window; the unmapped-number fallback. Each case asserts both the reply content (no other company's names, homes, or facts) and the rows written (partition matches the channel's company). | Tonight's two leaks would have been caught before Fede's phone did. Without it, every new client is a new way to leak. | Runs on every merge against real table shapes (Constitution VII.2) and nightly against production read-only. Blocks merge on any hit. Owner named; replay count reported like every other guardrail. | Gera (harness) · Fede (voice fixtures) | M–L, start week 1, grows with each channel |
| G14 | Voice pickup binds one property to the call, and ten tools depend on it (audit Sep 6 morning, after the prototype line went dead). At ring time the personalization webhook looks up the dialed number, picks its property, and stamps property_id on the call; the tour slots, available units, upcoming tour, after-hours flag, and transfer numbers read into the prompt are all built from that one property. Ten distinct tool behaviors take the property from that stamp, not from Clara: property details, neighborhood, available units, amenities, term pricing, pricing details, save prospect, link on behalf, schedule tour, and the taught-policy lookup on escalation callbacks. About twenty more (work orders, appliances, vendors, balance, lease terms, key pickup, turnover, escalate) inherit the property through the conversation record; the webhook already falls back to the caller's own property when the dialed number maps to nothing, so residents are mostly covered and unknown callers are not. Already portfolio-safe: reschedule and cancel tour, opt out, the tenant-keyed renewal tools, identify caller and tenant, check availability, get property info, and a search_properties tool that exists but is on no prototype agent. | Proven Sep 6, 7:33 AM: Fede called +1 970-822-0641 and it hung up in one second, error "Missing required dynamic variables in tools: property_id, caller_phone", because the booking tools were attached with the ring-time lookup off. On a real portfolio line there is no property to stamp, so this fails structurally, not by misconfiguration. Same-morning stopgap: turn the lookup on against the umbrella prototype record. | Decision, not a per-tool patch (options on the architecture page): bind the company at ring time; bind the property when the caller identifies as a resident or when a prospect picks a home; every property-scoped tool accepts the property as a normal field Clara fills, and the backend resolves home to property. The injected prompt context (slots, units, hours, transfers) becomes per-home or company-level. Put search_properties on the front-door agent. Extends G3 from the number map to the whole voice stack. | team (decision, prototype tools) · Gera (webhook + tool contract) | M, decide week 1, build week 2 |
Order of attack. Mon: G1 organization + property import scaffold (Gera), G4 importer and G2 parser on real lead emails (Fede). Tue: G2 routing, G3 stopgap number mapping (Gera); mailbox consent and G6 setup (Fede). Wed–Thu: G5 calendar and buffer, G9 knowledge from their answers (Fede); G7 leak paths and the first cut of the G13 isolation harness, text + voice (Gera). Fri: end-to-end proof on one property; G8 transfers only if the direct numbers arrived. Week 1 decision, any day: G14 (which layer binds the property on a shared line), since it shapes G3, G5, and the front-door agent.
Baseline: 89 facts cover 99% of real messages (4,851 Camellia messages mapped to 112 fact keys). Western Slope's first-draft knowledge base was built from public data only (their site, AppFolio listings, Pomona's site, reviews, state filings; 73 files, nothing guessed). Result: public data answers about 27% of message volume. Of the 89 priority facts: 14 known, 6 partial, 69 gaps. The gaps are exactly the things that live in AppFolio or in staff heads, which is why the clara@ user and their answers are the whole week-one job.
Files: ~/westernslope-data/knowledge/BRAIN.md (22 questions, cited per bullet), facts.json (112 keys), properties.json (14 properties). Move-in, payments, lease terms, and maintenance gaps are recorded there too but are out of scope until leasing is live.
Sensitive scopes only, no audit, no fee. Google states 2–3 business days brand plus 3–5 days scope; plan 4–8 weeks. Domain, privacy and terms pages are in place; the privacy paragraph is in a PR. Blocker for the required demo video: Gmail send and calendar writes are not wired, and Google rejects scopes not shown in use. Kit: email-architecture page. Interim for Situs: their Workspace admin allowlists our app for their org.
Superseded by the fuller readiness check below: Google Workspace readiness for Situs Group (2026-09-15). Gmail send and calendar scopes are wired now (per property); reading Gmail is still not built and needs Google's restricted-scope review.
PropFlow already has a working Google connect, built 2026-08-13, one per property. It can send email (send-only, scope gmail.send) and read/write a calendar for tours (calendar.events, calendar.readonly). It is wired into property onboarding, the settings page, and the admin integrations inspector. The privacy page already discloses the send-only scope in section 8.
What it cannot do: read Gmail at all, get push notifications when new mail arrives (no watch/Pub-Sub), or connect at the company level instead of per property. There is an unused constant for company-level scopes (ORG_GOOGLE_SCOPES in org-connect.ts) with a note that reading Gmail needs Google's annual security assessment. Gera's Google work so far is just the shared control layer: handling a declined OAuth consent (#7518) and one place to disconnect any integration (#7875). The Google button on the welcome and calendar page is a dead end today, it says "coming shortly."
We have a Google API client set up in the keychain (propflow-google-client-id), but no record of Google's app verification or the CASA security assessment being done. gcloud is installed for fede@propflowai.co on the propflow-admin-tools project, but it needs Fede to log in again before we can confirm the consent screen setup and which APIs are turned on.
Researched today against Google's own docs. The short version: no app can read a Google Group or a delegated mailbox just because a person signs in with their own Google account. Reading mail only works two ways: an alias on someone's personal account, or a real account with its own separate login. The other option, "domain-wide delegation," only a domain super-admin can turn on, and it grants access across the whole domain at once, which is a much bigger ask than a single shared inbox.
Reading Gmail needs a "restricted" scope (gmail.readonly or gmail.modify). Restricted scopes require Google's full app verification plus a CASA Tier 2 security assessment, a paid, self-serve lab test that runs roughly $540 to $1,000 and takes 4 to 12 weeks including Google's own review. It has to be redone every 12 months. Calendar scopes are a lighter category, "sensitive" not "restricted," so they only need standard review, no lab test.
Sources: Restricted scope verification · Gmail delegation · Domain-wide delegation best practices · Restricted scope requirements
| Piece | Status |
|---|---|
| Connect a shared inbox (not a person's own login) | Missing |
| Read Gmail | Missing, needs a restricted scope |
| Get new mail without polling all day (push or a scheduled read) | Missing |
| Company-level Google connect (one setup for a whole portfolio) | Missing |
| Verified Google app | Missing |
| Calendar | Exists per property, needs company-level wiring |
In order. "Can start today" means it does not wait on anything else on this list.
| # | Step | Can start today |
|---|---|---|
| 1 | Confirm the Cloud project and consent screen setup | No, needs Fede to log back into gcloud |
| 2 | Public privacy policy and homepage listed on the consent screen | Yes |
| 3 | Add the Gmail read scope and build the read flow | Yes |
| 4 | Record a demo video showing the grant flow | Yes, once step 3 exists |
| 5 | Submit to Google for review | Yes, once steps 1 to 4 are done |
| 6 | Run the CASA Tier 2 security lab | No, this is a purchase and needs Fede's go |
| 7 | Reverify every 12 months | No, ongoing once approved |
| 8 | Design and build the company-level Google connect (Gera's org model) plus a scheduled read of the shared inbox | Yes to the design; the read itself waits on step 3 and, for full Gmail read, step 6 |
Situs Group's own Google Workspace admin can allowlist PropFlow's app as a trusted app for their domain (Admin console, Security, API controls, App access control). That removes the "unverified app" warning screen for what we already have today, sending email and calendar, with no engineering work. It does not unlock reading Gmail; that still needs the verification and CASA path above.
Is leasing@ a Google Group, a mailbox someone delegated to others, an alias on one person's account, or its own separate login? The answer decides whether a person's own sign-in can ever read that inbox, or whether it needs its own dedicated account.
| # | Decision | Options |
|---|---|---|
| W1 | Data rail on Core | Overtaken by events, 2026-09-09: Western Slope upgraded off Core, so this is no longer a choice between (A) running on the logged-in session now and (C) waiting for Plus — both happened. The session rail carries leasing today; the Reports API key arrives this week and the tier is still to confirm. |
| W2 | Lead intake | (A) Microsoft consent on leasing@westernslopepm.com, if guest-card and Zillow notices land there — recommended · (B) forward that inbox to a propflowai.co address (Yale pattern) if their admin is slow |
| W3 | Send-as | (A) from leasing@westernslopepm.com via Microsoft — recommended · (B) from a propflowai.co address |
| W4 | Go-live shape | (A) shadow mode 2–3 days, then live — recommended · (B) straight to live on one property |
| W5 | Listings in scope | (A) all 16 homes — recommended · (B) Ember Estates lease-up only |
| W6 | Cross-sell v1 | (A) static nearby table per property — recommended · (B) scored ranking over live availability · (C) no cross-sell at launch |
| W7 | Cross-sell scope | (A) same owner only (Situs-owned Grand Junction stock) — recommended · (B) include third-party-managed homes with owner approval |
| W8 | Phone for the prototype | (A) one rule on their side: press 2 (leasing) no-answer → +1 970-822-0641, after hours first; press 3 stays with AppFolio's call center — recommended · (B) whole line forwards to Clara after hours (she would then have to route maintenance back to the center) · (C) publish the 970 number as a separate leasing line |
| W9 | Language | (A) greeting offer + auto-detect — recommended · (B) press-2 menu |
| W10 | Out of scope, Phase 1 | (A) exclude commercial, industrial, medical, and third-party Ridgway — recommended · (B) include Ridgway |
| W11 | Shared mailbox → which property | (A) resolve from the AppFolio "New Lead for <address>" / "Interested in" line, human fallback when unmatched — recommended · (B) one mailbox or alias per property on their side · (C) treat the whole book as one property (loses per-property knowledge) |
| W12 | v1 scope | (A) leads + questions + tours; applications and move-in to a human — recommended, per Fede Sep 5 · (B) add application link sending from day one |
| # | Decision | Options |
|---|---|---|
| S1 | Contract unit basis | (A) 645 whole database (Fede's stated number) · (B) 431 units with bedrooms, per the live dashboard · the team's call |
| S2 | Leasing email rail (Phase 2) | (A) forward leasing inbox to a propflowai.co address now; verification in parallel — recommended · (B) wait for Gmail send + Google verification · (C) send through the AppFolio composer |
| S3 | Phone Phase 1 routing | (A) permanent forward to us, we ring staff, Clara on no-answer — lean · (B) Clara as a node in their Crexendo tree · needs Crexendo answers |
| S4 | Emergency behavior | (A) connect caller to on-call · (B) take details and page on-call · (C) both · needs Hugo/Eileen |
| S5 | Writes into their AppFolio | (A) browser writer, test property first, written go per building — recommended · (B) hold all writes until API |
| S6 | First building | not proposed yet — pick at the whiteboard |
| S7 | The six open items on the Situs hub (D-A…D-F) | see situs-group-initiative "Decisions for Fede" |
| # | Decision | Options |
|---|---|---|
| X1 | Voice-agent direction — DECIDED for the voice agents only (Fede, Sep 5): Option A. Scope note, Fede, Sep 6: the overall architecture and the binding mechanism (how a number and the property-tied tools bind to a company, a group, or a building) are OPEN, under the Sep 6 architecture study. One shared set of agents for every client; a number binds to a company, a group, or a building; company-bound lines start with the building unknown and Clara asks; server-side work, not ElevenLabs; three holes close first. Standing rule added: strict isolation between client companies (company-level data such as JP Co or Situs never reaches another client). Full study: voice-agent-architecture-decision. | Decided |
| X2 | Google verification paperwork while Gmail is paused | (A) submit the sensitive-scope review now (needs the send branch for the video) · (B) hold until un-paused — consistent with today's pause |
| X3 | Leak fix as a gate | (A) gate: no second customer live on shared Clara until closed — recommended · (B) run in parallel, accept risk |
| X4 | Owner split | (A) as in section 2 — recommended · (B) adjust |
| X5 | Identity across companies | (A) one person may exist in several companies, but each company's lookups see only its own rows; cross-company linking, if ever, is an explicit admin action — recommended, matches Fede's ruling and Constitution VII.2 · (B) keep the global person lookup and filter roles afterwards (tonight's stopgap only) · needs Gera: what changes in the identity spine and the migration |
| X6 | Owner grouping for third-party managers (Sean, Sep 6: a manager like ConAm wants to sort and report by owner). The management company stays the organization; owners are a grouping inside it. Detail and diagram: portfolio-architecture-options. | (A) soft grouping: an owner record under the company, each property points at one, views and reports filter by it; hard walls stay at the company boundary — recommended · (B) owner is also a policy rung: property → owner → company for fees, knowledge, legal (only if an owner's policy actually diverges) · (C) owner as a hard isolation wall (only if a manager needs owner-level data walls for legal reasons) |
| X7 | Portfolio test line for the harness — DECIDED (A), Sep 7: +1 720-807-1724 bought and registered for the seeded test company under Fede's standing pre-approval (#7285). The ElevenLabs import landed 8:58 PM MT Sep 9 (#7491, on Fede's go, import only — no agent bound yet, which is a separate one-line decision). Still to do: bind an agent, then the six robot runs. | |
| X8 | Nightly slot for the harness's red view (22 reproductions, red by design). (A) yes, nightly, results to the job summary only — recommended · (B) on pull requests touching the audited surfaces only (today's state). | |
| X10 | Portfolio architecture — what "company" means. Reviewed by Fede and Gera Sep 6 evening, no decision; Gera owns the implementation. Detail: portfolio-architecture-options. | (A) The operator, the account holder whose Clara answers; owner and manager are relationship rows — recommended · (B) the owner · (C) both privileged |
| X11 | Yale 25 now | (A) Leave it under JP&Co; add "owned by JP&Co" and "managed by ConAm" with the operational view of Yale only — recommended · (B) move it under a ConAm company now · (C) two records |
| X12 | Conversations on a company line | (A) Born in the company's front door with a frozen anchor, gain the home as a field plus an index, never move — recommended · (B) move partitions when the home is named · (C) umbrella property |
| X13 | The group tier | (A) Ship the scope variant and resolver support now, zero groups — recommended · (B) defer · (C) create groups for Situs at onboarding |
| X14 | Taught answers | (A) A staff reply teaches the building it answered; on a company-line conversation with no building yet, the company; one click promotes or demotes — recommended · (B) the level of the line it arrived on · (C) always the company |
| X15 | Default grants for related companies | (A) "Managed by" grants the operational view of that building only; "owned by" grants reports only; computed from the live relationship, never stored — recommended · (B) owners see everything · (C) per-relationship configuration from day one |
| X16 | Scattered homes | (A) One building record per home, with the dedicated work-order index built first — recommended · (B) one umbrella property with homes as units |
| X17 | Situs's calendars (production refuses Google today; the adapter is half built behind a guard) | (A) Ask Situs to connect a Microsoft calendar for tours · (B) finish the Google calendar writer (unpriced) · Recommended: ask Situs first, and price B the same week |
Every customer answers two separate questions, and they don't have to have the same answer.
A customer can have a fast intake channel and a slow reply identity, or the reverse. Fixing one does not fix the other.
This section is the current-state map, one channel at a time. For the bigger redesign — one intake pipeline every source runs through, replacing today's two separate lead writers, plus provenance and PMS write-back as a customer-level choice — see Lead-intake engine: where we are, where we're going, just below.
| Channel | What it covers | Speed | AppFolio plan needed | Status in our product | Notes |
|---|---|---|---|---|---|
| Listing-site lead email to a property mailbox | Zillow, Apartments.com, and other ILS partners, when the customer's own site or listing relays a lead by email | Seconds | Any plan, including Core | Live | Parsed by agents/clara/lib/email/parse-lead-source.ts. Fastest path today; the only automated path available on Core plans; Western Slope: upgraded off Core (2026-09-09); Reports API key arriving this week; tier to confirm (Plus or Max). |
| AppFolio new-guest-card notification email to Clara's login mailbox | ILS syndication, the property's AppFolio web form, staff-entered cards, phone/text inquiries, and tour-integration cards (ShowDigs, Rently) — AppFolio emails "New Lead" or "New Inquiry on an existing guest card" immediately to every user who has the property in their view, per General Settings > Communications > Automatic Communication Settings | Immediate on guest-card creation | Not documented by plan tier; appears to be a baseline feature | Trigger for an immediate fetch, not a lead source — pending the Willows template check (revised in revision 4, E-04: aligned with the lead-intake engine's own corrected framing below, which this section previously contradicted by still calling the email "recommended primary intake") | Guest-card id, the prospect's message body, and Core-plan availability are undocumented; AppFolio's own docs don't confirm what's in the email body beyond name/phone/email/unit. It makes the per-database poll (the actual intake of record) fetch immediately rather than waiting for its normal cadence — see the lead-intake engine, decision (b), for the concurrency review finding (A-04) that downgraded this from an independent source to a trigger, and the test that would let it graduate back to one. |
| Guest-card pull via Reports API | Every guest card on the account, independent of how it was created | Today: every 15 minutes (full-list diff, date filters may be ignored on the guest-card report). Proposed: 60-second incremental pull per AppFolio database | Plus (read-only) or Max (read-write); no access on Core | Live at 15 minutes; 60-second pull proposed, not built | src/lib/integrations/appfolio/pms-adapter.ts. Available to Western Slope (upgraded off Core as of 2026-09-09; Reports API key arriving this week; tier to confirm). Fallback intake for Plus/Max customers if the notification email can't be confirmed. |
| Rental-application pull | Applications, separately from guest cards | Every 5 minutes | Plus or Max | Live | src/lib/integrations/appfolio/pms-adapter.ts. |
| Calls and texts to Clara's number | Prospects who call or text the leasing line directly, bypassing AppFolio and listing sites entirely | Immediate | Not applicable — doesn't touch AppFolio | Live | Routing: config/phone-registry.json, src/lib/domain/properties/phone-registry.ts. A first-time texter/caller on a PMS-delivered line not yet writing a prospect + guest card is an open item (block 2 of the engineering-areas table, above). |
| AppFolio Stack partner webhooks | Real-time push the moment a guest card is created, if "new guest card" turns out to be a published webhook event | Real-time (unconfirmed — event list not published) | Certified Stack partner only (application, security questionnaire, sandbox) | Proposed — needs the partner program | 2–4 week application cycle; may be rejected if guest-card creation isn't a published event. |
| Direct Zillow / Apartments.com feeds | Leads from those two sources only, delivered straight to us, bypassing AppFolio | Seconds | Not applicable — bypasses AppFolio | Proposed — needs per-vendor registration | 4–6 week onboarding per feed; doesn't cover AppFolio's own web form or other ILS partners; needs its own dedup against AppFolio-native guest cards for the same person. |
| Identity | Who controls it | Setup needed | Status |
|---|---|---|---|
| Customer's own leasing mailbox, connected | The customer (Microsoft 365 or Google Workspace sign-in) | One mailbox connection (Microsoft Graph today; Google sign-in exists but sending/reading don't yet) | Live for Microsoft-connected mailboxes (Western Slope's leasing@westernslopepm.com). src/lib/integrations/email/property-graph-sender.ts is the shared property-voiced send path — Graph primary, SendGrid fallback only when no mailbox is connected. |
Property-specific mailbox we create (the Yale 25 Station pattern, yale25@) | Us — a dedicated mailbox per property, not the customer's own domain | Mailbox creation + routing | Live at Yale 25 Station. Used when the customer won't connect their own mailbox, or hasn't finished doing so (Situs's Phase 2 plan: forward their leasing inbox to a propflowai.co address). |
| Clara's phone number (SMS / voice) | Us — a Twilio number per property or portfolio line | Number provisioning + routing in the phone registry | Live. Reply identity is always ours here; the PMS-delivery lane (below) is a separate way to send that borrows the customer's own AppFolio texting number instead. |
| AppFolio guest-card messaging (send/read from inside AppFolio, no mailbox or number of ours) | The customer's own AppFolio account — identity is their AppFolio leasing email / texting number, not ours | None beyond what AppFolio already has — no Max plan, no partner program, no mailbox connection | Built and live only at the Willows test property (Property.messagingDelivery === 'pms'). Sends go through a pure-HTTP session replay against AppFolio (src/lib/integrations/appfolio/message-sender.ts → sendGuestCardMessageL4), not the official write API — there is no official API for guest-card messaging at any plan tier. Reading the prospect's reply is also built (src/lib/domain/pms/guest-card-message-reader.ts, one-way, read-only per ADR-0094). Zero customer properties use it yet; texts sent this way go out under AppFolio's own consent rules. Also useful for writing Clara's notes/outcomes onto the guest card even when the actual conversation runs through our own mailbox or number. |
| Customer | AppFolio database | Plan tier | Intake channels in use today | Reply identity in use today | Gaps |
|---|---|---|---|---|---|
| Camellia (JP&Co) | jpco.appfolio.com | Unknown (not documented on this page; writes go through the browser session replay, not the official write API, so plan tier doesn't gate them) | Guest-card pull, every 15 minutes (Reports API) | Connected Microsoft mailbox; Clara's own phone number | AppFolio guest-card messaging capability is built but switched off here — proven only at the Willows. |
| Yale 25 Station (ConAm) | jpco.appfolio.com (Yale 25 currently sits under JP&Co's company record — decision X11) | Unknown | Unknown — not documented on this page beyond the shared jpco pull | yale25@ property-specific mailbox, created by us | Not a separate company record yet; intake channel not itemized separately from Camellia's. |
| Willows (test bench) | jpco.appfolio.com | Same as Camellia — unknown | Guest-card pull, same 15-minute cadence as Camellia | AppFolio guest-card messaging — the only property where messagingDelivery: 'pms' is live | None — this is the proving ground, not a gap. |
| Situs Group | situsgroup.appfolio.com | Plus (read-only Reports API; write access needs Max) | Guest cards; applications mostly land in outside tools (TurboTenant, Tenant Turner), not AppFolio | Google Workspace sign-in exists; sending and inbox reading don't work yet — Phase 2 plan is to forward their leasing inbox to a propflowai.co address, Yale-25 pattern | No working reply identity today. No company record of its own (sits administratively near JP&Co). AppFolio guest-card messaging untested here. |
| Western Slope | westernslopepm.appfolio.com | Upgraded off Core (2026-09-09); tier to confirm (Plus or Max); Reports API key arriving this week | Listing-site lead email to leasing@westernslopepm.com (the only automated path available on Core) | Connected Microsoft 365 mailbox (leasing@westernslopepm.com) — same pattern Camellia runs on | Upgraded off Core (2026-09-09); Reports API key arriving this week; tier to confirm (Plus or Max). Once tier is confirmed, guest-card pull and AppFolio guest-card messaging paths become available. |
agents/clara/lib/email/parse-lead-source.ts, agents/clara/lib/email/process-inbound-email.tssrc/lib/integrations/appfolio/pms-adapter.tssrc/lib/integrations/email/property-graph-sender.tssrc/lib/domain/calendar/outlook-client.ts, src/lib/domain/calendar/provider/google.ts, src/lib/integrations/email/graph-subscription.tsconfig/phone-registry.json, src/lib/domain/properties/phone-registry.tssrc/lib/data/dynamo/pms-credential.ts (PK PMSCRED#<userId>), src/lib/domain/pms/registry.tssrc/lib/integrations/appfolio/message-sender.ts, src/lib/domain/pms/message-sender.ts, src/lib/domain/pms/guest-card-message-reader.ts, src/lib/integrations/appfolio/guest-card-message-reader.tssrc/lib/integrations/appfolio/resolve-account.ts, src/lib/integrations/appfolio/config.ts| # | Decision | Options |
|---|---|---|
| I1 | Primary intake channel for new customers | (A) The per-database poll is the intake of record; the AppFolio new-guest-card notification email is a trigger that makes it fetch immediately, not an independent source — recommended (corrected in revision 4, E-04; matches the lead-intake engine's decision (b) below). No plan upgrade, no partner application; confirm the exact email template at the Willows first, and see the lead-intake engine for the test that would let the email graduate to an independent, dedup-safe source of its own. (B) 60-second incremental pull per AppFolio database — fallback for Plus/Max customers if the notification email can't be confirmed or reliably parsed; still polling, not real-time, but needs no partner approval. (C) AppFolio Stack partner webhooks — later, once the partner application is in and "new guest card" is confirmed as a published event; real-time, but a 2–4 week cycle and gated on AppFolio's own event catalog. |
| I2 | Default reply identity for a new customer | (A) Property-specific mailbox we create (the Yale 25 Station pattern) by default — recommended for a fast go-live: no dependency on the customer finishing their own mailbox connection, no plan requirement. (B) Connected leasing mailbox by default, falling back to a mailbox we create only when the customer can't or won't connect one — matches Western Slope's and Camellia's existing pattern, but blocks on the customer's own IT/OAuth consent step. (C) AppFolio guest-card messaging as the fallback for a customer who will neither connect a mailbox nor buy Max/Plus — needs no plan upgrade at all, but reading replies is fragile (session-replay against AppFolio's UI) and unproven outside the Willows. |
Sources: this page's own audit sections (2 · Inbound guest cards/leads; the two-clients side-by-side table; the household-identity and lead-intake-speed sections above) · new-customer-onboarding-experience-2026-09 · ~/.claude/propflowai/CLAUDE.md · origin/main of the propflowai repo, read 2026-09-09: src/lib/integrations/appfolio/pms-adapter.ts, src/lib/domain/pms/registry.ts, src/lib/domain/pms/message-sender.ts, src/lib/integrations/appfolio/message-sender.ts, src/lib/domain/pms/guest-card-message-reader.ts, src/lib/integrations/email/property-graph-sender.ts, src/lib/data/dynamo/pms-credential.ts, agents/clara/lib/email/parse-lead-source.ts · AppFolio Stack/Reports API public documentation (see the lead-intake-speed section's own source list, above).
Podcast companion: The Week of Whack-a-Mole — the whack-a-mole week, the Willows proof, all four adversarial review rounds (25/19/17/5), the engine, and the three decisions below, in plain English. Audio pending; the brief and outline are already on that page.
What changed in revision 2: two independent adversarial reviews (concurrency/scale; identity/tenancy/consent) plus a 62-row setup-permutation gauntlet found 3 blockers and 9 must-fix gaps in the first draft — mostly a poll cadence and shared-breaker design that couldn't survive scale, a consent model with no cross-customer opt-out mechanism, and a write-back design that could loop or leak. All 12 are resolved below with a concrete design change; the full list, including the should-fix findings adopted along the way, is in the review log. A fourth CRM-of-record mode ("read-only during onboarding") was added — it's Western Slope's actual state today and the three-mode design had no way to say so. See existing mechanisms the engine reuses for what does not change.
What changed in revision 3: two more adversarial reviews ran against revision 2 — reviewer C re-walked every one of the 62 setup rows for identity/consent breaks, reviewer D checked whether the plan can actually be built and operated one dark step at a time. Between them: 3 blocks, 9 must-fix, 4 should-fix, 3 notes, plus a proposed executable-gauntlet appendix (16 findings from D alone, 3 from C — see totals in the review log's convergence line). The two most serious findings were that revision 2's name-agreement fix (B-07) was scoped to the leasing writers only and leaves the shared Person-mint spine — the one place every entity type reuses — fusing (and, on the tenant path, silently renaming) people on bare phone match (C-01), and that the "never auto-enroll AppFolio prospects in outreach" consent rule (ADR-0094) is a Writer-B-only mechanism this design never named as something the unified pipeline has to keep (D-02). Both are now named explicitly, with the rest resolved below: a rules ledger names every rule each writer owns today and where it must survive the merge; the platform opt-out table gets its own ladder step ahead of write-back instead of being implied by write-back's prose; step 1's dedup-key change is split out from the plumbing move and gets its own proof; a legacy-provenance backfill default, a reply-identity backfill policy, and a concrete AppFolio polling-budget worked example are added; and every new gate names an owner and an alert reader. An executable gauntlet merges reviewer D's proposed L-01..L-10 rows with the permutation rows that read FAIL or CANNOT-EXPRESS today into one runnable table, modeled on the portfolio harness's own gauntlet. The L-nn numbering below was a drafting artifact of that merge and never matched the gauntlet's own T-nn ids — see the crosswalk note at the top of the rules ledger, revision 4.
What changed in revision 4: two more adversarial reviews ran against revision 3 — reviewer E (coherence/completeness, reading this cold as the engineer who builds step 1 next week) found 1 blocker, 5 must-fix, 4 should-fix, 2 notes; reviewer F (auditor of every finding from rounds 1–3) found the design converged, with 1 should-fix and 4 notes of its own. Reviewer E's blocker (E-01) re-opens D-02: the rules ledger's "never auto-enroll" row only ever described guest-card.ts's deliberate, ADR-0094-driven refusal to enroll AppFolio guest cards in outreach. It said nothing about AppFolio rental applications — a separately listed intake source — whose writer (rental-application.ts) is fully wired to the same enrollment hook and today enrolls zero applicants only because mapApplicationStage happens to always return a cadence-stop stage, not because of any consent decision. That accident is one stage-mapping change away from auto-enrolling a PMS-sourced applicant in outreach with no consent check at all. Fixed below: the rules ledger now carries one never-auto-enroll row per adapter, stating plainly which enforce it by decision and which merely happen not to trigger it yet, and a new gauntlet row fails loudly the moment an application-sourced lead would be enrolled without consent evidence. The other four must-fix findings split the two name-blind matching functions the rules ledger had conflated into two rows with two gauntlet rows, renamed every stray L-nn citation to the gauntlet's own T-nn ids with a crosswalk line, resolved a real contradiction between this section and the integration map over whether the AppFolio notification email is a lead source, and gave the LeadSignal contract a way to express two source records folding into one. A household-grouping rule-precedence order is now stated explicitly (E-06). All of E's should-fix items and F's should-fix and notes are adopted below; the full list and this round's verdict are in the review log. A commit (#7463, restamping a corrected guest-card contact value onto its Person) landed on origin/main during this review round and directly closes setup-permutation row P-21, which was FAIL as of revision 3 — updated below.
What changed in revision 5: one more adversarial review ran against revision 4 — reviewer G (fresh eyes, reading cold) found 0 blockers, 3 must-fix, 1 should-fix, 1 note (G-01 through G-05; see the review log). G-01 caught a self-contradiction revision 4 introduced in its own two fixes to the same shape: F-02 tightened gauntlet row T-14 to require the review-queue outcome and remove "mint a second Person" as a passing branch, but the new T-19 row E-02 added in the same revision still accepted minting as passing and wrongly cited itself as "the same tightened either/or resolution as T-14" — fixed by tightening T-19 to match T-14 exactly, cross-referenced below. G-02 found origin/main had moved one substantive commit (#7464, two co-pendency fixes) past this document's own re-confirmed tip since revision 4 was written; P-03, P-20, and P-38 are re-verified against the current tip below, with two citation updates and one verdict flip (P-38, PARTIAL → PASS). G-03 fixed a real contradiction between the refactor ladder's step 3.5 and step 5 cells over which one T-08 actually gates — step 3.5's write-back opt-out table cannot run T-08's write-back scenario before write-back itself ships in step 5, so step 3.5's cell no longer claims T-08 "turns green." G-04 adds a stated migration posture for legacy SMS consent records, mirroring D-06's existing name/contact backfill default. G-05 reworded the household-precedence text so gauntlet rows are cited as tests pinning the rule, not as the rule itself. All five are resolved below with a concrete text change; details and this round's verdict are in the review log.
Every bug fixed this week was the same bug wearing a different hat: two doors disagreeing about what a lead is. A phone number matched two different people because one writer trusted it and the other didn't (#7444). A cosigner's application went ungrouped because one writer reads a relationship marker the other one ignores. A unit showed up as two different ids depending on which door the lead walked through (#7441, #7455). A dead person's leftover marker silently broke an entire property's sync because only one of the two writers knew to clean it up (#7439). None of these are new bugs so much as the same design gap resurfacing: PropFlow has two independent places that turn a signal into a lead, an inbound-email/voice path and an AppFolio-sync path, and every rule that lives in only one of them is a place the two doors can disagree. AppFolio is one source today, but it is already one of hundreds a portfolio property might use — other guest-card CRMs, other PMSs (Yardi), listing sites, a website contact form, a phone call, a text. Chasing each disagreement as it's found is whack-a-mole; the fix is one door, not two, with one set of rules everything walks through, so a new source is an adapter, not a rewrite of the rules.
Two writers, two rule sets, one Person mint at the far end. Everything below the People/household boxes is the reply-identity coupling — a second, orthogonal instance of the same "one place decides" problem.
Plain version: reviewer D's attack (D-02, D-03, D-04) found that the diagram above names Writer B's rules but not Writer A's with the same precision, and that two of Writer B's rules — the consent rule and the stage-progression rule — don't appear anywhere else in this document either, so nothing stated that the unified pipeline in step 2 has to keep them. This table is the fix: every rule either writer enforces today, who owns it today, where it lives, and which gauntlet row (below) pins it so a future refactor that drops it fails a test instead of shipping quietly. Revision 4 note (E-03): revision 3 cited these gauntlet rows with an "L-nn" prefix (reviewer D's original proposal numbers) while section 9's actual executable gauntlet built the same rows under a "T-nn" prefix, matching the portfolio harness's own convention — two different id strings for the same rows, so a reader searching this document for "gauntlet row L-09" would never find it. Crosswalk: every L-nn below is renamed to its matching T-nn — L-01→T-01, L-02→T-02, L-03→T-03, L-04→T-04, L-07→T-07, L-08→T-08, L-09→T-09, L-10→T-10, L-11→T-11, L-12→T-12, L-13→T-13, L-14→T-14 — one id space from here on. Revision 4 also splits two rows reviewer E found conflated (E-01, E-02): the never-auto-enroll rule is now one row per adapter instead of one row that only ever described the guest-card writer, and the two independent name-blind matching functions get two rows instead of one that implied a single fix covers both.
| Rule | Owner writer today | Where it lives | Gauntlet row that pins it |
|---|---|---|---|
| Never auto-enroll AppFolio guest-card-sourced prospects in outreach/cadence — ADR-0094's consent decision (SMS consent given to the PMS doesn't transfer to Clara's number), enforced by deliberate design | Writer B (guest cards) only | src/lib/domain/pms/writers/guest-card.ts ~29-40 — the writer's own header comment states the rule and the reason ("ingested prospects are OBSERVATIONAL — never enrolled... ADR-0094 rejected auto-enrollment on consent grounds... THAT DECISION STANDS"); no enrollment hook is wired to this writer at all | T-01 (below) — asserts zero enrollment-seam calls for an AppFolio guest-card-sourced LeadSignal |
| Rental-application enrollment seam — wired, and inert by a stage-mapping accident, not by a consent decision (E-01, new in revision 4) | Writer B (rental applications) only | src/lib/domain/pms/writers/rental-application.ts — onFreshProspectMinted is declared (~line 284) and IS called on every fresh mint (~line 2184), wired through to engagePmsMintedProspect (src/lib/domain/leasing/pms-mint-engagement.ts). That function's own header comment confirms this stands down 100% of applications today only because mapApplicationStage (~line 612) always returns a cadence-stop stage (applied/approved/rejected/not_interested) — never because of an ADR-0094-style consent refusal. Shipping an APPLICANT-ACKNOWLEDGMENT message class (a named, separate product decision, not a filter change) would flip this live with no consent check in front of it at all. | T-18 (new) — fails the moment an AppFolio rental-application-sourced LeadSignal is enrolled in outreach without a recorded consent-evidence record for the channel it would be messaged on; this is the gate that has to exist before the accidental stand-down is ever allowed to lift |
| Stage mirror is one-directional — inquiry→not_interested on an inactive card, inquiry→applied on "Application Completed"; never demotes a progressed prospect, never resurrects a closed one | Writer B only | resolveStage, guest-card.ts ~495 (line drifted from revision 3's ~429-460 citation with unrelated commits since; function and behavior unchanged) — fixed a real Camellia denominator bug | T-02 (below) — a stale/duplicate signal must never move stage backward |
| External-id match / dedup on the AppFolio guest-card uuid, redelivery-safe | Writer B only | guest-card.ts external-id match ~823-828 | P-09 (same-source redelivery, PASS today) plus T-03 (cross-org id-collision case) |
| Application-group suppression (avoid a phantom prospect when a group reference has no filter) | Writer B only | src/lib/domain/leasing/application-group.ts, group-suppression counters ~738-741 in guest-card.ts | P-02 / P-38 (household rows, above) |
| On-behalf refusal (ADR-0101) — Clara never saves a prospect she was told about by someone other than the prospect without the refusal check firing | Writer A only | tools-leasing.ts:614, 2105, 2116 | T-11 (reviewer D's D-04): an on-behalf save attempt must be refused after the merge, same as today |
propflowai.co self-email guard — never mints a prospect from Clara's own outbound address bouncing back as if it were an inbound lead | Writer A only | tools-leasing.ts:2192-2203 | T-12 — a self-addressed signal never mints a prospect |
| SMS-consent provenance capture on the conversation path (distinct from ADR-0094's PMS-side consent gap, above) | Writer A only | resolveProspectIdentity/tools-leasing.ts:2065, 571 | T-13 — consent evidence captured on a Writer-A-sourced signal is never dropped by the merge |
ensurePersonForSignals reuse cascade — name-blind on the tenant path (C-01's fix) | Every entity type's mint (Tenant, Prospect, Vendor, Cotenant, Conversation) — the shared Person-mint spine, not scoped to leasing | src/lib/domain/identity/person-stamp.ts — ensurePersonForSignals's phone-reuse → email-reuse cascade, confirmed name-blind; on the tenant path specifically it feeds tenant-spine-stamp.ts's applyTier1IdentityOverwrite (~line 196), which unconditionally renames the tenant Person on a bare phone match | T-14 — covered by the in-flight name-guard prerequisite PR (C-01, being built now); this row's fix ships with that PR, not separately |
pickBestProspect multi-candidate ranking — name-blind on the prospect path (E-02, new in revision 4, NOT the same function as the row above) | Writer A (conversation path) only | src/lib/domain/identity/resolve.ts — pickBestProspect/gatherProspectCandidates, confirmed to have zero references to ensurePersonForSignals and zero name comparison of its own; sorts purely by recency and property filter. This is a separate, independent code path from the row above — fixing ensurePersonForSignals does not touch this function, and the P-05/P-33/P-40 family-sharing-a-phone FAIL rows (setup permutations, below) exercise this path, not the tenant path T-14 covers. | T-19 (new) — NOT covered by the in-flight C-01 PR; a new prerequisite of its own, needed before step 2 can honestly claim both name-blind matchers are fixed |
Step 2 of the refactor path (below) is the step that folds these into one pipeline; its Deletes/Keeps columns now point at this table by name instead of re-describing each rule in prose, and every row above gets a pinning test in the unified pipeline before step 2 is called done (D-02, D-03, D-04). The never-auto-enroll rule is a source-keyed rule by design (F-04): ADR-0094's consent gap is specific to PMS-sourced signals, not a rule every source must carry identically — "not re-derived from source type at call time" (below, step 2) means the check itself is implemented once, not re-invented per call site, not that every adapter must refuse enrollment the same way a source with no PMS consent gap would.
Plain version: every source — AppFolio, a website form, a phone call, a text, Yardi, a generic webhook — gets its own small adapter that translates that source's shape into one common shape. That common shape runs through exactly one pipeline: figure out who this is, which unit/property they mean, which household they belong to, then write. One lead comes out the other end, with a record of exactly where it came from and a pointer back to the source system. Writing back to the customer's PMS — if they want that — is a separate, later step, not folded into the same code that decides who someone is.
The dashed line into reply identity is deliberate: it is read, never written, by the intake pipeline. Intake decides nothing about who Clara replies as.
One shape every adapter must produce, so the pipeline only has to know one thing. Changed in revision 2 (review findings A-12, B-02, B-09 below): the dedup key now names the org, not just the source record; person fields carry their own provenance instead of last-write-wins; and the role field's cross-PMS limits are named instead of implied.
jpco vs situsgroup). New in revision 2 (A-12): an AppFolio guest-card uuid is only unique within one database — src/lib/integrations/appfolio/resolve-account.ts already resolves every AppFolio write fail-closed, from the property record, specifically because a global default is "a live cross-tenant-write hazard" (its own header comment); the dedup key inherits that discipline instead of trusting uuid non-collision across jpco.appfolio.com and situsgroup.appfolio.com.appfolio_guest_card, appfolio_notification_email, website_form, yardi, voice_call, sms, generic_webhook).(org/account id, source, source record id), not (source, source record id) alone — the revision-2 fix for A-12.ProspectInquiry rows already persisted under the old bare-unit-id shape are read compatibly — the read path accepts both shapes — they are never re-keyed. A re-key would move a DynamoDB partition/sort key built from the unit id, which is exactly the kind of silent partition move this design must never cause; compatible reads cost nothing extra and avoid that risk entirely."(Co-signer for [name])" name-text convention (#7424). Yardi/RealPage do not expose an equivalent text marker in the API tiers this design has looked at; role there lives in structured occupancy/lease-party fields the Yardi/RealPage adapter (refactor step 6) has not been scoped against yet. The contract stays source-agnostic, but each new adapter's build-out must state, in writing, what field it populates role from — or that it cannot, and defaults every applicant to primary until it can. A source that cannot populate role is not silently treated as "no cosigner"; it is a named gap on that adapter's own step in the refactor table.{channel, source, timestamp, evidence} per channel the person has been reached on, not one blanket flag. Changed in revision 2 (B-01, B-03) — see consent and cross-customer opt-outs, below, for the full model; today's SmsConsentRecord (agents/clara/lib/data/types.ts:2167-2176, PK CONSENT#{phone}) records consent per phone number with no channel field at all, so it cannot express "consented to AppFolio texting, not to Clara's number" — the LeadSignal contract's consent-evidence field is the fix. evidence's own shape, new in revision 4 (E-07): {text, ip, userAgent}, mirroring today's SmsConsentRecord fields (consentText, ip, userAgent) rather than leaving it an untyped blob — a source that can't populate one of the three (e.g. a phone call has no ip/userAgent) leaves it null, never a placeholder value.{foldedSourceRecordIds: [{org, source, sourceRecordId}, ...], survivingSourceRecordId: {org, source, sourceRecordId}}. This is how the notification-email trigger and the poll's own delivery of the same guest card (gauntlet row T-04) — or a guest card and a duplicate website-form submission for the same person, once that adapter exists (step 6) — collapse to one lead without either contributing record's provenance getting silently dropped: every folded id is appended to the raw-payload-pointer list above, and the surviving id is the one the pipeline's dedup key and every downstream write key off going forward.Adding a new source is: write one adapter that emits LeadSignal, add its fixtures to the household/lead corpus, add a corpus slice from real data if one exists. The pipeline — identity resolve, unit resolve, household resolve, write — does not change. That is the whole point: today, adding a new intake path means someone has to decide whether it plugs into Writer A's rules, Writer B's rules, or invents a third set. Under this contract there is only one set of rules to plug into.
Plain version: three sources can each hand the pipeline a slightly different version of the same person's name or contact info. Something has to decide which one sticks, and it has to write down who said what and when — not just silently overwrite. New in revision 2 (B-02): the resolver already has exactly this discipline for settings (portfolio-architecture-how.html §04: "every effective value has provenance... a chain node, a default or a floor"); person fields had no equivalent, so whichever writer ran last silently overwrote the others' spelling with no record of who touched the legal name last — the same class of bug (#7424, cosigner text in a name field) recurring one layer up.
{value, setBy: source, setAt: observed-at, supersedes} — the resolver's provenance shape, applied to person fields instead of settings.supersedes chains the history, the same way MAPLOG# keeps the full undo trail for settings, so a wrong precedence call can be audited and reversed without reconstructing it from source logs.setBy/setAt — the precedence rule is meaningless on cutover day unless both sides of the first comparison have a value. The backfill default: synthesize setAt from the row's own updatedAt, set setBy: 'legacy-unknown', and rank legacy-unknown below every named source, so any new, attributed observation always wins over an unattributed legacy one on the very first comparison after the switch flips. This is a one-time synthesis at read time (no bulk rewrite of existing rows required) — gauntlet row T-07 (below) is the case that proves it against a legacy-shaped fixture.{value, setBy, setAt, supersedes} covered names and contacts in revision 2 but not the grouping decision itself — which household a person was folded into, and by which rule (cosigner marker, shared contact, AppFolio group id, or the unresolved-cohabitation default, below). The household-resolve stage now writes the same shape on its own output: value = the group id assigned (or "ungrouped"), setBy = the rule that decided (cosigner-marker / shared-contact / appfolio-group-id / unresolved-cohabitation-default), setAt = when, supersedes = the prior grouping if a later signal changes it. This closes the gap C-03 named: today a wrong name overwrite is auditable, a wrong household fuse or split was not.appfolio-group-id) — the source's own explicit grouping signal, when it exists, always wins over an inferred one.cosigner-marker) — the LeadSignal contract's role/cosigner field, read when no group id is present.shared-contact) — a matching phone or email between two LeadSignals, read only when neither of the above fired, and only after the name-agreement gate (the ensurePersonForSignals/pickBestProspect fixes, pinned by gauntlet rows T-14/T-19 above — new in revision 5, G-05: those rows are the tests that pin this gate, not the gate itself) clears — a bare contact match is never sufficient on its own.unresolved-cohabitation-default) — no grouping signal at all; the two people stay separate individuals, graded as the unresolved-cohabitation class (B-08), never silently fused and never silently swallowed into "zero failures."supersedes entry citing the rule and value it lost to, never a silent pick and never averaged.Plain version: the same lead can arrive twice (AppFolio's poll re-sends the whole list; a webhook can retry), leads can arrive out of order (an application can land before the guest card that created it), the same tick can start again before the last one finished, and at a few thousand units there will be a lot more of all of this happening per minute than there is today. The pipeline has to be built assuming all four from day one, not patched in later. This section changed substantially in revision 2 — the original draft's "60-second incremental pull" and "each adapter owns its own rate-limit" claims did not hold up against the code that exists today; see findings A-01, A-02, A-03, A-05, A-07, A-10 in the review log.
src/lib/domain/pms/writers/guest-card.ts's own comment states the fetch is unwindowed (status: 'all'), so every tick re-reads the full card list. Before committing to any flat cadence, the date-filter question gets an actual answer: a live probe against a real AppFolio database (not "appears to ignore"), run once per plan tier we support. If the report genuinely cannot be filtered server-side, cadence is stated as a function of property size and the account's remaining API budget (a 5,000-card cold-start customer polls slower than a 50-card one), never a flat 60 seconds for every property regardless of size.guest-card.ts already fingerprints a card's content and skips re-processing an unchanged one (its own comment: "fingerprint the card's CONTENT, persist the fingerprint on the prospect it landed on, and on the next tick skip the identity reads for a card whose fingerprint is unchanged") — the polling adapter reuses this pattern and emits a LeadSignal only on a fingerprint change, not on every row it observes. Without this, provenance storage and pipeline load grow with poll frequency for every unchanged row, forever.retryOnRowConflict (src/lib/domain/pms/writers/utils.ts) bounds damage from two overlapping runs racing on the same row — it does not stop the runs from overlapping, and nothing in lambda/appfolio-sync/handler.ts today stops a new tick starting while the prior one for the same property is still in flight. A DynamoDB conditional-write lease per connection (property + AppFolio database), held for the duration of a tick and released or TTL'd on completion, skips a new tick outright rather than launching a second one on top of the first. retryOnRowConflict stays as the second line of defense for the residual cross-job case (guest-card sync vs. application sync vs. an explicit manual invoke), not the only one.src/lib/platform/resilience.ts's appfolioBreaker is a single module-level circuit breaker, not scoped per AppFolio database or per property — its own file documents the incident this already caused ("the balances cron's own burst opened the shared breaker mid-run, so every property still queued behind the tripped one in that invocation failed too, '0/2 properties synced'"). One property's bad data or transient outage must never stop a different property's sync in the same invocation. The breaker (and the underlying rate-limiter) is scoped per AppFolio-database connection before any cadence increase ships, so a faster poll doesn't multiply this failure mode by however much faster it runs.ADR-0094 uses so a later application submission merges onto the ingested prospect instead of duplicating it), an email's message id, a call's conversation id — and the pipeline dedupes on (org/account id, source, source record id) (A-12, LeadSignal contract above), never on inferred identity alone. Inferred identity is exactly the layer that fused two different people together in #7444.person-stamp.ts's own doc comment names this as an accepted gap ("a crash between person-mint and relation-row-write already leaves an orphan Person row in DDB, by design"). Every stage states its own redo key — the value it re-derives its output from, not a stale closure — so re-running the full pipeline from scratch against the same LeadSignal, on top of whatever partial state a crash left, converges to the same result rather than double-processing. retryOnRowConflict's own discipline (re-read the fresh row, re-derive from the row, bounded retries, rethrow on exhaustion) is the model. Partial-failure recovery rule: a crash between any two stages is recovered by redelivering the same LeadSignal and re-running the pipeline from stage 1 — never resuming mid-pipeline from assumed state — because every stage's redo key makes the earlier stages' outputs a no-op to recompute. "Partial-pipeline-crash-replay" is added as an eighth grading class in the stress-test plan (below), alongside the seven already named.observed-at on the LeadSignal decides which of two present records is older, but says nothing about a child signal (an application, a cosigner marker referencing a group id) arriving before the parent guest card/person it references exists in the pipeline's world yet — the actual shape of #7439 (orphan dedup sentinels) and the application-group writer's own "no filter → phantom prospect" history. The pipeline design now states this case explicitly: a LeadSignal whose parent reference can't be resolved is persisted as a provisional record, tagged with what it's waiting for (the specific parent id/group id), and reconciled automatically on the next signal that resolves that reference — never silently dropped, and never used to mint a floating Person that a later-arriving parent must then merge into. The application-before-guest-card sequence resolves this way: the application creates the lead immediately, with its role/cosigner marker intact, and marks its own guest-card pointer pending until the guest-card poll or notification catches up and resolves it.historyFullyRead/liveSinceIds cutover and per-connection cursor/wall-clock-budget/fail-soft-resume mechanism already proven for guest-card messages (lambda/appfolio-sync/handler.ts, ~lines 2867-2922 and ~1879-2140) rather than leaving cold start undesigned — a historical card is tagged as history, not treated as a brand-new live lead by notification alerts or future write-back, and a backfill that can't finish in one invocation resumes instead of restarting.appfolioPolicy comment, resilience.ts); other sources will have their own ceilings. Each adapter owns its own rate-limit and backoff — the pipeline downstream of LeadSignal is source-agnostic and does not need to know any of that. At org/customer-count scale (toward 50-100 customers), a stated infra-layer fan-out/backpressure ceiling across orgs sharing the same DynamoDB tables and the same AppFolio rate limiter prevents the same "0/2 properties synced" failure class from recurring at org granularity as it did at property granularity (A-11) — this is a note for the infra layer, separate from the per-org business-logic isolation claim below.
| Customer (real unit count) | Cadence | Requests/database/month | % of the 1 req/sec ceiling (2.59M req/mo max) |
|---|---|---|---|
| Western Slope — ~250 doors | 5 min | 30 × 24 × 60 / 5 = 8,640 | ≈0.33% |
| Situs Group — 640 units (live AppFolio dashboard count) | 5 min | 8,640 | ≈0.33% |
| Situs Group — 640 units | 60 sec (revision-1's original flat cadence) | 43,200 | ≈1.7% |
| Situs Group — 640 units | 15 min (current guest-card schedule, schedules.json) | 2,880 | ≈0.11% |
Request count is not the binding constraint at any real customer's size today, at any cadence in this range — the ceiling has slack of two orders of magnitude. What is not yet a measured number: the per-tick payload size (bytes per guest-card row in the Reports API response), which is what actually grows with card count × frequency. No live capture of that byte size exists in the corpus today, so it is named here as an open measurement, not invented — capture it from one real tick against a Plus/Max database before committing cadence past 5 minutes for a 500+ unit customer, and set the per-database breaker to trip on payload-bytes/tick past a stated threshold once that number exists, not on request volume alone (request volume is proven safe by the table above; unmeasured payload growth is the actual risk A-02's breaker redesign has to cover).
Plain version: once write-back ships, PropFlow's own guest-card write must never look like a brand-new lead to the poll or notification adapter, and it must never create a duplicate card next to one a leasing agent already typed by hand. New in revision 2 (A-08, B-04): neither case was specified in the original draft.
resolveGroupLinkVerdict's own refuse-on-ambiguity pattern. This inherits the same name-agreement gate C-01 requires inside ensurePersonForSignals (rules ledger, above, row T-14) — write-back's search-before-create does not ship ahead of that gate; step 5 in the refactor table below states this dependency explicitly, not just "after ADR-0094 amendment is decided." A provenance row logs which of the two happened on every write-back write.PMSMessageEvent carries a sentByUs flag (src/lib/domain/pms/guest-card-message-reader.ts:65-77), resolved by comparing the sender id against the PMS seat this account sends as, so PropFlow's own sends are never re-ingested as new inbound signals. The LeadSignal contract gets the same field for guest-card leads: a write-back write carries a provenance marker identifying it as PropFlow-authored, and the poll/notification adapters treat a card carrying that marker as a no-op, never a new lead — modeled directly on sentByUs, not reinvented.Plain version: two blockers from the identity/consent review (B-01, B-03) found that the design's own consent story didn't actually exist as data anywhere in the product, and that a well-meaning feature (syncing PropFlow's leads into a customer's AppFolio account) could get a real person texted by a system they never agreed to hear from. Both are resolved with a concrete mechanism below, not just a corrected sentence.
SmsConsentRecord (agents/clara/lib/data/types.ts:2167-2176, PK CONSENT#{phone}) has no channel field at all, so it cannot even express "opted out of AppFolio's texting but not Clara's." Separately, once write-back creates or updates a guest card in a customer's own AppFolio account, AppFolio's own automatic communications can fire a welcome text from AppFolio's number to a person who only ever consented to Clara's number — with zero PropFlow record that it happened.src/lib/domain/identity/resolve.ts's gatherProspectCandidates already calls cross-org helpers (findPersonsByPhoneAcrossOrgs, findPersonByEmailAcrossOrgs; src/lib/data/store.ts:3147,3179) to look up a contact value across every org's Person rows for prospect matching. The same infrastructure, pointed at a real opt-out check rather than identity matching, is the platform-level link this design was missing — a table keyed OPTOUT#<channel>#<value>, checked before any send on any channel, from any org. This is the cross-org link Gera's platform-architecture design (portfolio-architecture-how.html §05, "an opt-out follows the human") already commits to in prose; this design names the concrete mechanism it runs on.consent evidence field (above) carries {channel, source, timestamp, evidence}. "Consented to AppFolio texting" and "consented to Clara's number" are two different rows, not one flag on a phone number.SmsConsentRecord (agents/clara/lib/data/types.ts:2167-2176, PK CONSENT#{phone}) has no channel field and does not migrate into the per-channel consent-evidence shape — there is no backfill script and none is planned. This is deliberate, not a gap the way D-06 closed the equivalent gap for names: since write-back (step 5) refuses to enable PMS-side texting without a recorded consent-evidence record for the specific channel it would message on, every prospect who consented under today's channel-less record has, by construction, zero per-channel evidence on cutover day, and write-back correctly refuses for them until a fresh, per-channel evidence record is captured going forward — the same fail-closed posture the design uses everywhere else a signal is ambiguous or missing, not a migration this design owes anyone.docs/adr/0094-appfolio-guest-card-sync.md:40,61) is already the right policy in prose — this makes it a data check the write-back adapter enforces, not a behavioral assumption that happens to hold today because write-back doesn't exist yet.Plain version: this design adds several mechanisms that hold, delay, or refuse something a person or a write would otherwise get. Constitution Article III.4 requires any such gate to be registered in the guard replay registry with a 30-day replay before it merges; Article VII.9 requires every alert to name a reader and an action, or it gets turned off. None of the gates below had an owner or an alert reader named anywhere in revision 2 — this table is the fix, and none of these gates merges without its guard-replay-registry entry per Article III.4.
| Gate | What it holds/refuses | Owner | Alert reader & dashboard |
|---|---|---|---|
| Per-connection overlap lease (A-03, step 3) | Skips a new sync tick outright while a prior one for the same connection is in flight | Fede (lead-intake lane) | Per-database breaker/lease-state dashboard (built alongside step 3); lease-skip count read weekly by Fede as part of the lane's weekly change-budget review (Constitution VII.6) |
| Per-database error breaker (A-02, step 3) | Trips a single AppFolio-database connection's sync, without stopping any other property's sync in the same invocation | Fede (lead-intake lane) | Same per-database dashboard; a tripped breaker alerts Fede in #agent-trinity only if it stays tripped past one retry cycle — a single trip-and-recover is not paged |
| Low-confidence name-match review queue (B-07/C-01, step 2) | Routes an ambiguous phone/email match to a scrubbed corpus case instead of auto-merging or forking | Fede (lead-intake lane) until Gera's platform-identity lane takes point on cross-entity identity — not reassigned by this document. Clarified in revision 4 (F-01): this includes tenant-path cases surfaced by the C-01 prerequisite PR specifically — Fede owns triage for those today too, no reassignment date set. | Weekly queue-depth read by Fede; a queue that grows past one week's normal volume with nobody reviewing it is the Article VII.9 case for turning the queue off and fixing the match logic instead of tuning it |
| Provisional-record reconciliation (A-05, step 4) | Holds a child signal (application before its guest card) as pending until the parent resolves | Fede (lead-intake lane) | A pending record that never resolves within 24h alerts in #agent-trinity; read and cleared by Fede |
| Platform-wide opt-out check (B-01/B-03, new ladder step 3.5 below) | Refuses to enable PMS-side texting on a write-back-created card without a recorded per-channel consent | Fede (lead-intake lane) | Every refusal is logged with the citing opt-out record; Fede reads the refusal log as part of write-back's own weekly review once step 5 is live — no refusal should ever be silent |
Both reviews flagged specific patterns already built and proven elsewhere in the product that this design must reuse rather than reinvent. Listed once, here, so no future step re-derives one of these from scratch.
src/lib/domain/pms/writers/guest-card.ts (fingerprints a card's content, skips re-processing an unchanged one on the next tick). Reused by the polling adapter (A-10).sentByUs write-back-loop guard — src/lib/domain/pms/guest-card-message-reader.ts:65-77 (flags PropFlow's own sends so they're never re-ingested as new inbound signals). Reused by the write-back provenance marker (A-08).historyFullyRead/liveSinceIds cold-start cutover and per-connection cursor/wall-clock-budget/fail-soft resume — lambda/appfolio-sync/handler.ts ~2867-2922 and ~1879-2140. Reused for the LeadSignal pipeline's own cold start (A-06).retryOnRowConflict — src/lib/domain/pms/writers/utils.ts (re-read fresh row, re-derive from the row not a stale closure, bounded retries, jitter, rethrow on exhaustion). Kept as the pipeline's per-record write primitive and the second line of defense against overlap, behind the new per-connection lease (A-03).resolveAppfolioAccountFromProperty, fail-closed per-property account resolution — src/lib/integrations/appfolio/resolve-account.ts. The LeadSignal contract's org/account id (A-12) and the notification-email adapter's org attribution (B-05) both inherit this discipline instead of inventing a weaker lookup.resolveGroupLinkVerdict, refuse-on-ambiguity household grouping — src/lib/domain/leasing/application-group.ts:178-209 (refuses to fold a card into a group when the match is ambiguous, rather than guessing). Extended to the no-group-id, no-shared-contact case (B-08's unresolved-cohabitation class) as the same refusal pattern, not a new one.recordDroppedNotification audit path — src/lib/integrations/email/webhook-processors.ts:524-575. Confirmed today's unknown_subscription branch already alerts (classified failed, not routine — B-06's correction); the new notification-email adapter's own "no matching org/subscription" branch routes through this same path, not a new one.{value, setBy, setAt, supersedes} shape and MAPLOG# full-undo trail (portfolio-architecture-how.html §04). Copied for person/contact fields (B-02) instead of building a second, weaker provenance story.portfolio-architecture-how.html D1/E3); kept as-is. The gap the reviews found (B-01) is in the consent layer riding on top of this shape, not the shape itself.scripts/household-harness/, src/__tests__/household-harness/, npm run household:regress. Extended, not rebuilt, for multi-source replay (section 5).Plain version: before this replaces anything live, it runs against everything real we have — a million real emails, Camellia's actual inbox history, real phone calls and texts, and the mined AppFolio corpus from all three companies — and it has to come out clean. Passing hand-written test cases is not proof; replaying real history is.
scripts/household-harness/, src/__tests__/household-harness/, run via npm run test:harness:household / npm run test:harness:household:replay / npm run household:regress) and already replays real PMS records through the grouping engine, grading against known failure classes. This becomes a multi-source replay: the same harness shape, fed by every source's real corpus, not just AppFolio's.partial-pipeline-crash-replay (A-07 — a crash mid-pipeline, then the same LeadSignal redelivered, must converge to one correct result, not double-process) and unresolved-cohabitation (B-08 — two genuine roommates with no group id and no shared contact correctly stay ungrouped rather than getting fused, and that "correctly separate but not grouped" outcome is tracked as its own class, not silently swallowed into "zero failures"). Every real failure found becomes a permanent scrubbed case per Constitution Article III.1 — reproduced red on the old pipeline, green on the new one, before the fix is called done. New in revision 3 (D-14): the household harness replays a corpus synchronously through the real JSON store in a temp dir — nothing in that shape today can inject a crash mid-pipeline. partial-pipeline-crash-replay needs its own mechanism to be runnable, not just a named class: a stage-N throw behind a test flag, then a second harness invocation redelivering the same LeadSignal. This mechanism is built as part of step 3 (above), before this grading class is added to the suite.Every step below ships dark, gets proven at the Willows, and runs clean against npm run household:regress before the next step starts. No per-property switch survives past its own proof step (Constitution: no per-property switches for correctness behavior); each step names what it deletes. New in revision 2: a "turns green" column, naming which of the 62 setup-permutation rows (below — 24 PASS · 16 FAIL · 3 PARTIAL · 22 CANNOT-EXPRESS today) that step is expected to flip, plus a step order change: the scale/idempotency fixes (A-01/02/03/07/10) move to step 3, ahead of the notification-email adapter, because the notification email is a trigger under the revision-2 design (below), not a source — it can't safely ship before the poll it triggers is itself scale-safe.
Plain version, new in revision 4 (E-10). What a real customer would actually notice, step by step: step 1 changes nothing anyone can see. Steps 2 and 3 fix bugs Fede already knows about (fused people, a stuck sync) but don't turn anything new on. Step 3.5 is invisible — it's the safety rail that has to exist before step 5 can ship. Step 4 makes the AppFolio email that already lands in a mailbox trigger a faster check for new leads, not a new channel. Step 5 is the first step a customer could actually feel — it's what lets a customer keep using their own AppFolio account as their system of record instead of PropFlow replacing it, and it doesn't ship at anyone until Fede turns it on by name. Step 6 is one new intake door at a time (a website form, a different PMS) — routine after step 2, not a redesign each time.
Ladder prerequisite, new in revision 3 (D-09): the "runs clean against household:regress before the next step starts" gate above has no mechanical enforcement today — the harness's own README states plainly, "Not wired into CI today. That is a deliberate, separate decision" (confirmed against origin/main), which means, per Constitution Article VIII.2 ("a rule nobody can check is a wish"), every "gated" claim on every step below currently depends on an engineer remembering to run it by hand. Wiring household:regress into CI as a required check, scoped to this refactor's own file footprint, is a prerequisite for this ladder — it happens before step 1's work starts, not implicitly assumed to already be true. This is still parked, per Fede, for the general repo-wide CI hook; this refactor gets its own narrowly-scoped required check instead of waiting on that broader decision. Also a prerequisite, not a ladder step (new in revision 3, C-01): the graded name-similarity gate inside ensurePersonForSignals — the shared reuse-or-mint spine every entity type composes, not just the leasing writers — is being fixed in code now as a live bug (the shared Person mint's name-agreement default for contact-only reuse, for every caller, not scoped to leasing) and is referenced below as a prerequisite PR, not folded into step 2's own effort estimate; step 2 depends on it landing first.
| # | Step | Deletes | Keeps | Turns green | Effort |
|---|---|---|---|---|---|
| 1 | Split in revision 3 (D-01) into a plumbing half and a behavior-change half, stated honestly as two different claims: (1a) Plumbing, no behavior change: define the LeadSignal contract's shape and route both existing doors (Writer A's conversation path and Writer B's sync path) through it and through a single writer, keeping today's (source, source record id) dedup key as-is. Provable by the existing regression suite alone, as originally claimed.(1b) Real behavior change, its own proof: widen the dedup key to (org/account id, source, source record id) (A-12) — this changes what counts as "the same lead" the moment two different orgs' AppFolio guest-card uuids were ever assumed not to collide. Proven with its own before/after case: two orgs' guest cards sharing a synthetic colliding source-record id (no such collision exists in the real corpus today) must dedupe to two separate leads, one per org — gauntlet row T-03 (below) is this case. Field-level person provenance and precedence (B-02, with the D-06 legacy-backfill default above), {channel, source, timestamp, evidence} consent evidence (B-01/B-03), and the applicant-role cross-PMS limitation (B-09) ship in this half too, since each is itself a stated behavior change, not plumbing. | Nothing behavioral in (1a) — the two call sites collapse into one, but every rule that lived in only one writer keeps firing exactly where it does today. (1b) deletes the bare (source, source record id) dedup key's implicit cross-org non-collision assumption. | Both rule sets, now visible side by side in one place instead of two files (1a); the org-scoped dedup key, person provenance, and consent-evidence shapes (1b). | None yet — contract only. Unblocks every row below. (1b) turns T-03 from untestable to a real regression case. | (1a) 3–4 days · (1b) 3–5 days |
| 2 | Fold the two rule sets into one, naming each rule by the rules ledger above, not re-describing it in prose (D-02/D-03/D-04): every rule in the rules ledger — on-behalf refusal, self-email guard, SMS-consent provenance, pickBestProspect ranking on Writer A's side; external-id match, group suppression, status mirror (resolveStage's one-directional progression, D-03), never-enroll (ADR-0094, D-02) on Writer B's side — becomes a pipeline step that runs for every source, each pinned by the gauntlet row named in the ledger's own column so dropping one fails a test, not a review. Depends on the prerequisite PR named above (C-01): the graded name-similarity check (transliteration/nickname/hyphenation-aware, confidence-scored — B-07) that replaces the bare-phone-match default ships inside ensurePersonForSignals itself before this step starts, not duplicated per-caller and not scoped to the leasing writers only — this step folds the leasing-specific candidate-ranking code into the pipeline on top of that already-fixed shared gate, it does not itself add the gate. A low-confidence match routes to a scrubbed corpus case for engineering review, never an auto-merge or a silent fork, since manual-merge UI is banned. State explicitly that "no relationship signal" defaults to not-grouped (B-08), graded as the new unresolved-cohabitation class, not folded into wrong-fuse. | The second, competing rule set — a shared phone match now either always checks the name or never does, not depending on which writer saw it first. Bare string-equality name matching. | Every individual rule from the rules ledger, now applied uniformly and pinned by its own test. resolveGroupLinkVerdict's refuse-on-ambiguity pattern, extended to the no-signal case. | P-05, P-33, P-40 (family-sharing-phone — name comparison required before match, Willows engine bug 1; the underlying gate is C-01's prerequisite PR, proven here in the leasing path once that PR lands) · P-03, P-20, P-38 (cosigner marker as role field / over-fusion, once autoLinkCoApplicants's successor ships armed by default, no per-property switch) · P-29 (Western Slope's 33 split households — same auto-link mechanism, run once against the backlog). Not flipped by this step alone (C-01): Tenant/Vendor/Cotenant/Conversation identity resolution reuse the same shared gate and are proven by gauntlet row T-14 (below) once the prerequisite PR lands — no permutation row exercises the tenant-sync shared-phone shape today, a gap in the P-table itself, not just the design. Also not flipped by this step (E-02): pickBestProspect's own name-blind ranking on the prospect path is a separate function from ensurePersonForSignals and needs its own prerequisite (gauntlet row T-19, new in revision 4) before P-05/P-33/P-40 can flip — the C-01 PR alone does not cover it. | Rule folding + per-rule pinning tests: 4–5 days · Name-similarity engine (already landed via the prerequisite PR, verified here rather than built here): 0 additional days in this step's estimate (D-10 split) |
| 3 | Scale and idempotency, before any cadence change: per-AppFolio-database poll (not per-property, not a shared account-wide default), content fingerprint as the no-op gate (A-10), per-connection overlap lease replacing reliance on retryOnRowConflict alone (A-03), per-database error breaker replacing the shared module-level appfolioBreaker (A-02), and the live date-filter probe that decides real cadence instead of a flat 60 seconds (A-01) — including the payload-bytes-per-tick capture named in section 4's worked example (D-12), used to set the breaker's own trip threshold once measured. Every pipeline stage gets its stated redo key and the partial-failure recovery rule: redeliver and re-run from stage 1, never resume mid-pipeline (A-07). Add partial-pipeline-crash-replay as a grading class — needs the crash-injection mechanism named in section 5 (D-14: a stage-N throw behind a test flag, then a second harness invocation with the same LeadSignal) built as part of this step, or the class has nothing to run against. | The shared, module-global circuit breaker and rate limiter as the only defense against a bad property degrading every other property's sync in the same invocation. | retryOnRowConflict as the second line of defense, now behind the new lease. | No permutation row flips directly (this is an infrastructure/scale fix, not a lead-shape fix) — it is the prerequisite every row in steps 4–5 that touches the poll depends on; without it, raising cadence would make A-02's "0/2 properties synced" failure recur 15x more often. Gauntlet row T-09 (overlap lease) and T-10 (partial-pipeline-crash-replay, once D-14's mechanism exists) are this step's own regression gates. | 7–10 days |
| 3.5 | New in revision 3 (D-16). Build the platform-level, contact-value-keyed opt-out table (OPTOUT#<channel>#<value>, section 4b) and wire it into every send path, platform-wide. B-01 is rated a blocker in the review log — "write-back and any second concurrent intake channel should not ship at a customer before this exists" — but no step in revision 2's ladder actually built it; it was described only in open decision (e) as a design choice, not scheduled as a deliverable with an owner and an effort number. It moves here, ahead of step 5 which depends on it, rather than being implied by step 5's prose. The design's own read on size stands: "the build is small enough not to block on a separate lane's timeline," built on the cross-org lookup helpers (findPersonsByPhoneAcrossOrgs, findPersonByEmailAcrossOrgs) already proven for prospect matching. | Nothing — new table, new checks on existing send paths. | Every existing send path, now consulting the table before it fires. | P-35 (opt-out platform-keyed, already PASS via a different mechanism — this step gives write-back the same guarantee) · unblocks step 5. Gauntlet row T-08 (corrected in revision 5, G-03): T-08's literal scenario is a write-back write, and write-back does not exist until step 5 — this step builds the opt-out table itself but cannot exercise T-08. T-08 stays CANNOT-EXPRESS through this step; it first reaches PASS at step 5, where the write-back adapter that can attempt the scripted write exists. | 2–3 days, owner and alert reader per section 4c above |
| 4 | Add the AppFolio notification-email adapter — as a trigger, not a lead source (revised from the original draft's "primary intake," see the review log, A-04). Gated before this step's build work starts, new in revision 3 (D-15): confirm the notification email's body reliably carries the true AppFolio guest-card id (the test named in open decision (b), below) — one day, reading 20 real notification emails across at least two properties; 19 of 20 must yield a guest-card id the poll adapter can independently derive, or the adapter build does not start (E-08, new in revision 4: a stated, measurable pass bar instead of "a handful," matching the rigor the D-12 polling-budget worked example applies to cadence) — before the adapter's own 4–6 day build begins, not discovered partway through it since the id-reliability question is cheap to check and the adapter's design branches on the answer. Once confirmed: the email makes the per-database poll fetch immediately, on the same dedup key and through the same fail-closed org-attribution path AppFolio writes already use (resolve-account.ts, reused rather than a fresh weaker lookup — B-05). Its "no matching org/subscription" branch calls the existing recordDroppedNotification path (B-06), not a new one. A LeadSignal whose parent reference can't be resolved yet (an application before its guest card) is persisted provisional, tagged with what it's waiting for, and reconciled on the next signal that resolves it — the application creates the lead immediately with its role intact and marks its own guest-card pointer pending (A-05). | The "primary intake" framing itself — the poll (step 3) stays the thing that actually reads and writes leads. | The 15-minute poll's role as the intake of record; the notification email only ever makes it run sooner. | P-09 (cross-source dedup — now expressible: one dedup key, one adapter chain, no second independent writer to disagree with the poll) · P-10 (application-before-guest-card ordering) · P-16 (Yale 25 duplicate lead across the shared jpco mailbox and portfolio number, same cross-source-dedup mechanism). Gauntlet row T-04 (below) is this step's own regression gate — expected FAIL/CANNOT-EXPRESS until this step ships. | 1 day (id-reliability gate) + 4–6 days (adapter build), after step 3 |
| 5 | Write-back as its own step, honoring the customer's CRM-of-record mode — now four modes: PropFlow-only, PropFlow replaces, write-back to their CRM, and read-only during onboarding (new in revision 2 — Western Slope's actual state today, previously inexpressible). Every write-back write runs through the same identity-resolve stage as intake — search the target CRM by contact value and require name agreement before creating, never contact alone (B-04, revised C-02) — and carries a sentByUs-style provenance marker so the poll/notification adapters read it back as a no-op, never a new lead (A-08). Before enabling PMS-side texting on any card it creates, checks the platform-level, contact-keyed opt-out table (now step 3.5, not implied here) and the per-channel consent-evidence record (B-01, B-03) — never bundled into the intake writer that decides identity. Depends explicitly, new in revision 3 (C-02): the shared name-agreement gate (C-01's prerequisite PR, verified in step 2) — write-back's search-before-create does not ship ahead of it. B-10's CRM-mode-transition/PMS-swap backfill-reconciliation design is its own prerequisite design work, not included in this step's estimate (D-11) — it is scoped as its own deliverable before any customer's mode changes mid-life, per open decision (a). | Nothing on the intake side; adds a new adapter class. The "no fourth mode" gap in ADR-0094's three-mode framing. | PropFlow-only behavior as the default until a customer's mode says otherwise. | P-13, P-62 (durable crmOfRecord setting, not a migration-drain flag) · P-24 (Situs write-back to AppFolio) · P-28 (Western Slope's read-only-during-onboarding mode, named instead of an ad hoc lock). Gauntlet row T-08 is the consent-refusal case this step must pass — corrected in revision 5 (G-03): step 3.5 cannot run T-08 at all (no write-back adapter yet to attempt the write), so this is T-08's first PASS, not a second confirmation of a step-3.5 pass. | Write-back adapter itself: 7–10 days, after step 3.5 and ADR-0094 amendment is decided · B-10's transition/backfill design: separately scoped, not included above (D-11) — estimated once scoped |
| 6 | Add the remaining sources (website form, generic webhook, TurboTenant/Tenant Turner, Yardi) as adapters, one at a time, each with its own fixtures and, where real data exists, its own corpus slice. Each adapter's build-out states in writing what field (if any) it can populate role from (B-09) before it ships. | Any source-specific glue code that predates the contract. | The pipeline, unchanged since step 2. | P-19 (TurboTenant/Tenant Turner adapter) · P-26 (cross-adapter dedup once that adapter exists) · P-49, P-50 (generic webhook / no-PMS customer). Yardi/RealPage live reads (P-47, P-48, P-14) stay CANNOT-EXPRESS past this step — they need a live PMS client, a separate integration effort from the adapter shape this step builds. | 3–5 days per adapter |
What this refactor does not touch, and why: rows that depend on org-structure decisions outside this design (Yale 25's company record, X11 — P-15P-51P-52), on a different product's own missing feature (AppFolio's withdraw step still needing a human click inside AppFolio itself — P-08, P-42), on the isolation gauntlet's own separate lane (staff dual-company login, batch reads, misconfigured lines — P-23, P-56, P-57, P-58, P-59), or on vendor-identity scoping across AppFolio databases (B-13 — a real, must-fix cross-tenant collision in canonicalVendorId, but a vendor/staff-identity fix, not a lead-intake one; handed to Gera's lane as its own finding, not solved here) stay open after this refactor and are tracked on their own lanes, not silently folded into "done" here.
Changed in revision 2: decision (a) now has four modes, not three (the setup-permutation gauntlet exposed a real customer — Western Slope — the three-mode design couldn't name). Decision (b) is corrected: the notification email was recommended in revision 1 as the primary intake channel; the review found that claim unproven (A-04) and it is downgraded here to a trigger for the poll, pending the test named below. A new decision (e) is added for the platform-wide opt-out mechanism the consent review (B-01) found missing.
| # | Decision | Options |
|---|---|---|
| a | ADR-0094 reversal on PMS write-back — now four modes, not three | Amend ADR-0094 — recommended. The July decision ("PropFlow is the CRM, no PMS write-back") was right for a customer with no CRM of their own, which is Camellia's situation and the case the ADR was written against. It doesn't fit every customer: Yale 25 Station runs on RealPage and PropFlow is meant to replace it outright, other customers may want to keep AppFolio (or another system) as their CRM of record and have PropFlow write back into it, and — found by the setup-permutation gauntlet, row P-28 — a customer can be under an explicit owner decision to allow reads but forbid writes during onboarding, which is Western Slope's actual state today ("it stays read-only, no writes there, the lock stays on," Fede, 2026-09-09) and which none of the original three modes could express. Model this as a per-customer setting with four modes — PropFlow-only (Camellia's posture, no write-back), PropFlow replaces (Yale 25 Station's RealPage-replacement posture), read-only during onboarding (Western Slope's posture — reads allowed, writes refused by owner decision, not by plan tier, promotable to another mode later by the same explicit decision), and write-back to their CRM (a customer who wants to keep using AppFolio's own guest-card view, gated on the per-channel consent check above) — and require every write-back adapter to honor it. The July rationale doesn't disappear: it said two writers with different rules re-create a split-brain, and a single engine with one write-back adapter per mode is exactly what removes that risk, so the amendment doesn't reopen the original problem, it generalizes the original answer. Mode transitions (a customer moving from PropFlow-only to write-back, or a PMS swap like Yale 25's eventual RealPage-to-AppFolio move) are named as a real gap here (B-10) and get their own backfill/reconciliation design before any customer's mode changes mid-life — not assumed safe by default. Alternative: leave ADR-0094 standing everywhere — rejected, because it would force Situs Group and Western Slope, if they want their CRM kept in sync, into either a second engine we said we wouldn't build, or living without it. |
| b | Primary AppFolio intake mechanism — corrected from revision 1 | (A) The 60-second per-database poll (scale-safe per step 3) is the intake of record; the AppFolio notification email is a trigger that makes it fetch immediately — recommended. Revision 1 recommended the notification email itself as primary intake; the concurrency review (A-04) found this unproven — the email body's guest-card id is undocumented, and if the poll adapter and a notification-email adapter compute different dedup keys for the same underlying card, the same #7444-class identity-fuse the whole redesign exists to prevent recurs across two adapters instead of one. The test that would change this: confirm at the Willows that the notification email's body reliably carries the true AppFolio guest-card id (or another value the poll adapter can independently compute, e.g. a normalized property+unit+contact hash) — if proven, the email can graduate from trigger to an independent, dedup-safe source. Until then it only ever changes when the poll runs, never what it dedupes on. (B) Stack partner webhooks — later, once the partner application is in and "new guest card" is confirmed as a published event; real-time, but a 2–4 week cycle and gated on AppFolio's own event catalog. Not reopened from the integration map's I1 framing beyond this correction. |
| c | Reply identity default for new customers | Already decided as I2 in the integration map, above — (A) a property-specific mailbox we create, recommended as the fast-go-live default. This design's ReplyIdentity setting (step 3) is exactly what makes that default cheap to apply per property without touching intake. Not reopened here. Scope stated explicitly, new in revision 3 (D-08): this default applies to newly onboarded properties only. Situs Group is named elsewhere on this page as having no working reply identity today (P-18) — retrofitting a reply identity onto an already-onboarded property with none is its own per-property activation, gated the same as any other Article IV.6 turn-on, never license to auto-create mailboxes for existing properties under this decision. Backfill policy for properties that already have a working reply identity: stated as "same value, no customer-facing change, verified by a diff" — any migration of an existing property's reply-identity record onto the new ReplyIdentity setting shape must resolve to the exact mailbox/number/session that property already replies from today, proven by diffing the before/after value, not re-derived. If any customer would see a different reply address or number as a result, that is a customer-facing behavior change and needs its own Fede-approved activation, named as such, not folded into this migration. |
| d | Own page vs. this hub section | (A) Stay as this hub section — recommended. One document per initiative, per the standing rule against new doc pages; this design is a continuation of the integration-map and household-identity work already on this page, not a separate initiative. (B) Split into its own page only if this design grows past the point where "one page per initiative" still reads as one initiative — not the case today. |
| e | Platform-wide opt-out mechanism — new in revision 2 (B-01) | (A) A contact-value-keyed opt-out table (OPTOUT#<channel>#<value>), checked by every send path platform-wide, built on the cross-org lookup infrastructure that already exists for prospect matching (findPersonsByPhoneAcrossOrgs / findPersonByEmailAcrossOrgs) — recommended. Smallest build, reuses proven infrastructure, and is the concrete mechanism behind the platform-architecture design's own "an opt-out follows the human" commitment (Gera's portfolio-architecture-how.html §05) rather than a competing design. (B) Wait for Gera's platform-level consent design to land the mechanism — rejected as the default, because B-01 is a blocker: write-back and any second concurrent intake channel should not ship at a customer before this exists, and the (A) build is small enough not to block on a separate lane's timeline. If Gera's own design lands a different but compatible mechanism first, this design adopts it instead — the point is that some platform-level, contact-keyed link exists before write-back goes live, not that this design owns the implementation. Scheduled, new in revision 3 (D-16): this is no longer only a design decision — it's refactor-path step 3.5, above, with its own effort number and owner, ahead of step 5's dependency on it. |
Convergence, updated in revision 5. Rounds run: 4. Round 1 (reviewer A — concurrency/scale, 12 findings, 1 blocker; reviewer B — identity/tenancy/consent, 13 findings, 2 blockers) produced revision 2, closing 3 blocks and 9 must-fix findings, adopting 12 should-fix/note findings, and deferring 1 (B-13, Gera's lane). Verdict: one more revision. Round 2 (reviewer C — identity/consent re-walk of all 62 setup rows against revision 2, 3 findings, 1 block; reviewer D — buildability/operability, 16 findings, 2 blocks, plus a proposed gauntlet appendix) produced revision 3, closing all 3 blocks and 9 must-fix findings, adopting 4 should-fix and 3 note findings, and merging D's proposed gauntlet appendix into section 9. Verdict: one more revision. Round 3 (reviewer E — coherence/completeness re-read of revision 3, 12 findings, 1 blocker; reviewer F — full audit of every finding from rounds 1–3, 5 new findings, 0 blockers) produced revision 4, closing E's blocker and 5 must-fix, E's 4 should-fix and 2 notes, and F's 1 should-fix and 4 notes. Verdict: one more revision (E); converged (F) — F independently re-checked all 44 prior findings against current code and found every one still holds. Round 4 (reviewer G — fresh eyes, reading revision 4 cold, 5 findings, 0 blockers) produced this revision (5). G's verdict was "not converged — one more revision": F-02's fix to T-14 and E-02's new T-19 row, both landed in revision 4, were never cross-checked against each other, and G-01 caught the result — T-19's either/or outcome, unreconciled against T-14's same-revision tightening, was a real self-contradiction on the design's single most safety-critical row. G-01 through G-03 (all "must fix before build") and G-04 (should fix) and G-05 (note) are resolved below with a concrete change — T-19 tightened to match T-14 exactly (G-01), P-03/P-20/P-38 re-verified against the current origin/main tip with P-38 flipped PARTIAL → PASS (G-02), the step 3.5/step 5 contradiction over gauntlet row T-08 corrected (G-03), a legacy SMS-consent migration posture stated (G-04), and the household-precedence text reworded to cite T-14/T-19 as pinning tests rather than the rule itself (G-05). Open items after round 4: none blocking. The prerequisites this document has always named as build-order facts, not design gaps, remain open in code as of this revision, unchanged by round 4: the CI wiring for household:regress (D-09), the ensurePersonForSignals name-agreement prerequisite PR (C-01), and the separate pickBestProspect prerequisite (E-02) and the rental-application enrollment-seam gate (E-01/T-18) — G-02 confirmed the one substantive commit landed since revision 4 (#7464) touches neither. B-10's CRM-mode-transition design and B-13's vendor-identity fix remain named, scoped-elsewhere gaps, exactly as in round 1's resolution.
Every finding from the two adversarial reviews (reviewer A — concurrency/scale, 12 findings, 1 blocker; reviewer B — identity/tenancy/consent, 13 findings, 2 blockers), and how this revision addresses it. All 3 blockers and all 9 must-fix findings are resolved with a concrete design change, above; every should-fix and note is resolved or explicitly deferred to a named, separate lane below — none were silently dropped.
| ID | Severity | What changed |
|---|---|---|
| A-04 | blocks | The notification email is downgraded from "recommended primary intake" to a trigger that makes the per-database poll fetch immediately — it is never treated as an independent lead source with its own dedup key, closing the two-adapter same-card collision the review found. Section 3/4 and open decision (b), above; the test that would prove the email can safely graduate to a real source is named there. |
| B-01 | blocks | A platform-level, contact-value-keyed opt-out table, checked by every send path regardless of org, built on the existing cross-org lookup helpers. Section 4b and open decision (e), above. |
| B-03 | blocks | Write-back never enables PMS-side texting on a card it creates without a recorded, per-channel consent record for that specific channel; where the customer's plan allows it, AppFolio's own automatic communications are disabled on adapter-created cards. Section 4b, above. |
| A-01 | must fix | Cadence is no longer a flat 60 seconds — it is stated as a function of the live date-filter probe's result and the property's own size/API budget. Section 4, step 3. |
| A-02 | must fix | The circuit breaker and rate limiter are scoped per AppFolio-database connection, replacing the single shared module-level appfolioBreaker that today lets one bad property stop others' syncs in the same invocation. Section 4, step 3. |
| A-03 | must fix | A per-connection DynamoDB conditional-write lease skips a new tick outright while a prior one is in flight, instead of only bounding damage after two ticks race. retryOnRowConflict stays as the second line of defense. Section 4, step 3. |
| A-05 | must fix | An unresolved parent reference (an application before its guest card) is persisted as a tagged provisional record and reconciled on the next signal that resolves it — the application creates the lead immediately with its role intact and marks its guest-card pointer pending, rather than being silently dropped or minting a floating Person. Section 4. |
| A-07 | must fix | Every pipeline stage states its own redo key; the partial-failure recovery rule is redeliver-and-rerun-from-stage-1, never resume-mid-pipeline. partial-pipeline-crash-replay added as an eighth grading class. Section 4 and section 5. |
| B-02 | must fix | Person name/contact fields carry {value, setBy, setAt, supersedes} provenance, borrowed from the resolver's settings-provenance shape. Named precedence: PMS of record wins for legal name, latest verified contact wins for phone/email, every prior value is kept via supersedes. Section 3, "Person-field precedence." |
| B-04 | must fix | Every write-back write runs through the same identity-resolve stage as intake — search the target CRM by contact value before creating — so a staff-typed card written the morning after Clara mints a phone-call lead is matched, not duplicated. A provenance row logs which happened. Section 4, "Write-back never re-ingests its own writes." |
| B-05 | must fix | The notification-email adapter resolves its org through the same fail-closed path AppFolio writes already use (resolve-account.ts) — matching the email against a property already known to belong to exactly one org, refusing when ambiguous — instead of a fresh, weaker lookup from email-body text alone. Section 4, step 4. |
| B-13 | must fix | Not solved in this design. canonicalVendorId (vendor-write-invariants.ts:46-73, confirmed against origin/main) mints vendor_appfolio_<af.vendorId> with no database qualifier, so two unrelated vendors sharing a numeric id in jpco.appfolio.com and situsgroup.appfolio.com collide on write. This is a vendor/staff-identity fix, not a lead-intake one — it is named here as a real, confirmed, must-fix cross-tenant hazard and handed to Gera's lane as its own finding, with a note in the refactor path (section 6) that this design does not depend on "vendor identity is platform-unique" being true yet. |
| A-06 | should fix | Adopted. The new pipeline reuses the historyFullyRead/liveSinceIds cutover and per-connection cursor/wall-clock-budget/fail-soft resume already proven for guest-card messages, instead of leaving cold start undesigned. Section 4. |
| A-08 | should fix | Adopted. A sentByUs-style provenance marker (modeled on guest-card-message-reader.ts:65-77) lets the poll/notification adapters read a write-back write as a no-op, not a new lead. Section 4, "Write-back never re-ingests its own writes." |
| A-09 | should fix | Adopted. person-stamp.ts's documented fallback to the admin merge UI on a name-mismatch claim race is a manual-decision UI in the identity path, which the standing autonomous-linking rule bans. It goes on the delete list, replaced by the graded name-similarity check (B-07) that routes low-confidence cases to a scrubbed corpus case for engineering review instead of a human click. Section 6, step 2. |
| A-10 | should fix | Adopted. The polling adapter emits a LeadSignal only on a content-fingerprint change, reusing the existing fingerprint pattern in guest-card.ts, not on every row observed each tick. Section 4, step 3. |
| A-12 | should fix | Adopted. The LeadSignal dedup key is (org/account id, source, source record id), not just (source, source record id) — an AppFolio guest-card uuid is only unique within one database. Section 3. |
| B-06 | should fix | Adopted, with a correction: unknown_subscription already alerts today (webhook-processors.ts:524-575 classifies it failed, not routine, and writes an audit row) — the original draft's "silently dropped" framing was wrong about today's code and is corrected to "already alerts; reuse." The real risk is a new adapter reinventing this branch weaker; the notification-email adapter's own no-match case is required to call the same recordDroppedNotification path. Section 2 diagram caption and section 4, step 4. |
| B-07 | should fix | Adopted. Bare name equality is replaced by a graded, transliteration/nickname/hyphenation-aware similarity check producing a confidence score; low-confidence cases route to a scrubbed corpus case, never auto-merge or silent fork (manual-merge UI is banned). Section 6, step 2. |
| B-08 | should fix | Adopted. "No relationship signal" is stated explicitly as defaulting to not-grouped, tracked as its own unresolved-cohabitation grading class rather than folded into "zero failures." Extends resolveGroupLinkVerdict's existing refuse-on-ambiguity pattern rather than inventing a new one. Section 5 and section 4b's reuse list. |
| B-09 | should fix | Adopted. The LeadSignal contract's role field now states its only proven source is AppFolio's cosigner text convention; each new adapter must state in writing what field (if any) populates role, and Yardi/RealPage are named as unconfirmed rather than assumed to work under the same contract. Section 3. |
| B-10 | should fix | Adopted. CRM-mode transitions and PMS swaps are named as an open gap requiring their own backfill/reconciliation design (mirroring the change catalog's scripted/dry-run/pre-image/revert treatment for mergers and transfers) before any customer's mode changes mid-life. Open decision (a), above. |
| B-11 | should fix | Adopted. The raw payload pointer lives inside the same per-org partition the identity wall already defines, not a separate ungated blob store; identity/household/eligibility decisions read only structured LeadSignal fields, never the raw payload, closing the fair-housing self-inflicted-risk path. Section 3. |
| A-11 | note | Adopted. A fan-out/backpressure ceiling across orgs sharing the same DynamoDB tables and AppFolio rate limiter is named as an infra-layer requirement, separate from the per-org business-logic isolation claim, so the "0/2 properties synced" failure class doesn't recur at org granularity as customer count grows. Section 4. |
| B-12 | note | No design change — status note only. Confirmed correct: autoLinkCoApplicants stays on the delete list once its Willows proof is clean, but the first-pass proof (P-05/P-33/P-40, P-38) isn't clean yet, so the switch isn't ready to delete today. Tracked in section 6, step 2's "turns green" list rather than declared done early. |
| — Round 2 (revision 3), reviewer C and reviewer D — | ||
| C-01 | blocks | Revision 2's B-07/A-09 name-similarity fix was scoped to the leasing writers only; it does not reach the shared ensurePersonForSignals spine every entity type (Tenant, Vendor, Cotenant, Conversation) composes, and on the tenant path specifically, a name-blind phone match is followed by an unconditional rename via applyTier1IdentityOverwrite — a silent identity overwrite on a live tenant record, worse than a fuse. Not added as a ladder step: named as a prerequisite PR — this is a live bug being fixed in code now (the shared Person mint's name-agreement default for contact-only reuse, for every caller, not scoped to leasing). Refactor-path step 2 above states the dependency explicitly and points at the rules ledger's own row (T-14) for it; the rules ledger and gauntlet row T-14 (section 9) are the fix's permanent regression coverage, including the tenant-sync fixture no permutation row exercised before this revision. Section 2a, section 6 preamble and step 2, section 9. |
| C-02 | must fix | Write-back's "search before create" (B-04) inherited intake's identity-resolve stage as-is, which C-01 shows is a bare contact match with no name gate — a write-back write for a genuine new lead sharing a phone with an unrelated stranger's existing PMS card would attach to the stranger's card instead of creating a separate one, mutating a real customer's real record. Fixed: write-back's search-before-create now requires contact match and name agreement, never contact alone; a low-confidence disagreement refuses rather than attaching, mirroring resolveGroupLinkVerdict's refuse-on-ambiguity pattern. Explicitly dependent on C-01's prerequisite PR landing before step 5 ships, stated in the ladder rather than left implicit. Section 4 "Write-back never re-ingests its own writes," section 6 step 5, gauntlet row T-08. |
| C-03 | should fix | Adopted. Household/group-membership decisions get the same {value, setBy, setAt, supersedes} provenance shape B-02 gave to name/contact fields — which rule assigned a group (cosigner marker / shared contact / AppFolio group id / unresolved-cohabitation default), when, and what it superseded — so a wrong-fuse or wrong-split case is auditable the same way a wrong name overwrite now is. Section 3, "Person-field precedence." |
| D-02 | blocks | The never-auto-enroll consent rule (ADR-0094 — AppFolio-sourced prospects are observational, never enrolled in outreach without a founder decision reversing the ADR) lives only in Writer B today and appeared nowhere in revision 2's Deletes/Keeps columns or review log — a real risk that the merged pipeline could silently drop it if the canonical code path descends from Writer A, which never needed the restriction. Fixed: named explicitly in the new rules ledger (section 2a) with its file/line and its own gauntlet row (T-01); step 2 requires the unified pipeline to carry forward the same "declared-but-never-called enrollment seam, pinned by a test" pattern verbatim, not re-derived from source type at call time. Section 2a, section 6 step 2. |
| D-16 | blocks | B-01 (the platform opt-out table) was rated a blocker in round 1 but was only ever described as a design decision (open decision e), never scheduled with an owner and an effort number — an engineer building write-back (step 5) in order would discover its own hard dependency has nowhere to live in the plan. Fixed: moved to its own ladder step, 3.5, ahead of step 5, with an owner (section 4c) and its own gauntlet row (T-08). Section 6, new step 3.5; open decision (e). |
| D-01 | must fix | Step 1 bundled a true plumbing move (routing both doors through one writer) with a real behavior change (widening the dedup key to include org/account id, A-12) under one "no behavior change, existing suite proves it" claim. Fixed: step 1 is split into 1a (plumbing, no behavior change) and 1b (the dedup-key widening, its own before/after case — two orgs' guest cards sharing a synthetic colliding id, gauntlet row T-03). Section 6, step 1. |
| D-03 | must fix | resolveStage's one-directional stage-progression rule (never demotes a progressed prospect, never resurrects a closed one — fixed a real Camellia denominator bug) is Writer-B-only and was unnamed anywhere in the ladder, same risk class as D-02 though less safety-critical. Fixed: named in the rules ledger with its own gauntlet row (T-02); step 2 requires it survive the merge, pinned by a test. Section 2a, section 6 step 2. |
| D-06 | must fix | No migration plan existed for Person/contact rows written before the B-02 provenance shape ships — every legacy row has no setBy/setAt, so the very first precedence comparison after cutover would be arbitrary. Fixed: a stated backfill default — synthesize setAt from the row's own updatedAt, setBy: 'legacy-unknown', ranked below every named source — proven by gauntlet row T-07. Section 3, "Person-field precedence." |
| D-08 | must fix | Open decision (c)'s reply-identity default named new customers only, but said nothing about existing properties with no working reply identity today (Situs Group, per P-18) — a gap that could read as license to auto-create mailboxes for already-onboarded properties, a customer-facing change with no per-property switch and no Fede go. Fixed: scope stated explicitly (new-customer default only); a backfill policy for properties that already have a working identity ("same value, no customer-facing change, verified by a diff"); any customer-visible difference is named as its own Fede-approved activation. Open decision (c). |
| D-09 | must fix | The ladder's own between-every-step gate (household:regress clean before the next step) has no mechanical enforcement — the harness's own README says plainly it is "not wired into CI today," a deliberate, separate decision — so every "gated" claim in the ladder depended on an engineer remembering to run it by hand. Fixed: wiring household:regress into CI as a required check, scoped to this refactor's own file footprint, is stated as a ladder prerequisite before step 1 starts — still parked for the general repo-wide CI hook, per Fede, but this refactor does not wait on that broader decision. Section 6 preamble. |
| D-12 | must fix | Section 4's cadence rule ("a function of property size and API budget, never a flat number") never turned the ingredients it cited into an actual number an operator could budget against. Fixed: a worked example table using real customer sizes (Western Slope ~250 doors, Situs Group 640 units) at three cadences, showing request count is not the binding constraint at any real size today (0.11%-1.7% of the 1 req/sec ceiling) — and naming the real, currently unmeasured cost driver (payload bytes per tick × frequency) as an open measurement to capture before cadence increases past 5 minutes for a 500+ unit customer. Section 4, per-org rate limits bullet. |
| D-13 | must fix | None of the design's new gates (overlap lease, per-database breaker, low-confidence-match review queue, provisional-record reconciliation, platform opt-out check) named an owner or an alert reader, though Constitution Article III.4 and VII.9 require both before a gate merges. Fixed: a table naming owner and alert reader for each. Section 4c (new). |
| D-04 | should fix | Adopted. Writer A's four rules (on-behalf refusal, self-email guard, SMS-consent provenance, pickBestProspect ranking) got one hand-wavy sentence in revision 2's Keeps column versus Writer B's per-rule accounting. Fixed: named individually in the rules ledger with file/line and a pinning gauntlet row each (T-11, T-12, T-13, T-14). Section 2a. |
| D-07 | should fix | Adopted. Nothing stated what happens to ProspectInquiry rows already persisted under the old bare-unit-id shape once the property-scoped reference ships. Fixed: read-compatible with both shapes, never re-keyed — a re-key would move a DynamoDB partition/sort key, which this design must never cause. Section 3, LeadSignal contract, property/unit reference bullet. |
| D-10 | should fix | Adopted. Step 2's 5–7 day estimate bundled the name-similarity engine build with the full per-rule accounting D-02/D-03/D-04 found missing — two separable, nontrivial deliverables in one number. Fixed: split — the name-similarity engine ships as the C-01 prerequisite PR (not counted in step 2's own estimate), rule-folding and per-rule pinning tests get their own 4–5 day line. Section 6, step 2. |
| D-11 | should fix | Adopted. Step 5's 7–10 day estimate covered the four-mode write-back adapter but not B-10's own named prerequisite (a CRM-mode-transition/PMS-swap backfill-reconciliation design), which the document already says must exist before any mode change ships. Fixed: named as its own, separately-scoped deliverable, not folded into step 5's number. Section 6, step 5; open decision (a). |
| D-05 | note | Resolved by the D-02/D-03 fixes above — the document's own scoping discipline (naming deferred items explicitly, e.g. B-13) now applies to these two rules as well. No further action. |
| D-14 | note | Adopted. The household harness's synchronous, temp-dir JSON-store replay has no built-in way to inject a mid-pipeline crash for the partial-pipeline-crash-replay grading class to run against. Fixed: named the mechanism (a stage-N throw behind a test flag, then a second harness invocation redelivering the same LeadSignal), built as part of step 3. Section 5, section 6 step 3, gauntlet row T-10. |
| D-15 | note | Adopted. Step 4's notification-email adapter build could start before the id-reliability question open decision (b) names as unconfirmed was actually checked, risking a wasted 4-6 day build. Fixed: the one-day confirmation check is a gate at the start of step 4, separate from and before the adapter's own build clock starts. Section 6, step 4. |
| — Round 3 (revision 4), reviewer E and reviewer F — | ||
| E-01 | blocks (re-opens D-02) | D-02's fix scoped the never-auto-enroll rule to the guest-card writer alone; AppFolio rental applications — a separately listed intake source — are fully wired to the same enrollment hook (rental-application.ts, onFreshProspectMinted called on every fresh mint) and stand down today only because mapApplicationStage always yields a cadence-stop stage, not because of a consent decision. Fixed: the rules ledger now carries a second never-auto-enroll row naming this specific gap plainly ("wired, and inert by a stage-mapping accident, not by a consent decision"), and gauntlet row T-18 fails loudly the moment an application-sourced lead is enrolled without consent evidence. Section 2a, section 9. |
| E-02 | must fix | The rules ledger's pickBestProspect row claimed its fix was "inherited by every ensurePersonForSignals caller" — but pickBestProspect (resolve.ts, Writer A's prospect-path ranking) and ensurePersonForSignals (person-stamp.ts, the shared cross-entity reuse cascade C-01 targets) are two independent functions with zero references to each other; the in-flight C-01 PR fixes only the second. Fixed: split into two ledger rows and two gauntlet rows (T-14 for ensurePersonForSignals, covered by the in-flight PR; new T-19 for pickBestProspect, a new prerequisite the in-flight PR does not cover). Section 2a, section 6 step 2, section 9. |
| E-03 | must fix | Every gauntlet-row citation in the rules ledger and the refactor ladder used an "L-nn" prefix (reviewer D's original proposal numbers); section 9's actual executable gauntlet built the same rows as "T-nn." The two id spaces were never reconciled in writing, so a reader searching for "gauntlet row L-09" would not find it. Fixed: every L-nn citation renamed to its matching T-nn, with a crosswalk line stating the 1:1 mapping at the top of the rules ledger. Section 2a, section 6. |
| E-04 | must fix | The integration map's channel table and its own I1 decision still called the AppFolio notification email "recommended primary intake" with no caveat, while the lead-intake engine's decision (b) states the same email was downgraded to a trigger — decision (b) asserted this correction had been made without the integration map actually being edited. Fixed: the integration map's channel-table status and I1 option (A) now read "trigger for an immediate fetch, not a lead source, pending the Willows template check," pointing at the engine's decision (b). Integration map, "Intake channels" table and open decision I1. |
| E-05 | must fix | Gauntlet row T-04 asserts "provenance shows two source records folded into one," but the LeadSignal contract's raw-payload-pointer field was described only in the singular, with no stated shape for how a folded lead retains two contributing source records' provenance. Fixed: the raw-payload-pointer field is now a list, one entry per contributing source record, and a new fold/merge signal ({foldedSourceRecordIds, survivingSourceRecordId}) states explicitly how two records collapse into one lead. Section 3, LeadSignal contract. |
| E-06 | must fix | C-03 gave household-grouping decisions a provenance shape (which rule decided, when) but never stated what happens when two rules for the same person disagree — e.g. an AppFolio group id says household A, a cosigner marker says household B. Fixed: an explicit, ordered precedence — group id > cosigner marker > shared contact > none — with the losing signal recorded as a supersedes entry, never a silent pick. Section 3, "Person-field precedence." |
| E-07 | should fix | Adopted. The consent-evidence field's own evidence sub-field had no stated shape. Fixed: {text, ip, userAgent}, mirroring today's SmsConsentRecord fields exactly. Section 3, LeadSignal contract. |
| E-08 | should fix | Adopted. Step 4's id-reliability gate ("reliably," "a handful of real notification emails") had no testable pass bar. Fixed: 20 real notification emails across at least two properties, 19/20 must resolve a derivable guest-card id, or the adapter build does not start. Section 6, step 4. |
| E-09 | should fix | Adopted. Section 9's closing paragraph said gauntlet rows were "runnable today... once the unified writer exists" — two contradictory claims in one clause. Fixed: reworded to "runnable today as a baseline against the two existing writers, and re-run unchanged against the merged writer once it lands." Section 9. |
| E-10 | should fix | Adopted. The refactor ladder (section 6) was the one technical subsection without a "Plain version" gloss, despite being the single most Fede-relevant artifact on the page (what ships when). Fixed: a customer-visible-terms paragraph added above the ladder table. Section 6. |
| E-11 | note | Adopted. The pruning-rules paragraph's raw combinatorics (4×3×2×3×256×4×5×12) had no computed total in prose. Fixed: stated as "roughly 4.4 million combinations." Section 8, "Pruning rules." |
| E-12 | note | Adopted. origin/main's actual tip (20dac1348f, #7463) sat one commit past this document's own re-confirmed tip at the time of reviewer E's read, and that commit fixes exactly the gap named as FAIL in setup-permutation row P-21. Fixed: P-21 re-verified against origin/main and flipped to PASS in this revision, with the commit cited directly. Section 8, Camellia/Situs Group P-21 rows. |
| F-01 | note | Adopted. The low-confidence-match review queue's ownership note named the reassignment boundary (Gera's platform-identity lane) without saying who owns triage today for a tenant-path case specifically. Fixed: one clause stating Fede owns it today, no date set for reassignment. Section 4c. |
| F-02 | should fix | Adopted. Gauntlet row T-14's assertion accepted either of two outcomes (a second Person minted, or the match routed to review) as passing — looser than every other row, on the design's single most safety-critical fix. Fixed: tightened to require the review-queue outcome specifically, matching the design's own refuse-on-ambiguity logic elsewhere (C-02, step 2). Section 9, row T-14. |
| F-03 | note | Adopted. The round-2 convergence line's "none blocking" could read as "ready to build today" to a reader who skips the ladder preamble two paragraphs down, when in fact three code facts (CI wiring, the C-01 PR, the shared breaker) are real prerequisites. Fixed: the convergence line above now states the open prerequisites directly rather than only in the ladder preamble. Review log, convergence line. |
| F-04 | note | Adopted. The rules ledger's "not re-derived from source type at call time" language for the never-enroll seam was in mild tension with the pipeline's own "one set of rules, source-agnostic" framing — a future reader could wrongly conclude the seam must fire identically for every source. Fixed: one clause clarifying ADR-0094 is a source-keyed rule by design, and "not re-derived" means the check is implemented once, not that it must generalize to every source. Section 2a, closing note. |
| F-05 | note | No design change needed now — a pointer for whoever implements T-08: confirm the isolation gauntlet's T11 row still exists at that number and polarity before wiring T-08 against it, since gauntlet numbering has already shifted once in this document's own history (L-nn → T-nn, this revision). Section 9, row T-08. |
| G-01 | must fix before build | Adopted. Gauntlet row T-19 (new in revision 4, E-02) accepted an either/or outcome — a second Person minted, or the match routed to review — as passing, and cited itself as "the same tightened either/or resolution as T-14," but T-14 was tightened in the same revision (F-02) to remove minting as a passing branch; the two rows described opposite rules for the identical shape. Fixed: T-19 tightened to require the review-queue outcome specifically, matching T-14, with the false self-citation removed and an explicit cross-reference to T-14 added. Section 9, row T-19. |
| G-02 | must fix before build | Adopted. This document's re-confirmed tip had drifted one substantive commit (#7464, two household co-pendency fixes) behind origin/main by the time of reviewer G's read, and that commit touches files this design cites directly. Fixed: setup-permutation rows P-03, P-20, and P-38 re-verified against the current origin/main tip (229c8f6c) — P-03 and P-20 confirmed unchanged (still gated on the dark autoLinkCoApplicants switch, not on the underlying mechanism, which #7464 further hardened); P-38 flipped PARTIAL → PASS (the over-fusion bug it named is fixed, fail-closed on genuine ambiguity rather than swallowing a stranger). Section 8, P-03/P-20/P-38 rows. |
| G-03 | must fix before build | Adopted. The refactor ladder's step 3.5 cell claimed gauntlet row T-08 "turns green... then must PASS" by itself, but T-08's literal scenario is a write-back write, and write-back does not exist until step 5 — step 3.5 alone cannot mechanically run it. Fixed: step 3.5's cell now states T-08 stays CANNOT-EXPRESS through that step and first reaches PASS at step 5; step 5's cell and the T-08 gauntlet row itself are corrected to match. Section 6 (steps 3.5 and 5), Section 9 row T-08. |
| G-04 | should fix | Adopted. No migration/backfill policy was stated for today's channel-less SmsConsentRecord rows against the new per-channel consent-evidence shape, unlike D-06's explicit backfill default for name/contact fields. Fixed: one clause in §4b stating the deliberate posture — legacy SMS-consent rows do not migrate; write-back refuses on every existing prospect until a fresh, per-channel evidence record is captured going forward. Section 4b. |
| G-05 | note | Adopted. The household-grouping precedence text (E-06) cited gauntlet rows T-14/T-19 as if they were the code mechanism that "clears" the name-agreement gate, when they are the regression tests that pin it. Fixed: reworded to name the ensurePersonForSignals/pickBestProspect fixes as the gate, with T-14/T-19 cited as the tests pinning it. Section 3, household-grouping rule precedence. |
Round 1 — resolved: 24. Deferred to a named separate lane: 1 (B-13, Gera's vendor/staff-identity lane). Rejected: 0. Round 2 (revision 3) — resolved: 18 of 19 findings with a concrete design change (3 blocks, 9 must-fix, 4 should-fix, 3 notes, one of which — D-05 — resolves itself once D-02/D-03 are addressed). Rejected: 0. Round 3 (revision 4) — resolved: 17 of 17 findings with a concrete design change (1 blocker re-opening D-02, 5 must-fix, 4 should-fix, 2 notes from reviewer E; 1 should-fix, 4 notes from reviewer F). Rejected: 0. Round 4 (revision 5) — resolved: 5 of 5 findings with a concrete design change (0 blocks, 3 must-fix before build, 1 should-fix, 1 note from reviewer G). Rejected: 0. Every block, must-fix, should-fix, and note from all four rounds got a concrete design change within this document, except B-13 (round 1), which is a real, confirmed cross-tenant hazard outside lead-intake's scope and is handed to Gera's lane rather than solved here, and B-10's CRM-mode-transition design, C-01's underlying fix, and the separate pickBestProspect prerequisite (E-02) and the rental-application enrollment gate (E-01), all of which are named as prerequisites/separately-scoped work rather than solved inline — each named explicitly so nothing silently falls through the gap between two designs or two pieces of work.
Sources: origin/main of the propflowai repo, read 2026-09-09: agents/clara/lib/agent/tools-leasing.ts (handleSaveProspectImpl:2065, unit-id helpers targetUnitIdFromLabel/stripListingPropertyPrefix:1981,2016, self-email guard:2192-2203, ADR-0101 on-behalf guard:614,2105,2116) · src/lib/domain/identity/resolve.ts (resolveProspectIdentity:571, gatherProspectCandidates:385, pickBestProspect:354) · src/lib/domain/pms/writers/guest-card.ts (syncPropertyGuestCards:691, group-suppression counters ~738-741, external-id match ~823-828) · lambda/appfolio-sync/handler.ts (resolvePersonByContact:2728, sync call site:2793) · src/lib/data/dynamo/leasing.ts (saveProspect:589) and src/lib/domain/identity/prospect-spine-stamp.ts (wraps ensurePersonForSignals) — both writers converge here, the sync path via saveProspect rather than a direct call · src/lib/domain/pms/types.ts (PMSGuestCard:569) · src/lib/data/types.ts (emailIntegration:3082, EmailIntegration:13109) · src/lib/integrations/email/webhook-processors.ts (findIntegrationBySubscription:419, call site:706, unknown_subscription:709) · src/lib/integrations/email/property-graph-sender.ts (sendPropertyEmail:278, reads emailIntegration:286) · config/phone-registry.json, src/lib/domain/properties/phone-registry.ts · src/lib/data/dynamo/persons.ts (claim keys:17-18, dedup sentinel:18,540,612,979), src/lib/domain/identity/person-stamp.ts, src/lib/domain/identity/resolve-known-applicant-person.ts, src/lib/domain/identity/backfill-person-contact.ts (the contact-restamp gap, prod 2026-07-26/08-07) · src/lib/domain/leasing/household/{resolve.ts,co-pendency.ts,writers.ts} · src/lib/integrations/appfolio/message-sender.ts · appfolio-browser-agent repo, origin/main (lib/guestCardMessaging.ts: "NEVER creates a guest card" per its own doc comment) · docs/adr/0094-appfolio-guest-card-sync.md (Status: Proposed, not yet finalized in the doc itself) · scripts/household-harness/, src/__tests__/household-harness/, package.json (household:regress and related scripts) · lambda/appfolio-sync/schedules.json (guest_cards: rate(15 minutes); rental_applications: rate(5 minutes)) · git log for #7417, #7420, #7424, #7439, #7441, #7444, #7455, #7462, #7327 · ~/.claude/CONSTITUTION.md, ~/.claude/propflowai/CLAUDE.md, ~/.claude/propflow-vision-2026-08.txt. Corrected from the original brief: the household-reminder fix is #7327, not #7466 (no #7466 commit exists). Could not verify: an exact PR number for the cosigner-marker/shared-phone-from-claims fix (content matches commit 0c1b240c39 closely; no PR number appears in its log entry) or for the guest-card-contact-restamp fix (content matches backfill-person-contact.ts, documented against a 2026-07-26/08-07 production gap; no PR number found). restamp-person-contact.ts, named in the original brief, does not exist — backfill-person-contact.ts is the real file and is cited above instead.
Revision 2 sources (2026-09-09): two independent adversarial design reviews (concurrency/scale, 12 findings; identity/tenancy/consent, 13 findings), both checked against origin/main of the propflowai repo at commit 65ea04b60953e58b93565983a6e8c48197ead258, confirmed current as of this revision (git fetch, read-only, no working-tree checkout). Additional files read for revision 2: src/lib/platform/resilience.ts (appfolioBreaker:308, single module-level instance; appfolioPolicy:312; 429-cliff comment:320) · src/lib/domain/pms/writers/utils.ts (retryOnRowConflict:48) · src/lib/domain/pms/writers/guest-card.ts (content-fingerprint comments ~275-331, ~501-512; unwindowed status: 'all' fetch, ~496) · agents/clara/lib/data/dynamo/compliance.ts (SmsConsentRecord access pattern, PK CONSENT#{phone}) and agents/clara/lib/data/types.ts (SmsConsentRecord:2167-2176 — no channel/fromNumber field) · src/lib/domain/identity/resolve.ts (gatherProspectCandidates, cross-org helpers) and src/lib/data/store.ts (findPersonsByPhoneAcrossOrgs:3147, findPersonByEmailAcrossOrgs:3179) · docs/adr/0094-appfolio-guest-card-sync.md (lines 40, 61 — consent-rationale prose) · src/lib/integrations/appfolio/message-sender.ts (sendGuestCardMessageL4, no consent references) · src/lib/domain/vendors/vendor-write-invariants.ts (canonicalVendorId:46-73, no database qualifier) · src/lib/domain/leasing/application-group.ts (resolveGroupLinkVerdict:178-209) · src/lib/integrations/email/webhook-processors.ts (DROPPED_NOTIFICATION_IS_ROUTINE:524-575, confirms unknown_subscription is failed, not routine) · portfolio-architecture-how.html §§04-05 (resolver provenance shape, wall/consent framing) · household-identity-2026-09-08 (Willows E2E proof table, above).
Revision 3 sources (2026-09-09): two further independent adversarial design reviews against revision 2 — reviewer C (identity/consent re-walk of all 62 setup-permutation rows, 3 findings) and reviewer D (buildability/operability, 16 findings plus a proposed gauntlet appendix) — both checked against origin/main of the propflowai repo at commit 1b938ffecb1effc628e0d80121558d460cc74aed (2026-09-09), git fetch, read-only, no working-tree checkout. This revision itself re-confirmed the repo's tip at commit 71eacc5b8e2cb1530b68c52c706df754f62ed72f (2026-09-09, #7461, CI cost-routing only) — one commit ahead of both reviewers' cited tip; the delta touches only CI test-lane logic, not any file this design cites, so no finding or code claim above needed re-verification against it. Additional files read/spot-re-checked for revision 3: src/lib/domain/identity/resolve.ts (pickBestProspect/gatherProspectCandidates, confirmed no name comparison in the reuse path) · src/lib/domain/identity/person-stamp.ts (ensurePersonForSignals header comment, confirmed shared across Tenant/ProspectInquiry/VendorCompany/User/Conversation call sites) · src/lib/domain/identity/tenant-spine-stamp.ts (applyTier1IdentityOverwrite:196, unconditional rename after name-blind reuse, confirmed) · src/lib/domain/pms/writers/guest-card.ts (never-enroll/onFreshProspectMinted comment ~29-40, confirmed pinned by a never-called test) · scripts/household-harness/README.md (confirms "Not wired into CI today. That is a deliberate, separate decision," line 40) · docs/adr/0094-appfolio-guest-card-sync.md (Status: Proposed, confirmed unchanged) · src/lib/integrations/appfolio/resolve-account.ts · unit-count figures: Situs Group 640 units (live AppFolio dashboard, cited in the setup-permutations customer profile above), Western Slope ~250 doors (Jason, Sep 4 call, cited in the onboarding audit above) — used for the D-12 polling-budget worked example, request-count math shown inline, payload-bytes figure explicitly named as an open measurement, not invented.
Revision 4 sources (2026-09-09): two further independent adversarial design reviews against revision 3 — reviewer E (coherence/completeness, 12 findings) and reviewer F (full audit of all 44 prior findings plus 5 new ones) — checked against origin/main of the propflowai repo, git fetch (read-only, no working-tree checkout), tip 20dac1348f5f696ebe0b477f03d19b09100befac (2026-09-09, #7463) — one commit ahead of both reviewers' independently-fetched tip; that commit is not CI-only (it fixes setup-permutation row P-21, above) and was checked directly rather than assumed immaterial. Files read/spot-re-checked for revision 4: src/lib/domain/pms/writers/rental-application.ts (onFreshProspectMinted declared ~284, called ~2184; mapApplicationStage ~612; "WIRED BUT INERT TODAY" header comment ~260-275, confirmed) · src/lib/domain/leasing/pms-mint-engagement.ts (header comment confirming the three-check stand-down and that guest cards are "deliberately NOT wired," rental applications ARE wired and stand down only via mapApplicationStage) · src/__tests__/appfolio-rental-application.test.ts (confirmed test spies assert onFreshProspectMinted IS called on fresh mints — the opposite polarity from the guest-card writer's never-called pin) · src/lib/domain/identity/resolve.ts (pickBestProspect:354, gatherProspectCandidates:385, re-confirmed zero references to person-stamp.ts) · src/lib/domain/identity/person-stamp.ts, src/lib/domain/identity/prospect-spine-stamp.ts (confirmed as two separate files — person-stamp.ts holds ensurePersonForSignals, prospect-spine-stamp.ts wraps it for the leasing writers; neither file references resolve.ts's functions) · src/lib/domain/pms/writers/guest-card.ts (resolveStage now at ~495, function and one-directional behavior unchanged from revision 3's citation) · src/lib/domain/identity/backfill-person-contact.ts (restampPersonContact:334, the #7463 fix — fill-or-change contact restamp, gated on name agreement) · lambda/appfolio-sync/handler.ts (restampPersonContact wired live ~2789-2858, confirming #7463 is in production, not just merged). Corrected from revision 3: the rules ledger's never-auto-enroll row previously implied the seam is declared inside guest-card.ts itself; on re-check, guest-card.ts only narrates the seam in its header comment and never declares an onFreshProspectMinted parameter — the seam is declared and called in rental-application.ts only, and guest cards simply have no enrollment hook wired to them at all. This distinction is now stated correctly in the ledger.
Revision 5 sources (2026-09-09): one further independent adversarial design review against revision 4 — reviewer G (fresh eyes, 5 findings) — checked against origin/main of the propflowai repo, git fetch (read-only, no working-tree checkout, no edits), tip 229c8f6cd586998d2157b86558debe253adbc337 (2026-09-09) — two commits ahead of this document's revision-4 re-confirmed tip (20dac1348f, #7463). The intervening commit that is substantive rather than mechanical is a9353b392475cda5267fc9a57f184b8bd639c32e (#7464, "Fix two household co-pendency gaps: bare cosigner role + cross-tick shared-phone signal"), read directly (13 files, 1094 insertions): src/lib/domain/leasing/household/co-pendency.ts (new coPendencyRoleEvidence, org-salted contact fingerprints replacing raw values), src/lib/domain/leasing/household/writers.ts (applyRoleEvidence's role correction and never-zero-primaries guard), src/lib/domain/pms/writers/rental-application.ts (cross-tick contact-fingerprint pool seed) — confirmed this touches none of resolve.ts, person-stamp.ts, or tenant-spine-stamp.ts (T-14/T-19/C-01/E-02 unaffected) and none of mapApplicationStage/onFreshProspectMinted (T-18/E-01 unaffected). The two remaining commits to the current tip — fb833206d6 (#7468, claim-without-sentinel self-heal) and 229c8f6c (#7459, AppFolio credentials moved to the company) — were read and confirmed unrelated to household co-pendency, identity resolution, or consent. New test coverage read directly: src/__tests__/household-harness/scenarios.ts (BARE_COSIGNER_SEED_WITH_UNRELATED_RECORDS, SHARED_PHONE_SEED_DIFFERENT_UNIT fixtures) and src/__tests__/household-harness/simulate.test.ts (the "a THIRD, unrelated same-unit applicant makes the bare flag AMBIGUOUS — no link at all, never a guess" case, confirming P-38's over-fusion shape now fails closed rather than swallowing the stranger).
Plain version: every customer answers eight questions when they onboard — which PMS, which plan tier, how many PMS databases, who owns the CRM record, which doors leads walk through, who Clara replies as, how the org is shaped, and what shape the actual leads take. The rows below run real combinations of those eight questions through today's code the way the isolation gauntlet (T1–T14) already runs identity/org questions through it — one row, one scenario, one verdict, cited to a file or a PR. This table is proposed — draft, same status as the rest of this section: it names what the engine can and cannot express today, it does not change anything, and every FAIL becomes a scrubbed corpus case (Constitution III.1) before any fix ships.
The raw cross-product of the eight dimensions in the brief is enormous (4 PMS × 3 tiers × 2 DB-counts × 3 CRM modes × 256 channel subsets × 4 reply identities × 5 org shapes × 12 lead shapes) — multiplied out, roughly 4.4 million combinations (new in revision 4, E-11: stated in prose rather than left for the reader to compute). Three rules cut that down to the 62 rows that actually distinguish a verdict:
src/lib/integrations/appfolio/pms-adapter.ts); a row exists for each gate, not for every tier × channel combination. DB-count only matters for AppFolio, and only where two-database account resolution is exercised (src/lib/integrations/appfolio/resolve-account.ts) — it is collapsed everywhere else.Net: every real customer's actual setup gets full lead-shape coverage (the dimension Fede asked the Willows proof to run first); every dimension gets at least one row that isolates it; nothing is duplicated across two rows expected to verdict identically for the same reason. New in revision 2: the refactor path above (§6) now names, step by step, which of these 62 rows (24 PASS · 16 FAIL · 3 PARTIAL · 22 CANNOT-EXPRESS today) it turns green, and which stay open past this refactor because they depend on a separate lane (partner programs, org-structure decisions, other PMS clients, Gera's vendor-identity fix).
Setup: AppFolio, plan tier ungated here (writes go through browser-session replay, not the official write API — docs/adr/0094-appfolio-guest-card-sync.md), 1 database, CRM mode PropFlow-only (ADR-0094, unreversed at Camellia), intake = guest-card pull every 15 min + AppFolio lead-notification email over the connected mailbox + calls/texts to Clara's number, reply = connected Microsoft mailbox (camelliaapts@jp-co.com) + Clara's number, org shape = single property, single company.
| ID | Lead shape | Expected outcome (proposed engine) | Today's verdict |
|---|---|---|---|
| P-01 | Single applicant | One LeadSignal → one Person → one household → no write-back (PropFlow-only) → reply from connected mailbox → provenance: appfolio_guest_card, source record id = guest-card uuid. | PASS — ordinary case, no known defect (syncPropertyGuestCards, guest-card.ts:691). |
| P-02 | Cosigner grouped by AppFolio (has group id) | Two LeadSignals sharing a group id → one household, two people, no name pollution. | PASS — group id honored, cosigner text no longer saved into the name (#7424, live). |
| P-03 | Cosigner ungrouped, marker in text only | The marker becomes the applicant-role field on the LeadSignal contract → still one household. | FAIL today outside the dark autoLinkCoApplicants switch — the text marker isn't read as a relationship signal by the live path (design doc §1, "a cosigner's application went ungrouped because one writer reads a relationship marker the other one ignores"); fix (#7417) is merged but off everywhere. Re-verified in revision 5 (G-02) against origin/main tip 229c8f6c: #7464 (a9353b39, merged since revision 4) hardened the same mechanism — it fixes the cosigner's own household-role assignment and a cross-tick shared-phone signal gap, both inside co-pendency.ts/household/writers.ts — but does not touch the switch's off-by-default state; verdict unchanged, FAIL until the switch is armed. |
| P-04 | Two competing applicants, same unit, no relationship signal | Two separate households — competing, not related. | PASS — #7444 (stop household overmerge on shared phone/email) and #7445 (stop co-pendency fusing competing applicants) are both merged and live. |
| P-05 | Family member sharing a phone with a known lead | Two people, one shared-contact flag, never auto-fused — name comparison required before any match. | FAIL — proven FAIL at the Willows on the identical shape (below); phone/email match still adopts before names are compared (engine bug 1, Willows proof). |
| P-06 | Two strangers, same name | Two people, two households — name alone is never sufficient to merge, either. | PASS — proven at the Willows on the identical shape (below). |
| P-07 | Known lead re-applies with a dot/plus-tag email variant | Same Person updated, not duplicated; Clara's prior conversation retained. | PASS — #7420 (known applicant updates existing record) merged; the harness's own false-positive on this shape was a test-bed Gmail-folding gap, not a production one (household-identity section, "Duplicate-people finding, corrected"). |
| P-08 | Withdraw, then re-apply | Old lead closed, new one opens clean, same Person. | PARTIAL — new application syncs cleanly; withdrawal itself still needs a human click inside AppFolio (Willows proof, identical shape). |
| P-09 | Same guest card delivered twice by the 15-minute poll (redelivery) | Deduped on (source, source-record-id) — a no-op replay, per the LeadSignal idempotency design (§4). | PASS for same-source redelivery — the poll upserts on AppFolio's guest-card external id, not a fresh insert (guest-card.ts external-id match ~823-828). CANNOT-EXPRESS for cross-source dedup (same lead via the notification email AND the poll) — no shared dedup key exists across Writer A and Writer B today (design doc §2, "two independent writers... every rule that lives in only one is a place they can disagree"). |
| P-10 | Application lands before the guest card that produced it (out-of-order) | observed-at on the LeadSignal orders it correctly regardless of processing order. | CANNOT-EXPRESS — no ordering field exists on today's two writers; design doc §4 names this exact case as unhandled ("an application should never be treated as older than the guest card it belongs to just because the guest-card poll ran late" — not built). |
Setup: PMS on paper is RealPage (migration in progress off ILM Lead Manager), but Yale 25's property row lives under JP&Co's AppFolio company record (decision X11) — a company/PMS mismatch, not a clean single-PMS case. Intake = yale25@ mailbox + shared jpco guest-card pull (unconfirmed whether Yale 25's actual leads ever create AppFolio guest cards at all). Reply = yale25@ property-specific mailbox we created. CRM mode = "PropFlow replaces" (Fede's stated intent for RealPage), org shape = property administratively parked under the wrong company.
| ID | Lead shape | Expected outcome (proposed engine) | Today's verdict |
|---|---|---|---|
| P-11 | New lead, migration-drain flag off (default everywhere) | Ordinary triage, Clara owns it, writes go nowhere (no RealPage adapter). | PASS — this is today's actual default state; types.ts migration-drain field, "ABSENT or false — the default, EVERYWHERE." |
| P-12 | RealPage/ILM tracking-sender email, migration-drain flag ON | Classify-only: stored and labeled, Clara never replies, never mints a fresh prospect — the PM already owns it in RealPage/ILM. | PASS — exactly the built behavior (isRealPageIlmTrackingSender, parse-lead-source.ts; suppression lane in process-inbound-message.ts), on/off only at Yale 25 today. |
| P-13 | CRM mode = "PropFlow replaces RealPage" as a named, durable customer setting | A per-customer flag the whole product reads consistently, not a property-by-property migration-drain toggle. | CANNOT-EXPRESS — no crmOfRecord field exists; the migration-drain flag is a temporary drain switch, not the durable four-mode setting design doc §7 open decision (a) proposes. Turns green at refactor step 5. |
| P-14 | Live read from RealPage (rent roll, guest cards) as Yale 25's real PMS | A RealPage adapter reads Yale 25's own account, independent of jpco AppFolio. | CANNOT-EXPRESS in production — getPMSClient('realpage', …) returns null unless isMockPmsEnabled(); there is no live RealPage read path (src/lib/domain/pms/registry.ts). |
| P-15 | A Yale 25 guest card accidentally created in jpco's AppFolio account (wrong-company parking) | Rejected or re-routed — a lead for one company should never land in another's identity spine. | FAIL-shaped by construction — Yale 25 has no company record of its own to route into; the parking under JP&Co is a known, named gap ("Not a separate company record yet," integration map matrix, above). |
| P-16 | Duplicate lead: same person contacts both the yale25@ mailbox and calls a shared JP&Co-portfolio number | One Person, one household, provenance shows two source records. | CANNOT-EXPRESS — same cross-source dedup gap as P-09, compounded by the company-record mismatch; no test or code path asserts this today. |
| P-17 | Staff-entered lead directly into RealPage by an on-site leasing agent | Ingested identically to any other source, with provenance recorded as staff_entered. | CANNOT-EXPRESS — no RealPage write-visibility exists at all (P-14), so a RealPage-side manual entry is invisible to PropFlow by construction. |
Setup: AppFolio, Plus tier (read-only Reports API; write needs Max), 1 database distinct from jpco's, CRM mode undecided (no company record of its own — administratively near JP&Co), intake = guest-card pull, but most applications land in TurboTenant/Tenant Turner outside AppFolio entirely, reply = none working today (Google Workspace sign-in exists but send/read don't; Phase 2 plan is to forward their leasing inbox), org shape = staff overlap with JP&Co's admin surface (no company record of its own).
| ID | Lead shape | Expected outcome (proposed engine) | Today's verdict |
|---|---|---|---|
| P-18 | Single applicant via AppFolio guest card | One Person, one household, no write-back yet (mode undecided), no reply sent (no working identity). | PASS for ingestion (Reports API read works on Plus); FAIL for reply — "no working reply identity today" (integration map matrix). |
| P-19 | Application submitted through TurboTenant or Tenant Turner, not AppFolio | An adapter for that source produces a LeadSignal like any other. | CANNOT-EXPRESS — no adapter exists for either tool; today's two writers only know AppFolio and inbound email/voice. |
| P-20 | Cosigner ungrouped in AppFolio | Grouped automatically by relationship signal, not just group id. | FAIL while autoLinkCoApplicants stays off (dark everywhere, per the household-identity section) — the harness proved the fix works on Situs's own real data once armed ("the auto-link fix correctly linked same-unit co-applicant pairs in every company tested... 4 at Situs Group... zero cases of wrongly merging"), so this row flips to PASS the day the switch turns on, not before. Re-verified in revision 5 (G-02) against origin/main tip 229c8f6c: the Willows round-2 proof (2026-09-08) that motivated this row's original "zero cases of wrongly merging" claim had in fact found two live gaps in the armed mechanism (a cosigner's own membership stuck on the default role: 'primary', and a cross-tick shared-phone signal that never fired when the two applications synced more than one tick apart) — both are now fixed by #7464 (a9353b39), restoring the "zero cases of wrongly merging" claim on current code. Verdict unchanged, FAIL — still gated on the switch, not on the underlying mechanism. |
| P-21 | Guest card whose contact details change on a later visit | The new value attaches to the existing Person, not a new one. | PASS — flipped in revision 4: fixed and merged (#7463, restampPersonContact, backfill-person-contact.ts), wired live in the production sync handler (lambda/appfolio-sync/handler.ts ~2789-2858). A changed phone/email on a re-synced card now restamps onto the existing Person (old value demoted, never deleted, so a caller-id lookup still resolves it); restamping is itself gated on the card's name still structurally agreeing with the Person on file, so it can't fuse a stranger's corrected contact onto the wrong record. Confirmed against origin/main tip 20dac1348f. |
| P-22 | Two-database identity leak: a Situs person's phone happens to match a jpco person's phone | Two independent Persons — identity spine is per-org, never merges across AppFolio accounts. | PASS — matches gauntlet T1 exactly (one phone at two companies mints two people); the identity spine walls per org, not per AppFolio database (resolve-account.ts, config.ts). |
| P-23 | Staff member who works both JP&Co and Situs Group logs in once | A deterministic, user-selected active company. | FAIL — matches gauntlet T8 exactly: "the login lands wherever the first role row happens to be." |
| P-24 | CRM mode = "write-back to AppFolio" (Situs wants to keep AppFolio as their system of record) | A write-back adapter honoring Situs's chosen mode, separate from the intake writer. | CANNOT-EXPRESS — ADR-0094 says no write-back, full stop, today; the four-mode setting is open decision (a) in this section, not built. Turns green at refactor step 5. |
| P-25 | AppFolio guest-card messaging as Situs's reply identity (avoids buying Max) | Sends and reads through AppFolio's own guest-card view — needs no plan upgrade. | CANNOT-EXPRESS at Situs — built and proven only at the Willows (messagingDelivery: 'pms'); "AppFolio guest-card messaging untested here" (integration map matrix). |
| P-26 | Two leads for the same unit, one via AppFolio guest card, one via TurboTenant, arriving a minute apart | Deduped into one household once the TurboTenant adapter exists (target design, §3). | CANNOT-EXPRESS — depends on P-19's adapter, which doesn't exist; today these would be two unrelated, disconnected leads with no way to notice the collision. |
Setup: AppFolio, upgraded off Core 2026-09-09 (tier to confirm, Plus or Max), 1 database, intake = listing-site lead email to leasing@westernslopepm.com (the only automated path Core allowed; guest-card pull becomes available once tier is confirmed), reply = connected Microsoft 365 mailbox, org shape = single property, single company — but AppFolio access here is explicitly read-only, writes locked by Fede's decision ("it stays read-only, no writes there, the lock stays on," 2026-09-09), which the proposed engine's three CRM-of-record modes don't cleanly name.
| ID | Lead shape | Expected outcome (proposed engine) | Today's verdict |
|---|---|---|---|
| P-27 | Single applicant via listing-site lead email (pre-upgrade, Core-only state) | One LeadSignal, one Person, reply from connected mailbox, no PMS write. | PASS — live today, parsed by parse-lead-source.ts; "the only automated path available on Core plans" (integration map). |
| P-28 | CRM mode = "read-only during onboarding" (reads allowed, writes forbidden, by explicit owner decision, not by plan tier) | The fourth mode, added in revision 2, alongside PropFlow-only / PropFlow-replaces / write-back — named explicitly in open decision (a) and honored by every write-back adapter (refactor step 5). | CANNOT-EXPRESS in production today — the design now names this mode (revision 2, open decision (a)), but the crmOfRecord field itself is not built; Western Slope's lock remains an ad hoc operational decision until step 5 ships it as a modeled setting. Turns green at refactor step 5. |
| P-29 | 33 split households found in the real corpus | Auto-linked once relationship signals fire, same fix as P-03/P-20. | FAIL today, pending manual review — "Western Slope: 33 split households, still waiting on someone to check them by hand" (household-identity §"What the real-data test run found"). |
| P-30 | Guest-card pull turns on once Plus/Max is confirmed, running alongside the existing listing-email path | Both channels feed the same pipeline; a lead arriving via both is deduped into one. | CANNOT-EXPRESS — guest-card pull isn't live at Western Slope yet ("tier to confirm... once tier is confirmed, guest-card pull... become available," integration map matrix); the cross-channel dedup question is also P-09's open gap. |
| P-31 | A write attempt against Western Slope's AppFolio account (any path — sync, messaging, work-order) | Refused, with the refusal citing the read-only lock. | PASS — proven: "the dry proof walked each write flow to the blocked save and passed" (household-identity §"Already answered — no action needed"). |
| P-32 | Known lead re-applies with a new phone number, no PMS write-back | Same Person updated; PropFlow's own record advances even though nothing writes back to AppFolio. | PASS — same mechanism as P-07 (#7420), independent of the read-only lock since it only touches PropFlow's own store. |
| P-33 | Family member sharing a phone with a known Western Slope lead | Same as P-05 — name comparison required before any match. | FAIL — same underlying defect as P-05/Willows engine bug 1, not customer-specific. |
| P-34 | Staff at Western Slope shares a maintenance-vendor phone number with a Camellia vendor | Two Vendor rows, correctly walled per org — see gauntlet T1's identity-spine reasoning. | PASS by the same mechanism as P-22/gauntlet T1 — per-org identity spine, not per-database or per-customer-label. |
| P-35 | Opt-out via Western Slope's number should suppress outreach to that phone everywhere, including at other customers | Consent is keyed by phone number platform-wide, not per customer. | PASS — matches gauntlet T11 exactly: "consent is platform-keyed by phone, by decision, not by defect." |
Setup: same jpco AppFolio account as Camellia, same 15-minute guest-card cadence, but the only property anywhere with Property.messagingDelivery === 'pms' — reply identity is AppFolio's own guest-card messaging (browser-session replay, no official API at any tier). Org shape = single sandboxed test property, walled by #7442's refusal to clear/park/reset/wipe/seed a non-test property. These eight rows are the actual first-pass Willows end-to-end proof (above), run as real AppFolio applications and checked in the production database — reproduced here as gauntlet rows rather than restated.
| ID | Lead shape | Expected outcome (proposed engine) | Today's verdict |
|---|---|---|---|
| P-36 | Single applicant | One Person, one household, reply via AppFolio guest-card messaging. | FAIL — "its record was taken over by the shared-phone case below and pulled into a stranger's household" (Willows proof, table above). |
| P-37 | Cosigner grouped by AppFolio | One card, one household, names clean. | PASS — "one card, one household, names clean" (Willows proof). |
| P-38 | Cosigner not grouped by AppFolio | The pair groups correctly and nobody unrelated joins. | PASS — flipped in revision 5 (G-02): was PARTIAL — "the pair grouped correctly, but the household also swallowed unrelated applicants on the same unit" (Willows proof, engine bug 2, same-unit-within-3-days over-fusion). Re-verified against origin/main tip 229c8f6c: fixed and merged (#7464, a9353b39), armed-mode test coverage in src/__tests__/household-harness/simulate.test.ts reproducing this exact corpus shape. The unambiguous case (exactly one same-unit, in-window cosigner candidate) links the pair correctly with the correct role. Where a bare "(Co-signer)" marker carries no named target and a third, unrelated same-unit application also falls in the window — the exact over-fusion shape this row pins — the fix now refuses to link any of the three rather than swallowing the stranger ("ambiguous evidence must link nobody... never guessed into the wrong pair"), matching the design's own refuse-on-ambiguity pattern used everywhere else (C-02, T-14/T-19). Nobody unrelated ever joins the household under either case; the pair groups whenever the marker is actually resolvable. |
| P-39 | Known lead applies later with a different email and new phone | Same Person advances to applied, Clara's conversation retained, no second record. | PASS — "same person, original record advanced to applied, Clara's conversation kept, no second record" (Willows proof). |
| P-40 | Family member sharing a phone | Two people; name mismatch blocks the auto-match. | FAIL — "matched by phone before names were compared; two people fused into one" (Willows proof; engine bug 1). |
| P-41 | Two strangers, same name | Two people, two households. | PASS — "separate people, separate households" (Willows proof). |
| P-42 | Withdraw, then re-apply | Clean close, clean reopen, same Person, no human step needed. | PARTIAL — "new application syncs cleanly; the withdraw step needs a human click in AppFolio" (Willows proof). |
| P-43 | Roommates grouped by AppFolio | One card, one household. | PASS — "one card, one household" (Willows proof). |
| P-44 | Deleting a Person mid-corpus leaves an orphaned dedup sentinel | Sync self-heals; one bad record never stops a whole property. | FAIL found, fix in progress — "21 such orphans at the Willows silently stopped every sync there. Repaired at the Willows; fix in progress" (Willows proof, engine bug 5; #7439). |
| P-45 | A newly created unit is missing its AppFolio id from the periodic sync | Applications on that unit match correctly regardless of when the unit was created. | PASS now — "vacant units were being created without their AppFolio id... found during the Willows proof" and fixed and shipped (#7435, merged and in production). |
| P-46 | Reply sent through AppFolio guest-card messaging, retried after a timeout (idempotency) | A single message reaches the prospect even if the send is retried. | PASS — "AppFolio's send endpoints return no id, so a retried send would double-message a real person unless the envelope id carries the idempotency key end to end" — this discipline is already built into message-sender.ts, the pattern the wider LeadSignal design (§4) generalizes. |
These rows exist to name a gap before a real customer hits it, per Constitution VII.10 ("their identifiers entered as data, never as code") — not because a design decision is owed today. Org-shape rows reuse the isolation gauntlet's own verdicts (portfolio-architecture-how.html, "The isolation gauntlet — T1 to T14") rather than re-deriving them.
| ID | Setup / lead shape | Expected outcome (proposed engine) | Today's verdict |
|---|---|---|---|
| P-47 | PMS = Yardi, live production read | A Yardi adapter emits LeadSignal like any other source. | CANNOT-EXPRESS — getPMSClient('yardi', …) returns null unless isMockPmsEnabled(); no live Yardi client exists (src/lib/domain/pms/registry.ts). |
| P-48 | PMS = RealPage, live production read (not the Yale 25 migration-drain shape) | Same as P-47, RealPage adapter. | CANNOT-EXPRESS — same gate, getPMSClient('realpage', …), mock-only. |
| P-49 | PMS = none — a customer with no PMS at all, leads by phone/text/website only | Website-form and generic-webhook adapters both emit LeadSignal. | CANNOT-EXPRESS — "No contact-form endpoint exists anywhere in the app" (Camellia-vs-Western-Slope audit §2, row D); no generic-webhook intake exists either. |
| P-50 | Intake channel: generic webhook | One adapter, no pipeline change (design doc §3). | CANNOT-EXPRESS — not built; no source in src/lib/domain/pms/registry.ts or the email/voice writers accepts an arbitrary webhook payload. |
| P-51 | Intake channel: AppFolio Stack partner webhook (real-time) | Real-time push replacing the 15-minute poll. | CANNOT-EXPRESS — "needs the partner program," 2–4 week application cycle, "new guest card" not confirmed as a published event (integration map, decision I1 option C). |
| P-52 | Intake channel: direct Zillow/Apartments.com feed, bypassing AppFolio | An adapter alongside the AppFolio one; dedup against AppFolio-native guest cards for the same person. | CANNOT-EXPRESS — "needs per-vendor registration," 4–6 week onboarding per feed, not built (integration map). |
| P-53 | Plan tier = Core, guest-card pull attempted anyway | N/A — Core doesn't support this channel; only the listing-email path is available. | FAIL-by-design — "no access on Core" (integration map, Reports API row); the pull silently has nothing to read, not a refusal with a clear message. |
| P-54 | Plan tier = Max, expecting the official guest-card-messaging write API | N/A under the proposed design — write-back is still browser-session replay regardless of tier. | CANNOT-EXPRESS — "there is no official API for guest-card messaging at any plan tier" (integration map, reply-identity table); Max buys Reports API write access, not messaging. |
| P-55 | 2+ AppFolio databases inside one customer (a future portfolio client spanning two AppFolio accounts under one org) | One org, two PMS accounts, resolved per-property to the correct database. | CANNOT-EXPRESS — account resolution picks one AppFolio account (resolve-account.ts); no Organization row expresses "more than one PMS account," matching gauntlet T6's finding that the row kind for org-level PMS binding doesn't exist. |
| P-56 | Portfolio/management company with sub-orgs (owner companies under one manager) | A company-level line resolves to the company with no single building implied; per-org phone/calendar. | CANNOT-EXPRESS — gauntlet T6 and T9, exactly this shape: "the row kind does not exist," and onboarding one with a company-level line and two buildings FAILS at gauntlet T14 ("the company line silently becomes one home's line, chosen by roster order"). |
| P-57 | Staff working across two customers reads a batch of people | Deterministic result naming which customer each person belongs to. | FAIL — gauntlet T7: "a batch person read returns a person minted at A who holds an active role at B... two readers disagree." |
| P-58 | Staff of company A texts company B's line | No staff tier granted at B — B's line only recognizes B's own staff. | FAIL — gauntlet T3: "the ring's identity call still passes no customer at all." |
| P-59 | A dialled number maps to no building at all (misconfiguration) | Refused, never silently adopts the caller's own company. | FAIL — gauntlet T5: "the fail-open block is present, self-labelled, and still adopts." |
| P-60 | Shared vendor/staff phone numbers across customers (routine, not adversarial) | Each customer's lookup returns only its own person, minted independently. | PASS — gauntlet T1: "the identity spine already walls per customer. Keep it." |
| P-61 | Cross-customer identity merge attempted (adversarial or accidental) | Refused outright; a same-customer merge still works in the same run. | PASS — gauntlet T10. |
| P-62 | CRM mode = "PropFlow replaces theirs" as a durable per-customer setting (not the Yale 25 migration-drain flag) | A modeled, product-wide setting every write-back adapter honors. | CANNOT-EXPRESS — same gap as P-13; ADR-0094's amendment (open decision (a), above) is proposed, not built. Turns green at refactor step 5. |
Row count: 62 rows (P-01–P-62) after pruning. Verdict counts, today (by pill, counted directly from this table's markup): 24 PASS · 16 FAIL · 3 PARTIAL · 22 CANNOT-EXPRESS — 65 verdicts across 62 rows because three rows carry two verdicts for two sub-cases within one setup (P-09: same-source redelivery vs. cross-source dedup; P-18: ingestion vs. reply; P-56: T6 vs. T14). P-21 flipped FAIL→PASS in revision 4 (E-12): #7463 landed on origin/main during this review round and fixes exactly the gap that row named. Sources: everything cited inline above, plus origin/main of the propflowai repo read 2026-09-09 — src/lib/domain/pms/registry.ts (getPMSClient, the yardi/realpage cases gated by isMockPmsEnabled()) · src/lib/data/types.ts (OrganizationPms:12626, RealPage migration-drain field and isRealPageIlmTrackingSender doc comment ~3493-3499, pmsSource:3468) · src/lib/integrations/appfolio/resolve-account.ts, config.ts · src/__tests__/household-harness/scenarios.ts:1721, README.md:648-694 (autoLinkCoApplicants, default off, per-property) · the household-identity section's Willows end-to-end proof table and "What the real-data test run found," above · the integration map and its per-customer matrix, above · portfolio-architecture-how.html, "The isolation gauntlet — T1 to T14" (scripts/portfolio-harness/gauntlet/results/latest.md:9-22) · PRs #7417, #7420, #7424, #7435, #7439, #7444, #7445 · docs/adr/0094-appfolio-guest-card-sync.md (Status: Proposed). Labeled inference, not fact: P-15, P-16, P-26 describe shapes not yet observed in real Yale 25/Situs traffic — they follow deductively from the code gaps cited, not from an incident.
Plain version: sections 6 and 8, above, name a lot of things that should be true once this refactor ships — a family member sharing a phone never overwrites the wrong tenant's name, a consent refusal actually refuses, a crash mid-pipeline never doubles a lead. This table turns those claims into one runnable table: one row, one scenario, one expected outcome, one assertion, cited to a file, modeled directly on the portfolio harness's own gauntlet (scripts/portfolio-harness/gauntlet/, T1–T14) and the household harness (scripts/household-harness/). It merges reviewer D's proposed L-01..L-10 rows with the setup-permutation rows above that read FAIL or CANNOT-EXPRESS today and aren't already an explicit regression target elsewhere in this document. Every row here becomes a real test in the household harness before the step it's cited against is called done — this table is proposed — draft, same status as the rest of this section, and does not change anything by existing.
| ID | Setup | Signal sequence | Expected outcome | Assertion | Runs against JSON store |
|---|---|---|---|---|---|
| T-01 | Seeded org, one property, AppFolio-only source, JSON store (household-harness pattern) | One AppFolio guest-card LeadSignal, single applicant, no prior Clara contact | Prospect created at inquiry; zero outreach-cadence enrollment | prospect.outreachCadenceAnchorIso unset AND a spy on the enrollment seam (onFreshProspectMinted-equivalent) records zero calls — same pattern guest-card.ts already pins today | Yes |
| T-02 | Same org; a prospect already at applied | A stale/duplicate LeadSignal for the same card carrying status "Active" | Stage stays applied — never demoted or resurrected | prospect.stage === 'applied' after the pipeline runs | Yes |
| T-03 | Two orgs (jpco, situsgroup), one guest card in each with a synthetic colliding source-record id | LeadSignal A (org=jpco, id=X), LeadSignal B (org=situsgroup, id=X) | Two independent Persons/prospects, one per org — no cross-org fuse from the id collision | Two prospect rows exist, each scoped to its own org — this is step 1b's (A-12) own proof, cited above | Yes |
| T-04 | One property, both the poll adapter and the notification-email adapter configured | Notification-email LeadSignal, then poll LeadSignal for the same card 10s later | One lead, one household — no second Person minted from the trigger race | Exactly one ProspectInquiry for the card's dedup key; provenance shows two source records folded into one. CANNOT-EXPRESS/FAIL until step 4 ships — this row is that step's own regression gate, not a pass today | Yes |
| T-05 | Same org | Application LeadSignal referencing guest-card group G (not yet ingested), then guest-card LeadSignal for G 30s later | Application creates the lead immediately, role intact, guest-card pointer pending; resolves to one household once G arrives | After signal 1: pendingParentRef === G. After signal 2: pendingParentRef cleared, one household, not two | Yes |
| T-06 | One prospect known from a staff-typed AppFolio card, legal name "Robert Smith" | Voice-transcribed LeadSignal for the same person spells the name "Bob Smith" | Legal name stays "Robert Smith" (PMS-of-record wins); "Bob Smith" recorded as superseded, not an overwrite | legalName.value === 'Robert Smith'; legalName.supersedes contains the voice-sourced entry with its own setBy/setAt | Yes |
| T-07 | A Person/prospect row written by today's code, no provenance shape | A new LeadSignal supplies an updated phone number | Legacy value treated as lowest precedence (synthesized setAt from updatedAt, setBy: 'legacy-unknown'); new value adopted without error | No exception; new value wins; a provenance record is created going forward — proves the D-06 backfill default above against a legacy-shaped fixture | Yes |
| T-08 | Two orgs A, B; a phone number STOPs on org A's channel | A write-back write for the same phone at org B attempts to enable PMS-side texting on the same channel | Refused — matches the isolation gauntlet's T11 polarity ("consent is platform-keyed by phone, by decision, not by defect") | No automatic-communication trigger fires; refusal cites the opt-out record. CANNOT-EXPRESS today — no OPTOUT#<channel>#<value> table exists yet. Corrected in revision 5 (G-03): this row is not step 3.5's own regression gate — its literal scenario is a write-back write, and write-back doesn't exist until step 5. It stays CANNOT-EXPRESS through step 3.5 (which only builds the opt-out table itself) and first reaches PASS at step 5, once the write-back adapter exists to attempt the write it scripts | Yes |
| T-09 | One AppFolio-database connection, 50 guest cards | Two sync invocations for the same connection start within the same second (simulated overlapping invokes) | Second invocation skips outright (lease held) — not "both run, conflicts absorbed" | Exactly one invocation has a nonzero write count; the other logs a lease-skip, and retryOnRowConflict's absorbed-conflict counter stays at zero for this pair | Yes |
| T-10 | Single LeadSignal for a new applicant | Pipeline run interrupted after stage 2 (identity resolve), before stage 5 (write); same LeadSignal redelivered, pipeline reruns from stage 1 | Exactly one Person, one ProspectInquiry, one household — no orphan Person row, no duplicate | Post-replay record counts equal a clean single run's counts — needs D-14's crash-injection mechanism (a stage-N throw behind a test flag) before it can run | Yes |
| T-11 | Seeded org, one property, conversation-path source | A caller reports someone else's contact info as if applying on that person's behalf, with no confirmation from the named person | Refused — on-behalf save never mints a prospect from a third party's say-so (ADR-0101) | Zero saveProspect calls after the refusal fires — pins the rules-ledger row "on-behalf refusal," Writer A today, must survive the merge (D-04) | Yes |
| T-12 | Seeded org with propflowai.co configured as Clara's own outbound sender | An inbound signal whose sender address is Clara's own outbound address (a bounce or loop) | No prospect minted from a self-addressed signal | Zero new Person/prospect rows created — pins the rules-ledger's self-email guard, Writer A today (D-04) | Yes |
| T-13 | Seeded org, conversation-path source with SMS consent evidence attached | A LeadSignal carrying {channel: 'sms', source: 'voice_call', timestamp, evidence} consent evidence | Consent evidence is written and readable after the merged pipeline runs, not silently dropped | The written Person/prospect's consent-evidence record matches the LeadSignal's input exactly — pins the rules-ledger's SMS-consent-provenance-capture row, Writer A today (D-04) | Yes |
| T-14 | Seeded org, an existing tenant Person row (Tier-1, appfolio_sync-sourced), not a prospect | A family member shares the same tenant's phone number and is ingested as a new applicant via the tenant-sync path (Tier-1) | Two people, not one — the existing tenant's name is never overwritten by the new arrival's name on a bare phone match | The original tenant Person's firstName/lastName/displayName are unchanged after the new signal, AND the low-confidence match routes to the review queue — tightened in revision 4 (F-02): revision 3 accepted either outcome ("a second Person is minted or the low-confidence match routes to review") as passing, which is looser than every other row in this gauntlet and wrong for the design's own logic — a shared-phone match with disagreeing names on the tenant path should route to review, mirroring the write-back refusal pattern (C-02) and the leasing-path low-confidence handling (step 2), never silently mint a second Person with no household-shape context to catch a wrong split. This is C-01's own proof, the case the leasing-only fix in step 2 does not cover by itself, and the gap no P-row exercises today (no permutation row today tests a shared phone on the tenant-sync path, as opposed to a prospect/applicant path) | Yes |
| T-15 | Two genuine roommates, same unit, same short window, no AppFolio group id and no shared contact value (P-38's shape) | Two independent LeadSignals, same unit, no relationship signal between them | Two people, correctly kept separate — not fused, and not silently swallowed into "zero failures" | Two Person rows, two ProspectInquiry rows, both tagged unresolved-cohabitation (B-08) rather than grouped or left unclassified | Yes |
| T-16 | Seeded org, a Person row carrying a dedup sentinel with no corresponding Person (an orphan, P-44's shape) | A guest-card sync tick for the property that owns the orphaned sentinel | The sync self-heals — the orphan never stops the rest of that property's sync | Every other card in the same tick processes normally; the orphan is logged, not fatal — permanent regression coverage for #7439, which today has a fix merged but no gauntlet row pinning it | Yes |
| T-17 | A customer's crmOfRecord setting is read at write-back time (P-13/P-24/P-28/P-62's shape) | A write-back write attempted under each of the four modes: PropFlow-only, PropFlow-replaces, write-back-to-CRM, read-only-during-onboarding | PropFlow-only and read-only-during-onboarding refuse every write; write-back-to-CRM and PropFlow-replaces proceed through the identity-resolve stage | Exactly the two writing modes produce a target-CRM write attempt; the other two produce zero, with the read-only mode's refusal citing the owner decision, not a plan-tier gate — step 5's own proof that the four-mode setting is a modeled, durable setting rather than an ad hoc lock | Yes |
| T-18 (new, E-01) | Seeded org, one property, AppFolio rental-application source, an application whose PMS status maps to a non-cadence-stop stage (a hypothetical near-term product state, not true today per mapApplicationStage) | An AppFolio rental-application LeadSignal for a fresh applicant with no recorded consent-evidence record on any channel | The signal is never enrolled in outreach/cadence without a recorded consent-evidence record for the channel it would be messaged on — the never-auto-enroll rule holds for applications the same way it holds for guest cards, even once the current stage-mapping accident that stands it down today no longer applies | Zero enrollment-seam / onFreshProspectMinted-equivalent calls proceed to onProspectEngaged for an application-sourced signal lacking consent evidence — fails loudly the moment this would-be silent gap is exercised, rather than passing by accident because mapApplicationStage happens to stand it down. CANNOT-EXPRESS today in the sense that nothing forces this case to occur (the accident already stands it down) — this row exists so a future stage-mapping change cannot silently remove the accident without this test catching it first. | Yes |
| T-19 (new, E-02) | Seeded org, one property, conversation-path (Writer A) source, an existing prospect known by phone number, legal name "Maria Garcia" | A new conversation-path LeadSignal shares that phone number but gives the name "Maria Lopez" | Two people, not one — pickBestProspect's ranking never adopts a same-phone candidate as a match without a name-similarity check, mirroring T-14's tenant-path fix but for the independent prospect-path function | The low-confidence match routes to the review queue — tightened in revision 5 (G-01) to match T-14 exactly: revision 4 stated an "either/or" (a second Person minted, or the match routed to review) as passing, but T-14 was tightened that same revision (F-02) to remove "mint a second Person" as a passing branch, and T-19 was never updated to match — the two rows described opposite rules for the identical shape one row apart, and T-19's own text wrongly cited itself as "the same tightened either/or resolution as T-14" when no either/or was left in T-14 to be the same as. Minting a second Person here is a FAIL, exactly as it is in T-14 — never silently attach the new name to the existing prospect, and never silently mint a second Person with no household-shape context to catch a wrong split (see T-14, above, for the full rationale). This is the P-05/P-33/P-40 family-sharing-a-phone shape's own regression gate; CANNOT-EXPRESS/FAIL until the new pickBestProspect prerequisite (rules ledger, above) lands — NOT covered by the in-flight C-01 PR, which fixes ensurePersonForSignals, a different function. | Yes |
Rows T-01–T-03, T-06, T-07, T-09, T-11–T-13, T-15–T-17 are runnable today as a baseline against the two existing writers, and re-run unchanged against the merged writer once it lands (fixed in revision 4, E-09 — the prior wording said "runnable today" and "once the unified writer exists" in the same clause, which contradict each other as written). Rows T-04, T-05, T-08, T-10, T-14, T-19 depend on pipeline stages or fixes this design hasn't shipped yet (steps 2–5, C-01's prerequisite PR, D-14's crash-injection mechanism, and — new in revision 4 — the separate pickBestProspect prerequisite T-19 needs) and are expected CANNOT-EXPRESS or FAIL until those land; T-18 is runnable today and is expected to PASS by accident (the stage-mapping stand-down), which is exactly the state this row exists to keep visible — same polarity convention the isolation gauntlet and the P-01–P-62 table already use. This table does not attempt to re-litigate every one of section 8's 39 FAIL/CANNOT-EXPRESS verdicts individually — the ones not listed here already have an explicit regression target named against a refactor step in section 6 (e.g. P-19/P-26 against the adapter step 6, P-47/P-48 against a future PMS-client integration outside this refactor's scope) and gain their own gauntlet row when that step is scoped in detail, not before.
Date: 2026-09-16. Trigger: a Camellia resident with a lease and a move-in date was treated as a stranger on two calls (9/15, 9/16) because two sync writers fought over his phone record every minute. Fede: "seems like a bigger architectural issue", "take some time to think this through", "this has to work beyond just AppFolio, like Yardi." Decisions on the engine are deferred until after the Western Slope go-live on 9/17 (Fede, 2026-09-09).
Sources read: 18 ADRs and design docs, ~60 PRs (May 4 → Sep 16), the 2026-09-09 authoring session, memory rulings from 8 sessions, the constitution and lanes, Gera's portfolio architecture page, the shared PMS layer and the Yardi/RealPage adapters, public docs for Yardi, RealPage, Entrata, Buildium.
| When | Decision | Still true in code? |
|---|---|---|
| May 4 | A person is identified by claims (phone, email, PMS id), not by a phone number. Claims are append-only; retire, never delete. Trust tiers order the claims. | Yes, the model held. |
| May 23 | Every entity type keeps its own writer; one shared core underneath. | Yes. This is the origin of "many writers". |
| Jun 10 | One identity chokepoint for all channels. The ADR itself lists four separate resolvers at the time. | No. Gera's docs count 11 resolution sites and 20 decision sites today; three chokepoint attempts were abandoned ("the second adopter never came"). |
| Jul 1 | Multiple ingestion sources may each mint identity. | Yes. |
| Jul 15 | AppFolio guest cards become a source; the writer must defer to identity resolution "owned elsewhere". ADR status: Proposed, never accepted. | The writers shipped and iterated anyway. |
| Aug 14 | Fede: same phone or email in one org means the same human; merge automatically; no review queue ever. | Yes. |
| Aug 15 | A PMS-corrected value replaces the primary by demoting the old one, never retiring it. Applied to email only. | Yes for email. The phone path added on Sep 9 did the opposite. |
| Sep 8 | Fede: no manual-decision UI anywhere; identity decisions are the engine's. | Yes. |
| Sep 9 | Fede: identity and grouping are global, never per-property switches. | Yes. |
| Sep 9 | Fede: the lead-intake engine is the chance to stop whack-a-mole; sources as adapters, one pipeline, provenance; take a few days; build after 9/17. | Not started. |
| Sep 10 | A person may hold more than one live value per claim type. | Yes. Relaxed an assumption every earlier writer was built on; no re-audit recorded. |
| Sep 15 | The same human at a second customer is a second person plus a link. | Conflicts with the May 17 wording that allowed cross-org collapse; never reconciled. |
Nine paths write a person's phone or email record, each born from one incident, each encoding one local rule, none aware of the others:
Readers disagree too: the call-start lookup reads retired rows; the general lookup does not. That difference is documented in one PR body and one code comment.
Fourteen identity incidents since April. Six are the same shape: two writers disagree about one person, on their own clocks.
Each fix was correct for its incident and moved the problem to the next writer. The Sep 9 session's 18 progress reports never contain the word "tenant".
The dedup sentinel, a second row in a different partition that must live and die with its claim, has caused four incidents and is fenced by "never hand-delete" warnings in four separate memory files. It has self-heal in both directions now. It has not been redesigned.
This section is about intake speed only. For the full per-channel, per-customer picture — including reply identity — see Integration map: how a lead reaches Clara, and who Clara replies as, just above.
Every new lead deserves a response within 60 seconds. Today's guest-card pull from AppFolio runs every 15 minutes. That 15-minute window is a bottleneck: conversion rates are 35x higher when first contact occurs within 5 minutes vs. over 1 hour.
Two doors deliver leads to Western Slope:
| Surface | Who can use it | Speed | Notes |
|---|---|---|---|
| Stack partner API + signed webhooks | Certified partner program only (apply, questionnaire, security approval) | Real-time (on event publish) | Leasing event list not published; must confirm guest-card event exists with AppFolio. Fastest path if webhook is a published event. |
| Reports API | Plus plan (read-only) or Max plan (read-write); no API access on Core | ~1 request per second, date filter may be ignored on guest-card report (use full-list diff) | 5–30 min response time in practice. Requires polling every 15 min for coverage; 30-minute pagination window means stale page URLs after 30 min. |
| ILS lead email | Any plan including Core | Seconds after prospect submits | Syndication email auto-converts leads to guest cards. Works for Zillow, Apartments.com, and all ILS partners. Only path available on Core plans. |
| Realm-X Leasing Performer (native AppFolio AI) | Max plan (inside AppFolio, no polling needed) | No polling; built-in to AppFolio UX. Claimed 2.5 min response (call-to-booked-tour average) | Autonomous guest card creation and updates within AppFolio. Does not write to external systems; Clara must poll to sync. |
Industry benchmark for first response: 60 seconds or less. Here's how 13 leasing CRMs and AI assistants integrate with AppFolio and list-management platforms:
| Vendor | Intake mechanism | Latency claim | Dedup / household handling | Guest card write-back |
|---|---|---|---|---|
| Respage (ResMate AI) | AppFolio Stack API | 30 seconds (case study) | Unified CRM; no explicit dedup docs | Bidirectional API sync |
| AppFolio Realm-X Leasing Performer | Native AppFolio product | 2.5 min (call-to-tour booking) | Native to AppFolio; auto-updates guest cards | Native integration |
| AppFolio Lisa | Native AppFolio product | <2 min (text initiation) | No explicit dedup docs | Automatic guest card creation |
| EliseAI (MeetElise) | AppFolio Stack API | <1 min (email/chat response) | Unified CRM sync; no explicit dedup | Bidirectional API |
| LeadSimple | Stack API + email reports | ~5 min applicant sync | Duplicate Lead Checker (only vendor with explicit dedup tool); marks duplicates, manual merge option | Bidirectional API sync |
| Knock CRM | AppFolio Stack API | Not specified | Unified prospect 360; no explicit dedup | Bidirectional API; creates guest cards with AI data |
| ShowMojo | AppFolio Stack API (two-way) | Automated workflows (latency not claimed) | Implicit dedup via listing data; not explicit | Automatically pushes to AppFolio as guest cards |
| Rently | AppFolio Stack API (recent) | Not specified | Implicit via tour/lead sync | Pushes touring and guest card data back |
| Funnel Leasing | AppFolio Stack API (partner API) | Not specified | Non-destructive overlay as "system of engagement" | Likely via API (unclear if explicit write-back) |
| Tenant Turner | Not confirmed for AppFolio | Not found | Not found | Not found |
| LeaseHawk (ACE) | AppFolio integration (details unspecified) | 24/7 continuous; latency not claimed | Virtual assistant; no explicit dedup | Bidirectional; unclear guest card mechanism |
| Hyly | Multi-PMS (AppFolio included) | Not specified | No explicit dedup | Unclear guest card write-back |
| Rentgrata | Partner/RentPress integration | Not specified | AI-powered insights; no explicit dedup | Unclear |
Key finding: Leasing CRMs claim dedup via unified CRM, but only LeadSimple documents an explicit "Duplicate Lead Checker." None document household or co-applicant grouping. LeadSimple's dedup tool requires manual merge; household logic is vendor-specific and incomplete across the board.
How it works: One polled request per AppFolio database per minute across all properties. Diff new guest cards by ID against the last known state; keep a 15-minute full-pull as the safety net. Incremental pulls stay within the ~1 request/second Reports API limit on Plus plans. No permission gate beyond the Plus tier upgrade (Western Slope upgraded off Core as of 2026-09-09; tier to confirm; Situs is Plus).
Timeline: Builds this week. Owner: Ours to build; requires no partner application.
Latency: Up to 1 minute per database. Faster than today's 15-minute pull; aligned with the 60-second response SLA.
Trade-off: Still polling, not real-time; misses the 30-second Respage benchmark. Real-time arrives via option B.
How it works: Submit the partner application, complete the security questionnaire, and request confirmation that "new guest card" is a published webhook event (it may not be; must verify with AppFolio). On approval, configure signed HTTP webhooks to deliver guest cards in real-time as they land in AppFolio.
Timeline: Partner application and questionnaire: 2–4 weeks. Confirmation of event availability and webhook setup: same cycle.
Latency: Real-time (seconds after guest card created in AppFolio).
Owner: Fede's call to start; partnership coordination with AppFolio required.
Trade-off: Longer cycle time, but real-time delivery. If guest-card is not a published event, the application may be rejected for that specific use case.
How it works: Register directly with Zillow Lead API and Apartments.com as a lead recipient. Prospect submits on Zillow or Apartments.com, lead posts directly to our endpoint, skipping AppFolio entirely. Structured JSON with phone, email, beds, move-in, source, and listing URL.
Timeline: 4–6 weeks onboarding per feed (testing, security approval, credit check per vendor).
Latency: Seconds (direct from listing site to our system).
Owner: Fede coordinates with vendors; engineering builds the webhook receiver.
Trade-off: Does not cover non-Zillow/Apartments.com sources (property website, ILS partners, AppFolio's own inquiry form). Western Slope lists on Apartments.com and Zillow, so this covers the majority of online leads. Situs Group will have broader exposure; C becomes more valuable there. Requires dedup/household logic to handle the same lead arriving via AppFolio guest card AND Zillow feed simultaneously.
The lead-email path (ILS syndication email to leasing@westernslopepm.com) remains the fastest available without permission gates or partner applications. Email delivery is seconds after a prospect submits. This is the only path available on AppFolio Core plans. For Western Slope (upgraded off Core as of 2026-09-09; Reports API key arriving this week; tier to confirm), the email mailbox remains a fast path; guest-card pull via Reports API becomes available once tier is confirmed.
Sources: AppFolio Stack Partner Program · AppFolio Stack APIs · Skywalk API for AppFolio Guest Cards · AppFolio Realm-X Leasing Performer · LeadSimple AppFolio Setup · Respage ResMate Case Study · Zillow Lead API · Apartments.com Integration with AppFolio
Sep 4 demo transcript (raw) · Sean's proposal PDFs (Sep 4) · Situs AppFolio dashboard and occupancy report, read-only, Sep 5 · Western Slope AppFolio listings and pomonaparktownhomes.com, Sep 5 · production unit counts · GitHub merged-PR authorship · repo voice config and drift audit · ElevenLabs and Google docs · Colorado SOS mirrors.