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
- 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.
- 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.
- 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.
- 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.
- 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.
- Delivered by hand via Camellia's own Microsoft mailbox: Caleb's cancellation notice, Montez Brownlee's tour confirmation.
- 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.
- 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 PM | PR #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 AM | Real 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 PM | PR #5759 — TOUR STATE context block on SMS/email (grounding input for the guard). |
| Aug 14, 8:23 PM | PR #5767 — guard grounds on TOUR STATE; identify_caller exemption fix. 96 hand-mocked unit tests; no real-history replay. |
| Aug 14, 11:00 PM | PR #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 AM | Separate: 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 AM | Real 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 AM | Real 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 AM | Bounce #1 — "Tour confirmed: Montez Brownlee" to camelliaapts@jp-co.com rejected by Microsoft (banned sender). Silent. |
| Aug 15, ~12:00 PM | Fede 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 PM | Bounces #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 PM | PR #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 PM | PR #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 PM | Caleb cancels his 4 PM tour by SMS. Clara answers correctly (post-revert). No team email — none exists. |
| Aug 15, 3:53 PM | Hand-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)
- Shipped blocking with no trial period. An observe-only week would have surfaced every false positive at zero customer cost.
- 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.
- 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.
- 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.
- "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.
- Clock time + "tour" in one clause = "offered a slot". Cancellations, reminders, restatements all have that shape (Ciara, Noa).
- 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.
- 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)
| Population | Turns | Guard would rewrite | Rate | Of which real fabrications |
| Camellia, every real customer text turn since launch (Apr 4 → Aug 15) | 1,107 | 93 | 8.4% | not all hand-read; the tool's mechanical "true-positive" label ≠ verified |
| Real people, all properties, Jul 15 → Aug 15 | 177 | 28 | 15.8% | 0 (all 28 read against the full thread) |
| Willows bench (synthetic), Aug 1 → Aug 15 | 2,061 | 172 | 8.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
- #5731/#5767: unit tests hand-inject
succeededTools — none built from a real tool result, so the check_availability shape mismatch was invisible. The Willows harness cannot observe a guard block at all (its client keeps only tool_execution events); when the bench went red, the AI judge blamed the model.
- #5769: ran a real-history replay — of the other guard (0/166 real-person fires, all true positives). That number felt like proof and was about a different predicate.
- Sentry: bench and real blocks share one issue;
pipeline-lab isn't a synthetic tag and sms-stress is tagged production. 40+ bench events buried the 3 real ones.
- Repo rule ("done for an agent-loop change = harness green against real history") satisfied on paper, not in substance.
2.6 What we did (all live)
- Threads: Alexis
aabe42f4-… and Ciara 8258d02a-… set to human takeover 1:13 PM; Ciara SMS sent 1:19 PM (Fede-approved text); Alexis emailed the exact Clara draft that had been blocked (Fede-approved).
- #5790 (revert ledger) merged 1:24 PM; #5791 (revert guard + follow-ups; 31 files, +22/−3,527 lines per GitHub; only conflict resolutions were keeping later, unrelated imports and deleting two guard-only marker tests + the guard's registry id) merged 2:31 PM. Both deploy targets verified on the merge commit 2:46 PM.
- Repo-wide: the forbidden sentence and the guard predicate no longer exist in code.
- Camellia full-history replay harness built (branch
fede/camellia-guard-replay, not pushed: scripts/eval/replay-camellia-guards.ts, npm run replay:camellia-guards; reports kept local — they contain names/phones).
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)
- Caleb Wetzel — "Tour cancelled" notice → camelliaapts@jp-co.com ✓
- Montez Brownlee — "Tour confirmed" (the 11:57 AM one that bounced) ✓
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)
- Blocked-draft UX never shipped — parked drafts render in the conversations UI as if sent (planned after #5764).
- Anthropic prod key 429s 2:10–10:10 AM; consumer unidentified; needs the console usage page.
- Ciara's turn ran with empty
completedActions after the handler cancelled her tour — Clara was right by inference, not by being told (mechanism unconfirmed).
- Bench ≠ real separation in Sentry (see 2.5).
- Nikolas Espinal (Apr 17 cancellation) prospect row not found under its personId — data-integrity loose thread.
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)
- 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.
- 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.
- 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.")
- Every guard has a one-command rollback that reaches every deploy target (web app + Lambdas), and the rollback is exercised before the guard ships.
- 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.
- Bounces to a team inbox alert same-day, for every email kind, not just renewals.
- 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?
| Option | Customer impact when wrong | Effort |
| A | Nothing for now — pre-guard Clara (prompt rules only), and invest in 7.2/7.3 | None | 0 |
| 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 |
| C | Model-judged post-turn check with a hold-for-human path | Delay, needs the unbuilt PM review screen | Weeks |
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
| Item | State | Where |
| Production revert of the guard | Done, verified both targets 2:46 PM | PRs #5790, #5791 |
| Alexis / Ciara threads | Answered by hand; still in human takeover | conversations aabe42f4…, 8258d02a… |
| Caleb cancel notice, Montez confirmation | Delivered via Camellia mailbox | camelliaapts@jp-co.com |
| Camellia full-history replay harness | Built, 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 it | Not started (found during the Aug 15 verification pass) | resend via Camellia mailbox, like Caleb's/Montez's |
| Microsoft delist / SendGrid ticket / dedicated IP | Not started | needs the SendGrid account owner |
| Bounce alerting for all email kinds | Not started | src/app/api/sendgrid/event/route.ts |
| Cancellation team email (feature) | Not started | apply-tour-intent.ts applyCancel() |
| Blocked-draft UX | Not started | follow-on to #5764 |
| Anthropic 429 consumer | Unknown | Anthropic console |
| Grounded-claims decision page | Its "Shipped overnight" section is stale (increment 1 reverted) — corrected banner added | docs.propflowai.co/a/grounded-claims-architecture |
Appendix — evidence pointers (IDs, queries)
- Guard traces:
trace_7640cf99-… (Alexis), trace_e8e6034b-… (Ciara), conv_voice_6051a70d-… (Noa). Fire census: GSI entityType-createdAt-index, GSI3PK='AgentTrace' AND GSI3SK>='2026-08-13', then TRACE#<id>/META steps with checkName='no_fabricated_confirmations_guard' AND blocked=true → 44.
- Version matrix: both drafts block identically on
947babd9e (pre-#5767), bdb241cf7 (#5767), 708250d3c (#5769).
- Ciara tour
f149b871-…: cancelledAt 17:19:44.766Z by tour_confirmation_handler (sms); inbound stamped 17:19:48.931Z; TOUR STATE rendered "NO UPCOMING TOUR"; 20-tool catalog un-stripped. Correction: "completedActions empty" was over-stated — the trace stores only systemPrompt+assistantText, not the user-role context message, so the note's presence can't be read off the trace; code + Clara's mirrored phrasing indicate the note was present but action-less, hence invisible to strip/persist/grounding. Same-invocation confirmed (single call site inbound-router.ts:337; one trace, one inbound MSG in the window).
- Caleb tour
f1d22a34-…: cancelledAt 20:50:09Z; conversation fc64b85c-….
- Camellia cancellations (11): Apr 17 Nikolas Espinal (ui), May 6 Daniella Pruiskam, May 6 Jackie Sedley, May 10 Zachary Molin, May 15 Kailey Silvestri, May 22 Dario Quintana, Jun 1 Blair Bene, Jun 5 + Jul 22 Federico (test), Aug 15 Ciara, Aug 15 Caleb — none produced a team email (kinds
pm_* scanned).
- SendGrid: msg ids
Y_l9X5YGThiIZoPlu65tGg (Caleb, bounce 50.31.49.42), Jacob/Salvador reminders (bounce 167.89.40.80), Montez confirmation 17:57Z (bounce). GET /v3/ips → forbidden (no dedicated IPs).
- Independent verification (Aug 15, multi-agent fleet + live re-checks): all PR dates/authors, the fire census (44 = 41 bench + 3 real, 0 true fabrications), replay totals (1,107/93; 177/28; 2,061/172), the guard file path at the relevant commits, the version matrix, the Lambda deploy at 10:24 PM, the Ciara/Caleb tour records, the SendGrid bounces/blocklist, the "no cancellation email has ever existed" negative (full mailbox history: 45 Tour confirmed / 9 Tour update / 6 Tour rescheduled, zero cancel; full git history: no such code ever), and the live calendar (only Salvador's move-in remains on Aug 15) were re-verified against primary sources. Corrections applied above: #5791 diff stats, Jacob's undelivered reminder, the 14-vs-15 slot-mechanism count, and the A–E hand-read caveat.
- Deploy topology: web app via CI job
vercel-promote (Vercel git builds are cancelled by design); inbound Lambda propflow-inbound-processor-prod via deploy-lambdas.yml; it dynamically imports conversation-manager — the reply loop for inbound SMS/email runs in AWS.