Skip to content

Notification transport architecture ​

Audience: Platform team (internal only)

Status: Design complete — covers all three layers of the communications model. Aligns with ADRs 0012, 0013, and 0023.

ADO tracking: Epic AB#3138 (Messaging and Notifications); Feature AB#3145 (Notifications service); Feature AB#3089 (Communications authoring and approval).

Source of truth: ADRs 0012, 0013, and 0023. Where anything in this document conflicts with those ADRs, the ADR wins.


Overview ​

Every approved announcement fans out across all three delivery channels — SMS, push notification, and email — simultaneously, with in-app delivery as the always-available baseline. A failure on one channel does not block delivery on the others. No announcement reaches a member's device unless it has passed the approval workflow described below.

The communications model has three distinct layers. Each layer is owned by a separate ADR. This document explains how the three layers connect and describes the transport layer (ADR 0013) in operational detail.


Three-layer communications model ​

Heritage Community Hub treats community communications as a pipeline with three independent concerns. Keeping these concerns separate means each can be implemented, tested, and changed without affecting the others.

LayerADRConcern
1 — Announcement contentADR 0012The Announcements table: fields, audience scope, priority, status values, receipts
2 — Authoring and approval workflowADR 0023Who drafts vs who approves; the comms_author role; queue lifecycle; self-approve prohibition
3 — Delivery transportADR 0013Channel adapters (SMS, push, email, in-app); fan-out; failure isolation; member preferences

Layer 1 — Announcement content (ADR 0012) ​

An announcement is the single one-way broadcast content type on the platform. It is stored in the Announcements table with audience scoping, a priority level, optional scheduled publish and expiry timestamps, and a status field that tracks the approval lifecycle. There is no reply endpoint, no reply table, and no reply affordance in any client surface. The one-way constraint is architectural, not a configuration option.

The AnnouncementReceipts table records per-member delivery and read state. Ownership of that table stays with the Announcements feature, not the transport.

Layer 2 — Authoring and approval workflow (ADR 0023) ​

The authoring and approval layer defines two distinct actors and enforces that they are never the same person for a given announcement:

  • Author (comms_author role): drafts and submits announcements for assigned audiences. Cannot approve, reject, or publish anything — including their own drafts.
  • Approver (ministry_leader or admin role): reviews the pending queue and approves or rejects. The API enforces that approved_by_id never equals author_user_id for comms_author-drafted content.

The approval step is what gates the transport. Nothing reaches fan-out until an approver-capable role transitions an announcement to approved status.

text
  Author (comms_author)          Approver (ministry_leader | admin)
  ──────────────────────         ──────────────────────────────────
  Creates draft
  Sets title, body, audience,    ← pending queue visible in Admin / Ministry portal
    priority, scheduled_at       Reviews content and audience scope
  Submits for approval
    → status: pending_approval
                                 Approves  → status: approved → transport fan-out
                                 Rejects   → status: rejected → author notified
  Revises and resubmits if
  rejected
    → status: draft → pending_approval

Every state transition writes an AuditLog row per ADR 0005.

Layer 3 — Transport (ADR 0013) ​

The transport layer is covered in detail in the sections below. Its job is narrow: receive a delivery request, resolve the recipient list from the announcement's audience scope, and dispatch to all active channels in parallel.


Delivery channels ​

The platform uses four channels. Three are external-provider channels (SMS, push, email); one is internal (in-app). Every external channel sits behind a provider-neutral adapter so the provider can be swapped by configuration without touching feature code.

SMS — Twilio ​

SMS is delivered through Twilio behind the SmsProvider adapter interface.

Provider selection: Twilio was chosen over Azure Communication Services (ACS) because ACS is an Azure-locked resource. The platform's portability posture (ADR 0024) prohibits Azure-locked communications providers; SMS must work unchanged if the platform relocates to a different cloud or on-premises host. Twilio runs identically on any host.

A2P 10DLC registration: All US carriers require Application-to-Person (A2P) 10 Digit Long Code (10DLC) registration — a brand and campaign registration through The Campaign Registry — before a provider may send application-originated SMS. This is a carrier requirement, not a Twilio requirement; every US provider including Telnyx and Plivo is subject to the same registration. Live credentials are added to Key Vault only after registration completes (ADO task linked to ADR 0013).

Cost-down swap: Telnyx is documented as the provider-neutral drop-in if SMS volume grows and the cost delta becomes material. Swapping to Telnyx is a configuration change to the SmsProvider adapter; no feature code changes.

Member preferences: Members may opt out of non-urgent SMS. Notifications with priority = 0 (normal) are suppressed for members who have opted out. Notifications with priority = 2 (urgent — weather cancellation, safety) always send regardless of preference. Adult members with a phone on file cannot suppress urgent SMS.

Push notifications — Expo push notification service ​

Push notifications are delivered through the Expo push notification service, which routes to Apple Push Notification service (APNs) for iOS and Firebase Cloud Messaging (FCM) for Android.

This reuses the existing React Native and Expo stack decision (ADR 0002). No separate push infrastructure or additional vendor account is required beyond what mobile already requires.

Push delivery is conditional: the member must have the app installed, have granted push notification permission at the OS level, and have a valid Expo push token on file. The push adapter removes stale tokens on first rejection (standard Expo practice). Push tokens expire when a member reinstalls the app; the removal is automatic.

