0089 — Language-aware outbound construction
- Status: Proposed
- Date: 2026-07-14
- Deciders: Gera, Fede
- Anchors: ADR-0079 (single canonical conversation writer — the persistence sink this ADR sits upstream of), ADR-0055 / ADR-0073 (topic taxonomy), ADR-0032 (spine construction invariant),
src/lib/i18n/(the existing detect / templates / metrics layer).
Context
A Spanish-preferring prospect (conv dedc1d80, person pers_e8ba62c1…) received an automated English follow-up. The immediate cause was fixed per-path in PR #3762 (the scheduling-engaged walker didn't read languagePreference), but the incident exposed a structural gap, not a one-off:
Outbound message construction has no single owner, and language is optional at every construction site. ADR-0079 unified which code path persisted a row (writerId, the branded sink). It never unified which code path composed the text — a different single-writer problem that does not yet exist. Concretely, on main today:
FollowUpContext.languageis optional (src/lib/domain/leasing/generate-followup-message.ts:45) — that optionality is literally how the bug shipped: the walker just didn't pass it, andtscsaid nothing.minimalFallback(generate-followup-message.ts:95) is English-only. Every Haiku failure, empty completion, or anti-fabrication trip (CONCESSION_RE,POLICY_CLAIM_RE) ships English to a Spanish-preferring lead right now, even after #3762 — the same bug class, still live on the fallback path.- Tour reminders hard-code
'en'twice (src/lib/temporal/activities/tour.ts:145T-24h and:269T-1h) — a deliberate "Spanish rollout is a separate cutover" product decision, but a live English-to-Spanish leak nonetheless. - Language resolution is cloned per surface:
resolveOutreachLanguage(2 callers), a private phone-keyed clone insend-application-link.ts:267(carries the GSI1-lag silent-English failure mode documented insrc/lib/i18n/metrics.ts:5), the tour lookup, and several language-blind senders (renewalrecap-sms.ts, maintenancenotify-tenant-of-pms-creation.ts/tech-sms-dispatch.ts/rating.ts).
The founder's ask: one place where outbound is constructed/validated, aware of the recipient's language preference, and structurally unable to ship the wrong language. The open question was whether the single writer should enforce or translate. This ADR answers: neither — enforce at construction time with the type system, self-correct deterministically, and keep the writer language-agnostic.
Decision
Language correctness is a construction-time concern owned by an outbound-composition layer that sits upstream of the carrier send (dispatch() / the email senders), not in the ADR-0079 writer (which runs best-effort after the send has already left and cannot enforce anything), and not left as the per-path status quo.
Four load-bearing decisions:
One resolver.
resolveRecipientLanguage(personId, organizationId): Promise<{ language: LanguageCode; source }>becomes the single stored-preference resolver for all outbound composition, absorbingresolveOutreachLanguage, thesend-application-linkphone-keyed clone, and the tour lookup. It ispersonId-keyed (a direct spine read — eliminating the app-link phone→GSI1-lag English failure), never throws (fail-open to the org/product default), and keeps emittingrecordLanguageDecisionwith the surface dimension so the existing metric contract survives. The inbound ask-once ladder (src/lib/i18n/resolve-language.ts) stays separate — it is turn-scoped and consult-order-sensitive (detect → stored → ask-once); this resolver is stored-pref-or-default. Different functions, shared storage accessor.Branded language, not a bare param. Introduce a branded
ResolvedLanguagetype, mintable only byresolveRecipientLanguage(...)or an explicit escape hatchenglishByProductDecision(reason: string). Every renderer — the LLM composer (FollowUpContext), the deterministictemplates.tscatalog renderers, the reminder/app-link builders — takesResolvedLanguage, not'en' | 'es'. This makes a bare'en'literal a compile error (closing the "decided lazily" hole that a merely-required param leaves open —tour.tspasses a required-position'en'today), and makes every deliberate-English site self-document via the escape hatch. This mirrors ADR-0079's own un-exportedSTAMPbrand: don't check at runtime what the compiler can refuse at authoring time.Deterministic self-correction, never inline translate, never inline reject. For the residual where an LLM composer is asked for
es:- Localize the fallback templates.
minimalFallbackand the deterministic tour/app-link copy render through the i18n catalog, so a generation failure degrades to right-language plainer copy, not English. - Composer-local output check. When the composer requested
esanddetectLanguage(src/lib/i18n/detect.ts) scores the just-generated body high-confidence English, fall back to the (now-localized) deterministic template. This is not unreliable short-inbound detection — the composer knows what it asked for and a false trip degrades to a correct-language template. No added LLM call, no translation, fail-open preserved. - Anti-fabrication regexes must gain Spanish coverage (
POLICY_CLAIM_REwill not match "sección 8" / "vales de vivienda") before any expansion of es LLM copy — otherwise more Spanish generation silently weakens the Bug-A voucher guard.
Inline translate is rejected: an LLM call in the (often Temporal-activity) write path adds latency/cost/failure and replay hazards — the exact fail-open bug class fixed on the outreach cooldown (PR #3762) — mangles the deliberately-English tokens (property names, unit numbers, links), and runs the output past English-tuned anti-fab guards. Generating in-language (which the composer already does — native formal Spanish, not translationese) is strictly better than generate-English-then-translate. Inline reject is rejected: at the writer it is too late (post-send, best-effort); at
dispatch()it lacks Person context, and email bypassesdispatch()entirely — there is no universal pre-send chokepoint to reject at.- Localize the fallback templates.
Two backstops.
- Runtime totality throw. Add a required
language: ResolvedLanguagetoDispatchOptionsand throw when absent for SMS (mirroring the existingdispatch: smsFrom is requiredthrow,dispatcher.ts:162); mirror the required param on the two email senders (inboxClient.sendReply,sendEmail). This converts decision 2's compile-time totality into runtime totality across all ~18 dispatch call sites, including onestsccan't see through. - Read-time drift detector. A nightly comparison job — riding the existing
recordLanguageDecisionCloudWatch surface (which already reserves atour-reminderdim,metrics.ts:41) and Smith's alert plumbing, not a net-new stack — samples recent outbound rows where the Person preferses, compares against the provenance stamp (below) and, when unstamped, high-confidence body detection; a mismatch fires a bake alert to#agent-smith. Kind-allowlist exempts the bilingual ask-once prompt, link-only bodies, and OTP-class sends.
- Runtime totality throw. Add a required
The ADR-0079 writer stays language-agnostic, with one exception: OutboundSpec gains an optional language field as pass-through provenance (a peer of providerId / triggerSource — stamped, never branched on), so drift is detectable at read time exactly as writerId makes persistence bypasses detectable. Because this physically edits ADR-0079's spec type, the same PR adds a one-line cross-reference to 0079's spec table — a sibling ADR with an honest cross-link, not a pretense that 0079 is untouched.
Consequences
Easier: every outbound surface resolves language one way; a new sender cannot compile without deciding a language, and cannot decide English-by-accident (only English-by-declared-reason); the still-live minimalFallback / tour-reminder English leaks close; the app-link GSI-lag English failure disappears; drift becomes observable on plumbing that already exists.
Harder / commits us to: threading ResolvedLanguage through ~18 dispatch sites + the email senders + every renderer (a wide but mechanical migration, tsc-guided); extending the anti-fab regexes to Spanish before widening es generation; a product decision (below) before the tour-reminder Spanish cutover flips; carrying one more branded type + a drift detector.
Explicitly out of scope: the voice sibling prompts' ## LANGUAGE blocks (reactive follow-the-caller, no stored-pref needed) stay prompt-based — a different mechanism for a different problem. One cheap adjacent fix noted for a follow-up: the sibling-prompt drift test (src/__tests__/sibling-prompts-language-block.test.ts) pins 6 of the 8 prompts carrying the block — unknown-caller has a coverage gap.
Alternatives considered
- Language in the ADR-0079 writer (the founder's first framing). Rejected: the writer runs after the carrier send, best-effort and failure-swallowing (
conversation-writer.ts:361), so it can only un-log a delivered message — recreating the invisible-history disease 0079 exists to cure. It also serves inbound + audit rows where "wrong language" is meaningless, and a Person read / LLM client would violate its purity + Lambda-bundle constraints. - Inline translation at the write boundary ("make the writer smart / translate"). Rejected — see Decision 3: LLM-in-write-path Temporal hazards, token/link mangling, English-tuned-guard bypass, and a no-op tax on the ~95% of paths already correct. In-language generation beats generate-then-translate.
- Fail-closed block at
dispatch()(a pre-send gate). Rejected as a correctness gate:dispatch()has only a phone string (no personId/org), a stamp minted by the same composer always agrees with itself (no independent signal), and email bypassesdispatch()— no universal chokepoint exists. The totality half of this idea is kept as a backstop (Decision 4). - Merely-required (unbranded)
languageparam. Rejected as half-measure: satisfiable by a hard-coded'en'literal (exactlytour.tstoday). The brand is what closes "decided lazily." - Status quo (shared resolver only, per #3762). Rejected: this is the third per-path patch of this class (app-link, tour, walker); resolver-sharing only works when someone remembers to call it, and the tour activity proves nobody does.
Phased plan
Each phase is independently shippable.
- PR1 — one resolver.
resolveRecipientLanguage; migrate the outreach callers + app-link; delete the clones. Pure refactor, no behavior change. - PR2 — the honest MVP (this is the actual "never again" slice). Branded
ResolvedLanguage+englishByProductDecisionescape hatch; make it required on every renderer; localizeminimalFallback+ the deterministic fallbacks; land Spanish anti-fabrication regexes.tscsurfaces every remaining undeclared caller. - PR3 — provenance stamp + runtime totality.
OutboundSpec.languagepass-through + one-line 0079 cross-ref; required-throw indispatch()+ the two email senders. - PR4 — drift detector. Nightly comparison job on the existing
recordLanguageDecision/ Smith plumbing + a conformance-lane entry. - PR5 — (product-gated) tour-reminder Spanish cutover. The machinery exists;
tour.ts:145,269pass'en'on purpose pending a founder decision.
Decide vs. product call. Engineering-decided here: writer stays language-agnostic; one resolver; branded totality; deterministic self-correction; flag-not-block; provenance stamp. Founder's call: (PR5) the tour-reminder / T-1h Spanish cutover (customer-visible copy on a live surface); whether any legal-notice send class (renewal / holdover) should route to human review rather than auto-Spanish (a compliance question, not architecture); confirmation that inline translation is never wanted anywhere.