Compare commits

...

4 Commits

Author SHA1 Message Date
2fd5d065b7 docs(website): §7.1 Q9 — core's own news.post emitter
`news.post` is a declared trigger with no caller: `coreTriggers.js` says so in
as many words, and Phase 6 migrated only the four `team.*` ones. A rule naming
it can never fire, so on a real deployment the only in-app or email items the
engine can produce today come from Teams. Phase 7 flagged it in passing; this
writes it down properly as an open question.

The substance is not the call — it is the three other things a news publish
already fires, and which of them the engine has any business replacing:

- the announce leg (`announce_job_legs`, `module-uo` owns `towncrier`) — a
  one-shot delivery to a channel of the deployment, with retry. NOT the
  engine's.
- a module's post hook (`registerPostHook`) — idempotent state mirroring that
  also runs on delete and refreshes on a silent edit. NOT the engine's.
- the raw `pushDispatch.publish('news.post', …)` — a per-person notification.
  THIS is the one that becomes an emit.

So modules keep both doors onto a news publish and neither changes. What a
module does not get is the ability to fire `news.post` itself — the id's owner
is core, `emit` binds the owner at the call, and §7.2's one namespace gives an
id exactly one owner across both facets. A module wanting its own person-facing
news notification declares its own trigger.

Three things to settle first, recorded rather than decided: continuity (the
emit replacing the tickle stops push silently until a rule is enabled — G22's
shape, and Phase 6 decision 3's), reusing the job-id transition signal rather
than re-deriving it, and which phase owns it. Recommended home: Phase 11, whose
title understates it — a pointer and an extra acceptance line land there too.

Code: RunicGateway/website#175

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:27:48 -05:00
d9abe3d941 docs(website): the in-app channel as built (engagement Phase 7)
ENGAGEMENT.md gains the Phase 7 as-built: the four decisions the org lead
settled before any code (in-app defaults to `instant`; the phase takes push's
`deliver` and the web preferences screen; the inbox takes `/notifications` and
the settings move under it; `ctx.inbox.push` respects a preference where one
exists), the block-role mapping that turns a template into a row, and five
things the tree contradicted or the build found — including the one only the
live rig could see, that staff had no reachable inbox at all.

Two earlier passages amended where the phase made them false: Phase 2's
"`ctx.inbox.push` throws until Phase 7" and Phase 3's "`inapp` is declared
`off`". Both kept as history with the correction beside them.

BACKEND_DESIGN.md gains the four inbox routes in the `/auth/me` table and a
`user_notifications` entry in the table inventory — the user-scoped dedupe
index, why `body` is text rather than the email HTML, and the retention policy.

Code: RunicGateway/website#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:11:46 -05:00
73c3a467e7 Merge pull request 'docs(website): the email channel and the Teams migration as built (engagement Phase 6)' (#187) from docs/engagement-email-channel into edge
Reviewed-on: #187
2026-08-31 06:07:57 +00:00
c93151b352 docs(website): the email channel and the Teams migration as built (engagement Phase 6)
ENGAGEMENT.md gains the Phase 6 as-built: the seven decisions settled up front,
the audience problem that shaped the phase, why scope_key is not subject_key, the
one place decision 4 as phrased could not ship, the projection's rule and what it
refuses to guess, the digest correcting Phase 4a rather than only implementing
§4.2b, and the three defects the build found. §4.2b and §6.0b updated with it.

TEAMS.md §6.4 rewritten: the pipeline it described no longer exists as its own
thing. It now says which of the three sinks moved and which did not, where each
of the four properties it always claimed lives now, that Team email is OFF until
an operator turns a rule on, and what the generalized unsubscribe token does.
§6.3's `last_digest_at` is marked as no longer read.

BACKEND_DESIGN.md: engagement_digest_state, engagement_outbox.scope_key, and the
unsubscribe routes — the canonical /public/engagement pair plus the /public/teams
path kept permanently because mail is not editable once sent.

Code: RunicGateway/website#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:12:15 -05:00
3 changed files with 2306 additions and 1696 deletions

View File

@@ -574,7 +574,8 @@ cooldown passes, always. See `ENGAGEMENT.md` Phase 4a.
| trigger_id | VARCHAR(96) NOT NULL | denormalized; survives a rule edit |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| channel | VARCHAR(32) NOT NULL | VARCHAR, never ENUM: the channel set is data, and a module must not require an ALTER |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | what a COOLDOWN counts, from the trigger's declared `subjectKey`. A display string is fine here: it is only ever compared with itself |
| scope_key | VARCHAR(190) NULL | what a PREFERENCE and an UNSUBSCRIBE are keyed on (engagement phase 6), e.g. `team:12`. Deliberately **not** `subject_key`: an unsubscribe token is signed over this and sits in a mailbox for months, so it has to be a stable identifier — signing over a display name orphans every link the first time somebody renames a Team. NULL means an unscoped event; `''` is reserved for "deployment-wide" in `engagement_digest_state` |
| payload | JSON NOT NULL | the declared variables, snapshotted at emit |
| dedupe_key | VARCHAR(190) NULL | the emitter's replay guard; NULL never collides |
@@ -622,6 +623,31 @@ a rule's budget and mute it.
**It is deliberately not a second address book.** The address is a hash; the values of a payload never
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
counts only.
### engagement_digest_state — how far each digest has got (engagement phase 6)
| col | type | notes |
|---|---|---|
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| channel | VARCHAR(32) NOT NULL | |
| scope_key | VARCHAR(190) NOT NULL DEFAULT '' | `''` = deployment-wide; `team:12` = one Team. NOT NULL with a `''` default because it is a PRIMARY KEY column and MariaDB coerces a nullable one anyway — the same workaround `team_integration_config` and `teams.active_key` both carry |
| last_digest_at | DATETIME NULL | stamped **only on a successful send** |
| updated_at | DATETIME | |
`PRIMARY KEY (user_id, channel, scope_key)`, `INDEX(channel, last_digest_at)` — the worker's driving
question is "whose digest is due?", which is a range scan of that index rather than of every digest ever
sent.
**This is a digest's only state, and deliberately not a digest queue.** The engine writes an outbox row
per (rule, user, channel) at emit time carrying a snapshot of the payload; that is right for an instant
send and wrong for a digest, which is re-derived from the source tables when it goes out. Three
properties depend on the re-derivation: a two-day outage sends one digest rather than replaying two
days, a post hidden after it was written is not in the query, and — the security one — a user who lost
access between the post and the send is no longer in the recipient set. So `engine.subscribedTo`
enqueues **`instant` recipients only**.
Lifted out of `team_notification_prefs.last_digest_at`, which was a worker's column on a user's
preferences row; `schema.sql` backfills it with an `INSERT IGNORE … SELECT`, replay-safe by the primary
key rather than by a flag.
### engagement_templates — the message bodies (engagement phase 5a)
| col | type | notes |
@@ -654,6 +680,45 @@ to the in-code seed whenever the row is absent or its `blocks` will not parse
to the in-code seed whenever the row is absent or its `blocks` will not parse — before the first seed
runs, after a restore that dropped the table, or on a row hand-edited in the database. That fallback is
what makes it safe for a password-reset mail to depend on this table at all.
### user_notifications — the in-app inbox (engagement phase 7)
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | CASCADE, unlike `engagement_sends`: this is content addressed to a person, not an audit of what the deployment sent |
| trigger_id | VARCHAR(96) NOT NULL | denormalized, **no foreign key** — a trigger is declared in code |
| title | VARCHAR(300) NOT NULL | rendered from the template's first `email.heading`; falls back to the projected `title`, then to the key. Truncated rather than refused |
| body | TEXT NULL | the **text** render of the template's remaining blocks. Not the email HTML — see below |
| url | VARCHAR(500) NULL | **site-relative only**, validated with the same character class `pageUrlTemplate` and the engine's `url` variables use. An absolute url on this deployment's own base is reduced to a relative one; anything else is dropped to NULL |
| dedupe_key | VARCHAR(190) NULL | NULL = this item does not dedupe |
| read_at | DATETIME NULL | |
| created_at | DATETIME | |
`UNIQUE (user_id, dedupe_key)`, `INDEX(user_id, read_at, created_at)`, `INDEX(created_at)`.
**The unique key is scoped to the USER, and that is deliberately narrower than the outbox's.**
`engagement_outbox` scopes its dedupe to `(rule, user, channel)` because one event legitimately becomes
one row per channel; an inbox has no channel dimension, so two rows for one event would be one item
shown twice. Multiple NULLs are permitted by a UNIQUE index, which is what "does not dedupe" means, and
`INSERT IGNORE` is what makes a replay, a retry and a module writing the same item twice all one no-op.
**`body` is text, and that is the load-bearing choice rather than a shortcut.** The `email.*` renderer
produces markup built for mail clients — table rows, inline hex colours, a light-only palette declared
with `color-scheme` — which dropped into a page that follows the viewer's theme renders as a pale card
floating in a dark one. `toText` is the same content with none of that, and it is the part the block
contract already promises every block can produce. It also means there is no operator markup on this
surface to sanitize, and no way for one to appear: every renderer treats the column as text.
**The template maps onto the three columns by block ROLE** (`templates.renderInappByKey`): the first
`email.heading` is the title, the first `email.button` is the url, and everything else is the body. So
an operator editing `inapp.event` in the Phase 5b editor changes what appears in the inbox, which is the
only reason the template exists at all.
**Retention: `utils/userNotificationsPrune.js`, nightly, READ items only.** Age alone would delete the
evidence for "I was never told", which is the complaint this table answers, and an inbox that quietly
drops unread items is one whose badge means nothing. The horizon is `settings.user_notifications_retain_days`
(default 90), so an operator tightens a busy shard without a deploy — `team_activity`'s posture, in the
worker that file is modelled on.
### The two block registries — pages and mail (engagement phase 5a)
@@ -1072,6 +1137,10 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
| GET | `/me/notifications/streams` | cookie / bearer | — | the subscribable catalog (`personal`/`requiresLinkedAccount` flags) |
| GET · PUT | `/me/notifications/subscriptions` | cookie / bearer | `{streams:[id]}` on PUT | get / replace own opted-in streams (unknown ids dropped) |
| GET · PUT | `/me/notifications/channels` | cookie / bearer | `{prefs:[{id,channel,mode}]}` on PUT | get / update own **per-channel** preferences ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5, phase 3). Returns the delivery-channel registry (`email`/`push`/`inapp`, each with `defaultMode`, `supportsDigest`, `modes`) plus one item per subscribable id — the **union** of push streams and event triggers, one namespace (§7.2) — carrying the **effective** mode on each channel that applies to it. A trigger-only id has no `push` toggle; a mode with no stored row reads as that channel’s default, so a client never sees which is which. The PUT is **sparse**: only the `(id, channel)` pairs listed are written and every other pair is untouched, so setting `email` cannot disturb `push`. `off` is a mode, never an omission — which is why this endpoint has no required-empty-array case. Entries naming an unknown id, an inapplicable channel or a mode that channel does not accept are **dropped, not refused**; the full stored state is echoed back. A `push` entry is mirrored into `/me/notifications/subscriptions`, whose wire shape is unchanged |
| GET · PUT | `/me/notifications/teams` | cookie / bearer | `{teams:[{teamId,muted,emailMode}]}` on PUT | get / replace own **per-Team** preferences (phase 6, [`TEAMS.md`](TEAMS.md) §6.3). One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they already hold a preference for; server-side defaults applied. An entry naming a Team the caller has no access to is **dropped, not refused**: a Team left between loading the screen and saving it is a race, not a client bug. The array is required even when empty (`../android/PLAN.md` §11) |
| GET | `/me/notifications` | cookie / bearer | `?limit&before&unread` | **one page of the caller's in-app inbox** ([`ENGAGEMENT.md`](ENGAGEMENT.md) §4.5 G17, phase 7), newest first. `before` is a **keyset cursor** (the previous page's last id), never an offset: the list gains rows at the top while it is being read. `limit` defaults to 30, capped at 100. Carries `unread`, the count for the whole inbox rather than the page, so a client rendering both a list and a badge cannot show them disagreeing. **No parameter names a user** — the caller is the only account any of these four routes can read |
| GET | `/me/notifications/unread-count` | cookie / bearer | — | `{unread}`. Its own route because it is **polled**: asking "is there anything new" must not make the server assemble a page of bodies to answer with one integer |
| POST | `/me/notifications/:id/read` | cookie / bearer | — | mark one item read. **Idempotent** — the statement carries `read_at IS NULL`, so a second call does not move the stamp. **404 both** when no such item exists and when it belongs to another account: the same answer on purpose, so this cannot be used to ask whether an id is anybody's |
| POST | `/me/notifications/read-all` | cookie / bearer | — | mark the whole inbox read; returns `{ok, changed, unread:0}` |
**Role-agnostic self-service (`/auth/me/*`).** The **only** self-service account surface, for every
@@ -1229,7 +1298,8 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current, plus `enabled` — whether this deployment has Teams at all |
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
| POST · GET | `/teams/unsubscribe/:token` | one-click unsubscribe from a Team's notification emails (phase 6, [`TEAMS.md`](TEAMS.md) §6.4). **The only write in this tier and the only route with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC whose whole capability is "set `muted` for one (user, Team) pair". POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, Team) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to mute Teams |
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
| POST · GET | `/engagement/unsubscribe/:token` | one-click unsubscribe (engagement phase 6, [`ENGAGEMENT.md`](ENGAGEMENT.md)). **The only write in this tier and the only routes with no `siteMode`** — the reader is in their mail client, not signed in, and the mail went out before the site went into maintenance. The token is a stateless HMAC naming a **channel and a scope**, and its whole capability is "turn that channel off for that scope, for one account": it reads nothing, cannot turn anything back on, and names no other scope. POST acts and **always answers 200**, valid token or forged: distinguishing them would be an oracle for which (user, scope) pairs exist. GET acts on nothing and redirects to the site's own `/unsubscribe/:token` page, because a mail client's link scanner must not be able to unsubscribe people who asked for nothing |
| POST · GET | `/teams/unsubscribe/:token` | **the same two handlers, at the path mail sent before phase 6 points at.** Kept permanently: mail is not editable once sent, so a route that moves is a person who cannot unsubscribe. A pre-phase-6 token verifies and reads as `{ channel: 'email', scopeKey: 'team:<id>' }` — it turns that Team's email off and, unlike before, no longer mutes its push |
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |

