ADR-0119 — The outbound record as a construction invariant


1. Context — the bug class

Six live code paths texted or emailed a real human and wrote no conversation message row. Clara rebuilds her context from those rows. So she texted a tenant a renewal offer inviting a reply, the tenant replied, and she answered a message she had no record of sending.

The mechanism is not a bug in any one of those six paths. It is the seam:

Two prior PRs closed instances of this reactively. #4842 fixed three divergence axes (property scoping, liveness, recency) and added the F9 drift rule. #4859 collapsed the renewal gate and anchor onto one resolved saga so they can no longer drift. Both were correct and neither addressed the seam.

1.1 The mirror bug: the phantom row

dispatch() returns skipped: true when it intentionally did not send — consent denial (the tenant replied STOP), the disabled-SMS policy, a shadowed capability, a synthetic origin. Recording anyway produces a phantom row asserting we texted someone who opted out. That gate is hand-written correctly today in a handful of places (sendTenantSms at agents/clara/lib/agent/tools/renewal/handlers.ts gates on !result?.skipped at :284; send-application-link.ts:172 gates on if (sent); renewal.ts:1357 gates on if (result.success)) — three different spellings of the same rule, each independently remembered.

So the invariant has two halves, and they are inseparable:

A send that reaches a person is recorded. A send that did not happen is not.

1.2 Why F9 cannot close it

src/__tests__/conversation-single-writer.drift.test.ts (F1–F9) is the compile-time/CI fence for ADR-0079. Every rule in it inspects writes that happen. F9 is the one rule aimed at this class, and it is a floor by construction — its own doc comment says so. Measured on this tree:

