Two scope decisions taken by the org lead on 2026-08-31, before any Phase 10 code,
plus the three factual corrections that finding them out produced.
Phase 10 — the protocol bump now carries three enrichments, not one. The argument is
specific to this phase: a bump costs a sidecar release, a republished bundle and an
operator update on every shard, so a field left out does not cost a follow-up commit,
it costs a second bump with the same lead time and a split operator population. The
two additions:
* player-vendor fee state on vendor.listing (ownerAcct, holdGold, chargePerDay,
daysRemaining), because uo.vendor.expiring is the same "owned asset at risk with
a deadline" shape as the flagship and today has neither an address nor a deadline;
* a post-decision account.login.result, because EventSink.AccountLogin is a veto
hook that fires BEFORE the auth decision.
Moving vendor.sale out of the opt-in patch tier was explicitly declined.
Phase 11 — ships every checked row of 8.6 rather than a single rule, carving out
uo.market.item_listed (a saved search; no per-user query store exists). ~23 triggers
grouped by the audience kind each family exercises, since exercising the ceiling
lattice at scale is the point rather than volume of mail. Every rule still ships
enabled = 0 per Q3. Notes that the phase will likely want an 11a/11b split on the
4a/4b precedent, to confirm at its start.
Three corrections to 8.6, each verified against the emitters rather than the table:
* uo.vendor.sale is real and does carry ownerAcct, but lives in servuo-plugins/
patches/ (opt-in, verified only against ServUO 57.4) — dormant, not broken, on a
shard that declined the tier;
* uo.vendor.expiring had no data at all, not merely no mapper — vendor.listing
carries ownerSerial/ownerName and nothing carries held gold or daily charge;
* uo.account.login_attempt could not have been built as described — it would have
mailed "someone tried to log into your account" on every successful login.
Renamed uo.account.login_failed so the id cannot be misread again.
Also amended: the status header, scope decision 3, and 6.0b's documentation
assignment for rows 10 and 11 (v5.md now earned; the patch-tier caveat is an
operator-facing doc obligation; runicgateway.com's capability claim changes when
"one rule" becomes "the catalogue").
Co-Authored-By: Claude <noreply@anthropic.com>
3663 lines
265 KiB
Markdown
3663 lines
265 KiB
Markdown
# The Engagement System — findings and plan
|
||
|
||
**Status:** design of record. **Phases 1, 1a, 1b, 2, 3, 4a, 4b, 5a, 5b, 6, 7, 8 and 9 are built**
|
||
(Phase 1: website#165 + docs#178, with website#164 as its prerequisite; Phase 1a: website#166 +
|
||
docs#179; Phase 1b: website#167 + docs#180; Phase 2: website#168 + docs#181; Phase 3: website#169 +
|
||
docs#182; Phase 4a: website#170 + docs#183; Phase 4b: website#171 + docs#184; Phase 5a: website#172 +
|
||
docs#185; Phase 5b: website#173 + docs#186 + runicgateway.com#22; Phase 6: website#174 + docs#187 +
|
||
runicgateway.com#23; Phase 7: website#175 + docs#188 + runicgateway.com#24; Phase 8: the Android
|
||
half, Android-app#42 + docs#190; Phase 9: website#176 + docs#191); everything from Phase 10 on is
|
||
still design. **Phases 10 and 11 were both widened on 2026-08-31, by the org lead, before any code:**
|
||
the protocol bump carries three wire enrichments rather than one, and Phase 11 ships **every ✅ row of
|
||
§8.6** rather than a single rule. The scope decisions
|
||
below are settled; **eight of the nine questions in §7.1 are answered** - Q1, Q3, Q5 and Q7 on
|
||
2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's namespace question),
|
||
**Q2 and Q4 on 2026-08-29 at the start of Phase 4**, and **Q8 on 2026-08-31 at the start of Phase
|
||
8**. Q1's answer added a whole phase (**Phase 1b**, unique email addresses); Q4's answer and the
|
||
phase's size split **Phase 4 into 4a and 4b**; **Q9** (core's own `news.post` emitter, added
|
||
2026-08-31 in docs#189 and recommended for Phase 11) is the only one still open. Per CLAUDE.md § Conventions, no
|
||
implementation starts without the org lead's approval of the phase it belongs to.
|
||
|
||
**Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover
|
||
(Phase 13). §6.0a records the blocking precondition — six `edge` branches are stale and two repos have
|
||
none.
|
||
|
||
**Scope decisions, settled by the org lead (2026-08-28):**
|
||
|
||
1. **The in-app channel is in scope.** It does not exist today and has to be built, not adapted.
|
||
2. **The Teams notification pipeline is generalized and migrated onto the new system**, not built beside it.
|
||
3. **The `house.decay` protocol enrichment is in scope**, as a coordinated four-repo `PROTOCOL_VERSION`
|
||
bump. **Widened 2026-08-31:** that single bump now carries **three** enrichments (house decay,
|
||
player-vendor fee state, and a post-decision login result), because a second bump would cost another
|
||
release + bundle + operator update. See Phase 10.
|
||
4. **Gmail OAuth2 is removed, not retained as a transport.** SMTP is the baseline; the OAuth2 consent
|
||
flow, its two routes, its borrowed Google client and its stored refresh token all go. See §1.2a for
|
||
what that deletes and §6/Phase 1 for the operator cutover it forces.
|
||
5. **The system ships with a seeded set of working templates and an editor**, so a fresh deployment
|
||
sends correctly-branded mail before anyone opens the editor. See §4.6.
|
||
|
||
**Four further decisions, settled 2026-08-28** (recorded in full at §7.1, with the findings that shaped
|
||
them in §0.6):
|
||
|
||
6. **Engagement mail is opt-in only, and `users.email` becomes UNIQUE.** Standard marketing-email practice
|
||
applies: explicit opt-in, working unsubscribe, suppression. Uniqueness is not a detail — it is
|
||
**Phase 1b**, because the column is nullable-and-duplicated by design today and three code paths break
|
||
the moment an index is added. Whether an *unverified* address may receive opt-in mail is an **admin
|
||
setting**, defaulting **on for fresh installs and off for upgrades**.
|
||
7. **There is no campaigns surface.** No operator-authored send screen, no free-form list building. An
|
||
admin's expressive power lives in trigger conditions and rules.
|
||
8. **Audiences are module-declared and operator-composable.** A module registers named, queryable
|
||
audiences over *its own* data (`uo.team.members`, `uo.governors`); core exposes the same registration
|
||
surface to every module and learns no game vocabulary. An operator may compose declared audiences with
|
||
and/or/not into a saved segment — and the composed result is still bounded by the trigger's G24
|
||
audience ceiling. See §5.1a.
|
||
|
||
---
|
||
|
||
## Part 0 — Six findings that contradict the brief
|
||
|
||
Stated up front because the rest of the document is shaped by them.
|
||
|
||
### 0.1 There is no In-app channel. The target diagram's "existing In-app / Push" is one channel, not two
|
||
|
||
Core has exactly one notification sink: **content-free push tickles** to ntfy/UnifiedPush
|
||
(`website/server/src/utils/pushDispatch.js`). There is no notification table, no read API, no
|
||
mark-read, and no in-app list on either client. `android-app/.../ui/notifications/NotificationsScreen.kt`
|
||
is a *preferences* screen. `module-uo`'s SSE stream (`server/utils/shardBroadcast.js`) is a live game
|
||
feed, not an inbox.
|
||
|
||
So "add Email as a third channel" is really **add two channels to a system that has one**, and the
|
||
in-app one needs its own storage, read API and two client surfaces.
|
||
|
||
### 0.2 Email is already two-thirds of an engagement system — scoped to Teams
|
||
|
||
The brief frames the current email implementation as "the Gmail OAuth2 code". Gmail OAuth2 is only the
|
||
*transport*. Sitting on top of it, `utils/teamNotify.js` + `utils/teamDigestWorker.js` +
|
||
`team_notification_prefs` already implement:
|
||
|
||
| Engagement concern | Where it already lives |
|
||
| --- | --- |
|
||
| Trigger → recipient selection | `teamNotify.js:recipientIds` / `emailRecipients` via the Team access resolver |
|
||
| Per-user opt-in, per-scope | `team_notification_prefs.email_mode ENUM('off','digest','immediate')` (schema.sql:1285) |
|
||
| Immediate vs. digest scheduling | `teamNotify.emailImmediate` and `teamDigestWorker.tick` |
|
||
| Digest windowing + clamping | `teamDigestWorker.clampSince`, `MAX_LOOKBACK_MS`, `MAX_ITEMS` |
|
||
| One-click unsubscribe | `utils/unsubscribeToken.js` (stateless HMAC) + RFC 8058 `List-Unsubscribe-Post` |
|
||
| "Off unless configured" gate | `mailer.isConfigured()` checked *before* the recipient query |
|
||
| Multi-sink fan-out from one computed audience | `teamNotify.forumPost` → push + email + Discord bridge |
|
||
|
||
This is the prototype of the system being scoped. Building beside it would give the deployment two
|
||
unsubscribe mechanisms and two digest workers. Hence decision 2.
|
||
|
||
### 0.3 The `uo.house.idoc_warning` payload does not exist on the wire, and one field of it is not knowable
|
||
|
||
`house.decay` carries (`docs/link/INTEGRATION.md:207`,
|
||
`servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs:199`):
|
||
|
||
```
|
||
serial, from, to, map, x, y, z, region, name, ownerSerial, ownerAcct, ban{x,y,z}, builtOn, lastRefreshed
|
||
```
|
||
|
||
Against the brief's example payload:
|
||
|
||
| Brief field | Reality |
|
||
| --- | --- |
|
||
| `house` | ✅ `name` (from the house sign) |
|
||
| `location` | ✅ `map`, `x/y/z`, `region`, plus `ban` (where you stand to read the sign) |
|
||
| `decay_status` | ✅ `to` |
|
||
| `character` | ❌ **not emitted** — but trivially available: `WriteDecay` already reads `house.Owner`, so `owner.Name` is one line |
|
||
| `shard` | ❌ **not an event field, and should not become one** — one deployment is one shard; core already has `ctx.settings.getInstanceName()` |
|
||
| `next_stage` | ❌ **not emitted** — obtainable, see below |
|
||
| `estimated_collapse` | ❌ **not emitted, and not exactly knowable in advance** — see below |
|
||
|
||
**Why `estimated_collapse` is the hard one.** ServUO runs the *dynamic* decay system on any modern
|
||
shard (`Scripts/Multis/DynamicDecay.cs`: `Enabled => Core.ML`). Under it, each stage's duration is
|
||
drawn at random **when that stage is entered** (`GetRandomDuration`, e.g. `Greatly` = 1–2 days,
|
||
`IDOC` = 12–24 h). `BaseHouse.NextDecayStage` (`BaseHouse.cs:37`) is therefore an exact, already-persisted
|
||
timestamp for the *next* transition — but a collapse time two stages out does not exist yet, even
|
||
inside the game.
|
||
|
||
Consequences for the design:
|
||
|
||
- **`next_stage` is exact and cheap** — emit `NextDecayStage`.
|
||
- **`estimated_collapse` is exact only once the house is at IDOC**, where `NextDecayStage` *is* the
|
||
collapse time. Before that it can only be an envelope (min/max from the remaining stage table).
|
||
Emit it as `estimatedCollapse` only when `to == "IDOC"`, plus an optional
|
||
`estimatedCollapseMin`/`Max` envelope earlier — never a single number that reads as a promise.
|
||
- Under the legacy static path (`GetOldDecayLevel`, `BaseHouse.cs:205`) decay is a pure function of
|
||
`lastRefreshed + DecayPeriod`, and `lastRefreshed` is already on the wire. The **`decayPeriod`** is
|
||
not, and should be added so a consumer can compute stages without hardcoding 5 days.
|
||
|
||
**And the current mapping fires at the wrong stage for the brief's own example.** The brief's story is
|
||
"house becomes greatly damaged". `module-uo/server/config/shardStreams.js` maps `house.decay` to a
|
||
stream only when `String(event.to).toUpperCase() === 'IDOC'` — the *final* stage, which is 12–24 h from
|
||
collapse. A "greatly damaged" warning needs a `Greatly` transition mapping. That part needs **no
|
||
protocol change at all** and can ship long before the bump lands.
|
||
|
||
### 0.4 Event-name collision handling already exists — the brief's §6 forward-compat note is already satisfied
|
||
|
||
The brief asks for a one-line note that a module-id prefix would be needed *if* multi-module ever
|
||
happens. It already happened:
|
||
|
||
- `modules/registries.js:namespaced()` **requires** every stream id and announce-leg id to start with
|
||
`<owner>.`, with a small grandfathering allowlist (`LEGACY_STREAM_IDS`, `LEGACY_LEGS`) for the seven
|
||
pre-module-system ids.
|
||
- `registries.apply()` throws on any collision, **naming the current holder**, before it commits a
|
||
single claim.
|
||
- `modules/loader.js` cross-checks mounts, tables and slots across *all* loaded modules
|
||
(`for (const other of modules.values())`), so N modules is a supported configuration, not a future one.
|
||
|
||
`uo.house.idoc_warning` is therefore already the house style, and the trigger registry gets collision
|
||
handling for free by reusing `namespaced()` verbatim. **This is a resolved item, not a forward-compat
|
||
note.** The genuine forward-compat note is elsewhere — see §7.3.
|
||
|
||
### 0.5 `MODULE_API_VERSION` 1.6.0 is on `main` now, so this needs a real bump
|
||
|
||
`MODULE_API.md` §1.1 currently argues that additions may join 1.6.0 in place because "1.6.0 has only
|
||
ever been on `edge`". That is stale: `git show main:server/src/modules/version.js` reads `1.6.0`. The
|
||
Teams cutover landed it.
|
||
|
||
So the engagement additions take **1.7.0** — additions only (`api.registerEventTriggers`, `ctx.events.emit`,
|
||
`ctx.inbox.push`), no removal, no changed signature, so minor by the §1.1 table. `module-uo`'s
|
||
`coreApi: "^1.3.0"` still resolves.
|
||
|
||
---
|
||
|
||
### 0.6 A UNIQUE email is not a one-line ALTER — it breaks three paths and can stop a boot
|
||
|
||
Decision 6 (opt-in only, unique addresses) reads like a schema tweak. It is not. `users.email` is
|
||
`VARCHAR(255) NULL` with **no** unique index, and `schema.sql:24` says so deliberately: *"Optional
|
||
contact email (players). Not unique — SSO emails may repeat."* Adding the index touches registration,
|
||
SSO provisioning and the upgrade path. All four findings below were read out of the tree on 2026-08-28.
|
||
|
||
**1. The boot-time `ALTER` is how schema reaches a deployment, and it would fail loudly.** Upgrades ride
|
||
the idempotent `ALTER TABLE … IF NOT EXISTS` block at `schema.sql:1409+`, executed by `ensureSchema()`
|
||
on **every** boot (`server/src/server.js:66`). `ADD UNIQUE INDEX` against a table that already holds
|
||
duplicate addresses errors, `ensureSchema()` throws, and **the site does not start**. A de-duplication
|
||
step must run before the index, in the same release — see Phase 1b.
|
||
|
||
**2. `isDuplicateUsername()` does not inspect which index collided.** `users.model.js:24` is
|
||
`err.code === 'ER_DUP_ENTRY' || err.errno === 1062` and nothing more. Two callers misread an email
|
||
collision as a username collision the instant the index exists:
|
||
|
||
| Site | Today | After `UNIQUE(email)`, unfixed |
|
||
| --- | --- | --- |
|
||
| `auth.controller.js:137` (register) | 409 *"That username is already taken."* on a genuine username race | Same message for a duplicate **email** — wrong, and it misattributes the conflict to the one field the user did not collide on |
|
||
| `sso.controller.js:197` (`provisionSsoPlayer`) | Retries the next username suffix on collision | Retries **usernames** for an **email** conflict, which can never clear; burns `PROVISION_MAX_TRIES` and returns `null`, so SSO sign-up fails opaquely with the log blaming usernames |
|
||
|
||
The fix is to distinguish the constraint (read the index name off the driver error) before Phase 1b adds
|
||
the index — not after.
|
||
|
||
> **Amended 2026-08-29 (Phase 1b, as built).** This table names **two** callers of
|
||
> `isDuplicateUsername()`. There are **five**, and the three it omits fail worse than the two it
|
||
> names — `invite.controller.js` accepts an invite to an address already held and fails *after* the
|
||
> invitee has clicked the link and chosen a password, while `admin.controller.js` `createUser` and
|
||
> `updateUser` had **no catch at all** and turned a duplicate address into an opaque 500 for an admin
|
||
> who could see nothing wrong with the form. (`auth/account.controller.js` changeUsername is the
|
||
> fifth and is username-only, so it was already correct.) All five are handled; each answers
|
||
> differently on purpose, because a public form, an authenticated IdP callback, a half-completed
|
||
> invite and an admin screen do not owe the same person the same amount of truth.
|
||
>
|
||
> The index name is available **only in the driver's message text** — the mariadb connector exposes
|
||
> no structured field for it — so the discrimination is a regex over `for key '…'`, with its own
|
||
> test. That message also embeds the bound parameters, so on an email collision it *contains the
|
||
> address*: a second, independent reason these errors must never be echoed to a client.
|
||
|
||
**3. SSO auto-provisioning is the source of the duplicates, and CLAUDE.md is stale about it.** CLAUDE.md
|
||
states *"identities are never auto-provisioned"*. `provisionSsoPlayer` (`sso.controller.js:197`) does
|
||
exactly that whenever `player_registration ∈ {sso, both}`, writing `profile.email` straight into
|
||
`users.email`. It also sets `emailVerified: Boolean(profile.email)` — **verified because an address was
|
||
present, not because the IdP asserted `email_verified`**. That matters twice over: it manufactures the
|
||
duplicates Phase 1b must clean up, and it makes `email_verified` too weak a signal to arbitrate *which*
|
||
duplicate wins (which is why §7.1 Q1's answer is oldest-wins, not verified-wins).
|
||
|
||
**4. There is no self-serve email flow at all.** No route lets a user set or change their own address
|
||
after signup — `router/v1/auth/me.routes.js`, the self-service surface, has none, and
|
||
`users.model.js:72`'s `update()` is reached only by admin user management. An address is captured once,
|
||
at registration or SSO provisioning, and is thereafter unchangeable by its owner. A verification gate
|
||
presupposes a change-and-verify flow, so Phase 1b builds one; it is not an add-on to an existing
|
||
screen.
|
||
|
||
> Read out of the tree on 2026-08-28, when self-service had *three* URL surfaces onto one controller
|
||
> (`/auth/me/account/*`, `/player/account/*`, `/admin/account/*`), so a new field meant writing it
|
||
> three times. **The two smaller ones were deleted on 2026-08-29** — see Phase 1a below. Phase 1b's
|
||
> change-and-verify routes are written once, on `/auth/me/account`.
|
||
|
||
**One consequence for the error surface.** A unique constraint needs a user-facing failure, and the
|
||
obvious wording (*"that email is already registered"*) makes account existence queryable — a step back
|
||
from a posture the codebase holds deliberately elsewhere (`passwordReset.controller.js` answers a generic
|
||
200 *"to avoid account enumeration"*). §7.1 Q1 settles it: the message stays generic, the real reason is
|
||
logged not returned, the endpoint stays rate-limited, and the failure is **not** fed to the bot scorer —
|
||
an honest typo on a taken address must not push a legitimate user toward an IP ban.
|
||
|
||
---
|
||
|
||
## Part 1 — Current-state map
|
||
|
||
### 1.1 Notification system, end to end
|
||
|
||
```
|
||
registerNotificationStreams() [modules/registries.js]
|
||
│
|
||
config/coreStreams.js (news.post, team.*) ──┤ ← core, via registerCore()
|
||
module-uo/config/shardStreams.js (7 ids) ──┘ ← module, via api.*
|
||
|
||
catalog read back through registries.allStreams()
|
||
│
|
||
GET /auth/me/notifications/streams ──────────────────► web + Android
|
||
GET·PUT /auth/me/notifications/subscriptions ─────────► notification_subscriptions
|
||
│
|
||
shard event ──► module-uo/utils/shardPush.js ─┤
|
||
news publish ─► posts controller ─────────────┤──► pushDispatch.publish(streamId,{ref,ownerUserId})
|
||
team event ───► utils/teamNotify.js ──────────┘──► pushDispatch.publishToUsers(streamId,{ref,userIds})
|
||
│
|
||
push_devices rows (SSRF-gated endpoints)
|
||
│
|
||
POST {stream, ref} ──► ntfy / UnifiedPush ──► app wakes, PULLS content
|
||
```
|
||
|
||
**Files.**
|
||
|
||
| Concern | File |
|
||
| --- | --- |
|
||
| Catalog registry (core + modules) | `server/src/modules/registries.js` — `registerNotificationStreams`, `allStreams`, `isValidStream`, `personalStreams` |
|
||
| Core's own streams | `server/src/config/coreStreams.js` — `news.post` + four `team.*` |
|
||
| Module streams | `module-uo/server/config/shardStreams.js` — seven grandfathered ids + `mapShardEvent` |
|
||
| Fan-out | `server/src/utils/pushDispatch.js` — `publish` (all-subscribers / one-owner), `publishToUsers` (computed set), `isAllowedEndpoint` (SSRF gate) |
|
||
| Shard → push adapter | `module-uo/server/utils/shardPush.js` — resolves `ownerAcct` → website user via `shardLinks` |
|
||
| Team fan-out | `server/src/utils/teamNotify.js` — one audience, three sinks |
|
||
| Self-service API | `server/src/router/v1/auth/notifications.controller.js` + `notifications.routes.js` |
|
||
| Subscriptions model | `server/src/model/notificationSubs/` |
|
||
| Devices model | `server/src/model/pushDevices/` |
|
||
| Android surface | `android-app/.../data/api/NotificationsApi.kt`, `dto/NotificationsDto.kt`, `ui/notifications/` |
|
||
|
||
**Data model.**
|
||
|
||
```sql
|
||
-- schema.sql:411
|
||
push_devices(id, user_id, transport ENUM('unifiedpush','fcm'), endpoint, platform, created_at, last_seen_at)
|
||
-- schema.sql:428
|
||
notification_subscriptions(user_id, stream_id VARCHAR(64), created_at, PRIMARY KEY(user_id, stream_id))
|
||
-- schema.sql:1285
|
||
team_notification_prefs(user_id, team_id, muted, email_mode ENUM('off','digest','immediate'), last_digest_at, updated_at)
|
||
```
|
||
|
||
**Can it cleanly take Email as a third channel? Partly — and the coupling is in one place.**
|
||
|
||
- ✅ **The catalog is channel-neutral.** A stream entry is `{ id, label, description, personal, requiresLinkedAccount }`.
|
||
Nothing in it is push-specific. It can describe an email or in-app subscription unchanged.
|
||
- ✅ **The registry mechanism generalizes.** `stage()` / `apply()` / `namespaced()` are about *claims and
|
||
collisions*, not about push.
|
||
- ❌ **`notification_subscriptions` has no channel dimension.** `PRIMARY KEY (user_id, stream_id)` means a
|
||
subscription is a boolean, and "subscribed" currently means exactly "push me a tickle".
|
||
- ❌ **The wire shape is frozen by a shipped client.** `NotificationSubscriptionsDto` is
|
||
`{ streams: List<String> }` and the app PUTs the whole set. Turning that array into objects breaks
|
||
every installed app. (The DTO ignores *unknown keys*, so purely **additive** fields are safe — this
|
||
is recorded in the app's own comments.) A per-channel model must therefore arrive as a **new
|
||
endpoint**, with the old one preserved as the push projection.
|
||
- ❌ **Email opt-in semantics differ from push, deliberately.** `team_notification_prefs` documents the
|
||
asymmetry in its own DDL comment: push is opt-*out* (`muted` defaults 0), email is opt-*IN*
|
||
(`email_mode` defaults `'off'`), because digest-by-default would start mailing everyone the moment an
|
||
operator connects a mailbox. Any unified model must keep per-channel defaults, not one shared default.
|
||
|
||
**Self-hosted ntfy / UnifiedPush integration points** (email must sit beside these, not duplicate them):
|
||
|
||
- `pushDispatch.isAllowedEndpoint` — HTTPS-only, private-host denylist, plus an origin allow-set from
|
||
`NTFY_ALLOWED_ORIGINS` / `NTFY_BASE_URL`. **There is no hardcoded default host.** Empty allow-set is
|
||
the dev fallback.
|
||
- `NTFY_PUBLISH_TOKEN` — optional bearer for the relay.
|
||
- The **content-free tickle** invariant: `{ stream, ref }` and nothing else, because ntfy is treated as
|
||
an untrusted relay. Email deliberately breaks that rule (a mailbox is a destination the recipient
|
||
chose) and `teamNotify.js`'s header comment is the standing argument for why the asymmetry is the
|
||
security model rather than an inconsistency. **The engagement system must preserve this per channel,
|
||
not flatten it.**
|
||
|
||
### 1.2 Current email implementation
|
||
|
||
**Transport.** `server/src/utils/mailer.js` (267 lines). nodemailer over `smtp.gmail.com:465` with
|
||
`auth.type: 'OAuth2'`. Client id/secret are *reused from the `google` auth_providers row*; only the
|
||
refresh token is email-specific. `buildTransport()` returns `null` when unconfigured, and every sender
|
||
handles that itself.
|
||
|
||
**Config.** `email_config` singleton (schema.sql:336) — `provider` (already a `VARCHAR(20)` defaulting
|
||
`'gmail_oauth2'`, so the column is ready for a second provider), `enabled`, `sender_email`, `sender_name`,
|
||
`refresh_token_enc` (AES-256-GCM via `utils/secretBox.js`), `status`, `status_detail`, `last_verified_at`.
|
||
Admin-managed, never env — `docs/website/BACKEND_DESIGN.md` §7.
|
||
|
||
**Admin API.** `router/v1/admin/email.router.js` — six routes: `GET·PUT /config`, `GET /connect/start`,
|
||
`GET /connect/callback`, `POST /test`, `POST /disconnect`. Client: `client/src/routes/admin/views/EmailDelivery.jsx`.
|
||
|
||
**Every caller — the full migration surface.** Six call sites, five sender functions:
|
||
|
||
| Caller | Function | Failure contract |
|
||
| --- | --- | --- |
|
||
| `router/v1/public/public.controller.js:156` (contact form) | `sendContactMessage` | **Never throws when unconfigured** — returns `{sent:false, fallback:'mailto', email}` and the client renders a `mailto:` link |
|
||
| `router/v1/admin/emailConfig.controller.js:188` (admin "Send test") | `sendTest` | Throws `NOT_CONFIGURED` / `NO_RECIPIENT`; 502 to the admin |
|
||
| `router/v1/admin/invites.controller.js:46` | `sendInvite` | Returns `{sent:false, reason:'NOT_CONFIGURED'}` so the admin gets the accept link to share by hand |
|
||
| `router/v1/auth/passwordReset.controller.js:49` | `sendPasswordReset` | Returns `{sent:false, …}`; caller still answers a generic 200 to avoid account enumeration |
|
||
| `utils/teamNotify.js:234` (immediate) | `sendTeamNotification` | **Never throws at all** — logged and swallowed; the forum write already returned |
|
||
| `utils/teamDigestWorker.js:73` (digest) | `sendTeamNotification` | Same; return value gates the `last_digest_at` stamp |
|
||
|
||
Nothing in `website/bot`, `module-uo`, or any other repo sends mail. `mailer` is not on `ctx` — **modules
|
||
already cannot send email**, which matches the target architecture.
|
||
|
||
**Hardcoded assumptions the abstraction has to remove:**
|
||
|
||
1. **One provider, compiled in.** The Gmail host, port and OAuth2 auth type are literals in
|
||
`buildTransport()`. `email_config.provider` exists but nothing reads it.
|
||
2. **The credential shape is Gmail's.** One `refresh_token_enc` column plus a borrowed OAuth client.
|
||
SMTP needs host/port/secure/user/password; SES needs a region and IAM keys; Mailgun/SendGrid need a
|
||
domain and an API key. None of those fit the current column set.
|
||
3. **One transport built per send.** `buildTransport()` runs on every call — a fresh DB read, a fresh
|
||
decrypt and a fresh nodemailer transport per message, with no pooling. Fine for a password reset;
|
||
the serial per-recipient loop in `emailImmediate` is explicitly a rate-limit workaround for it.
|
||
4. **Text-only, composed inline.** Every body is a template literal inside `mailer.js`. There is **no
|
||
HTML part anywhere** and no template storage. `sendTeamNotification` builds its body by pushing lines
|
||
into an array.
|
||
5. **Synchronous send, no queue, no retry.** A send either succeeds inside the request/tick or is lost.
|
||
`recordStatus` writes the last outcome to a singleton column — there is no per-message record, so
|
||
"did user X get the IDOC mail?" is unanswerable today.
|
||
6. **`recordStatus` is global.** One transient failure sets `email_config.status='error'` for the whole
|
||
deployment, from any of six unrelated call sites.
|
||
7. **No suppression, no bounce handling, no verification gate.** `users.email` is **not unique**
|
||
(SSO addresses repeat) and `users.email_verified` is set to 1 only on invite-accept
|
||
(`invite.controller.js:55`) and SSO (`sso.controller.js:208`). Self-registration accepts an address
|
||
and leaves it unverified (`auth.controller.js:124`). Today only *transactional* mail goes out, so
|
||
this is tolerable; the moment game events drive volume it is a deliverability and complaint problem.
|
||
|
||
### 1.2a Removing Gmail OAuth2 — the deletion inventory *(decision 4)*
|
||
|
||
Gmail OAuth2 is not a transport we keep beside SMTP. It goes. That is a **subtraction with a live
|
||
deployment behind it**, so the exact surface is worth writing down before anyone starts.
|
||
|
||
**Server — deleted:**
|
||
|
||
| Thing | Where |
|
||
| --- | --- |
|
||
| `GET /admin/email/connect/start` | `router/v1/admin/email.router.js` |
|
||
| `GET /admin/email/connect/callback` | same |
|
||
| `connectStart` / `connectCallback` | `router/v1/admin/emailConfig.controller.js` (~half the file's 211 lines) |
|
||
| The `email_oauth_tx` signed cookie, the PKCE verifier and CSRF nonce plumbing | same controller |
|
||
| `EMAIL_SCOPE = 'https://mail.google.com/ openid email'` | same |
|
||
| `googleClient()` — the borrowed `google` auth_providers credential read | same |
|
||
| The OAuth2 nodemailer transport (`auth.type: 'OAuth2'`, `smtp.gmail.com:465` literals) | `utils/mailer.js:buildTransport` |
|
||
| `emailConfig.getWithSecret()`'s `refreshToken` decrypt | `model/emailConfig/emailConfig.model.js` |
|
||
|
||
**Client — deleted:** the "Connect Gmail" button and `connect()` handler, the
|
||
`?email_connected` / `?email_error` redirect-banner handling, and the five Gmail-specific error strings
|
||
(`bad_state`, `no_client`, `no_refresh_token`, …) in `client/src/routes/admin/views/EmailDelivery.jsx`.
|
||
Replaced by an ordinary credential form driven by the transport's `credentialFields` (§3.1).
|
||
|
||
**Database — deprecated, not dropped.** `email_config.refresh_token_enc` and `provider` stay as columns
|
||
(additive-only discipline; core's `schema.sql` contains exactly one `DROP` and it is documented as such).
|
||
They stop being read. A later cleanup PR may drop them once every deployment has booted past the cutover.
|
||
|
||
**Three consequences worth naming:**
|
||
|
||
1. **SSO is unaffected.** The `google` auth_providers row exists for SSO in its own right; email merely
|
||
*borrowed* its client id/secret. Removing the borrow removes a coupling — one of the better side
|
||
effects of this decision, since today an admin who rotates the Google SSO secret silently breaks
|
||
outbound mail with no indication that the two are related.
|
||
2. **`sender_email` changes meaning.** Today it is read back from Google's `userinfo` and is therefore
|
||
guaranteed to be an address the mailbox owns. Under SMTP it is **operator-typed**, so nothing stops a
|
||
mismatch between the envelope sender and what the SMTP account is permitted to send as — which is a
|
||
silent deliverability failure (SPF/DMARC), not an error. The admin "Send test" path has to become the
|
||
real verification, and its failure text has to be specific enough to diagnose a rejected `From`.
|
||
3. **The live deployment goes dark at cutover unless the operator acts.** UOMysticmoon is connected via
|
||
Gmail OAuth2 today. On upgrade, `transport` backfills to `smtp` with **no credentials**, so
|
||
`isConfigured()` returns false and every sink politely does nothing — the contact form falls back to
|
||
`mailto`, invites surface a copyable link, password resets still answer a generic 200. Nothing breaks
|
||
loudly, which is precisely the risk: **email silently stops and nobody is told.** Phase 1 therefore
|
||
owes three things: an admin dashboard warning when `transport='smtp'` and credentials are absent, a
|
||
release note naming the required action, and `INSTALL.md`-style operator guidance. Gmail itself
|
||
remains usable as plain SMTP (`smtp.gmail.com:587` with an app password), which is the shortest
|
||
migration path for the existing deployment and should be the documented one.
|
||
|
||
### 1.3 Module contract fit
|
||
|
||
**The registration surface, as it stands** (`modules/registries.js`, `MODULE_API.md` §2.4):
|
||
|
||
| Call | Shape | Cardinality |
|
||
| --- | --- | --- |
|
||
| `registerRoutes` | tier → prefix → router | declared in `module.json`, cross-checked |
|
||
| `registerExtension(slot, router)` | fills a **core-declared** slot | one filler per slot |
|
||
| `registerNotificationStreams([…])` | push catalog entries | many, `<owner>.`-prefixed |
|
||
| `registerAnnounceLeg({leg, label, dispatch, classify})` | a **delivery leg** with retry classification | many, `<owner>.`-prefixed |
|
||
| `registerPostHook({onSaved, onDeleted})` | idempotent state mirroring | one per owner |
|
||
| `registerTeamProvider({…})` | core **calls the module and waits** | one per deployment |
|
||
| `registerSlashCommands([…])` | definition travels, handler stays | many, *not* namespaced (Discord grammar) |
|
||
|
||
**Is there a natural extension point? Yes — and `registerAnnounceLeg` is the closest structural match,
|
||
but for the *delivery* half, not the *trigger* half.**
|
||
|
||
The engagement system needs **two** things a module does not have today:
|
||
|
||
1. **A way to declare a domain event and its data contract** — nothing like this exists. `registerNotificationStreams`
|
||
declares a *subscription toggle*; it carries a label and two booleans, and no statement whatsoever
|
||
about payload. A module cannot tell core what a `uo.house.idoc_warning` *contains*.
|
||
2. **A way to emit one.** Today `ctx.push.publish(streamId, {ref, ownerUserId})` is the only outbound
|
||
path, and it is deliberately content-free. A module that wanted to send a *rendered* message has to
|
||
go through `pushDispatch`, which will not carry the data.
|
||
|
||
So this needs a **new registration surface**, not a reuse. It should be modelled on `registerNotificationStreams`
|
||
(shape-checked at the call, collision-checked at `apply()`, `<owner>.`-prefixed) rather than on
|
||
`registerAnnounceLeg` (which is a *core-calls-module* dispatch with retry classification — the wrong
|
||
direction: a module *reports* an event, it does not deliver one).
|
||
|
||
**What module-uo exposes to core today.** Only what the registries take: seven stream ids, one announce
|
||
leg, one extension router, a Team provider, one slash command, five route mounts. Everything else — 27
|
||
`shard_*` tables, the sidecar client, the visibility framework — is module-internal (`MODULE_API.md` §1.2).
|
||
There is **no data channel from a module into core carrying structured game data**. `ctx.teams.activity.push`
|
||
is the nearest thing, and it is instructive: core stores `summary` **already rendered by the module**,
|
||
because core cannot phrase a sentence in a vocabulary it does not know, and `kind`/`payload` are opaque.
|
||
|
||
The engagement system deliberately takes the **opposite** position — core *does* interpolate module data
|
||
into a template — which is only safe because the operator authors the template and the module *declares*
|
||
the variables. That is the whole reason §4.3's variable contract has to exist rather than being optional.
|
||
|
||
### 1.4 Job, scheduling and queue infrastructure that already exists
|
||
|
||
**There is no cron. There is no Redis, no BullMQ.** The stack has exactly two patterns:
|
||
|
||
**(a) In-process `setInterval` + `unref()` + `stop()`, wired into `server.js` start/shutdown.** Six of them:
|
||
`announceWorker`, `teamDigestWorker`, `teamActivityPrune`, `teamForumUploadSweep`, `teamVoiceSync`, and
|
||
`middleware/botScore`'s sweeper.
|
||
|
||
**(b) A durable job table with per-leg backoff** — `announce_jobs` + `announce_job_legs` (schema.sql:737/757),
|
||
swept by `announceWorker.tick`. This is a real outbox: `status`, `attempts`, `last_error`,
|
||
`next_attempt_at`, `INDEX idx_announce_leg_due (status, next_attempt_at)`, and a `classify()` that maps a
|
||
delivery result to done / retry / terminal. **It was explicitly designed so a module can add a delivery
|
||
leg without altering a core table** — the child-table shape and the `VARCHAR` (not `ENUM`) `leg` column
|
||
are both justified in the DDL comment on exactly those grounds.
|
||
|
||
**Which of the brief's two scheduling use cases each pattern serves:**
|
||
|
||
- **(a) Delayed send per event** — "wait 30 min in case the player fixes it". Needs a durable row with a
|
||
`due_at`, and — the part the brief does not name but which is the actual point — the ability to
|
||
**cancel** a pending row when a later event resolves the condition. `announce_jobs` is the exact
|
||
precedent; this needs its own table because a module cannot alter a core one and the payload differs.
|
||
- **(b) Batched / digest** — `teamDigestWorker` already does this, and its header comment is the design
|
||
argument: it **computes at send time and keeps no queue**, whose three consequences are (1) a
|
||
deployment down for two days sends *one* digest, not a replay, (2) content hidden after it was written
|
||
is not in the query so not in the mail, and (3) **a user who lost access between the post and the send
|
||
is no longer in the recipient set** — which it calls out as the one that would have been a security bug.
|
||
|
||
**That split is the answer to the brief's §4 scheduling question: (a) is a queue, (b) must not be.**
|
||
Copying (a) for digests would reintroduce all three problems.
|
||
|
||
**One gap in both patterns: neither is multi-instance safe.** No advisory lock, no leader election, no
|
||
`SELECT … FOR UPDATE SKIP LOCKED`. Two app containers means two digest sweeps and two announce workers.
|
||
The current deployment is single-instance (`website/docker-compose.yml`), so this is latent — but an
|
||
engagement mailer doubles messages rather than doubling reads, so it becomes visible here first.
|
||
|
||
**Reusable primitives worth naming:**
|
||
|
||
- `utils/unsubscribeToken.js` — a stateless HMAC whose whole capability is "set `muted` for one (user, Team)
|
||
pair". Generalizes to (user, channel, trigger) with no structural change.
|
||
- `utils/settingsJson.js` — the fail-safe JSON-settings parse (malformed ⇒ *absent*, never an error).
|
||
- `blocks/registry.js` + `blocks/types/*` + `sanitizeBlocks.js` + `validateBlocks.js` — a versioned,
|
||
schema-validated, sanitize-on-save visual block system already driving `pages.blocks` (MEDIUMTEXT JSON).
|
||
This is the template editor's foundation; see §4.4.
|
||
- `utils/secretBox.js` — AES-256-GCM for provider credentials at rest.
|
||
- `model/settings/settings.model.js` + the `settings` key/value table — right for a handful of scalars,
|
||
**wrong for cooldowns** (see §4.1).
|
||
|
||
---
|
||
|
||
## Part 2 — Gap list against the target architecture
|
||
|
||
```
|
||
Game Module ──► Domain Events + Data ──► Core ──► Engagement ──► Preferences ──► Template ──► Delivery
|
||
```
|
||
|
||
| # | Layer | Gap | Severity |
|
||
| --- | --- | --- | --- |
|
||
| G1 | Module → events | No way for a module to **declare** a domain event or its payload contract | Blocking |
|
||
| G2 | Module → events | No way for a module to **emit** one carrying data (`ctx.push.publish` is content-free by design) | Blocking |
|
||
| G3 | Events → Core | No event **catalog** surface for the admin UI to enumerate triggers | Blocking |
|
||
| G4 | Engagement | No **rules** concept at all — today a trigger's consequence is hardcoded in the emitting file | Blocking |
|
||
| G5 | Engagement | No **cooldown / rate-limit** state of any kind, per-recipient or otherwise | Blocking (brief calls this out as pre-first-trigger) |
|
||
| G6 | Engagement | No **delayed-send** queue and no cancellation | High |
|
||
| G7 | Engagement | Digest exists but is **Team-shaped**, not generic (`last_digest_at` lives on `team_notification_prefs`) | High |
|
||
| G8 | Preferences | `notification_subscriptions` has **no channel dimension**; the shipped app's wire shape is frozen | Blocking |
|
||
| G9 | Preferences | Per-channel **defaults differ** (push opt-out, email opt-in) and there is nowhere to express that generically | High |
|
||
| G10 | Preferences | Unsubscribe is **Team-scoped** (`unsubscribeToken.sign(userId, teamId)`) | Medium |
|
||
| G11 | Template | **No template storage, no HTML part, no renderer, no preview, no plain-text fallback.** Every body is a string literal in `mailer.js` | Blocking |
|
||
| G12 | Template | No **variable contract** — nothing declares what a template may interpolate | Blocking (see §4.3) |
|
||
| G13 | Delivery | **One hardcoded provider**; `email_config.provider` is written but never read | Blocking |
|
||
| G14 | Delivery | Credential schema is Gmail-shaped (one refresh token + a borrowed OAuth client) | Blocking |
|
||
| G15 | Delivery | No per-message record — **no send log, no delivery status, no audit** | High |
|
||
| G16 | Delivery | No **suppression list**, no bounce/complaint handling, no unverified-address policy | High |
|
||
| G17 | Channels | **In-app channel does not exist** — no table, no read API, no web surface, no app surface | Blocking (in scope) |
|
||
| G18 | Infra | Workers are **not multi-instance safe** — latent today, doubles *messages* under engagement | Medium |
|
||
| G19 | Contract | `MODULE_API_VERSION` must go 1.6.0 → **1.7.0** (§0.5) | Process |
|
||
| G20 | Wire | `house.decay` lacks `ownerName`, `nextStage`, `decayPeriod`, collapse estimate (§0.3) | High (in scope) |
|
||
| G21 | Ops | No **preview/test-send** path for a template against a real trigger payload | Medium |
|
||
| G22 | Delivery | Removing Gmail OAuth2 leaves the live deployment **silently unconfigured** — every sink degrades quietly, so email stops with no signal (§1.2a) | High (in scope) |
|
||
| G23 | Template | No **seeded default templates** — without them, "add a trigger" implies "and now author a template", and a fresh install mails nothing (§4.6.1) | High (in scope) |
|
||
| G24 | Engagement | An audience **ceiling** per trigger. `shardStreams.js` already filters sensitive kinds off the public push path; the engine needs the equivalent or a rule can widen a staff-only trigger to everyone (§8.6) | Blocking (security) |
|
||
| G25 | Engagement | No **time-based** trigger kind — "nothing happened for 30 days" is a periodic evaluator, not an event (§8.5) | Medium (design in Phase 2, build later) |
|
||
|
||
---
|
||
|
||
## Part 3 — The delivery abstraction (brief §5)
|
||
|
||
### 3.1 Name it `DeliveryChannel`, and split *channel* from *transport*
|
||
|
||
The brief asks whether the interface should be `EmailProvider` or something more generic. **Neither
|
||
alone.** Two axes are being conflated, and the current code conflates them too:
|
||
|
||
- A **channel** is *what kind of sink this is* — email, push, in-app, later Discord DM. It determines the
|
||
address kind (mailbox / endpoint URL / user id / snowflake), the render contract (subject + HTML + text
|
||
vs. `{stream, ref}` vs. an embed), the preference semantics, and whether content may ride at all.
|
||
- A **transport** is *how one channel actually delivers* — SMTP / Gmail-OAuth2 / Mailgun / SES / SendGrid
|
||
for email; ntfy-UnifiedPush / FCM for push.
|
||
|
||
Push already has this shape and nobody named it: `push_devices.transport ENUM('unifiedpush','fcm')` is a
|
||
transport column on a channel that has exactly one implementation today.
|
||
|
||
**Recommended surface:**
|
||
|
||
```js
|
||
// core-internal registry, mirroring modules/registries.js's shape
|
||
registerDeliveryChannel({
|
||
id: 'email', // 'email' | 'push' | 'inapp' | later 'discord.dm'
|
||
label: 'Email',
|
||
carriesContent: true, // false for push — enforces the tickle invariant structurally
|
||
defaultMode: 'off', // G9, expressed here once. All three are opt-IN as built —
|
||
// 'push opt-OUT' was wrong; see Phase 3's as-built
|
||
supportsDigest: true, // in-app and push are instant-only in v1
|
||
addressFor(userId), // → [{ address, meta }] ; email reads users.email, push reads push_devices
|
||
render(template, vars, ctx), // → the channel's own payload shape
|
||
deliver(address, payload), // → { ok, retryable, error } — never throws
|
||
})
|
||
|
||
registerMailTransport({
|
||
id: 'smtp', // 'smtp' | 'mailgun' | 'ses' | 'sendgrid' — NOT gmail_oauth2 (removed)
|
||
label: 'SMTP',
|
||
credentialFields: [...], // drives the admin form AND the encrypted credential blob
|
||
build(config), // → a nodemailer transport (or an API client)
|
||
verify(config), // → the admin "Send test" path
|
||
})
|
||
```
|
||
|
||
**Answering the brief's actual question: no, this will not need a breaking rename when Discord DM
|
||
arrives.** A Discord DM is a `registerDeliveryChannel({ id: 'discord.dm', carriesContent: true, … })`
|
||
whose `deliver` calls `utils/botInternalClient.js` — the bot-internal API already exists and
|
||
`utils/teamBridge.js` is the working precedent for core handing a composed message to the bot. Nothing
|
||
in the interface above says "email".
|
||
|
||
**One vocabulary warning.** The announce pipeline already calls a delivery a **leg**
|
||
(`registerAnnounceLeg`, `announce_job_legs.leg`). `channel` and `leg` will coexist and mean *nearly* the
|
||
same thing. They should stay distinct rather than being unified: a leg is a **one-shot delivery of one
|
||
artifact** with retry and terminal classification; a channel is a **per-recipient sink** with preferences,
|
||
addresses and digest semantics. `teamBridge.js`'s header already argues this distinction for the Discord
|
||
case ("one-shot, not queued… a notification is the moment it describes"). The design doc should say so
|
||
explicitly so nobody "tidies" them together later.
|
||
|
||
### 3.2 No phone-home — the existing posture is already correct, and the registry must preserve it
|
||
|
||
Confirmed, and there is nothing to fix — only something to not break:
|
||
|
||
- `email_config` is **DB-backed and admin-managed**, never env (`BACKEND_DESIGN.md` §7). There is no
|
||
default host, no default sender, and `status` starts `'unconfigured'`.
|
||
- `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. **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:**
|
||
|
||
1. No transport may ship a default host, endpoint, API base or sender. A transport with no operator
|
||
configuration is `unconfigured` and its channel is **off**, not defaulting to anything.
|
||
2. No engagement code may read an env var naming an external service that the operator did not set.
|
||
3. The "off unless configured" gate is checked before recipient resolution, per channel.
|
||
4. A CI guardrail: extend `scripts/checkModuleIdentifiers.js`'s sibling pattern with a check that no
|
||
file under `server/src/engagement/` contains a bare external hostname literal. (Cheap; the check
|
||
pattern and its self-test discipline already exist — see `test/checkModuleIdentifiers.test.js`, which
|
||
feeds the checker code it *must* reject precisely so a check cannot silently stop checking.)
|
||
|
||
---
|
||
|
||
## Part 4 — Proposed schema additions
|
||
|
||
All additive. All `CREATE TABLE IF NOT EXISTS` / `ALTER … ADD COLUMN IF NOT EXISTS`, replayed on every
|
||
boot, per `MODULE_API.md` §2.6's rules (which core's own `schema.sql` follows too). **Core tables, no
|
||
prefix** — every one of these is game-agnostic.
|
||
|
||
> **MariaDB trap, already learned twice in this codebase:** a `PRIMARY KEY` column is coerced `NOT NULL`,
|
||
> so "NULL means the default row" is unrepresentable in a PK. `team_integration_config` (schema.sql:1335)
|
||
> and `teams.active_key` both work around it with a surrogate key plus a generated column folding NULL onto
|
||
> a sentinel. Two tables below need the same treatment; both are flagged.
|
||
|
||
### 4.1 Cooldowns — its own table, not `settings`
|
||
|
||
The brief asks whether the `settings.model.js` JSON-value pattern is a reasonable fit. **No.**
|
||
`settings` is `(key VARCHAR(64) PRIMARY KEY, value TEXT)` — a single-row-per-key store read whole. Cooldown
|
||
state is high-cardinality (recipients × rules × subjects), written on every fire, and queried as
|
||
"is this one pair still cooling?". A JSON blob under one key would be a read-modify-write of the entire
|
||
deployment's cooldown state on every event, with a lost-update race between two concurrent triggers. It is
|
||
the wrong shape by an order of magnitude.
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS engagement_cooldowns (
|
||
rule_id INT NOT NULL,
|
||
user_id INT NOT NULL,
|
||
-- The SUBJECT the cooldown is about, opaque to core: a house serial, a vendor id, ''.
|
||
-- NOT NULL with a '' default, because this is a PRIMARY KEY column and MariaDB
|
||
-- would coerce a NULL one anyway. '' is "this rule cools per user, not per subject".
|
||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||
last_fired_at DATETIME NOT NULL,
|
||
fire_count INT NOT NULL DEFAULT 1,
|
||
PRIMARY KEY (rule_id, user_id, subject_key),
|
||
CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||
CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
INDEX idx_engc_sweep (last_fired_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
```
|
||
|
||
**Why `subject_key` is not optional.** "One IDOC mail per player per day" is the wrong rule — a player
|
||
with four houses decaying should hear about all four, once each. Cooling per (rule, user) alone silently
|
||
drops three of them. The module supplies `subject` on emit; core stores it opaquely.
|
||
|
||
`INDEX idx_engc_sweep (last_fired_at)` exists so a prune worker can drop rows older than the longest
|
||
configured cooldown — otherwise this table grows without bound, which is the failure mode
|
||
`teamActivityPrune` was written for.
|
||
|
||
**The check must not be a read-then-write**, or two concurrent emits both see an expired cooldown and
|
||
both send. The obvious single statement — `INSERT … ON DUPLICATE KEY UPDATE` with the interval guard in
|
||
the assignments, reading the answer out of `affectedRows` — **does not work against this codebase's
|
||
pool**, and Phase 4a is where that was found: the mariadb connector defaults `foundRows: true`, so a
|
||
no-op update reports 1 rather than 0 and every cooldown passes. What ships instead is a guarded UPDATE
|
||
(the interval in a WHERE clause, where a row either matches or does not) falling back to an
|
||
`INSERT IGNORE` for the first fire. See Phase 4a's as-built for the statements and the races.
|
||
|
||
### 4.2 Scheduling — a queue for delay, and deliberately no queue for digest
|
||
|
||
**(a) Delayed send ⇒ `engagement_outbox`.** Modelled on `announce_jobs`/`announce_job_legs`.
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS engagement_outbox (
|
||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||
rule_id INT NOT NULL,
|
||
trigger_id VARCHAR(96) NOT NULL, -- denormalized; survives a rule edit
|
||
user_id INT NOT NULL,
|
||
channel VARCHAR(32) NOT NULL, -- 'email' | 'push' | 'inapp' | … VARCHAR, never ENUM
|
||
subject_key VARCHAR(190) NOT NULL DEFAULT '',
|
||
payload JSON NOT NULL, -- the module's declared variables, snapshotted at emit
|
||
-- Idempotent enqueue. A sidecar reconnect that replays the same event must not
|
||
-- produce a second mail. Same reasoning as ctx.teams.activity.push's dedupeKey.
|
||
dedupe_key VARCHAR(190) NULL,
|
||
status ENUM('scheduled','sending','sent','failed','cancelled','suppressed') NOT NULL DEFAULT 'scheduled',
|
||
due_at DATETIME NOT NULL,
|
||
attempts SMALLINT NOT NULL DEFAULT 0,
|
||
last_error TEXT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
sent_at DATETIME NULL,
|
||
CONSTRAINT fk_engo_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE,
|
||
CONSTRAINT fk_engo_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
-- SCOPED, not global. One event legitimately becomes one row per (rule, user,
|
||
-- channel); a global unique index would admit the first recipient's row and
|
||
-- silently ignore every other. Corrected in Phase 4a — see its as-built.
|
||
UNIQUE KEY uq_engo_dedupe (rule_id, user_id, channel, dedupe_key),
|
||
INDEX idx_engo_due (status, due_at),
|
||
INDEX idx_engo_cancel (rule_id, user_id, subject_key, status)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
```
|
||
|
||
Three things this buys that a straight send does not:
|
||
|
||
- **`due_at` is the 30-minute grace window.** The worker sweeps `status='scheduled' AND due_at <= now`.
|
||
- **`status='cancelled'` is the actual point of that window.** `idx_engo_cancel` is what a *resolving*
|
||
event queries: a `house.decay` back up to `LikeNew` cancels every scheduled row for that
|
||
(rule, user, house). Without cancellation, a delay is just a late mail.
|
||
- **`dedupe_key` UNIQUE per (rule, user, channel) makes replay safe.** The sidecar has no
|
||
schema-migration mechanism and a reconnect backfills; an at-least-once feed must not become an
|
||
at-least-once mailer. The scope matters as much as the constraint: a dedupe key names the *event*,
|
||
and the event fans out to every recipient of every channel of every matching rule.
|
||
|
||
`channel` is `VARCHAR(32)` and not an `ENUM` for exactly the reason `announce_job_legs.leg` is —
|
||
the channel set is data, and a module (or a later core channel) must not require an `ALTER`.
|
||
|
||
**(b) Digest ⇒ no queue.** Keep `teamDigestWorker`'s compute-at-send-time design and generalize its
|
||
state, not its absence of one:
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS engagement_digest_state (
|
||
user_id INT NOT NULL,
|
||
channel VARCHAR(32) NOT NULL,
|
||
scope_key VARCHAR(190) NOT NULL DEFAULT '', -- '' = deployment-wide; a Team id for the Teams case
|
||
last_digest_at DATETIME NULL,
|
||
PRIMARY KEY (user_id, channel, scope_key),
|
||
CONSTRAINT fk_engd_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
INDEX idx_engd_due (channel, last_digest_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
```
|
||
|
||
`team_notification_prefs.last_digest_at` backfills into this with `channel='email'`,
|
||
`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
|
||
|
||
The brief asks whether anything in the codebase already does this. **Two things do, and they are the two
|
||
halves of the right answer:**
|
||
|
||
- **`blocks/registry.js`** — a registered definition carries `version` (a *prop-schema* version, bumped
|
||
when props change so a migration can transform older blocks), `schema: (props) => [errors]`, and
|
||
`sanitize: (props) => props` run on save *after* validation. That is the validation half.
|
||
- **`server/scripts/routeManifest.js` + `routes.manifest.json`, checked in CI with `--check`** — a
|
||
generated artifact committed to the repo, whose diff is the review signal. That is the drift half.
|
||
(`module-uo` carries its own `routes.manifest.json` for the same reason, and ships a prebuilt
|
||
`swagger-fragment.json` because core never has its sources to analyse — `MODULE_API.md` §6.1a.)
|
||
|
||
**Proposal: the trigger declaration carries its variables, and a generated manifest freezes them.**
|
||
|
||
```js
|
||
api.registerEventTriggers([{
|
||
id: 'uo.house.idoc_warning', // <owner>.-prefixed, checked by the existing namespaced()
|
||
label: 'House approaching collapse',
|
||
description: 'A player house dropped into a late decay stage.',
|
||
kind: 'event', // 'event' | 'scheduled' (G25); default 'event'
|
||
subjectKey: 'house', // which variable identifies the subject, for cooldowns
|
||
audience: 'owner', // the DEFAULT a rule is created with
|
||
ceiling: 'owner', // the widest a rule may EVER be given (G24, §5.1a)
|
||
version: 1, // bumped on a rename or a type change
|
||
variables: [
|
||
{ name: 'character', type: 'string', required: true, example: 'Darrow' },
|
||
{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' },
|
||
{ name: 'location', type: 'string', required: true, example: 'Britain, Trammel (1119, 1794)' },
|
||
{ name: 'decayStatus', type: 'string', required: true, example: 'Greatly' },
|
||
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' },
|
||
{ name: 'estimatedCollapse',type: 'datetime', required: false, example: '2026-09-01T04:00:00Z' },
|
||
],
|
||
}])
|
||
```
|
||
|
||
Four properties, each with a reason:
|
||
|
||
1. **Validated at emit, not at render.** `ctx.events.emit` checks the payload against the declaration.
|
||
A missing `required` variable or a wrong type is **dropped and logged in production, thrown in
|
||
development** — the same posture `ctx.teams.activity.push` takes ("a malformed item is dropped and
|
||
logged"), because this is called from inside a game-event handler and a storage problem of core's must
|
||
not become the module's control flow.
|
||
2. **The editor reads it, so autocomplete is real.** `GET /admin/engagement/triggers` serves the
|
||
declarations; the template editor offers exactly those names and refuses to save a template
|
||
referencing one that is not declared. That is G12 closed — the editor never blindly interpolates
|
||
module JSON.
|
||
3. **`example` is not decoration — it is the preview and the test-send.** Without it, previewing a
|
||
template requires a live game event, which is the reason template systems go untested.
|
||
4. **Drift is caught by a committed manifest.** `npm run engagement:manifest` writes
|
||
`server/engagement-triggers.json` (core's) and CI runs it with `--check`, exactly as
|
||
`routes:manifest -- --check` already gates the URL surface. Changing a variable's name or type
|
||
without regenerating is a red build; the diff is what a reviewer reads. **A module ships its own
|
||
prebuilt `engagement-triggers.json` in its bundle**, for the same reason it ships a prebuilt
|
||
swagger fragment: core never has its sources.
|
||
|
||
**Two corrections from building it (Phase 2).** `audience` and `ceiling` are two fields, not one: the
|
||
first is the default a rule is created with, the second is the maximum it may be raised to, and the
|
||
registry refuses a default that the ceiling does not permit. And `'computed'` is gone from the
|
||
`audience` vocabulary — a rule pointing at a composed segment says so by naming the segment, so a
|
||
third pseudo-value that means "look elsewhere" would be a value the ceiling arithmetic cannot compare.
|
||
Both fields take the same six-value vocabulary (§5.1a).
|
||
|
||
**`example` is enforced, not encouraged.** A variable without one is refused at registration. Property
|
||
3 above is right and a soft version of it is worth nothing: the moment one variable has no example,
|
||
previewing that template needs a live game event again.
|
||
|
||
**Versioning.** A variable's *addition* is additive and needs nothing. A **rename or a type change** breaks
|
||
every stored template referencing it, so a trigger declaration carries `version`, bumped like a block's
|
||
prop-schema version, and templates store the trigger version they were authored against. A template
|
||
pinned to an older version renders with a warning in the admin list rather than silently interpolating
|
||
`undefined`.
|
||
|
||
### 4.4 Templates — reuse the block registry, do not build a second editor
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS engagement_templates (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
`key` VARCHAR(96) NOT NULL UNIQUE, -- stable id a rule points at
|
||
name VARCHAR(160) NOT NULL,
|
||
trigger_id VARCHAR(96) NULL, -- NULL = a reusable/shared template
|
||
trigger_version INT NULL, -- what its variables were authored against (§4.3)
|
||
channel VARCHAR(32) NOT NULL, -- one template per channel; a rule names a set
|
||
subject VARCHAR(300) NULL, -- email only; may interpolate
|
||
blocks MEDIUMTEXT NOT NULL, -- JSON array — the pages.blocks pattern
|
||
text_body MEDIUMTEXT NULL, -- authored plain-text override; else generated
|
||
status ENUM('draft','published') NOT NULL DEFAULT 'draft',
|
||
-- A seeded template that the system itself depends on (password reset, invite).
|
||
-- Editable, NOT deletable — the pages.protected flag, for the same reason.
|
||
protected TINYINT(1) NOT NULL DEFAULT 0,
|
||
-- Which seed revision this row came from, and whether an operator has since
|
||
-- touched it. Together they let a later release ship an improved default
|
||
-- WITHOUT overwriting an operator's edits. See §4.6.
|
||
seed_key VARCHAR(96) NULL,
|
||
seed_version INT NULL,
|
||
customized TINYINT(1) NOT NULL DEFAULT 0,
|
||
updated_by INT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_engt_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||
INDEX idx_engt_trigger (trigger_id, channel, status)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
```
|
||
|
||
**Why blocks and not raw HTML.** The brief asks for a visual editor taking inspiration from the CMS
|
||
page builder and the hero editor. The CMS builder is *already* a registry-driven block system with
|
||
server-side prop validation and sanitize-on-save (`blocks/registry.js`, `blocks/types/*`,
|
||
`sanitizeBlocks.js`). Storing raw operator HTML would give up all of that and hand the renderer an
|
||
injection surface.
|
||
|
||
**But email needs its own block set, not the page one.** Page blocks emit modern CSS that mail clients
|
||
do not support. Register a parallel family — `email.heading`, `email.text`, `email.button`,
|
||
`email.divider`, `email.image`, `email.itemList` — that render to table-based, inline-styled HTML. The
|
||
registry is designed for exactly this: "adding a block later means adding ONE entry".
|
||
|
||
**Plain-text fallback is generated by default, overridable per template.** Every block type gets a
|
||
`toText(props)` alongside its renderer, so a text part always exists. `teamNotify.excerpt()` is the
|
||
existing markup-to-text helper and should move into that family rather than being duplicated.
|
||
|
||
**A `text_body` that is empty for a published template is a save-time error, not a runtime one** — a
|
||
mail with no text part is a spam-filter signal, and finding out at send time means finding out from a
|
||
deliverability report.
|
||
|
||
### 4.5 Rules, channel preferences, send log, suppression
|
||
|
||
```sql
|
||
-- What an operator actually configures: trigger → audience → template → timing.
|
||
CREATE TABLE IF NOT EXISTS engagement_rules (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
trigger_id VARCHAR(96) NOT NULL,
|
||
name VARCHAR(160) NOT NULL,
|
||
enabled TINYINT(1) NOT NULL DEFAULT 0, -- OFF by default; an operator turns it on
|
||
audience VARCHAR(32) NOT NULL DEFAULT 'owner',
|
||
audience_segment_id INT NULL, -- a composed segment (§5.1a); NULL = the plain audience above
|
||
-- §7.1 Q3: the hard stop that makes operator-editable rules safe to choose over
|
||
-- code-registered ones. Counted in engagement_sends, enforced before the outbox
|
||
-- row is written, never overridable from the rule editor beyond this column.
|
||
max_sends_per_hour INT NOT NULL DEFAULT 100,
|
||
channels JSON NOT NULL, -- ['email','inapp'] — a rule may span channels
|
||
template_keys JSON NOT NULL, -- { email: 'idoc-warning', inapp: 'idoc-warning-short' }
|
||
conditions JSON NULL, -- declared-variable predicates, e.g. decayStatus in [Greatly, IDOC]
|
||
cooldown_seconds INT NOT NULL DEFAULT 0,
|
||
delay_seconds INT NOT NULL DEFAULT 0, -- the grace window (§4.2a)
|
||
cancel_on JSON NULL, -- trigger ids that cancel a pending row for the same subject
|
||
updated_by INT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_engr_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||
INDEX idx_engr_trigger (trigger_id, enabled)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
-- §5.1a: an operator-composed segment over module-declared audiences. Stored as a
|
||
-- boolean tree of audience ids + params; `ceiling` is DERIVED at save time as the
|
||
-- NARROWEST ceiling in the tree and re-checked against the trigger's own ceiling,
|
||
-- so composition can never widen. It is a column rather than a runtime computation
|
||
-- so an audit can read what a rule was allowed to reach without re-resolving it.
|
||
CREATE TABLE IF NOT EXISTS engagement_audience_segments (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
name VARCHAR(160) NOT NULL,
|
||
expression JSON NOT NULL, -- { op: 'and'|'or'|'not', nodes: [...] | { audienceId, params } }
|
||
ceiling VARCHAR(32) NOT NULL, -- derived, never operator-typed
|
||
updated_by INT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_engseg_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
-- G8/G9: the channel dimension notification_subscriptions lacks.
|
||
CREATE TABLE IF NOT EXISTS notification_channel_prefs (
|
||
user_id INT NOT NULL,
|
||
stream_id VARCHAR(64) NOT NULL, -- a stream OR a trigger id; one namespace, see §7.2
|
||
channel VARCHAR(32) NOT NULL,
|
||
mode ENUM('off','instant','digest') NOT NULL DEFAULT 'off',
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (user_id, stream_id, channel),
|
||
CONSTRAINT fk_ncp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
INDEX idx_ncp_channel (channel, mode)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
-- G15: per-message record. Today "did user X get the mail?" is unanswerable.
|
||
CREATE TABLE IF NOT EXISTS engagement_sends (
|
||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||
outbox_id BIGINT NULL,
|
||
rule_id INT NULL,
|
||
trigger_id VARCHAR(96) NOT NULL,
|
||
user_id INT NULL, -- SET NULL, so the log survives an account deletion
|
||
channel VARCHAR(32) NOT NULL,
|
||
transport VARCHAR(32) NULL, -- which mail transport actually carried it
|
||
address_hash CHAR(64) NULL, -- sha256; the log must not be a second address book
|
||
status ENUM('sent','failed','suppressed','bounced','complained') NOT NULL,
|
||
detail VARCHAR(500) NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_engs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||
INDEX idx_engs_trigger (trigger_id, created_at),
|
||
INDEX idx_engs_user (user_id, created_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
-- G16. Keyed on the ADDRESS, not the user. That was written when users.email was
|
||
-- not unique; after Phase 1b it stays, for a better reason: a bounce arrives as
|
||
-- an ADDRESS, does not know which account was behind it, and stays true after
|
||
-- that account changed its address or was deleted.
|
||
--
|
||
-- **`address_masked` and `created_by` are Phase 9's additions to this DDL.** The
|
||
-- hash-only table cannot be operated: an operator reading sha256 digests cannot
|
||
-- tell three typos from a whole domain refusing mail, and un-suppressing somebody
|
||
-- who fixed their mailbox is the one action the table must support. The domain
|
||
-- survives so a domain-wide failure is visible; the local part is DESTROYED
|
||
-- rather than shortened, so the column can never be read back as an address book.
|
||
-- `created_by` is what separates a row an admin typed from one the outbox worker
|
||
-- wrote (NULL).
|
||
CREATE TABLE IF NOT EXISTS engagement_suppressions (
|
||
address_hash CHAR(64) NOT NULL PRIMARY KEY, -- sha256 of the lowercased address
|
||
address_masked VARCHAR(190) NULL, -- d***@example.com; never the local part
|
||
channel VARCHAR(32) NOT NULL DEFAULT 'email',
|
||
reason ENUM('bounce','complaint','manual','unverified') NOT NULL,
|
||
detail VARCHAR(500) NULL,
|
||
created_by INT NULL, -- the admin, for a manual row; NULL if automatic
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_engsup_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||
INDEX idx_engsup_created (created_at),
|
||
INDEX idx_engsup_reason (reason, created_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
|
||
-- G17: the in-app inbox. Core, game-agnostic, content-carrying.
|
||
CREATE TABLE IF NOT EXISTS user_notifications (
|
||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||
user_id INT NOT NULL,
|
||
trigger_id VARCHAR(96) NOT NULL,
|
||
title VARCHAR(300) NOT NULL,
|
||
body TEXT NULL, -- rendered by the inapp template, sanitized on write
|
||
url VARCHAR(500) NULL, -- relative only, validated like pageUrlTemplate
|
||
dedupe_key VARCHAR(190) NULL,
|
||
read_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
CONSTRAINT fk_un_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
UNIQUE KEY uq_un_dedupe (user_id, dedupe_key),
|
||
INDEX idx_un_unread (user_id, read_at, created_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||
```
|
||
|
||
**`email_config` grows rather than being replaced** (additive-only discipline):
|
||
|
||
```sql
|
||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS transport VARCHAR(32) NOT NULL DEFAULT 'smtp';
|
||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS credential_enc TEXT NULL; -- AES-GCM JSON blob, transport-shaped
|
||
ALTER TABLE email_config ADD COLUMN IF NOT EXISTS reply_to VARCHAR(255) NULL;
|
||
```
|
||
|
||
`transport` defaults to `'smtp'` because Gmail OAuth2 is gone (§1.2a) — there is no longer a transport
|
||
for an existing `provider='gmail_oauth2'` row to backfill *into*, so the upgrade lands every deployment
|
||
on SMTP with empty credentials and an explicit admin warning rather than on a transport that no longer
|
||
exists. `provider` and `refresh_token_enc` stay as dead columns (additive-only) and stop being read.
|
||
|
||
A single opaque `credential_enc` JSON blob is better than one column per provider field: SMTP, SES and
|
||
Mailgun have disjoint credential shapes and the transport's `credentialFields` already describes its own.
|
||
It also means adding Mailgun later is a registration plus an admin form, with **no schema change at all**.
|
||
|
||
**The backfill of `notification_subscriptions` → `notification_channel_prefs`** follows the precedent
|
||
already in `schema.sql:770` (the `announce_jobs` → `announce_job_legs` migration): an `INSERT IGNORE …
|
||
SELECT` guarded so replay on every boot is a no-op after the first.
|
||
|
||
```sql
|
||
INSERT IGNORE INTO notification_channel_prefs (user_id, stream_id, channel, mode)
|
||
SELECT user_id, stream_id, 'push', 'instant' FROM notification_subscriptions;
|
||
```
|
||
|
||
### 4.6 Basic templates and the editor *(decision 5)*
|
||
|
||
Two halves, and the first is the one that decides whether the second gets used.
|
||
|
||
#### 4.6.1 The seeded set — a fresh deployment mails correctly before anyone opens the editor
|
||
|
||
Today every message body is a template literal inside `mailer.js`. Phase 5 moves them into
|
||
`engagement_templates` **as seeded rows**, so the migration is a relocation rather than a regression:
|
||
nothing that sends mail today starts depending on an operator authoring something first.
|
||
|
||
Seeded on boot by an idempotent seeder alongside `db/seed.js`'s admin seed — `INSERT … ON DUPLICATE KEY
|
||
UPDATE` keyed on `seed_key`, **and it refuses to overwrite a row whose `customized` flag is set**.
|
||
|
||
**Transactional (`protected = 1`, editable but not deletable — the system breaks without them):**
|
||
|
||
| `seed_key` | Replaces | Variables |
|
||
| --- | --- | --- |
|
||
| `auth.password-reset` | `mailer.sendPasswordReset` | `username`, `resetUrl`, `expiresIn`, `siteName` |
|
||
| `auth.invite` | `mailer.sendInvite` | `acceptUrl`, `role`, `invitedByName`, `siteName` |
|
||
| `auth.email-verify` | *(new — Phase 9)* | `username`, `verifyUrl`, `expiresIn` |
|
||
| `admin.contact-message` | `mailer.sendContactMessage` | `fromName`, `fromEmail`, `message` |
|
||
| `admin.test` | `mailer.sendTest` | `siteName`, `transport`, `sentAt` |
|
||
|
||
**Notification (`protected = 0`, replaceable):**
|
||
|
||
| `seed_key` | Replaces | Variables |
|
||
| --- | --- | --- |
|
||
| `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`, `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:**
|
||
|
||
1. **`notify.event` and `notify.digest` are generic on purpose.** A new trigger — from core or from any
|
||
module — renders through them with no authoring at all, because their variables are structural
|
||
(`title`, `intro`, `items[]`) rather than domain-specific. An operator who wants a bespoke IDOC mail
|
||
writes one; an operator who does not still gets a sane one. **This is what stops "add a trigger"
|
||
from meaning "and now write a template."**
|
||
2. **They are branded from data, not hardcoded.** `BRAND_*` env and the `theme_visual` / `brand_assets`
|
||
settings already drive the site's colours and logo (`THEMING_AND_NAV.md` §4.4); the seeded templates
|
||
read the same resolved values, so one prebuilt image running as any shard mails in that shard's
|
||
colours. No template contains a literal hex code or a logo URL.
|
||
3. **A later release can improve a default without stealing an operator's work.** `seed_version` +
|
||
`customized` is the whole mechanism: on boot, a seed whose version is newer updates rows where
|
||
`customized = 0` and **skips** rows where it is 1, surfacing "an updated default is available" in the
|
||
admin list instead. Same posture `settingsJson` takes — a stored value that is unusable is treated as
|
||
absent, never as an error.
|
||
|
||
#### 4.6.2 The editor
|
||
|
||
Built on the existing block machinery (`blocks/registry.js`, the prop panels, `sanitizeBlocks.js`),
|
||
with an `email.*` block family (§4.4) — **not a second editor**. What it adds over the page builder:
|
||
|
||
- **A variable palette from the trigger declaration.** The right-hand panel lists exactly the variables
|
||
§4.3 declares for this template's trigger, with type and example. Inserting one writes a token; it
|
||
is never free-text. A template referencing an undeclared variable is **refused at save, naming the
|
||
variable** — the editor validates, it does not blindly interpolate module JSON.
|
||
- **Live preview from `example` values.** No live game event needed. This is the reason `example` is a
|
||
required part of the trigger declaration rather than documentation.
|
||
- **A side-by-side HTML / plain-text view.** The text part is generated from each block's `toText`, and
|
||
is overridable per template. A published template with an empty text part is a **save-time error**.
|
||
- **Test send to an address of the admin's choosing**, through the configured transport, recorded in
|
||
`engagement_sends` like any other message.
|
||
- **Three preview widths** (desktop / mobile / plain-text) and a dark-mode preview, because mail clients
|
||
invert backgrounds and a light-only template renders as unreadable dark-on-dark in about a third of
|
||
inboxes.
|
||
- **A duplicate action**, which is how an operator customizes a `protected` template safely: duplicate,
|
||
edit, point the rule at the copy, leave the original intact.
|
||
|
||
**Security posture, stated because this is the one new place operator HTML reaches a rendered surface:**
|
||
blocks are validated and sanitized on **write** (the existing `validateBlocks` → `sanitize` order), the
|
||
preview renders in a sandboxed iframe with no `allow-scripts`, and variable interpolation is
|
||
**HTML-escaped by default** with no raw-HTML variable type in v1. A module supplies data; it does not
|
||
supply markup.
|
||
|
||
---
|
||
|
||
## Part 5 — The module registration mechanism
|
||
|
||
### 5.1 Three additions to the contract, all modelled on what already works
|
||
|
||
```js
|
||
// api — what the module registers (MODULE_API.md §2.4). Modelled on
|
||
// registerNotificationStreams: shape-checked at the call, collision-checked at
|
||
// apply(), <owner>.-prefixed by the existing namespaced() helper.
|
||
api.registerEventTriggers([{ id, label, description, variables, subjectKey, audience, version }])
|
||
|
||
// ctx — what core hands the module (§2.3). Modelled on ctx.teams.activity.push:
|
||
// fire-and-forget, never throws, never rejects, malformed input dropped and logged.
|
||
ctx.events.emit(triggerId, { subject, data, ownerUserId?, dedupeKey?, occurredAt? })
|
||
|
||
// ctx — the in-app sink, for a module that wants to write the inbox directly
|
||
// without a rule. Optional; most modules will only emit.
|
||
ctx.inbox.push(userId, { triggerId, title, body, url, dedupeKey })
|
||
|
||
// api — the audiences a module can resolve over its own data (decision 8).
|
||
// Same registration discipline as the triggers above; see §5.1a.
|
||
api.registerAudiences([{ id, label, description, ceiling, resolve }])
|
||
```
|
||
|
||
**Why a new surface rather than extending `registerNotificationStreams`.** A stream entry is a
|
||
*subscription toggle* — label plus two booleans, with no statement about payload. A trigger is a *data
|
||
contract*. Overloading the stream entry with a `variables` array would make every existing push stream
|
||
look like it has an (empty) payload contract, and would put the emit path for content-free tickles and
|
||
content-carrying events through one function whose behaviour depends on which fields the caller filled
|
||
in. The two should stay separate for the same reason `registerPostHook` was kept out of
|
||
`registerAnnounceLeg` ("a leg is a one-shot DELIVERY with retry and classification; a post hook maintains
|
||
idempotent STATE" — `registries.js`).
|
||
|
||
**What core reuses verbatim:** `stage()` / `apply()`'s validate-then-commit-per-registrant discipline,
|
||
`namespaced()` for the `<owner>.` prefix, the collision message that names the current holder, and the
|
||
rule that nothing a registrant claims takes effect until the whole registrant is known good.
|
||
|
||
**Core registers its own triggers through the same door**, in `registerCore()`, exactly as it does for
|
||
streams and the Discord leg. That is not ceremony — `registries.js`'s header states the reason: "a registry
|
||
only core's hardcoded base bypasses is a registry whose first real exercise is a module, which is the drift
|
||
this PR exists to prevent."
|
||
|
||
### 5.1a Audiences — module-declared, operator-composable *(decision 8)*
|
||
|
||
The org lead's correction to Q7 is precise and worth stating exactly: **there is no campaign surface, but
|
||
lists exist — powered by game data, through the module, on a surface every module shares.** "Team X's
|
||
members" and "the governors" are legitimate audiences; "everyone who opened the last mail" is not, and
|
||
nothing here builds it.
|
||
|
||
```js
|
||
api.registerAudiences([{
|
||
id: 'team.members', // namespaced() prefixes it → 'uo.team.members'
|
||
label: 'Members of a team',
|
||
params: [{ id: 'teamId', type: 'int', required: true }],
|
||
ceiling: 'members', // the widest this audience can EVER resolve to (G24)
|
||
resolve: async (params, ctx) => [/* user ids */],
|
||
}])
|
||
```
|
||
|
||
**Four rules, each of which exists because of something already in the tree:**
|
||
|
||
1. **Core learns no game vocabulary.** Core never knows what a governor is; it knows an id, a label and
|
||
a `resolve` it may call. This is the same boundary `registerNotificationStreams` holds, and
|
||
`check:modules` already proves core's own ids name no game concept.
|
||
2. **The resolver returns user ids and nothing else.** It is not handed a template, a channel or an
|
||
address, and it cannot enumerate them — a module still cannot send mail (§1.2), and this must not
|
||
become the back door that lets it. The engine maps ids to addresses on core's side, after
|
||
preferences, suppression and the verification gate.
|
||
3. **A composed segment is bounded by the *narrowest* ceiling it contains, not the widest.** Operators
|
||
may combine declared audiences with and/or/not into a saved segment. That is real power and it is the
|
||
part with a security edge: composition must never *widen*. `A OR B` takes the tighter of the two
|
||
ceilings, and the result is still checked against the trigger's own G24 ceiling before a rule using it
|
||
can be saved. Union-widens is the intuitive implementation and it is the wrong one.
|
||
4. **An audience whose module is uninstalled goes dormant, exactly as a rule does** (§7.3). It resolves
|
||
to the empty set and the rule referring to it shows as dormant — never an error, never auto-deleted,
|
||
never a silent send to a *different* set of people because the id stopped resolving.
|
||
|
||
**The lattice itself — settled in Phase 2, because this document named it everywhere and defined it
|
||
nowhere.** "Narrowest" needs an ordering, and the obvious one is wrong:
|
||
|
||
```
|
||
everyone anyone at all, signed in or not
|
||
└── authenticated any signed-in user
|
||
├── subscribers signed-in users who opted into this id
|
||
├── members a module-declared list (a Team, the governors)
|
||
├── staff admin / editor / moderator
|
||
└── owner the one user the event is about
|
||
```
|
||
|
||
**It is containment, not size.** The tempting model is a flat total order — `self < owner < staff <
|
||
members < authenticated < everyone`, compared with `<=` — and under it a trigger ceilinged at `staff`
|
||
also permits `owner`, so a rule could mail `uo.cheat.detected` to the player it detected. Fewer people
|
||
is not less exposure; the question is always *which* people.
|
||
|
||
So the four leaves are **mutually incomparable**, deliberately: `owner` is not a subset of
|
||
`subscribers` (an owner need not have subscribed), `staff` is not a subset of `members`, and no pair
|
||
of them has a common descendant. Three consequences:
|
||
|
||
- `permits(ceiling, candidate)` is "walk `candidate` up the tree and see whether you reach `ceiling`",
|
||
and it **fails closed** on anything it does not recognise.
|
||
- `meet(a, b)` — the narrower of two — exists only when one is an ancestor of the other. Two
|
||
incomparable ceilings have **no bound at all**, and the composition is REFUSED rather than resolved
|
||
to a guess. Union-widens is the intuitive implementation and it is the wrong one; picking a side
|
||
when there is no answer is the second-wrong one.
|
||
- The direction of the boolean operator is irrelevant. `A AND B` takes the tighter ceiling exactly as
|
||
`A OR B` does, because a ceiling states what an expression is *allowed* to reach, not what it will
|
||
resolve to. An empty composition has no bound and is `null`, never `everyone`.
|
||
|
||
The vocabulary travels with the trigger catalog (`GET /admin/engagement/triggers` serves it), so the
|
||
rule editor never offers an audience the server will refuse. The server is still the boundary: Phase 4
|
||
re-checks every rule save.
|
||
|
||
**Where it lands.** The registration surface and the ceiling arithmetic belong in **Phase 2**, with the
|
||
trigger declaration — G24's reasoning applies unchanged, and both are cheap now and expensive to retrofit
|
||
into a rule model that already has rows in it. The composition UI belongs with the rules screen in
|
||
**Phase 4**. `module-uo`'s first real audiences come in **Phase 11**.
|
||
|
||
**The composition UI, as built in Phase 4b.** Its own nav entry, **Admin → Engagement → Audiences**,
|
||
rather than a tab of the rules screen: a segment is reusable, independently edited, and deleted by a
|
||
refusal that has to say how many rules still point at it. The composer offers one top-level
|
||
all-of/any-of over audience leaves, each leaf rendering the `params` its declaration names (an `int`
|
||
param is sent as a number, because the server type-checks it). Three things it does that follow from
|
||
the rules above rather than from taste:
|
||
|
||
- **“Exclude” appears only under “all of”.** `not` is legal only as a child of `and`, so switching the
|
||
group to “any of” drops the exclusions rather than composing a tree the server will refuse — and
|
||
says so, because keeping them and failing at save would be worse than either.
|
||
- **The stored ceiling is displayed, never chosen.** The list shows what each saved audience *may*
|
||
reach beside what it *does* reach right now (on demand, as a count), which is the pair an operator
|
||
needs in order to understand a refusal.
|
||
- **A tree nested deeper than the composer renders is not editable, and says so** rather than being
|
||
flattened — the same posture the rule editor takes with a nested condition. The stored grammar
|
||
allows more nesting than any screen should offer.
|
||
|
||
The rule editor's half is the other side of the same rule: it offers only the audiences and saved
|
||
segments the trigger's ceiling **permits**, derived from the `permits` list `GET /admin/engagement/triggers`
|
||
serves. That is an affordance and not a boundary — the client must not hold a second copy of the
|
||
lattice — and the save re-checks every time.
|
||
|
||
### 5.2 The seam, end to end
|
||
|
||
```
|
||
module-uo core
|
||
───────── ────
|
||
register(ctx, api)
|
||
api.registerEventTriggers([...]) ─────────► registries: shape-check, namespace-check, collide-check
|
||
│
|
||
shard event arrives (uoLinkSocket) │ GET /admin/engagement/triggers ──► the rule + template editors
|
||
shardIngest → mapper │
|
||
ctx.events.emit('uo.house.idoc_warning', { │
|
||
subject: serial, │
|
||
data: { character, house, location, … }, │
|
||
ownerUserId: <resolved via shardLinks> ▼
|
||
}) ───────────────────────────────────────► engagement engine
|
||
1. validate payload against the declaration (§4.3)
|
||
2. find enabled rules for this trigger, eval conditions
|
||
3. resolve audience → user ids
|
||
4. per user: check notification_channel_prefs per channel
|
||
5. check engagement_cooldowns (rule, user, subject)
|
||
6. delay_seconds ? enqueue engagement_outbox : deliver now
|
||
7. cancel_on: cancel pending rows for the same subject
|
||
│
|
||
▼
|
||
render template (blocks → HTML + text)
|
||
│
|
||
▼
|
||
DeliveryChannel.deliver → transport → engagement_sends
|
||
```
|
||
|
||
**Owner resolution stays in the module.** `module-uo/server/utils/shardPush.js` already turns an
|
||
`ownerAcct` into a website user via `shardLinks` — core has no idea what a game account is and must not
|
||
learn. The module resolves and passes `ownerUserId`; core never sees `ownerAcct`.
|
||
|
||
### 5.3 What this costs the contract
|
||
|
||
`MODULE_API_VERSION` 1.6.0 → **1.7.0**. Additions only (`registerEventTriggers`, `registerAudiences`,
|
||
`ctx.events.emit`, `ctx.inbox.push`), no removal, no changed signature ⇒ minor by §1.1's table. `module-uo`'s
|
||
`coreApi: "^1.3.0"` still resolves, so no module is broken by the bump.
|
||
|
||
Knock-on obligations:
|
||
|
||
- `client/src/modules/version.js` carries the same number and a test asserts they agree.
|
||
- `integration-kit/ci/core-ref.json` pins a website `main` sha and `scripts/checkCoreApi.js` asserts
|
||
**equality** with `MODULE_API_VERSION`. **A bump turns the integration kit red on purpose** — that is
|
||
the mechanism, not a bug: someone must re-read the chapters and move the pin. Budget a kit PR.
|
||
- `docs/website/MODULE_API.md` §1.1, §2.3 and §2.4 need the new members, and §1.1's "1.6.0 has only ever
|
||
been on `edge`" paragraph needs correcting (§0.5).
|
||
|
||
---
|
||
|
||
## Part 6 — The phased plan
|
||
|
||
Same shape as the API v2 router-split plan: grouped, reviewable increments, one acceptance check per
|
||
phase, and an explicit note on which guardrails apply.
|
||
|
||
**Every phase carries the standing obligations** — `npm test --prefix server` green, `npm run swagger`
|
||
regenerated when a route changes, `npm run routes:manifest -- --check` clean, `npm run check:modules`
|
||
clean, **the documentation edits §6.0b assigns it**, Conventional Commits, the AI-disclosure trailer,
|
||
and a branch cut from a freshly-pulled base.
|
||
|
||
**Stage A (1–1b–2) is prerequisite. Stage B (3–6) is the engagement system. Stage C (7–8) is the in-app
|
||
channel. Stage D (9) is deliverability. Stage E (10–11) is the shard enrichment and runs in parallel
|
||
from day one. Stage F (12–13) is the public site and the cutover.**
|
||
|
||
### 6.0a The branching model — everything lands on `edge`, then one cutover to `main`
|
||
|
||
**Every phase PR in every repo targets `edge`. `main` is touched exactly once, by the cutover
|
||
(Phase 13).** This is the model the module system, protocol v3, Teams and the M12 theming workstream
|
||
each used, and it is the right one here for a specific reason: this workstream changes a wire protocol,
|
||
a module API version and the mail path simultaneously, and those three land in different repos on
|
||
different days. `main` must never hold a half-applied set of them.
|
||
|
||
**Two operational findings, both checked on 2026-08-28 and both blocking before Phase 1:**
|
||
|
||
1. **Every existing `edge` is stale.** `git rev-list --left-right --count origin/main...origin/edge`
|
||
says `edge` is **0 ahead** and behind `main` by: `docs` 16, `module-uo` 9, `installer` 7,
|
||
`servuo-plugins` 7, `website` 5, `link` 3. They are leftovers from previous cutovers that were never
|
||
refreshed after merging. **Fast-forward each `edge` to `main` before the first phase PR** — it is
|
||
lossless (0 ahead), and skipping it means the cutover diff carries stale content or conflicts that
|
||
have nothing to do with this workstream.
|
||
2. **Three repos have no `edge` at all** and need one cut from `main`: `android-app` (its M12 branch
|
||
was deleted after that cutover), `runicgateway.com`, and `Integration-kit`.
|
||
|
||
**Phase -1, executed 2026-08-28.** Five fast-forwards — `module-uo` 9 behind, `installer` 7,
|
||
`servuo-plugins` 7, `website` 5, `link` 3 — plus three branches cut from `main` (`android-app`,
|
||
`runicgateway.com`, `Integration-kit`). `docs`' `edge` was fast-forwarded earlier and is ahead. Every
|
||
fast-forward was 0 ahead, so all were lossless. **Phase -1 is complete.**
|
||
|
||
**One trap worth recording, because it produced a wrong answer here first.** `git fetch origin` does
|
||
**not** prune, so a `refs/remotes/origin/edge` left over from a branch that was deleted server-side after
|
||
a previous cutover still resolves. `git rev-parse --verify origin/edge` succeeds and
|
||
`git rev-list --left-right --count origin/main...origin/edge` returns a plausible count — for
|
||
`android-app` it reported "1 behind", which read exactly like a stale-but-present branch and is why this
|
||
section was briefly "corrected" to say two repos rather than three. The branch had not existed on the
|
||
server since the M12 cutover. **Use `git ls-remote --heads origin edge` (or fetch with `--prune`) to ask
|
||
whether a remote branch exists**; a remote-tracking ref is a cache, not an answer.
|
||
|
||
**Android CI does not run on `edge`.** `android-app/.gitea/workflows/pr-checks.yml` triggers only on
|
||
PRs into `main`, so every Phase 8 PR lands with **zero CI** and the cutover is the first real run. That
|
||
was true of all nine M12 phase PRs and it is true again here. Either fix the trigger as Phase 8's first
|
||
commit or budget for the cutover being the first honest build — decide deliberately rather than
|
||
discovering it.
|
||
|
||
**The cutover is per-repo but not independent.** Phase 13 names the order, because a `main` that has the
|
||
v5 sidecar and the v4 overlay is a shard that cannot pair.
|
||
|
||
### 6.0b Documentation is a phase deliverable, not an appendix
|
||
|
||
Every phase below owes specific documentation, and the phase is **not done until it lands in the same
|
||
PR** (or, for cross-repo docs, a companion PR in the same review window). CLAUDE.md's rule — "a code
|
||
change is not complete until `docs/` reflects it" — is the floor; this table is the assignment.
|
||
|
||
| Phase | `docs/` | Other repos |
|
||
| --- | --- | --- |
|
||
| **1** Remove Gmail OAuth2, SMTP | `website/BACKEND_DESIGN.md` §7 **rewritten** (not amended — it documents Gmail OAuth2 as *the* mechanism); route tables lose `/admin/email/connect/*` | `website/README.md` + `.env.example` wherever they point at Connect Gmail · **`runicgateway.com`**: `notifications-and-email.mdx` (its "There is no SMTP option" aside is now false), `configuration.mdx:62`, `troubleshooting.mdx:101`, `system-architecture.mdx:117` · a release note |
|
||
| **1a** One self surface | `website/BACKEND_DESIGN.md` — the `/auth/me` prose and the `/player`+`/admin` router trees · `api-route-inventory.json` regenerated · `website/ENGAGEMENT.md` this phase | `android/PLAN.md` §6.4/§8 — the "routes stay for web back-compat" note is now false · `website/API_V2_PLAN.md` historical tables get a pointer |
|
||
| **1b** Unique email | `website/BACKEND_DESIGN.md` — the `users` table (the "not unique" note is now false), the new change/verify routes, and the de-dupe migration as an operator-visible upgrade step | `website/README.md` upgrade notes · a release note naming the admin report and the verification-gate default |
|
||
| **2** Trigger registry | `website/MODULE_API.md` §1.1 (**1.7.0** + correct the stale "1.6.0 has only ever been on `edge`" paragraph), §2.3 (`ctx.events`, `ctx.inbox`), §2.4 (`registerEventTriggers`, `registerAudiences`), the dormant-rule note (landed as §6.8) · `website/ENGAGEMENT.md` §4.3 and §5.1a kept true · `BACKEND_DESIGN.md` route table | **Both deferred to the Phase 13 cutover window, deliberately — see Phase 2's as-built.** `Integration-kit`'s `ci/core-ref.json` pins a **`main`** sha, so the equality check stays green (and must stay green) for the whole `edge` period; `runicgateway.com`'s `checkFacts.mjs` *fetches* from `main`, so setting `platform.json.moduleApi` → 1.7.0 now would turn that repo red immediately |
|
||
| **3** Channel preferences | `website/BACKEND_DESIGN.md` route table · `android/PLAN.md` §11 | — |
|
||
| **4a** Engine | `website/ENGAGEMENT.md` (rules/cooldown/outbox as built, and the two §4 defects it corrects) · `BACKEND_DESIGN.md` table inventory | — |
|
||
| **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 · `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` §7 (the Room exception) + §11 (the inbox as built) · `website/ENGAGEMENT.md` this phase as built | `android-app/README.md`. Landed with the phase |
|
||
| **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 **and §Market** (tables + examples) + the new `account.login.result` row · `link/PLAN.md` §5/§7 · **a `link/v5.md`** — the bump now carries three enrichments across three subsystems, which is what earned v3 and v4 their own design docs | `servuo-plugins/overlay.toml` · `servuo-plugins/patches/README.md` — `vendor.sale` stays in the tier and the docs must say what that costs · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
|
||
| **11** module-uo triggers | `modules/uo/API.md` — **the full trigger catalogue, its audiences and its ceilings**, not one entry · `modules/uo/README.md` · `website/ENGAGEMENT.md` §8.6 kept true as rows ship | `module-uo/README.md` · **`runicgateway.com`**: `capabilities.mjs` and the notifications page — "one rule" and "the whole catalogue" are different marketing claims |
|
||
| **12** Public site | — | **`runicgateway.com`**, in full — see the phase |
|
||
| **13** Cutover | `README.md` index rows · every doc's status line | `.profile/README.md` if this is a headline capability |
|
||
|
||
**One thing this table is protecting against.** `runicgateway.com` appears in eight rows, and it is the
|
||
only repo here whose checks are *fetching* these values rather than being told them — see Phase 12.
|
||
|
||
|
||
|
||
---
|
||
|
||
### Phase 0 — Design of record ✅
|
||
|
||
This document, landed as `docs/website/ENGAGEMENT.md` with the five settled decisions recorded at the
|
||
top. No code.
|
||
|
||
**Acceptance:** merged into `docs/`; §7.1's open questions each answered or explicitly deferred before
|
||
the phase that depends on them starts. **Q1, Q3, Q5 and Q7 were answered on 2026-08-28** (§7.1), which
|
||
unblocked Phases 1, 2 and 4 and added Phase 1b; **Q6 and §7.2 were answered on 2026-08-29**, at the
|
||
start of Phase 2 and before a line of it was written. Still outstanding: **Q2 before Phase 4**, **Q4
|
||
before Phase 5b**, **Q8 before Phase 8**.
|
||
|
||
This acceptance criterion has now paid for itself twice. Q6 and §7.2 were both tagged "decide in
|
||
Phase 2", and §7.2's answer went **against** the recommendation in the text — which is exactly the
|
||
outcome that is cheap to act on before the registry exists and expensive afterwards.
|
||
|
||
---
|
||
|
||
### Phase 1 — Remove Gmail OAuth2; the transport registry + SMTP ✅
|
||
|
||
**This phase is a subtraction and a replacement in one PR**, because leaving the OAuth2 flow half-wired
|
||
across a release is worse than either end state.
|
||
|
||
Delete everything in §1.2a's inventory. Extract `utils/mailer.js` behind the §3.1 interface with `smtp`
|
||
as the sole registered transport. Admin → Email becomes a credential form driven by `credentialFields`.
|
||
`email_config` gains `transport` / `credential_enc` / `reply_to`, defaulting to `smtp` with no
|
||
credentials. All six existing call sites keep their **exact** failure contracts — the contact form's
|
||
`mailto` fallback, the invite's copyable-link fallback, the password reset's generic 200, and
|
||
`sendTeamNotification`'s never-throws.
|
||
|
||
Ships with the cutover safety net §1.2a demands: an admin dashboard warning when the transport is
|
||
configured but credential-less, a release note naming the operator action, and the
|
||
`smtp.gmail.com:587` + app-password migration path documented as the shortest route for the existing
|
||
deployment.
|
||
|
||
**Acceptance:** `test/mailer.test.js` and `test/emailConfig.model.test.js` pass (amended only where they
|
||
assert OAuth2 specifics); a fresh install with SMTP configured sends every one of the five current
|
||
message types; an upgraded install with no SMTP credentials degrades exactly as an unconfigured
|
||
deployment does today — contact form falls back to `mailto`, invites surface the link, resets answer 200
|
||
— **and shows the warning**; `grep -r "smtp.gmail.com\|mail.google.com" server/src` returns nothing.
|
||
**Guardrails:** swagger regen + `routes:manifest --check` (two routes removed); no-hardcoded-host check
|
||
(§3.2 rule 4) — which the deleted `smtp.gmail.com` literal is the first real test of.
|
||
|
||
#### As built (website#165, docs#178)
|
||
|
||
Delivered as specified, with four things worth recording because they are not what the plan above says.
|
||
|
||
1. **Only half of §3.1 was built, deliberately.** `registerMailTransport` ships;
|
||
`registerDeliveryChannel` does not. Phase 1 has no consumer for `addressFor`/`render`/`deliver` —
|
||
the engine that calls them is Phase 4 — and a registered channel nothing calls is a shape frozen
|
||
before anything tried to use it. It arrives with the phase that consumes it. `credentialFields` is
|
||
the piece that mattered here, since it is what makes the admin form generic.
|
||
2. **`enabled` now gates every sender, which it did not before.** `buildTransport()` used to test only
|
||
"is there a refresh token and a sender", so the contact form kept sending after an admin unticked
|
||
*Enable email sending* — `isConfigured()` honoured the toggle but the five direct senders bypassed
|
||
it. The connect flow used to set `enabled` as a side effect of a consent redirect; with a credential
|
||
form it has to mean what it says, so the gate moved onto the one path every sender shares. **A
|
||
deliberate behaviour change, not a refactor**, and the only one in the phase.
|
||
3. **The G22 warning reads the deprecated column.** `refresh_token_enc` stays unread *as
|
||
configuration*, but its presence is exactly "this deployment had working mail before the upgrade",
|
||
which is the warning's whole condition. `hadLegacyConnection && !hasCredential` fires for the one
|
||
deployment this happens to and stays silent on a fresh install, which has never had mail and would
|
||
only learn to ignore the banner. The warning clears itself once a credential is saved — nothing has
|
||
to remember to dismiss it.
|
||
4. **`npm run swagger` was already broken and had to be fixed first** (website#164, its own PR). It
|
||
died with swagger-autogen's "invalid array length" runaway on a pristine `edge`, under Node 20 and
|
||
24, and at the commit whose own PR last regenerated the spec — so no phase that touches a route
|
||
could have met the standing obligation. Bisected to one statement in `teams.router.js`:
|
||
`param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v))`. **The rule is that nothing
|
||
reaching `.test(` may sit inside a route statement**, and the "per-file route limit" that phases 8
|
||
and 9 recorded does not exist — a three-route file carrying only that one route dies too. Worth
|
||
knowing for every later phase in this workstream, all of which add routes.
|
||
|
||
Two smaller decisions: the credential is one encrypted JSON blob rather than a column per field, so
|
||
adding a transport is never a schema change; and a blob that will not decrypt reads as **absent**
|
||
rather than raising, so a rotated `SECRET_ENC_KEY` lands an admin on an "unconfigured" screen instead
|
||
of a 500 that takes the contact form with it.
|
||
|
||
---
|
||
|
||
### Phase 1a — One self-service surface: `/auth/me/account` ✅
|
||
|
||
**Not in the original plan.** It was added on 2026-08-29, ahead of Phase 1b, when mapping Phase 1b's
|
||
ground truth turned up that self-service account security had **three URL surfaces onto one
|
||
controller** — `/auth/me/account/*`, `/player/account/*` and `/admin/account/*`, all mounting the same
|
||
`account.controller` handlers. Phase 1b adds a self-service field (email), Phase 3 adds another
|
||
(channel preferences), and each would otherwise have been written three times.
|
||
|
||
**`/auth/me/account` was already a strict superset**, which is what settled which one to keep: it was
|
||
the only surface carrying recovery codes, and `/admin/account` carried no username or password change
|
||
at all. The web client already reached into `/auth/me` for two calls on a screen it otherwise served
|
||
from `/admin/account` — the split was leaking before anyone touched it. Gating was equivalent where it
|
||
overlapped: `/player` and `/auth/me` apply byte-identical `noindex, requireAuth`, and `staffOnly` on
|
||
`/admin/account` was strictly narrower while buying nothing, since every handler is self-scoped to
|
||
`req.user.id`.
|
||
|
||
**14 routes deleted, 0 added, no handler changed.** `account.controller.js` moved from
|
||
`router/v1/admin/` to `router/v1/auth/`, beside the one router that still reaches it. The web client's
|
||
14 call sites moved onto the root-level `api.myAccount` / `api.changeUsername` / … group. The Android
|
||
app needed **nothing** — `MeApi.kt` was already 100% `/auth/me/account/*`.
|
||
|
||
It is a **breaking change to the published OpenAPI surface**, accepted deliberately: both consumers are
|
||
in this org, and deprecating-then-deleting would have meant Phase 1b deciding whether to add its email
|
||
routes to surfaces already marked for removal.
|
||
|
||
**As built:** `routes.manifest.json` shows exactly 14 deletions and 0 additions; the OpenAPI spec loses
|
||
the same 14 paths with **zero surviving path definitions changed** and identical `components` (its large
|
||
textual diff is pure reordering — removing the first-mounted router shifts every later path). Two
|
||
swagger tags — `Admin · Account` and `Player` — were declared *only* by the deleted routes and went with
|
||
them. 1203 server tests and 288 client tests green.
|
||
|
||
**One thing this phase did not fix, on purpose:** `docs/website/api-route-inventory.json`, the docs
|
||
mirror of the route manifest, had drifted badly on its own (168 entries against the manifest's 203 —
|
||
missing every Teams route, and still listing the two Gmail `connect` routes Phase 1 deleted). It is a
|
||
generated mirror with no CI gate, so it was regenerated wholesale here rather than partially updated.
|
||
**A mirror nothing checks will drift again** — a gate for it belongs in a later phase.
|
||
|
||
---
|
||
|
||
### Phase 1b — Unique, changeable, verifiable email addresses ✅ *(decision 6)*
|
||
|
||
**Lands alone, between 1 and 2, and before any engagement mail exists.** It touches registration, SSO
|
||
provisioning and the boot-time schema path — three security-sensitive surfaces — and retrofitting
|
||
uniqueness *after* a send log and a suppression list hold rows is strictly worse than doing it now.
|
||
§0.6 is the finding this phase discharges.
|
||
|
||
**Four pieces, in this order within the PR:**
|
||
|
||
1. **Distinguish the constraint before adding one.** Replace `isDuplicateUsername()`'s bare
|
||
`ER_DUP_ENTRY`/`1062` test with a check that reads the violated index off the driver error, and give
|
||
its two callers (`auth.controller.js:137`, `sso.controller.js:197`) separate branches. **This must be
|
||
in the tree before the index is**, or the register path starts lying and SSO sign-up starts failing
|
||
opaquely the moment the ALTER runs.
|
||
2. **De-duplicate, then index.** A migration step that runs *before* the `ALTER`: for each duplicated
|
||
address, the **earliest-created** account keeps it; every later duplicate has `email` set to `NULL`
|
||
and `email_verified` to `0`. Multiple `NULL`s are legal under a UNIQUE index, so nobody loses an
|
||
account and nothing cascades. The affected accounts are written to an admin-visible report — *who*
|
||
was cleared and *what* address they lost — because they are exactly the users who must be contacted.
|
||
Then a UNIQUE index — **on a generated `email_norm` column, not on `email`.** The reasoning above
|
||
(`Foo@x.com` and `foo@x.com` are one mailbox; folding belongs in the index rather than in bypassable
|
||
application code) is right, but the collation this originally named is not: see the amendment below.
|
||
3. **A self-serve change-and-verify flow**, which does not exist today (§0.6 finding 4). Set/change
|
||
address, a signed time-boxed verification link, `email_verified` set only on link use. It lands on
|
||
**`/auth/me/account` and nowhere else** — Phase 1a made that the single self-service surface. SSO's
|
||
`emailVerified: Boolean(profile.email)` is corrected at the same time to honour the IdP's actual
|
||
`email_verified` claim rather than the mere presence of an address.
|
||
4. **The verification gate as an admin setting** — `on` for fresh installs, `off` for upgrades, so the
|
||
live deployment does not silently stop mailing its existing opted-in users on the day it upgrades.
|
||
The asymmetry is deliberate and is the same lesson as G22: a safe default must not be applied
|
||
retroactively to a running system without telling anyone.
|
||
|
||
**The error surface stays anti-enumeration.** A collision returns a generic failure, the real reason is
|
||
logged and not returned, the endpoint keeps its rate limit, and the failure is **not** scored by the bot
|
||
detector — a legitimate user typing a colleague's address must not be pushed toward an IP ban for it.
|
||
|
||
**Acceptance:** a DB seeded with three accounts sharing an address boots clean, keeps the oldest, nulls
|
||
two, and lists both in the admin report; registering with a taken address returns the generic failure,
|
||
logs the specific one, increments no bot score, and does **not** say "username"; SSO sign-up with an
|
||
address already held by another account fails with a distinguishable reason rather than exhausting
|
||
`PROVISION_MAX_TRIES`; a user can change their address and it stays `email_verified = 0` until the link
|
||
is used; `Foo@x.com` collides with `foo@x.com`; an upgraded install has the gate `off` and a fresh one
|
||
`on`.
|
||
**Guardrails:** swagger regen + `routes:manifest --check` (the change/verify routes are new); the
|
||
migration is idempotent and re-running `ensureSchema()` is a no-op; **no destructive DDL** — the
|
||
de-dupe nulls a column, it never deletes a row.
|
||
|
||
#### As built (2026-08-29)
|
||
|
||
**The index is on a generated column, because every `_ci` collation is also accent-insensitive.**
|
||
Step 2 above said to pin `email` to a case-insensitive collation "for the same reason `username` was".
|
||
Tested against the deployment's own MariaDB 11.8, that is wrong in a way that would have destroyed
|
||
data: under **both** `utf8mb4_general_ci` and the server-default `utf8mb4_uca1400_ai_ci`,
|
||
`josé@x.com` and `jose@x.com` compare EQUAL. They are different mailboxes. A UNIQUE index over either
|
||
collation refuses the second address forever, and the de-duplication below would have nulled a
|
||
legitimate account's address and reported it as a duplicate that never was.
|
||
|
||
The accent-sensitive, case-insensitive collations that would be exactly right
|
||
(`utf8mb4_general1400_as_ci`, `utf8mb4_0900_as_ci`) are MariaDB 11.4+ only, so pinning one moves the
|
||
"a UNIQUE email can stop a boot" failure of §0.6 to a different trigger. What shipped instead:
|
||
|
||
```sql
|
||
email VARCHAR(255) NULL,
|
||
email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED,
|
||
UNIQUE KEY uq_users_email_norm (email_norm)
|
||
```
|
||
|
||
`LOWER()` under a `_bin` collation folds case without folding accents — verified, not assumed. The
|
||
fold still lives in the schema rather than in bypassable application code, which was the point of the
|
||
original rule. Multiple NULLs remain legal, which is what lets the de-dupe clear an address without
|
||
deleting an account. No foreign key references `users.email`, so the STORED-generated-column trap from
|
||
TEAMS.md phase 2 (`ER_GENERATED_COLUMN_FUNCTION_IS_NOT_ALLOWED` on `ON DELETE SET NULL`) does not apply.
|
||
|
||
**The de-dupe must group on that same column, and the first version did not.** Written as
|
||
`LOWER(u2.email) = LOWER(u.email)`, the comparison uses the *column's* collation — accent-insensitive
|
||
— so it over-folds even when the index does not. A seeded fixture caught it nulling `jose@x.com` as a
|
||
"duplicate" of `josé@x.com`: the exact defect the index change was made to prevent, reintroduced one
|
||
statement later. The migration therefore **adds `email_norm` before de-duplicating and groups on it**,
|
||
so the two agree by construction rather than by a hand-matched `COLLATE` clause a later edit can get
|
||
wrong. Order in `schema.sql` is load-bearing and commented as such.
|
||
|
||
**Four decisions taken at build time**, all approved before any code:
|
||
|
||
| | Decision | Why |
|
||
|---|---|---|
|
||
| Index folding | generated `LOWER()` column + `_bin` index | above |
|
||
| Change flow | **pending column**, live address untouched | a typo cannot silently redirect account-recovery mail. Cost: a pending address reserves nothing, so two users may both be pending on one address and the second to confirm loses — with the same generic failure |
|
||
| Re-auth | `currentPassword` required, SSO carve-out | an address is where recovery lands, so repointing it is credential-grade; mirrors `changePassword` |
|
||
| Report surface | table + dashboard warning + read route | reuses the Phase 1 G22 shape: narrow, self-clearing, silent on installs it does not concern |
|
||
|
||
**One deviation from the text above, deliberate:** step 3 says a "signed" link. Every comparable flow
|
||
in this codebase (`user_invites`, `password_resets`, `mobile_refresh_tokens`) uses an opaque random
|
||
token with only its sha256 at rest, and `email_verifications` matches them rather than introducing a
|
||
second token mechanism for one caller.
|
||
|
||
**`provisionSsoPlayer` now returns `{ user }` or `{ error }`** instead of the user or a bare null. Two
|
||
ways to fail need two things said to the person at the browser; the two call sites map `error`
|
||
straight onto the `sso_error` code the login pages already render.
|
||
|
||
**Verified on a live rig**, not only in unit tests — a real MariaDB 11.8 seeded with the pre-upgrade
|
||
schema plus three accounts sharing an address, upgraded by booting the real server, with a real SMTP
|
||
send into a mail catcher (which also discharges Phase 1's outstanding "no live SMTP send"):
|
||
|
||
- the upgrade **boots clean**; oldest kept the address, two were nulled and reported with the exact
|
||
addresses they lost; `josé@` and `jose@` both survived
|
||
- the gate seeded **`off` on the upgrade** and `on` on a fresh install
|
||
- the dashboard warning fired with the right count and cleared on acknowledge
|
||
- a change request staged the address and **left the live one receiving mail**; the link went only to
|
||
the new address; opening it from a session-less client installed the address and **set no cookie**
|
||
- a replayed link, and a second account confirming an address the first had just taken, both returned
|
||
the **byte-identical** generic 404 — the real reason logged, never returned
|
||
- `NEWMAIL@RIG.TEST` was refused at registration as a duplicate of `newmail@rig.test`, while
|
||
`néwmail@rig.test` registered successfully beside it
|
||
|
||
**Left for later, deliberately:** nothing consumes `email_verification_required` yet — the engine that
|
||
would honour it is Phase 4 and the deliverability rules are Phase 9. It is seeded and editable now
|
||
because the fresh-vs-upgrade distinction is only knowable at the migration that adds it, and
|
||
reconstructing "was this install fresh?" afterwards is guesswork.
|
||
|
||
---
|
||
|
||
### Phase 2 — The trigger registry and the variable contract ✅
|
||
|
||
`api.registerEventTriggers` + `ctx.events.emit` in `modules/registries.js` and `modules/loader.js`;
|
||
`MODULE_API_VERSION` → 1.7.0 on both halves; core registers its own triggers (news, the four Team
|
||
events) through `registerCore()`. `npm run engagement:manifest` + the CI `--check`. **No delivery yet** —
|
||
emit validates, logs and stops.
|
||
|
||
A trigger declaration also carries its **audience ceiling** (G24) — the widest audience a rule may ever
|
||
give it — and its `kind` (`event` now, `scheduled` reserved for G25). Both are cheap here and expensive
|
||
to retrofit into the rule model later.
|
||
|
||
Phase 2 also lands `api.registerAudiences` and the ceiling arithmetic (§5.1a) — the same registration
|
||
discipline, and the same "cheap now, expensive later" argument G24 makes for the trigger ceiling.
|
||
|
||
**Acceptance:** core's triggers appear in `GET /admin/engagement/triggers`; a module registering an
|
||
un-namespaced trigger or audience fails to load with the holder named; **an `A OR B` composition takes
|
||
the narrower of the two ceilings, not the wider**; an audience whose module is uninstalled resolves
|
||
empty and shows dormant rather than erroring; a payload missing a `required` variable
|
||
throws in dev and is dropped+logged in prod; **a rule cannot be saved with an audience wider than its
|
||
trigger's ceiling**; `engagement-triggers.json` diffs zero in CI.
|
||
**Guardrails:** the new manifest `--check` (this is where the "manifest-style guardrail" the brief asks
|
||
about belongs); `check:modules` proves core's own trigger ids name no game concept.
|
||
|
||
#### As built (2026-08-29)
|
||
|
||
**Three design questions were settled by the org lead before any code**, and two of them the plan had
|
||
deferred to this phase on purpose:
|
||
|
||
| | Question | Decision |
|
||
|---|---|---|
|
||
| §7.2 | one namespace, or two? | **ONE.** A trigger is a payload contract attached to an id that may also carry a subscription toggle |
|
||
| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9** — `kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today; no evaluator yet |
|
||
| — | what "narrower" means for a ceiling | an explicit **subset lattice**; two incomparable ceilings have no bound and the save is REFUSED, never guessed |
|
||
|
||
**One namespace was the more invasive of the two options and it is worth stating what it bought and
|
||
what it cost.** It buys `notification_channel_prefs.stream_id` staying single-keyed (§4.5): under two
|
||
namespaces its primary key would have needed a `kind` discriminator, and `news.post` would have named
|
||
two different things forever. It costs a new rule in `registries.js` — **an id has exactly one owner
|
||
across both facets** — enforced in both directions, so a module cannot attach a payload contract to
|
||
another module's stream and cannot claim a stream id another module declared a trigger for. Core's
|
||
five trigger ids *are* its five stream ids, so the same-owner upgrade case is exercised on every boot
|
||
rather than only by a module.
|
||
|
||
Three consequences fell out of it that the §7.2 text did not anticipate:
|
||
|
||
- **The id grammar had to be relaxed, not just shared.** `STREAM_ID` did not admit `_`, and §4.3's own
|
||
worked example is `uo.house.idoc_warning`. Two grammars over one namespace would mean an id that is
|
||
legal as a trigger and illegal as the stream it is the same event as. It is a relaxation only —
|
||
every id valid before is valid now, and no stored id changes.
|
||
- **The legacy allowlist had to be shared too.** The seven grandfathered `uo.*` stream ids are exempt
|
||
from the prefix rule for triggers as well, and it cannot be otherwise: under one namespace
|
||
`idoc.warning` is a single id, so if `uo` may hold it unprefixed as a stream it may hold it
|
||
unprefixed as a trigger. Any other answer means those seven could never gain a payload contract.
|
||
- **The push catalog is unchanged.** `allStreams()` still serves the stream facet only, so a
|
||
trigger-only id (`uo.house.idoc_warning`) does not appear in the catalog the shipped Android client
|
||
reads. A trigger-only id gets email and in-app preferences in Phase 3 and no push toggle, which is
|
||
correct — there is nothing to push it to.
|
||
|
||
**The ceiling lattice is the one thing this plan named everywhere and defined nowhere**, and getting
|
||
it wrong would have been a security defect rather than a rough edge. §5.1a now carries the definition;
|
||
the short version is that the tempting flat ordering — self < owner < staff < members < authenticated
|
||
< everyone — permits a `staff`-ceilinged trigger to be given an `owner` audience, which is a rule that
|
||
mails `uo.cheat.detected` to the player it detected. Fewer people is not less exposure.
|
||
|
||
**What landed:**
|
||
|
||
- `server/src/modules/ceilings.js` — the six-value lattice, `permits`, `meet`, `meetAll`
|
||
- `registerEventTriggers` / `registerAudiences` on the existing `stage()` + `apply()` discipline, with
|
||
the cross-facet collision checks and the shared legacy allowlist
|
||
- `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`
|
||
- `MODULE_API_VERSION` → **1.7.0** on both halves; `MODULE_API.md` §1.1, §2.3, §2.4 and a new §6.8
|
||
|
||
**Two things §6.0b's Phase 2 row assigns that deliberately do NOT land in this window**, and neither
|
||
is a slip:
|
||
|
||
- **`integration-kit`'s `ci/core-ref.json`.** The pin names a website **`main`** sha and
|
||
`checkCoreApi.js` asserts equality with what that sha declares. 1.7.0 is on `edge`, `main` still
|
||
says 1.6.0, so the kit is green and moving the pin now would pin the book to a commit that is still
|
||
in flight — which the pin's own prose forbids ("the kit is written against what shipped"). The
|
||
re-pin and chapter 2's "registering a trigger" section belong in the **Phase 13 cutover window**.
|
||
- **`runicgateway.com`'s `platform.json.moduleApi` → 1.7.0.** `scripts/checkFacts.mjs` *fetches* the
|
||
authority from the source repo's `main`. Setting it to 1.7.0 today turns that repo red immediately
|
||
and for the whole `edge` period. It is Phase 12's, in the cutover window, exactly as the phase says.
|
||
|
||
**Four smaller decisions taken at build time:**
|
||
|
||
| | Decision | Why |
|
||
|---|---|---|
|
||
| `ceiling` required, no default | registration fails without one | there is no safe value to guess: `owner` silently breaks a broadcast, `authenticated` silently widens a staff-only event |
|
||
| `example` required per variable | registration fails without one | §4.3 property 3 is right and a soft version of it is worthless — without an example, preview needs a live game event, which is how template systems ship untested |
|
||
| audiences get their own id space | not the trigger/stream namespace | an audience names a set of PEOPLE, a trigger names an EVENT; `uo.team.members` as both is not a collision |
|
||
| `url` variables are site-relative | validated like `pageUrlTemplate` | a payload variable ends up in an href in an email; `//evil.test/x` passes an "is it rooted" check and is protocol-relative |
|
||
|
||
**One acceptance criterion is only half-dischargeable here, and that is not a slip.** "A rule cannot be
|
||
saved with an audience wider than its trigger's ceiling" needs `engagement_rules`, which is Phase 4's.
|
||
What Phase 2 owes and delivers is the arithmetic that check will call (`permits` / `meet` / `meetAll`,
|
||
with tests for the incomparable cases a total order would have waved through) **and** the same rule
|
||
applied where a row already exists: a declaration whose default `audience` is wider than — or
|
||
incomparable with — its own `ceiling` is refused at registration. Phase 4 adds the second call site,
|
||
not the second implementation.
|
||
|
||
**Left for later, deliberately:** nothing consumes an emitted event yet. `emit` validates and logs,
|
||
and Phase 4 replaces that log line with the engine call. Core's five declarations are registered but
|
||
not yet *emitted* — the Team pipeline keeps its hardcoded mail until Phase 6 migrates it onto the
|
||
engine, and this is what it migrates onto.
|
||
|
||
---
|
||
|
||
### Phase 3 — Channel preferences ✅
|
||
|
||
`notification_channel_prefs` + the idempotent backfill from `notification_subscriptions`. New
|
||
`GET·PUT /auth/me/notifications/channels`. **`/auth/me/notifications/subscriptions` keeps its exact wire
|
||
shape** and becomes the push projection — writes fan out to both.
|
||
|
||
**Acceptance:** the shipped Android app's flat `{streams:[…]}` PUT still round-trips, including the
|
||
empty-array case the app's DTO comment warns about; a per-channel PUT sets `email` without touching
|
||
`push`; a fresh user's email mode defaults `off` and ~~push defaults `instant`~~ **push defaults
|
||
`off` too** (§4.5's `defaultMode` — the struck text was wrong; see the as-built below).
|
||
**Guardrails:** swagger + route manifest; a test pinning the legacy wire shape byte-for-byte.
|
||
|
||
---
|
||
|
||
#### As built (2026-08-29)
|
||
|
||
**Three decisions were settled by the org lead before any code, and one of them corrects this
|
||
phase's own acceptance criterion.**
|
||
|
||
| | Question | Decision |
|
||
|---|---|---|
|
||
| — | how much of §3.1's `registerDeliveryChannel` lands now | **the declarative half only** — id, label, `carriesContent`, `defaultMode`, `supportsDigest`. `addressFor` / `render` / `deliver` wait for the phases that can exercise them |
|
||
| — | push's `defaultMode` | **`off`.** The acceptance line below said `instant`; it could not be |
|
||
| — | whole-set PUT or sparse | **sparse**, on the `(id, channel)` pair — deliberately unlike the two whole-set PUTs either side of it |
|
||
|
||
**The acceptance line was wrong, and it is worth saying exactly how.** "A fresh user's email mode
|
||
defaults `off` and push defaults `instant`" reads naturally beside §3.1's "email opt-IN, push
|
||
opt-OUT", and §3.1 got that from `team_notification_prefs`, where no row genuinely does mean notified.
|
||
But push **stream subscriptions** have never worked that way: `notification_subscriptions` holds a row
|
||
only when a user opted in, so no row means not subscribed. A `defaultMode` of `instant` would have
|
||
projected the **entire catalog** into `GET /auth/me/notifications/subscriptions` for every existing
|
||
user, and the shipped Android client would have shown every toggle switched on after an upgrade
|
||
nobody asked for. It is a live behaviour change dressed as a default. All three channels declare
|
||
`off`, and a test asserts the legacy GET returns `{streams:[]}` for a fresh user so it cannot drift
|
||
back.
|
||
|
||
**Why the channel registry could not wait for Phase 6.** §3.1 says `defaultMode` is expressed once,
|
||
and reading a preference means knowing it — a row exists only where a user has said something. The
|
||
alternative was a constant list beside the prefs model, i.e. that expression in a second place, two
|
||
phases before the registry replaced it. What did *not* land is the behavioural half: registering a
|
||
`deliver` nothing calls freezes a signature before anything has tried to use it, which is the reason
|
||
`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 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
|
||
`off` becomes a mode rather than an omission — which means this endpoint has **no empty-array case at
|
||
all**, so the kotlinx gotcha `putTeamPrefs` had to document (a defaulted array field is dropped from
|
||
the body, and "clear the last one" arrives as no array) simply cannot arise here. `prefs` is still
|
||
required, so a request DTO with no default is still the right shape on the app side.
|
||
|
||
**The projection, stated as an invariant.** `notification_subscriptions` stays exactly what
|
||
`utils/pushDispatch` reads, so this phase touches no delivery path at all. Both endpoints maintain:
|
||
**a `push` pref with `mode <> 'off'` ⟺ a `notification_subscriptions` row** — the legacy PUT with a
|
||
whole-set sweep, the channels PUT one pair at a time. An explicit `off` is *stored* rather than
|
||
deleted, because folding "I turned this off" back into "I never said" is only harmless while the
|
||
default happens to be off.
|
||
|
||
**One thing landed that the phase did not name, and it is a G24 consequence rather than scope creep.**
|
||
A trigger whose ceiling is `staff` can never reach a non-staff user, so offering them a toggle is
|
||
offering a control that does nothing *and* disclosing that the event exists — `uo.cheat.detected`
|
||
would otherwise appear by name in every player's preferences screen the moment Phase 11 declared it.
|
||
It is filtered from the catalog and gated on write, not merely hidden. `members` is deliberately not
|
||
filtered: membership is a runtime resolver's answer, and a preference set before joining a Team should
|
||
already be in place when you join. This gave `ceilings.js` its first consumer for the `staff` label's
|
||
long-standing claim of "admin / editor / moderator", now written down as `STAFF_CEILING_ROLES` — and
|
||
deliberately **not** `teamGrants.STAFF_ROLES` (`['admin','moderator']`), which answers the different
|
||
question of who may act on a Team they are not in.
|
||
|
||
**What landed:**
|
||
|
||
- `server/src/engagement/channels.js` — `registerDeliveryChannel`, `MODES`, `defaultMode`, `modesFor`,
|
||
`acceptsMode`; `coreChannels.js` declares push / email / inapp, registered through the subsystem's
|
||
one door (`require('./engagement')` from `app.js`, beside `registerCore()`)
|
||
- `notification_channel_prefs` + the replay-safe `INSERT IGNORE … SELECT` backfill, copying the
|
||
`announce_jobs → announce_job_legs` precedent
|
||
- `model/notificationChannelPrefs/` — the catalog union, effective-mode resolution, the sparse apply,
|
||
and `mirrorPushSet` for the legacy path; two single-row helpers on `notificationSubs.db`
|
||
- `GET · PUT /auth/me/notifications/channels`, swagger schemas, route manifest
|
||
- `ceilings.STAFF_CEILING_ROLES` / `isStaffRole`
|
||
|
||
**Left for later, deliberately:** no web or app surface. The endpoint exists and is documented
|
||
(`../android/PLAN.md` §11); the screens are Phase 7 (web) and Phase 8 (app), which is where a user can
|
||
see something a preference actually governs.
|
||
|
||
---
|
||
|
||
### Phase 4 — The engine: rules, cooldowns, outbox
|
||
|
||
**Split into 4a and 4b** at the start of the phase, on the same argument that split Phase 5: the half
|
||
that first makes this system capable of sending is worth reviewing without a React screen in the same
|
||
diff, and the ceiling arithmetic that decides who a rule may reach is worth reading on its own.
|
||
|
||
#### 4a — the engine (server only) ✅
|
||
|
||
`engagement_rules`, `engagement_audience_segments`, `engagement_cooldowns`, `engagement_outbox`,
|
||
`engagement_sends`, the sweep worker (`setInterval` + `unref` + `stop`, wired into `server.js` like
|
||
its five siblings), audience resolution, condition evaluation, delay and cancellation, and the
|
||
save-path validation the admin surface will call. **No HTTP surface at all** — provably done when a
|
||
fired trigger produces an outbox row and a send-log entry with no UI in the picture.
|
||
|
||
#### 4b — the admin surface ✅
|
||
|
||
Admin → Engagement → Rules, the §5.1a segment composition UI, and the routes underneath them. Q4's
|
||
answer places it in **its own top-level nav group** (below), so 4b also creates the group that
|
||
Triggers, Templates and the send log join in Phase 5.
|
||
|
||
**Acceptance:** a trigger fired twice inside `cooldown_seconds` for the same (rule, user, subject) sends
|
||
once; the same trigger for a *different* subject sends again; a scheduled row is cancelled by a
|
||
`cancel_on` trigger and never sends; a restart mid-window still sends exactly once; a duplicate
|
||
`dedupe_key` is a successful no-op.
|
||
**Guardrails:** swagger + route manifest (4b — 4a adds no routes); a named test for the multi-house
|
||
cooldown case (§4.1).
|
||
|
||
---
|
||
|
||
#### As built — 4a (2026-08-29)
|
||
|
||
**Three decisions were settled by the org lead before any code, and two of them are §7.1 questions
|
||
this phase was blocked on.**
|
||
|
||
| | Question | Decision |
|
||
|---|---|---|
|
||
| **Q2** | multi-instance: `SKIP LOCKED`, or document single-instance | **Neither, exactly**: a compare-and-set claim — `UPDATE … SET status='sending' WHERE id=? AND status='scheduled'`, the winner being whoever the server reports `affectedRows = 1` to. It is what §4.2a's ENUM was already shaped for (nothing else needs a `sending` state), it needs no open transaction and no MariaDB version floor, and it delivers Q2's intent |
|
||
| **Q4** | where the engagement admin surface lives | **its own top-level nav group**, "Engagement", beside Content / Moderation / System — Rules now, Triggers / Templates / Send Log in Phase 5. Email Delivery stays a section of Settings for now |
|
||
| — | one PR or two | **4a / 4b**, as above |
|
||
|
||
**What Q2's answer does and does not buy.** It makes the *outbox* safe for two app instances. It does
|
||
not make the deployment multi-instance: `announceWorker`, `teamDigestWorker`, `teamForumUploadSweep`
|
||
and `teamActivityPrune` are all still written for one, and widening them is not this phase's scope.
|
||
What it buys is that the one table that will carry mail is ready for the day it is, which is cheap now
|
||
and expensive after mail has doubled once.
|
||
|
||
**Two defects in this document's own §4, both found by building it.**
|
||
|
||
1. **§4.2a's `UNIQUE KEY uq_engo_dedupe (dedupe_key)` was a data-loss bug, not a style question.** A
|
||
dedupe key names the EVENT — "house 0x4001 entered IDOC" — and one event legitimately becomes many
|
||
outbox rows: an audience of fifty users is fifty rows, a rule spanning email and in-app doubles
|
||
that, and a second rule on the same trigger doubles it again. Under a *global* unique index the
|
||
first of those inserts wins and every other one is silently ignored, so ninety-nine recipients are
|
||
dropped by the mechanism that exists to stop a replayed event becoming a second mail. Shipped as
|
||
**`UNIQUE (rule_id, user_id, channel, dedupe_key)`**, which keeps exactly the replay guarantee and
|
||
nothing more. A test asserts one key fans out to six rows.
|
||
|
||
2. **§4.1's single `INSERT … ON DUPLICATE KEY UPDATE` cooldown claim does not work against this
|
||
codebase's pool**, and the way it fails is silent. Its answer is read out of `affectedRows` on the
|
||
usual contract — 1 inserted, 2 updated-and-changed, **0 for a duplicate key whose update changed
|
||
nothing**, that 0 being "still cooling". **The mariadb Node connector defaults `foundRows: true`**,
|
||
which makes `affectedRows` report rows *matched* rather than rows *changed*, and `utils/db.js` does
|
||
not override it. Under that pool the no-op returns 1 and is indistinguishable from a fresh insert:
|
||
**every cooldown passes, always.** Shipped as two statements instead, each of which is its own
|
||
atomic decision and neither of which asks `affectedRows` to mean two things:
|
||
|
||
```sql
|
||
-- 1. claim by moving the row, guarded in a WHERE clause where a row either matches or does not
|
||
UPDATE engagement_cooldowns SET last_fired_at = ?, fire_count = fire_count + 1
|
||
WHERE rule_id = ? AND user_id = ? AND subject_key = ?
|
||
AND last_fired_at <= ? - INTERVAL ? SECOND;
|
||
-- 2. matched nothing? then the row is absent or cooling; INSERT IGNORE separates the two
|
||
INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count)
|
||
VALUES (?, ?, ?, ?, 1);
|
||
```
|
||
|
||
Still race-free, and each race resolves the right way: two concurrent first fires both fall to the
|
||
INSERT and the primary key picks one; two concurrent fires after expiry serialise on the row lock
|
||
and the second re-evaluates its guard against the committed `last_fired_at`.
|
||
|
||
**The second defect is the reason this phase has a second test file.** `engagementEngine.test.js`
|
||
stubs the five tables and runs the engine's logic against in-memory stand-ins, which is right for
|
||
everything the engine *decides* — and it was **green against the broken cooldown claim**, because a
|
||
stub can only agree with whoever wrote it, and the same misreading produced both. The statements whose
|
||
correctness is a *server* contract now run against a real MariaDB in `engagementEngineSql.test.js`,
|
||
which creates a throwaway database, drops it, and **skips when there is none** so CI stays green
|
||
without one. The general lesson: a stub is a fine stand-in for a table and a poor one for a protocol.
|
||
|
||
**The gate order is the design.** Enabled rules → conditions → audience → **ceiling re-check** →
|
||
per-channel preference → per-rule hourly ceiling → cooldown → deduped enqueue. Two of those placements
|
||
are load-bearing:
|
||
|
||
- **The G24 ceiling is re-checked at SEND time, not only at save.** The save path already ran the same
|
||
`ceilings.permits`, so the only way this can fail is the case it exists for — a module upgrade that
|
||
*narrows* its trigger's declaration underneath a rule saved when it was wider. Without it, a rule
|
||
written against yesterday's declaration keeps reaching yesterday's population forever. This is the
|
||
second call site §5.1a promised, not a second implementation.
|
||
- **The hourly ceiling is checked before the cooldown**, because the ceiling is about the rule and the
|
||
cooldown is about one recipient. A rule that has hit its ceiling should not also burn every
|
||
recipient's cooldown slot on sends that never happen.
|
||
|
||
**Segments (§5.1a) as built, with one rule the design did not state.** `not` is legal **only as a
|
||
child of `and`**. A complement needs a universe, and the only one available that does not widen is
|
||
the set its siblings produced: `A AND NOT B` is "A, less B", which is what an operator wants and
|
||
cannot be composed into a broadcast. A bare `NOT B` — or `A OR NOT B` — would have to mean "everyone
|
||
except…", which is a way to build the whole deployment out of one narrow audience and is precisely the
|
||
widening rule 3 forbids. It is refused at save with that sentence.
|
||
|
||
The other half of that: **a `not` contributes no ceiling to the meet.** Excluding people cannot widen
|
||
who an expression reaches, so folding the excluded audience's ceiling in would refuse safe segments —
|
||
`members AND NOT staff` would hit `meet('members','staff') = null` and be rejected even though it
|
||
reaches strictly fewer people than `members` alone.
|
||
|
||
**Dormancy, three ways, and none of them deletes anything** (§7.3, §5.1a rule 4). A rule naming an
|
||
unregistered trigger, a rule whose channel is gone, and a rule whose segment was deleted are all
|
||
*listed, flagged and left alone*. In particular `engagement_rules.audience_segment_id` deliberately
|
||
carries **no foreign key**: `ON DELETE CASCADE` would delete an operator's rules and `ON DELETE SET
|
||
NULL` would silently fall the rule back to its plain `audience` column — and that fallback reaches a
|
||
*different set of people*, which is the exact failure §5.1a rule 4 exists to prevent. Deleting a
|
||
segment that a rule still uses is refused in the model, with the count.
|
||
|
||
**Conditions are a small closed grammar**, not an expression language: and/or/not over comparisons of
|
||
one *declared* variable against a literal, every operator renderable as a dropdown, bounded in list
|
||
length and nesting depth because the tree comes out of a JSON column an admin can write and is walked
|
||
on the emit path. Two properties worth keeping:
|
||
|
||
- **An absent variable makes every comparison false, including `ne`.** "Not equal to IDOC" reads as
|
||
satisfied by nothing at all, and treating it that way would fire a rule on every event that omits an
|
||
optional variable. `present` / `absent` are the honest way to ask.
|
||
- **A tree that no longer parses evaluates false**, never "no conditions". Failing closed stops the
|
||
mail; failing open mails everyone the rule could ever reach.
|
||
|
||
**`ctx.events.emit` does not await the engine.** It is called from inside a game-event handler, and
|
||
the caller's job is to say the event happened — not to wait on rule lookups, audience resolution and a
|
||
dozen inserts to find out whether it is allowed to carry on. That is the same argument the C# side's
|
||
`Emit()` makes about the Core thread. `dispatch` catches everything internally and never rejects. The
|
||
consequence a caller must know: **`emit` returns before the outbox rows exist**, so a test that wants
|
||
the delivery decision calls `engine.dispatch` directly.
|
||
|
||
**Nothing is delivered, and that is visible rather than pretended.** A channel's `deliver` arrives with
|
||
email in Phase 6 and the in-app inbox in Phase 7. Until then the worker claims the row, finds no
|
||
`deliver`, finishes it `failed`, and writes a send-log row saying so in as many words. Recording
|
||
`sent` would be a lie in the one table whose entire purpose is answering "did they get it"; leaving the
|
||
row `scheduled` would mean an IDOC warning queued today arriving three weeks later on the deploy that
|
||
first shipped a mailer. On a real deployment the path is unreachable anyway — core seeds no rules and
|
||
`enabled` defaults to 0, so nothing enqueues until 4b's screen exists and an operator uses it.
|
||
|
||
**Three smaller things the phase settled:**
|
||
|
||
- **`everyone` and `authenticated` resolve identically.** A signed-out visitor has no address, no
|
||
device and no inbox, so the widest set the engine can deliver to is the active user table. The
|
||
lattice still distinguishes them — a trigger ceilinged `everyone` permits an `authenticated` rule
|
||
and not the reverse — and only the *resolution* coincides.
|
||
- **A plain `members` audience with no segment reaches nobody.** `members` is the ceiling for "a
|
||
module-declared list"; without a segment there is no list, and core knows no game vocabulary with
|
||
which to guess. Inert and visible, rather than quietly falling back to something wider.
|
||
- **Every audience is filtered through `users.status = 'active'`, including a module's.** A module's
|
||
resolver returns ids over its own store and has no notion of account status; a banned account must
|
||
not be mailable by a module returning its id.
|
||
|
||
---
|
||
|
||
#### As built — 4b (2026-08-29)
|
||
|
||
The admin surface over the Phase 4a engine: **Admin → Engagement → Rules**, **Admin → Engagement →
|
||
Audiences**, the twelve routes under them and the reach preview. Nothing in the engine changed; what
|
||
changed is that an operator can now reach it, which is what makes every rule in 4a's model a rule a
|
||
person will actually meet.
|
||
|
||
**Four decisions the org lead settled before any code**, all four the recommendation:
|
||
|
||
| | Question | Decision |
|
||
|---|---|---|
|
||
| Segments | a fifth nav entry, a sub-tab, or inline in the rule editor | **its own nav entry, "Audiences"** — a segment is a reusable object with its own list, its own edit and a delete that is *refused with a count*, and none of those has anywhere to live inside a screen about rules |
|
||
| The switch | a `PATCH …/enabled` route, or a full `PUT` | **its own route**, writing that column and no other |
|
||
| Preview | count only, count plus a sample, or none in 4b | **count only, on demand** |
|
||
| Delete | hard delete with a confirm, or disable-only | **hard delete** — the send log survives it |
|
||
|
||
**The enable switch is the decision with the most content in it, and it is a correctness argument
|
||
rather than a convenience one.** A `PUT` re-validates, and validation is against the registries *as
|
||
they are now*. So the rules a re-validating toggle cannot switch off are exactly: a rule whose module
|
||
has been uninstalled, a rule naming a channel that is gone, and a rule whose trigger has since
|
||
**narrowed** its ceiling underneath a saved audience. Those are the three rules an operator most
|
||
urgently wants stopped. `PATCH …/enabled` writes one column and always works. Switching a rule *on*
|
||
without re-validation is safe for a different reason: `audiences.permitted` runs again at send time,
|
||
so an enabled-but-no-longer-permitted rule resolves to nobody rather than to the wrong people.
|
||
|
||
**The reach preview calls the engine's own resolver**, `audiences.resolveForRule`, rather than a
|
||
second query that agrees with it today — a preview built out of its own SQL can be wrong about the
|
||
one thing it exists to say. It answers a **count and nothing else**: not a sample, not names. The
|
||
resolver's output for a module-declared segment is a set of players derived from game data, and an
|
||
editor that rendered them would be a user-enumeration surface reached from a screen about mail
|
||
scheduling. Three fields exist because the bare number would otherwise be a lie:
|
||
|
||
- **`capped`** — every audience query is bounded at `MAX_AUDIENCE` (5000), so a count landing exactly
|
||
on the bound is a floor. The screen says "at least 5000", never "5000".
|
||
- **`reason`** — an `owner` audience resolves per event from an id the event carries, so it has no
|
||
advance answer. It reports 0 *with the reason*, because a bare 0 reads as "nobody".
|
||
- **`permitted`** — whether the trigger's G24 ceiling allows the reach just counted. Without it the
|
||
editor shows a healthy number beside a save the server will refuse, which reads as a bug in the
|
||
save rather than as the ceiling doing its job.
|
||
|
||
**Two defects found by walking it against a live server**, neither of which any test in the tree
|
||
would have caught, and both of them in Phase 4a's code rather than 4b's:
|
||
|
||
1. **A rule pointing at a *dormant* segment was reported as healthy.** `listAnnotated` asked only
|
||
whether the segment ROW still existed (§7.3's case: the segment was deleted). The other shape of
|
||
the same failure is §5.1a rule 4's: the segment row is exactly where it was, and every audience in
|
||
it belongs to a module that has been uninstalled. Both leave the rule reaching nobody; only one
|
||
leaves nothing behind. Uninstalling a module under an enabled rule produced a rule the screen
|
||
showed as **on, healthy and firing**, which is the one thing this screen exists not to do. The
|
||
walk over the expression now lives in `engagement/segments.js` as `missingAudiences`, and both the
|
||
segment list and the rule list ask it, so the two cannot disagree about what dormant means.
|
||
2. **"1 rule still use this segment."** The delete refusal pluralised the noun and not the verb. It is
|
||
a one-word fix and it is in the sentence an operator reads at the moment they are being told no.
|
||
|
||
**Six more defects came out of driving the two screens in a browser**, after the API walk above had
|
||
already found the two in Phase 4a's code. None of them is visible from a test or from `curl`, and two
|
||
of them cost an operator something real:
|
||
|
||
1. **The Audience dropdown rendered empty before a trigger was chosen.** `audienceChoicesFor(null)`
|
||
answers `[]` — correctly, because without a trigger there is no ceiling and therefore nothing it
|
||
may legitimately offer. But a `<select>` with zero options reads as a control that is *broken*,
|
||
not one that is waiting. It now carries "Choose a trigger first…" and is disabled.
|
||
2. **A `members` audience with no saved audience reaches nobody, and only the preview button said
|
||
so.** This is the design (§5.1a: `members` is the ceiling for "a module-declared list", and
|
||
without a list core cannot guess) — but it is also the **default** the instant an operator picks
|
||
any `members`-ceiling trigger, which turns a documented property into a trap: the rule saves, gets
|
||
switched on, and mails nobody, with nothing on the screen saying so unless the operator happens to
|
||
press Preview. The editor now says it inline, before the save, and stands down once a preview has
|
||
answered the same question more precisely.
|
||
3. **The composer offered "exclude" on the only row**, which builds an `and` whose every child is a
|
||
complement. The server refuses that correctly — "has nothing but complements, there is no set to
|
||
exclude from" — but only after a save. It is one checkbox away at all times, so the composer now
|
||
refuses it inline, in the operator's words.
|
||
4. **The template-key input truncated its own placeholder** and the text said "optional until Phase
|
||
5", which is a sentence about this document rather than about the operator's deployment.
|
||
5. **"segment" leaked into a screen that says "saved audience" everywhere else.** The API, the schema
|
||
and this document should keep saying *segment* — one word for one table — but an operator meets
|
||
the concept under a heading that reads "Audiences", and a sentence that switches vocabulary
|
||
mid-screen reads as a sentence about something else. Translated at the point of display only.
|
||
6. The composer repeated its "AUDIENCE" heading above every row; the heading belongs to the group.
|
||
|
||
**And one thing that is worth knowing but is not a defect in this phase:** rebuilding
|
||
`client/dist` while the server is running blanks the whole SPA. The HTML shell resolves core's hashed
|
||
bundle filename at boot, so after a rebuild it still points at a filename that no longer exists;
|
||
core's bundle 404s, `window.__rg` is never published, and `modules/uo/entry.js` throws the
|
||
"core did not publish its shared dependencies" error from `MODULE_API.md` §3.1 into a blank page.
|
||
**Restart the server after every client build.** The error names core, and core is not at fault.
|
||
|
||
**A `window.confirm` freezes browser automation.** The two destructive actions here use it, matching
|
||
`InvitesAdmin` and `ModulesAdmin`, and that is the right call for the product — but a native dialog
|
||
blocks CDP entirely, so a delete cannot be driven from a script and a session that opens one is stuck
|
||
until a human dismisses it. Exercise those two paths over the API instead.
|
||
|
||
**Three smaller things the screens decided:**
|
||
|
||
- **A rule's trigger is fixed once the rule exists.** `engagementRules.db.update` never carried
|
||
`trigger_id`, and 4b is where that becomes a stated rule rather than an omission: a rule's cooldown
|
||
rows, its pending outbox rows and its send-log history are all about one trigger id, and
|
||
re-pointing the rule silently re-attributes all three. The editor renders the field read-only and
|
||
says why; changing the trigger means a new rule.
|
||
- **A condition tree the editor cannot render is shown, not flattened.** The editor offers the flat
|
||
half of the grammar — one and/or over a list of comparisons, every operator a dropdown narrowed to
|
||
the picked variable's declared type. `A AND (B OR C)` flattened to `A AND B AND C` fires on
|
||
different events and the operator would have no way to know the save had done it, so such a rule
|
||
opens read-only with its JSON visible and one honest choice: leave it, or clear it and start again.
|
||
- **Literals are coerced to the type the trigger declared**, in the client, before the save. Every
|
||
value in an HTML input is a string and the server rightly refuses `{cmp: 'gt', value: "5"}` against
|
||
an `int`. A value that does not parse is passed through **unchanged** rather than becoming `NaN` or
|
||
`false`, so the refusal names the variable instead of the rule saving cleanly having compared
|
||
against a number nobody typed.
|
||
|
||
**Where the logic lives.** `client/src/lib/engagementRules.js` — plain JS, 25 tests — holds
|
||
everything the two screens *decide*: the form↔payload mapping, which audiences a trigger permits
|
||
(derived from the `permits` list the server sends, never a second copy of the lattice), the reach
|
||
sentence, the `not`-placement check, the condition round trip. None of it is a boundary; the server
|
||
decides and the engine re-checks. It is in a `.js` because the test runner cannot reach a `.jsx`, and
|
||
because a client-side copy of a security rule is a copy that drifts — so the client holds the
|
||
*affordance* (do not offer what will be refused) and the server holds the answer.
|
||
|
||
**The rig that made the live walk possible, and worth reusing.** Core declares **no audiences** — it
|
||
knows no game vocabulary — and `module-uo` 0.3.0 declares none either, so on a stock local stack the
|
||
whole segment half of this phase is untestable. A ~20-line throwaway module dropped into
|
||
`website/modules/rig/` (a `module.json` plus a `server/index.js` calling `api.registerAudiences`)
|
||
declares four audiences at three different ceilings and makes the entire §5.1a arithmetic walkable:
|
||
`OR` taking the tighter ceiling, two incomparable ceilings being refused rather than guessed,
|
||
`members AND NOT staff` being allowed, a parameterised audience, the 409 on a segment in use, and —
|
||
by deleting the directory and restarting — dormancy. `modules/*` is gitignored, and the module needs
|
||
its ids **namespaced to its own id** (`rig.roster.governors`, not `roster.governors`); the registry
|
||
refuses anything else, and it also refuses an id with no dot in it at all.
|
||
|
||
---
|
||
|
||
### Phase 5 — Templates: the seeded set, then the editor ✅
|
||
|
||
Two slices, landing in this order **on purpose** — the seeded set has to exist before the editor, so the
|
||
editor is opening something rather than facing a blank page.
|
||
|
||
**5a — storage, blocks, renderer, seeds.** ✅ `engagement_templates`, the `email.*` block family with
|
||
`toText`, the HTML + plain-text renderer, brand-value resolution, and the §4.6.1 seeded set. The five
|
||
transactional bodies move out of `mailer.js` into seeded rows and `mailer` renders them. **No editor
|
||
yet** — this slice is provably done when the same mail goes out from a template that used to come from a
|
||
string literal.
|
||
|
||
**5b — the editor.** ✅ The §4.6.2 surface: variable palette from the trigger declaration, live preview
|
||
from `example` values, side-by-side HTML/text, preview widths plus dark mode, test send, duplicate.
|
||
Built on the existing block/prop-panel machinery, not a second one — and, because a mail body is rendered
|
||
by the SERVER, the preview is rendered there too and framed rather than redrawn in React. It also lands
|
||
the other two screens Q4 promised Phase 5: **Triggers** (read-only, from the registries) and the
|
||
**Send Log** (G15).
|
||
|
||
**Acceptance (5a):** every one of the five current message types renders byte-comparably from its seeded
|
||
template; re-running the seeder is a no-op; a seeder bump updates a `customized = 0` row and **skips** a
|
||
`customized = 1` one; two deployments with different `BRAND_*` produce differently-branded mail from the
|
||
same seed.
|
||
**Acceptance (5b):** a template referencing an undeclared variable is refused at save **with the variable
|
||
named**; preview renders from examples with no live event; a published template with an empty text part
|
||
is refused; a `protected` template cannot be deleted but can be duplicated; a template pinned to an
|
||
older `trigger_version` is flagged in the admin list; an interpolated variable containing `<script>`
|
||
renders escaped.
|
||
**Guardrails:** **CSP — this is the one genuinely new CSP surface.** The preview renders
|
||
operator-authored HTML; it must be sandboxed (`<iframe sandbox>` with no `allow-scripts`, `srcdoc`,
|
||
`about:blank` origin) so it never executes under the site's origin, and `sanitizeHtml` runs on write as
|
||
well as on render. Worth a `test/csp.test.js` sibling asserting the preview frame's attributes.
|
||
|
||
#### As built — 5a (2026-08-29)
|
||
|
||
Built as website#TBD. Three decisions were settled by the org lead before any code, each because the
|
||
tree contradicted something the plan assumed.
|
||
|
||
| | Question the survey raised | Decision |
|
||
| --- | --- | --- |
|
||
| **The HTML part** | **Not one existing mail has one.** All six senders in `mailer.js` set `text:` only, so "renders byte-comparably" is a statement about a *text* body and whether 5a introduces HTML to live mail was an open choice | **Multipart now, text byte-identical.** The text part is byte-for-byte what went out before; the HTML alternative is new. The renderer is therefore exercised by real mail in the phase that builds it rather than shipping dead until 5b |
|
||
| **The block registry** | **The server block registry has no renderer of any kind.** Page blocks are drawn by React on the client; `registerBlock` freezes a fixed field set and would silently DROP a `toHtml`/`toText` | **A sibling registry, with the machinery shared by binding.** §4.4's "do not build a second editor" is about the editor, and 5b still drives these through the existing block/prop-panel machinery |
|
||
| **The seed scope** | §4.6.1 lists nine seeds but `teamNotify`/`teamDigestWorker` are explicitly Phase 6's to rewrite | **Seed all nine, wire the six transactional.** Phases 6 and 7 open something rather than each shipping seeds of their own |
|
||
|
||
**And a correction to §4.6.1 itself:** it lists `auth.email-verify` as "*(new — Phase 9)*". Phase 1b
|
||
already shipped `mailer.sendEmailVerification`, so it is a **current** message type, not a future one —
|
||
six bodies moved, not five, and all six are pinned by the byte-comparison test.
|
||
|
||
##### The two registries are siblings, and that is an argument rather than a preference
|
||
|
||
Sharing one Map would have cost three things. Email blocks **render on the server** and so carry
|
||
`toHtml`/`toText`, which the page registry's frozen entry shape has nowhere to put. One Map is one
|
||
namespace, and the page registry's only server consumer is `pages.model.js` — so `email.heading` in it
|
||
means a CMS page containing an email block validates and saves, with nothing on the client able to draw
|
||
it. And the entry shapes genuinely differ: `cacheTTL` and `container` mean nothing to a mail body.
|
||
|
||
What *is* the same rule for both is shared by binding, not by copy. `blocks/validateBlocks.js` and
|
||
`sanitizeBlocks.js` became factories over a registry lookup (`makeValidateBlocks` /
|
||
`makeSanitizeBlocks`), each exporting the page-bound instance every existing caller already imports, and
|
||
`emailBlocks/` binds the same walk to its own registry. The envelope rules, id uniqueness, schema
|
||
dispatch and the validate-then-sanitize order therefore cannot drift between the families. There is a
|
||
test asserting both directions of the isolation: an `email.heading` fails page validation, and a plain
|
||
`heading` fails email validation.
|
||
|
||
##### The token grammar has no conditional, so the ternaries stayed at the call site
|
||
|
||
`{{ name }}`, a bare declared variable, and nothing else — no filters, conditionals, loops or dotted
|
||
paths. Repetition is a block (`email.itemList` renders a declared *list* variable), which is the one
|
||
place a template needs "for each" and it already has a typed, validated home.
|
||
|
||
That has a visible consequence. `mailer` built ` for the account “Darrow”` with a ternary, and a
|
||
logic-free template cannot. So the ternary stays where a ternary belongs and its **result** arrives as a
|
||
variable — `forWhom`, `roleLabel`, `invitedBy`, `moreNote` — each declared with an `example` showing
|
||
exactly what it produces, leading space and quotes included. It is not pretty in the editor and it is
|
||
the price of not giving operator-authored data a conditional to get wrong. **Both branches of every
|
||
ternary are asserted**, because the empty one is what a template language with a conditional would most
|
||
likely get wrong.
|
||
|
||
Where a conditional would otherwise be reached for, "nothing in, nothing out" stands in: a block whose
|
||
content interpolates to nothing renders nothing, in **both** parts. `{{moreNote}}` on its own line is a
|
||
line the caller can decline to supply.
|
||
|
||
##### Three properties of the renderer that are load-bearing
|
||
|
||
- **The shell contributes structure and no content.** No appended footer, no injected logo, no "sent by"
|
||
line. An unsubscribe line is a *variable inside the template*, so an operator can move it, reword it,
|
||
or see that a transactional mail correctly has none — and, more importantly, the HTML and text parts
|
||
say the same things. A footer in one and not the other is a deliverability signal and means the text
|
||
reader is told less than the HTML reader.
|
||
- **Only the accent comes from the theme.** Every shipped preset is a DARK palette, and §4.6.2 already
|
||
names the failure: a light-only template "renders as unreadable dark-on-dark in about a third of
|
||
inboxes", because clients invert or force their own background. Deriving a light palette from a dark
|
||
one is a guess at six colours; taking the one colour that carries the brand is exact. §4.6.1's
|
||
property 2 holds either way — no seeded template contains a hex code, asserted by a test.
|
||
- **A URL built from a variable is re-checked after substitution.** A stored `{{resetUrl}}` says nothing
|
||
about where it points. Checking only the literal would let a variable carrying `javascript:` become an
|
||
href; a substituted value that fails `isSafeUrl` loses its href and renders as inert text rather than
|
||
vanishing, because silently dropping it would hide from the reader that the mail meant to offer them
|
||
something.
|
||
|
||
##### A missing row renders the shipped default, which is what makes the whole move safe
|
||
|
||
`renderByKey` falls back 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, on a row hand-edited in the
|
||
database. Without it, moving a password-reset body into a table would have made every failure mode of
|
||
that table a failure mode of account recovery. `protected = 1` stops the last of those from being
|
||
reachable through the API at all.
|
||
|
||
##### The seed guard is in the SQL, not in a read-then-write
|
||
|
||
`INSERT IGNORE`, then `UPDATE … WHERE seed_key = ? AND customized = 0 AND seed_version < ?`. A check in
|
||
JavaScript followed by an UPDATE leaves a window in which a concurrent boot overwrites an edit an
|
||
operator made a moment earlier, and a deployment can start two app processes at once. MariaDB's
|
||
`ON DUPLICATE KEY UPDATE` cannot carry a WHERE, which is why this is two statements rather than the
|
||
upsert §4.6.1 sketches.
|
||
|
||
Related, and recorded because [Phase 4a](#as-built--4a-2026-08-29) was bitten by the same thing: the
|
||
connector defaults **`foundRows: true`**, so `affectedRows` on an UPDATE counts *matched* rows. For the
|
||
seeder that is harmless (its WHERE only matches a row that will change); for an operator's save it is
|
||
the semantics wanted — re-saving a template unchanged is a success, not a 404. Both are now stated in
|
||
the code rather than relied on.
|
||
|
||
##### Two behaviour changes an operator will notice
|
||
|
||
1. **Mail is multipart.** A client that prefers HTML now shows a branded body where it used to show
|
||
plain text. Nothing a text-only reader sees has changed.
|
||
2. **Subjects resolve the deployment's own name.** They interpolate `{{siteName}}`, which is
|
||
`settings.getInstanceName()` — the admin-set `site_title` falling back to `BRAND_NAME`, rather than
|
||
`BRAND_NAME` alone. On an instance that never set a site title nothing changes; on one that did, the
|
||
subject finally says what the site calls itself.
|
||
|
||
Also inherited rather than introduced, and now visible: `admin.contact-message` declares **both**
|
||
`fromLabel` and `fromName` — the same missing name with the two different fallbacks the literal used
|
||
('a visitor' in the subject, 'unknown' in the body). Kept exactly, asserted, and now editable by whoever
|
||
wants one word.
|
||
|
||
##### One defect this phase found in a Phase 1 check
|
||
|
||
`npm run check:hosts` (§3.2 rule 4) read the template key **`auth.email-verify`** as the hostname
|
||
`auth.email`. `.email` is a real TLD and the pattern's trailing `\b` matches between `l` and `-`, so any
|
||
engagement identifier whose label happens to end in a TLD tripped it — and §4.6.1 names that key. Fixed
|
||
with a `(?![-\w])` after the TLD: a real hostname's TLD is its last label, so a following `-` or word
|
||
character means the match is a truncation of a longer identifier. Everything a host *is* followed by (a
|
||
quote, `/`, `:`, `?`) still matches, and the checker's own suite gained both the identifiers it must now
|
||
accept and two real `.email` hosts it must still catch.
|
||
|
||
##### Verification
|
||
|
||
- **31 new server tests** (`test/emailTemplates.test.js`) and 3 added to the host-check suite. Full
|
||
server suite green: 1388 passing, with the one known Windows CRLF artifact
|
||
(`engagement-triggers.json` byte comparison) and `honeypot.test.js`'s 10-second pool-acquire flake
|
||
under full-suite parallelism, both of which reproduce on clean `edge`. `routes.manifest` /
|
||
`routes.guards` need `modules/uo` moved aside, as ever; 5a adds no routes.
|
||
- **The seeder's SQL against a real MariaDB**, because the unit tests stub `seedOne` and therefore prove
|
||
the loop rather than the statements — the exact shape of Phase 4a's `foundRows` trap, where a stub
|
||
agreed with a broken query. First run 9 inserted; second 9 skipped; after a `seedVersion` bump 8
|
||
updated and the customized row skipped, keeping both its words and its old version, and surfaced by
|
||
`staleCustomized`. The blocks JSON round-tripped through `MEDIUMTEXT`, and `update()` twice with
|
||
identical values returned true both times.
|
||
- **Real mail, end to end**, through nodemailer and SMTP into a mailpit catcher: all five senders, the
|
||
message arriving as `multipart/alternative; charset=utf-8` with the curly quotes correctly
|
||
quoted-printable-encoded, the button rendering with its bare URL beneath it, and the text part
|
||
matching the deleted literal.
|
||
- **The same seed rows, two deployments.** Run as *UOMysticmoon* with a gold accent and as *Vesper Isle*
|
||
with a blue one, changing only stored settings: the subjects and the button colour follow the
|
||
deployment, from identical rows. §5a's fourth acceptance criterion, proved rather than argued.
|
||
- **A contact message carrying `<script>alert(1)</script> & <img src=x onerror=…>`**, sent for real:
|
||
escaped in the HTML part, raw in the text part, no live tag in the delivered message.
|
||
- One accidental proof worth keeping: an early rig run had a `settings` table missing `updated_at`, and
|
||
`ambient()` degraded to the `BRAND_*` env values with a warning and sent the mail anyway. That path
|
||
is not otherwise easy to reach.
|
||
|
||
**Still 5b's:** the editor, the admin Templates screen, the template CRUD routes, the save-time
|
||
undeclared-variable refusal (`variablesFor` is in place and is what it will ask), the sandboxed preview
|
||
and its CSP test, and the `runicgateway.com` admin docs page §6.0b assigns the pair.
|
||
|
||
#### As built — 5b (2026-08-29)
|
||
|
||
Built as website#TBD. **Q4 was already settled** (Phase 4a), so the only thing needing a decision before
|
||
code was the scope: §Phase 5's body names the editor and nothing else, but Q4's answer and §6.2 both
|
||
promise "Triggers, Templates and the send log" in Phase 5. **All three shipped.** Triggers is a
|
||
read-only render of two endpoints Phase 2 already serves and cost no server work; the send log is one
|
||
paged route over a table that has been filling since Phase 4a and whose index was built for it. Leaving
|
||
either out would have left the nav group half-built and **G15 — "no send log, no delivery status, no
|
||
audit" — open with the rows already on disk.**
|
||
|
||
Five more decisions were settled by the org lead before any code, each because the tree contradicted
|
||
the plan or the plan contradicted itself.
|
||
|
||
| | What the tree said | Decision |
|
||
| --- | --- | --- |
|
||
| **Where the preview comes from** | The client block registry mirrors the server's, but its entry shape carries a **React `component`** — page blocks are drawn in the browser. Email blocks are drawn on the SERVER; a mail body is a string this process produces | **A server preview route**, rendered into a sandboxed iframe. A React preview would be a second renderer for one artifact, agreeing with the send path on the day it was written and drifting from the first Outlook fix onward |
|
||
| **`status` was unenforced** | `draft`/`published` shipped in 5a and **nothing read it** — `getByKey` returns any row, so an operator who saved a template as a draft kept mailing it | **`renderByKey` requires `published`** and otherwise falls back to the shipped seed, the posture 5a already built for a missing or unusable row. A draft now means what the word means |
|
||
| **Test send vs. `trigger_id NOT NULL`** | §4.6.2 wants a test send "recorded in `engagement_sends` like any other message", but every transactional template has `trigger_id` NULL and there was nothing honest to put in the column | **A synthetic `core.admin.test-send` id.** No schema change, no nullable column, and the log keeps meaning one thing. It is deliberately not a registered trigger, and the screen renders it by name so nobody goes looking for it in the catalog |
|
||
| **Deleting** | `protected` blocks deletion. Nothing stopped deleting a template a rule's `template_keys` points at | **409 while a rule uses it, naming the rules** — the answer Phase 4b already gives for a segment in use, for the same reason: the alternative is a rule that silently stops producing mail |
|
||
| **Creating** | §4.6.2 names duplicate and never mentions a blank create | **Duplicate only.** Every template on a deployment descends from a shipped one that renders, which is the whole reason 5a landed first |
|
||
|
||
##### The correction that changed the most code
|
||
|
||
§4.6.2 introduces duplicate as "how an operator customizes a `protected` template safely: duplicate,
|
||
edit, point the rule at the copy, leave the original intact". The schema comment written in 5a says the
|
||
opposite — "Editable, NOT deletable" — and **the org lead's ruling is the schema's: a default template
|
||
is edited in place.** `customized = 1` is what stops the next seed bump from taking that edit back, and
|
||
it has been in the UPDATE's own WHERE since 5a. So `protected` now blocks deletion and *nothing else*,
|
||
and duplicate is how a NEW template comes into being rather than how an existing one is customized.
|
||
|
||
##### Three things the plan did not know, found by building it
|
||
|
||
**1. The variable check cannot be a token scan, because of one block.** §4.6.2's refusal — "a template
|
||
referencing an undeclared variable is refused at save with the variable named" — reads as a scan for
|
||
`{{name}}`. It is not sufficient. `email.itemList.variable` holds a **bare name** (`items`), because the
|
||
block iterates the value rather than interpolating it; a token scan sees nothing there. A digest pointed
|
||
at `itmes` would have saved clean and arrived empty, which is the one variable mistake a reader of the
|
||
template cannot see. Blocks now optionally declare `variables(props)` in the registry — `itemList` is the
|
||
only one that does — and `emailBlocks/variables.js` walks tokens *and* declarations across the subject,
|
||
the text override and every block prop. The editor makes that field a `<select>` over the trigger's list
|
||
variables rather than a text input, so the mistake is unavailable in the first place.
|
||
|
||
**2. A duplicate that drops its seed reference cannot be saved.** The obvious shape for `create` was
|
||
`seed_key = NULL` — a copy is not a seed and must never be adopted by the seeder. But
|
||
`templates.variablesFor()` resolves the palette from the trigger *or, for the generic templates tied to
|
||
no trigger, from the seed*, so a seedless, triggerless copy has only the four ambient variables, and
|
||
**duplicating `notify.event` would have been refused for the `{{title}}` and `{{intro}}` it was copied
|
||
with** — the one action §4.6.2 offers, refusing itself. The copy inherits `seed_key`, and it is safe to
|
||
because `customized = 1` is what the seeder actually reads: `seedOne`'s UPDATE carries `AND customized =
|
||
0`, so it can only ever match the seeded row. `staleCustomized` *does* match a copy, and should — "the
|
||
default you duplicated has been improved" is worth telling someone.
|
||
|
||
**3. `validateEmailBlocks` returns `{ valid, errors }`, not an array** — and the first version of the
|
||
model tested it with `.length`, which is `undefined` on an object, so **block validation never ran at
|
||
all**. Unvalidated props would have reached both the renderer and the row. Nothing about the code looked
|
||
wrong; the test that caught it was the one asserting a preview refuses a block with an unknown prop.
|
||
`pages.model.js` destructures it, which is why that caller has never had the bug.
|
||
|
||
##### The one genuinely new CSP surface, and how it is held
|
||
|
||
§4.6.2 called the preview out as "the one genuinely new CSP surface", and it is: operator-authored HTML
|
||
reaching a rendered surface. Three things hold it, in order of what they cost to remove:
|
||
|
||
- The preview renders in `<iframe sandbox="" srcDoc={…}>` — **every restriction on, nothing granted
|
||
back**, from an opaque origin. No `allow-scripts`, no `allow-same-origin`.
|
||
- The HTML crosses as a **JSON string**, never as a document served from this origin. That is not a
|
||
convenience: served as a document it would run under the site's own CSP with access to its cookies.
|
||
- Blocks are validated then sanitized **on write**, against the `email.*` registry, so the stored row is
|
||
already through the gate before any of the above matters.
|
||
|
||
The sibling test §4.6.2 asked for is `client/test/emailTemplates.test.js`. It reads the **attribute**,
|
||
not the file — the first version searched the source for `allow-scripts` and failed on the comment above
|
||
the iframe explaining that there is no `allow-scripts`. A check a correct file fails is worse than no
|
||
check, because the way to make it pass is to delete the explanation.
|
||
|
||
##### What was verified
|
||
|
||
- **23 model tests** (`server/test/engagementTemplatesAdmin.test.js`), one per acceptance criterion plus
|
||
the two the tree made necessary. Server suite 1403, client 324, both green.
|
||
- **The drift check was verified by breaking it both ways** — a client `version: 2` against the server's
|
||
`1`, and deleting a registration outright — because a pairing check that spans a process boundary is
|
||
the kind that silently stops checking.
|
||
- **`renderByKey`'s draft arm has its own test**, asserting the seed goes out and the draft's words do
|
||
not; the 5a fixture that carried no `status` was updated rather than the rule being softened.
|
||
|
||
**Still Phase 6's:** the email channel's `deliver` on the engine, and the `teamNotify` /
|
||
`teamDigestWorker` migration onto the four `notify.*` templates this phase can now edit.
|
||
|
||
---
|
||
|
||
### 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`;
|
||
`unsubscribeToken` generalizes from `(userId, teamId)` to `(userId, channel, scopeKey)` **while still
|
||
verifying old two-part tokens**, because tokens are already in people's mailboxes.
|
||
|
||
**This is the highest-risk phase.** It touches live behaviour that people receive by email.
|
||
|
||
**Acceptance:** every existing Team notification test passes against the new pipeline; a pre-existing
|
||
`email_mode='digest'` row produces exactly one daily digest with the same window clamping; an
|
||
unsubscribe link from a mail sent *before* the migration still works; `teamDigestWorker`'s three
|
||
compute-at-send-time properties are each covered by a named test, especially *a user who lost access
|
||
between the post and the send is not mailed*; `List-Unsubscribe` + `List-Unsubscribe-Post` still both
|
||
present.
|
||
**Guardrails:** route manifest (the unsubscribe routes move); a migration test asserting the backfill is
|
||
replay-safe.
|
||
|
||
---
|
||
|
||
#### 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.
|
||
|
||
**Acceptance:** one event delivered to `inapp` produces exactly one row; a duplicate `dedupeKey` is a
|
||
no-op; mark-read is idempotent; a user cannot read another user's row (asserted at the route, not only
|
||
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) ✅
|
||
|
||
Inbox screen, unread badge, and the tickle → pull → inbox path. App-store cadence, separate repo,
|
||
separate release.
|
||
|
||
**Acceptance:** a tickle wakes the app, which pulls and shows the item; the inbox works offline from
|
||
cache; the existing preferences screen gains the per-channel toggles from Phase 3.
|
||
**Note:** `android-app`'s `pr-checks.yml` triggers only on PRs into `main`, so phase PRs onto a working
|
||
branch get **no CI** — the cutover PR is the first real run. Plan for that. **Resolved: §7.1 Q8 was
|
||
answered "fix it" and the trigger now runs on PRs into `edge` too, as this phase's first commit.**
|
||
|
||
#### As built — 8 (2026-08-31)
|
||
|
||
The four inbox routes Phase 7 shipped had no consumer on either platform; this is the Android one.
|
||
**Four decisions were settled by the org lead before any code**, and one of them resolves a
|
||
contradiction the phase carried from the day it was written.
|
||
|
||
1. **§7.1 Q8 — fix the CI trigger, as the first commit.** `pr-checks.yml` now runs on PRs into
|
||
`edge` as well as `main`. All nine M12 phase PRs merged with no CI at all and engagement Phase 8
|
||
was about to repeat it; the alternative was finding a Kotlin compile error inside the cutover
|
||
window with a whole workstream's diff to bisect. `sonarqube.yml` is untouched — it is a
|
||
push-on-`main` analysis, not a PR gate, so no phase PR was ever expected to run it.
|
||
2. **The offline snapshot is a JSON blob, not Room.** This phase's acceptance line ("the inbox works
|
||
offline from cache") **contradicted a decision already recorded in `docs/android/PLAN.md` §7**:
|
||
*"Offline caching is not a v1 requirement (decided)… does not ship a Room cache in v1. No `Room`
|
||
dependency in the initial build."* §7's decision stands and this is its one named exception. An
|
||
inbox is a short, read-only, newest-first list with a server-side cursor and no joins, so what
|
||
"works offline" needs is the newest page and the badge — one blob in the DataStore the push code
|
||
already uses, capped at the server's own default page size.
|
||
3. **The drawer's "Notifications" is the inbox; the settings are behind its gear.** Exactly the
|
||
arrangement Phase 7 shipped on the web (the bare path is the content, `…/settings` the
|
||
preferences), and what a person means when they tap the word.
|
||
4. **The settings screen moved onto `/notifications/channels`**, rendering a control per channel that
|
||
applies to each id and per mode that channel accepts.
|
||
|
||
**The one thing the app must do that no plan said: resolve the item's `url`.** Phase 7's acceptance
|
||
specifies `url` is **relative-only** and validates it as such — right for a browser already on the
|
||
site, and a dead link on a phone. The live rig caught it: every item came back as
|
||
`/guilds/the-silver-anvil/forum/403`, and the app's first cut only opened `http(s)`-prefixed strings,
|
||
so **every link in the inbox did nothing**. It now resolves against the configured base with OkHttp's
|
||
`HttpUrl.resolve`, which absolutises the path *and* returns null for anything that would not end up
|
||
http(s) — so a `javascript:` or `intent:` url in a notification body opens nothing at all. The
|
||
relative-only contract did not change; the client half of it was simply never written down.
|
||
|
||
**Three further things the build settled:**
|
||
|
||
- **The tickle's `ref` is what routes it, not its stream.** An engagement rule's tickle carries the
|
||
TRIGGER id as `stream` (§7.2's one namespace), and the app's shipped `forStream` map knows only the
|
||
eight push streams — so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)`
|
||
sends anything whose ref starts with `notification:` to the inbox and leaves every other tickle on
|
||
the route it has always had. The ref is not decoded past that prefix and is never rendered: it is a
|
||
hint that a row exists, and the contract stays wake-and-pull, exactly as `pushChannel.js` says.
|
||
- **The snapshot is scoped to (base URL, user id), and that is the security property** — not the
|
||
clear-on-logout beside the push deregistration. A cache is only ever handed back to the pair that
|
||
wrote it, so the teardown paths that never reach a logout at all (a dead refresh token, a server
|
||
switch) cannot surface one person's notifications under another's session.
|
||
- **Reads are optimistic and are not rolled back.** The row flips locally, the server's post-write
|
||
`unread` replaces the local guess, and a failure is left alone: un-reading a row under someone's
|
||
finger looks like a bug, and the next refresh corrects it. A local read also rewrites the snapshot,
|
||
without which going offline right after reading everything would bring the badge back on the next
|
||
cold open.
|
||
|
||
**Verified on the live rig** (throwaway emulator + the real server on `edge` + real rows emitted
|
||
through `ctx.events.emit` → engine → outbox → `inappChannel`, never hand-written):
|
||
|
||
- the drawer badge showed **3**, the inbox listed the three items newest-first with unread dots and
|
||
local-zone timestamps, and a tap marked one read (3 → 2, server-side row updated);
|
||
- tapping an item opened its resolved link in a Custom Tab at the configured shard;
|
||
- one email chip wrote **exactly one row** — `(35, news.post, email, instant)` — and disturbed nothing
|
||
else, which is the sparse PUT's whole point;
|
||
- the push controls were **absent**, correctly, because the rig's shard advertises no relay, with the
|
||
reason in a note beside the list rather than replacing the screen (email and on-site preferences are
|
||
still worth setting on a shard with no push);
|
||
- with the network cut, the inbox rendered all five items from the snapshot under **"Offline — showing
|
||
what was saved on this device."**, unread count and read flags intact;
|
||
- a simulated tapped tickle (`STREAM=team.forum.post`, `REF=notification:5` — the exact extras
|
||
`PushNotifier` builds) landed on the inbox rather than Home.
|
||
|
||
**One finding this phase did NOT fix, deliberately.** *Offline works in a running app, not on a cold
|
||
start* — and the reason is the app shell, not the inbox. `MainActivity` gates the whole of `RunicApp`
|
||
on loading the site's appearance (M12), so an offline launch shows "Can't reach the site / Retry" and
|
||
never reaches the drawer at all. Widening this phase into the shell's startup model is the kind of
|
||
out-of-scope blocker that is a reason to ask, not a licence to widen the PR, so it is written up here
|
||
for the org lead. The acceptance line holds for the case the cache exists to serve — the app you were
|
||
just using, on a train — and fails for the case where the app was killed first.
|
||
|
||
**Still later phases':** nothing of this channel. The app now consumes every route Phase 7 built.
|
||
|
||
---
|
||
|
||
### Phase 9 — Deliverability: suppression and bounces
|
||
|
||
`engagement_suppressions` and bounce/complaint capture per transport (SMTP has none — this is where the
|
||
API-based providers earn their place).
|
||
|
||
**The verification flow is no longer part of this phase.** §7.1 Q1's answer moved it forward into
|
||
**Phase 1b**, along with the admin gate setting that decides whether an unverified address is excluded.
|
||
Phase 9 therefore *consumes* `email_verified` rather than introducing a writer for it, and — the reason
|
||
this reshuffle is worth it — **Phase 9 no longer blocks Phase 11.** The first real rule can ship on the
|
||
verification mechanism 1b already built, with bounces following.
|
||
|
||
**Acceptance:** a suppressed address is skipped with `status='suppressed'` in `engagement_sends` and no
|
||
transport call; a hard bounce suppresses the address; with the Phase 1b gate `on`, an unverified address
|
||
is excluded from engagement rules but still receives password resets; with it `off`, it receives both.
|
||
|
||
#### As built (2026-08-31)
|
||
|
||
**Four decisions, settled by the org lead before any code:**
|
||
|
||
1. **Mechanism plus SMTP's real signal; no API transport.** The phase's own text says "SMTP has none —
|
||
this is where the API-based providers earn their place", and that is too strong. SMTP has no
|
||
*asynchronous* bounce or complaint feed, but a single-recipient send refused at `RCPT TO` throws
|
||
synchronously with the reply code intact — the highest-value deliverability signal there is, and
|
||
`mailer.js` was already catching it as a `PERMANENT_CODE` and throwing it away. So this phase reads
|
||
it. A transport MAY declare a bounce handler; none does, and **no webhook route ships** — a route
|
||
with no producer is §7.1 Q9's problem in a different costume.
|
||
2. **Suppression scopes to engagement rules only.** Password resets, invites, verification mail and
|
||
the contact form still attempt. This is the posture `passwordReset.controller.js` already stated
|
||
for the verification gate, and the argument carries: user-initiated mail must not be blocked by a
|
||
background system's opinion, and one reset to a dead mailbox is not a reputation problem.
|
||
3. **`address_masked` is added to §4.5's DDL.** The hash-only table cannot be operated — an operator
|
||
staring at sha256 digests cannot tell three typos from a whole domain refusing mail, and
|
||
un-suppressing somebody who fixed their mailbox is the one action the table must support.
|
||
4. **The verification gate filters at ENQUEUE, not at delivery.**
|
||
|
||
**The defect this phase exists to have avoided: `PERMANENT_CODES` is not a bounce classifier.**
|
||
The obvious implementation is "the mailer already tells us a failure is terminal, so suppress on
|
||
that". `mailer.PERMANENT_CODES` is `{550, 553, 554, EENVELOPE, EAUTH}`, and it answers a different
|
||
question — *is retrying pointless?* `EAUTH` is the operator's password being wrong and `554` is a
|
||
relay-wide policy refusal; neither says anything about the recipient. Under that implementation **one
|
||
stale SMTP credential suppresses every address the outbox worker touches**, with a clean send log, no
|
||
warning, and a mailing list that has to be rebuilt by hand. So `bounceClassify.js` is its own judge:
|
||
|
||
- the **RFC 3463 enhanced status** decides on its own where there is one — `5.1.1`, `5.1.2`, `5.1.3`,
|
||
`5.1.6`, `5.1.10` and `5.2.1` suppress, and `5.3.x`, `5.5.x` and `5.7.x` explicitly never do, being
|
||
about the server or about our standing with it;
|
||
- without one, a phrase match applies **only** after `550`/`551`/`553` has already narrowed the
|
||
failure to the recipient address, and only past a veto list — `552` and `554` are excluded from even
|
||
that, because a full mailbox gets emptied and "transaction failed" is what a relay says when it does
|
||
not want to say why;
|
||
- **anything it is unsure about is not suppressed.** A false negative costs one retry next month; a
|
||
false positive costs a person who silently stops hearing from the deployment and cannot find out.
|
||
|
||
`sendNotification` now returns an `smtp: { code, responseCode, response }` triple so this is
|
||
answerable at all: `retry` and `detail` cannot distinguish `550 5.1.1` from `550 5.7.1`, which are an
|
||
identical `retry: false` and mean completely different things.
|
||
|
||
**The two mechanisms sit at different points, and the split is the design.** A suppression can appear
|
||
inside a rule's `delay_seconds` grace window, so the only correct check is the one taken immediately
|
||
before the transport call — which is also what produces the `status='suppressed'` row with no
|
||
transport call that the acceptance line asks for. Being unverified is a *standing* property, stable
|
||
across that window, so excluding at delivery would write an outbox row purely to throw it away — and
|
||
on a deployment that upgraded before verifying anybody, one rule firing would write thousands of
|
||
`suppressed` rows nobody can read.
|
||
|
||
**The gate is a channel hook, not an engine branch.** It hangs off a new optional
|
||
`registerDeliveryChannel({ eligible })`, and `email` is the only channel that declares one. Both
|
||
alternatives were wrong in a way the build made obvious: filtering the shared audience before the
|
||
per-channel loop silences the wrong sink — **a rule spanning email and in-app must still put an item
|
||
in an unverified user's inbox**, since being unverified is a reason not to mail somebody and no reason
|
||
at all to hide their notifications — and an `if (channel === 'email')` in `engine.js` puts one
|
||
channel's rule inside the generic engine. Its excluded counts flow into `summary.ineligible` and into
|
||
the admin reach preview, which until now reported an audience size that was never the number of people
|
||
who would get a mail.
|
||
|
||
**Both new checks fail OPEN**, and the `try/catch` in `eligible` is load-bearing rather than habit:
|
||
`applyRule` awaits it *before* the per-user loop, so an uncaught throw abandons the whole rule for
|
||
every channel it names — a rule that silently sent nothing, with a clean log and an empty outbox. That
|
||
is G22's shape again, and the recoverable mistake is mail going out.
|
||
|
||
**The live rig found the one defect the stubs could not.** Against a real MariaDB, a real SMTP
|
||
conversation (mailpit) and the real engine and worker, a hard bounce was recorded as `status='failed'`
|
||
— honest, but `engagement_sends.status` has carried **`bounced`** since §4.5 and nothing had ever
|
||
written it, so the Send Log's "Bounced" filter matched nothing and always would have. It is now a
|
||
distinct outcome, because "the relay would not take this" and "this mailbox does not exist" send an
|
||
operator to two different places. `engagement_outbox.status` still records it as `failed`: that ENUM
|
||
has no `bounced`, and from the queue's point of view a bounced row is one that finished
|
||
unsuccessfully. `complained` still has no writer, and cannot have one without a provider feedback loop.
|
||
|
||
**Verified on the live rig**, seven rungs, each one a real send or a real refusal:
|
||
|
||
1. baseline — three recipients, three mails in mailpit, three `sent` rows;
|
||
2. one address suppressed by hand — **two** mails, and a `suppressed` row naming the reason;
|
||
3. suppression lifted — three mails again;
|
||
4. gate `on` — two mails, `ineligible: { unverified: 1 }`, and **no send-log row at all** for the
|
||
excluded user, which is what enqueue-time exclusion means;
|
||
5. a `550 5.1.1` from the relay — two mails, one `bounced` row, and `b***@example.test` written to the
|
||
suppression list with `detail: hard bounce: 5.1.1`;
|
||
6. the same rule again — that address `suppressed`, with no transport call;
|
||
7. **a password reset to the suppressed address still arrived**, which is decision 2 proved rather
|
||
than asserted.
|
||
|
||
The three admin routes were then driven over real HTTP: `created_by` records the admin on a manual row
|
||
and stays **NULL** on the worker's automatic one, which is what separates them in the list; a repeat
|
||
POST answers `200 {created:false}` rather than a 409; DELETE matches case-insensitively, which is the
|
||
whole point of hashing a folded address; no response contains `address_hash`; and all three refuse a
|
||
signed-out caller.
|
||
|
||
**Still later phases':** an API-based transport with a signed webhook receiver, which is what would
|
||
bring asynchronous bounces and `complained` to life. Nothing here blocks it — a transport may declare
|
||
a bounce handler today.
|
||
|
||
---
|
||
|
||
### Phase 10 — Protocol bump: three wire enrichments *(parallel from day one)*
|
||
|
||
**Scope widened 2026-08-31, by the org lead, before any code.** The phase was written as the
|
||
`house.decay` enrichment alone. It now carries **three** enrichments, for one reason that is a property
|
||
of this phase and of no other: **a protocol bump costs a sidecar release, a republished bundle and an
|
||
operator update on every shard.** A field left out here does not cost a follow-up commit — it costs a
|
||
*second* bump with the same three-part lead time, and an operator population split across two protocol
|
||
versions in the meantime. So everything the engagement workstream is known to need from the wire is
|
||
decided **now** and rides the single v4 → v5 bump.
|
||
|
||
The two additions come from Phase 11's expanded trigger set (below). Each was verified against the
|
||
emitters rather than assumed, and each closes a gap that §8.6 did not know it had:
|
||
|
||
- **Player-vendor fee state.** `uo.vendor.expiring` is the same shape as the flagship — an owned asset
|
||
at risk, with a real deadline — and is the second-strongest driver in the catalogue. But
|
||
`vendor.listing` carries `ownerSerial` and `ownerName` and **no `ownerAcct` and no fee state at
|
||
all**, so the trigger can today neither be addressed to a person nor know that anything is expiring.
|
||
- **A login *outcome*.** `EventSink.AccountLogin` is a pre-decision veto hook — the emitter's own
|
||
comment says so ("Fires before the auth decision, so this is an attempt, not a result"). It therefore
|
||
fires on every **successful** login too, and `uo.account.login_attempt` as §8.6 describes it
|
||
("someone tried to log into your game account") cannot be built on it: it would mail a security alert
|
||
every time the player themselves logged in. This is the one addition that is a new emitter rather
|
||
than new fields on an existing one.
|
||
|
||
**Explicitly NOT in this bump:** moving `vendor.sale` out of the opt-in patch tier. It stays where it
|
||
is (see §8.6's corrected row), which leaves `uo.vendor.sale` dormant on a shard that declined the
|
||
patches — a documentation obligation, not a protocol one.
|
||
|
||
| Repo | Change |
|
||
| --- | --- |
|
||
| `servuo-plugins/` | **(a)** `BridgeSweeps.WriteDecay` gains `ownerName` (`house.Owner.Name`), `nextStage` (`BaseHouse.NextDecayStage`), `decayPeriod`, and `estimatedCollapse` **only when `to == "IDOC"`** (§0.3) · **(b)** `BridgeMarket`'s `vendor.listing` gains `ownerAcct`, `holdGold`, `chargePerDay`, `daysRemaining` · **(c)** a new post-decision `account.login.result` (`acct`, `accepted`, `rejectReason`, `ip`) beside the existing pre-decision attempt |
|
||
| `servuo-plugins/overlay.toml` | `protocol = 4` → `5`, **in the same PR as the emitters** |
|
||
| `link/` | `PROTOCOL_VERSION: u32 = 4` → `5` (`sidecar/src/main.rs:55`); the houses board carries the new decay fields; `account.login.result` is a forwarded kind like any other — the sidecar stays a dumb forwarder |
|
||
| `website/` (`module-uo`) | ingest maps the new fields; `uoLinkConfig` protocol version; **`shardVisibility.js`'s `KIND_FEATURE` gains `account.login.result`** — rule 2 fails an unmapped kind closed to admin-only, which is the right answer for a frame carrying an IP, but it must be *chosen* here rather than inherited by accident |
|
||
| `docs/` | `docs/link/INTEGRATION.md` §Housing and §Market tables + examples, `docs/link/PLAN.md` §5/§7 |
|
||
|
||
**Two things the emitter work must not get wrong**, both following from §0.3's finding about how
|
||
ServUO actually decays:
|
||
|
||
- `estimatedCollapse` is exact **only** at IDOC, because dynamic decay draws each stage's duration at
|
||
random when the stage is entered. Emitting it at an earlier stage would publish a guess as a fact.
|
||
- `daysRemaining` for a vendor **is** exact (`HoldGold / ChargePerDay`), unlike the house. The two
|
||
fields must not be documented as though they carried the same confidence — a template that says
|
||
"your house collapses on the 4th" and one that says "your vendor is dismissed in 2 days" are making
|
||
very different promises.
|
||
|
||
**Acceptance:** a live run on the local rig (`C:\Users\colby\Desktop\ServUO` + the Rust sidecar, not
|
||
the PowerShell stub) shows **(a)** a real decay transition carrying the new fields, **(b)** a seeded
|
||
player vendor whose listing carries a fee state that falls as its held gold is drawn down, and **(c)** a
|
||
*failed* game login producing `account.login.result accepted:false` where a successful one produces
|
||
`accepted:true`; a v4 overlay paired with a v5 sidecar is **refused by the installer**, not mis-parsed;
|
||
CI publishes a bundle whose manifest pairs v5 with v5; and the five-rung shard visibility walk still
|
||
shows no leak, with `account.login.result` reaching **admin only**.
|
||
**Note:** this phase's lead time is a release plus a bundle plus an operator update, which is why it
|
||
starts early and lands independently.
|
||
|
||
---
|
||
|
||
### Phase 11 — module-uo's triggers: the full catalogue, and core's `news.post`
|
||
|
||
**Scope widened 2026-08-31, by the org lead, before any code.** This phase used to say `module-uo`
|
||
registers `uo.house.idoc_warning` "(and siblings)", and Part 8's preamble used to say "Phase 11 ships
|
||
exactly one rule". Both are now wrong on purpose: **Phase 11 ships every ✅ row in §8.6**, with one
|
||
carve-out named below. §8.6 was written as "a catalogue, not a commitment" — it is now the commitment,
|
||
and the burden of proof has inverted: a row that does *not* ship needs a reason recorded here.
|
||
|
||
**What that is worth, and it is not "more mail".** One trigger proves that a module can register a
|
||
trigger. Twenty-odd triggers spanning five audience kinds are the first real test of the things Parts
|
||
3–5 asserted and nothing has yet stressed: that the ceiling lattice holds when most triggers are *not*
|
||
public, that `check:modules` still finds no UO identifier in core when the module is this large, and
|
||
that a player's preferences screen stays legible when the catalogue is real rather than a demo. Every
|
||
rule ships `enabled = 0` (Q3), so this is a catalogue an operator turns on, not a switch that floods
|
||
anyone.
|
||
|
||
**The carve-out: `uo.market.item_listed` does not ship here**, for the reason §8.6 already gives — it
|
||
is a *saved search*, not a trigger. Its audience is "users whose stored query matches this listing",
|
||
and no per-user query store exists anywhere in the tree. It is its own workstream on top of this one.
|
||
`vendor.listing` remains ingested; nothing is lost by waiting.
|
||
|
||
**Two rows ship only because Phase 10 does.** `uo.house.idoc_warning`'s enrichment and
|
||
`uo.vendor.expiring` both need v5 fields; `uo.account.login_attempt` needs v5's new
|
||
`account.login.result` emitter and is renamed **`uo.account.login_failed`** to say what it actually
|
||
is. Everything else is mapping-only and can ship whether or not the bump has landed — the flagship's
|
||
`Greatly` mapping included, which **needs no protocol change** and can ship with Phase 6 if Phase 10
|
||
is still in flight, the trigger simply omitting `nextStage`/`estimatedCollapse` until the v5 overlay is
|
||
deployed, exactly as the `required: false` declaration already permits.
|
||
|
||
**The set, grouped by the audience kind each family exercises** — which is the point of grouping them
|
||
this way, since the audience kind is the thing being tested:
|
||
|
||
| Family | Triggers | Audience kind | Notes |
|
||
| --- | --- | --- | --- |
|
||
| **Owned asset at risk** | `uo.house.idoc_warning`, `uo.house.collapsed`, `uo.vendor.expiring` | `owner` (linked account) | The flagship family. All three resolve through `ownerAcct` → `shard_links` |
|
||
| **Passive income** | `uo.vendor.sale` | `owner` | **Patch-tier only** — see §8.6's corrected row; dormant on a shard that declined the patches, and the seeded rule's description must say so |
|
||
| **Personal security** | `uo.account.login_failed`, `uo.account.unlinked`, `uo.link.requested` | `owner`, ceiling `owner` | `uo.link.requested` is also the linking funnel: a player ran `[link` in game, finish it on the site |
|
||
| **Personal milestone** | `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered` | `owner`, opt-in | The two death triggers are a killfeed some players want and most do not — both ship `enabled = 0` and default `off` per channel |
|
||
| **Social / civic** | `uo.guild.joined`, `uo.guild.left`, `uo.guild.disbanded`, `uo.governor.elected`, `uo.election.opened` | `members`, `subscribers` | `uo.election.opened` carries `autoPickAt` — a real deadline, so it is the first trigger whose template has a genuine call to action with an expiry |
|
||
| **Come online now** | `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down` | `subscribers` | **`uo.server.up`/`down` is the cooldown table's stress test** — a flapping shard emits both repeatedly. Hard per-rule cooldown, not a per-send one |
|
||
| **Leaderboard** | `uo.points.rank_changed` | `subscribers`, `owner` | Fires both ways (you entered a top N; you were pushed out) |
|
||
| **Staff-facing** | `uo.page.new`, `uo.cheat.detected`, `uo.audit.staff_action` | ceiling `staff` / `admin` | These are why the ceiling exists. Phase 3 already filters a `staff`-ceiling trigger out of a player's catalogue *and* gates it on write, so this family is the production proof of that work rather than new mechanism |
|
||
| **Operator-facing** | `uo.economy.milestone`, `uo.world.saved` | ceiling `admin` | Digest-shaped by nature; neither should ever be instant |
|
||
|
||
**Also core's own `news.post` emitter, which this phase's title has always understated** (§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.
|
||
|
||
**On the size of this phase.** It is now the largest in the workstream: ~23 trigger declarations, their
|
||
audiences and ceilings, their `shardIngest` mappings, and a seeded template each — against Phase 5a's
|
||
nine seeded bodies in total. It will likely want to land as **11a (declarations, mappings, ceilings —
|
||
server only) / 11b (the seeded templates and the live walk)**, on the 4a/4b and 5a/5b precedent, and
|
||
that split should be confirmed with the org lead at the start of the phase rather than assumed here.
|
||
|
||
**Acceptance:** the five-rung shard visibility walk still shows no leak, and **no staff- or
|
||
admin-ceiling trigger appears by name in a player's preferences catalogue**; 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; a player vendor drawn down to its last day produces one `uo.vendor.expiring`
|
||
to its owner; a failed game login produces one `uo.account.login_failed` and a **successful** one
|
||
produces none; a shard restarted three times in a minute produces **one** downtime mail, not six; and
|
||
**a news post published on the rig reaches a rule, with the town-crier leg and every registered post
|
||
hook still firing exactly as they did.**
|
||
**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point —
|
||
which is a materially stronger claim now that the module registers twenty-odd UO-named triggers.
|
||
|
||
---
|
||
|
||
### Phase 12 — `runicgateway.com`: the public site and the docs journey
|
||
|
||
**This phase is not optional polish, and it is not "update the marketing copy". Two of its checks
|
||
will fail the build on their own**, and they read from `main`, which fixes exactly when this has to
|
||
land (see the timing note below).
|
||
|
||
**The mechanical half — the site's own checks go red at cutover.** `scripts/checkFacts.mjs` fetches
|
||
each value's authority from the **`main` branch of the source repo** and fails the build on any
|
||
disagreement (`link main:sidecar/src/main.rs`, `servuo-plugins main:overlay.toml`,
|
||
`website main:server/src/modules/version.js`, `Module-uo main:module.json`, and the `bundles` branch).
|
||
So `src/data/platform.json` needs:
|
||
|
||
- `protocol: 4` → **5** (Phase 10) — asserted three times over: the sidecar, the overlay, the bundle.
|
||
- `moduleApi: "1.6.0"` → **"1.7.0"** (Phase 2).
|
||
- `bundle.tag` / `bundle.sidecar` / `bundle.overlay` — whatever Phase 10's republished bundle carries.
|
||
- `verifiedOn` moved, and the page that quotes each value re-read rather than the JSON edited to make
|
||
the check pass — which is what the file's own header comment tells you in as many words.
|
||
|
||
**The content half — the site currently documents the opposite of what Phase 1 ships.**
|
||
`src/content/docs/docs/administration/notifications-and-email.mdx` carries an
|
||
`<Aside type="note" title="There is no SMTP option">` and the sentence *"Gmail over OAuth2 is the only
|
||
supported delivery path today."* Also `configuration.mdx:62` ("it is Gmail over OAuth2, it reuses the
|
||
Google authentication client"), `troubleshooting.mdx:101` ("Connect Gmail in **Settings → Email
|
||
delivery**"), and `system-architecture.mdx:117` ("the Gmail refresh token"). All four become false the
|
||
day Phase 1 merges to `main`.
|
||
|
||
**The capability claim to fix while here.** `src/data/capabilities.mjs`'s Notifications item already
|
||
says *"Web, push and email, chosen per stream by each person"* — **"Web" is not true today** (§0.1) and
|
||
becomes true at Phase 7. One list feeds `/`, `/features/` and `/modules/`, so this is one edit, and
|
||
`assertDetailCoverage()` will make sure the detail line comes with it.
|
||
|
||
**Legal and Play Data Safety.** `PLAY_DATA_SAFETY.md` and `/privacy` are generated from **one
|
||
inventory** (`scripts/playDataSafety.mjs`, `src/data/legal.mjs`), and an engagement mailer changes what
|
||
that inventory has to say: an email address is now used for **more than account function**, there is a
|
||
send log (`engagement_sends`, address hashes) and a suppression list. Play's Data Safety form
|
||
distinguishes "app functionality" from "communications/marketing", and getting that wrong is a store
|
||
review problem rather than a doc nit. Phase 9's decisions are the input, so this lands with or after
|
||
Phase 9 — **and it is the one part of Phase 12 with an external deadline attached to it.**
|
||
|
||
**New docs pages** for the admin docs journey: the engagement rules screen, the template editor, and
|
||
per-channel notification preferences. `scripts/checkSidebar.mjs` and `checkQuickstart.mjs` both hold
|
||
this to the actual site, and `checkQuickstart` is a **two-way** drift check against the website repo's
|
||
own setup — Phase 1 changes email setup, so expect it to have an opinion.
|
||
|
||
**Timing — this is the sequencing trap.** Because `checkFacts` reads `main`, the site stays **green for
|
||
the entire `edge` period** and goes red **the instant the cutover lands**. So Phase 12's work must be
|
||
**written and reviewed before the cutover and merged inside the same window** — not "after we ship".
|
||
Left until afterwards, the public site is broken and publishing false statements about the product at
|
||
exactly the moment anyone would look at it.
|
||
|
||
**Acceptance:** `node scripts/checkFacts.mjs`, `checkLinks`, `checkSidebar`, `checkQuickstart`,
|
||
`checkReference`, `checkA11y`, `checkCsp`, `checkBrand` and `npm test` all green **against the
|
||
post-cutover `main` of every source repo**; `grep -ri "gmail" src/` returns only historical/legal
|
||
references; `/privacy` and `PLAY_DATA_SAFETY.md` regenerate from the amended inventory with no manual
|
||
edit; the Notifications capability line is true of the shipped system.
|
||
|
||
---
|
||
|
||
### Phase 13 — The cutover: `edge` → `main`, in order
|
||
|
||
One PR per repo, all in one window. **The order is not cosmetic** — a `main` holding a v5 sidecar and a
|
||
v4 overlay is a shard that cannot pair, and the installer refuses it by design.
|
||
|
||
1. **`docs`** — the design of record and every doc the phases produced, so the reference exists before
|
||
the code that needs it.
|
||
2. **`servuo-plugins`** and **`link`** together — the emitter and `overlay.toml` and
|
||
`PROTOCOL_VERSION` are one protocol bump with three declaration sites (CLAUDE.md). Then CI
|
||
publishes the paired bundle.
|
||
3. **`website`** — core: the transport abstraction, the trigger registry, `MODULE_API_VERSION` 1.7.0,
|
||
the engine, the templates, the in-app channel, the Teams migration.
|
||
4. **`module-uo`** — its `coreApi` range and its triggers, after the core it declares against.
|
||
5. **`Integration-kit`** — `ci/core-ref.json` to the new website `main` sha. Its equality check is red
|
||
until this lands, **on purpose**; moving the pin is the acknowledgement that someone re-read the
|
||
chapters.
|
||
6. **`android-app`** — the in-app inbox. First real CI run (§6.0a).
|
||
7. **`runicgateway.com`** — last, because every fact it fetches has to be true on `main` first.
|
||
8. **`.profile/README.md`** — only if this is a headline capability.
|
||
|
||
**Acceptance:** a clean install from `main` alone stands the whole stack up — installer pairs a v5
|
||
sidecar with a v5 overlay, the site boots, SMTP is configured, a `Greatly` house transition on the
|
||
local rig produces exactly one email and one in-app item to the linked owner and nothing to anyone
|
||
else, and `runicgateway.com` builds green with no fact disagreeing with its authority. Every `edge`
|
||
is then fast-forwarded to `main` again so the next workstream starts from a clean one — the step
|
||
§6.0a found had been skipped after every previous cutover.
|
||
|
||
---
|
||
|
||
### Sequencing
|
||
|
||
```
|
||
Phase -1 fast-forward every edge to main; create edge in android-app,
|
||
runicgateway.com and Integration-kit ← blocking, §6.0a
|
||
|
||
Stage A 1 ── 1b ── 2
|
||
Stage B └─ 3 ── 4 ── 5a ── 5b ── 6
|
||
Stage C └─ 7 ── 8 (8 = app-store cadence)
|
||
Stage D └─ 9
|
||
Stage E 10 ──────────────────────────────────── 11 (10 parallel from day one; 11 needs 6 + 10)
|
||
Stage F 12 ── 13 (12 written before 13, merged in its window)
|
||
|
||
── all of the above onto `edge` ──
|
||
13 is the only thing that touches `main`
|
||
```
|
||
|
||
**Phase -1 is blocking and takes minutes.** Every `edge` is 0 ahead / 3–16 behind `main` (§6.0a), so
|
||
the fast-forward is lossless; skipping it means the cutover diff carries other workstreams' leftovers.
|
||
|
||
Phases 1, 1b and 10 can start immediately and in parallel; **Phase 2 now follows 1b** rather than 1,
|
||
because a trigger's audience resolves to users and the identity those users are mailed at should be
|
||
unique and verifiable before anything resolves an audience over it. **Phase 1 and Phase 6 are the two that touch
|
||
mail people actually receive** and should each land alone: Phase 1 because it can silently stop email
|
||
for the live deployment (§1.2a), Phase 6 because it rewrites the pipeline behind notifications going
|
||
out today. Both get the local rig exercised before merge, not only tests.
|
||
|
||
5a can land before 4 if that sequencing is more convenient — the seeded templates and renderer have no
|
||
dependency on the engine, only on Phase 1's channel interface. The order above simply keeps the engine
|
||
provable before anything renders through it.
|
||
|
||
**Phase 12 is the one with a deadline rather than a dependency.** `runicgateway.com`'s checks read the
|
||
source repos' `main`, so the site stays green through the whole `edge` period and breaks at the cutover.
|
||
Its work therefore has to be finished *before* Phase 13 and merged *inside* the same window — the
|
||
failure mode of leaving it until after is a public site making false claims about the product on the
|
||
day it ships.
|
||
|
||
---
|
||
|
||
## Part 7 — Open questions and forward-compat notes
|
||
|
||
### 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
|
||
three things at once and creates **Phase 1b**:
|
||
- **Opt-in only**, following standard marketing-email practice — explicit consent, working
|
||
unsubscribe, suppression. This matches the `team_notification_prefs` pattern already in use.
|
||
- **`users.email` becomes UNIQUE.** Not a schema tweak — see §0.6 for the three code paths it breaks
|
||
and the boot it can stop, and Phase 1b for the work. Duplicates are resolved **oldest-wins**: the
|
||
earliest account keeps the address, later ones are nulled and listed in an admin report.
|
||
- **The verification gate is an admin setting**, default **on for fresh installs, off for upgrades**,
|
||
so a running deployment does not silently stop mailing its opted-in users at cutover.
|
||
- The collision error stays **generic and anti-enumeration**, rate-limited but **not bot-scored**.
|
||
|
||
*Consequence for ordering:* Phase 9 no longer blocks Phase 11 — the verification *mechanism* moves
|
||
forward into 1b, and Phase 9 keeps only bounces and suppression.
|
||
|
||
**The narrower half is settled (2026-08-31, at the start of Phase 9): an unverified address is
|
||
excluded at ENQUEUE, and only from the email channel.** With the gate on, `emailChannel.eligible`
|
||
drops the user before an outbox row is written, so nothing is queued only to be thrown away and
|
||
the admin reach preview can report the exclusion. It is email-only because a rule spanning
|
||
channels must still put an item in that person's in-app inbox. Transactional mail — resets,
|
||
invites, verification itself — is unaffected either way.
|
||
2. ✅ **ANSWERED — multi-instance.** Neither of the two the question offered, and the third is
|
||
better than both: the outbox sweep **claims each row with a compare-and-set** —
|
||
`UPDATE … SET status='sending' WHERE id=? AND status='scheduled'` — and the instance the server
|
||
reports `affectedRows = 1` to owns it. §4.2a's status ENUM already carried a `sending` state that
|
||
nothing else needed, so this is what the schema was shaped for; it needs no open transaction (which
|
||
nothing else in this codebase's workers does) and no MariaDB version floor. It makes the **outbox**
|
||
safe for two app instances and does not, on its own, make the deployment multi-instance — the four
|
||
existing workers are still written for one. Built in Phase 4a.
|
||
3. ✅ **ANSWERED — rules as data vs. rules as code.** Data, as recommended: rules are operator-editable
|
||
rows, `enabled` defaults to `0`, and every rule carries a **hard per-rule hourly send ceiling**. The
|
||
ceiling is not a nicety — it is the thing that keeps a misconfigured rule from becoming a mail storm,
|
||
and it is what makes "data" safe enough to choose over "code". Phase 4 builds both.
|
||
4. ✅ **ANSWERED — the engagement admin surface gets its own top-level nav group**, "Engagement",
|
||
beside Content / Moderation / System. Four screens is too much to bury: Settings is already one long
|
||
page of sections, and a send log is a paged table rather than a settings section. Rules lands with
|
||
Phase 4b; Triggers, Templates and the send log join it in Phase 5. **Email Delivery stays a section
|
||
of Settings** for now — moving it is not part of either phase.
|
||
5. ✅ **ANSWERED — which SMTP posture is the documented default?** Document all three; **lead with a
|
||
relay** (Mailgun/SES/Postmark); name Gmail-with-an-app-password (`smtp.gmail.com:587`) explicitly as
|
||
the migration path off OAuth2 for the existing deployment (§1.2a). Phase 1 owes all three in
|
||
`runicgateway.com`'s `notifications-and-email.mdx` as well as `BACKEND_DESIGN.md` §7.
|
||
6. ✅ **ANSWERED — time-based triggers (G25), design now, build when?** As recommended: the
|
||
declaration lands in Phase 2 so `kind: 'scheduled'` is in the contract, the manifest and every
|
||
stored declaration from day one; the evaluator is built after Phase 9. Registration accepts
|
||
`scheduled` today and `ctx.events.emit` **refuses** to fire one — an evaluator's trigger is not a
|
||
caller's — so `kind` means something from the moment it is declarable rather than from the moment
|
||
it is honoured. The lifecycle uses in §8.5 are the highest-value non-game triggers on the list and
|
||
the first thing anyone will ask for after the IDOC mail works.
|
||
7. ✅ **ANSWERED — manual/operator-authored sends.** *"There is no campaign in the normal sense of email
|
||
marketing. But admins can create all sorts of trigger conditions"*, and separately: *"lists can be
|
||
built if they are powered by game data — say team X members or governors or whatever — thru the uo
|
||
module; same surface will be exposed to all modules."*
|
||
|
||
So: **no campaigns surface, no operator-authored send screen, no free-form list building.** An
|
||
admin's expressive power lives in trigger conditions and rules. `kind: 'scheduled'` therefore stays in
|
||
the Phase 2 contract as an admin-definable *condition*, not as a campaign.
|
||
|
||
**Lists do exist, module-declared.** A module registers named audiences over its own data on a surface
|
||
core exposes to every module, and an operator may compose them with and/or/not into a saved segment.
|
||
Composition must **narrow, never widen** — the segment takes the tightest ceiling it contains and is
|
||
still checked against the trigger's G24 ceiling. §5.1a is the design; Phase 2 owns the surface,
|
||
Phase 4 the composition UI, Phase 11 `module-uo`'s first real audiences.
|
||
8. **Android CI on `edge`** (§6.0a). **ANSWERED 2026-08-31 (org lead), at the start of Phase 8: fix
|
||
it, as the phase's first commit.** `pr-checks.yml` triggered only on PRs into `main`, so Phase 8
|
||
would have landed with zero CI and Phase 13 would have been its first real build — as happened to
|
||
all nine M12 phase PRs. `pull_request.branches` is now `[main, edge]`. **`sonarqube.yml` was
|
||
deliberately left alone**: it is a push-on-`main` analysis rather than a PR gate, so no phase PR
|
||
was ever expected to run it.
|
||
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?
|
||
|
||
`notification_channel_prefs.stream_id` above holds *either* a push stream id *or* a trigger id. Streams
|
||
and triggers are close enough to collide conceptually — `idoc.warning` (a stream) and
|
||
`uo.house.idoc_warning` (a trigger) would describe the same game moment. Two options:
|
||
|
||
- **One namespace**: a trigger *is* a stream with a payload contract; the seven grandfathered ids stay as
|
||
contract-free triggers. Cleanest long-term, but touches the shipped app's catalog.
|
||
- **Two namespaces**: streams stay exactly as they are (push only), triggers are new and separate.
|
||
Zero risk now, permanent duplication later.
|
||
|
||
Recommendation was **two namespaces through Phase 8**, on the grounds that the shipped Android client
|
||
reads the stream catalog.
|
||
|
||
✅ **ANSWERED 2026-08-29 by the org lead: ONE namespace.** A trigger is a payload contract attached to
|
||
an id that may also carry a subscription toggle; `news.post` names one event whichever question is
|
||
being asked of it. They stay two REGISTRATIONS with two shapes — a stream entry is a subscription
|
||
toggle and says nothing about payload — but **an id has exactly one owner across both facets**, and
|
||
that is checked in both directions.
|
||
|
||
The risk the recommendation was hedging against does not materialise, and that is worth recording
|
||
because it is the part that looked expensive: **the push catalog is untouched.** `allStreams()` still
|
||
serves the stream facet only, so the shipped app sees exactly the seven-plus-five ids it saw before,
|
||
and a trigger-only id simply has no push toggle. What one namespace actually costs is the ownership
|
||
check; what it buys is `notification_channel_prefs.stream_id` staying single-keyed, where two
|
||
namespaces would have forced a `kind` discriminator into its primary key and left `news.post` naming
|
||
two things forever. See Phase 2's as-built for the two knock-on effects (a relaxed id grammar and a
|
||
shared legacy allowlist) that only appeared once it was implemented.
|
||
|
||
### 7.3 Forward-compat: the note the brief asked for, corrected
|
||
|
||
Event-name collision handling is **already implemented** (§0.4), so the brief's §6 concern is closed.
|
||
The real forward-compat note is different, and belongs in `MODULE_API.md`:
|
||
|
||
> **A trigger id is namespaced by its owner and collision-checked at registration, exactly as a
|
||
> notification stream is. What is *not* yet expressed is a rule or template referring to a trigger whose
|
||
> module has been uninstalled.** `engagement_rules.trigger_id` is a plain `VARCHAR`, deliberately, so a
|
||
> module can be removed and reinstalled without the operator's rules being destroyed — the same decision
|
||
> `announce_job_legs` took for a leg whose module is gone ("Leave it alone: failing it would make the job
|
||
> roll up terminal on the strength of a leg that no longer exists, and reinstalling the module should
|
||
> resume it"). A rule whose trigger is unregistered must therefore show as **dormant** in the admin UI,
|
||
> never as an error and never auto-deleted.
|
||
|
||
### 7.4 Documentation obligations — the whole surface
|
||
|
||
**§6.0b assigns each of these to the phase that causes it; this is the inventory it draws from.**
|
||
Nothing here is "documentation to do at the end" — a phase is not done until its rows have landed.
|
||
|
||
- `docs/website/ENGAGEMENT.md` — this document, as the design of record.
|
||
- `docs/website/BACKEND_DESIGN.md` — §7 (Email) **rewritten, not amended**: it currently documents Gmail
|
||
OAuth2 as the mechanism, and every mention of "Connect Gmail", the borrowed `google` client and the
|
||
`mail.google.com` scope goes with it (§1.2a). The table inventory gains eight tables; the route tables
|
||
gain the engagement and channel-preference surfaces and lose the two `/admin/email/connect/*` routes.
|
||
- **Operator-facing, for the Gmail removal** — a release note naming the required action, and SMTP setup
|
||
guidance wherever `.env.example` / `website/README.md` currently point an operator at the Connect Gmail
|
||
flow. This is the one documentation obligation with a live deployment depending on it.
|
||
- `docs/website/MODULE_API.md` — §1.1 (1.7.0 + the stale-`edge` correction), §2.3 (`ctx.events`,
|
||
`ctx.inbox`), §2.4 (`registerEventTriggers`), and §7.3's dormant-rule note.
|
||
- `docs/website/TEAMS.md` — §6.3/§6.4 rewritten once Phase 6 migrates the Team pipeline.
|
||
- `docs/link/INTEGRATION.md` + `docs/link/PLAN.md` — Phase 10's wire fields.
|
||
- `docs/android/PLAN.md` — §11 gains the in-app inbox and the per-channel preferences.
|
||
- `docs/modules/uo/API.md` + `README.md` — Phase 11's triggers.
|
||
- `integration-kit/` — `ci/core-ref.json` moved to the new pin; chapter 2 gains a section on registering
|
||
a trigger, since the kit currently teaches none of the registries. The kit **teaches and never
|
||
re-specifies**, so it links to `MODULE_API.md` rather than restating the contract.
|
||
- **`runicgateway.com`** — the largest single obligation and the only one whose checks *fetch* their
|
||
facts rather than being told them, so it fails on its own. Full detail in Phase 12; in summary:
|
||
`src/data/platform.json` (`protocol` 4→5, `moduleApi` 1.6.0→1.7.0, the bundle triple) ·
|
||
`administration/notifications-and-email.mdx` (whose "There is no SMTP option" aside becomes false) ·
|
||
`configuration.mdx`, `troubleshooting.mdx`, `architecture/system-architecture.mdx`,
|
||
`architecture/protocol-versions.mdx` · `src/data/capabilities.mjs` (the Notifications line claims a
|
||
web channel that does not exist until Phase 7) · new admin pages for rules, templates and per-channel
|
||
preferences · **`PLAY_DATA_SAFETY.md` + `/privacy`**, both generated from one inventory that an
|
||
engagement mailer materially changes.
|
||
- `.profile/README.md` — only if this lands as a headline capability.
|
||
|
||
---
|
||
|
||
## Part 8 — What the system could be used for
|
||
|
||
Written as a catalogue, not a commitment — to check that the design in Parts 3–5 is general enough, and
|
||
to show what an operator gets for the phases they pay for. **§8.6 is no longer that.** On 2026-08-31 the
|
||
org lead scheduled **every ✅ row of §8.6 into Phase 11**, so for the `module-uo` section the burden of
|
||
proof has inverted: a row that does not ship needs a reason recorded in the phase. The rest of Part 8 —
|
||
§8.1–8.5 and §8.7 — remains unscheduled and stays a catalogue.
|
||
|
||
Read the availability column as: **✅** the data exists today and a rule could be written the day the
|
||
engine lands · **⚠** the data exists but needs a mapper or a resolver · **❌** needs new plumbing
|
||
(named in the row).
|
||
|
||
### 8.1 Core — account, access and security
|
||
|
||
| Use | Trigger source | Data | Channels | Timing |
|
||
| --- | --- | --- | --- | --- |
|
||
| Password reset | `password_resets` | ✅ (a sender exists today) | email | instant |
|
||
| Account invite | `user_invites` | ✅ | email | instant |
|
||
| Email verification | Phase 9 | ❌ no verification flow yet | email | instant |
|
||
| **New sign-in from an unrecognised device** | `trusted_devices` | ⚠ the table exists; nothing notifies | email, in-app | instant |
|
||
| **A trusted device was added / all devices revoked** | same | ⚠ | email, in-app | instant |
|
||
| **2FA enabled / disabled, recovery codes regenerated** | `users.totp_enabled`, `recovery_codes` | ⚠ | email, in-app | instant |
|
||
| **Password or email changed** | `/auth/me` account routes | ⚠ | email | instant |
|
||
| **Your account was locked by the bot-score / IP-ban layer** | `middleware/botScore` | ⚠ | email | instant, hard cooldown |
|
||
| **SSO identity linked or unlinked** | `user_identities` | ⚠ | email, in-app | instant |
|
||
| **Your session was revoked everywhere** | `revoked_sessions` | ⚠ | in-app | instant |
|
||
|
||
The security family is the strongest argument for the in-app channel: every one of these is something a
|
||
user should be able to *review a history of*, not merely be pinged about once.
|
||
|
||
### 8.2 Core — content and community
|
||
|
||
| Use | Trigger source | Data | Channels | Timing |
|
||
| --- | --- | --- | --- | --- |
|
||
| New news post / Five-on-Friday | `posts` + `news.post` stream | ✅ | email, push, in-app | instant or digest |
|
||
| Team forum reply / new thread | `team_forum_posts` | ✅ (shipping today) | email, push, in-app | instant or digest |
|
||
| Team announcement from a leader | `team.announcement` stream | ✅ | email, push, in-app | instant |
|
||
| Joined / removed from a Team | `team.member.*` streams | ✅ | in-app, email | instant |
|
||
| Team leadership changed | `team.leadership.changed` | ✅ | in-app | instant |
|
||
| **Someone replied to *your* thread** | `team_forum_posts.parent` | ⚠ author resolution exists; no per-author rule | email, in-app | instant |
|
||
| **You were mentioned in a post** | forum body | ❌ no mention parsing | in-app, push | instant |
|
||
| **A wiki page you edited changed** | `wiki_revisions` | ⚠ revisions exist; no watch list | email | digest |
|
||
| **A wiki page you watch changed** | — | ❌ needs a watch table | email | digest |
|
||
| **Weekly "what happened" digest** | posts + forum + activity | ⚠ digest machinery lands in Phase 6 | email | weekly |
|
||
| **Your uploaded image was removed by the sweep** | `team_forum_uploads` | ⚠ | in-app | instant |
|
||
|
||
### 8.3 Core — moderation, appeals and reports
|
||
|
||
| Use | Trigger source | Data | Channels | Timing |
|
||
| --- | --- | --- | --- | --- |
|
||
| **You received a warning** | `warnings` | ⚠ | email, in-app | instant |
|
||
| **You were muted / banned, and why** | `mod_actions` | ⚠ | email, in-app | instant |
|
||
| **Your appeal was received / decided** | `appeals` | ⚠ | email, in-app | instant |
|
||
| **Your content was reported** *(to staff, not the author)* | `content_reports` | ⚠ | in-app, email | instant, staff audience |
|
||
| **A moderation request needs a second pair of eyes** | `team_moderation_requests` | ⚠ | in-app, email | instant, staff |
|
||
| **Report queue is over N items** | `content_reports` count | ❌ needs a threshold evaluator | email, in-app | daily digest |
|
||
|
||
Note the audience direction: several of these go to **staff**, not to the subject. `engagement_rules.audience`
|
||
has to support a role-derived audience (`'staff'`, `'admins'`) as well as `'owner'` and `'subscribers'` —
|
||
worth confirming in Phase 4 rather than discovering in Phase 11.
|
||
|
||
### 8.4 Core — operator and staff operations
|
||
|
||
| Use | Trigger source | Data | Channels | Timing |
|
||
| --- | --- | --- | --- | --- |
|
||
| **Email delivery is failing** | `email_config.status` | ⚠ — and note the bootstrap problem: this one cannot be emailed | in-app, Discord | instant |
|
||
| **A module failed to load / is in `startup_failed`** | `modules/loader.js` | ⚠ | in-app, email | instant |
|
||
| **Sidecar unreachable / protocol mismatch** | `uoLinkClient` `ok:false`, `409` | ⚠ | in-app, email, Discord | instant, cooldown |
|
||
| **Announce leg exhausted its retries** | `announce_job_legs.status='failed'` | ✅ | in-app, email | instant |
|
||
| **A new admin was created, or a role was elevated** | `activity_log` | ⚠ | email to admins | instant |
|
||
| **Weekly operator report** — signups, active users, moderation volume, send volume | `activity_log`, `engagement_sends` | ⚠ | email | weekly |
|
||
|
||
`engagement_sends` making the send volume reportable is a small thing that closes a real gap: today
|
||
"how much mail did we send" is unanswerable (G15).
|
||
|
||
### 8.5 Lifecycle and re-engagement — the "engagement drivers" the brief names
|
||
|
||
| Use | Trigger source | Data | Channels | Timing |
|
||
| --- | --- | --- | --- | --- |
|
||
| **Welcome / first steps** after registration | `users.created_at` | ⚠ needs a time-based evaluator | email | delayed (e.g. +1 h) |
|
||
| **Finish setting up** — no linked game account after N days | `shard_links` absence | ⚠ module-supplied predicate | email, in-app | delayed |
|
||
| **We miss you** — no login in N days | `users.last_login_at` | ⚠ needs a scheduled sweep | email | monthly, hard cap |
|
||
| **Come back for X** — a scheduled event is starting | operator-authored | ❌ needs a manual/scheduled trigger type | email, push, in-app | scheduled |
|
||
| **Your invite is about to expire** | `user_invites.expires_at` | ⚠ | email | delayed |
|
||
| **Account dormant, scheduled for cleanup** | policy | ❌ no dormancy policy exists | email | scheduled, staged |
|
||
|
||
**These need a trigger kind the design does not yet have: a *time-based* trigger, not an event-based one.**
|
||
Everything in Parts 3–5 is "a thing happened → maybe send". "Nothing happened for 30 days → send" is a
|
||
periodic evaluator over a query. That is a real addition — a `registerScheduledTrigger({ id, cron, evaluate })`
|
||
whose `evaluate` returns candidate (user, subject) pairs, with the cooldown table doing exactly the job
|
||
it already does. **It should be designed in Phase 2 even if it is built later**, because retrofitting a
|
||
second trigger kind into the rule model afterwards is the expensive version.
|
||
|
||
The same mechanism gives the operator a **manual send**: "announce this to everyone who opted into news",
|
||
which is the one legitimately campaign-shaped use and the one most likely to be asked for first.
|
||
|
||
### 8.6 Module events — `module-uo`, grounded in what the shard actually emits
|
||
|
||
Every kind below is real (`docs/link/INTEGRATION.md`). The trigger id is what `module-uo` registers;
|
||
core stays ignorant of every word in it. **Every ✅ row here is Phase 11 scope** except
|
||
`uo.market.item_listed`, carved out there with its reason.
|
||
|
||
**Three rows were corrected on 2026-08-31**, when the set was scheduled and each claim was checked
|
||
against the emitters rather than against this table. Two of the corrections are what widened Phase 10
|
||
from one wire enrichment to three; the third is a caveat that has to reach the operator, not a defect.
|
||
|
||
| Trigger | Wire source | Data | Why anyone cares |
|
||
| --- | --- | --- | --- |
|
||
| `uo.house.idoc_warning` | `house.decay` → `Greatly` | ⚠ mapping only (the enrichment is Phase 10) | **The flagship.** Your house is decaying; log in or lose it and its contents |
|
||
| `uo.house.collapsed` | `house.decay` → `Collapsed`, `house.remove` | ✅ | The bad news, so it is not a surprise |
|
||
| `uo.vendor.sale` | `vendor.sale` | ✅ (`ownerAcct` → linked user) — **but patch-tier only**, see below | Your player vendor sold something, for how much |
|
||
| `uo.vendor.expiring` | `vendor.listing` + **v5 fee fields** | ⚠ → ✅ **after Phase 10**; `vendor.listing` carries no `ownerAcct` and no fee state today | Your vendor is about to be dismissed for unpaid fees |
|
||
| `uo.account.login_failed` | **v5 `account.login.result`** | ⚠ → ✅ **after Phase 10**; the existing `account.login.attempt` is pre-decision, see below | Someone tried to log into your game account, and failed, and from where |
|
||
| `uo.character.death` / `murdered` | `player.death`, `player.murdered` | ✅ | Opt-in; a killfeed some players want and most do not |
|
||
| `uo.skill.capped` | `skill.gain` where `base == cap` | ✅ | You hit the cap in a skill — a genuine milestone |
|
||
| `uo.quest.complete` | `quest.complete` | ✅ | Milestone / achievement mail |
|
||
| `uo.guild.joined` / `left` | `guild.join`, `guild.leave`, `guild.roster` | ✅ | Roster changes to guild leadership |
|
||
| `uo.guild.disbanded` | `guild.remove` | ✅ | Consequential and easy to miss |
|
||
| `uo.champ.started` | `champ.update` → active | ✅ (transition tracker exists) | A champion spawn is up — the classic "come online now" driver |
|
||
| `uo.champ.boss_up` | `champ.update` `bossUp` | ✅ | Higher-value version of the same |
|
||
| `uo.governor.elected` | `city.update` | ✅ (transition tracker exists) | Civic events; naturally digestible |
|
||
| `uo.election.opened` | `city.update` `electionPhase` | ✅ | "Voting is open in Britain" — with `autoPickAt` as a real deadline |
|
||
| `uo.server.up` / `down` | `server.hello`, `server.shutdown`, `server.crashed` | ✅ | Downtime notice; **needs a hard cooldown**, a flapping shard is a mail loop |
|
||
| `uo.page.new` | `page.new` | ✅ | A player opened a help page → **staff** audience |
|
||
| `uo.cheat.detected` | `cheat.fastwalk` | ✅ | Staff audience only — this kind is *not* on the public allowlist |
|
||
| `uo.audit.staff_action` | `audit.set`, `audit.command`, `admin.audit` | ✅ | Admin-audience digest of staff activity |
|
||
| `uo.link.requested` | `link.request` | ✅ | A player ran `[link` in game — confirm on the site |
|
||
| `uo.account.unlinked` | `account.unlinked` | ✅ | Someone severed the tie from in-game |
|
||
| `uo.market.item_listed` | `vendor.listing` | ✅ (protocol v3) | Saved-search hit: "a Vanquishing kryss appeared under 50k" |
|
||
| `uo.points.rank_changed` | `points.board` | ✅ (protocol v3) | You entered/left a leaderboard top N |
|
||
| `uo.economy.milestone` | `economy.supply` | ✅ | Operator-facing; economy health thresholds |
|
||
| `uo.world.saved` | `world.save.after` | ✅ | Operator-facing only; world-size trend |
|
||
|
||
**Three rows as originally written were wrong, and finding out cost one sweep of the emitters:**
|
||
|
||
- **`uo.vendor.sale` is real and does carry `ownerAcct` — but it lives in `servuo-plugins/patches/`,
|
||
the opt-in patch tier**, verified only against ServUO 57.4. It is a `PlayerVendorSale` EventSink the
|
||
patches *add* to core plus a subscriber that reads it, which is why it is absent from `overlay/`. A
|
||
shard that declined the tier, or runs another ServUO version, emits this kind never — so the rule is
|
||
silently dormant there rather than broken. Phase 10 deliberately does **not** try to move it into the
|
||
overlay; the obligation is that the seeded rule and the operator docs say which tier it needs.
|
||
- **`uo.vendor.expiring` had no data at all, not merely no mapper.** `vendor.listing` carries
|
||
`ownerSerial` and `ownerName` — no `ownerAcct`, so it cannot even be addressed to a website user —
|
||
and nothing anywhere on the wire carries a vendor's held gold or daily charge. Phase 10 adds all four.
|
||
- **`uo.account.login_attempt` could not have been built as described.** `EventSink.AccountLogin` is a
|
||
pre-decision veto hook; the emitter's own comment says "Fires before the auth decision, so this is an
|
||
attempt, not a result". A rule on it would have mailed "someone tried to log into your account" every
|
||
time the player logged in successfully — the exact inversion that makes people distrust security
|
||
mail. Phase 10 adds a post-decision `account.login.result`, and the trigger is renamed
|
||
**`uo.account.login_failed`** so its id cannot be misread again.
|
||
|
||
**Three others are worth calling out as design pressure on Parts 3–5:**
|
||
|
||
- **`uo.market.item_listed` is a *saved search*, not a plain trigger.** The audience is "users whose
|
||
stored query matches this listing", which `audience: 'computed'` covers but a per-user query store
|
||
does not exist. Scoping note only — it is the most-requested feature of every UO shard site and would
|
||
land as its own workstream on top of this one.
|
||
- **`uo.server.up`/`down` is the cooldown table's real stress test.** A flapping shard emits both kinds
|
||
repeatedly; without `engagement_cooldowns` keyed per rule this is a mail loop, which is exactly why
|
||
the brief is right that cooldowns must exist *before* the first trigger ships.
|
||
- **`uo.cheat.detected` and `uo.audit.*` must never be publicly subscribable.** `shardStreams.js`
|
||
already enforces this for push with a public-allowlist filter (`PUBLIC_KINDS`) and the comment calls it
|
||
defence in depth. **The engagement engine needs the equivalent**: a trigger declares an
|
||
`audience` it is *permitted* to have, and a rule cannot widen it. Without that, the module system's
|
||
security boundary has a second door. **This belongs in Phase 2's trigger declaration, not Phase 4.**
|
||
|
||
### 8.7 Module events — the game-agnostic shape
|
||
|
||
What a *different* game's module would register, to prove nothing above is UO-specific:
|
||
|
||
| Pattern | UO instance | A survival/Rust-like instance | An MMO instance |
|
||
| --- | --- | --- | --- |
|
||
| **Owned asset is at risk** | house decaying | base decaying, upkeep expiring | plot/housing lease lapsing |
|
||
| **Owned asset changed hands** | house demolished | base raided/destroyed | item traded |
|
||
| **Passive income event** | player vendor sold | shop/market sale | auction house sold |
|
||
| **Identity/security event** | game-account login attempt | server-account login | account login |
|
||
| **Time-boxed world event opening** | champion spawn up | raid window / airdrop | world boss / dungeon rotation |
|
||
| **Civic / social structure change** | governor elected | clan leadership change | guild leadership change |
|
||
| **Personal milestone** | skill capped, quest done | level/blueprint unlocked | achievement earned |
|
||
| **Server availability** | shard up/down | server wipe/restart | realm maintenance |
|
||
| **Staff-facing signal** | help page opened, cheat flagged | report filed, anticheat flag | GM ticket |
|
||
|
||
The engine sees none of the left-hand words. It sees a trigger id, a declared variable set, an audience
|
||
kind and a subject key — which is the test the design has to pass, and the reason §4.3's variable
|
||
contract is not optional.
|
||
|
||
### 8.8 What it is deliberately not for
|
||
|
||
Worth writing down, because the failure mode of an engagement system is scope creep into a marketing
|
||
platform — which the brief explicitly rules out and which would change the security posture.
|
||
|
||
- **No list building, no imported contacts, no audiences of non-users.** Every recipient is a user row
|
||
with a preference. The system has no concept of an address that is not attached to an account.
|
||
- **No open/click tracking, no tracking pixels, no link rewriting.** Beyond the privacy position, it
|
||
would put a per-recipient beacon URL in every mail — a phone-home by construction (§3.2).
|
||
- **No A/B testing, no drip sequences, no lead scoring, no funnels.**
|
||
- **No sending on behalf of a third party.** The transport is the operator's own; there is no
|
||
multi-tenant sender.
|
||
- **No raw HTML authoring and no HTML-typed variables** (§4.6.2). A module supplies data, never markup.
|
||
- **Push stays content-free.** Whatever else this system does, the tickle invariant survives it: a
|
||
channel declares `carriesContent`, and push's is `false` structurally rather than by convention.
|