0089 — Language-aware outbound construction

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:

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:

  1. One resolver. resolveRecipientLanguage(personId, organizationId): Promise<{ language: LanguageCode; source }> becomes the single stored-preference resolver for all outbound composition, absorbing resolveOutreachLanguage, the send-application-link phone-keyed clone, and the tour lookup. It is personId-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 emitting recordLanguageDecision with 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.

  2. Branded language, not a bare param. Introduce a branded ResolvedLanguage type, mintable only by resolveRecipientLanguage(...) or an explicit escape hatch englishByProductDecision(reason: string). Every renderer — the LLM composer (FollowUpContext), the deterministic templates.ts catalog renderers, the reminder/app-link builders — takes ResolvedLanguage, 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.ts passes a required-position 'en' today), and makes every deliberate-English site self-document via the escape hatch. This mirrors ADR-0079's own un-exported STAMP brand: don't check at runtime what the compiler can refuse at authoring time.

  3. Deterministic self-correction, never inline translate, never inline reject. For the residual where an LLM composer is asked for es:

    • Localize the fallback templates. minimalFallback and 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 es and detectLanguage (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_RE will 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 bypasses dispatch() entirely — there is no universal pre-send chokepoint to reject at.

  4. Two backstops.

    • Runtime totality throw. Add a required language: ResolvedLanguage to DispatchOptions and throw when absent for SMS (mirroring the existing dispatch: smsFrom is required throw, 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 ones tsc can't see through.
    • Read-time drift detector. A nightly comparison job — riding the existing recordLanguageDecision CloudWatch surface (which already reserves a tour-reminder dim, metrics.ts:41) and Smith's alert plumbing, not a net-new stack — samples recent outbound rows where the Person prefers es, 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.

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

Phased plan

Each phase is independently shippable.

  1. PR1 — one resolver. resolveRecipientLanguage; migrate the outreach callers + app-link; delete the clones. Pure refactor, no behavior change.
  2. PR2 — the honest MVP (this is the actual "never again" slice). Branded ResolvedLanguage + englishByProductDecision escape hatch; make it required on every renderer; localize minimalFallback + the deterministic fallbacks; land Spanish anti-fabrication regexes. tsc surfaces every remaining undeclared caller.
  3. PR3 — provenance stamp + runtime totality. OutboundSpec.language pass-through + one-line 0079 cross-ref; required-throw in dispatch() + the two email senders.
  4. PR4 — drift detector. Nightly comparison job on the existing recordLanguageDecision / Smith plumbing + a conformance-lane entry.
  5. PR5 — (product-gated) tour-reminder Spanish cutover. The machinery exists; tour.ts:145,269 pass '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.