Inbound Email Death-Path Audit

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.

Scope: 82 terminal paths across 6 stages (webhook pre-gate → triage → decision → SQS → Lambda → send/post-send). 24 paths carry a full adversarial verdict; 58 are inventory-mapped only and are flagged as such.
Founder invariant under test: an active conversation must always keep engaging — no rule or classifier may silently drop a mid-thread reply.
In-flight fix: fede/active-thread-invariant @ 6766ba7b9 (1 commit, 11 files, +967/−37, vs main @ c3f070050). Re-verified in the worktree today — see §5 for exactly what it does and does not cover.

1. VERDICT TABLE

1A. Verdict-audited paths (24)

# Path Stage Eats active-thread mail? Sev Visibility Tests
1 route-malformed-jsonwebhook/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-enveloperoute.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-swallowroute.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-teardownroute.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-idwebhook-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-idwebhook-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-matchwebhook-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-subscriptionwebhook-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-mismatchwebhook-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-idwebhook-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-failedwebhook-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-throwwebhook-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-404webhook-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-200inbox-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-idwebhook-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-consumeswebhook-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-consumeswebhook-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-consumeswebhook-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-claraprocess-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-rowwebhook-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-scopewebhook-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-silentsqs/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-throwssqs/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-rowwebhook-processors.ts:687-721 sqs CONDITIONAL — the happy path is the only unaudited terminal high nothing until the Lambda writes a row partial

1B. Inventory-mapped, not verdict-audited this round (58)

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

2. REMAINING HOLES AFTER THE IN-FLIGHT FIX

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.

H1 — The three deterministic gates run before the bypass is ever computed · HIGH · small PR

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-Unsubscribeauto_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).

H2 — The publish arm is the only unaudited terminal, and the fix routes more mail into it · HIGH · small PR

saveIngestion has exactly 5 call sites on the fix branch — NTV :428, renewal :462, countersign :501, pm-reply :553, skip :583the publish arm is not among them (verified today). So:

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.

H3 — Graph 404 on folder move eats mail that is still in the mailbox · CRITICAL · small PR

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.

H4 — 120 s route budget vs a 150 s LLM client timeout · HIGH · small PR

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).

H5 — Post-bypass throws leave no recoverable row · HIGH · fold into H2

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.

H6 — Whole-mailbox outages die with no row and no page · HIGH · surgical each, one PR together

H7 — The bypass is keyed on From only · HIGH · surgical

process-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.

H8 — The backstop's own failure is correlated with the drop · HIGH · surgical

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.

H9 — Aggregator fast path decides before the bypass · MEDIUM · surgical (new, verified today)

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.

H10 — Send-side: transient failures become permanent, and the row says COMPLETED · HIGH · needs design (not verdict-audited — next round)

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.

H11 — Message-ID reuse and dedup keying · MEDIUM · small PR

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.

H12 — AppFolio sender trust is a substring test · LOW · surgical, one helper

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.

H13 — Recipient truncation makes the PM-reply matcher unreachable · MEDIUM · surgical

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.


3. TEST-HARNESS GAP LIST

Tier 0 — tests that currently pin the bug as correct. Change these first.

  1. 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.
  2. 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.
  3. 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.
  4. 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).

  1. 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.

  2. 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.

  1. 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).
  2. Same file — the alert gate: assert 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.
  3. Same file — missing subscriptionId / missing resourceData.id (both untested), asserting the recovered id from resource / @odata.id and, when unrecoverable, a page.
  4. Same file — the AppFolio pre-gates have zero webhook-level tests: (a) a genuine NTV short-circuits and does not reach processInboundEmailMessage; (b) a prospect reply whose subject happens to match does reach it; (c) tenant_not_found alerts.
  5. src/__tests__/parse-appfolio-*.test.tsisTrustedAppfolioSender('…appfolio.us.attacker.com') must be false (true today); 'Re: …' / 'FW: …' subjects must be false.
  6. NEW lambda/outlook-subscription-renewer has no test file at all — a 429 must not clear subscriptionId; a 404 must.
  7. 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.
  8. src/__tests__/email-inbox-integration.test.tsparseGraphMessage with a valid from, a real body, and a malformed internetMessageHeaders entry must still parse (returns null today, inbox-client.ts:932-936,966).
  9. Lambda-side — a payload whose property lost its emailIntegration must write a FAILED row instead of ACKing silently (handler.ts:387-390).

4. VISIBILITY GAPS

Ranked by "a real email dies and no human can ever find out."

# Where mail dies invisibly What exists today Cheapest close
V1 Publish armwebhook-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 200webhook-processors.ts:327 one console.warn Discriminated result + FAILED row + logCritical (H3).
V3 Vercel teardownroute.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 issuelogging.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 conversationIdwebhook-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 gatesprocess-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 rowsprocess-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 NTVhandle-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" commentswebhook-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.

5. WHAT THE MAP MISSED — cross-reference findings

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 awayprocess-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 fixdecideAndRecord 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.

PropFlow Docs