Inbound email: what dropped Denise, and the deterministic-first redesign
TL;DR
A real Zillow lead (Denise Holguin, Camellia) was silently dropped by the inbound-email pipeline on 2026-07-24. She was recovered same-day through the real prod path — Clara replied at 4:44 PM MT. The investigation found the drop is systemic, not an edge case: 76% of 8,182 prod ingestion rows end in silent skips, and policy_skip is an unalerted, unrecoverable sink fed by at least five independent mechanisms.
A redesigned pipeline — deterministic-first, LLM only for genuinely ambiguous human free-text — was prototyped and benchmarked against the current pipeline on 50 real prod emails, 5 trials each:
Under an injected total LLM outage the prototype still replies to both guest-card leads; the current pipeline answers neither. One earlier causal claim did not survive the experiment — see the correction below.
Head-to-head results 50 emails × 5 trials × 3 arms
| Metric | Current (contaminated menu) | Current (clean menu) | Prototype |
|---|---|---|---|
| Dropped reply-worthy (silent) | 0.0% (0/95) | 0.0% (0/95) | 0.0% (0/95) |
| Correct disposition | 91.2% (228/250) | 92.4% (231/250) | 96.0% (240/250) |
| Disposition flipped across 5 trials | 4.0% (2/50) | 4.0% (2/50) | 0.0% (0/50) |
| Label flipped across 5 trials | 6.0% | 8.0% | 2.0% |
| Decided with zero LLM calls | 32.0% | 32.0% | 66.0% |
| Mean LLM calls per email | 1.36 | 1.36 | 0.34 |
| Total LLM calls (250 decisions) | 340 | 340 | 85 |
| Harness errors | 0 | 0 | 0 |
Named failures (no bare “passed”)
- Prototype, 2/50 wrong: “Re:Parking” (read as current resident; corpus itself flags it ambiguous) and “Please print” — misdirected HR mail that should have landed in
needs_reviewbut got a high-confidenceoperational_extract. That one is the real miss. - Current, 5/50 wrong (both menus): four are real operational mail discarded as spam by AI triage — a staff utility bill, both sides of a syndication support case, a calendar invite. A silent loss through a different branch than the one that dropped Denise.
Denise & Jay guest cards, per arm
All three arms landed reply / tour_request 5-of-5 in the harness. The difference is mechanism and cost: the current pipeline spends 10 LLM calls across the pair and the answer is a sampled token — prod sampled differently on 2026-07-24 and never replied to Denise. The prototype resolves both at zero LLM calls via the guest-card body parser. With a dead LLM endpoint injected, the prototype still replies to both; the current pipeline files both as operational and answers neither.
Correction: the contamination link is not supported
The original root-cause chain claimed the classifier picked appfolio_ntv_processed because deterministic-handler bookkeeping strings contaminated its DB-derived category menu. The experiment does not support that link. In 750 current-pipeline decisions, policy_skip was reached zero times; a focused 160-call probe never once got the classifier to select appfolio_ntv_processed even with the string sitting in the menu; and the contaminated-vs-clean ablation showed no measurable difference (91.2% vs 92.4%, same five failures).
What remains verified fact: Denise’s prod row is classified appfolio_ntv_processed → policy_skip, and she got no reply. Open hypothesis (unverified): that string exists nowhere in the current source tree — it’s legacy data in prod rows — so a legacy deterministic handler stamping the row directly is a live alternative explanation. This needs re-verification before the mechanism is asserted anywhere.
The systemic finding stands regardless of which mechanism stamped the row: an open classification vocabulary feeding an exact-string response allowlist means any unrecognized label — from contamination, LLM flake, keyword fallback, or a novel-but-valid category — becomes a silent drop.
Flaw inventory from 3 adversarial reviews
stripQuotedReplyreturns an empty string for any reply starting “On <date>…” and for forwarded messages → classifiedunknown→ dropped. Verified by executing the real regex.- No safety-net poller exists. The functions are dead code with zero callers; comments claiming “the poller is the safety net” are stale. The webhook is the only path and Graph already got its 202 — every drop is permanent.
- A Graph 429/5xx on message fetch returns
null— no throw, no requeue. The email is gone with only a log line. classifyEmailhas no resilience wrapper while the triage call beside it in the samePromise.alldoes — one LLM hiccup routes a real prospect to a non-allowed classification.- Concurrent webhook notifications clobber
webhookSeenIdsvia unguarded read-modify-write, and can persist a superseded OAuth refresh token for the whole mailbox. - Observability is thin:
classificationSourcepresent on 1.3% of rows,agentTraceIdon 9.7%.
Held security P0 — needs Fede’s explicit go-ahead, not shipped. The webhook path performs zero email authentication (DMARC/SPF parsed into headers, never checked), and isTrustedAppfolioSender uses an unanchored substring match — …appfolio.us.attacker.com is trusted, and the attacker’s own domain passes SPF/DKIM legitimately. Deterministic AppFolio handlers run before any AI gate and the tenant resolver matches on unit number alone, so a crafted email can file a fake notice-to-vacate (killing a renewal, spawning a turnover) or unpublish live listings. Proposed fix: separate PR — exact-address equality plus a shared isAuthenticatedSender(headers) DMARC gate. Held because it changes sender trust on the live Camellia mailbox.
The redesign: deterministic-first, LLM-last
Design directive (Fede, locked): maximize determinism — the LLM is a fallback for genuinely ambiguous human free-text, never the default path. Low confidence goes to a human, never to a guessed category.
flowchart LR
A[Inbound email] --> B{Sender-class router
deterministic}
B -->|guest card / aggregator /
system mail / bounce / internal| C[Closed category
0 LLM calls]
B -->|structured body| D[Parsers
guest-card, portal relay]
D --> C
B -->|unknown human sender,
free text| E[One structured LLM call
category + confidence]
E -->|high confidence| C
E -->|low confidence / failure /
empty-after-strip| F[needs_review
never drops]
C --> G[Action map over closed enum
unmapped = compile error]
F --> G
- Closed category enum — a typed union; the DB-derived vocabulary (
getExistingCategories) is deleted. No data can widen the label space. - One structured decision call replaces the triage+classifier pair, killing their contradiction modes; forced tool output, returns
{category, confidence, reasoning}. needs_reviewis a first-class terminal state that never drops — low confidence, LLM failure, empty-after-strip, and unknown sender class all land there.- Response policy inverted to an exhaustive action-map over the enum. An unmapped category is a TypeScript compile error (verified: deleting a member produces TS2741), not a silent runtime drop.
- Decision-level persistence — every email gets a durable decision record with its route, category, confidence, and terminal state.
- Observability as drift/rate alerts (needs_review rate, per-category volume), not per-email pages.
Honest caveats
- In-sample bias: the deterministic rules were written with the 50-email corpus visible, so 66% zero-LLM and 96% correct are in-sample numbers. A held-out corpus should be scored before shipping claims.
- The confidence net barely fired in the main run (high confidence 249/250). Its plumbing is proven only by fault injection: with a dead LLM endpoint, 17 emails landed in
needs_review, zero dropped, and both guest cards still got replies. - Harness ≠ prod: the current-pipeline arm is a faithful adapter over the real modules, but it is not the deployed webhook path end-to-end; prod-only behaviors (Graph fetch failures, concurrency) are covered by the flaw review, not the A/B numbers.
- The harness never reproduced the Denise drop, which is itself informative: the failure lives in low-probability sampling or in a mechanism outside the replayed modules (see the correction above).
Decisions needed & next steps
- decide Green-light the security P0 PR (sender-trust anchoring + DMARC gate) — held for explicit approval.
- decide Approve productionizing the deterministic-first pipeline (closed enum, action-map,
needs_reviewqueue + its UI surface) as a staged rollout behind the existing property scoping. - verify Resolve the
appfolio_ntv_processedprovenance question — what actually stamps that string on prod rows. - verify Score the prototype on a held-out corpus the rules never saw.
- quick win Fix
stripQuotedReplyemptying “On <date>…” replies, and add requeue-on-fetch-failure — shippable independently of the redesign.