View File

@@ -593,7 +593,11 @@ Confirmed, and there is nothing to fix — only something to not break:
- `pushDispatch.allowedOrigins()` reads `NTFY_ALLOWED_ORIGINS` / `NTFY_BASE_URL` and **returns empty when
neither is set**. No Runic Gateway host appears anywhere in it.
- `mailer.isConfigured()` gates every sink, and `teamNotify.emailImmediate` checks it *before* the
recipient query so an unconfigured deployment pays nothing.
recipient query so an unconfigured deployment pays nothing. **As of Phase 6 that sink is the engine's**
and the gate moved with it: `mailer.sendNotification` returns a *retryable* failure when mail is
unconfigured, so an operator midway through typing SMTP credentials finds the outbox drains rather
than a backlog the worker gave up on. The digest worker still checks `isConfigured()` up front, before
any query that costs anything.
**Rules to carry into the abstraction:**
@@ -725,10 +729,20 @@ CREATE TABLE IF NOT EXISTS engagement_digest_state (
```
`team_notification_prefs.last_digest_at` backfills into this with `channel='email'`,
`scope_key = team_id`. The three properties from `teamDigestWorker`'s header comment must be preserved
verbatim by the generic worker, and the third one (a user who lost access is no longer in the recipient
set) should get its own named test, the way the Teams phase-5 work gave the leader-can't-see-reports rule
its own test.
`scope_key = CONCAT('team:', team_id)`. The three properties from `teamDigestWorker`'s header comment must
be preserved verbatim by the generic worker, and the third one (a user who lost access is no longer in the
recipient set) should get its own named test, the way the Teams phase-5 work gave the
leader-can't-see-reports rule its own test.
**Phase 6 as built, and it corrects §4.2a rather than only implementing this.** Keeping compute-at-send-time
means a digest-mode recipient must get **no outbox row at all**: Phase 4a's `subscribedTo` enqueued them
("what changes in Phase 6 is who drains it") and what changed in Phase 6 is that nothing drains it. An
outbox row holds a payload snapshotted at emit time and therefore has none of the three properties above —
including the security one. The engine now enqueues `instant` only. Two more things landed with it:
`engagement_outbox` gained a **`scope_key`** column (what a preference and an unsubscribe are keyed on,
which is not `subject_key` — see Phase 6's as-built), and the backfill's replay-safety is a property of the
PRIMARY KEY rather than of a flag, so a window the worker has since moved forward is never dragged
backwards by a restart.
### 4.3 The template variable contract — a code-declared schema, mirrored to a checked-in manifest
@@ -1008,7 +1022,7 @@ UPDATE` keyed on `seed_key`, **and it refuses to overwrite a row whose `customiz
| --- | --- | --- |
| `notify.event` | the generic single-event mail | `title`, `intro`, `items[]`, `actionUrl`, `unsubscribeUrl` |
| `notify.digest` | `teamDigestWorker`'s body | `intro`, `periodLabel`, `items[]`, `moreCount`, `scopeUrl`, `unsubscribeUrl` |
| `notify.team-post` | `mailer.sendTeamNotification` immediate | `teamName`, `authorName`, `threadTitle`, `excerpt`, `threadUrl` |
| `notify.team-post` | `mailer.sendTeamNotification` immediate | `teamName`, `authorName`, `threadTitle`, `excerpt`, `postUrl` — **`postUrl`, not `threadUrl`**: it has to be the name `team.forum.post` DECLARES, or the mail links nowhere. Renamed in Phase 6 (`seedVersion` 2) |
| `inapp.event` | *(new)* — the in-app channel's short form | `title`, `body`, `url` |
**Three properties of the seeded set that are design, not packaging:**
@@ -1319,8 +1333,8 @@ change is not complete until `docs/` reflects it" — is the floor; this table i
| **4b** Rules screen ✅ | `website/BACKEND_DESIGN.md` route table (the twelve routes, incl. the `PATCH …/enabled` argument and the count-only preview) · `website/ENGAGEMENT.md` §5.1a composition UI | Landed with the phase (docs#184) |
| **5a** Templates ✅ | `website/ENGAGEMENT.md` §4.6 as built · `BACKEND_DESIGN.md` — the `engagement_templates` table, the two block registries, the token grammar, and §7's multipart/subject changes | Landed with the phase |
| **5b** The editor | `website/ENGAGEMENT.md` §4.6.2 as built · `BACKEND_DESIGN.md` route table | **`runicgateway.com`**: a new admin docs page for the template editor |
| **6** Email channel + Teams migration | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing | **`runicgateway.com`**: `administration/teams.mdx` notification section |
| **7** In-app channel (core+web) | `website/BACKEND_DESIGN.md` routes + tables · `website/ENGAGEMENT.md` | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel |
| **6** Email channel + Teams migration ✅ | `website/TEAMS.md` §6.3/§6.4 **rewritten** — the Team pipeline it describes no longer exists as its own thing · `website/ENGAGEMENT.md` §4.2b + this phase as built · `BACKEND_DESIGN.md` route table and table inventory | **`runicgateway.com`**: `administration/teams.mdx` notification section. Landed with the phase |
| **7** In-app channel (core+web) ✅ | `website/BACKEND_DESIGN.md` routes + tables (the four inbox routes, `user_notifications`) · `website/ENGAGEMENT.md` this phase as built | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel. Landed with the phase |
| **8** In-app (Android) | `android/PLAN.md` | `android-app/README.md` |
| **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 |
| **10** Protocol bump | `link/INTEGRATION.md` §Housing (table + example) · `link/PLAN.md` §5/§7 · a `link/v5.md` if the bump earns its own design doc, as v3 and v4 did | `servuo-plugins/overlay.toml` · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
@@ -1646,6 +1660,7 @@ mails `uo.cheat.detected` to the player it detected. Fewer people is not less ex
- `ctx.events.emit` (`utils/engagementEmit.js`) — validate, log, **stop**; throws in dev, drops and
logs in prod; the owner is bound by core and never read from the arguments
- `ctx.inbox.push` — present and **throws** until Phase 7, the shape 1.6.0 settled on
*(Phase 7 filled it in. Not a version bump: the signature is the one 1.7.0 declared.)*
- `config/coreTriggers.js` — core's five, registered through `registerCore()`
- `GET /admin/engagement/{triggers,audiences}` — admin-only, served from the registries, no table
- `npm run engagement:manifest` (+ `--check` in CI) and the committed `engagement-triggers.json`
@@ -1731,7 +1746,9 @@ phases before the registry replaced it. What did *not* land is the behavioural h
`transports/index.js` deferred the whole file in Phase 1. Core's three channels are declared, and
`inapp` is declared `off` for a reason particular to it — the inbox does not exist until Phase 7, and
a default of `instant` would mean every user is opted into a surface with no rows, so the first thing
Phase 7 shipped would be a backlog.
Phase 7 shipped would be a backlog. **Phase 7 changed it to `instant`** once there was a surface to
look at: an inbox item wakes no device and leaves no building, and the backlog this paragraph feared
cannot happen against an empty table. See Phase 7's decision 1.
**The sparse PUT is the one place this phase leaves the router's idiom, and it buys two things.** A
whole-set body forces a client that only manages email to send every push row back or wipe them. And
@@ -2335,7 +2352,7 @@ check, because the way to make it pass is to delete the explanation.
---
### Phase 6 — The email channel on the engine, and the Teams migration
### Phase 6 — The email channel on the engine, and the Teams migration ✅
Email becomes a `DeliveryChannel` driven by rules. `teamNotify.js` and `teamDigestWorker.js` are rewritten
onto the generic pipeline; `engagement_digest_state` backfills from `team_notification_prefs.last_digest_at`;
@@ -2355,7 +2372,244 @@ replay-safe.
---
### Phase 7 — The in-app channel (core + web)
#### As built — 6 (2026-08-29)
The email channel gained a `deliver`, and the Team pipeline stopped being its own thing. Concretely:
`teamNotify.emailImmediate` and `mailer.sendTeamNotification` are gone, `teamNotify.forumPost` emits an
event instead, and a rule decides who is mailed. One end-to-end walk on a live rig now goes
**forum write → `events.emit` → rule → outbox → worker → email channel → template → SMTP → mailbox**,
with the send recorded in `engagement_sends` like everything else the platform sends.
**Seven decisions the org lead settled before any code**, because the tree contradicted the phase body
in every one of them:
| | Question | Decision |
|---|---|---|
| Sinks | `teamNotify` has three sinks; which move onto the engine | **email only.** The tickle and the Discord bridge stay direct calls in `teamNotify.js` |
| Audience | how a Team event reaches that Team's members, when `members` resolves to nobody and a segment takes constant params | **the event carries the set** — the same access-checked list `teamNotify.recipientIds` has always computed |
| Continuity | rules default off and core seeds none, so a straight migration stops Team email silently | **seed the four rules DISABLED**, plus an admin banner and a release note. The invariant is honoured, not excepted |
| Prefs | per-Team granularity has nowhere to live in `notification_channel_prefs` | **keep `team_notification_prefs`**, consulted by the engine as a scoped preference |
| Templates | §4.6.1 property 1 promises a generic template renders any trigger, and nothing implemented it | **payload wins, a structural projection fills gaps** |
| Digest | §4.2b says compute at send time; the engine snapshots at emit time | **keep compute-at-send-time**; generalize only the state |
| Unsubscribe | a v1 token set `muted`, silencing push as well as email | **turn off the channel the token names, and nothing else** |
##### The audience problem, which is the one that shaped the phase
The engine could not express "the members of the Team this post was in", and the reason is structural
rather than an oversight. A rule names its audience two ways: a plain ceiling name resolved from core's
own tables, or a **saved segment** composing module-declared audiences with **constant** parameters. A
Team forum post needs neither — the recipient list is different for every firing, and it is the answer
to an access question (`teamAccess.forumAccess`'s two tables) that core already knows how to ask and a
segment has no way to ask at all. `audiences.js` said as much in its own comment: *"core knows no game
vocabulary and cannot guess which members were meant."*
So the **event** names it. `engagementEmit`'s envelope gained `recipientUserIds`, and a rule whose
audience is `members` resolves to exactly that set. Three properties make it a narrowing input rather
than a hole in the ceiling lattice, and all three are tested:
- the carried set is still filtered through `users.status = 'active'`, so a banned account is not
mailable by an emitter that forgot;
- the ceiling returned is still `members`, so **G24 still runs** — a rule cannot be given
`authenticated` on a trigger that ceilings at `members`, carried set or no carried set;
- the list is bounded at `MAX_AUDIENCE` (5000) *at the emit boundary*, so an emitter cannot assert an
audience larger than the engine would have loaded from a query.
**A rule with any other audience ignores it entirely.** This is not "the emitter decides who gets
mailed": it is the emitter answering the one question core cannot, and the rule deciding everything
else.
##### `scope_key` is not `subject_key`, and Phase 6 is where that stopped being theoretical
Both are per-event strings on the outbox row and they are keyed on different things:
- **`subject_key`** is what a **cooldown** counts. It comes from the trigger's declared `subjectKey`,
which for all four Team triggers is `teamName` — a display string, which is fine, because a cooldown
only ever compares it with itself.
- **`scope_key`** is what a **preference** and an **unsubscribe** are keyed on, and it has to be a
stable identifier. `team:12` survives a rename; `The Silver Anvil` does not. Signing an unsubscribe
token over a display name would orphan every link in every mailbox the first time staff renamed a
guild — and those links have no expiry, so "the first time" means "ever".
`engagement_digest_state.scope_key` already used that vocabulary in §4.2b, so the outbox column is the
same vocabulary in the same shape rather than a second one.
##### The scoped preference, and the one place the decision as phrased could not ship
Decision 4 was recorded as *"a suppression below the channel preference"*. Building it showed that
reading is the one that cannot ship, and the reason is worth stating because it is a second instance of
the G22 pattern — a migration that degrades silently:
`notification_channel_prefs` holds a row **only where a user has expressed something**; absence means
the channel's `defaultMode`; and email's is `off`. Nobody has ever expressed a stream-level opinion
about `team.forum.post` — the screen that would let them is Phase 3's and the preference predates it by
a year. So intersecting the two preferences would resolve **every existing Team-email subscriber** to
`off`, and the deploy that migrated the pipeline would be the deploy that silenced it.
What shipped is **replacement**: where a scope has an opinion, that opinion is the preference.
`engagement/scopedPrefs.js` is a small registry keyed on the scope-key prefix; `engagement/coreScopePrefs.js`
registers `team` and maps the two columns:
- `muted` → `off` **on every channel**. That is what the toggle has always meant on the account screen,
and narrowing it to email would be a behaviour change nobody asked for.
- `email_mode` → the engine's vocabulary (`immediate` → `instant`), and **only for the email channel**.
On push or in-app the provider returns no opinion and the stream-level preference decides.
- **absence of a row means `off` for email**, which is why the provider answers for every user in the
set rather than only the rows it finds. The column defaults to `'off'` and both recipient queries
COALESCE to it: no row has always meant "has not asked for Team email". Deferring to the stream-level
preference instead would mean somebody who once switched on `team.forum.post` email in the channels
screen starts receiving mail from **every Team on the deployment** — a widening produced by a
migration, of a preference expressed about something else.
The cost, stated so nobody rediscovers it: a user cannot turn Team email off for every Team at once
from the channels screen. That control lives on the per-Team screen, which is where it has always lived
and where the unsubscribe link points.
The provider **fails open** — a lookup that throws leaves the stream-level preference in charge, which
for every core channel is `off`. So a failure means nothing is sent rather than that everybody is
mailed, and it does not drop an unrelated IDOC warning because a Team preference query timed out.
##### The projection: what §4.6.1 property 1 actually required
Property 1 says a new trigger renders through `notify.event` **with no authoring at all**. Nothing
implemented it, and building the channel is what made the hole visible: trigger payloads are
domain-named (`teamName`, `threadTitle`, `postUrl`) and the generic seeds are structural (`title`,
`intro`, `items`, `actionUrl`). The two vocabularies never met.
`engagement/projection.js` is the meeting, and its rule is one line: **a name the payload already
carries is left exactly as emitted; only a name it does not carry is supplied.** `news.post` and
`team.announcement` both declare their own `title`, and a projection that overwrote it would replace a
real headline with a category label — on the one variable every generic template puts in the subject.
What it fills comes from the **declaration**, never from a table of domain synonyms:
| | Filled from | Why not something cleverer |
|---|---|---|
| `title` | the trigger's `label` | a mapping of `threadTitle` → `title` is one game's vocabulary compiled into core, and it is wrong on the first module that names the same thing differently |
| `intro` | the trigger's `description` | same |
| `actionUrl` | the first declared `url`-typed variable holding a value | it is the only structural fact available: the declaration says which variables are links |
| `items` | `[]` | set rather than left absent, so a generic mail does not report `items` as a *missing variable* in the editor's preview |
The consequence, seen on the live rig and accepted: an unauthored `team.forum.post` mail is titled
"Team — new forum post" rather than the thread's title. That is a **plain** mail, not a wrong one, and
the operator's answer is the bespoke template shipped beside it. A projection clever enough to do
better is a projection that is confidently wrong on the first module that does not follow core's naming.
##### The digest: the state generalizes, the design does not
§4.2b's instruction was to keep `teamDigestWorker`'s compute-at-send-time design and generalize its
state. Doing that meant **correcting the engine**, not just the worker: Phase 4a's `subscribedTo`
enqueued `digest`-mode recipients with a comment reading *"what changes in Phase 6 is who drains it"*,
and what changed in Phase 6 is that **nothing drains it**. A digest is re-derived from the source tables
when it goes out; an outbox row carries a snapshot taken at emit time, and a snapshot has none of the
three properties the design exists for. So the engine now enqueues **`instant` only**, and each of the
three properties has its own named test:
1. a two-day outage sends ONE digest, not two days of replay;
2. a post a moderator hid after it was written is not in the query, so it is not in the mail;
3. **a user who lost forum access between the post and the send is not mailed** — the security one.
`engagement_digest_state` replaces `team_notification_prefs.last_digest_at`, keyed
`(user_id, channel, scope_key)` so a second digest — on another channel, or over another scope — needs
no second column on somebody's preferences row. The backfill is an `INSERT IGNORE … SELECT` in
`schema.sql`, and it is replay-safe **by construction rather than by a flag**: the primary key rejects
the second run, so a window the new worker has since moved forward is never dragged backwards by a
restart. Verified on the rig by restarting the server after a digest had gone out — the stamped row kept
its stamp, and the unstamped row was added.
**The digest is gated on an enabled email rule**, which is the part that was not in the phase body.
Without it, disabling the rule would stop the instant mail and leave a daily summary arriving
indefinitely, which reads to an operator as the switch being broken.
##### The unsubscribe: a narrowing, and a path that can never move
The token is now `(userId, channel, scopeKey)`, signed as `2.<uid>.<channel>.<scope>.<mac>`. **v1
tokens still verify, permanently**, and read as `{ channel: 'email', scopeKey: 'team:<id>' }` — which is
a reading of what they always meant, since a v1 token could only ever have arrived in an email.
Two things about it are deliberate and easy to undo by accident:
- **The route did not move, even though the phase body said the unsubscribe routes would.** The
canonical pair is now `/api/v1/public/engagement/unsubscribe/:token`, and
`/api/v1/public/teams/unsubscribe/:token` **stays forever**, handing straight to the same handlers.
Mail sent before this phase carries the old path in its `List-Unsubscribe` header and in its body;
mail is not editable once sent, so a route that moves is a person who cannot unsubscribe. Both paths
are in the route manifest.
- **A v1 token now turns off email and no longer mutes push.** That is the live behaviour change
decision 7 accepted: a link labelled "stop these emails" was quietly stopping notifications on
somebody's phone. Verified on the rig — POSTing a v1 token to the old path set `email_mode = 'off'`
and left `muted = 0`.
A channel id may legally contain a dot (`discord.dm` is §3.1's own example) and the token's separator is
a dot, so `sign` **refuses** such a channel rather than producing a token that verifies as a different
one. A scope the format cannot carry costs the mail its unsubscribe link, not the mail.
##### Two defects the phase found in code it did not write
1. **`email.button` never absolutized its href.** `email.image` and `email.itemList` both call
`ctx.absolute`; the button called `ctx.safeHref` alone. It had never mattered, because every caller
before this phase passed an absolute URL. A trigger's `url` variables are validated **site-relative
by construction** (`engagementEmit.RELATIVE_URL` exists so a variable cannot carry a recipient
off-site), so every rule-driven call-to-action would have interpolated to `/guilds/x` — a path a mail
client has no origin to resolve, i.e. a dead link in every notification the engine sends. Fixed in
both parts, with a test either side of the relative/absolute split.
2. **The digest's send-log row had no `address_hash`.** The instant path wrote one and the digest path
did not, so half the deployment's mail would have been uncorrelatable when Phase 9's bounce handling
arrives. Found by reading `engagement_sends` on the live rig, where the two rows sat next to each
other. Both paths now hash through the same function, lower-cased and trimmed — a bounce reported for
`Darrow@` has to match the row written for `darrow@`.
##### A naming inconsistency left alone, on purpose
`team.forum.post` declares its title as `threadTitle` and `team.announcement` declares it as `title`,
though both describe a thread in a Team forum. A template can only name one of them, so
`notify.team-post`'s `{{threadTitle}}` renders empty for an announcement — which is why the seeded
announcement rule points at `notify.event` instead, where the projection gets it right.
Reconciling the two declarations is a **variable rename, which §4.3 makes a version bump**, and this
phase did not take that on its own authority. A test pins the current shape so that reconciling it is a
deliberate act rather than a silent rename that empties somebody's subject line.
##### What an operator sees at cutover, which is the whole of decision 3
Core seeds four rules — one per Team trigger — all `enabled = 0`, all `audience: 'members'`, all on the
email channel. **Team email is off until an operator switches one on.** Three things make that
survivable rather than a silent regression:
- **Admin → Engagement → Rules carries a banner** whenever every Team rule is off, saying that these
used to send automatically, that they arrived switched off so nothing starts mailing on its own, and
that per-member preferences and unsubscribe links still work above them. It reads the rules rather
than a flag, so it disappears the moment one is enabled and returns if they are all switched off
again. A deployment that deleted them sees nothing, which is right — they made that choice.
- **The release note names it**, alongside the v1-token narrowing.
- **The push tickle and the Discord bridge are unaffected.** Only email moved, so the app and the
Discord channel keep working exactly as before while the rules are off.
The rules are seeded **once**, guarded by a settings key rather than ensured on every boot: an operator
who deletes a rule must not find it back after a restart, and one they enabled must not be reset to
off. The guard is stamped even after a partial run — re-running would duplicate the rules that did
insert, and a duplicate rule is two mails per event, which is worse than one missing rule an operator
can add from the screen.
##### What was verified
- **97 tests**: 35 in `teamNotifyDispatch.test.js` (rewritten — the mail-body assertions moved down to
the channel and what is asserted here is now the envelope), 31 in the new `engagementEmail.test.js`,
23 in `teamNotify.test.js`, plus the mailer, engine and block-renderer additions. Server suite **1447**
green, client **324** green.
- **A live rig**, which is where three of the findings above came from: MariaDB + Mailpit + a booted
server + a real Team with three members. The walk covered an instant mail with both
`List-Unsubscribe` headers, a digest gathering two posts and stamping `engagement_digest_state`, the
generic `notify.event` path rendering an announcement with no authoring, a pre-migration v1 token
unsubscribing through the old path, and a restart proving the backfill replay-safe.
- **The route manifest and swagger** carry the two new routes; the two legacy ones are unchanged.
**Still later phases':** the push and in-app channels' `deliver` (Phase 7), whether an unverified
address may receive opt-in mail (§7.1 Q1's narrower half, Phase 9), and the `address_hash` this phase
started writing, which is the column Phase 9's bounce correlation reads.
---
### Phase 7 — The in-app channel (core + web) ✅
`user_notifications`, the in-app `DeliveryChannel`, `GET /auth/me/notifications` + mark-read, and the web
surface (bell + list). Push tickles gain a `ref` that deep-links into the inbox.
@@ -2365,6 +2619,159 @@ no-op; mark-read is idempotent; a user cannot read another user's row (asserted
the model); `url` is relative-only, validated by the same character-class rule `pageUrlTemplate` uses.
**Guardrails:** swagger + route manifest; the sanitize path for `body`.
#### As built — 7 (2026-08-31)
The third channel gets behaviour, the oldest one gets a `deliver` at last, and the preferences endpoint
Phase 3 shipped with no surface gets one. **Four decisions were settled by the org lead before any
code**, two of them widening the phase past its own acceptance line.
##### Decision 1 — `inapp` defaults to `instant`, and it is the only channel that does
`coreChannels.js` deferred this in as many words: "whether the inbox is opt-out once it is real is a
Phase 7 decision with a live surface to look at." The surface exists now, and the answer is opt-OUT.
The argument for opt-IN was never about in-app. §7.1 Q1 is standard marketing-email practice and Phase
3's `push` default is about a device somebody is holding; **an inbox item wakes nothing and leaves
nothing** — it is a row on a page the user chose to open, on this deployment, costing one glance. Left
at `off` the channel would ship dead: no rule could reach anybody until every user found a toggle for a
channel they had never seen deliver anything. The backlog Phase 3 worried about cannot happen either —
the table is empty at cutover, rules default to `enabled = 0`, and every rule carries a per-hour
ceiling.
##### Decision 2 — the phase takes the two pieces its acceptance line omitted
Two earlier phases assigned work here that Phase 7's own bullets never mention, and both were taken:
- **`push` gets its `deliver`** (§2603's "the push and in-app channels' `deliver`"). Without it a rule
naming push still finished `failed` in the send log — the oldest sink in the system, unreachable from
the engine. It is the channel that got behaviour last because until the inbox existed there was
nothing for a content-free tickle to point at.
- **The web per-channel preferences screen** (Phase 3's as-built: "the screens are Phase 7 (web) and
Phase 8 (app)"). The endpoint had shipped with no consumer on either platform.
##### Decision 3 — the inbox takes `/notifications`; the preferences move under it
`/auth/me/notifications/*` was already the preferences namespace — `streams`, `subscriptions`,
`channels`, `teams` — and `/account/notifications` was already the preferences *page*, with a bell icon
in the portal nav. Content and settings are different kinds of thing, and **the plain word belongs to
the content**: it is what a person means when they say "notifications", and what the bell opens.
So the inbox is `GET /auth/me/notifications` and the page is `/account/notifications`; the preferences
screen moved to `/account/notifications/settings` and gained its own nav row. Route order is not
incidental and is commented as such: the four named preference sub-paths are declared above, and the one
parameterised path added below them is a **POST** whose `:id` is digits-only, so nothing can shadow
`streams` or `channels`.
##### Decision 4 — `ctx.inbox.push` respects a preference where one exists
The rule-less sink has no trigger declaration to project from, no rule to pick a template and no
audience to resolve. It now writes the inbox directly **unless** `triggerId` names a *registered*
trigger and that user's effective `inapp` mode is not `instant`: a toggle somebody switched off must not
be walkable around by the module that owns the trigger behind it. An id nothing has registered has no
toggle on any screen, so there is no preference to protect and the item is written.
Scoped preferences are deliberately not consulted — a scope is a property of an *event* (`team:12`), and
a caller with no declaration has no scope to name. The engine's path, which does, still applies them.
##### The block → column mapping, which is the whole of how a template becomes a row
`user_notifications` has `title` / `body` / `url` where email has a subject and a document. The in-app
renderer (`templates.renderInappByKey`) maps by block **role**: the first `email.heading` is the title,
the first `email.button` is the url, everything else is the body. A second heading or button is ordinary
body content, which is what an operator who added one meant.
**The body is TEXT, not the email HTML**, and that is load-bearing rather than a shortcut. The `email.*`
renderer produces markup built for mail clients — table rows, inline hex colours, a light-only palette
declared with `color-scheme` — which dropped into a page that follows the viewer's theme renders as a
pale card floating in a dark one. `toText` is the same content with none of that, and it is the part
every block already promises. The consequence worth stating: **there is no operator markup on this
surface to sanitize, and no way for one to appear.** The phase's "sanitize path for `body`" guardrail is
discharged by the column never holding markup in the first place, which is a stronger guarantee than a
sanitizer.
##### Five things the tree contradicted, or the build found
- **The shipped `inapp.event` seed named variables nothing supplies.** Phase 5a wrote it before the
channel that renders it existed, declaring `body` and `url` — but a trigger declares domain names
(`teamName`, `threadTitle`) and `projection.project` fills the gaps with the *structural* ones
(`title`, `intro`, `actionUrl`). Every rendering would have produced a title and nothing else.
Renamed to `notify.event`'s vocabulary at **`seedVersion` 2**, which is §4.6.1 property 1 restated for
this channel: a new trigger must render with no authoring at all.
- **The dedupe index is scoped to the USER, which is narrower than the outbox's.** `engagement_outbox`
scopes to `(rule, user, channel)` because one event legitimately becomes one row per channel; an inbox
has no channel dimension, so two rows for one event would be **one item shown twice**. Same family of
defect as the global index Phase 4a found in §4.2a, in the opposite direction.
- **The push tickle's `ref` needed an ordering to be worth anything.** A rule spanning `inapp` and
`push` enqueues two independent rows and the outbox sweeps `ORDER BY due_at, id`, so the ref only
resolves if the in-app row was enqueued first. `engine.liveChannels` now sorts `inapp` ahead of the
rest (`CHANNEL_ORDER`) — an ordering, not a dependency: the ref is a **hint**, null when there is no
row, and the app's contract stays wake-and-pull.
- **There was no retention policy for this table at all**, and neither the outbox nor the send log
bounds it (both hold one row per *delivery*; an inbox item outlives its delivery by design).
`utils/userNotificationsPrune.js` is `teamActivityPrune`'s shape with one policy difference:
**read items only.** Age alone would delete the evidence for "I was never told", which is the
complaint this table answers. The horizon is `settings.user_notifications_retain_days`, default 90.
- **Staff had no reachable inbox, and only the live rig could see it.** `/auth/me/notifications` is
role-agnostic — behind `requireAuth` only, like every `/auth/me` route — so the server, the tests
and the API all agreed a staff member had an inbox. On the web they did not: `RequirePlayer` sends
anyone who is not a player out of `/account` (staff manage their own account under `/admin/account`),
so the bell pointed at a page that redirects. **Signed in as an admin, the feature was unreachable.**
Fixed by mounting the same two components at `/admin/notifications` and
`/admin/notifications/settings`, adding the bell to the admin header, and putting the one mapping in
`client/src/lib/notificationPaths.js` with its own test. One trap inside the fix worth keeping:
`allowedPathsFor` turns an `end: true` nav row into an EXACT match, so marking the admin row exact
left `/admin/notifications/settings` outside the allowlist and bounced staff off their own
preferences screen — the row has to cover its sub-routes.
##### What an operator and a user actually see
- **The bell** sits in the public site header and in the player portal's own header, renders nothing
when signed out, and **polls** its badge once a minute — pausing while the tab is hidden and
refreshing the moment it comes back. There is nothing to push over: the site's two SSE streams are
the shard's, neither is per-user, and a third authenticated stream carrying one integer would mean an
open connection per signed-in tab forever.
- **The preferences screen is now a matrix**, not a checkbox list. The push-only stream list it replaced
was a strict subset: `/notifications/channels` already returns every push stream *and* every event
trigger with the effective mode on each channel that applies, so a trigger-only id simply has no push
cell and core never has to explain which kind of id a row is. The two legacy whole-set endpoints are
untouched and are that surface's push projection, so **the shipped Android app keeps its wire shape**.
##### What was verified
- **28 new tests**: 23 in `engagementInapp.test.js` (the five acceptance criteria, the role mapping, the
four `ctx.inbox.push` cases, the tickle's exact key set, and the route-level ownership check) and 5 in
`userNotificationsSql.test.js` — a throwaway MariaDB, because three properties here are a *server*
contract rather than a reading of this code: a UNIQUE index admitting many NULLs, `INSERT IGNORE`
reporting `affectedRows = 0` on a duplicate, and `read_at IS NULL` making mark-read idempotent.
- **Two existing tests moved with the behaviour, and both moves are the point.**
`engagementEngine`'s "a channel with no `deliver()` finishes failed" named `inapp` (and `email` before
it) and so was rewritten by every phase that gave a channel behaviour; it now registers a throwaway
channel, because the property was never about a particular one. `notificationChannelPrefs`'s defaults
assertion carries decision 1.
- **Swagger and the route manifest** carry the four new routes, with four new component schemas.
- **A live rig**: MariaDB + a booted server + the real outbox worker + a browser. The walk is where
the staff-reachability defect came from, and it also proved the three things unit tests cannot —
that `liveChannels`' ordering really does put the in-app row first (a rule stored as
`["push","inapp"]` enqueued outbox 2 = inapp before outbox 3 = push, and the tickle carried
`ref: "notification:2"`); that **two rules on one event produce three outbox rows and exactly ONE
inbox item**, with the send log saying "already in this inbox (duplicate dedupe key)" rather than
claiming a second delivery; and that the retention worker drops an aged READ row while leaving an
equally aged UNREAD one. The preferences matrix wrote exactly one row for the one cell that changed.
**One thing this phase did NOT wire, and it is worth knowing before Phase 11.** `news.post` is a
declared trigger that **nothing emits through the engine** — `coreTriggers.js` says so in as many words
("these declare; nothing here emits yet") and Phase 6 migrated only the four `team.*` ones, so the
admin publish path still fires a raw `pushDispatch.publish` beside the engine rather than through it.
The consequence for this phase: on a real deployment the only in-app items a rule can produce today
come from the four Team triggers. Wiring the news emitter is a one-line `ctx.events.emit`-shaped change
that belongs with whoever owns that decision, not smuggled into the channel's own phase. **Written up as
§7.1 Q9**, which sets out the three other things a publish already fires (an announce leg, a module's
post hook, the raw tickle), which of them the engine replaces and which it must not touch, and the
continuity question that has to be answered before anyone writes the line. Recommended home: Phase 11.
**Still later phases':** the app's inbox screen and the tickle → pull → inbox path (Phase 8), and the
suppression list, which this channel has no equivalent of — there is no address to suppress.
---
### Phase 8 — The in-app channel (Android)
@@ -2423,10 +2830,19 @@ starts early and lands independently.
with Phase 6 if Phase 10 is still in flight — the trigger simply omits `nextStage`/`estimatedCollapse`
until the v5 overlay is deployed, which the `required: false` declaration already permits.
**Also core's own `news.post` emitter, which this phase's title understates** (§7.1 Q9). `news.post` is
declared with no caller, so a rule naming it can never fire; wiring it is one `ctx.events.emit`-shaped
call in `announceIfNewlyPublished`, gated on the same job-id transition signal the push already uses.
**The announce legs and the post hooks are untouched** — a module's town-crier leg is a delivery to a
channel of the deployment and its news-gump hook is state mirroring, neither of which is a per-person
notification. What the emit replaces is the raw `pushDispatch.publish` beside them, and Q9's continuity
question has to be answered before it does.
**Acceptance:** the five-rung shard visibility walk still shows no leak; a house transitioning to
`Greatly` on the live rig produces one email to the linked owner and nothing to anyone else; a second
transition inside the cooldown produces nothing; a refresh back to `LikeNew` inside the delay window
cancels the pending mail.
cancels the pending mail; **a news post published on the rig reaches a rule, and the town-crier leg and
every registered post hook still fire exactly as they did.**
**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point.
---
@@ -2559,7 +2975,7 @@ day it ships.
## Part 7 — Open questions and forward-compat notes
### 7.1 Questions for the org lead — seven answered, one still open
### 7.1 Questions for the org lead — seven answered, two still open
1. ✅ **ANSWERED — may unverified addresses receive engagement mail?** *"Emails need to be unique and
verification blocking sending is an admin setting."* Combined with the opt-in answer, this settles
@@ -2622,6 +3038,52 @@ day it ships.
nine M12 phase PRs. Fix the trigger as Phase 8's first commit, or accept it deliberately?
Recommendation: fix it. It is a two-line workflow change and the alternative is finding out about a
Kotlin compile error during the cutover window.
9. **Core's own `news.post` emitter — and the three other things a publish already fires.**
`config/coreTriggers.js` declares core's five triggers and says in as many words that **nothing here
emits yet**; Phase 6 migrated only the four `team.*` ones onto the engine. So `news.post` is a
declared payload contract with **no caller**, and on a real deployment the only in-app or email items
a rule can produce today come from Teams. Phase 7 found this and deliberately did not wire it, on the
grounds that a channel's own phase is not the place to give another phase's trigger an emitter.
**The call itself is one line. The care is entirely in what it must not disturb.**
`admin.controller.js`'s `announceIfNewlyPublished` already fans one publish four ways, and they are
different in kind — three of them are *not* the engagement engine's business:
| What fires on a publish | Whose | What kind of thing it is | The engine's? |
| --- | --- | --- | --- |
| `announceJobs.enqueueIfNeeded` → `announce_job_legs` | core, with legs registered by modules — `module-uo` owns `towncrier` | a one-shot **delivery to a channel of the deployment**, with retry and classification | **No** |
| `registries.dispatchPostHook('onSaved')` | modules, via `registerPostHook` (API 1.1.0) | idempotent **state mirroring** — it also runs on delete, and refreshes silently on an edit | **No** |
| `pushDispatch.publish('news.post', { ref })` | core | a **per-person notification**, to whoever subscribed | **Yes — this is the one that should become an emit** |
| *(missing)* `engagementEmit.emit('core', 'news.post', …)` | core | rules → email / in-app / push | **Yes** |
`registries.js` already states the first two apart and why they were not folded together ("a leg is a
one-shot DELIVERY with retry and classification; a post hook maintains idempotent STATE, has to run on
delete as well as save, and refreshes silently on an edit"). **Adding the engine makes a third
distinction of the same kind, not a replacement for either.** A module's town-crier leg and a module's
news gump must keep firing exactly as they do; what changes is only that the raw tickle stops being
the one person-facing sink and becomes one channel of a rule.
**What a module gets out of this, stated so nobody widens it by accident.** A module already has two
doors onto a news publish — the announce leg and the post hook — and it keeps both. What it does *not*
get is the ability to fire `news.post` itself: the id's owner is core, `ctx.events.emit` binds the
owner at the call and never reads it from the arguments, and §7.2's one namespace means an id has
exactly one owner across both facets. A module that wants a person-facing notification of its own
declares its own trigger through `registerEventTriggers`. That is the whole of "modules can use it".
**Three things to settle before anyone writes the line:**
- **Continuity, and it is the same shape as G22 and Phase 6's decision 3.** Today publishing news
tickles every `news.post` subscriber directly. If the emit *replaces* that call, push stops the
moment this lands and stays stopped until an operator enables a rule — silently, because `enabled`
defaults to `0`. Either core seeds a `news.post` rule (and then: enabled, against the standing
default, or disabled with a banner as the Team rules got?), or the raw tickle stays beside the emit
for one release and is removed once a rule is known to exist.
- **The transition signal must be reused, not re-derived.** `enqueueIfNeeded` returning a truthy job
id is the single "newly published news" test, and the push call already piggybacks on it
deliberately so an edit or a re-publish does not re-fire. The emit must gate on the same value; a
second reading of the transition is a second chance to disagree with the first.
- **Which phase owns it.** Recommendation: **Phase 11**, which already ships "the first real rule" and
is where a declared trigger first gets a caller. It is core work rather than `module-uo`'s, so that
phase's title understates it — say so there rather than inventing a phase for one call site.
### 7.2 One namespace, or two?

View File

@@ -1595,8 +1595,13 @@ function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient
would be a refactor away from being used.
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two
places:
a `last_digest_at DATETIME NULL`, and it is surfaced in two places:
> **`last_digest_at` is no longer read** (engagement Phase 6). The digest's state moved to
> `engagement_digest_state`, keyed `(user_id, channel, scope_key)` so a second digest needs no second
> column here; `schema.sql` backfills it once. The column stays as the backfill's source and as the
> record of what a row meant before the migration. **The two preference columns are unchanged and are
> still the authority for Team notifications** — the engine reads them rather than replacing them.
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
@@ -1608,7 +1613,7 @@ places:
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
"is this person in it", and the guild page is public.
### 6.4 Email — the third sink, already built and unused
### 6.4 Email — the third sink
Push needs the Android app. The Discord bridge (§7.2) needs Discord. **A web-only user on a deployment
running neither currently gets no notification that someone replied to their own thread** — which is
@@ -1618,47 +1623,120 @@ Core already has `utils/mailer.js` and an admin-configured `email_config`. The e
notifications is computing the recipient set, and §6.2 builds it; email is a **third consumer of the
same event**, not a fourth pipeline.
- Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is off.
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge
(§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an
unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
> **Rewritten 2026-08-29 — the engagement system's Phase 6 took this sink over.** Everything below the
> line still describes what a recipient receives; what changed is who decides to send it. The design of
> record for the mechanism is now `docs/website/ENGAGEMENT.md` (Phase 6 as built), and this section is
> the Teams-shaped view of it. **Do not re-specify the engine here** — the same rule §6.0b applies to
> every other doc that touches a contract it does not own.
#### What moved, and what did not
`teamNotify.js` had three sinks. **One moved:**
| Sink | Where it lives now |
| --- | --- |
| The content-free push tickle | still `teamNotify.js`, unchanged. Its `deliver` on the engine is the engagement Phase 7's, with the in-app inbox that gives a tickle a `ref` worth deep-linking |
| The Discord bridge (§7.2) | still `teamNotify.js`, unchanged. A bridge is a *leg* — one-shot, to whoever can read a channel — and not a per-recipient *channel*; `ENGAGEMENT.md` §3.1 argues that distinction and it holds here |
| **Email** | **the engagement engine.** `teamNotify.forumPost` emits `team.forum.post` / `team.announcement`; a rule decides who is mailed, through which template, how often at most |
`mailer.sendTeamNotification` and `teamNotify.emailImmediate` no longer exist. The mail body is an
`engagement_templates` row an operator can edit (`notify.team-post` for a post, `notify.digest` for the
digest, `notify.event` for the two roster events and for announcements).
#### The four properties this section always claimed, and where each one lives now
- **Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is
off.** All three still hold, and the first is now explicit rather than incidental:
`teamNotify.recipientIds` computes the access-checked set and it travels on the event envelope as
`recipientUserIds`. A rule whose audience is `members` resolves to exactly that set — still filtered
for `users.status = 'active'`, still under the trigger's ceiling. Core does not learn what a Team is;
the event says who it is about.
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge (§7.2):
the recipient's mailbox is a destination they chose, not an untrusted relay reached by an unguessable
topic. It carries the thread title, an excerpt and a link; never the full post.
- **The per-Team preference is unchanged and is still the authority.** `team_notification_prefs` stays
exactly where it is, with exactly the meaning §6.3 gives it. The engine reads it through a
**scoped-preference** adapter: for a Team-scoped event that table *is* the preference, `muted`
silences every channel, and `email_mode` decides email and says nothing about the others. The
alternative — intersecting it with the newer per-stream preference — would have silenced every
existing subscriber on the migrating deploy, because nobody has ever expressed a stream-level opinion
about a Team trigger. The argument in full is in ENGAGEMENT.md Phase 6.
- **Off unless email is configured.** No usable `email_config` means the sink is absent, not broken —
and as of Phase 6 there is a second gate above it, below.
#### **Team email is OFF until an operator turns it on**
This is the one live behaviour change and it is deliberate. An engagement rule arrives `enabled = 0` so
that no import, restore or upgrade can start mailing on its own, and core seeds four Team rules under
that same rule. **On upgrade, Team notification emails stop until somebody opens Admin → Engagement →
Rules and switches one on.** The screen carries a banner saying so for as long as every Team rule is
off; the release note says it too. Push and the Discord bridge are unaffected.
#### The digest
**Computes at send time and keeps no queue.** The worker asks what arrived after the last stamp and
re-runs the access resolver. Three properties fall out, and the third is why it was chosen over a
pending-items table — and, in Phase 6, over the engine's own outbox:
1. a deployment down for two days sends **one** correct digest rather than replaying a backlog;
2. a post a moderator hid after it was written is simply not in the query;
3. **a user who lost forum access between the post and the send is no longer in the recipient set**, so
they are not emailed content they can no longer read.
`since` is clamped to at most seven days so a long outage cannot produce one enormous mail, and the
stamp is written **only on a successful send** — stamping first would quietly eat a day of somebody's
notifications every time the mail provider had a bad minute.
**What Phase 6 changed is the state, not the design.** The stamp moved from
`team_notification_prefs.last_digest_at` into `engagement_digest_state`, keyed
`(user_id, channel, scope_key)`, backfilled once by `schema.sql`. A digest-mode recipient gets **no
outbox row** — a row would carry a snapshot taken at publish time and would have none of the three
properties above. The worker is also gated on an enabled email rule, so switching Team email off
switches off both halves of it rather than the instant half only.
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
`team_notification_prefs`.
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
> would start mailing every member of every Team the moment an operator connects Gmail. Email is
> the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already
> has.
- **The digest computes at send time and keeps no queue** (as built). The only state is
`last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three
properties fall out, and the third is why it was chosen over a pending-items table: a deployment
down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator
hid after it was written is simply not in the query; and **a user who lost forum access between the
post and the send is no longer in the recipient set**, so they are not emailed content they can no
longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous
mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly
eat a day of somebody's notifications every time the mail provider had a bad minute.
- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed`
tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink.
- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken.
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client
writes the preference the site shows.
> **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property
> that makes a password-reset token a row is absent here — the link sits in a mailbox for months so
> it has no useful expiry, and clicking it twice must mean what clicking it once meant. The
> capability it carries is deliberately the narrowest that does the job: set `muted` for **one**
> (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the
> only revocation a stateless design can offer — bumping it invalidates every outstanding link at
> once — and it exists before it is needed rather than after.
>
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
> `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and
> does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to
> leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid
> token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no
> session behind it.
> would start mailing every member of every Team the moment an operator connects a mail transport.
> Email is the one opt-IN sink here. Push stays opt-out, because a mute silences something the user
> already has.
- **Roster events do not email by default** (as built, restated by Phase 6). `team.member.joined` and
`team.leadership.changed` do now *emit*, so an operator who wants that mail can have it — but the
rules that would send it are seeded disabled and carry an hour-long cooldown, so §6.4's original
argument survives as the default rather than as a sink the code declines to call.
#### One-click unsubscribe
A link honouring the same per-Team preference, so an unsubscribe from the mail client writes what the
site shows.
> **A stateless HMAC, not a token table.** Every property that makes a password-reset token a row is
> absent here — the link sits in a mailbox for months so it has no useful expiry, and clicking it twice
> must mean what clicking it once meant. The capability is deliberately the narrowest that does the
> job: turn **one channel** off for **one scope** for one account. It reads nothing, cannot turn
> anything back on, and names no other scope. `version` is the only revocation a stateless design can
> offer — retiring one invalidates every outstanding link of it at once — and it exists before it is
> needed rather than after.
>
> **Phase 6 generalized the token from `(userId, teamId)` to `(userId, channel, scopeKey)`, and
> narrowed what it does.** A v1 token set `muted`, which silenced that Team's *push* as well as its
> email — a link labelled "stop these emails" quietly stopping notifications on somebody's phone. A
> token now turns off the channel it names and nothing else. **Old tokens still verify, permanently**,
> and read as the email channel for that Team, which is a reading of what they always meant.
>
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
> `List-Unsubscribe` *header* carries `POST /api/v1/public/engagement/unsubscribe/:token`, because RFC
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and does
> not act** — a mail client's link scanner would otherwise silently unsubscribe people who asked for
> nothing. The endpoint answers `200` whatever the token was: a response that distinguished a valid
> token from a forgery would be an oracle for which (user, scope) pairs exist, on a surface with no
> session behind it.
>
> **`POST|GET /api/v1/public/teams/unsubscribe/:token` still exists and always will.** It hands
> straight to the same handlers. Mail sent before Phase 6 carries that path in its header and in its
> body, mail is not editable once sent, and a route that moves is a person who cannot unsubscribe.
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
is already being built there.