# Nuntius's money and send paths: security review

Reviewed 9 November 2026 (plan NU, slice NU7), against the code on `develop` after NU6. Plan NU's last slice asks for a fresh reader's
review of what NU1 to NU6 added: what a text costs and buying credit, bulk sends, sender names, products' e-mail, and who isn't written
to. A dated record: review again before charging is switched on (`notify.charge_texts`), before the first real provider and SES account
carry a pilot school's messages, and whenever a provider, the chain of mailers or SNS changes. The send path's own plan (NS) and the
foundation's assessments still stand for what they cover; this one covers what NU added.

**Method.** Two adversarial reads, in parallel, by reviewers who hadn't written the code and were given its intent and its files, not an
opinion of it: one of the money paths (the credit ledger, reservation, give-backs, packs, checkout, refunds and chargebacks for texts),
one of the send paths (single texts, bulk sends, sender names, products' e-mail, SES through SNS, the block list and unsubscribing). Each
finding was then checked against the code before it was accepted, and every accepted finding was fixed with a test (`ReviewTest`, with
`BatchTest`, `BlockTest`, `BuyCreditTest`, `SenderTest` and `NotifyTest`). `composer audit` and `npm audit --omit=dev`, both clean on
9 November. The reviewers scored the paths as they found them: money **6.5 / 10**, send **7 / 10**.

**Verdict.** **Ready for the free pilot; ready for charging once the price is set and the real provider is measured.** The ledger's
mechanics were sound from the start: one row lock for every balance change, a database check that the balance never goes below nothing,
one spent and one given-back line a text by a unique index, and composite keys that make a cross-organisation line impossible. The review
found two serious faults, that an organisation could switch its own charging off and that a single text could be sent twice after a worker
died, and seventeen lesser ones. All but one are fixed in this slice; the one left is accepted below, with why.

---

## Scoring scale

As the foundation's: 1–3 unsafe for production with personal data; 4–6 staging or a friendly pilot; 7–8 production-ready with documented
caveats; 9–10 enterprise-grade with formal compliance.

---

## 1. What a text costs, and buying credit: **8 / 10** (6.5 as found)

**In place.** Every balance change holds the organisation's `sms_credit` row (`Credit::locked`); `CHECK (balance >= 0)`; one `spent` and
one `given_back` line a text, one `bought` line a payment; composite keys tying a line to its organisation's text and payment. A pack is
paid on Paystack's page, believed only from Paystack's own answer, credited once; its price is fixed once bought.

**Fixed in NU7.**
- **Serious: an organisation could switch its own charging off.** `notify.charge_texts` allowed the workspace level, so any owner with
  `setting.manage` could set "off" and the company would pay for every text. It's now the Service's alone: set for everyone, one client
  differing only by the Service's time-limited exception (`exceptable`). A workspace row, however it got there, counts for nothing.
- **The purge couldn't take a charged text.** The ledger's key to the text had no delete rule, so the nightly purge failed on any charged
  text past `log.message_days` and the log outlived its retention. The key now empties (`ON DELETE SET NULL (message_id)`), the line keeping
  its amount and balance.
- **A give-back could be lost.** A cancelled bulk send gave back after its cancel committed, and a failed single text after its state was
  saved: a crash between the two kept the money. The bulk give-back is now inside the cancel's transaction, and `notify:check` sweeps every
  failed text paid from credit that has no give-back line (`Credit::sweepGiveBacks`), once each by the ledger's index.
- **A lost chargeback could keep its texts.** The dispute was marked lost, the loss recorded and the texts taken back in three steps.
  They're now one transaction, so a failure leaves the dispute open for the next check to decide again.
- **A single give-back didn't start the low and out notices again**, so the next run-out went untold. It now clears them as a purchase
  and a bulk give-back do.
- **Codes had no overall cap.** A product's sign-in form could text many numbers at the company's cost (SMS pumping). A server may now send
  `notify.codes_per_hour` codes in an hour (600 by default), whatever the numbers, beside the cap per number.
- **A request could hold the credit lock with a novel.** A text is now ten segments at most (`Message::MOST_SEGMENTS`), alone or in a bulk
  send, and a request's values are thirty keys at most.

## 2. Single texts: **8 / 10** (6 as found)

**Fixed in NU7.**
- **Serious: a text could be sent twice.** If the worker died after the provider accepted a text, or the save after it failed, the job ran
  again, found the text still `queued`, and sent it again: a sign-in code twice, perhaps half an hour apart. A text is now claimed with a
  conditional update (`message.handed_at`) before the provider hears of it; a run that finds the claim moves the text to `unknown` and
  never sends it; an answer that nothing reached the provider takes the claim back, so a real retry still goes.
- **A text could stay charged and never sent** if its job was never queued. `notify:check` settles a single text queued over an hour:
  never handed over, it fails and is given back; handed over, it may have gone, so it's `unknown`.
- **The provider's pace held only for bulk sends.** Single texts and codes went out beside a bulk send at full pace, and a provider's
  429 failed the text for good. Every request now passes one count a second per provider (`Handover::call`), and a 429 means the provider
  took nothing, so the text waits and goes again.
- **One refusal that mentioned a sender unregistered the name.** A provider's message containing "sender" flipped a registered name to
  refused, stopping every text of a client that may not fall back. The name now stays registered; the provider's words are kept on it for
  the Service to read on the console, and the text falls back as the client allows.

## 3. Bulk sends: **8.5 / 10** (7.5 as found)

**In place.** The cursor moves before each text, only while the send is sending, so a cancel and a run can't both win; a run that dies
resumes with the text in flight marked `unknown`; the whole cost is reserved or the send refused.

**Fixed in NU7.**
- **Two runs could hand over the same texts.** The cursor's update checked only that the send was sending. It now moves forward only
  (`sent_through < id`), so of two runs only one hands over a text.
- **A waiting send starved, and extra runs cut its waits to seconds.** A send waiting for the provider's lock never touched its
  `updated_at`, so the check added a run every ten minutes; with a provider down, those runs each retried at once and stopped the send within
  seconds instead of after the 1, 5 and 15-minute waits. A waiting send now says it's alive, the wait is the send's own (`wait_until`) and
  every run honours it, and a send breathes between minutes so another gets its turn.

## 4. Products' e-mail: **7.5 / 10** (6.5 as found)

**Fixed in NU7.**
- **An e-mail could stay queued for ever.** A slow mailer could take a 50-recipient e-mail past the job's timeout; the worker was killed,
  no try was counted, and nothing ended it. A run now sends for 70 seconds at most and carries the rest on without spending a try; a job
  whose tries run out marks whoever wasn't reached not sent and tells the server (`failed()`).
- **A bounce could be overwritten.** The job saved its whole list of recipients from memory, writing a bounce that landed mid-run back to
  `sent`. Each recipient is now written under the row's lock onto what's there, by the job and by SES's reports alike.
- **Quotas were undercounted.** A recipient that later bounced stopped counting against its mailer's month. A mailer's month now counts
  everything it took.
- **Unsubscribes could be skipped by leaving `kind` out**, which defaulted to transactional. `kind` is now required.

**Accepted.** An e-mail handed to a second mailer after the first timed out may arrive twice: SMTP can't say whether a timeout came before
or after the first mailer accepted it. One e-mail twice through two providers is the lesser harm next to one that never arrives; revisit if
a provider's API (not SMTP) replaces it.

## 5. Who isn't written to: **8.5 / 10** (8 as found)

**In place.** SES is believed only from the allow-listed topic, with Amazon's signature over the right fields checked against a
certificate fetched from an SNS host; a subscription is confirmed only at an SNS address; the unsubscribe token is encrypted and
authenticated, puts no address in a URL, and its page never acts on a GET.

**Fixed in NU7.**
- **An old SNS message could be replayed**, re-blocking an address the Service had lifted, and an event was marked done before it was
  applied. A message older than an hour is refused (`SesReports::FRESH_SECONDS`; SNS retries well inside it), and an event is kept and
  applied in one transaction.

## Weighted overall: **8.1 / 10** (6.8 as found)

Money and single texts weigh most, since they spend the organisation's money and the company's; bulk sends and the block list next.

## Before charging is switched on

1. **Set the price** (packs in the console) and switch `notify.charge_texts` on for everyone; give a pilot school the Service's exception
   if it should stay free a while.
2. **Measure the real provider.** Its answer time sets how fast a bulk send goes (one text at a time per provider); its error codes, not
   words, should say a sender name was refused; its "not a phone" reports can then feed the block list.
3. **SES:** each product's `notify.<domain>` verified, a configuration set per product, the SNS topic subscribed and listed in
   `NOTIFY_SES_TOPICS`.

## Tracking

Each fix's test is named for its finding in `tests/Feature/Notify/ReviewTest.php` ("money 1", "send 1", …), with the bulk, block,
chargeback and sender-name proofs in their own files. Review again at each point under "Before charging is switched on".
