ADR-0119 — The outbound record as a construction invariant
- Status: Accepted (design only — implementation is issue-tracked, see §9)
- Date: 2026-07-29
- Supersedes: nothing. Extends: ADR-0079 (the single writer), ADR-0089 (the required-field pattern this borrows), ADR-0108 (one spine-owned dispatch).
- Related issues: #4802 (the original orphan-send class), #4846 (gate/anchor consolidation, closed by PR #4859).
- Prior PRs: #4825, #4842 (three divergence axes + the F9 fence), #4859 (one resolved saga owns gate and anchor).
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:
- Sending and recording are two independent calls with zero coupling.
agents/clara/lib/messaging/dispatcher.tscontains zero references to the conversation writer (verified: no match forconversation-writer/outbound-login that file). - An omitted write has no signature. No error, no type error, no failing test. The absent second call is invisible to every instrument we own.
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:
- harness kill switch (
:270) - prompt-canary block (
:287) - test recipient with no Mailpit on this runtime (
:332) - Mailpit delivered successfully (
:367) - kill-switch / undeliverable-recipient / bounce-suppression / ADR-0092 opt-out gates (
:413, :445, :487, :519, :532) - send failed and was enqueued to the DLQ for retry (
:590)
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):
- 44 comment-stripped matches, minus 1 that is
sendPropertyEmail's own declaration (property-graph-sender.ts:92), minus 3 that are its shared transport legs (:123,:124,:150) → 40 send-decision sites. (Counted comment-stripped so a prose mention ofsendEmail()cannot inflate the figure — two such mentions exist, atsend-clara-reply.ts:4andalert-digest/route.ts:4.) - Of those 40, 10 are live and participant-facing; 2 more are participant-facing but dead (§2.4).
- The remaining 28 are PM-facing, staff/ops digests, auth (magic link, OTP), bake alerts, canaries and dev routes.
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:286as a live eleventh site. That call is insidesendTourAutoReply(:214), whose own header at:208declares "no production callers (only tests) … ItssendEmailuse is dead … for a dedicated dead-code sweep." Verified: zero production callers, while the live sibling holding row 1 (sendTourConfirmationEmail,:332) is called fromsrc/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 againstsrc/lib/data/conversation-writers.registry.ts: the six under theORPHAN-SEND FIXES (issue #4802)header arerenewal.call-recap,renewal.change-term-confirmation,renewal.tool-sms,vendor.dispatch-email,voice.emergency-contact-sms,voice.emergency-relay— one is email. The vendor chases,vendor.quote-email,turnover.vendor-dispatch-emailand 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.stripMarkdownshared that module and was preserved asagents/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.tsis now 145 lines withsendTourConfirmationEmailthe sole survivor. Its now-unused imports went too, includingsendEmail— 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.RESOLVED by #5071: deleted, not wired. The path was already bounded bycheckReplyChainDepthrunaway-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_KINDSdropped'tour_auto_reply_email'(its emitter is gone; the guard windows 25h so the kind cannot appear), andCALLEESdropped'sendOutboundMessage'. The kind itself STAYS inOutboundMessageKind— historical rows carry it and the UI must still label them.
agents/clara/lib/email/send-clara-reply.ts:73(sendClaraEmailReply) has no production caller. Every reference in the repo is a test mock, a comment, a doc, or a script comment (positive-controlled: the same grep shape findssendProspectEmailTouchin four production files). Four test files mock it as though it were live. The 2026-07-06 deep audit found the same shape forcheckReplyChainDepthin the same file (since deleted by #5071 — that path turned out to be already bounded, so the analogy holds but the symbol is gone).agents/clara/lib/email/tour-email-actions.tsholds three senders and two are dead, each by its own header ("no production callers (only tests) … for a dedicated dead-code sweep"):forwardTourRequestToPM(:66, sending at:112/:171toproperty.propertyEmail/NOTIFICATION_EMAIL— PM/ops) andsendTourAutoReply(:214, sending at:286— participant-facing). OnlysendTourConfirmationEmail(:332, sending at:381) is live, called fromsrc/lib/domain/leasing/tour/process-tour.ts.- The SMS twin:
sendOutboundMessage(src/lib/domain/messaging/send-outbound.ts:20) is a@deprecatedback-compat forwarder intodispatch()with zero production callers — only test mocks anddispatcher-callsite-drift.test.ts'sCALLEESlist. So it is one of stage 3's 20 files purely on the strength of a dead door, and deleting it drops a file from that stage.
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.ts → sendBakeAlert 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>), whichlogDeliveryalready 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 |
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.provideris 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:
conversationRecord({ target, spec })— a real append, ornoConversationRecord(reason: string)— a deliberate non-append, with a required human-readable reason.
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:
- #4859 replaced the in-function resolution with a required
cycle: RenewalCycleResolutionparameter — "not resolved in here, so the anchor is provably the same row the caller's gate read" — leaving the anchor as one field read off it. - #4865 went further and narrowed the parameter itself.
sendTenantSms(agents/clara/lib/agent/tools/renewal/handlers.ts:231) now takesanchorSaga: RenewalSaga(:240) — not a resolution object, the anchor row, required and non-null (:235) — and appends withrenewalId: anchorSaga.id(:308).anchorRenewalIdno longer exists.
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:
- the
_unsafe_dispatch-never-exported lock (the ADR-0032-shaped half of D1), - the pin table for
noConversationRecord(reason)sites. F9 already ships this table —PINNED_NON_APPENDING_SENDERS, empty today, with the right standard already written into it ("an addition here needs a reason a reviewer would accept, not a path"). So the ask is not a new table: it is adopting F7's staleness check on top of the existing one, so a pin left behind by a migrated site fails loudly instead of quietly widening the waiver. That is strictly the smaller change, - the explicit watch on whatever the wrapper does not cover, with the §2.5 coverage table restated in the rule so a green run is never mistaken for a closed class.
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_emailis 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:
- It does not claim email has a chokepoint. It has three entry points and two transports (§2.1).
- 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.
- It does not claim
sendTenantSmsneeds its anchor logic hoisted. #4859 already did that; D2 generalises #4859 rather than proposing it (§D2). - It does not claim the import-graph risk is live. Verified: no cycle
(
conversation-writer.tsandoutbound-log.tscontain zero dispatcher references),conversation-manager.ts:176already 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.
- Positive control on every scan. F9's
>= 4floor is a template; every new rule states a scan-health floor and a reason it cannot go vacuously green. - Behavioural, not
readFileSync+regex. #4825 shipped drift guards mislabelled as behavioural tests; #4842 replaced them. New coverage asserts the wrapper's observable behaviour: skipped ⇒ no row, sent ⇒ row, append throw ⇒ send still reported sent + signal raised, retry with the sameidempotencyKey⇒ one row. - A real-harness run per §"no mock theater". Pipeline Lab against the
appfolio-45bench for the SMS leg;scripts/email-stress/run.tsfor the email leg. A unit test that mocks our own dispatcher proves nothing about it. - The workflow-permutation diff (
npm run workflow-harness:diff -- --against main --domain pipeline) in the body of any PR touching the renewal pipeline — ADR-0108 makes it mandatory there. - A rebase invalidates evidence — re-measure every citation into a file the new
base touched, not just the one a reviewer named. This ADR learned it the hard
way twice on the same merge: #4865 landed mid-review, and the first pass updated
§6 (the section the reviewer pointed at) while leaving §D2 describing the
pre-#4865 signature of the very same function. A systematic pass — diff the base
range, intersect with the document's cited files — then caught a third drift
nobody had flagged (
conversation-manager.ts's writer import had moved two lines). For a document whose argument is its citations, a stale line number is not cosmetic: it is the same class as a guard that passes against buggy code. The intersection is two commands; run it before every push, not once.
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:
sendEmailreturns 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).- Email ✅ SHIPPED / Telegram in flight (issue #4881) — Email and Telegram capture drift. Extend the
conversation_capture_driftfamily 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). 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 deletedsend-outbound.ts, see the note at stage 5. Original wording: "20 files (19 if stage 5'ssendOutboundMessagedeletion lands first — see §2.4).- The same invariant on E1/E2/E3 — D3, on top of stage 1.
- Dead-sender sweep —
sendClaraEmailReply,forwardTourRequestToPM,sendTourAutoReplyand the SMS twinsendOutboundMessage(§2.4), plus their coupled test mocks, so the fence is not reporting on surface nobody calls. DeletingsendOutboundMessagealso 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
agents/clara/lib/messaging/dispatcher.ts—DispatchOptions(:47), thelanguageruntime-totality throw (:157-170),DispatchResult(:105).src/lib/i18n/resolve-recipient-language.ts:52-70— the branded-type + reason-bearing-escape pattern D1 copies.src/lib/domain/messaging/conversation-writer.ts—ConversationTarget(:363),MessageSpec.idempotencyKey/occurredAt(:98-100), the R5a throw (:174-177),writeMessageBestEffort(:455).src/lib/data/conversation-writers.registry.ts— the writer inventory and the#4802/ cluster-6 orphan-closure blocks.src/__tests__/conversation-single-writer.drift.test.ts— F1–F9; F9's rule at:674, itsdescribeat:731(:665is inside F8).src/lib/domain/messaging/capture-drift/sweep.ts— the SMS-only runtime detector.src/lib/domain/messaging/outbox/tour-outbox.ts— the tour intent→sent→delivered ledger (its header cites "ADR-0101 S3"; no ADR file indocs/adr/names the outbox, so this cites the module, not the ADR): a domain-scoped precedent for the same idea, whose own header names this incident class and whose SendGrid event-webhook leg is still marked(future).DELETED in PR #4978 (stage 5) — thesrc/lib/domain/messaging/send-outbound.ts:13,20@deprecatedzero-caller forwarder intodispatch(); one of stage 3's files on the strength of a dead door.agents/clara/lib/email/tour-email-actions.ts— line citations:66,208,214,332were valid pre-#4978; that PR deletedforwardTourRequestToPM(:66) andsendTourAutoReply(:214) and the file is now 145 lines, so onlysendTourConfirmationEmailsurvives. The three-senders / two-dead / one-live file that §2.3 got wrong on its first draft.4b7a339f8(PR #4865) —anchorSagaas a required non-null parameter; D1+D2 shipped for one module at the type level.docs/planning/orphan-send-fence-handoff.md— the #4842 / #4859 record.