##### Context Rempla cannot send anything. Not an email, not a text message, to anyone, ever. This ADR is about closing that, and about the record it should leave behind. > **Revised 2026-09-10.** That opening sentence is false as written, and it was false when it was written. Rempla causes email to be sent today, through Descope. `DescopeManagementClient.InviteAsync` sets `invite = true` (`Rempla.Admin/Services/DescopeManagementClient.cs:68`, `:77`), which makes Descope send its own invitation mail to a new Admin user, and `SendPasswordResetAsync` (`:86`) makes it send a reset. Both are reached from Admin screens — `Pages/Users/Invite.cshtml.cs:56` and `Pages/Users/Detail.cshtml.cs:179`. That file was added on 2026-04-20 (`66ef9f7`) and has not changed since, so the capability predated this ADR by four months. > > The narrower claim is the true one, and it is the one everything below actually rests on: **Rempla composes and sends nothing itself, and nothing it causes to be sent goes through the outbox.** Descope's mail is templated by Descope, addressed to Admin staff, and leaves no `Notification` row behind — so for every message Rempla has ever caused, "what did we tell whom, and when" cannot be answered from data. No decision below changes. > > The overstatement mattered in a specific way: an ADR that says "never, to anyone" invites the reader to stop looking for senders, and the one that existed was in the other application. The gap is not where it looks. There is already a well-formed outbox — ADR-009 introduced `Notification` as a transactional outbox, written in the same transaction as the delivery transition that caused it, on the explicit argument that *"what did we tell whom, and when" is a question somebody will eventually have to answer from data.* `NotificationAudience` decides who may be told, and it is careful work: a recipient is **never** told what they will receive or when, and a purchaser whose account is `Deceased` is suppressed rather than silently skipped, because *"the decision not to write to a grieving family is worth keeping evidence of"*. That reasoning stands and this ADR does not revisit it. **What is missing is everything on either side of it.** Measured against the code on 2026-09-02: - **Nothing drains the outbox.** `INotificationDispatcher` is registered in `Rempla.Admin/Program.cs:62` and **never injected anywhere** — not in Admin, not in Web, which does not register it at all. No background service, no admin action, no scheduled job resolves it. `SendAsync` has no caller anywhere in the solution, tests included — `LoggingNotificationDispatcher` is untested as well as unused. Substituting a real provider today would change nothing observable, because there is no code path that reaches a dispatcher. - **There is one writer, and it is delivery-scoped.** `OrderAdminService` is the only code that creates `Notification` rows, on the six `NotificationKind` values, all of which are delivery lifecycle events. - **`Notification.DeliveryId` is `int`, non-nullable, with a foreign key** (`Rempla.Core/Entities/Notification.cs:26`; `20260819160749_AddNotificationsAndCardMessage.cs:27`). Every message must belong to a delivery. - **It is email-only.** `ToAddress` is a single string resolved from `EmailAddresses`. There is no channel concept. - **Nothing inbound exists at all.** No bounce, no reply, no delivery receipt, no opt-out, no protector answer. The forcing function is the Plan Protector. ADR-015 as amended on 2026-09-02 now **refuses to designate a protector without an email address** — and, as further amended on 2026-09-04, without a mobile number either — and the screens for the un-invitable state were deleted, on the strength of an invitation that does not exist. The customer can now press "Send invitation" and `InviteAsync` records that they asked — and nothing happens, on either channel. Every protector flow in the framework package dead-ends in the same place. The protector case also breaks the outbox outright: an invitation concerns **no delivery**, so `DeliveryId` has nowhere to point. That is why ADR-015 deferred wiring invitations into the outbox rather than doing it. ##### Decision **1. The subject of a message is polymorphic, and `DeliveryId` goes away.** `Notification` gains `SubjectType` (enum) and `SubjectId` (int), replacing the delivery foreign key. `SubjectType` starts as `{ Delivery, PlanProtectorDesignation }` and grows. The alternative was making `DeliveryId` nullable and adding a second nullable reference beside it. That is cheaper and it is the wrong shape: a row with every subject column null is indistinguishable from a row whose subject was dropped, and "which thing was this about" stops being answerable in a single query — which is the one job the outbox exists to do. A nullable foreign key that is null for most rows is not a relationship, it is a comment. A second outbox for non-delivery messages was also considered and rejected. Two tables means two things to drain, two retry policies, and two places to look when somebody asks what we told a customer. The argument for the outbox is strongest when there is exactly one of them. The cost is real and accepted: **the database can no longer enforce that a subject exists.** Referential integrity moves into the writer. It is bounded by the same rule ADR-015 applies to the designation projection — one writer, and the check lives there. **2. Channel is explicit, and a message is per-party *per channel*.** `Notification` gains `Channel` (`{ Email, Sms }`), and `ToAddress` means "the address on that channel". A protector invitation is therefore **two rows**, not one row sent twice. This falls out of the protector framework requiring both channels. > **Revised 2026-09-04.** This section originally rested its argument on a protector possibly having no SMS address at all, since ADR-015 then required only an email. That amendment reinstated the mobile requirement, so **every protector designated from 2026-09-04 carries both addresses** and the argument that stood here no longer holds. The conclusion is unchanged, and the two reasons below are the ones that survive it. First, partial failure is the common case and has to be representable: email accepted, SMS rejected, is two rows in two states rather than one row that is somehow both. A single row would have to collapse two independent outcomes into one status, and the collapse always loses the bad half. Second, a protector is not the only party we write to. Gift and vault recipients are required to have an email address and **not** a mobile number (ADR-015, 2026-09-04), so a message to a recipient has one channel and a message to a protector has two. Per-party-per-channel is the shape that holds both without a special case, and it does not need every audience to be reachable everywhere. Rows predating 2026-09-04 may still carry a null mobile — the columns stay nullable and those designations are being retired through the UI rather than by migration — so a sender must still tolerate a missing address on a channel rather than assuming both are present. **3. Inbound is its own table, not a status on the outbound row.** Add `CommunicationInbound`: `Channel`, `FromAddress`, `ReceivedAt`, `Kind`, `RawReference` (the provider's message id), `NotificationId?` (the outbound row it answers, where one can be determined), and `Body`. `InboundKind` covers at least `{ Reply, Bounce, DeliveryReceipt, OptOut, ProtectorAnswer }`. These are not states of the message we sent. A bounce is a fact about an address, an opt-out is a standing instruction that outlives the message that provoked it, and a protector's answer to "as far as you know, has Eleanor passed away?" is **evidence in a verification case** — ADR-015's `PlanProtectorConfirmation` is where that lands, and the inbound row is what feeds it. Collapsing them into a `Status` on the outbound message would lose the distinction exactly where it matters most. Correlation is best-effort by design. A reply to an email that was never sent by us, or an SMS from a number we cannot match, is still recorded with a null `NotificationId`. **An unmatched inbound message is data, not an error** — and dropping it because it did not correlate is how an "I'm alive" reversal gets lost. **4. A dispatcher is a provider adapter, and draining is a job.** `INotificationDispatcher` stays as the seam and gains a channel dimension: one implementation per channel per provider, selected by `Channel`. The existing `LoggingNotificationDispatcher` stays as the null implementation and remains the default until a provider is configured. Draining is an explicit, resumable job that claims `Pending` rows and records the outcome. It follows the delivery worker's discipline from ADR-009 rather than inventing a second one: at-most-once semantics are **not** required here — ADR-009 already says a duplicate email is a nuisance rather than a disaster — but the `Failed` state stays terminal-until-a-human-acts, because *"a retry loop nobody is watching is how a person receives the same message forty times."* **5. Consent and suppression are checked at send time, against the address.** An `OptOut` inbound row creates a standing suppression on that address and channel. The drain job checks it before handing anything to a provider and writes `Suppressed`, which the audience model already defines and already means "deliberately not sent, recorded". This is not optional politeness. `NotificationAudience` already states that a recipient *"never opted in to anything, so only strictly transactional messages may ever reach them"*, and SMS carries a legal obligation to honour STOP that email does not. **6. The sending identity is `notify.rempla.com`, a dedicated subdomain.** > **Added 2026-09-10.** The Decision above deliberately left the provider open and said nothing about the domain. The domain turns out to be the part with a measurement behind it, so it is settled here; the provider still is not. Measured on 2026-09-10 with `dig`: ``` rempla.com MX → rempla-com.mail.protection.outlook.com (Microsoft 365) TXT → v=spf1 include:spf.protection.outlook.com -all _dmarc TXT → v=DMARC1; p=quarantine; adkim=r; aspf=r; rua=mailto:dmarc_rua@onsecureserver.net NS → ns45/ns46.domaincontrol.com (GoDaddy) ``` No DKIM selector answered on nine common names (`selector1`, `selector2`, `default`, `mail`, `google`, `k1`, `k2`, `s1`, `s2`), and no `mail`/`notify`/`em`/`mg`/`send` subdomain resolves. Sending as `@rempla.com` is therefore not available without editing the record that carries corporate mail: SPF is `-all` and authorizes Outlook alone, so any provider added at the root either goes in that record or fails SPF outright into a `p=quarantine` policy. A dedicated subdomain avoids the edit entirely — and because alignment is **relaxed** (`aspf=r`, `adkim=r`), a subdomain with its own SPF and DKIM still aligns under the organizational DMARC policy. It also separates the two reputations. Transactional mail to protectors and recipients and staff mail out of Microsoft 365 fail for different reasons and recover on different timescales; a shared domain reputation couples them for no benefit. **The failure mode of this choice is silence, and that has to be designed for.** There is no `sp=` in the DMARC record, so `notify.rempla.com` inherits `p=quarantine` — which is the wanted behaviour, and it means a DKIM misconfiguration does not bounce. It quarantines. The one channel that would report it, `rua`, points at `dmarc_rua@onsecureserver.net`, a registrar-provisioned address nobody on this team reads. So the default arrangement is a check whose failure is indistinguishable from success — the same shape as a health report that renders "could not determine" as "pass", and the defect family this codebase has hit most often. Two things follow, and they are requirements rather than suggestions: - Point `rua` at an address a person actually reads before the first send. - Treat the provider's per-message delivery and bounce webhooks as the operative signal. DMARC aggregate reports are daily and statistical; a queued invitation that quarantined is a per-message fact, and §3's `CommunicationInbound` is already the table it lands in. **The provider is not decided here.** The current recommendation is Postmark for email and Twilio for SMS, on three grounds: inbound is first-class rather than an afterthought, which §3 and §5 both depend on; the shared-pool reputation is the relevant deliverability variable at Rempla's volume, where a dedicated IP cannot be warmed and would be worse; and A2P registration is the schedule risk, which argues for the most-trodden path on that channel specifically. **None of that is measured.** It is general knowledge as of 2026-09-10 and must be confirmed against current provider documentation before an account is opened — Azure Communication Services in particular was a serious candidate on .NET and Azure-adjacency grounds and was set aside mainly on inbound uncertainty, which is exactly the kind of claim that goes stale. **What it costs, and why that does not decide anything.** Priced 2026-09-10 against the recommendation above, so that the number does not have to be re-derived from scratch every few months. Email — Postmark, read from `postmarkapp.com/pricing`: | Plan | Monthly | Included | Overage | Inbound | | --- | --- | --- | --- | --- | | Free | $0 | 100/mo, no overage permitted | — | no | | Basic | $15 | 10,000 | $1.80 / 1,000 | no | | **Pro** | **$16.50** | 10,000 | $1.30 / 1,000 | **yes** | | Platform | $18 | 10,000 | $1.20 / 1,000 | yes | **Pro is the plan, and this ADR picks it rather than the volume doing so.** Inbound is the only thing separating it from Basic, and §3 and §5 both rest on inbound — a bounce is what creates the suppression §5 consults before every send. The difference is $1.50 a month. Nothing about Rempla's volume would otherwise justify leaving the Basic tier for years. Dedicated IPs start at $50/month and require a 300,000-message monthly minimum, which confirms rather than complicates the reasoning above: it is not available at this volume and would not help if it were. SMS — Twilio. Per-message and number rates read from `twilio.com/en-us/sms/pricing/us`; **the A2P registration fees are second-hand** (summaries of Twilio support articles; the fee article itself would not render) and should be confirmed in the console at signup: - One-time: $4 brand registration + $15 campaign vetting ≈ **$19** - Recurring: $1.15/month long-code number + $1.50–$10/month campaign fee, with a notification or customer-care campaign at the low end - Per message: $0.0083 to Twilio plus roughly $0.003–$0.005 carrier surcharge ≈ **$0.012–$0.013 all in** The brand tier is **Low Volume Standard** — for a registrant with a tax ID sending under 6,000 segments per day, which Rempla is nowhere near. Sole Proprietor is specifically for registrants *without* an EIN and carries worse throughput; a signup flow that steers there should be corrected. **Total at the volume actually in prospect** — a handful of protector invitations a month: **≈ $21/month and ≈ $19 once.** Roughly $16.50 email, $1.15 number, ~$2 campaign, and a dollar or two of messages. A single protector invitation is two rows by §2, so one email plus one SMS ≈ **$0.013 marginal**. The conclusion that matters is the negative one: **at this volume price does not distinguish the candidates**, because every serious option costs about the same trivial amount. So cost is not grounds to reopen the choice, and the provider question stays exactly where the paragraph above leaves it — on whether the inbound story holds up. The one cost line that grows with volume is the carrier surcharge rather than Twilio's own rate. Two notes that bear on sequencing rather than on price: - Postmark's free tier reportedly excludes custom domains, so it likely **cannot** be used to rehearse `notify.rempla.com` end to end before paying — the first real send would come from a Postmark address rather than Rempla's. Not confirmed on Postmark's own pricing page; worth asking them, because it changes the order of operations. - Postmark sells DMARC monitoring at $14/month per domain, which addresses the `rua` problem named above. It is also roughly 85% of the email bill for something a monitored mailbox and a free aggregator also do. Recorded as available, not recommended. **The $19 buys none of the schedule.** A2P registration remains what the Consequences call the long pole: calendar time through the carriers, and paying the fee starts that clock rather than shortening it. **7. Draining needs a host, and the application does not have one.** > **Added 2026-09-10.** §4 says draining is "an explicit, resumable job" and stops there. Measured against the code, that job has nowhere to run. There is no `BackgroundService` and no `IHostedService` anywhere in the solution — a grep for both over `*.cs` returns nothing (2026-09-10). There is no scheduled task, no worker process, and no job runner of any kind. §4 assumed an execution context that has never existed. IIS is the constraint. All three sites run as IIS applications on a single Azure VM (`DEPLOYMENT.md`), and an IIS application pool idles out and recycles. A hosted service living inside `Rempla.Web` or `Rempla.Admin` would therefore stop draining without erroring, on a schedule nobody chose — **reintroducing at the drain the precise invisible failure the outbox was built to prevent.** Making it work means `startMode="AlwaysRunning"`, preload, and an idle timeout of zero, which is three server-side settings that are not in source control and whose absence is silent. So the first drain is an explicit action on an Admin page, not a timer. That matches the precedent already in the codebase — `OrderAdminService` writes its outbox rows on a path a human triggers from Admin — it needs no infrastructure that does not exist, and an operator pressing "send pending" and seeing the count is a stronger signal than a background loop nobody watches. Automation is a later, separate decision, and when it comes a scheduled console runner is preferable to an in-process timer for the reason above. One wiring detail this exposes: `INotificationDispatcher` is registered in `Rempla.Admin/Program.cs:62` and **`Rempla.Web` does not register it at all**. Whichever application ends up draining has to register a real one; today only Admin could resolve even the logging stub. ##### Rationale The through-line is the one this codebase keeps re-learning: **a record that asserts more than was measured is worse than no record.** The outbox was built to answer "what did we tell whom, and when". Every decision above protects the answerability of that question — a single subject-addressable table, a row per channel so partial failure is visible, inbound kept separate so a bounce is never mistaken for a reply, and `Suppressed` preserved so a deliberate silence is distinguishable from a dropped message. It is also why the drain job, not the customer's click, must be what reports a message as sent. ADR-015 already made this concrete: the invitation chip reads "queued", never "sent", because `DateInvited` records that the customer asked. When this ADR is implemented, **that display must move to the notification's own state** and stop deriving from the request. ##### Consequences - **A schema migration against the shared database.** Every environment and all three Azure sites share one database, and nothing applies migrations automatically. Dropping `Notification.DeliveryId` in favour of `SubjectType`/`SubjectId` rewrites existing rows. This is the least reversible step in the plan and should be taken deliberately, with the existing rows' delivery ids mapped to `SubjectType = Delivery`. - **Referential integrity for a message's subject becomes the writer's job.** Named here so it is a known cost rather than a discovered one. - **Inbound requires a publicly reachable endpoint.** Provider webhooks must reach the application, and those endpoints must authenticate the provider rather than trust the caller. > **Corrected 2026-09-10.** This bullet originally said the single-VM deployment "does not expose" such an endpoint. That is wrong. `dev.rempla.com`, `admin.rempla.com` and `docs.rempla.com` are public IIS sites on that VM (`DEPLOYMENT.md`), so the capability is already there and has been throughout. What is missing is a route and its authentication, which is application work of ordinary size — not the infrastructure gap the original wording implied, and not a reason to defer §3. - **SMS is a schedule risk, not a code risk.** US A2P messaging requires brand and campaign registration through the carriers before any traffic flows, on the order of days to weeks, against a registered business entity. **Verify current requirements with the chosen provider before planning around that** — it is the long pole, and no amount of code shortens it. - **Email requires a verified sending domain** with SPF, DKIM and DMARC, and a from-address that is a business decision. - The `Notification.Summary` field stays prose, not a rendered template. Templates remain the last and most reversible part. ##### Deliberately out of scope - **Choosing the providers.** An operator decision with an account and a contract behind it, and deliberately not settled by this ADR. The design admits any provider per channel. - **Templates and rendering.** Deferred for the reason already recorded on `Notification.Summary`: a queued message that depends on a template cannot be read until the template exists. - **The verification engine.** ADR-015 deferred it and still does. This ADR delivers the inbound row that a protector's answer arrives on, and stops there. - **The protector's annual wellness clock and its escalation ladder.** The designation carries the dates; the job that acts on them is separate work. - **Marketing or non-transactional messaging.** Nothing in Rempla sends it, and the audience model would have to be reopened before anything could. - **Backfilling communications that predate the outbox.** There are none, and inventing them would be exactly the fabrication the outbox exists to prevent. ##### Status Proposed, 2026-09-02. Nothing here is built. The `Notification` outbox, `NotificationAudience` and the `INotificationDispatcher` interface described in the Context already exist; everything in the Decision does not. **Amended 2026-09-04** (Context, and Decision §2) to follow ADR-015's reinstated mobile requirement. Still proposed; still nothing built. **Amended 2026-09-10** (Context, Consequences, and new Decision §6 and §7). **Partially built, and the "nothing here is built" line above no longer holds.** Measured against `rempla-app` `db748f8` on 2026-09-10: | Section | State | | --- | --- | | §1 polymorphic subject | **Built.** `Notification.SubjectType`/`SubjectId` (`Rempla.Core/Entities/Notification.cs:44`, `:46`), migration `20260906134251_ReplaceNotificationDeliveryWithSubject` applied to the shared database 2026-09-06. | | §2 per-channel rows | **Built.** `Notification.Channel` (`:62`); a protector invitation writes two rows, one per channel (`Rempla.Core/Services/PlanProtector.cs:409`). | | §3 inbound table | Not built. `CommunicationInbound` does not exist. | | §4 dispatcher + drain job | **Not built, and see §7** — the seam and the null implementation exist (`Rempla.Core/Services/NotificationDispatch.cs`), `SendAsync` still has no caller, and there is no host for a job. | | §5 consent and suppression | Not built. | | §6 sending identity | Decided (`notify.rempla.com`); nothing published in DNS yet. Provider still open. | Two of the Context's five "what is missing" bullets are therefore now historical rather than current — `DeliveryId` is gone, and the outbox is no longer email-only. They are left in place because they are dated evidence for why §1 and §2 were decided, not claims about today. **Author's note on evidence.** The Context's claims about what exists were read from the code on 2026-09-02 and the file and line references are given so they can be re-checked. The A2P registration timeline is general industry knowledge, not a measurement, and is flagged as needing confirmation. No claim in the original was verified against the running application or the shared database. **2026-09-10 additions, by evidence class.** Read from a primary source: the DNS records in §6 (`dig`, 2026-09-10); the built/not-built table above and the code references in §7 (read from `db748f8`); the absence of any `BackgroundService` or `IHostedService` in the solution; the Descope send paths corrected in the Context, including the 2026-04-20 commit date; and, in §6's cost model, Postmark's plan table and Twilio's per-message and phone-number rates, taken from each vendor's own pricing page. Second-hand, and flagged in place: Twilio's A2P registration and campaign fees, which come from summaries of Twilio support articles rather than the fee article itself, and the claim that Postmark's free tier excludes custom domains. Not measured at all: every claim about a provider's *capabilities* — inbound support above all, which is the one fact the provider recommendation actually turns on. All prices are as of 2026-09-10 and will drift. Still not verified against the running application or the shared database, and no message has been sent by any of this.