# Notifications: how S14 is built

What S14 built (plan CORE §3.20; TuteDesk's notices and digests, TuteCampus's category × channel matrix, 24 October
2026) on the mail engine (S7, `mail.md`). The rules: **code raises one event; its rules name the channels** (the
app, e-mail, the daily digest), never a class; **each person narrows them per kind**; **nobody hears of what they may
not open**.

## How one event reaches people

`Notify::event()` → `Dispatcher::handle()` → for each active rule (`Dispatcher::fire()`):

1. `RecipientResolver` turns the rule's lines into people, each with their account when they have one
   (`ResolvedRecipients::$userIds`; else the account signing in with the address).
2. **May they see it?** For every person with an account, `$event->canSee($user)`; a no drops them from every channel.
3. **Their channels:** `Preferences::channelsFor()`: the rule's `channels`, then
   - no account (a bare address): e-mail only;
   - security, or an event that isn't a choice (the digest): the rule's channels, past everything below;
   - a muted workspace (`notification_mute`): none;
   - else each channel their choice for the event's category (`notification_preference`), or `Preferences::DEFAULTS`
     (app and e-mail on, the digest off).
4. **E-mail** goes as before (`Outbox`), split by language and by when it may go: someone in their quiet hours
   (`Preferences::heldUntil()`: the person's settings, in their time zone) gets their own row, its job delayed until
   they end. Security is never held.
5. **App and digest:** `Notices::write()` makes one `notification` row in their language, `in_app` and `digest`
   flags as chosen.

## The pieces

| Piece | What it does |
|---|---|
| `NotificationEvent::category()` | Each event's kind, a value of `notification_category` (Input Fields; `icon` in its metadata). `SECURITY` always comes |
| `NotificationEvent::canSee(User)` | Whether this person may see what it is about. Account events: the account's owner; an export: whoever asked; a role change: the person or `person.view` in its workspace; a lens: the member seen as or `lens.notice`; a workspace's status: its active members; readiness: the Service. `Seeing::member()` asks a membership (and a permission) in the event's workspace |
| `NotificationEvent::chosen()` | False for the digest itself: no switch, no preferences |
| `notification_rule.to_app`, `to_mail`, `to_digest` | One flag per channel of `NotificationRule::CHANNELS`, at least one on (a check); the model reads and writes them as the list `channels` (a jsonb list until plan SN D5). `notifications:sync` gives each rule its defaults' channels, else app, mail and digest (security: app and mail; mail-only for events with a one-time link), and leaves a rule the Service changed. `RuleEditor::update()` changes them: never none; a locked rule keeps `mail` |
| `notification_template.in_app` | The in-app words, a template field like the subject: English required, versioned, edited on the template page; never a one-time secret (`TemplateEditor`, `notifications:check`, `MailGuardTest`) |
| `Notices` | Writes a notice: the in-app words filled (`TemplateRenderer::line()`: plain text, `**bold**` kept as marks), secrets masked, the template's action link unless it uses a secret, its label unless the link is only the app's home |
| `notification` / `Notification` ● | `user_id`, `tenant_id` (null: the account's own), `event`, `category`, rule, `locale`, `words`, `link`, `action`, the subject, `actor_name`, `in_app`, `digest`, `read_at`, `digested_at`, `created_at`. The person's own: read through `Notification::mine()`, never the workspace scope (platform data in `TenantGuardTest`) |
| `Preferences` | `channelsFor`, `always`, `wants`, `muted`, `heldUntil`, `set` (not security, only kinds that could reach the person), `mute`, `categories` (the kinds a person's roles let reach them: a relation line, a permission or standard role they hold in any workspace, their address, the Service) |
| `notification_preference`, `notification_mute` | A person's choice per category × channel (`is_on`); a muted workspace. Platform data per user |
| `Digests` / `notifications:digest` | Hourly: each person whose digest hour it is (their zone) with notices waiting gets one `notification.digest` e-mail; the notices are claimed (`digested_at`) before it goes, so a second run sends nothing |
| `NotificationDigest` | The digest event: platform mail to the person, locked, mail only, `digest.count`, `digest.items` (a line each: words · where · when), `digest.url` |
| `OldNotifications` | A `Sweep` on the night's purge (`notification`): notices older than `notification.keep_days` |
| `HandleInertiaRequests` | `notifications` (`unread`, `poll`) on every page; `bell`, an optional prop: the newest ten, loaded when the bell opens |
| `NotificationBell.vue`, `NoticeWords.vue` | The bell: polls every `poll` seconds while the page is visible (`X-Background`, so it keeps no session awake); draws `**bold**` as text and `<b>`, never HTML |
| `MyNotificationList` | The full list on the list engine: `permission()` null (the viewer's own rows, open to everyone signed in; `base()` reads only theirs), no saved views; Unread/Read, workspace, kind |

## Pages

| Page | Route | Who |
|---|---|---|
| The bell | every page's header | everyone signed in |
| Your notifications | `/me/notification`; `GET …/{notification}` opens one; `PATCH …/{notification}/read`; `DELETE …/{notification}`; `POST /me/notification/read` (all) | the person's own; another's is not found |
| What reaches you | `/me/notification/preference`; `PUT` (category, channel, on); `PUT /me/notification/quiet`; `PUT /me/notification/mute/{tenant}` | the person's own; a workspace not theirs is not found |
| A rule's channels, a template's in-app words | `/console/notification/rule/{rule}/edit`, `…/template/{template}/edit` | `notification.manage` |

## Settings

| Key | Levels | Bounds | Shipped |
|---|---|---|---|
| `notification.keep_days` | platform | 30–365 days | 90 |
| `notification.poll_seconds` | platform | 10–300 seconds | 30 |
| `notification.quiet_hours` | person | on or off | off |
| `notification.quiet_from`, `quiet_to` | person | 0–23 (hour) | 20, 7 |
| `notification.digest_hour` | person | 0–23 (hour) | 7 |

## Adding an event (on top of `mail.md`)

1. `category()`: a kind in `notification_category`; `canSee(User)` when it is about a record not everyone may open.
2. Its template's `in_app` words in `lang/en/notification_template.php`, without a one-time secret.
3. Its rule's `channels` in `defaults()` only when it must differ from the default.
4. A maker in `Notifications/SweepTest`: the sweep fails until it has one.

## The guards

| Test | Proves |
|---|---|
| `Notifications/SweepTest` | the done-when: every event raised with its rule widened to every Member on every channel (and their bare address); each notice a Member gets opens for them; none comes about another's account, export, roles or the platform's checks; a maker for every event |
| `Notifications/ChannelTest` | the done-when: the bell and the inbox by default; choices per kind, the digest off until chosen; only the rule's channels; a mute; security past everything and without a switch; quiet hours hold only the held person's e-mail; a one-time link never on the bell |
| `Notifications/DigestTest` | one digest at the person's hour in their zone, every waiting notice once, nothing on an empty day; the purge's sweep |
| `Notifications/BellTest` | the count on every page, the newest ten on opening; opening (read, its workspace, its link); read, delete, all read, another's not found; the list's filters; What reaches you (only reachable kinds, choices, quiet hours, mute); the rule's channels |
| `Guards/ChannelGuardTest` | no `via()` and no `PrefersChannels` anywhere in the app |
| `Guards/MailGuardTest` (extended) | every event's English in-app words, none with a secret; its category in the list |
