A proposal for one decision function that every Clara send passes through, with the inventory of send sites reconciled against the PRs already claiming them. For Gera's decision.
30 Oct 2026 · scope: Clara's outbound paths (email, text, call, customer Slack, AppFolio writes) · status: proposed, not started · source: origin/main 02a2004
Recommendation: Option A, staged. One function, decide(module, building), returns record, hold or send. Every sender and every consumer goes through it, and a send cannot be built without a module. This is the migration the repo already sanctions.
Step 1: the existing capabilitiesFor(mode, subject) at src/lib/domain/modules/module-mode.ts:210 is already the one rule. Make it the only decider, and migrate the readers that narrow the mode themselves (about 10 of them).
Step 2: type the send calls so module is required, and migrate the roughly 35 'none' and 'team_alert' sites to their real modules.
Step 3: a lint rule modelled on propflow/no-settings-bypass with a shrink-only exemption file, in the style of scripts/check-settings-reads-allowlist.ts, so raw senders are importable only through the door.
Each step is an old/new-equivalence split under ADR-0144 §6 (docs/adr/0144-the-gatekeeper-is-the-keeper-of-every-gate.md:145), dark until replay shows byte-identical output.
Fewer intermediate states, but it is the parallel-implementation shape ADR-0144 §6 forbids, across about 60 sites. It can't be replayed against real books before it lands. Not recommended.
Four sweeps in four weeks (7 Oct, 26–29 Oct, 28–30 Oct, 30 Oct) each patched sites, and none removed the bypass. Not recommended.
D1. Does Preview open an approval request at all? The audit recommends no. Step 1 can ship without it. It lands in the Preview hold front door: holdEscalationEmail and holdEscalationSms.
D2. Does a per-path Live override beat Preview? The audit recommends Preview absolute. It touches path-mode.ts:169-216, twilio/client.ts:1240 and process-envelope.ts:1739. Until D2 is answered, step 1 keeps today's behaviour.
Rows are the audit's five cause groups, not single sites. Status comes from PR titles on origin/main and the open PR list on 30 Oct. "Claimed" means a PR names the sites; "open" means none does.
| # | Cause | Where (examples) | Status | Claimed by |
|---|---|---|---|---|
| 1 | A hold does work after it opens: the Slack card, the bump reminder, approve routes | postHeldEmailForReview, postHeldTextForReview, bumpStaleHolds, bake-gate 'none' arms (:826, :1486), approve-held-pms-write, turnover-gate | partly claimed | Reviewer email asks the mode: #23805 (merged). Approve a Preview email: #23823 (merged). Collections owner alert: #23813 (merged). Renewal notes and PM page: #23841 (open). Emergency bounce fallback: #23846 (open). Card, bump and PMS-approve routes: open, no PR |
| 2 | Staff notices declared 'none' or 'team_alert', about 35 sites | Reply-release notes, gated-inbound team notice, promises pm-delivery, forward-to-PM, tour confirmations, renewal office notices, renewal_escalate, PM reminders, vendor outcome, move-out mismatch, staff welcome text, unknown-caller note | mostly claimed | Staff email asks its module: #23809 (merged). Emergency and caller-note locked paths: #23836 (merged). Bounce fallback and renewal call recap: #23838 (merged). Unreadable-mode text hold: #23796 (merged). Staff or vendor text under Supervised: #23850 (open). Vendor-lane disclosure: #23853 (open). Caller-wants-callback type: #23844 (open). Team quiet hours: #23828 (open). Sites with no PR: open |
| 3 | Gaps in the gates | decisionRequestModuleOf knows 3 modules (decision-request-hold.ts); supervised-reply-rule.ts; team-notify/engine.ts:527; new-lease-gate.ts; writer-registry.ts:219 has no Preview branch | partly claimed | Voice under Supervised: #23806 (merged). PMS writes at Preview and on an unreadable mode: #23827 (open). The rest: open, no PR |
| 4 | Slack asks to a customer's channel with no mode check | postOpsEvent action cards, new-lease review alert, fair-housing hold notify, reply-release :2551 | open | No PR names these. #23847 is Live's Slack switch, a different change. |
| 5 | A per-path override beats Preview | twilio/client.ts:1240; pathRuled skips previewRefusesSend; process-envelope.ts:1739 | depends on D2 | None. This is a decision, not a bug. |
The anchor per-building fix for mixed Preview and Supervised buildings (scan-batch.ts:131, build-batch.ts:79-82) does not depend on this design and proceeds on its own PR. Smith's session holds it.