Files
docs/website/ENGAGEMENT.md
wtclaude deaf491dc5 docs(website): the engagement engine as built (Phase 4a)
Companion to website#170. Section 6.0b's assignment for Phase 4 - ENGAGEMENT.md's
as-built and BACKEND_DESIGN.md's table inventory - plus the two corrections
building it forced on this document's own design sections.

ENGAGEMENT.md
  - Phase 4 is split 4a / 4b, with what each owes.
  - Section 7.1 Q2 and Q4 answered, so seven of eight are settled and only Q8
    (android CI) is open.
  - The as-built: the gate order and why two of its placements are load-bearing,
    the segment rule the design never stated (not is legal only inside an and,
    and contributes no ceiling), dormancy three ways and why audience_segment_id
    has no foreign key, the conditions grammar's two fail-closed properties, and
    why emit does not await the engine.
  - Section 4.1's cooldown statement and section 4.2a's dedupe index are
    corrected in place, so the design sections stop teaching the two defects.

BACKEND_DESIGN.md
  - Five new tables in the schema inventory, each with the reasoning a reader
    would otherwise have to reconstruct: why subject_key is in the primary key,
    why the dedupe index is scoped, why the send log survives an account
    deletion and is not a second address book, and why a rule points at a
    segment without a foreign key doing it.

No route table changes - Phase 4a adds no routes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 08:07:42 -05:00

172 KiB
Raw Blame History

The Engagement System — findings and plan

Status: design of record. Phases 1, 1a, 1b, 2, 3 and 4a 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); everything from Phase 4b on is still design. The scope decisions below are settled; seven of the eight 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), and Q2 and Q4 on 2026-08-29 at the start of Phase 4. 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. Q8 remains open and blocks Phase 8. 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.
  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):

  1. 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.
  2. 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.
  3. 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 = 12 days, IDOC = 1224 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 1224 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.jsregisterNotificationStreams, allStreams, isValidStream, personalStreams
Core's own streams server/src/config/coreStreams.jsnews.post + four team.*
Module streams module-uo/server/config/shardStreams.js — seven grandfathered ids + mapShardEvent
Fan-out server/src/utils/pushDispatch.jspublish (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.

-- 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 ctxmodules 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 backoffannounce_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 / digestteamDigestWorker 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:

// 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.

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.

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 affectedRowsdoes 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.

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:

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 = 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.

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.

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

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

-- 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: users.email is not unique.
CREATE TABLE IF NOT EXISTS engagement_suppressions (
  address_hash CHAR(64)     NOT NULL PRIMARY KEY,   -- sha256 of the lowercased address
  channel      VARCHAR(32)  NOT NULL DEFAULT 'email',
  reason       ENUM('bounce','complaint','manual','unverified') NOT NULL,
  detail       VARCHAR(500) NULL,
  created_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) 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):

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_subscriptionsnotification_channel_prefs follows the precedent already in schema.sql:770 (the announce_jobsannounce_job_legs migration): an INSERT IGNORE … SELECT guarded so replay on every boot is a no-op after the first.

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, threadUrl
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 validateBlockssanitize 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