Members who have not installed the app, or who have revoked push permission at the OS level, receive no push notification and are not flagged as delivery failures — in-app and other channels cover them.

Email — SendGrid ​

Email is delivered through SendGrid (free tier at initial build) behind the EmailProvider adapter interface.

Every adult member carries an email address (Users.Email NOT NULL for adults — ADR 0007). Email is therefore the most universally reachable external channel.

The free tier is sufficient at congregation scale. The EmailProvider adapter means an alternative provider can be wired in if the SendGrid free tier's usage policies become a constraint. Provider-level bounce and complaint webhooks feed into Application Insights (ADR 0005) for observability.

In-app delivery (baseline channel) ​

All four channels include an in-app delivery path: the transport writes a row to the Notifications table in PostgreSQL (ADR 0024). In-app delivery is always-on — it cannot be suppressed by member preferences or by a provider failure. It is the channel that guarantees a notification will be visible the next time the member opens the app or web shell.


Fan-out flow ​

An approval event on an announcement triggers the transport. The sequence is as follows:

text
  Announcement status transitions to 'approved'
  (or scheduled_at arrives for a pre-approved announcement)


  API calls POST /notifications/send
  (single endpoint in apps/api — ADR 0008 API-first rule)


  NotificationService resolves recipient list
  from Announcements.audience_role and audience_group_id

    ┌─────┴─────────────────────────────────────────┐
    │  For each recipient, dispatch in parallel:     │
    │                                               │
    ├─► SmsProvider adapter   → Twilio              │
    ├─► PushAdapter           → Expo → APNs / FCM  │
    ├─► EmailProvider adapter → SendGrid            │
    └─► In-app adapter        → Notifications table │


  Each adapter logs a delivery attempt to Application Insights (ADR 0005)
  (success or error code, per channel, per recipient)


  Announcements feature writes AnnouncementReceipts rows
  on confirmed delivery

Fan-out is parallel. A failure on one channel — for example, an expired push token or a SendGrid API error — does not block delivery on the remaining channels. Each adapter handles its own retry and failure logging.

System notifications (RSVP confirmations, approval outcomes, queue submission alerts sent to approvers) follow the same path through POST /notifications/send. The transport does not distinguish between an announcement fan-out and a system notification at the adapter level.


Phone number requirement ​

Every adult member must have a verified phone number on file. The database constraint CK_Users_PhoneRequiredForAdults enforces Users.Phone NOT NULL for accounts where AccountType != 'Child' (added per ADR 0013, 2026-06-18).

This constraint means the data prerequisite for SMS delivery is satisfied by the account model, not by a separate opt-in collection step. No member onboarding work is required to enable SMS delivery for an adult account.

Children have no phone on file (Phone is NULL for parent-managed accounts). Children receive in-app notifications only, delivered through their parent-managed session.


Cost note ​

SMS is the only delivery channel with a recurring per-message cost.

At congregation scale — approximately 150 adult members receiving three notifications per week — the estimated Twilio cost is roughly $17 per month before carrier surcharges. The cost-down swap to Telnyx would reduce this to approximately $9 per month. Both figures are immaterial at this scale and have been accepted by the platform owner (2026-06-18) as a modest ministry operating expense justified by guaranteed urgent reach.

The two cost-control levers built into the transport are:

  1. The member opt-out for non-urgent SMS (priority = 0) caps normal-priority volume.
  2. Urgent-only notifications (priority = 2) always send — this is the primary use case that justifies SMS (weather cancellations, safety alerts).

A Twilio spend alert or monthly cap should be set during the activation task (linked to ADR 0013) to protect against accidental fan-out misconfiguration.

Push delivery is free via the Expo free tier. Email is free at low volume via the SendGrid free tier. In-app delivery is covered by the database budget.


ADRTitleRelevance
ADR 0002Mobile: React Native and ExpoPush notification stack — Expo push notification service is an extension of this decision
ADR 0004Cloud hosting stackKey Vault — all provider API keys are stored here and injected at runtime
ADR 0005Observability modelEvery delivery attempt and every state transition in the authoring workflow writes to Application Insights / AuditLog
ADR 0006Two-plane RBACSix canonical roles — approval authority restricted to ministry_leader and admin; comms_author write-without-approve role
ADR 0007Account and family-group identityAdult phone and email required; children receive in-app only; data prerequisites for SMS and email delivery
ADR 0008Platform compositionAPI-first rule — POST /notifications/send is the single transport entry point; features do not call providers directly
ADR 0012Announcements: one-way broadcastContent artifact layer — Announcements table, AnnouncementReceipts, status lifecycle, audience scoping
ADR 0013Messaging and notifications (transport)Authoritative decision record for this document — channel adapters, fan-out, failure handling, member preferences, provider selection, cost estimates
ADR 0023Communications authoring and approval workflowAuthoring and approval layer — comms_author role, self-approval prohibition, state machine that produces the approved event that triggers fan-out
ADR 0024Cloud portability and provider abstractionPortability posture that drives the EmailProvider and SmsProvider adapter pattern; grounds the rejection of Azure-locked ACS for communications

Heritage Community Hub — Internal. Access restricted via Cloudflare Access + Entra ID.