12 KiB
SMS Notifications (T9 pivot + T10) — Design
Date: 2026-06-19
Status: Approved (full scope) — proceed to implementation plan.
Supersedes: the T9 "Teams delivery adapter" direction. T9/T10 were deferred to "next major version" in M6 (docs/plans/2026-06-17-m6-kpi-history-design.md). This design pulls them forward and pivots the second delivery channel from Microsoft Teams to SMS (Twilio).
Goal
Add NotificationType.Sms and a Twilio-REST SmsNotificationDeliveryAdapter behind the existing INotificationDeliveryAdapter seam, plus the T10 enum/UI/CLI plumbing, so notification lists can deliver to ZB team members by text message. Email delivery is unchanged.
Why the pivot (Teams → SMS)
The user's requirement is "notify ZB team members individually." Investigation of the current Microsoft Graph / Teams platform (2025–2026) established a hard architectural constraint:
- An outbound-only backend (our on-prem central cluster, which already does OAuth2 client-credentials to
login.microsoftonline.comfor SMTP) cannot send a full 1:1 chat-message body to a person via Graph. App-only chat sending is migration-only; real-time chat sending is delegated-user-only. - A full-body per-person Teams DM requires a Bot Framework bot with a publicly-reachable inbound HTTPS endpoint that Azure Bot Service calls into the datacenter — a significant security/deployment change for an on-prem SCADA system.
- The only purely-outbound per-person Teams mechanism (
sendActivityNotification) delivers a ~150-char headline + deep link, not a message body, and requires a published Teams app installed per recipient.
SMS sidesteps all of that: it is inherently per-person (recipient = phone number), carries a full message body, and is outbound-only (a single HTTPS POST to the provider). It is the natural "second delivery type" that makes the T10 Type selector meaningful.
Provider decision: Twilio, called directly over its REST API (no SDK), honoring the project's no-new-NuGet-package rule (central package management). Microsoft.Extensions.Http is already in central package management (used by NotificationService/ExternalSystemGateway); the NotificationOutbox project gains a <PackageReference> to it — no new package version is introduced.
Architecture
Mirror the existing Email/SMTP stack:
- A notification list's existing
NotificationList.Typefield decides the channel. Notify.To("list")/Notify.Sendstay type-agnostic at the site.- Central resolves the list's Type at ingest and stamps it on the
Notificationrow; the outbox actor already dispatches viaDictionary<NotificationType, INotificationDeliveryAdapter>and parks on a missing adapter, so routing is automatic.
Tech stack
C#/.NET 10, Akka.NET, EF Core (MS SQL central), Blazor Server (Central UI), System.CommandLine (CLI), IHttpClientFactory, ASP.NET Data Protection (EncryptedStringConverter) for secret-at-rest. No new NuGet packages.
Decisions
D1 — Delivery adapter (Twilio REST)
- New
SmsNotificationDeliveryAdapter : INotificationDeliveryAdapter(Type => NotificationType.Sms) inNotificationOutbox/Delivery, registered alongside the Email adapter inServiceCollectionExtensions. - Request:
POST {ApiBaseUrl}/2010-04-01/Accounts/{AccountSid}/Messages.json, HTTP Basic auth (AccountSid:AuthToken),application/x-www-form-urlencodedbodyTo/From(orMessagingServiceSid) /Body. NamedHttpClient"Twilio"viaIHttpClientFactory. - Success = Twilio 2xx (message accepted/queued). Note: true delivery confirmation needs a status-callback webhook (inbound) — out of scope; "accepted by Twilio" is treated as Delivered, exactly as the Email adapter treats "accepted by the SMTP server."
D2 — Secret-at-rest (SmsConfiguration entity)
- New
SmsConfigurationentity, provider-neutral name, mirroringSmtpConfiguration:Id,AccountSid(plaintext — also appears in the URL path),AuthToken(encrypted viaEncryptedStringConverter),FromNumber(E.164) and/or optionalMessagingServiceSid, optionalApiBaseUrl(defaulthttps://api.twilio.com; lets tests point at a fake handler / supports regional),ConnectionTimeoutSeconds,MaxRetries,RetryDelay.
- EF: add
AuthTokentoScadaBridgeDbContext.ApplySecretColumnEncryption. Idempotent migration adds the table. CredentialRedactor.Scrub(message, authToken)removes the AuthToken from any logged error.
D3 — Recipient model (per-list typing)
- A list is entirely Email or entirely SMS (single
NotificationList.Type). NotificationRecipientgains a nullablePhoneNumber(E.164, e.g. nvarchar(32));EmailAddressis relaxed to nullable (currently NOT NULL). Email-list recipients carryEmailAddress; SMS-list recipients carryPhoneNumber.- Type-aware validation: SMS recipients require a valid E.164
PhoneNumber; Email recipients require a validEmailAddress. - Constructor/factory: keep
NotificationRecipient(name, email); add an SMS construction path (factoryNotificationRecipient.ForSms(name, phone)/ForEmail(name, email)or object-initializer support). - Migration: additive nullable
PhoneNumbercolumn + one NOT-NULL→NULL relaxation onEmailAddress. Idempotent.
D4 — Message format
- SMS has no subject. Body =
Subject+ newline +Body(whichever present), plain text, truncated to a configurable cap (SmsOptions.MaxMessageLength, default 1600 = Twilio max) with an ellipsis when over. - Doc caveat: Twilio segments at 160 GSM-7 chars and bills per segment.
D5 — Error classification
- Small local
SmsErrorClassifierinNotificationOutbox/Delivery, mirroring the pattern ofSmtpErrorClassifier(no cross-project reference to ExternalSystemGateway):- Transient: HTTP 5xx, 408, 429;
HttpRequestException,TaskCanceledException/timeout (non-caller-cancel). - Permanent: other 4xx (incl. 401/403 bad creds, 400 invalid/unsubscribed number).
- Transient: HTTP 5xx, 408, 429;
D6 — Per-recipient rollup (no SMS BCC → one POST per number)
- Send one Twilio request per recipient; classify each.
- All accepted →
Success(ResolvedTargets= the numbers). - Any transient →
Transient(the whole notification retries at the fixed interval, then Parks after max-retries). Accepted caveat: numbers already accepted on a prior attempt get re-texted on retry — the same "re-send to all" characteristic the Email adapter already has with BCC. (v1 does NOT track per-recipient state; that is a documented future enhancement.) - No transient, mix of accepted + permanent-bad →
Successto the good numbers; permanently-bad numbers recorded inLastError. Do not park if anything got through. - All permanent / no recipients / no SMS config / list-not-found →
Permanent(Park).
D7 — Ingest type-stamping
- At
NotificationOutboxActoringest (currently hard-codesNotificationType.Email, ~line 1147), resolve the target list byListNameand stamp the Notification'sTypefrom the list's Type (default Email if the list is not found — delivery parks on "list not found" regardless). KeepsNotify.Totype-agnostic.
D8 — T10 UI/CLI plumbing
NotificationType.Smsadded to the enum.Create/UpdateNotificationListCommandgainNotificationType Type(additive, default Email); handlers persist it.- CLI:
notification list create/update --type email|sms, with--emails(email lists) /--phones(sms lists). - Central UI
NotificationListForm: an adapter-gated Type selector — it offers onlyNotificationTypevalues that have a registeredINotificationDeliveryAdapter(Email + SMS today; auto-extends if more are added). The recipient input switches email↔phone by the selected type with appropriate validation. - Type is chosen at create and is fixed on edit (prevents email/phone recipient mismatch within a list).
NotificationListslist page gains a Type column.- New SMS-config management mirroring SMTP:
ListSmsConfigsCommand/UpdateSmsConfigCommand(+HandleListSmsConfigs/HandleUpdateSmsConfig, role-gated like SMTP, audited via a credential-free public shape), CLInotification sms list|update, Admin-only Central UI page/notifications/sms.
D9 — Transport
NotificationRecipientDtogains a nullablePhoneNumber(backward-compatible: omitted = null).- New
SmsConfigDto(secret rides the existingSecretsBlock), mirroringSmtpConfigDto, carried inBundleContentDto/EntityAggregateand round-tripped byEntitySerializer.
Blast radius (file-level)
Commons
Types/Enums/NotificationType.cs— addSms.Entities/Notifications/NotificationRecipient.cs— addPhoneNumber; SMS construction path.Entities/Notifications/SmsConfiguration.cs— new.Messages/Management/NotificationCommands.cs— addTypeto Create/Update list commands; addListSmsConfigsCommand/UpdateSmsConfigCommand.Interfaces/Repositories/INotificationRepository.cs— add SMS-config read/write methods (mirror SMTP).
Configuration Database
Configurations/NotificationConfiguration.cs—EmailAddressnullable,PhoneNumbermapping;SmsConfigurationmapping.ScadaBridgeDbContext.cs—ApplySecretColumnEncryptionaddsSmsConfiguration.AuthToken;DbSet<SmsConfiguration>.- Repository impl — SMS-config methods.
- New idempotent EF migration (PhoneNumber + EmailAddress-nullable + SmsConfiguration table).
NotificationOutbox
Delivery/SmsNotificationDeliveryAdapter.cs— new.Delivery/SmsErrorClassifier.cs— new.ServiceCollectionExtensions.cs— register SMS adapter + named"Twilio"HttpClient.NotificationOutboxActor.cs— ingest type-stamping (D7).*.csproj— addMicrosoft.Extensions.HttpPackageReference (already centrally managed).SmsOptions(ScadaBridge:Sms) — timeout / max-length (retry reuses outbox settings where possible).
Management Service
ManagementActor.cs—HandleListSmsConfigs/HandleUpdateSmsConfig(+ role gating + audit public shape); list-command handlers persistType.
CLI
Commands/NotificationCommands.cs—--type+--phoneson list create/update;notification sms list|updategroup.- CLI
README.md.
Central UI
Components/Pages/Notifications/NotificationListForm.razor— Type selector (adapter-gated) + per-type recipient input.Components/Pages/Notifications/NotificationLists.razor— Type column.Components/Pages/Notifications/SmsConfiguration.razor— new (/notifications/sms, mirror SMTP page).- Nav entry for the SMS config page.
Transport
Serialization/EntityDtos.cs—PhoneNumberon recipient DTO;SmsConfigDto.Serialization/EntitySerializer.cs— map SMS config + recipient phone both directions.BundleContentDto/EntityAggregate— include SMS configs.
Tests (break / add)
- Update
NotificationOutbox.Tests/ServiceRegistrationTests.cs—Assert.Single(adapters)→ assert Email and SMS registered. - New
SmsNotificationDeliveryAdapterunit tests (fakeHttpMessageHandler: 201 / 5xx / 429 / timeout / 400 / 401 / rollup / redaction). - Ingest type-stamping test.
- CLI tests (
--type,--phones,smsgroup). - bUnit: Type selector (adapter-gated), per-type recipient editor, Type column.
- EF/migration config tests.
- Playwright: SMS-list create flow (INT).
Docs / deploy
docs/requirements/Component-NotificationService.md,Component-NotificationOutbox.md.CLAUDE.mddecision notes (NotificationType = Email+Sms; T9/T10 delivered as SMS, not deferred; flip the M6 deferral note).README.mdcomponent table / CLI README as needed.- Docker: no new secret (SMS config lives in DB like SMTP); minimal
ScadaBridge:Smsoptions.
Out of scope / deferred (logged, not built)
- Per-recipient delivery-state tracking to avoid duplicate texts on retry (v1 uses unit-retry, matching Email-BCC; see D6).
- Twilio status-callback (inbound) webhook for true delivery confirmation.
- Microsoft Teams delivery (dropped).
- Mixed email+phone single list (per-list typing only).
- Editable list Type after creation.
Execution
- Dedicated worktree
feat/sms-notificationsoffmain(c72d7b79); pathspec commits (-mbefore--, nevergit add -A); targeted builds/tests per task; full-solution build + docker rebuild + Playwright + live smoke at INT; thenfinishing-a-development-branch(merge-to-local-main per established pattern — push only on explicit confirmation). - Plan +
.tasks.jsonco-located indocs/plans/. Subagent-driven execution (classification-driven review chains).