Church Admin/Pastor SMS Routing — Expected Output Spec
§0 Context (read before building)
Current behaviour (verified in code, not assumed):
tenant_voice_agentshas exactly ONE SMS destination,notification_phone, and one email destination,notification_email. Both are single text columns — no plural/CC pattern like the realtor side ([[realtor-sms-recipients-plural-mvp]]).- The live code path for a church voice call is
verticals/church/agents.py::request_callback→verticals/church/tools.py::_request_callback→_notify_callback_request. This is NOT the same code ascore/escalation.py::handle_operational_handoff— that specific function (Track A) is fully built and unit-tested but has zero live callers today (onlytests/test_escalation_routing.pyandtests/test_funeral_at_need_alert.pyimport it). Precision matters here: it is the FUNCTION, not the wholecore/escalation.pymodule, that is dead —handle_safety_event(Track B, crisis) from the same file IS live-called, twice, fromsafety.py. Track B is explicitly out of scope for this feature (see §3) and untouched by it, so the routing decision (branch delivery insideverticals/church/tools.py, notcore/escalation.py) is unaffected either way. (This corrects an assumption in the founder's build request — flagged rather than silently followed, per house rule on verifying handoffs. Precision corrected 2026-09-13 per independent QA's seventh-pass review of the implementation PR, VOICE_QA_REVIEW_2026-09-12.md.) - Email fires on every callback request, any urgency, unconditionally
(
_notify_callback_request, viaget_notification_recipients— primarynotification_emailplus anyadmin/office_adminteam members). - SMS fires ONLY when
urgency in ("urgent", "pastoral_emergency", "at_need"), to the singlenotification_phone, already E.164-normalized at send time by_sms_target()(added 2026-09-11 after the Iglesia incident below). request_callbackhas norouteconcept. The only per-recipient signal today is Iglesia's ownpersonalityOverrides.customInstructionsprompt text (IGLESIA_CONFIG_SQL_2026-09-11_v2.sql), which tells the LLM to prefixreasonwith"RUTA 1 ADMINISTRACION:"or"RUTA 2 PASTOR:"— a string convention with no code behind it. It does not change WHO gets texted; it only changes the wording the caller hears.- Incident that started this: 2026-09-12, Iglesia's
notification_phonewas"940-208-2152"(the pastor's number, no country code). Telnyx rejects non-E.164 numbers outright (error 10016). 15 real callbacks since Sept 6 (including two urgent ones) most likely sent NO text. Founder data-write movednotification_phoneto Martín's (admin) E.164 number; pastor routing was explicitly deferred to this feature. - 10DLC dependency (real, not hypothetical): Telnyx confirmed ZERO 10DLC brand/campaign registration on the account as of 2026-09-12 22:18 Toronto; unregistered US A2P long-code SMS is unreliable/blocked by carriers. A brand was submitted same night; a campaign has not been created. This spec's SMS behaviour is correct regardless of 10DLC status — 10DLC governs whether Telnyx/carriers actually deliver the message, not what number the code decides to text. But do not tell a customer "texts are working" until the 10DLC campaign is Active; treat this as a shipped-but-throttled-by-carrier feature until then.
§1 Expected output
1.1 New request_callback parameter
route: "admin" | "pastor" | "unknown" (default "unknown"). The LLM sets
it from the conversation — who the caller is asking for or what the topic is
(documents/appointments/hall/offerings → admin; premarital counseling,
pastoral care, spiritual urgency → pastor). Routing is keyed on this
structured param, never on parsing reason text. The existing
Spanish "RUTA 1 / RUTA 2" spoken phrases stay as customer-facing copy (per
church, via customInstructions) for continuity — they are what the caller
hears, not how the code decides who gets texted.
1.2 Delivery matrix
| Route | Urgency = normal | Urgency ∈ {urgent, at_need, pastoral_emergency} |
|---|---|---|
admin | SMS → admin number only | SMS → both admin and pastor numbers (whichever configured) |
pastor | SMS → pastor number only | SMS → both |
unknown | SMS → none, unless "text me every call" is ON, then → admin number | SMS → both |
- Email fires unconditionally in every cell of this table, exactly as today — route and the SMS outcome never gate email.
- A church with no pastor number configured behaves exactly as today: admin route and unknown-urgent both degrade to "admin only" (nothing errors, nothing silently drops — pastor slot is just empty).
- If admin and pastor numbers normalize to the same E.164 number (a one-person office), send ONE text, not two.
- "Both" always means "each configured number that hasn't already been texted for this event" — never two texts to the same phone.
1.3 "Text me every call" toggle
Per-church boolean (new field, off by default). When ON, a route="unknown"
urgency="normal"callback also SMS's the admin number (the only case the base matrix leaves email-only). Urgent tiers already reach both numbers regardless of this toggle — it only affects the routine/no-route the AI could not classify.
1.4 SMS send failure
A failed SMS send (Telnyx error, Twilio fallback failure, missing/unusable
number) is non-fatal and never blocks email, matching today's
fire-and-forget posture (core/notifications.py catches all exceptions).
New requirement: a failed send must be visible, not just logged to
stdout — write to ops_errors (the existing failure-visibility sink used
elsewhere in the codebase) with the church id, target label (admin/pastor),
and reason, so it surfaces in the founder's ops/alerts view instead of only
a Railway/LiveKit log line nobody reads.
1.5 Phone normalization on save (dashboard)
The Settings form validates and normalizes on submit, not just on send:
"940-208-2152"→ stored as"+19402082152".- Already-E.164 input is accepted unchanged.
- Unparseable input (too short, letters, etc.) is rejected with an
inline message ("Enter a 10-digit US/Canada number") — the save does not
silently store garbage the way
notification_phonehas for months. - Reuses/extends the existing
normalize_phone_e164semantics (voice-agent-livekit/core/tools.py) so save-time and send-time agree — do not invent a second normalizer with different edge cases.
1.6 Opt-out (STOP)
Staff SMS recipients (admin/pastor) can text STOP to opt out, same as any
US/CA A2P sender must honor under carrier rules — and will be a condition
of the pending 10DLC campaign, not an optional nicety. Today there is no
opt-out mechanism anywhere in the voice agent's SMS code (send_sms,
send_urgent_callback_sms, send_crisis_contact_sms all send
unconditionally) and the only inbound-SMS handler
(src/app/api/telnyx/sms-inbound/route.ts) just forwards every inbound text
to the founder's email — it does not look at the text for STOP/UNSUBSCRIBE.
This spec requires, at minimum:
- A number that has texted STOP (or UNSUBSCRIBE/CANCEL/END/QUIT, matching Telnyx's/Twilio's own default keyword set) to that church's SMS-capable line is recorded.
- Every outbound staff SMS (not just this feature's — but this feature's admin/pastor sends are the ones in scope for THIS PR) checks the record before sending and skips (falling back to email-already-sent) if opted out.
- The check fails OPEN (sends proceed) if the opt-out store cannot be reached — an infrastructure hiccup must never look like "SMS routing is broken," and must never be confused with an actual opt-out.
1.7 Tier gating
The pastor-phone field and "text every call" toggle render only when
hasVoice is true (same gate NotificationsForm.tsx already uses for the
existing SMS field) — i.e., Voice or Bundle plans. Chat-only accounts never
see them, matching the existing single-phone field's gating.
1.8 Spanish labels
NotificationsForm.tsx has no i18n mechanism today (verified — no
useTranslation/locale switch anywhere under src/app/admin). This spec
does NOT invent one. Minimum bar: the two new field labels and the toggle
copy get a Spanish string available for the admin dashboard's existing
locale mechanism IF one exists at implementation time; if none exists
(current finding), ship English-only and record this as a gap in the PR
report rather than hand-rolling a one-off translation path for two labels.
Church-facing spoken Spanish (the RUTA phrases) is unaffected either way —
that already lives in customInstructions, per church, independent of the
dashboard's UI language.
1.9 Existing customers unchanged
A church with no pastor number and the toggle off (i.e., every church today,
including Iglesia post-2026-09-12) behaves byte-identical to current
production: email always, SMS to notification_phone only on
urgent/at_need/pastoral_emergency. Absence of the new fields = today's
behavior, not a behavior change — same posture as
[[realtor-sms-recipients-plural-mvp]] §1's "existing single-phone tenants
untouched" rule.
1.10 Church-facing vocabulary ("Who gets texted")
Settings copy, in plain language a non-technical admin can act on:
Who gets texted
- The administrator's number gets a text for office/appointment/hall requests, and always for anything urgent.
- The pastor's number (if you add one) gets a text for pastoral-care requests, and always for anything urgent.
- If we can't tell who a request is for, only email goes out — unless you turn on "Text me every call" below.
- Email always goes out no matter what, to whoever is set as your notification email.
§2 Acceptance checks
- Route → recipient, normal urgency.
route="admin"texts only the admin number;route="pastor"texts only the pastor number;route="unknown"texts neither (email only) with the toggle off. - Urgency escalation overrides route. Any of urgent/at_need/
pastoral_emergency texts BOTH configured numbers regardless of route,
including
route="unknown". - "Text every call" toggle. With it ON,
route="unknown"+urgency="normal"texts the admin number; with it OFF, no SMS for that cell (matches §1.2 table both ways). - Dedupe. Admin and pastor numbers that normalize to the same E.164 produce exactly one SMS send, not two.
- Missing number degrades silently. No pastor number configured +
route="pastor"→ no SMS, no error, email still sent, nothing in the call log claims a text was sent. - Email is unconditional. Every one of the 6 matrix cells sends email; assert this as its own check independent of the SMS assertions (a regression that breaks SMS must never also silently break email).
- SMS failure visibility. Force a send failure (mock the provider call)
and assert (a) email still sent, (b) an
ops_errorsrow (or equivalent failure-visibility write) exists naming the church and the failed target, (c) the callback's own success/DB write is unaffected. - Save-time normalization. Submitting
"940-208-2152"for the pastor field persists"+19402082152"; submitting"abc"is rejected with a field-level error and nothing is written. - Opt-out respected, fails open. A number in the opt-out store is skipped (email still sent); simulating the opt-out store being unreachable does NOT block the send (positive AND negative case both required — this is exactly the class of check [[feedback_negative_results_need_a_positive_control]] warns about).
- Tier gate. A chat-only admin token never renders the pastor field or toggle; a Voice/Bundle token does. Verify on the demo roster (a chat-only demo token → hidden; a voice-tier demo token → shown, save round-trips) — never on Iglesia.
- Regression pin. A church with
pastor_phoneNULL andnotify_every_callfalse/absent produces byte-identical behavior to the pre-change code path (same email, same single-SMS-on-urgent-only logic).
§3 Out of scope (this PR)
- Retrofitting
core/escalation.py's Track A onto the church path (a real duplication the codebase carries — see [[project_voice_shared_core_consolidation_backlog]] — but consolidating it is a separate, higher-blast-radius change touching funeral/vet/local_business and is not required to ship this feature). - Building a general admin-dashboard i18n mechanism (§1.8).
- Extending opt-out enforcement to crisis SMS (
send_crisis_contact_sms, Track B) or demo SMS — Track B is explicitly untouched per the build request; a crisis-response text is not a marketing/notification message carriers expect STOP to silence, and changing that needs its own founder decision, not a side effect of this PR. - Live-transfer / SIP-bridge routing by admin-vs-pastor (this spec is
Track A callback-and-SMS only, same scope as today's
request_callback). - Per-recipient quiet hours (mirrors the realtor spec's out-of-scope line).
- Retroactively fixing any other church's malformed
notification_phone(Iglesia's was fixed by direct founder-approved write 2026-09-12; a portfolio-wide audit of malformed numbers is a separate, smaller task the save-time validation in §1.5 prevents going forward).
§3a Website side (added 2026-09-29)
The same routing now also drives the WEBSITE — chat lead tools and the Pro
Website contact form — in [[notification-role-routing]]. That spec reuses this
one's §1.2 matrix as the website's default SMS behaviour (prayer = pastor
route, callback/contact = unknown route, urgent = both), this spec's opt-out
store, normalizer and ops_errors failure visibility, and adds a per-tenant
override map plus per-route contact-form texting. This spec remains the
authority for the VOICE line; nothing in the website work changes voice
behaviour.
§4 Open dependency for the founder (not a build blocker, a sequencing note)
Status update 2026-09-29: resolved. Both DB changes below were applied 2026-09-13/14 with founder GO (record:
churchwiseai-web/migrations/ add_church_pastor_sms_routing.sql, which also adds the RLS/grant step). Verified 2026-09-29 viainformation_schema.columns:church_voice_agentsexposespastor_phone+notify_every_call, andvoice_sms_opt_outsexists. The text below is kept as the original decision record.
Two DB changes are required and neither exists today (verified via
information_schema.columns — tenant_voice_agents has notification_email,
notification_phone, pastor_name, plus unrelated free-text
escalation_contact_name/escalation_contact_method/escalation_when
columns already used by OTHER verticals for a narrative escalation-policy
string, not a structured phone field — repurposing those would corrupt
their existing per-vertical prompt injection, so this spec requires NEW
columns, not reuse):
tenant_voice_agents.pastor_phone text+tenant_voice_agents.notify_every_call boolean not null default false.voice_sms_opt_outs (phone_number text primary key, opted_out_at timestamptz not null default now(), source text)— or an equivalent shared shape if one is preferred; §1.6 only requires that some durable per-number opt-out record exists and is checked before send.
Per the build request's own constraint ("no new tables if an existing
column/jsonb fits, and if a migration is unavoidable stop and report"): both
are proposed as migration files in the implementation PR, not applied.
Founder approval + mcp__plugin_supabase_supabase__apply_migration (or
equivalent) is required before the dashboard feature or the opt-out check
can do anything beyond fail open / render nothing. The voice-agent code in
the implementation PR is written to be forward-compatible with both columns
being absent (reads default to None/False), so merging the voice PR
before the migration lands is safe and changes nothing in production.