// 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.

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.

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 obligationsnpm 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 (11b2) is prerequisite. Stage B (36) is the engagement system. Stage C (78) is the in-app channel. Stage D (9) is deliverability. Stage E (1011) is the shard enrichment and runs in parallel from day one. Stage F (1213) 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 · website/ENGAGEMENT.md §5.1a composition UI
5a/5b Templates + editor website/ENGAGEMENT.md §4.6 · a template-authoring section in BACKEND_DESIGN.md or its own doc runicgateway.com: a new admin docs page for the template editor
6 Email channel + Teams migration website/TEAMS.md §6.3/§6.4 rewritten — the Team pipeline it describes no longer exists as its own thing runicgateway.com: administration/teams.mdx notification section
7 In-app channel (core+web) website/BACKEND_DESIGN.md routes + tables · website/ENGAGEMENT.md runicgateway.com: notifications-and-email.mdx gains the in-app channel
8 In-app (Android) android/PLAN.md android-app/README.md
9 Deliverability website/BACKEND_DESIGN.md §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) runicgateway.com: troubleshooting.mdx gains bounce/suppression · PLAY_DATA_SAFETY.md + /privacy — see Phase 12
10 Protocol bump link/INTEGRATION.md §Housing (table + example) · link/PLAN.md §5/§7 · a link/v5.md if the bump earns its own design doc, as v3 and v4 did servuo-plugins/overlay.toml · runicgateway.com: platform.json.protocol → 5, bundle.*, architecture/protocol-versions.mdx
11 module-uo triggers modules/uo/API.md · modules/uo/README.md module-uo/README.md
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 sendingisConfigured() 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 nothingMeApi.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 NULLs 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 settingon 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:

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 9kind: '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.jsan 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
  • 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_VERSION1.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.

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.jsregisterDeliveryChannel, 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:

    -- 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.

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, three preview widths plus dark mode, test send, duplicate. Built on the existing block/prop-panel machinery, not a second one.

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.


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.


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.


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.


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.


Phase 10 — Protocol bump: house.decay enrichment (parallel from day one)

Four repos plus the overlay declaration, per CLAUDE.md's "The bridge is a contract":

Repo Change
servuo-plugins/ BridgeSweeps.WriteDecay gains ownerName (house.Owner.Name), nextStage (BaseHouse.NextDecayStage), decayPeriod, and estimatedCollapse only when to == "IDOC" (§0.3)
servuo-plugins/overlay.toml protocol = 45, in the same PR as the emitter
link/ PROTOCOL_VERSION: u32 = 45 (sidecar/src/main.rs:55); houses board carries the new fields
website/ (module-uo) ingest maps the new fields; uoLinkConfig protocol version
docs/ docs/link/INTEGRATION.md §Housing table + example, docs/link/PLAN.md §5/§7

Acceptance: a live run on the local rig (C:\Users\colby\Desktop\ServUO + the Rust sidecar, not the PowerShell stub) shows a real transition carrying the new fields; 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. 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 and the first real rule

module-uo registers uo.house.idoc_warning (and siblings), emits from shardIngest, and ships the "greatly damaged" mapping. The Greatly transition mapping needs no protocol change and can ship with Phase 6 if Phase 10 is still in flight — the trigger simply omits nextStage/estimatedCollapse until the v5 overlay is deployed, which the required: false declaration already permits.

Acceptance: the five-rung shard visibility walk still shows no leak; a house transitioning to Greatly on the live rig produces one email to the linked owner and nothing to anyone else; a second transition inside the cooldown produces nothing; a refresh back to LikeNew inside the delay window cancels the pending mail. Guardrails: check:modules proves core gained no UO identifier across every phase to this point.


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: 45 (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: edgemain, 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-kitci/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 / 316 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, one 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.

  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-setUPDATE … 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). android-app/.gitea/workflows/pr-checks.yml triggers only on PRs into main, so Phase 8 lands with zero CI and Phase 13 is its first real build — as happened to all nine M12 phase PRs. Fix the trigger as Phase 8's first commit, or accept it deliberately? Recommendation: fix it. It is a two-line workflow change and the alternative is finding out about a Kotlin compile error during the cutover window.

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

A catalogue, not a commitment. Its purpose is to check that the design in Parts 35 is general enough, and to show what an operator gets for the phases they pay for. Nothing here is scheduled; Phase 11 ships exactly one rule.

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 35 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 would register; core stays ignorant of every word in it.

Trigger Wire source Data Why anyone cares
uo.house.idoc_warning house.decayGreatly ⚠ 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.decayCollapsed, house.remove The bad news, so it is not a surprise
uo.vendor.sale vendor.sale (ownerAcct → linked user) Your player vendor sold something, for how much
uo.vendor.expiring vendor.listing, vendor fees Your vendor is about to be dismissed for unpaid fees
uo.account.login_attempt account.login.attempt (already a personal stream) Someone tried to log into your game account, 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 of these are worth calling out as design pressure on Parts 35:

  • 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.