A generic core that answers a lead on any channel, with the channel it arrived on and the PMS it has to write back to both pushed out into adapters — so a crash in one adapter never blocks a reply, and a new channel or a new PMS is an adapter, not a rewrite.
2026-09-20 · Proposed — pending review (Fede, Gera, session 004) · owner: handoff to a fresh build session (plan and inspection by session 000-32; session 004 reviews against Option D) · new page (this is a multi-week program with its own buckets and tracker, like the portfolio-architecture pages; the Leasing Engine hub gets a one-line pointer later)
On this page
lead.received) instead of a caller into Clara. A crash in the sync no longer touches the reply.runSyndicatedGuestCardReply assigns the AppFolio card to Clara and adds a note as part of composing the reply, and the eligibility check that gates it reads raw AppFolio fields directly. A PMS write failure there can still take the reply down with it — the same shape of bug, one hop further in.A shared per-turn hook already exists (conversation-manager.ts) and stamps engagement after a turn regardless of channel — that is the piece of "one core" Fede is describing, already built. The gap is that both edges around it still carry channel-specific one-offs instead of going through the same normalized event: voice re-does the engagement stamp and the PMS write on its own after the call ends; email's first-reply path skips the stamp entirely; nothing under one shared adapter decides "note vs. new card" once for every channel.
| Side effect | Voice | SMS / text | Same code? | |
|---|---|---|---|---|
Engagement stamp (onProspectEngaged) | Shared turn hook, plus its own second call in the call-ended handler (call-ended/route.ts:2476) | Shared turn hook only — no separate call found | Missing. The first-email send path never calls it (fix in flight) | Partial — shared hook exists, voice duplicates it, email is missing it |
| Guest-card note + assign to Clara (AppFolio write-back) | Its own inline flow off the call-ended handler (postcall-guest-card-single-writer.ts) | Not found — SMS doesn't write a PMS guest card today | Its own inline flow inside the reply function (guest-card-reply-intake.ts:350) | No — two separate one-off implementations, no shared adapter |
Target: side effects (create/update the guest card, note, assign, engagement stamp) are defined once, on outcome events, and fire the same way whether the lead arrived by phone, text, or email.
Outbox key shapes: rows keyed OUTBOX#<type>#<eventId>, dedupe on OUTBOXKEY#<type>#<org>#<kind>#<id> written via transactWriteWithReasons, one drainer, with type generalized from today's lead.received-only constant — no second outbox.
Conversation.channel in src/lib/data/types.ts) — the record is the point of coupling on purpose; splitting it per channel would lose "one prospect, one thread."bake-gate.ts) sits inside the core loop's send step, not in an adapter — it gates the reply itself, before any channel or PMS sees it.| Bucket | What | Depends on | Size | Lane | Proof |
|---|---|---|---|---|---|
| B0 | Outcome event types on the existing outbox; PmsSideEffectAdapter interface; in-memory fake adapter; dispatcher skeleton with config lookup; harness that replays a recorded lead.received through the fake adapter and asserts the calls | — | S | leads/ | Harness green, no prod behavior touched |
| B1 | AppFolio adapter: move the existing write-back (assign + note, the note-vs-new-card rule) behind the interface; carries the relay-email rule (a Zillow/Apartments.com/Rent. masked relay never mints a lead, it attaches to or waits for the matching guest card); unit tests against the existing write-back outbox; not called from prod yet | B0 | M | pms/adapters/ | Adapter tests green |
| B2 | Shadow dispatcher: core loop emits lead.replied; dispatcher runs in SHADOW per org (logs the effect it would apply, diffs against what the inline write-back actually did, ops event on mismatch); inline write-back stays live | B1 | M | leads/ | N days / N cards, zero mismatch at Western Slope and Willows |
| B3 | Cutover: remove the inline write-back from the reply seam; dispatcher applies for real; per org, Willows first. Camellia's mint-time first touch (pms-mint-engagement.ts) is a different path and is untouched until B5 | B2 | M | leasing/ | Willows live on the dispatcher, zero duplicate cards / lost notes |
| B4 | Gate purity: eligibility gate consumes normalized event fields only; AppFolio-specific reads (e.g. assignedUserId) move into the intake adapter; the system-created-card rule becomes an event attribute | B3 | S | leasing/ | Gate has zero direct AppFolio field reads |
| B5 | More outcomes (conversation.updated: inbound reply, tour booked) and more clients: migrate Camellia's mint-time first touch onto the same core loop; Yardi adapter stub behind the interface | B3 | M | pms/adapters/, leasing/ | Camellia on one core loop, same as Western Slope |
| B6 | Channel intake normalization: each channel emits message.received / lead.received and stops doing side effects itself; channel adapters keep only channel-specific concerns (voice latency + speakable formatting, SMS length/quiet hours, email threading/subject) | B3, parallel with B4 | M | agents/clara/, leasing/ | Voice's second engagement-stamp call site and email's missing one are both gone; one call site for the stamp |
Sequence: B0 → B1 → B2 → B3 → {B4, B6} → B5. Lanes by directory ownership (Constitution IV-A). Steady burn: 2–3 Sonnet builders always on, one PR per row, each ≤300 lines, dependent chain on one integration branch; Opus review only where judgment gates a merge. Everything here ships dark — the only "turn on" step is B3 per org, and that is Fede's call.
bin/guest-card-corpus, 170 Camellia cards, the no-side-effect regression set) runs on every PR in this chain.resolveProspectRecordOwner(orgId, propertyId) — the same org-default-with-property-override resolver the prospect list and detail loaders already use — and it already exists.Chosen: generic outbox + one dispatcher + per-PMS adapters. In plain terms: the core loop reads the context and replies. When it is done it drops a short "this happened" record on the queue tonight's lead signal already uses. A separate dispatcher picks those up once a minute and hands each one to the right connector (AppFolio today, Yardi later), which writes the note and assigns Clara. A slow or failing PMS never holds the reply. Voice works the same way: the call ends, drops a record, the connector writes the card. Fede on 2026-09-20: "this is fine."
Considered and rejected: a Temporal workflow per outcome (a second orchestration plane for the same guarantee) and keeping the effects inline behind an adapter (a PMS failure could still fail the reply, the exact shape of tonight's incident).
Small calls made under the finish rule, open to override:
Not this program's: the intake rule that a listing-site relay email (Zillow, Apartments.com, Rent. masked addresses) never mints a lead on its own under an AppFolio or Yardi owner, and attaches to the matching guest card instead. That rule lives in the intake adapter owned by session 004's lead-model program. This program starts after a lead exists and does not touch it.
resolveProspectRecordOwner(orgId, propertyId) (src/lib/domain/prospects/prospect-record-owner-fetch.ts:51-69) — the same resolver the prospect list and detail loaders use today. Org default with a property-level override that exists in code (pickProspectRecordOwner(propValue, orgValue), field declared on both org settings and property leasing settings, src/lib/data/types.ts:14009 and :15139), with an org-isolation check. The dispatcher never reads org settings directly.lead.received lives on, rather than a second queue.This also lines up with Gera's org-as-the-wall model: the dispatcher resolves its adapter through the org boundary, with an org-isolation check, the same boundary his model draws everywhere else ; the property-level override on top of it stays for now.
| Path | What it shows |
|---|---|
src/lib/domain/leasing/guest-card-reply-intake.ts (~327–364) | runSyndicatedGuestCardReply does the AppFolio write-back (assign + note) inline, right after a successful send |
src/lib/domain/leasing/guest-card-reply-eligibility.ts (~198) | Eligibility gate reads raw AppFolio fields (assignedUserId) directly, instead of a normalized event field |
src/lib/domain/leads/lead-received-event.ts, lead-received-outbox.ts, src/lib/data/dynamo/lead-received-outbox.ts | The durable outbox and envelope (version, eventId, orgId, recordRef, pmsRef, personId, householdId) that tonight's PR #10317 already ships lead.received on |
src/app/api/cron/lead-received-outbox-drain/route.ts | The 1-minute drain route that consumes the outbox today |
src/lib/domain/pms/source-id.ts | toInternalPropertyId — PMS ids get translated to PropFlow ids at the edge, before the core loop ever sees them |
src/lib/domain/prospects/prospect-record-owner-fetch.ts (51–69), src/lib/platform/settings-resolver.ts | resolveProspectRecordOwner(orgId, propertyId) — org default with a property-level override (pickProspectRecordOwner), with an org-isolation check; the resolver the target dispatcher would key off, exactly as the prospect list and detail loaders do today |
src/lib/data/types.ts (Conversation.channel) | One conversation record shared across channels — the coupling this plan keeps |
src/app/api/voice/call-ended/route.ts (line 2476, and ~2308–2319) | Voice calls onProspectEngaged a second time here, on top of the shared per-turn hook, and runs its own guest-card write-back (postcall-guest-card-single-writer.ts) — the duplication this plan removes |
agents/clara/lib/agent/conversation-manager.ts (line 13600) | The one shared per-turn call to onProspectEngaged that voice, SMS and email all already go through — the "one core" piece that exists today |
src/app/api/twilio/webhook/route.ts | No separate onProspectEngaged or guest-card write-back call found — SMS relies on the shared turn hook only, and doesn't write PMS guest cards today |
src/lib/domain/leasing/clara-guest-card-first-email-send.ts | The first-email-reply path does not call onProspectEngaged — the gap Fede flagged tonight; fix already in flight separately from this program |
src/lib/domain/leasing/pms-mint-engagement.ts (~143) | A second, independent call site for onProspectEngaged, used by Camellia's PMS-mint-time first touch — the path this plan migrates onto the core loop in B5 |
At Western Slope, a prospect answered one of Clara's emails with "That works, thanks!" to accept a tour time. Our system read that reply, filed it under the company's shared catch-all mailbox record instead of her own thread, found no open conversation waiting there, judged five words too short to make sense of on its own, and skipped it — with no alert to anyone. Her real conversation, with the tour offer sitting right there waiting for a yes, lived under the specific house she had asked about.
The home a person is asking about is a detail on their conversation, there so Clara can answer smartly — it must never be the thing that decides whether their message even joins the conversation.
So at a company with many single homes the conversation starts before any home is chosen. Clara helps the person find the right home, the inquiry and the application attach to that home, and the conversation stays one thread the whole way.
Example from Western Slope: her thread lived under 486 32 1/8 Road. Her reply arrived at the company's front-door mailbox. Result: no match, message skipped.
At Camellia and the Willows, a property is a whole building with its own mailbox, so routing by "the exact property" and routing by "the person" happen to land in the same place. Western Slope is about 70 separate single houses that all share one company mailbox and one phone line, so every single reply arrives at the front door — the exact case the old pattern gets wrong.
| Area | How it works today | What goes wrong at a company like Western Slope | Impact |
|---|---|---|---|
| Finding the conversation (email, text, chat and calls all share this) | A person's threads are looked up, then any thread under a different property is thrown out | A reply at the front door matches nothing, so a new thread is forked instead | Drops a customer message |
| Short replies ("ok", "that works") | Joined to an open thread only if that thread is under the same property | Treated as a cold email out of nowhere and skipped | Drops a customer message |
| Company mailbox | Every email arriving there is filed under the front-door record | Nothing arriving there can ever match a thread under a real house | Drops a customer message |
| Phone calls | A known caller whose record sits under a different property is treated as unknown | Clara greets a known prospect or tenant as a stranger | Wrong answer |
| Inquiries | Stored under the person with the home as a detail — the right shape | But an inquiry created at the front door is never re-pointed once the real house is known, so settings get read from the wrong record | Wrong answer |
| Drafts waiting for approval | Approving or rejecting must name the same property the draft was held under | Otherwise it quietly does nothing, and a held email could sit unsent | Drops a customer message |
| Follow-up texts stopping when someone replies | Keyed to the person | Works correctly across homes | Fine |
| Follow-up enrollment check | Asks "is this person a tenant at THIS property" | A tenant in another house of the same company isn't recognized | Wrong answer |
| Tours and calendar | Front-door bookings use the company's shared calendar | Fine at Western Slope; would skip a house's own calendar if one existed | Wrong answer |
| AppFolio seat and message sync | Set per property; the front-door record has none | "A human already replied" checks quietly stop working | Wrong answer |
| Test-property flag | Read from whichever property record the message landed on | The front door's flag, not the real house's, decides test vs. live | Wrong answer |
| Company phone line | A company-level line doesn't pin a house; Clara asks which home | This is already the right pattern | Fine |
Next: a design on this page covering how each channel finds the person first, how threads already split per home are merged, and how this fits the portfolio model where the company is the wall. Build waits for the design review.
Western Slope's first live day surfaced a related set of assumptions worth reading alongside this design: docs.propflowai.co/a/one-code-base — what Western Slope's first live day taught us.
The same four steps apply no matter how someone gets in touch. Step 1 always finds the company; step 4 is the only step that ever looks at a specific home, and only to label the answer, not to decide who the message belongs to.
| Channel | Step 1 — which company | Step 2 — who is this person | Step 3 — their conversation | Step 4 — which home it's about |
|---|---|---|---|---|
| Email to a home's own mailbox | The mailbox belongs to that company | By their email address on file; a name plus the home they mention if the address is new | Join their one conversation; start one only if they don't have one yet | From what they're replying to, or the conversation Clara already started; otherwise their open questions, or the words in the message |
| Email to the company mailbox | The mailbox belongs to that company | Same as above | Same as above | Same as above; if none of that is clear, Clara asks |
| Text to a home's own line | The line belongs to that company | By their phone number on file; a name plus the home they mention if the number is new | Join their one conversation; start one only if they don't have one yet | Same lookup order as email |
| Text to the company line | The line belongs to that company | Same as above | Same as above | Same as above |
| Phone call, to either number | Whichever number was dialed belongs to that company | By caller ID on file; name plus the home they mention if the caller is new | Join their one conversation; start one only if they don't have one yet | Clara asks, or reads it from their open questions |
| Web chat | The chat widget belongs to that company | By whatever they've given so far (email, phone, name); a new visitor starts as a new person | Join their one conversation; start one only if they don't have one yet | Same lookup order as email |
The old catch-all "front door" record that every company-mailbox message used to be filed under stops being a place messages get routed to or looked up from. It becomes nothing more than the default home label a company-level conversation carries before a real home is chosen.
This happened today, in a smaller form: the same person's text landed at the company's shared line, found no thread waiting there, and forked into a second, disconnected thread under the front-door record instead of joining the conversation her email had already started.
How it should work
| Step | What changes | Risk to production | How we prove it | Who turns it on |
|---|---|---|---|---|
| 1. Stop losing messages | Already in flight: a reply at the company mailbox is routed to the sender's own open conversation; a known prospect's reply is never silently dropped | None new | Proven already, with real production data | Nobody — it's a fix, not a new behavior |
| 2. Measure | For one week, log every time the old per-home rule would have thrown away a same-company thread — on email, text, and calls. No behavior changes | None | Counts, per customer, published on this page | Not applicable |
| 3. Join instead of fork | A person with a conversation anywhere in the company joins their most recent one instead of starting a new one. A company-level setting, off by default | Low — context from two homes now sits in one conversation | Replayed against real past messages, proven live at the scattered-site test company (the 70-home mirror of a real customer, with one company mailbox and one line), with the Willows as a regression check only | The owner, one company at a time, Western Slope first |
| 4. One thread from birth | A conversation is created only when a person has none yet. Every question and message carries its home as a label. Clara's context lists all of a person's open questions, across homes | Medium | Replayed against real past messages, proven live at the scattered-site test company (the 70-home mirror of a real customer, with one company mailbox and one line), with the Willows as a regression check only, and watched closely after each company turns it on for any dropped message | The owner, per company |
| 5. Merge the threads that already split by home | A one-time cleanup per company: a preview report runs first, showing exactly what will merge, before anything happens. Old identities are kept as pointers so nothing breaks | Medium, and reversible | The owner reviews the preview report before it runs | The owner, per company |
| 6. Where conversations are stored | Conversations are stored under a home today; once every lookup is person-first, that becomes an internal detail with no effect on behavior. Changing it is a later, separate decision — or never | None until it's attempted | Not applicable yet | Deferred |
The owner's rule (2026-09-21): the proof that counts runs at the scattered-site test company, because only that shape has a company mailbox, a company line and a front-door record; Willows alone proves nothing about this failure. Second rule (owner, 2026-09-21): single-building customers must not break. Every step ships as a company setting that is off, so those customers keep today's behaviour until the owner turns it on; the harness and the corpus replay include the one-building shape as a control, and a change that alters that path does not merge.
This is built the same way the rest of this program is: a queue of small, one-concern changes, each one roughly the size of a single focused pull request, each one dark on its own (it can merge into production without changing what anyone sees). Two to four builders stay on it at all times; as soon as one change is done, the next one in the queue starts. Work is split into lanes by what each change touches — where messages get routed, the person and inquiry records themselves, and each channel's own sending logic — with a heads-up whenever one lane's work needs to cross into another's. Every change proves itself before it counts as done: a test that includes a planted failure to make sure the check actually catches something, a replay against real past messages, and a live run at the scattered-site test company (the lab should be shaped like it — one company mailbox, one company line, multiple homes — not the Willows' single-home shape), with the Willows as a regression check only. Only turning a step on for a real customer waits for the owner's word. Every merged change gets a row in the build log on this page.
Queue for step 3 (join instead of fork):
Build started 2026-09-20 evening by a fresh session (handoff from the planning session). Tracking issue: #10344. Every pull request in the chain cites it.
Goal for the night of 2026-09-20 (set with Fede before he went to sleep): By 7 AM MT, a real guest card injected in production at the Willows (on-site team) and at the test organization (centralized team) each produces, with no human step: one new-lead event, one delivered first email, one “Clara replied” event, exactly one note and one assignment on the AppFolio card written by the new adapter, and the lead enrolled in the follow-up cadence. Every customer organization stays in shadow with zero writes and a clean shadow diff. Zero open pull requests from this lane. Evidence, before and after, recorded below.
| Date | Bucket | What | Status |
|---|---|---|---|
| 2026-09-20 | B0 | Contracts: the lead.replied outcome event, the PMS side-effect adapter interface, an in-memory fake adapter and a no-op adapter for companies whose prospect records live in PropFlow only. Nothing wired into production. | Merged 2026-09-20 (pull request) |
| 2026-09-20 | B0 | The outbox from the lead.received work generalized so any outcome event rides the same rows, dedupe, drain and backoff. Every existing lead.received key string is frozen because rows already exist under them. | Merged 2026-09-20 (pull request) |
| 2026-09-20 | B0 | Dispatcher skeleton keyed off the company's prospect record owner, plus the local harness that replays a recorded event through the fake adapter with a planted-mismatch control. | Merged 2026-09-21 05:5x UTC after five review rounds (pull request). Registers the real AppFolio adapter, the Yardi stub and the no-op; the every-minute drain now also drains replied events. A failed settings read or adapter call retries instead of being marked delivered. Follow-ups filed: backlog age bound, shared strict resolvers. |
| 2026-09-20 | B0 | Yardi stub adapter behind the same interface, interface only, no Yardi calls. Proves the contract is not AppFolio-shaped. | Merged 2026-09-20 (pull request) |
| 2026-09-20 | B1 | AppFolio adapter behind the interface: assigns the card to Clara and writes one past-tense outcome sentence as the note. Never sends messages, never creates a second card. Not registered yet. | Merged 2026-09-20 (pull request) |
| 2026-09-20 | B1–B2 | Record consumer (enrolls the lead in the follow-up cadence from the one replied event), dispatcher keyed off the company's prospect record owner with a company-level shadow/apply setting, emit site after a delivered first email, production proof at the Willows and the test organization. | Record consumer: Merged 2026-09-20 (pull request). Company-level shadow/apply setting: Merged 2026-09-20 (pull request). Test companies prepared: the Willows company set to on-site teams with AppFolio as record owner. The Western Slope mirror test company's synthetic-card injector does not reach intake tonight (session 000 finding), so the Willows real card is the end-to-end proof. |
| 2026-09-20 | Proof | Fede's call (option 1 of 3): on a test-flagged property, a card typed in by Clara's own AppFolio login counts as system-created, so the robot-made Willows card runs the whole reply path. Customer properties are never test-flagged, so nothing changes for them. | Merged 2026-09-20 (pull request) |
| 2026-09-20 | B1 | The one line that emits the "Clara replied" event after a first email is actually delivered, right where the write-back now runs. Nothing else in the sender changes yet. | Merged 2026-09-20 (pull request) |
| 2026-09-21 | Proof | Willows production run, existing cards. With the dispatcher live and the sandbox company set to apply, the next guest-card sync emitted new-lead events for the older Willows bench cards; Clara sent 7 first emails (06:26–06:30 UTC, all to test addresses), each one produced one "Clara replied" event, the dispatcher delivered every one, and each card carries exactly one assignment and one note. Example: inquiry dfa11d14 (card 95): first email sent 06:30:50, engaged 06:30:54, follow-up cadence started 06:30:54 with 4 touches, replied event 06:31:04, assign sent 06:31:01, note sent 06:31:04. | Passed for existing cards. Caveat: the assignment and note were enqueued by the old inline write-back a few seconds before the replied event, so the dispatcher's write was a same-id no-op; independent proof needs the inline path removed (next PR). |
| 2026-09-21 | Proof | Willows production run, brand-new card. A fresh email-only robot card (card 120, inquiry 7326dc0f) was discovered by the sync in 7 seconds, but got no first email: the mirror emits the new-lead event before the person record exists, and the first-email consumer treated a missing person as a permanent skip. A first card (119) with the shared test phone was folded into an existing test person instead, which is correct identity behavior, not a defect. | Gap fixed and merged (pull request): the consumer falls back to the property's own inquiry list (the same call the sync makes every tick) and retries instead of skipping. |
| 2026-09-21 | Proof | Willows production run, brand-new card, after the fix. Card 121 (email only) created 07:51:53 UTC → inquiry 81eebdfd minted 07:56:24 → new-lead event delivered through the fallback → first email sent 07:56:43 → engaged and follow-up cadence started 07:56:43 (4 touches) → "Clara replied" event 07:56:49, delivered by the dispatcher in apply mode 07:56:50 → exactly one assignment (07:56:46) and one note (07:56:49) on the AppFolio card. Under 5 minutes card-to-email. Customer companies untouched, shadow by default; the sandbox email switch was turned back off at 08:00 UTC. | Passed. Before: a fresh card at a customer got no first email through the new path. After: the full loop runs with no human step. Still owed for an independent dispatcher proof: remove the inline assign/note call sites (next PR). |
| 2026-09-21 | Proof | Test company (centralized team), first try: cards sent to Clara's shared address never arrived. Cause: the shared address picks the home by name across every company, and all 70 test homes share names with the real Western Slope homes, so it refused. Filed as an isolation defect. | Found / follow-up filed (issue). |
| 2026-09-21 | Proof | Test company, relayed guest-card email: with a dedicated address on one test home the card arrived, Clara replied from the company mailbox (delivered 8:58 AM MT) and follow-ups enrolled. But this path is the general email agent, not the new lead loop, so no new-lead or Clara-replied events fire there, and it marked the lead engaged once before anything was delivered. | Found / follow-ups filed (issue, issue). |
| 2026-09-21 | Fix | New-lead events now carry the company's own property id from the sync, never an id built from the AppFolio number. Before: a test-company event pointed at the real Western Slope home with the same number, so Clara's first email never found the lead. No customer write happened. | Merged (pull request). |
| 2026-09-21 | Proof | Test company (centralized team), new lead loop PASS: one synthetic card through the same intake the sync uses → new-lead event delivered → first email sent from the company mailbox at 9:23 AM MT → follow-ups enrolled (4 touches) → Clara-replied event delivered → AppFolio note and assignment correctly skipped because this company has no AppFolio. Before: stuck retrying, no email. After: email in 95 seconds with no human step. Company switches restored after the run. | Pass. |
| 2026-09-21 | Proof | Fairhaven test company set to on-site teams (decentralized), simulated card because it has no AppFolio: PASS. Card at Alder Court → new-lead event delivered → first email sent from the company mailbox at 9:37 AM MT → follow-ups enrolled (4 touches) → Clara-replied event delivered. 35 seconds from card to email, no human step. Company switches restored after the run. | Pass. |
| 2026-09-21 | Found | Caveat on all three passes: test properties skip the "was this card created by AppFolio's System" check. At real properties the lead never keeps its "created by" value, so Clara reads "unknown" and skips every card, System-created ones included. Found by session 000 from production records; their fix is building. Until it ships, no real-property card gets Clara's first email. | Open, fix building. |
| 2026-09-21 | Hold | Fede paused the refactor while Western Slope is being fixed. The change that puts every company on the same write-back logic is finished and saved, but not opened for merge. | Parked on Fede's word |
| 2026-09-21 | Incident | Western Slope leads stopped getting Clara's first email after Saturday night's change: the new path looks up who created the card in our saved record, and that detail was never saved. Fix merged by the coordinating session; the two missed leads from this morning are being re-run as held drafts for Fede to approve. | Fix deploying |
| 2026-09-21 | Tooling | An admin-only way to put a skipped lead event back in line, one company at a time, preview by default. Nothing runs on its own. | PR open, set to merge on green |
| 2026-09-21 | Proof | Full check after the fix: a fresh Willows card got Clara's email, follow-ups, assignment and note in AppFolio; a sandbox home set up like a real property saved "created by System", held the draft for review, posted to Slack and delivered on approval. Not proven: a prospect's reply and tour booking (nothing can send mail as a test prospect yet). | Passed, one step unproven |
| 2026-09-21 | Parking lot | Approving a held first email does not fire the Clara-replied event; the older direct calls cover it today. Wire it when the refactor resumes. | Parked |
| 2026-09-21 | Audit | Found that a conversation is kept per person per home; a reply arriving at a company mailbox matches no home and is skipped. Diagram and proposed pattern published. | Design next — pending owner's go |
| 2026-09-21 | Design | Person-first conversation design drafted: lookup rules per channel, merge of split threads, staged rollout | Proposed — pending Gera's decision (asked 2026-09-21) |
| 2026-09-21 | Stop losing (step 1) | Three Western Slope mailbox fixes live in production: a prospect's reply joins their own thread's home instead of the front door; Clara learns the thread id before sending; a reply with a missing sent-thread record is no longer dropped (reply never dropped, draft then send, reply routes to thread home) | Merged and deployed |
| 2026-09-21 | Lab harness | Person-first harness: five seeded cases with controls; the two incident shapes (email reply at the front door, text at the company line) stay red until step 3 (harness). Email cases for the two fixed defects added: a reply to Clara on the company mailbox routes to the sender's thread home, and a reply with a missing sent-thread record is not dropped, each with a control (email cases). | Merged |
| 2026-09-21 | Measure (step 2) | Every place that discards a same-person conversation because of the home it belongs to now writes a log line, so the flip can be sized from production numbers (measurement). Two review suggestions filed as follow-ups (call-ended site, email site). | Merged, log-only |
Corpus replay: the guest-card corpus replay becomes a required check from B2 on, when the dispatcher first reads real events; B0 plumbing is proven by the harness and its mutation control instead.
Yardi adapter: not built in B0. Waits on the open question above.