RCA — Aug 15, 2026: Clara's new "booking guard" rewrote real customer replies, and Camellia's team emails bounced

Written 2026-08-15 evening (MT) · Owner: Fede · Status: production restored to pre-guard state; root causes confirmed; long-term fixes NOT started — deliberately. This page is the handoff. All times Mountain (MT) unless marked UTC.

The whole thing in 8 lines

  1. What customers saw. Two real Camellia prospects (Alexis, a brand-new web inquiry; Ciara, cancelling a same-day tour) got a canned line — "Nothing is scheduled yet — I don't want to give you a time that isn't real" — instead of Clara's correct answer. A third (Noa, Aug 14) got a similar canned line on a real virtual tour.
  2. Why. A new safety check ("booking guard", added Aug 13 evening) rewrote any reply that mentioned a time or a promise to book unless Clara herself had called a booking tool in that exact turn. True sentences that come from anywhere else (a tour already on file, an automatic cancellation seconds earlier, availability a tool just returned) counted as "made up". It went live for every property on day one, blocking, with no trial period and no test against real conversation history.
  3. How bad. Replaying it over every real Camellia conversation since launch (Apr 4): it would have rewritten 93 of 1,107 real customer turns (8.4%); over the last month, 28 of 177 (15.8%). Of the 28 hand-read: 0 real fabrications, 28 false positives. It fired for real 3 times before we pulled it — all 3 wrong.
  4. What we did. Took over both threads, sent the correct replies by hand, and reverted the guard and its follow-ups (PR #5791). Production = latest main with only the guard removed, on both the web app and the AWS Lambda that answers texts/emails, since 2:46 PM.
  5. Second incident, same afternoon. Caleb cancelled his 4 PM tour; the team got no email. Two things stacked: (a) a "tour cancelled" email to the team has never existed in the code — cancellations only write to the tour record; and (b) when a notice was sent by hand through Clara's normal email provider (SendGrid), Microsoft bounced it: "banned sender". Microsoft has blocklisted the shared SendGrid addresses we send from — 4 emails to the Camellia inbox bounced today (a real "Tour confirmed" at 11:57 AM among them). Nobody was alerted because our bounce handling only watches renewal emails.
  6. Delivered by hand via Camellia's own Microsoft mailbox: Caleb's cancellation notice, Montez Brownlee's tour confirmation.
  7. Not a coincidence, not a regression: the guard was a new feature that was wrong from its first day; the cancellation email never existed; the Microsoft block is external. They all surfaced today because today had two routine cancellations and a routine web inquiry.
  8. Next (Fede's call: analysis first, then a properly tested fix — no rush): the prevention gate in §6, then the fix options in §7 as multiple choice.

1. Timeline

When (MT)What
Aug 13, 10:22 PMPR #5731 "never fabricate confirmations or slots (P8)" merges — the booking guard goes live, blocking, all properties, no flag, no shadow period. Author: fede-propflow (a Claude Code session working the Willows journey-harness findings). Deployed to the inbound Lambda 10:24 PM.
Aug 14, 9:01 AMReal block #1 — Noa (voice-originated SMS thread): true reply "your tour is virtual, the PM will call you at 9:00 AM" replaced with "nothing is scheduled yet".
Aug 14, 1:52 PMPR #5759 — TOUR STATE context block on SMS/email (grounding input for the guard).
Aug 14, 8:23 PMPR #5767 — guard grounds on TOUR STATE; identify_caller exemption fix. 96 hand-mocked unit tests; no real-history replay.
Aug 14, 11:00 PMPR #5769 — grounded-claims ledger + escalation family (Option B increment 1). Shipped with a 2,594-turn real-history replay — of the escalation guard, not the booking guard.
Aug 15, 2:10–10:10 AMSeparate: prod Anthropic key hitting per-minute rate limits (429s); bursts fail, single requests pass. Consumer never identified (needs the Anthropic console). Posted to #alerts.
Aug 15, 10:59 AMReal block #2 — Alexis (web inquiry email): correct pricing/terms answer + "let me know a day and I'll get it on the calendar" → replaced.
Aug 15, 11:19 AMReal block #3 — Ciara (SMS): tour auto-cancelled by the pre-agent handler 4 s before her turn; Clara's "Your 2:00 PM tour has been cancelled" → replaced with "nothing is scheduled yet".
Aug 15, 11:57 AMBounce #1 — "Tour confirmed: Montez Brownlee" to camelliaapts@jp-co.com rejected by Microsoft (banned sender). Silent.
Aug 15, ~12:00 PMFede reports "misbehaving terribly". Both threads taken over (status escalated 1:13 PM); Ciara SMS sent 1:19 PM; Alexis email sent (verbatim Clara draft) via Camellia mailbox.
Aug 15, 1:00 PMBounces #2/#3 — two "Still waiting on your signature" reminders (Jacob Warren, Salvador Guerra) rejected. Correction (verified against SendGrid + the Camellia inbox): only Salvador's was re-sent and delivered (~1:01 PM, as "Still waiting on your team"); Jacob Warren's reminder was never redelivered — it is still lost (no message for him reached the inbox on Aug 15).
Aug 15, 1:24 PMPR #5790 merges — reverts #5769 (the ledger). Wrong target for the bug (both blocks reproduce without it) but clears the stack. Web app 1:37 PM, Lambda ~1:45 PM.
Aug 15, 2:31 PMPR #5791 merges — reverts #5731 + #5747 + #5767 (guard, comment fix, grounding follow-up). Web app + inbound Lambda both on the merge commit at 2:46 PM. Voice agent prompts auto-synced back.
Aug 15, 2:50 PMCaleb cancels his 4 PM tour by SMS. Clara answers correctly (post-revert). No team email — none exists.
Aug 15, 3:53 PMHand-sent "Tour cancelled: Caleb Wetzel" via SendGrid → bounce #4 (banned sender 50.31.49.42). 4:0x PM: resent via Camellia's Microsoft mailbox → delivered. Montez confirmation resent the same way → delivered.

2. Incident A — the booking guard

2.1 What it was

A post-turn check in Clara's reply loop (agents/clara/lib/agent/no-fabricated-confirmations.ts, called from conversation-manager.ts). Pattern-matching (regex) over the reply: if it saw a clock time + the word "tour"/"pickup", or a confirmation verb, or a promise-to-book, and no booking tool had returned ok/success in that turn, it threw away the whole reply and sent fixed copy: "I've passed this to the property team and they'll confirm a time with you. Nothing is scheduled yet — I don't want to give you a time that isn't real." A copy of the discarded draft was parked on the thread with a [BOOKING-GUARD-BLOCKED …] tag — which the conversations UI rendered as if it had been sent (the "show blocked drafts as blocked" UI change planned after #5764 never shipped).

2.2 Who introduced it, when, why

PR #5731, merged Aug 13 10:22 PM MT by fede-propflow — a Claude Code session working the Willows journey-harness bench, where Clara had (on the synthetic bench) stated a key-pickup time no tool produced. The fix was reasonable in intent; the shipping decision was the failure: blocking on day one, fleet-wide, no shadow mode, no flag, no replay against real conversations. Prompt half also rendered into 6 voice-agent prompts.

2.3 Why it fired on true sentences (root causes, ranked)

  1. Shipped blocking with no trial period. An observe-only week would have surfaced every false positive at zero customer cost.
  2. Never tested against real conversation history. All evidence was synthetic: hand-written unit sentences, a promptfoo gauntlet, the Willows bench. The deterministic replay technique existed (built for the escalation guard in #5769) and was never pointed at this guard.
  3. The replacement was maximally destructive. Discard the whole reply, substitute copy asserting "nothing is scheduled yet" — which in all 3 real fires was itself the false statement.
  4. The slot-offer exemption never worked in production. check_availability and get_tour_slots return no ok/success field, so a real availability call could never license the times Clara quoted. Cause of 14 of the 28 monthly false positives (machine recount from the replay data; an earlier hand tally said 15) and of Alexis's block.
  5. "Don't promise to book" was implemented without its precondition ("…if you cannot call the tool"). Clara had schedule_tour/book_tour available; the guard never checked.
  6. Clock time + "tour" in one clause = "offered a slot". Cancellations, reminders, restatements all have that shape (Ciara, Noa).
  7. Pre-agent state changes are invisible to the machinery. Ciara's tour was cancelled by the deterministic tour-confirmation handler 4.2 s before her agent turn. Corrected mechanism (traced 2026-08-15 evening, session archaeology + code + prod trace): the earlier "queue-boundary race" hypothesis is refuted — router and agent turn are one invocation, and the handler's plain-English note ("Tour on … has been cancelled") very likely did reach Clara (her blocked reply mirrors it). What failed: the legacy handler returns its note with no action field, and every machine consumer — the tool-catalog strip, the #5644 transcript-persist, the guard's grounding — filters on action ∈ DEFINITIVE_TOUR_ACTIONS, so all three ignored it (handle-tour-confirmation.ts cancel branch vs capabilities/leasing.ts, tour-pipeline-audit.ts). The #5644 session knew: it flagged the legacy confirm/cancel handler as a third decider "not folded into the new mechanism … a follow-up". Fix is one field: have the legacy handler emit the modern action vocabulary.
  8. No kill switch short of a code revert + two deploys (web app and Lambda).

2.4 Numbers (all from replaying the real guard code over real production traces, read-only, no AI)

PopulationTurnsGuard would rewriteRateOf which real fabrications
Camellia, every real customer text turn since launch (Apr 4 → Aug 15)1,107938.4%not all hand-read; the tool's mechanical "true-positive" label ≠ verified
Real people, all properties, Jul 15 → Aug 151772815.8%0 (all 28 read against the full thread)
Willows bench (synthetic), Aug 1 → Aug 152,0611728.3%
Actually fired in prod (Aug 14 04:24Z → revert)44 (41 bench, 3 real Camellia)0 of the 3 real

The four false-positive mechanisms in the 28: (A) slot tools can't ground — 15; (B) tour real but booked outside the turn's tool blocks (pipeline action / reschedule recorded after the reply) — 6; (C) no claim at all, just an offer to book — 4; (D) Clara honestly saying she can't confirm — 1; (E) Clara replying to a system notification email — 2. These A–E labels are from the in-session hand-read of the 28 threads and aren't reproducible from the saved replay files; the replay's own machine reason-codes are 25 no-booking-tool-fired / 2 slot-offered-without-tool / 1 booking-tool-errored.

2.5 Why every check said it was safe

2.6 What we did (all live)

3. Incident B — Camellia team emails

3.1 "Tour cancelled" email to the team: never existed

Correction after Fede pushed back (verified independently, evening)

"Never existed" is true only for email. The team IS notified of cancellations one real way: the tour event is deleted from the Camellia team's own Outlook calendar (deleteTourCalendarEvent inside applyCancel). Verified live: reading camelliaapts' calendar for Aug 15 via their calendar connection shows only Salvador's move-in — Ciara's 2 PM and Caleb's 4 PM events are gone, i.e. the deletion worked today. But it is a silent removal — no email, no notice; the slot just disappears. And on reschedules/confirmations the team does get an email ("Tour confirmed: …" with the new time) — so the memory "we get emails about tour changes" is correct; the cancel case is the one that only ever spoke through the calendar. The gap stands: nothing tells a human "this tour was cancelled" unless they notice a missing calendar slot.

Verified by a read of the whole code history and of all 11 Camellia cancellations since April (9 earlier + today's 2): applyCancel() only writes the tour history, signals Temporal, and deletes the Outlook calendar hold. No email, no Slack, no outbound-log row. The "team update" email that exists (notify_leasing_team, kinds on-the-way / running-late / …) has no "cancelled" type. The Outlook deletion can't notify either: the calendar event is created without attendees, and today's two tours had no outlookEventId at all. Nothing merged in the last two days touches this path. It is a missing feature, not a regression. What does exist and works: "Tour confirmed: <name> — Camellia Apartments · <when>" on booking.

3.2 Microsoft bouncing our email provider

Clara's team notifications go out via SendGrid on a shared sending pool (no dedicated IP on the account). Today Microsoft 365 (JP's inbox) blocklisted two of those pool addresses: 167.89.40.80 and 50.31.49.42 — bounce 550 5.7.511 Access denied, banned sender. In SendGrid's 30-day log, every bounce to a jp-co.com address is from today: 4 total (Montez confirmation 11:57 AM; two signature reminders 1:00 PM — later retried OK; Caleb cancellation notice 3:53 PM). Nothing on our side changed. Why nobody knew: the SendGrid event webhook (/api/sendgrid/event) acts only on renewal-tagged bounces; every other bounce is acknowledged and dropped. No fallback path existed.

Delivered by hand (via Camellia's own Microsoft mailbox — never blocklisted)

Not sent (Fede: "just Caleb"): a cancellation notice for Ciara.

Disputing with Microsoft: (1) Microsoft delist portal (sender.office.com) for both addresses; (2) SendGrid support ticket — they own the pool and delist at scale; ask for a dedicated IP.

4. Also surfaced today (not root causes, need owners)

5. Root cause, one paragraph, plain English

Clara got a new safety net that decides "is this appointment real?" by one test only: did Clara call a booking tool in this exact message. True things that arrive any other way counted as lies. It was turned on for every real customer the day it was written, without a trial period and without ever being run against a single past real conversation, so nobody learned that its most common trigger is a true sentence. When it fired it deleted Clara's whole answer and sent a fixed apology that was itself false. Separately, the "cancellation email to the team" that Fede expected never existed, and the day's attempts to email the team ran into Microsoft blocklisting our provider's shared sending addresses — silently, because we only watch bounces on renewal emails.

6. Prevention — the rules to adopt (proposed for CLAUDE.md / review lens)

  1. Real-history replay is the merge gate for anything that can alter customer-facing text. Deterministic replay of that exact code over ≥30 days of real-person conversations (the Camellia harness now exists), reporting turns / fires / false-positive count, with every real-person fire listed and labelled in the PR body. "Unit tests pass" is not evidence about production text.
  2. Observe-only first, always. ≥3 days in production recording verdicts and paging on real-person hits before any guard is allowed to change what a customer receives.
  3. Pattern-matching (regex) may never replace customer text. It may annotate, park, page, hold. Replacing needs grounded evidence of falsity, a model-scored judgment, or a human. (Fede: "no regex on the future version.")
  4. Every guard has a one-command rollback that reaches every deploy target (web app + Lambdas), and the rollback is exercised before the guard ships.
  5. Bench and real are separated in telemetry (source/property on the Sentry extra; pipeline-lab synthetic), and the journey harness keeps safety_check events and asserts guard_blocked=false on truthful fixtures.
  6. Bounces to a team inbox alert same-day, for every email kind, not just renewals.
  7. Two delivery paths for team notifications: the property's own connected mailbox first (Microsoft-native), SendGrid as fallback; retry across paths on bounce; request a dedicated SendGrid IP.

7. Fix-forward — decisions for Fede (multiple choice, recommendation marked)

7.1 What replaces the guard, if anything?

OptionCustomer impact when wrongEffort
ANothing for now — pre-guard Clara (prompt rules only), and invest in 7.2/7.3None0
B ★Receipts, not patterns — Clara may only state a booked time/slot by quoting a structured "receipt" the loop injects from real records (tour on file, tool result); the check is "does the reply's claim match a receipt", not "does it look like a claim". Watch-only for 3+ days, replay-gated per §6.Low (mismatch → annotate/page, never rewrite)Days–weeks, design first
CModel-judged post-turn check with a hold-for-human pathDelay, needs the unbuilt PM review screenWeeks

7.2 Team notification on cancellation

Build the missing feature: "Tour cancelled: <name> — <property> · <when>" fired from applyCancel() (deterministic, no AI), logged on the thread as pm_tour_cancellation_email, mirroring the confirmation email. Small. Recommend yes.

7.3 Email delivery hardening

Per §6 items 6–7. Recommend all three (alerts, mailbox-first, dedicated IP + delist).

8. Handoff — open items

ItemStateWhere
Production revert of the guardDone, verified both targets 2:46 PMPRs #5790, #5791
Alexis / Ciara threadsAnswered by hand; still in human takeoverconversations aabe42f4…, 8258d02a…
Caleb cancel notice, Montez confirmationDelivered via Camellia mailboxcamelliaapts@jp-co.com
Camellia full-history replay harnessBuilt, local branch, not pushed / no PR~/.claude/propflowai-camellia-replay, branch fede/camellia-guard-replay
Jacob Warren's "Still waiting on your signature" reminder (Unit 403) — bounced 1:00 PM, never redelivered; the team has not received itNot started (found during the Aug 15 verification pass)resend via Camellia mailbox, like Caleb's/Montez's
Microsoft delist / SendGrid ticket / dedicated IPNot startedneeds the SendGrid account owner
Bounce alerting for all email kindsNot startedsrc/app/api/sendgrid/event/route.ts
Cancellation team email (feature)Not startedapply-tour-intent.ts applyCancel()
Blocked-draft UXNot startedfollow-on to #5764
Anthropic 429 consumerUnknownAnthropic console
Grounded-claims decision pageIts "Shipped overnight" section is stale (increment 1 reverted) — corrected banner addeddocs.propflowai.co/a/grounded-claims-architecture
Appendix — evidence pointers (IDs, queries)
PropFlow Docs