Skip to main content

Church Admin/Pastor SMS Routing — Expected Output Spec

§0 Context (read before building)​

Current behaviour (verified in code, not assumed):

  • tenant_voice_agents has 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 as core/escalation.py::handle_operational_handoff — that specific function (Track A) is fully built and unit-tested but has zero live callers today (only tests/test_escalation_routing.py and tests/test_funeral_at_need_alert.py import it). Precision matters here: it is the FUNCTION, not the whole core/escalation.py module, that is dead — handle_safety_event (Track B, crisis) from the same file IS live-called, twice, from safety.py. Track B is explicitly out of scope for this feature (see §3) and untouched by it, so the routing decision (branch delivery inside verticals/church/tools.py, not core/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, via get_notification_recipients — primary notification_email plus any admin/office_admin team members).
  • SMS fires ONLY when urgency in ("urgent", "pastoral_emergency", "at_need"), to the single notification_phone, already E.164-normalized at send time by _sms_target() (added 2026-09-11 after the Iglesia incident below).
  • request_callback has no route concept. The only per-recipient signal today is Iglesia's own personalityOverrides.customInstructions prompt text (IGLESIA_CONFIG_SQL_2026-09-11_v2.sql), which tells the LLM to prefix reason with "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_phone was "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 moved notification_phone to 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​

RouteUrgency = normalUrgency ∈ {urgent, at_need, pastoral_emergency}
adminSMS → admin number onlySMS → both admin and pastor numbers (whichever configured)
pastorSMS → pastor number onlySMS → both
unknownSMS → none, unless "text me every call" is ON, then → admin numberSMS → 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_phone has for months.
  • Reuses/extends the existing normalize_phone_e164 semantics (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​

  1. 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.
  2. Urgency escalation overrides route. Any of urgent/at_need/ pastoral_emergency texts BOTH configured numbers regardless of route, including route="unknown".
  3. "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).
  4. Dedupe. Admin and pastor numbers that normalize to the same E.164 produce exactly one SMS send, not two.
  5. 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.
  6. 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).
  7. SMS failure visibility. Force a send failure (mock the provider call) and assert (a) email still sent, (b) an ops_errors row (or equivalent failure-visibility write) exists naming the church and the failed target, (c) the callback's own success/DB write is unaffected.
  8. 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.
  9. 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).
  10. 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.
  11. Regression pin. A church with pastor_phone NULL and notify_every_call false/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 via information_schema.columns: church_voice_agents exposes pastor_phone + notify_every_call, and voice_sms_opt_outs exists. 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):

  1. tenant_voice_agents.pastor_phone text + tenant_voice_agents.notify_every_call boolean not null default false.
  2. 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.