Adversarial audit of every terminal stop on the inbound email pipeline · August 2, 2026 · Camellia Apartments / PropFlow
Scope: 82 terminal paths across the six stages an inbound email passes through (webhook pre-gate → triage → decision → SQS → Lambda → send/post-send). 24 of them carry a full adversarial verdict; the remaining 58 are inventory-mapped only and are labeled as such wherever they appear.
The invariant under test is the one Fede locked on Aug 2: an active conversation must always keep engaging — no rule and no classifier may silently drop a mid-thread reply. Every path below is judged against that single question: can it eat a reply from someone Clara is already talking to, and if it does, can any human ever find out?
Decisions and the fix program are not here. The incident record, the locked decisions, and the workstreams in flight live in the companion artifact: Silent Drop of Mid-Conversation Emails. This page is the evidence base behind decision 5 of that record.
| # | Path | Stage | Eats active-thread mail? | Sev | Visibility | Tests |
|---|---|---|---|---|---|---|
| 1 | route-malformed-json — webhook/route.ts:71-77 |
pre-gate | CONDITIONAL — transport corruption only; identity-blind by construction | low | none (logWarn = console only, logging.ts:116-122); no row |
partial (outlook-webhook.test.ts:166 asserts status 400 and nothing else) |
| 2 | route-empty-envelope — route.ts:79-83 |
pre-gate | CONDITIONAL — non-array value gets a 202, foreclosing Graph retry |
low | none | partial (outlook-webhook.test.ts:176-184, status only) |
| 3 | route-after-error-swallow — route.ts:87-96 |
pre-gate | YES — post-bypass throws (unguarded publishInboundMessage, webhook-processors.ts:692) leave no row |
high | Sentry error w/ zero context ids; no row | partial (only the fetch-throw case, outlook-webhook.test.ts:206-256) |
| 4 | route-after-teardown — route.ts:54 (maxDuration=120) |
pre-gate | YES — hard kill skips catch blocks; Anthropic client timeout is 150s > 120s (ai/client.ts:221) |
high | none — and recordEmailDecision('reply') already fired, so the alert reads healthier |
partial (lifecycle pinned; no budget drift test) |
| 5 | notif-missing-subscription-id — webhook-processors.ts:180-183 |
pre-gate | CONDITIONAL — malformed/spoofed POST; undeliverable even if fixed | low | logWarn, no ids in payload | none |
| 6 | notif-missing-message-id — webhook-processors.ts:184-187 |
pre-gate | CONDITIONAL — recoverable: resource + resourceData['@odata.id'] carry the id and are ignored |
low | logWarn only | none |
| 7 | notif-no-integration-match — webhook-processors.ts:189-193 |
pre-gate | CONDITIONAL — but eats 100% of a mailbox while subscriptionId drifts | high | logWarn (console); no row; no redrive handle | partial — outlook-webhook.test.ts:339-344 asserts the silence as correct |
| 8 | notif-ambiguous-subscription — webhook-processors.ts:149-159 |
pre-gate | YES — 100% of a mailbox for as long as the duplicate exists; has prod precedent | high | Sentry error (not fatal), one grouped issue; no row | partial (outlook-webhook.test.ts:295-307, null return only) |
| 9 | notif-clientstate-mismatch — webhook-processors.ts:198-207 |
pre-gate | CONDITIONAL — fires on missing/divergent stored secret; keep rejecting | high | Sentry error; no row; message id omitted from the event | partial (outlook-webhook.test.ts:346-363) |
| 10 | notif-dedup-graph-id — webhook-processors.ts:211-215 |
pre-gate | CONDITIONAL — burns the id even when publish never happened | high | logInfo only | partial — outlook-webhook.test.ts:623-646 pins the burn as correct ✅verified today |
| 11 | notif-token-refresh-failed — webhook-processors.ts:217-224 |
pre-gate | YES — sustained whole-mailbox outage | high | Sentry error, one grouped issue; no row, no page | partial (one test asserting only fetchMessageById not called) |
| 12 | notif-fetch-failed-throw — webhook-processors.ts:246-325 |
pre-gate | CONDITIONAL — content-blind read failure | medium | best in pipeline: logCritical + FAILED redrivable row — but nobody is routed to it | covered (9 tests, outlook-webhook-fetch-retry.test.ts:182-367) |
| 13 | notif-message-gone-404 — webhook-processors.ts:327-332 |
pre-gate | YES — a mailbox rule/Junk move invalidates the id; Graph ids are not immutable and we never request IdType="ImmutableId" |
critical | one console.warn; no row, no Sentry |
partial — outlook-webhook-fetch-retry.test.ts:328-338 asserts no row is written |
| 14 | notif-message-unparseable-200 — inbox-client.ts:566-581 |
pre-gate | CONDITIONAL — bare catch { return null } at :966 can eat a message with a real sender |
medium | logWarn only; conflated with 404 | partial (email-inbox-integration.test.ts:1267-1294 asserts the log line) |
| 15 | notif-dedup-internet-message-id — webhook-processors.ts:338-344 |
pre-gate | CONDITIONAL — Message-ID is the sole key, no date/subject component | medium | logInfo only; no row → not redrivable | partial (intended behavior covered; no collision case) |
| 16 | appfolio-ntv-consumes — webhook-processors.ts:411-437 |
pre-gate | CONDITIONAL — sender lock holds on the Graph path; tenant_not_found destroys a real NTV with no log at all |
medium | less than log-only on the unresolved arms | partial (parser/handler units; zero webhook-level tests) |
| 17 | appfolio-renewal-signed-consumes — webhook-processors.ts:444-472 |
pre-gate | CONDITIONAL — substring sender trust (appfolio-parser-helpers.ts:26) ✅verified today |
low | row only, on an admin dev page; no Sentry | partial |
| 18 | appfolio-countersign-consumes — webhook-processors.ts:480-514 |
pre-gate | CONDITIONAL — ^-anchored subject is the only thing stopping a quoted reply |
low | row only; not in decision-rate alert | partial (no reply-prefix test, no display-name test) |
| 19 | pm-thread-reply-no-clara — process-inbound-message.ts:240-296 |
pre-gate | NO — from-self only; correct terminal | medium | good on the recorded path; gate MISS is fully silent | partial (webhook branch has zero tests) |
| 20 | skip-arm-ingestion-row — webhook-processors.ts:566-584 |
decision | CONDITIONAL — sink is bypass-blind; 3 gates return before bypass is computed | high | SKIPPED row on /admin/dev/email-ingestion, no conversationId so it never shows on the prospect's thread |
partial (2 tests, both about row shape) |
| 21 | skip-arm-alert-scope — webhook-processors.ts:601-612 |
decision | NO (read-only) — but watches the two exits the bypass already makes unreachable | high | Sentry fatal for 2–3 reasons; void-ed, races Vercel teardown |
none (zero tests repo-wide for inbound-triage-bypass-missed) |
| 22 | sqs-publish-false-silent — sqs/client.ts:46-47 |
sqs | CONDITIONAL — env-shaped, all-or-nothing; stamps webhookSeenIds anyway |
high | one INFO line; no row; no Sentry; permanently deduped | partial — test enshrines the loss |
| 23 | sqs-publish-throws — sqs/client.ts:51 |
sqs | YES — no sqs retry policy exists (resilience.ts:260-269); FIFO MessageGroupId >128 chars = permanent per-sender death |
high | Sentry error, no ids, no row | none (only Twilio has a publish-rejection test) |
| 24 | publish-no-ingestion-row — webhook-processors.ts:687-721 |
sqs | CONDITIONAL — the happy path is the only unaudited terminal | high | nothing until the Lambda writes a row | partial |
Verdicts below are inference from the mapper's gate/outcome data plus the ordering facts established in 1A — treat as unverified pending the next round.
| Path | Stage | Eats? (inference) | Audit trail left behind |
|---|---|---|---|
persist-integration-lost persist-integration-updates.ts:19 |
pre-gate | no (dedup state loss → duplicates) | none, silent return |
own-outbound-clara process-inbound-message.ts:240 |
triage | NO — correct terminal, keep | SKIPPED row |
own-outbound-pm-fallthrough :293 |
triage | no (burns LLM calls, can be filed spam) | normal chain |
pm-outbound-lookup-error :273 |
triage | no for prospect; loses PM's half of the thread | logError |
own-template-loopback :307 |
triage | YES — pre-bypass | SKIPPED row, no alert |
auto-reply-suppression :312 |
triage | YES — pre-bypass, broadest regexes | SKIPPED row, no alert |
deterministic-spam-duplicate-blast spam-detection.ts:184 |
triage | YES — pre-bypass, in-process Map, nondeterministic | SKIPPED row, no alert |
deterministic-spam-zillow-template :192 |
triage | CONDITIONAL — pre-bypass | SKIPPED row |
aggregator-fastpath-decision-skip process-inbound-message.ts:419 |
decision | YES — decides at :419, bypass runs at :464 ✅verified today | SKIPPED row + decision; now alerted |
bypass-lookup-error :439 |
triage | YES — and the alert detector re-runs the same failing lookup | logError only |
bypass-miss-no-org thread-context-bypass.ts:157 |
triage | YES | no log at all |
bypass-miss-no-person-claim :165 |
triage | YES — common (second address) | none |
bypass-miss-conv-unstamped :170 |
triage | YES — pre-Phase-5a rows | none |
bypass-miss-stale-or-resolved :196 |
triage | YES — day-31 replies lose all protection | none |
triage-spam-arm process-inbound-message.ts:481 |
triage | YES on bypass miss | SKIPPED row + alert armed |
triage-operational-arm :494 |
triage | YES on bypass miss (the Dario incident) | SKIPPED row + alert armed |
decision-extract-operational :158 |
decision | covered by fix when bypass hits | row + decision |
decision-discard-spam :178 |
decision | covered by fix when bypass hits | row + decision |
decision-review-queue :189 |
decision | covered by fix — this is the Jace/Sadie-Kate path | row + decision + (new) alert |
policy-coercion-unknown-label response-policy.ts:119 |
decision | YES on bypass miss — LLM outage sends 100% to review with no page | row, coerced:true |
policy-allowlist-narrowing :144 |
decision | YES on bypass miss — highest-volume designed non-reply | row |
lambda-env-gate-fatal handler.ts:172 |
lambda | no (DLQ, recoverable) | Sentry fatal + DLQ |
lambda-smoke-skip :217 |
lambda | CONDITIONAL — truthy _smoke vanishes |
logInfo only, no row |
lambda-legacy-channel-throw :253 |
lambda | no | DLQ |
lambda-unknown-channel-drop :274 |
lambda | CONDITIONAL — silent ACK drop | logWarn only, no row, no DLQ |
lambda-record-error-dlq :288 |
lambda | no | DLQ, redrivable |
lambda-integration-disconnected :387 |
lambda | YES — email fully gone, one WARN | no row at all |
lambda-defensive-redecision process-email-record.ts:236 |
lambda | YES if allowlist changed mid-flight | SKIPPED row |
lambda-no-integration-no-gate :223 |
lambda | no (gate removed, not closed) | row w/o decision |
shadow-mode-suppression :355 |
lambda | by design | COMPLETED + draft |
route-conflict-error :294 |
lambda | YES — a real turn is lost on optimistic-concurrency clash | logWarn, row COMPLETED |
route-ratelimit-retry-failed :326 |
lambda | YES — ACK'd, no DLQ | FAILED row |
route-generic-failure :335 |
lambda | YES — ACK'd, no DLQ | FAILED row |
empty-reply-skip :405 |
lambda | indistinguishable from success | COMPLETED |
delivery-declined-completed :432 |
lambda | YES — 6 different non-sends collapse to one COMPLETED row, error not persisted | COMPLETED, misleading |
delivery-threw-swallowed :440 |
lambda | YES — converts a retryable Graph failure into permanent loss | FAILED row, no DLQ |
send-duplicate-detection-suppression handler.ts:406 |
send | YES — substring match on 'no further action needed' |
COMPLETED, logWarn |
send-empty-reply-lambda-guard :424 |
send | mirror of above | COMPLETED |
send-undeliverable-recipient :442 |
send | CONDITIONAL — aggregator-domain correspondents | COMPLETED, logWarn |
send-token-refresh-failed :455 |
send | YES — infra failure audited as COMPLETED | mis-audited |
send-lock-already-sent outbound-dedup.ts:130 |
send | no — correct dedup | COMPLETED |
send-lock-ambiguous-stale :153 |
send | YES — deliberate at-most-once drop | logCritical (only send path that pages) |
send-lock-fresh-held :167 |
send | CONDITIONAL — if the other invocation fails, neither reply goes | logWarn |
send-lock-fail-open :103 |
send | no — chooses duplicate over drop | logWarn FAIL-OPEN |
send-graph-throw handler.ts:550 |
send | YES — transient Graph outage = terminal loss, no DLQ | FAILED row + Sentry |
send-outcome-suppressed-optout inbox-client.ts:632 |
send | by design | COMPLETED, logInfo |
send-harness-suppressed :624 |
send | YES if ever live in prod — reports sent:true, stamps sentAt |
false success |
send-record-only-on-sent :599 |
post-send | n/a — conversation shows a reply the prospect never got | none |
guard-runaway-ceiling conversation-manager.ts:2451 |
lambda | by design, escalates | logCritical + escalated |
guard-farewell-loop :2536 |
lambda | CONDITIONAL — misread short reply = silence | logInfo only |
guard-anomaly-review-gate :6234 |
lambda | CONDITIONAL — deduped repeat sends nothing | logCritical |
guard-anomaly-gate-failure :6276 |
lambda | CONDITIONAL | logError only |
dispatch-test-data-guard inbound-dispatcher.ts:231 |
lambda | CONDITIONAL — canned "reserved for testing" text is emailed | logWarn |
post-send-conv-not-found record-pm-reply.ts:61 |
post-send | no (PM half) | Sentry + SKIPPED row |
post-send-duplicate-provider-id :72 |
post-send | no — correct | SKIPPED row |
post-send-save-failed :95 |
post-send | no for prospect; PM's reply invisible to Clara forever, and the message is still deduped | Sentry + SKIPPED row |
post-send-meta-bump-failed :119 |
post-send | indirect YES — stale lastMessageAt ages a live thread past the 30-day bypass window early |
logError |
no-poller-safety-net docs/adr/0011 |
structural | amplifier for every row above | n/a |
The fix makes review_queue / extract_operational / discard_spam advisory when bypass.bypass is true (process-inbound-message.ts:605) and widens the alert to spam_ai || operational || needs_review (webhook-processors.ts:616-618). Verified today. Everything below survives it.
own_template_loopback (process-inbound-message.ts:307), auto_reply (:312), spam_deterministic (:328-351) all return before checkThreadContextBypass at :464. The branch says so in its own comment: "the deterministic pre-gates above … run before the bypass lookup and stay exactly as terminal as they were." None of their skip reasons is in the widened alert gate, so they are terminal and unalerted for an active-thread sender.
Concrete kill shapes: two prospects each replying "Yes, that works" within 15 min at one property → the second is duplicate_blast (spam-detection.ts:96-105,133-154); a prospect on a corporate relay that stamps List-Unsubscribe → auto_reply (auto-reply-guard.ts:109-121); a prospect replying into a thread where our escalation template was quoted → own_template_loopback (detect-own-template-loopback.ts:38,62).
Fix: hoist the bypass lookup from :464 to immediately after the own-outbound gate (above :307), keep own_outbound unconditional, and make the other three advisory under bypass. Cheaper variant: a lazy bypass check inside each of the three gates before returning skip. Also skip duplicate_blast when msg.inReplyTo is set — a threaded reply is never a bulk blast — while still recording the fingerprint (spam-detection.ts:183-186 relies on that side effect).
saveIngestion has exactly 5 call sites on the fix branch — NTV :428, renewal :462, countersign :501, pm-reply :553, skip :583 — the publish arm is not among them (verified today). So:
publishInboundMessage returns false on unset SQS_INBOUND_QUEUE_URL (sqs/client.ts:22,46-47) → one INFO line, and webhookSeenIds is stamped unconditionally at webhook-processors.ts:727-732, permanently killing redelivery and redrive.publishInboundMessage throws (client.ts:51) → escapes to route.ts:89, Sentry error with no ids, no row. There is no sqs policy in resilience.ts:260-269.if (published) guard protects only recentMessageIds — a write-only dead field since the poller was retired (ADR-0011). It protects nothing.Fix (one PR): write the ingestion row before the SQS send with s3Key: webhookIngestionS3Key(messageId) and pass the row id on the payload so the Lambda reuses it via processEmailRecord's existing existingRecord param (process-email-record.ts:145,191-212); wrap :692 in try/catch → logCritical('inbound-publish-failed', …); make the webhookSeenIds stamp conditional on success; add an sqs resilience policy; bound MessageGroupId to ≤128 chars (hash it, as the sibling dedup id already is at :695); add a 256 KB body guard mirroring twilio/webhook/route.ts:487-488.
webhook-processors.ts:327-332 treats null as "nothing to read." But we never request immutable ids — the only Prefer header is body-content-type (inbox-client.ts:519-530), and grep -rn 'ImmutableId\|IdType' is empty repo-wide. A Junk filter, sweep rule, or a PM dragging the message between the notification and the GET invalidates the id and manufactures a 404 for live mail. One console.warn, no row, no Sentry, no redrive handle.
Fix: send Prefer: IdType="ImmutableId" on fetchMessageById and on subscription creation (graph-subscription.ts:96-110), re-create subscriptions; return a discriminated result (gone vs unreadable vs message) so :327 can tell a deleted message from a 200-with-unparseable-payload (inbox-client.ts:566-581); write the FAILED row + logCritical on both. Delete outlook-webhook-fetch-retry.test.ts:328-338 — it asserts the silence as correct.
route.ts:54 sets maxDuration = 120; the Anthropic singleton is built with timeout: 150_000 (ai/client.ts:221) and neither email classifier overrides it (classify-spam-ai.ts:149-156), and claudePolicy retries (resilience.ts:44-47). A hard Vercel kill skips catch blocks entirely — no Sentry, no row, no log — and recordEmailDecision({action:'reply'}) has already fired at process-inbound-message.ts:149, so a torn-down email makes the decision-rate alert read healthier. The fix lengthens this path: bypass hits now await maybeSignalProspectReplyByPersonId (:451-458, unbounded) and then publish.
Fix: explicit deadline (~100 s) checked before the classifier and before publish → write the FAILED redrivable row and return; per-call timeout: 20_000 on both classifier calls; race the reply-bridge against ~5 s (it is already fail-soft, leasing-reply-bridge.ts:188-194).
route.ts:87-96 catches with logError and no extras — no propertyId, no message id, no subscriptionId. Anything that throws after processInboundEmailMessage returned publish (SQS, or the unguarded await import('@/lib/temporal/leasing-reply-bridge') at process-inbound-message.ts:452-458, whose non-bypass sibling at :517-528 does have a .catch) vanishes. Fix: last-resort try/catch inside processNotification (where property, integration, messageId, msg are in scope), writing the FAILED row and rethrowing; tag already-recorded failures so the outer net doesn't double-page.
notif-token-refresh-failed (:217-224): bare return, no row, error-level only. Every inbound to that mailbox dies until reconnect. Fix: logCritical + FAILED row + rethrow, mirroring :252-324; stamp tokenRefreshFailedAt and page only on the 0→1 transition; add a mailbox-health cron.notif-no-integration-match (:189-193): the drift generator is graph-subscription.ts:171-178 collapsing every non-ok response to null, and outlook-subscription-renewer/handler.ts:294-306 reacting to null by clearing a live subscriptionId. One throttled PATCH blinds a mailbox for up to 30 min. Fix: discriminated result, clear only on 404/410; write a FAILED row on the drop.notif-ambiguous-subscription (:149-159): 100% mailbox outage, error-level Sentry, no row. Has prod precedent (scripts/unlink-stale-email-integration.ts:1-11). Fix: if exactly one candidate has isTest !== true, route to it and warn loudly (clones set isTest = true, clone-property.ts:319); otherwise refuse and write the row; add a duplicate-subscriptionId check to the drift detector and a guard in the OAuth callback.notif-clientstate-mismatch (:198-207): keep rejecting — it is the route's only authentication (middleware.ts:554) — but write the FAILED row, escalate to logCritical, and make the renewer treat subscriptionId && !subscriptionClientState as recreate rather than healthy (handler.ts:179-191), which today black-holes a mailbox forever.From only · HIGH · surgicalprocess-inbound-message.ts:430-431 computes replyToEmail and then never passes it to checkThreadContextBypass at :435-438 (verified today, main). Same blind spot in the alert detector (webhook-processors.ts:756). A prospect answering from a second address misses the bypass and its backstop. Check From, then Reply-To, in both.
checkThreadContextBypass fails open to bypass:false on any DDB error (process-inbound-message.ts:439-441), and detectAndAlertOnKnownProspectDrop re-runs the same lookup (webhook-processors.ts:758-764) with a catch that only logErrors (:783-788). One spine wobble drops the reply and silences the page. Fix: pass the already-computed BypassResult down on ProcessResult; logCritical when the detector's own lookup throws; await the detector instead of void-ing it (:605) — the after()-lifecycle hazard is documented 9 lines below at :614-623.
On the fix branch, decideAndRecord is called at process-inbound-message.ts:419 for lead-aggregator senders, but checkThreadContextBypass doesn't run until :464 and the override is at :605. An aggregator-sourced lead with an active conversation, at a property whose allowedClassifications excludes tour_request, still dies terminally — it now merely alerts. Hoist the bypass above :419 (same change as H1) or apply the override at the fast path.
process-email-record.ts:440 catches a delivery throw, marks FAILED, then falls through to the normal return so the Lambda ACKs — no retry, no DLQ (handler.ts:550 for the Graph throw). :432 collapses six structurally different non-sends into one COMPLETED row with the error written only to a logInfo. handler.ts:455 records a token-refresh failure as COMPLETED. inbox-client.ts:624 (harness-suppressed) is not special-cased at handler.ts:562, so the pipeline would report sent:true and stamp sentAt for an email that never left. Net: the ingestion log cannot be trusted to answer "did the prospect hear back?" Design work: persist deliveryResult.error on the row, split COMPLETED from NOT-SENT as a first-class status, and rethrow delivery failures so SQS retries.
webhook-processors.ts:730 stores the bare RFC 5322 Message-ID; :338 compares it. Key on sha256(internetMessageId + '|' + msg.date) instead — a true Graph redelivery carries the same receivedDateTime so protection is unchanged, while a low-entropy/reused Message-ID stops colliding.
appfolio-parser-helpers.ts:24-30 uses fromLower.includes(...) (verified today), so communications@jpco.mailer.appfolio.us.attacker.tld is trusted; the doc-comment at :14-15 claiming it is "unspoofable in practice" is wrong. Shared by all three pre-gates (NTV, renewal-signed, countersign) — one fix covers all. Separately: real Camellia NTVs appear as noreply@jpco.mailer.appfolio.us / donotreply@appfolio.com in fixtures, neither of which passes the hardcoded communications local part — the deterministic NTV handler may be near-dead in prod. Verify against real ingestion rows before changing.
inbox-client.ts:928-929 keeps only toRecipients[0], and ccRecipients is absent from both $select projections (:431, :519). The multi-address loop at process-inbound-message.ts:257-292 — and its test — exercise an input shape production cannot produce. A PM reply-all with the prospect in CC loses the PM's turn from Clara's context, silently.
Tier 0 — tests that currently pin the bug as correct. Change these first.
src/__tests__/outlook-webhook.test.ts:623-646 — asserts webhookSeenIds toContain 'msg-1' after a failed publish (verified verbatim today). Must become not.toContain, plus assert a FAILED row and logCritical.src/__tests__/outlook-webhook-fetch-retry.test.ts:328-338 — "does not write a failed row when the message is merely gone." Delete or invert (H3). Relax :217-228 so logCritical is expected on gone.src/__tests__/outlook-webhook.test.ts:339-344 — asserts only that nothing happened on no-integration-match; must assert a no_integration_match row + page.src/__tests__/process-inbound-message-outbound-gate.test.ts:258-295 — feeds a comma-joined to string inbox-client.ts:929 can never produce. Rewrite against the real array shape.Tier 1 — the invariant itself (this is the drift test that would have caught 2026-08-02).
NEW src/__tests__/active-thread-invariant.drift.test.ts — a table-driven E2E over processNotification, fixture-fixed to one sender who has an active conversation at the property (bypass would hit), replaying the exact Jace/Sadie-Kate shape ("Her name is Sadie-Kate Ball her phone number is …"). One row per gate, each asserting "the reply is answered, or a human is named within minutes" — never both false:
| case | injected condition | must hold |
|---|---|---|
| auto-reply subject | Re: Tour Tuesday — returning on the 15th |
reaches publish |
| auto-reply header | List-Unsubscribe present |
reaches publish |
| duplicate blast | second sender, identical short body, <15 min (__clearFingerprintCache) |
reaches publish |
| template loopback | body contains PROPFLOW · FORWARDED BY CLARA |
reaches publish |
| needs_review | classifier returns an unmapped label | reaches publish, row decisionOverridden:true |
| aggregator fast path | lead sender, allowedClassifications:['tour_reply'] |
reaches publish |
| bypass lookup throws | getConversationsByPersonId rejects |
logCritical('inbound-triage-bypass-missed') fires |
| reply-from-second-address | From ≠ claim, Reply-To = claim | reaches publish |
| Graph 404 | fetchMessageById → null |
FAILED row exists, id round-trips through graphMessageIdFromS3Key |
| SQS publish false | SQS_INBOUND_QUEUE_URL unset |
FAILED row + webhookSeenIds unchanged |
| SQS publish throws | reject | FAILED row + webhookSeenIds unchanged |
| token refresh null | getValidEmailToken → null |
FAILED row + logCritical |
| control | Clara-stamped own_outbound |
skipped and no alert |
The control row is what keeps the test honest: exactly one gate is allowed to terminate an active thread, and it is named.
NEW src/__tests__/outlook-webhook-route-budget.drift.test.ts — modeled on the existing lease-execution-notices-route-budget.test.ts: import the route's maxDuration, GRAPH_MAIL_RETRY_AFTER_CAP_MS (resilience.ts:197) and the Anthropic client timeout (ai/client.ts:221), assert 3 × cap + worstCaseLLM < maxDuration × 1000. This fails on today's arithmetic — that is the point.
Tier 2 — per-gate coverage that does not exist at all.
src/__tests__/outlook-webhook.test.ts — publish-throws case (mockPublishInboundMessage.mockRejected*); no such test exists on the email path anywhere (only twilio-webhook.test.ts:541).inbound-triage-bypass-missed fires for every non-own_outbound skip reason under a hit bypass, and not for own_outbound. Zero tests exist for this symbol repo-wide.subscriptionId / missing resourceData.id (both untested), asserting the recovered id from resource / @odata.id and, when unrecoverable, a page.processInboundEmailMessage; (b) a prospect reply whose subject happens to match does reach it; (c) tenant_not_found alerts.src/__tests__/parse-appfolio-*.test.ts — isTrustedAppfolioSender('…appfolio.us.attacker.com') must be false (true today); 'Re: …' / 'FW: …' subjects must be false.lambda/outlook-subscription-renewer has no test file at all — a 429 must not clear subscriptionId; a 404 must.src/__tests__/outlook-webhook.test.ts — the pm_thread_reply webhook branch is entirely untested: a save_failed record must leave the id out of both recentMessageIds and webhookSeenIds.src/__tests__/email-inbox-integration.test.ts — parseGraphMessage with a valid from, a real body, and a malformed internetMessageHeaders entry must still parse (returns null today, inbox-client.ts:932-936,966).emailIntegration must write a FAILED row instead of ACKing silently (handler.ts:387-390).Ranked by "a real email dies and no human can ever find out."
| # | Where mail dies invisibly | What exists today | Cheapest close |
|---|---|---|---|
| V1 | Publish arm — webhook-processors.ts:687-734 + sqs/client.ts:46-51 |
one INFO line; no row; id burned | Write the ingestion row before the SQS send (H2). One saveIngestion call closes the loss, the alert, and the redrive handle at once. |
| V2 | Graph 404 / unparseable 200 — webhook-processors.ts:327 |
one console.warn |
Discriminated result + FAILED row + logCritical (H3). |
| V3 | Vercel teardown — route.ts:54 |
nothing, and the decision counter reads success | Deadline check → FAILED row before the ceiling (H4). |
| V4 | Token refresh / no-integration-match / ambiguous-subscription — :217, :189, :149 |
logWarn or error-level Sentry, no row | FAILED row + logCritical on the 0→1 transition (H6). |
| V5 | logCritical groups everything into one Sentry issue — logging.ts:139 uses captureMessage(swept, …) with a constant message |
archive that issue once and the whole net goes dark permanently | Put the gate name in the message string (not just extra), so grouping is per-gate; add a fingerprint per context. Systemic — affects every "page" in this report. |
| V6 | Skip rows carry no conversationId — webhook-processors.ts:371-396 |
row exists but only on /admin/dev/email-ingestion |
Stamp conversationId when the detector resolves one → the dropped reply appears on the prospect's own thread, where a PM would actually see it. |
| V7 | Pre-bypass gates — process-inbound-message.ts:307/312/328 |
SKIPPED row w/ classification 'unknown', no alert |
Invert the alert condition at webhook-processors.ts:616 to "every reason except own_outbound" — 3 lines, and it is the only signal these gates will ever have until H1 lands. |
| V8 | Send-side COMPLETED rows — process-email-record.ts:432, handler.ts:455 |
deliveryResult.error is logged, never persisted |
Persist the error on the row and stop marking non-sends COMPLETED (H10). |
| V9 | AppFolio unresolved NTV — handle-appfolio-ntv.ts:44-56 |
not even a log line | logCritical on tenant_not_found / ambiguous; a Camellia move-out date is being discarded. |
| V10 | Stale "poller is the safety net" comments — webhook-processors.ts:559-560, :936-941, lifecycle/route.ts:15, outlook-subscription-renewer/handler.ts:18,95, types.ts:7885, graph-subscription.ts:90 |
six live comments promising a Lambda deleted by ADR-0011 (2026-04-17) | Delete them; replace with the accurate text already written at webhook-processors.ts:233-239. Highest value-per-byte item in this report — it currently tells the next responder that a dropped notification self-heals. Also make the Graph missed lifecycle event a logCritical: Graph telling us it failed to deliver is handled today with a logWarn and a pointer to that non-existent poller. |
M1. The fix increases exposure to the pipeline's least-audited arm. Making the three decision arms advisory moves bypass-hit mail off the skip path (which writes an ingestion row at webhook-processors.ts:583) and onto the publish path (which writes none — confirmed today: saveIngestion still has exactly 5 call sites on the branch, publish not among them). Four independent verdicts reached this conclusion. Recommendation: land H2's row-on-publish in the same PR, or the invariant fix converts a visible review_queue death into an invisible one.
M2. The alert watched the two exits the bypass already made unreachable. On a bypass hit, process-inbound-message.ts:472+ substitutes a synthetic triage:'prospect', so spam_ai and operational — the only two reasons the old alert covered — were structurally unreachable. The alert could only ever fire on bypass misses. Adding needs_review fixes the specific Jace case; the actual blind spot remains the three gates that return before the bypass exists (H1).
M3. recentMessageIds is a dead field. Written at webhook-processors.ts:562,565,716,719, read by nothing in src/, agents/, or scripts/ — its only consumer, the inbox poller, was deleted by ADR-0011. So the if (published) guard at :715-721 protects a write-only field while the stamp that actually strands the email (webhookSeenIds, :727-732) is unconditional. The code's one apparent safety gesture on the publish-failure path is inert.
M4. Two passing tests actively enforce silent drops (§3 Tier 0, items 1–2). Any correct fix will fail them. Flagging so the next PR author does not "fix the test."
M5. replyToEmail is computed and thrown away — process-inbound-message.ts:430-431 vs the call at :435-438 (verified on main today). This single omission is the mechanism behind bypass-miss-no-person-claim, the most common real-world bypass miss (personal vs work address, aliases).
M6. The aggregator fast path is outside the fix — decideAndRecord at :419, bypass at :464, override at :605 (verified on the branch today). Not in the mapper's inventory as a fix-gap.
M7. One helper bug spans three pre-gates. appfolio-parser-helpers.ts:24-30 substring trust (verified) is shared by NTV, renewal-signed, and countersign. One 3-line fix; the mapper filed them as three independent "by design" paths.
M8. Sentry message-grouping makes every "page" a first-occurrence-only signal (V5). A Graph outage that eats 40 emails is one issue; a resolved issue may page nobody on recurrence. This weakens every recommendation in this report that ends in "add a logCritical" — fix the grouping first or the alerts are theater.
M9. saveEmailIngestionRecord has no TTL (analytics.ts:244-254) but its only surface is /admin/dev/email-ingestion — a developer page. The recovery data outlives the incident; nobody is routed to it. A row is a recovery handle, not a notification: pair every new row with a channel that reaches a person.
M10. Correlated failure between the drop and its detector (H8) — the backstop re-runs the exact lookup whose failure caused the drop.
M11. Two mapper claims corrected by the audit. (a) notif-missing-message-id's "the id is the only handle" is false — resource and resourceData['@odata.id'] both carry it and are ignored (webhook-processors.ts:86-98), so the drop is avoidable, not merely unalertable. (b) skip-arm-ingestion-row's row-save failure is not invisible: logError at :397 does Sentry.captureException (logging.ts:106). The email is still gone.
M12. Direction-of-failure asymmetry worth a principle. Every gate in this pipeline fails toward drop, except one — send-lock-fail-open (outbound-dedup.ts:103), which chooses duplicate-over-drop and is greppable as FAIL-OPEN. For an invariant that says "always keep engaging," that ratio is backwards. Suggested standing rule for the next round: any gate that cannot prove the message was engaged must fail toward duplicate-or-row, never toward silence.
M13. Recommended PR sequencing. (1) V10 doc-cleanup + V5 Sentry grouping + V7 alert inversion — one tiny PR, unblocks trust in everything else. (2) H2 row-on-publish + H5 inner net — ship with or before the invariant branch. (3) H1 bypass hoist (also closes H9). (4) H3 immutable ids. (5) H6 mailbox-outage rows + renewer drift. (6) H4 budget + drift test. (7) H10 send-side redesign — needs design, and needs its own audit round: the send stage was mapped but not verdict-audited, and it contains the paths where the row says COMPLETED and the prospect got nothing.
Audit run 2026-08-02 by the Fable/Claude session, against main @ c3f070050 and the in-flight branch fede/active-thread-invariant @ 6766ba7b9. Every claim marked “verified today” was re-read line-by-line in the worktree during this round; the 58 inventory-mapped paths in §1B are inference and are flagged as unverified pending the next round.