# NS · Nuntius, the send path (build plan step 1)

**Status:** NS-1 to NS-11 decided by the Architect, 7 November 2026. The mockups (`reji/ui/html/ns/`) were approved by the Architect the same day, which opened the build. **N1 to N6 are built, 7 November 2026; N7's docs are written, and Campus's gateway waits for its own plan and the Architect's say-so.** No real provider has been called. What the apps do was read from their code, and nobody has looked at production data, so traffic isn't confirmed.

---

## 0 · The one-paragraph version

Clavis can't land without SMS, because Campus's phone sign-in sends its codes that way, and Atrium can't send anything yet. NS is the smallest piece that fixes that: provider drivers lifted from Campus, one transactional SMS send, a receipt, and the count. No wallets, no bulk sends, no sender registration page. Those are plan NU. It's also the first real use of the foundation's keys, scopes, metering and webhooks.

---

## 1 · What the apps already do

| App | What's there | Lift | Leave |
|---|---|---|---|
| **Campus** (live) | About 6,100 lines of SMS: drivers for Sailup, Arkesel, Twilio, Africa's Talking and SNS; one provider sends at a time, with no failover; sender names per school and provider; wallets; delivery reports; bulk | The driver interface and drivers; `SendResult`; `wordless()` and `log_body`, which keep codes out of logs; `DeliveryReports`, which is once-only by delivery ID; looking a report up rather than trusting a callback; `SenderRegister`'s model | Refund-on-timeout (§2). `PlanPackage`, the `'central'` wallet string and UUID tenants. `SignInWatch`, which reads `User` and `Tenant`, so it belongs in Clavis |
| **Loop** | About 130 lines, one driver, no delivery reports | The SMS body on the same template row as the mail; general masking | The number handling |
| **Desk** | No SMS. About 1,480 lines of WhatsApp on Meta Cloud, off by default | For NU: a queued row and 1, 5, 15 minute retries; status ranks for out-of-order webhooks; append-only consent; versioned templates | What's tied to `User` and Desk's billing |
| **Atrium** | The mail engine, frozen log, templates and rendering; `server:notify`, `Idempotent`, `BackgroundJob` and `notify.message` limits are ready | `Outbox`, `TemplateRenderer` and the log's shape | `EventCatalogue` and `Dispatcher` resolve Atrium's own people, whereas a product names its own recipients, so an API send enters below them |

---

## 2 · Faults found in Campus's live code

Both were checked against the code, and neither should be lifted. They're Campus's to fix, in Campus.

- **A timeout counts as refused and is refunded.** `SailupDriver::send` catches every `Throwable` and returns `refused`, and `ProviderSmsGateway` then refunds, though the provider may have sent the text. A manual resend can double-text.
- **A sign-in code is marked sent even when it failed.** The gateway returns the row's ID either way, and `SignInCodes::text` sets `sent => true` regardless, so `SignInWatch`'s counts include failures.

---

## 3 · Gaps in Atrium this plan must close

- `usage_event_once` includes a nullable `tenant_id`, and PostgreSQL treats NULLs as distinct, so a platform send with a reference could count twice.
- `Usage::record` counts nothing without a current server, so Clavis's own sends wouldn't count.
- `Webhooks::mayHear` filters by product. A delivery receipt must reach only the server that sent the message.
- `usage_limit` is display only. A limit needs an atomic reservation inside the send, not `usage_event`, which swallows faults by design.
- `Idempotent` forgets after 24 hours, which is shorter than a long outage.

---

## 4 · Decisions

**Decided by the Architect, 7 November 2026: all as recommended.**

