# NU · Nuntius, the rest (build plan step 4)

**Status:** NU-1 to NU-12 decided under the Architect's autonomy grant, 8 November 2026; NU1 to NU7 built and reviewed (9 November 2026; `reji/docs/SECURITY_REVIEW_NUNTIUS.md`, 8.1 / 10). The mockups (`reji/ui/html/nu/`: texts,
batch, blocked, prompt) are drawn and checked in light, dark and on a phone; the slices in §6 follow. Prices, the SMS rate and anything sold are the Architect's commercial terms: built as data, filled in by the
Architect (§7).

---

## 0 · The one-paragraph version

NS gave Atrium one transactional text. NU makes Nuntius the company post office the spec describes: a school's texts come out
of its plan's monthly allowance, then out of credit it buys at `id`; a fee reminder to 600 parents goes as one paced bulk send
with live counts; each school's texts carry its own registered name; products' e-mail goes through Atrium from each product's
own domain, with a provider chain; and an address that bounces, complains or opts out is known to every product. WhatsApp and
inbound replies wait for TuteDesk's launch, which needs them; Loop moves in its own plan.

**What it is.** The money, the volume and the reputation of sending: who pays for a text, how many go at once, under whose name,
and who must not be written to again.
**Scope.** In Atrium: allowances and credit per organisation, buying credit, bulk sends, sender names, product e-mail and its
suppression, opt-outs and undeliverable addresses shared across products. Staying in the products: who receives a message (the
audience: Atrium never learns who Campus's parents are), the wording's meaning, and when to send. Out of NU: WhatsApp, inbound
replies and STOP by SMS (trigger: Desk's launch), moving Loop and Campus (their own plans).
**Failsafe.** Atrium down: products keep their outbox and retry key (NS-8), so a bulk send waits and goes once; codes fail
visibly and offer e-mail. A provider down: e-mail fails over along the chain; a text's `unknown` is never retried by itself
(NS-5). Paystack down: credit already bought keeps sending; only buying waits. A school out of credit: e-mail keeps going, codes
keep going (company-paid), and the product shows "top up SMS".

---

## 1 · What the apps already do

Read 8 November 2026 (P3), each claim against the code; faults verified before they shaped a decision.

| App | What's there | Lift | Leave |
|---|---|---|---|
| **Campus** (live, about 7,000 lines of SMS, 5,100 of mail) | A wallet per school (cached balance under a lock, signed lines with `balance_after`, `reconcile`, a `period` key for monthly charges); a free allowance counted from the log, whole texts only; segments by GSM-7 and UCS-2; sender names registered per provider by hand; delivery reports once per ID; mail streams, a provider chain with quotas, a suppression list; SES bounces through SNS | The ledger's shape, the allowance rules, `segments()`, `SenderRegister`'s model, `DeliveryReports`, `ProviderChain` and `ProviderQuota`, `SuppressionList`'s scopes | Everything keyed to Campus's string tenant, the `'central'` wallet, the rate in profile JSON, the audience resolver |
| **Desk** (unlaunched) | WhatsApp on Meta Cloud: a queued row per message, forward-only statuses, an append-only consent ledger, versioned templates, signed webhooks; inbound e-mail and WhatsApp matched to tickets; one row per e-mail with per-recipient delivery | For NU: one row per message with per-recipient state, forward-only statuses, the append-only consent ledger. For later: WhatsApp and inbound | Desk's `users` columns, its billing add-ons, ticket matching |
| **Loop** | One synchronous SMS driver, 4 places that send texts, all mail through one channel (`TemplatedMailChannel`) | Nothing to lift; its move is one seam for mail and 4 template keys | Its review holds and `group_id` |

## 2 · Faults found, not to copy

- **Campus: schools can't buy credit.** `SmsWalletService::topup()` has no caller; credit is added by the platform's hand
  adjustment only, and the `'central'` wallet that pays for platform blasts can't be credited from any screen.
- **Campus: a bulk send is one job that sends one number at a time with no pacing, no progress and no cancel,** with a 600-second
  timeout against the queue's 90-second `retry_after`; its counts are written only at the end and are lost if it overruns; school
  announcements go out synchronously inside a database transaction, so a timeout half-way rolls back the record of texts already
  sent.
- **Campus: money races.** A refund's idempotency checked outside the lock with no unique index; `lockForUpdate()->firstOrCreate()`
  locks nothing; a hand adjustment checked outside the lock; the bulk affordability snapshot taken outside any lock; a debit
  before the provider call with nothing sweeping a crashed send.
- **Campus: mail.** No SNS topic allow-list, so anyone's AWS topic can post signed, forged complaints that suppress any address;
  suppression checks only the first recipient; no unsubscribe at all; over the per-school rate, mail is dropped, not delayed.
- **Desk:** bounces are recorded but never stop a send, while its pages say they do; retries promise 1, 5 and 15 minutes but
  stop after 5; a WhatsApp row can stay `queued` for ever.

## 3 · Gaps in Atrium NU must close

- `usage_limit` is a cap the Service sets by hand; nothing gives an organisation an allowance from its plan, and nothing charges.
- The `message` log counts texts, not segments, and has no price.
- There is no batch: a product sending to 600 parents would make 600 calls.
- Sender names exist per provider (N5) but nobody can ask for one.
- Atrium sends only its own mail (`email_message`); products have no e-mail through Nuntius, no suppression and no unsubscribe.

---

## 4 · Decisions

**Decided under the Architect's autonomy grant, 8 November 2026.**

| # | Decision | Chosen | Why, and the alternative |
|---|---|---|---|
| **NU-1** | Where SMS money lives | Per organisation, in Atrium: each product's plan may include texts a month (an offering's `notify.message` limit), spent first by that product's texts; then one credit balance per organisation, shared by its products, kept as a ledger (signed lines, `balance_after`, under a row lock) | A wallet per product splits a school's money for no reason. Campus's balances move with Campus's own plan |
| **NU-2** | How credit is bought | At `id`, Billing → SMS credit: packs as data (the Service prices them in the console), paid through Tabula's Paystack checkout as a payment of its own purpose, invoiced with VAT like any other. The Service can also add or take credit by hand, with a reason, audited | Campus never had a way in. A free-amount top-up instead of packs: packs keep the price a commercial decision |
| **NU-3** | What a text costs | One credit a segment (GSM-7 160/153, UCS-2 70/67); credit is counted in texts, so the rate lives in the packs' prices (NU-2), which an agreed price can lower for one organisation. Reserved when queued, under the organisation's lock: the allowance first, then credit. Given back only when it was never sent (`failed`); `unknown`, undelivered and expired keep their charge (NS-5) | Charging on delivery would let a timeout send free texts |
| **NU-4** | Out of credit | Refused at once (422, `no_credit`), written to the log; the organisation's owners and billing people told once per crossing below a low mark (a setting) and once when it runs out, and products told by webhook (`notify.credit_low`, `notify.credit_out`) and through `GET /v1/notify/balance`, so they show "top up SMS". Codes never stop (NS-7) | Letting a balance go negative, as Campus's e-mail overage does: a debt nobody agreed to |
| **NU-5** | A bulk send | `POST /v1/notify/batch`: one template, up to 10,000 recipients each with its values, the audience the product's. The whole cost is reserved up front or the batch is refused (never half a school's parents); then chunked jobs paced to the provider (messages a second, a setting per provider), live counts, cancel (what hasn't gone is given back), and `notify.batch_finished` with the counts. Each recipient is a row in `message`, linked to its batch | Campus's single job. A product's own loop over the single send: no pacing, no reservation |
| **NU-6** | Sender names | An organisation asks at `id` (Organisation → Sender name) or its product asks by API; the Service registers it with each provider by hand, as on Campus (to register, submitted, registered, refused); a text goes under the organisation's registered name, else the fallback setting (NS-6). The name is checked for the provider rules (3 to 11 letters and digits) | Asking only by e-mail to support: no record, no state |
| **NU-7** | Product e-mail | `POST /v1/notify/email`: a template key and values, as texts; each product's own sending identity (`notify.<product's domain>`, its own SES configuration set), so one product's bounces never harm another's; a provider chain lifted from Campus (SES first); one row per e-mail with each recipient's state. E-mail is counted, not charged | Atrium's own `email_message` for products too: its shape is Atrium's own notifications |
| **NU-8** | Who must not be written to | A block list keyed by the address or number, not the account, since most parents have none: hard bounces and complaints block for every product and organisation; an unsubscribe blocks that organisation's non-essential mail; a code, a receipt or a sign-in link (transactional) ignores an unsubscribe but never a hard bounce. Consulted for every recipient at send. Each change is an append-only line | Keyed to Clavis's person: misses everyone without an account. The spec's "for the same person" is met: an address is a person's |
| **NU-9** | Unsubscribing | Every non-essential e-mail carries `List-Unsubscribe` with one-click (RFC 8058) and a link to a page at `id` that needs no sign-in, signed per recipient; it says whom you'll stop hearing from and undoes in one press. SMS STOP waits for inbound SMS (NU-10) | A preference centre now: needs accounts most recipients don't have |
| **NU-10** | WhatsApp, inbound replies, STOP by SMS | Not in NU. When TuteDesk launches, Desk's WhatsApp and inbound code is lifted with its faults fixed (§2) | Building them before a product needs them |
| **NU-11** | Loop's and Campus's move | Each in its own plan, on the Architect's say-so: Loop through its one mail channel and 4 template keys; Campus's wallets with Campus's gateway (NS-9) | Moving them inside NU, against P4 |
| **NU-12** | Bounces from providers | SES events through SNS, the topic allow-listed in config, the signature checked, each event once by its ID, per recipient, forward-only; a soft bounce counts but blocks only after three in a week | Campus's open SNS endpoint (§2) |

---

## 5 · Mockups (`reji/ui/html/nu/`)

Each page's picture first (lessons): what the reader is deciding, at a glance.

- **`texts.html`** at `id`, for owners and billing people: the month's texts as a gauge that drains through the plan's
  allowance into the credit behind it, the credit as days of sending left at this month's pace; buying credit as a stepper
  where the price per text falls as the pack grows; the sender name as a phone showing how the text arrives, with its state per
  provider; states: plenty, running low, out, buying, paid, a name pending, registered, refused, phone.
- **`batch.html`** in the console (and as the product would show it): a send as a river that fills (queued, sent, delivered,
  failed), its pace and time left, cancel; finished with its counts; refused for credit.
- **`blocked.html`** in the console: undeliverable and opted-out addresses, each with why (bounced, complained, unsubscribed) and
  for whom, lifted with a reason; the unsubscribe page a parent lands on, on a phone.
- **`prompt.html`**: what a product shows when credit runs low or out (Campus's banner and its phone).

## 6 · Slices

| Slice | What | Done when |
|---|---|---|
| **NU0** ☑ | Mockups | Drawn and checked in light, dark and on a phone, 8 November 2026 |
| **NU1** ☑ | SMS money: allowance from the plan, the credit ledger, segments and the rate, reservation and give-back under a lock, refused at zero, low and out told, the Service's hand adjustment | Each money path by test, two sends racing for the last credit, a failed text given back once |
| **NU2** ☑ | Buying credit at `id`: packs as data, Tabula's checkout, the invoice, credit on payment once | A pack bought through the development checkout in the app; paid twice by webhook and return credits once |
| **NU3** ☑ | Bulk sends: the batch API, reservation, chunked paced jobs, live counts, cancel, the finished webhook, the console's page | 1,000 recipients paced by test; cancel gives back the unsent; a crashed chunk resumes without double-sending |
| **NU4** ☑ | Sender names: asked at `id` or by API, registered in the console, used at send | Each state by test and in the app |
| **NU5** ☑ | Product e-mail: the API, per-product identity, the provider chain, one row per e-mail per recipient | A send fails over to the second provider by test |
| **NU6** ☑ | Blocks and unsubscribing: the block list, SES through SNS with its allow-list, the unsubscribe page and header, the console's page | A forged topic refused; a hard bounce blocks every product; an unsubscribe blocks one organisation's non-essential mail only |
| **NU7** ☑ | Review of the money and send paths by a fresh reader; docs | Findings fixed with tests |

## 7 · Open items for the Architect

**The Architect, 9 November 2026: no prices yet; the pilot is free and everything in it complimentary.** So `notify.charge_texts`
stays off, no pack is put on sale, and the items below wait for the end of the pilot. The build carries on: the money paths stay built
and tested, so charging is a setting switched on, not a slice to write.

- The SMS rate a segment, the credit packs and their prices, and whether a plan's allowance differs by plan: all data, set in the
  console.
- The SES account and each product's sending domain (`notify.<domain>`), with SPF, DKIM and DMARC records.
- The low-credit mark, if not the default.

### 6.1 · What building added

- **NU1 (8 November 2026).** Credit is counted in texts, not money, so the rate is the packs' price (NU-3 as built). Charging is a
  setting, `notify.charge_texts`, off by default and a client able to differ: until the Architect sets the price, texts stay free and
  only counted, so the pilot's sends (NS-9) aren't refused. An offering's `texts_a_month` allowance with no amount means none: texts are
  never unlimited. The Service sending a text again is on the company. A finished message is frozen, so a give-back is a line of its own
  and never touches the message.
- **NU2 (8 November 2026).** A credit purchase is a `billing_payment` of purpose `texts` with no entitlement, offering or period (the
  database holds the shape); "credit" was kept for credit notes. Its invoice reads *5,000 texts of SMS credit* under the product `texts`.
  Money back on it offers only keeping the texts; a chargeback lost takes its share back. Packs are priced in the console and none is
  seeded: the price is the Architect's.
- **NU3 (9 November 2026).** A send's recipients are all checked before anything is kept, and the same words to the same number go once.
  A refused send is kept with its reason and no message rows, so the console and the product can see it. A code template can't go in a
  bulk send: a code is one person's, and a send's words sit on each row (the encrypted job carries only the send's ID). Pacing spaces each
  text at least 1/`per_second` after the last, a setting on the provider (20 by default), rather than counting a second's worth: an even
  pace never bursts after a slow answer. The cursor (`sent_through`) moves before each text, only while the send is sending, which makes a
  cancel and a run safe against each other without a lock between them. A bulk send's texts raise no `message.changed` each; the product
  reads counts and recipients by state. Sending is one text at a time per provider, so the provider's answer time is the real ceiling;
  parallel sends wait for a measurement against the real provider.
- **NU4 (9 November 2026).** A name is a row per provider, so each provider's answer stands on its own; asking for another deletes only
  what was still waiting, and a registered name keeps sending until its successor is registered with that provider, which marks it
  `replaced`. Asking is for whoever manages the organisation's settings (`setting.manage`), reading for whoever sees them; the page sits
  under Texts. No upload of the registration letter: the Service asks the organisation for it when a provider does, until a provider
  needs it in Atrium. The Service is e-mailed when a name is asked for (`notify.sender_asked`); the organisation and its products hear when
  a provider decides (`notify.sender_changed`). The moves the console offers come from `SenderNames::MOVES`, not a second list in the page.
- **NU5 (9 November 2026).** Products send their own words for now, by the same per-server permission as finished texts (NS-4): no
  product's wording lives in Atrium yet, so templates as data wait until a product needs them (NU-7 as built). The chain is the deployment's
  (`NOTIFY_MAIL_CHAIN`, mailers in `config/mail.php`, credentials in the environment), shown on the console rather than edited there, and
  SES goes over its SMTP interface so nothing new is installed. Each recipient gets an e-mail of their own and is saved the moment a mailer
  takes it, so quota counts are exact and a crash never resends. One row an e-mail with its recipients, by the house rule. A product
  with no domain can't send. Products' e-mail is a personal-data part, emptied by an erasure as the e-mail log is.
- **NU6 (9 November 2026).** The unsubscribe page asks before it acts, an extra state beside the mockup's, because mail scanners open
  every link; the mail client's one-click POST acts at once. The link's token is the address and organisation encrypted, so no address sits
  in a URL. The list is asked at the moment of sending, so a block that arrives after an e-mail is queued still applies. Numbers are blocked
  by the Service's hand until a real provider's "not a phone" reports are known; a hand block stops codes too, since a number that isn't a
  phone takes nothing. The list and its lines outlive an erasure: a suppression list is how someone is never written to again. Pages a
  person with no account opens get a quiet shell, not the sign-in page's pitch.
- **NU7 (9 November 2026).** Two fresh reviewers, in parallel, money and send, given the intent and the code: 6.5 and 7 as found, 8.1 after.
  Two serious faults fixed: charging was a workspace setting its payer could switch off (now the Service's, one client differing by its
  exception), and a single text could go twice after a worker died (now claimed before the provider hears of it). Seventeen lesser ones
  fixed, each with a test named for it (`ReviewTest`); one accepted: an e-mail handed to a second mailer after a timeout may arrive twice.