Measure Count Method
Sites declaring recipientType: 'tenant' | 'prospect' (F9's entire surface) 7, in 5 files rg "recipientType:\s*'(tenant|prospect)'" over product code
True dispatch() call sites (the messaging dispatcher) 27, in 20 files files importing @/lib/messaging/dispatcher (19 static + 1 lazy at src/app/api/leases/[leaseId]/renewal/route.ts:796), counted comment-stripped
F9 coverage of the SMS/Telegram send surface ~26% 7 / 27

recipientType is optional (dispatcher.ts:73). A new sender that omits it compiles clean and is invisible to F9. F9's own positive control only requires >= 4 matching files against the 5 that exist — one file's deletion takes the fence vacuous.


2. The email finding — answered first, because it sizes everything

DispatchResult.provider is 'twilio' | 'telegram' (dispatcher.ts:105). There is no email channel in dispatch(). So a fence wrapped around dispatch() cannot see email at all. This section is the measurement.

2.1 There is no single email chokepoint. There are three entry points.

Below them sit exactly two production transports (no SES; the only nodemailer.createTransport is the Mailpit test transporter at src/lib/integrations/email/client.ts:80):

# Transport Sole call Reachable via
T1 SendGrid HTTP v3/mail/send client.ts:170 in sendViaSendGrid, called once from client.ts:536 E1, E2 (fallback leg)
T2 MS Graph /me/sendMail + /reply agents/clara/lib/email/inbox-client.ts:529, 599, 692 E2 (primary leg), E3

And three entry points a participant-facing email can leave through:

# Entry point Where Outcome signal
E1 sendEmail(opts) / sendRecapEmail(...) client.ts:257 / client.ts:632 none usable — see §2.2
E2 sendPropertyEmail(params) — Graph primary, SendGrid fallback src/lib/integrations/email/property-graph-sender.ts:92 'sent_graph' | 'sent_sendgrid' | 'suppressed' (:83)
E3 MicrosoftInboxClient.sendReply / .sendNewMessage — called directly 3 sites: lambda/inbound-processor/handler.ts:518, src/lib/domain/leasing/send-prospect-email-touch.ts:101, src/lib/integrations/email/send-turnover-vendor-dispatch.ts:460 'sent' | 'suppressed' | 'harness-suppressed'

E2 is a partial chokepoint — it is the shared path for property-voiced mail and was built for that reason — but it is not universal: E3 bypasses it three times and E1 is much larger than both.

So: no, email has no chokepoint. Wrapping dispatch() fences the SMS/Telegram half of the class and nothing else.

2.2 sendEmail cannot tell its caller whether the send happened

This is the sharper finding, and it is what makes the email half structurally worse than the SMS half rather than merely uncovered.

sendEmail is declared Promise<{ messageId?: string } | void>. Its own return points are one { messageId } (client.ts:563, the SendGrid-accepted path) and ten bare return;. Those ten collapse together:

A caller therefore cannot distinguish delivered, deliberately suppressed, and failed-but-queued. On the SMS side the phantom-row rule is a hand-written convention; on the E1 email side it is not expressible — there is no skipped to gate on. (E2 and E3 both return proper outcome unions, which is why the senders built on them read cleanly.)

2.3 How many email sends are participant-facing, and how many record?

"Participant-facing" here means the recipient is a tenant, prospect, or vendor — someone who can reply into Clara's inbound funnel and whose reply becomes a conversation turn. That is the class where an absent row corrupts Clara's context. PM/owner/staff-facing sends are a separate class the repo already handles by mirroring onto the related entity's thread (registry entries maintenance.pm-page, maintenance.pm-forward, renewal.pm-notify), pending the deferred first-class PM thread (ADR-0059).

Counting call sites in product code (src/, agents/, lambda/; scripts/ are tools, not product):

All 10 live participant-facing email sends record a conversation row today:

# Site Recipient Records — where, gated on what
1 agents/clara/lib/email/tour-email-actions.ts:381 (in sendTourConfirmationEmail, :332) prospect same fn, :402 logOutboundForProspect
2 lambda/inbound-processor/handler.ts:518 prospect / tenant upstream — the agent loop's persistIncrementally recorded the reply that this send delivers
3 src/lib/domain/leasing/send-prospect-email-touch.ts:101 prospect in all 4 callers (tour-confirm-nudge, post-tour-chase, prospect-reengagement, prospect-outreach) via logOutboundForProspect
4 src/lib/domain/leasing/send-application-link.ts:492 prospect :176, gated if (sent) at :172
5 src/lib/domain/leasing/send-application-link.ts:613 prospect :193, gated if (sent) at :192
6 .../renewal-orchestration/send-renewal-email.ts:215 tenant src/lib/temporal/activities/renewal.ts:1365 recordOutreachConversation, gated if (result.success) at :1357
7 src/lib/integrations/email/send-vendor-quote.ts:114 vendor registered writer vendor.quote-email
8 src/lib/integrations/email/send-vendor-dispatch.ts:175 vendor registered writer vendor.dispatch-email
9 src/lib/integrations/email/send-turnover-vendor-dispatch.ts:460 vendor registered writer turnover.vendor-dispatch-email
10 src/lib/temporal/activities/vendor-chase.ts:302 vendor registered writers vendor.dispatch-chase / vendor.quote-chase

The answer, stated plainly: the un-fenced email bug class is currently size zero — and it is completely unguarded. Ten sites do the right thing in five different shapes (same-function append, caller append, upstream append, if (sent) / if (result.success), registered-writer helper), each independently remembered, with nothing at compile time or at runtime that would notice if the eleventh did not. Row 8 (vendor.dispatch-email) is in that table only because it was one of the six #4802 orphans and was fixed by hand — this set was non-zero four weeks ago.

This table was wrong on its first draft, in the way §2.4 predicts. It listed tour-email-actions.ts:286 as a live eleventh site. That call is inside sendTourAutoReply (:214), whose own header at :208 declares "no production callers (only tests) … Its sendEmail use is dead … for a dedicated dead-code sweep." Verified: zero production callers, while the live sibling holding row 1 (sendTourConfirmationEmail, :332) is called from src/lib/domain/leasing/tour/process-tour.ts. That file is the single strongest instance of the §2.4 hazard — three senders, two dead, one live — and a file-level fence cannot tell them apart. Left in the record rather than silently corrected, because the mistake is the argument for §2.4.

Correction to an inherited claim. The brief said "several of the six #4802 orphans were email sends (vendor.dispatch-email, the vendor chases, the PM-page emails)." Checked against src/lib/data/conversation-writers.registry.ts: the six under the ORPHAN-SEND FIXES (issue #4802) header are renewal.call-recap, renewal.change-term-confirmation, renewal.tool-sms, vendor.dispatch-email, voice.emergency-contact-sms, voice.emergency-relayone is email. The vendor chases, vendor.quote-email, turnover.vendor-dispatch-email and the PM-page emails belong to the earlier cluster-6 closure wave, which was mostly email. Two separate waves; the email exposure is real, the attribution was not.

2.4 Four dead senders — and why a file-level fence counts them as covered

✅ RESOLVED — all four removed in PR #4978 (stage 5, issue #4885). The present-tense survey below is the PRE-SWEEP state, kept as the record of the measurement that justified the decision; it is history, not current fact. What changed since:

  • sendClaraEmailReply — module deleted. stripMarkdown shared that module and was preserved as agents/clara/lib/email/strip-markdown.ts: it is the repo's only markdown-stripping helper, has no production caller, and the live path does not strip — so the regression it was written to prevent is LIVE. Tracked as #4976; deleting it would have destroyed the evidence.
  • forwardTourRequestToPM + sendTourAutoReply — deleted; tour-email-actions.ts is now 145 lines with sendTourConfirmationEmail the sole survivor. Its now-unused imports went too, including sendEmail — the file had kept the E1 door open while never calling it, which is the same hazard this section describes.
  • sendOutboundMessage — file deleted; stage 3's file count resolves 20 → 19.
  • checkReplyChainDepth RESOLVED by #5071: deleted, not wired. The path was already bounded by runaway-ceiling.ts (#2341, 2026-06-13) — the 2026-07-06 audit that flagged it had missed that guard, so the premise was wrong. See #4977.
  • Coupled registries that would have rotted were cleaned in the same PR: EXEMPT_KINDS dropped 'tour_auto_reply_email' (its emitter is gone; the guard windows 25h so the kind cannot appear), and CALLEES dropped 'sendOutboundMessage'. The kind itself STAYS in OutboundMessageKind — historical rows carry it and the UI must still label them.

None of these is in the live class. All matter to the design, because a file-level fence reports on dead files as confidently as on live ones — and tour-email-actions.ts is the sharpest case: three senders, two dead, one live, and a file-level rule cannot tell them apart. §2.3's first draft got this exact file wrong.

2.5 The runtime backstop is SMS-only

There is a scheduled detector for exactly this class: src/lib/domain/messaging/capture-drift/sweep.tssendBakeAlert kind conversation_capture_drift. Its header: "Diffs a RECENT, SETTLED window of Twilio SMS on the real-customer TFNs against every persisted conversation." Post-ADR-0079 it must read zero; non-zero means a new send path bypassed the writer.

Status update (stage 2, issue #4881 — shipping in two PRs). The EMAIL half is this PR; the Telegram half follows in its own. Neither reconciles against a provider API, and the reason is worth recording because it contradicts the shape this section assumed:

  • Email reconciles against the durable delivery ledger (EMAIL_LOG#<date>), which logDelivery already writes on every leg of every email path — all 11 SendGrid/Mailpit legs and all 5 Graph legs. That beats Graph sent-items on three counts: it covers both transports in one index, it needs no SendGrid Activity entitlement and no per-mailbox OAuth token inside a cron, and it is written by the transport while the conversation row is written by the caller — which is the only property reconciliation actually requires.
  • Telegram has no provider ledger to reconcile against at all (the Bot API cannot enumerate what a bot sent), and its only other witness (logTelegramVendorSend) is the conversation write whose absence is the bug. So it needs a write-ahead intent ledger instead — intent recorded before the API call, outcome stamped after. That is built and tested but deliberately split into its own PR: it is a new persisted row shape whose real value is stage 3's wrapper and any future channel with no readable ledger, not Telegram itself, and holding it back keeps the email fence landing on its own schedule.

So the real split is not by channel, it is by whether an independent ledger exists — and stage 3's wrapper (#4882) is the natural place to record intent for anything that still lacks one; it should adopt that ledger rather than replace it. Design notes: docs/planning/capture-drift-email-telegram.md.

The email sweep reads zero as designed. Telegram is expected NOT to: notifyVendorOfConfirmedTime / notifyVendorOfReschedule (handle-tenant-confirmation.ts) Telegram a real vendor and record nothing — cluster-6 missed them, and they are tracked as issue #4911.

It covers neither email nor Telegram. Verified: the sweep reads only https://api.twilio.com/2010-04-01/Accounts/<sid>/Messages.json (sweep.ts:113, :140), and there are zero occurrences of "telegram" in either file under capture-drift/. So the class has three channels with three different defenses:

Channel Compile-time fence Runtime detector
SMS F9 — 7 of 27 dispatch() sites (~26%) conversation_capture_drift — complete for the real-customer TFNs
Telegram F9, same 7 sites — and recipientType is the Telegram routing hint, so its declared surface is the better covered one none — the sweep can only read the Twilio API
Email none — F9's recipientType is a dispatch() option and email never passes through dispatch() none

Telegram is not hypothetical. vendor.telegram-dispatch is itself a cluster-6 orphaned-send closure — this channel has already been a member of the bug class. It is the easiest row to overlook precisely because F9 does see it, so a reader checking the compile-time column concludes it is handled.

That three-way asymmetry, not the seam alone, is what this ADR is scoped by — and it is why D4 says to restate this table in the rule. A fence that encodes the two-row version of it would leave Telegram permanently unguarded with the ADR as the reason nobody looks.

Status note, 2026-08-03 — the Telegram row is now historical. PR #5013 removed the Telegram funnel; DispatchResult.provider is the literal 'twilio'. The row above described a live gap when this ADR was written and is retained unedited, because the three-way asymmetry is the ADR's problem statement — rewriting it to two rows would erase the reason the decision was made rather than record that the reason expired.

What D4 asked to be kept in sync is the rule's restatement of this table (conversation-single-writer.drift.test.ts, R4), and that is now two rows. D4's worry was a fence encoding a two-row world while a third channel ran unguarded; the fence is two rows today because the third channel is gone, not because it was omitted. Read this section as of its date, not as current coverage.


3. Decision

Make the outbound record a construction invariant at each channel's narrowest common send boundary, using the required-field-with-mintable-escape pattern ADR-0089 already proved in this exact file — and scope the rollout by the coverage table in §2.5, not by copying a pattern.

Concretely, four decisions:

D1 — record is a REQUIRED field on DispatchOptions, not a discriminant on recipientType

Do not widen F9 by discriminating a union on recipientType. That field is optional and declared at 7 of 27 sites (§1.2); a new sender omits it and compiles — the same hole F9 concedes in its own doc comment.

Instead add a required field whose type has exactly two mintable values:

export interface DispatchOptions {
  language: ResolvedLanguage;        // ADR-0089 — the precedent
  record: OutboundRecord;            // ADR-0119 — this decision
  // …
}

OutboundRecord is minted only by:

Omission then breaks the build for every sender, and the escape hatch is greppable and pinnable rather than silent.

The precedent is in this very file, not only in ADR-0032. dispatcher.ts:47-57 declares language: ResolvedLanguage required; :157-170 is a runtime-totality backstop that throws because "the compile-time requirement can be bypassed by spreads / untyped call paths tsc can't see through, and dispatch(to, body) with the whole options bag omitted still compiles (options?:)". ResolvedLanguage is a LanguageCode branded with an unexported unique symbol (src/lib/i18n/resolve-recipient-language.ts:52-57) so the only mints are the real resolver and englishByProductDecision(reason) — a reason-bearing escape with no runtime effect, existing purely as the paper trail at the point of send.

OutboundRecord copies that shape exactly: branded type, unexported mint, two public constructors, one of which demands a reason. Where ADR-0032's spine-stamp analogy contributes is the enforcement half — a file-private raw callee wrapped by the only exported symbol, plus a drift rule asserting the raw callee is never exported. Both patterns are load-bearing; neither alone is the design.

D2 — Resolution stays at the caller. The wrapper resolves nothing.

The wrapper accepts an already-resolved ConversationTarget — a type that already exists (conversation-writer.ts:363) with the two arms this needs: { conversationId, propertyId? } and { ensure: ConversationWrite }.

A correction the brief did not have, in two steps. The brief argued this on the grounds that sendTenantSms carries "~90 lines of renewal-domain anchor reasoning (live-saga finder + cross-property scoping + the RENEWED-but-closed recency fallback)." Two merged PRs had already deleted all of that, and the second one arrived while this ADR was in review:

That second step is the one that matters here, because it makes this ADR's central sentence the literal type signature: when the parameter is the anchor row and it cannot be null, "the gate IS the anchor, not a second predicate" stops being a convention a reviewer has to check. As the function now puts it: "There is no 'no anchor' branch left to write: both callers refuse to send without one, and the parameter type says so" (:296-298).

So the hardest case in the codebase is already in the shape the invariant requires — and its arms are now split across two functions, which is itself the right factoring:

Arm Where it lives now
1. gate the append on an actual send (!result?.skipped, the §1.1 phantom-row rule) sendTenantSms:284
2. take a pre-resolved anchor and never re-resolve it sendTenantSms:240 — as a required non-null parameter, so it is enforced by tsc rather than by discipline
3. distinguish no cycle from the read failed, so a storage outage is not logged as the expected "this tenant has no cycle" moved up into resolveRenewalCycle (logError at :206) — correctly, because that is the function that knows which of the two happened

D2 is therefore "make that shape mandatory rather than exemplary" — a generalisation of shipped, reviewed code, not a new idea. The wrapper stays ~40 lines and reads:

send → if (skipped) return → resolve nothing → append the caller's record → on
append failure, warn + signal (never fail the send)

An async resolver thunk remains permitted for the general case — a resolution too expensive to run before knowing the send happened — but it is now the exception, not the justification. Invoking it only after a non-skipped send preserves three properties: no wasted reads on a skipped send; a resolution failure can never block a legitimate send; a null resolution yields the structured sent-but-unanchored signal rather than a fabricated thread.

D3 — Fence email at its three entry points, and fix the outcome signal first

Because there is no email chokepoint (§2.1), the invariant lands on E1/E2/E3 independently. sendEmail's return type must be fixed before it can carry the invariant at all — a caller cannot gate an append on a send it cannot observe (§2.2). That is a prerequisite, not a follow-up, and it is the first implementation issue.

D4 — Do not delete F9. Repurpose it.

Never retire a fence before its replacement demonstrably covers the same surface, and §2.5 shows it does not yet. F9 becomes:

Two properties of F9 to carry forward deliberately: it strips comments before matching (a guard that passes on prose is vacuous), and it is file-level on purpose because several senders legitimately dispatch in one function and append in a sibling — rows 3 and 4 of §2.3 are exactly that. §2.4 is the price of file-level scope: it counts dead files as covered. State that limit in the rule.


4. New failure modes this decision introduces

A centralized invariant trades a per-site, bounded failure for a global one. Named here so the implementation cannot skip them.

F-i — One wrapper bug silently drops every record

Today's failure is per-site and bounded: one sender forgets, one thread is missing rows. A best-effort append inside a shared wrapper means a single bug drops every record, everywhere, silently — because the whole point is that the append never fails the send.

The existing signal is a logWarn inside writeMessageBestEffort (conversation-writer.ts:463). A log line is not a detector. Mitigation: a counted append-miss signal on the existing sendBakeAlert taxonomy, which already carries a conversation_*_drift family (conversation_capture_drift, conversation_conformance_drift, conversation_author_completeness_drift) — the new kind belongs beside them, and like them must read zero. Given §2.5 this is more important for email, where conversation_capture_drift would not catch the fallout.

Mitigation shipped (stage 2, email half). conversation_capture_drift_email is that detector: it reads the transport-side delivery ledger, which a wrapper bug in the caller-side append cannot affect, so a wrapper that silently stops recording shows up as email capture drift the next morning. The Telegram twin follows in its own PR. See the status note in §2.5.

F-ii — Temporal retries that re-send will now also re-append

Activities retry. An activity that re-sends will, under this design, also re-append. The primitives already exist and are already guarded: MessageSpec.idempotencyKey + occurredAt (conversation-writer.ts:98-100), and buildMessage throws when idempotencyKey arrives without occurredAt because "a default now() timestamp would mint a new SK on retry" (:174-177). Mitigation: thread idempotencyKey / occurredAt through the record arm and require them for any send originating in a Temporal activity — 6 of the 20 dispatcher-importing files are src/lib/temporal/activities/*.

F-iii — An ensure arm could mint junk threads

ConversationTarget's { ensure } arm creates a conversation if absent. A wrapper default of "ensure" would mint a thread for every send that lacks one — including PM pages, ops mail, and unresolvable recipients. ensureConversation already carries the scar: it preserves an existing personId on upsert because re-resolving "could churn identity (a sessionClaim anchor would mint a fresh sentinel Person each call — the exact phantom-Person class the Carly incident came from)" (conversation-writer.ts:346-352). Mitigation: conversation-minting decisions stay in domain resolvers. The wrapper never defaults to ensure; a sender that wants one passes it explicitly. A null target is the structured sent-but-unanchored warn — never a fabricated thread.


5. Alternatives considered

Alternative Why not
Widen F9 to every send site Measured in #4842 and re-measured here: the exception table would exceed its own coverage. A list is not a fence. §2.4 shows it would also count dead files as covered.
Discriminate the union on recipientType The field is optional and present at 7 of 27 sites. A new sender omits it and compiles — the hole F9 already concedes.
Runtime-only: extend conversation_capture_drift to email + Telegram Worth doing regardless (it closes §2.5's runtime column and is the F-i detector), but detection is not prevention: it fires after a real person got an unrecorded message, and the drift-alert docs describe the remediation as a human tracing the leak by hand.
A single unified send() over SMS + email + Telegram The genuinely clean end state, and out of scope here: it means collapsing three entry points with different outcome types, two identities (property-voiced vs clara@), and a Graph/SendGrid fallback ladder. D3 is the step that makes it possible later.
Do nothing; keep fixing instances Four rounds on #4842 plus #4859 addressed instances of one root cause. §2.3 shows ten email sites each remembering the rule in one of five shapes. The next one forgets.

6. Consequences

Good. A send that cannot be recorded stops compiling. Every deliberate non-append is enumerated with a reason. The phantom-row rule gets one spelling instead of three. Renewal's shipped shape (#4859) becomes the enforced shape. sendEmail gains an honest return type, which is worth having on its own.

Costs. Every one of the 27 dispatch() sites and the 40 email send-decision sites is touched. The blast radius is wide and shallow, and it must be staged — §9. F-i means a new detector is part of the work, not after it.

Not addressed here. The first-class Clara↔PM conversation (ADR-0059 successor) — PM-facing sends keep mirroring onto the related entity's thread. Voice legs are out of scope: voice.emergency-relay is a call, not a message, and its record has a different shape.

send_portal_link_sms is no longer a residual — and how it closed is the strongest evidence for D2. PR #4865 (4b7a339f8, "gate the portal-link SMS on the anchor — close the last orphan send") landed after this ADR was drafted and closed the gap #4859 had deliberately left open. Its strategy is D1+D2 in one module, at the type level: sendTenantSms now takes anchorSaga: RenewalSaga as a required, non-null parameter, documented as "REQUIRED and NON-NULL, so 'never text what cannot be recorded' is enforced by the type rather than by a branch that hopes to be unreachable" — and "the orphan send is now unrepresentable in this module rather than merely unlikely." Its commit message states the principle this ADR generalises: the gate IS the anchor, not a second predicate"inventing one is how gate and anchor drifted apart four separate times."

That refines D2 rather than merely confirming it. D2 as drafted said a null resolution yields a structured sent-but-unanchored warn. #4865 chose the stronger option for its case: refuse to send. Both are legitimate, and the choice is a product decision, not a logging one:

When the send presumes the thing being anchored to When the send must go out regardless
Refuse — prefer this. A portal link for a renewal cycle that does not exist is not a message worth sending, so make the anchor a required non-null parameter and the orphan send becomes unrepresentable. Send, and raise the structured sent-but-unanchored signal. Reserved for sends whose value does not depend on the anchor — an anchor lookup must never be able to suppress a message a person needs.

The implementation should reach for the left column wherever the domain allows, because it is enforced by the compiler rather than by a reviewer noticing a missing branch.


7. What this ADR does not claim

Stated because four claims in the proposal that reached this design were wrong, and the corrections are load-bearing:

  1. It does not claim email has a chokepoint. It has three entry points and two transports (§2.1).
  2. It does not claim the email half of the class is currently broken. All 10 live participant-facing email sends record today (§2.3). The claim is that nothing prevents the eleventh from not.
  3. It does not claim sendTenantSms needs its anchor logic hoisted. #4859 already did that; D2 generalises #4859 rather than proposing it (§D2).
  4. It does not claim the import-graph risk is live. Verified: no cycle (conversation-writer.ts and outbound-log.ts contain zero dispatcher references), conversation-manager.ts:176 already statically imports the writer, F6 bans the writer→Temporal direction and not this one, and no Temporal workflow file imports the dispatcher — only activities do, so dispatcher→writer cannot reach a workflow sandbox bundle either.

8. Verification plan

Every guard needs a red-without-fix proof: break the thing, watch the test fail naming it, restore. A guard never observed red is not evidence — the #4842 review found a drift guard that passed against the buggy code because the symbol it asserted appeared elsewhere in the same file.


9. Implementation staging (issue-tracked; this ADR ships no code)

Ordered so each stage is independently mergeable and none is a prerequisite for a later stage's safety:

  1. sendEmail returns an outcome union. A caller must be able to observe sent / suppressed / failed-queued. Prerequisite for D3, valuable alone, and the smallest diff in the set (§2.2).
  2. Email ✅ SHIPPED / Telegram in flight (issue #4881) — Email and Telegram capture drift. Extend the conversation_capture_drift family to both un-detected channels, closing the runtime column of §2.5 and providing F-i's detector. Telegram is the smaller half and must not be dropped for being small — it has already been in the bug class (vendor.telegram-dispatch).
  3. OutboundRecord + withOutboundRecord(_unsafe_dispatch) for SMS/Telegram — D1, D2, D4, F-ii, F-iii. 27 sites, 19 files (was 20; stage 5 landed first — PR #4978 deleted send-outbound.ts, see the note at stage 5. Original wording: "20 files (19 if stage 5's sendOutboundMessage deletion lands first — see §2.4).
  4. The same invariant on E1/E2/E3 — D3, on top of stage 1.
  5. Dead-sender sweepsendClaraEmailReply, forwardTourRequestToPM, sendTourAutoReply and the SMS twin sendOutboundMessage (§2.4), plus their coupled test mocks, so the fence is not reporting on surface nobody calls. Deleting sendOutboundMessage also drops a file from stage 3's migration set.

Stage 2 is strictly additive. Stage 1 is additive in intent but not in type — changing sendEmail's return from { messageId?: string } | void to a union changes the meaning of any truthiness check on its result, so its migration is a real (if small) one. Stage 3 is the wide one, and is where the F-i detector must already exist.


10. References