| # | Decision | Recommended | Alternative |
|---|---|---|---|
| **NS-1** | What NS covers | SMS only, with an internal call for Clavis and `/v1` for products. Atrium's own mail engine already covers Clavis's e-mail, and product e-mail is NU | E-mail too now, which brings the provider chain and suppression forward |
| **NS-2** | Whose accounts and money | Tutelora holds the provider accounts in Atrium and pays for sign-in codes. Campus's wallets stay in Campus until NU | Move the wallets now, before identity is settled, which the master plan names as the risk |
| **NS-3** | Which providers | The driver interface, then Sailup and Arkesel. One sends at a time, switched by hand | Automatic failover: needs sender names registered at both, and an uncertain send could double-text |
| **NS-4** | Template or finished text | A template key, as the spec says, held as data in Atrium. Sign-in codes get their own template with secret tokens. One narrow, audited allowance for finished text while Campus moves | Finished text only: quicker, but every product then owns its wording and the house rule goes |
| **NS-5** | What comes back | A message ID and `queued` at once; then signed webhooks to the sending server alone, with `GET` as the fallback. States: `queued`; `sent` (the provider accepted it); `delivered` and `undelivered` (from the provider's webhook); `failed` (never sent, so not charged); and `unknown` for a timeout, with no automatic retry or refund (§4.1) | Wait for the provider and answer in the call, which ties products to its speed |
| **NS-6** | Sender names | Record names per provider now, with no page yet. Codes use Tutelora's name unless the client's is registered. Falling back to Tutelora's name is a setting per client, off for outside clients, since the spec forbids it for them | Tutelora's name for everyone until NU |
| **NS-7** | Limits and the codes' brake | Sign-in codes skip a client's limit, as on Campus, because a code mustn't fail when a school runs out. Limits are enforced for everything else by an atomic reservation. The burst brake stays in Clavis | Enforce limits on codes too |
| **NS-8** | If Atrium is down | A small outbox in each product, with a key kept on the message row for good. Codes don't queue, since they expire in 10 minutes: they fail visibly and offer e-mail | Each product's own drivers as the fallback, for a limited time during the move only |
| **NS-9** | How Campus moves | A Nuntius gateway behind Campus's `SmsGateway`, switched per school. Non-money sends for one school first, with its local row kept and `paid_by` set so nothing double-debits. Codes move last, with Clavis. Each pilot school first needs an organisation, entitlement and live instance in Atrium | Move everything at once, against P4 |
| **NS-10** | The log | A new `message` log for sends through this module, one row per message with its channel, built so NU can fold e-mail into it. Atrium's own `email_message` stays as it is for now | Generalise `email_message` now, whose frozen, partitioned shape is e-mail's |

| **NS-11** | Short links in texts (the Architect, 7 November 2026) | A setting, `notify.short_links`, whether a text's links go through `tutel.ink`: at the platform's level, with a client able to differ, and off by default. It's built here, and has no effect until Tutelink (plan TL) exists | · |

**Facts from the Architect, 7 November 2026:**
- Campus sends through **Sailup**, so Sailup is what sends here. Arkesel stands by.
- The sending domain is **`notify.`**: `notify.tutecampus.app` in production, and each product's own under that name. The spec's `mail.` is overridden. This matters from NU, when product e-mail arrives.
- **Sailup has no whitelisting**, so `tutel.ink` needs no such step with it. The earlier "start early" task is dropped. If Arkesel ever sends, ask it the same question.
- Sailup's webhooks report what became of every text, as delivered or undelivered.

### 4.1 · A timed-out send

Webhooks settle a text once Atrium knows the provider's ID for it, and that ID arrives in the answer to the send. A timeout is the one case with no answer, so no ID: Campus's `DeliveryReports` looks a report up by that ID, and a report for such a text comes back as "not a text of ours" (`tutecampus/app/Services/Sms/DeliveryReports.php`).

So `unknown` is kept, and narrowed. When a report arrives that matches no ID, Atrium matches it to an `unknown` message by provider and number within a short window, oldest first, and takes the provider's ID from it. If nothing has come when the window closes, the message stays `unknown` and says so. It's never retried by itself, and never counted as `failed`, since either could double-text or misreport.

---

## 5 · Mockups, after the decisions

In `reji/ui/html/ns/`, drawn: `message.html`, the log and one message in each state, including `unknown` and a sign-in code; `channel.html`, which provider sends, whether its reports are arriving, connecting one, and the short-link setting.

---

## 6 · Slices

Nothing starts before the mockups are approved.

| Slice | What | Done when |
|---|---|---|
| N0 ☑ | Mockups in `reji/ui/html/ns/`: messages, one message, channels | Approved |
| N1 ☑ | `app/Modules/Notify/`, switched on. The driver interface, Sailup and Arkesel lifted from Campus with the timeout fault left out, `SendResult`, codes kept out of logs | A driver test per answer: accepted, refused, sender refused, timeout |
| N2 ☑ | The `message` log, one row per message and frozen once finished; SMS templates as data, with secret tokens; the send itself, queued, called from inside Atrium and through `POST /v1/notify/sms` | A send reaches a fake provider once, a retry gets the first answer, and a code never appears in a log |
| N3 ☑ | Webhooks from Sailup and Arkesel, once-only by delivery ID, with lookup as the sweep; the window for `unknown` (§4.1); signed receipts to the sending server alone; `GET /v1/notify/message/{id}` | Each state reached by test, and another server never hears of it |
| N4 ☑ | Counting and limits: an atomic reservation in the send, with codes exempt; the three gaps in §3 | Over its limit a client's text is refused and a code still goes; a retry doesn't count twice |
| N5 ☑ | Sender names per provider, recorded with no page; the fallback setting; the `notify.short_links` setting (NS-11) | Outside clients never go out under Tutelora's name, by test |
| N6 ☑ | The console's pages, to the approved mockups | Every state, in light, dark and on a phone |
| N7 ◐ | Docs: manual, reference, map, journey, `PROJECT.md` ☑. Then Campus's gateway, in Campus's own plan and on the Architect's say-so | Reviewed against the code |

### 6.1 · What building added

- Over its limit, `POST /v1/notify/sms` answers 422 with the message's row, so a product knows at once. No receipt follows, and a retry gets the same answer.
- A server with no product can't use an exempt template, since the company pays for those.
- A code goes under the company's name even where a client has turned the fallback off, since NS-6 says codes do. An outside client's server still can't send one.
- A provider's 5xx counts as `unknown`, as a read timeout does: it may have taken the text before failing.
- Atrium's own sends need a privacy part for `message.to_number` when Clavis brings them (`reji/docs/reference/notify.md`).

The first real caller is one pilot school's non-money texts (NS-9), which needs its organisation, entitlement and instance in Atrium first. Clavis follows, and brings the codes.
