Three additions the org lead called for, all in ENGAGEMENT.md. The branching model (new §6.0a). Every phase PR in every repo targets `edge`; `main` is touched exactly once, by the cutover. Two blocking findings checked on 2026-08-28: every existing `edge` is stale (0 ahead of `main`, behind by 16 docs / 9 module-uo / 7 installer / 7 servuo-plugins / 5 website / 3 link) and must be fast-forwarded before the first phase PR, and three repos have no `edge` at all — android-app, runicgateway.com and Integration-kit. Also records that android-app's pr-checks.yml triggers only on PRs into `main`, so Phase 8 lands with no CI and the cutover is its first real build, as happened to all nine M12 phase PRs. Documentation as a phase deliverable (new §6.0b). A phase-by-phase table assigning the specific docs each phase owes, in `docs/` and in every other repo, so nothing is deferred to a cleanup pass. §7.4 becomes the inventory that table draws from rather than a list of things to do at the end. Two new phases. Phase 12 is runicgateway.com, which is not optional polish: scripts/checkFacts.mjs fetches each fact's authority from the source repo's `main`, so `protocol` 4 to 5 and `moduleApi` 1.6.0 to 1.7.0 fail its build on their own. The site also currently documents the opposite of what Phase 1 ships — notifications-and-email.mdx carries a "There is no SMTP option" aside — its capabilities list claims a web notification channel that will not exist until Phase 7, and PLAY_DATA_SAFETY.md and /privacy generate from one inventory that an engagement mailer materially changes. Because checkFacts reads `main`, the site stays green through the whole `edge` period and breaks at the cutover, so Phase 12 must be written before Phase 13 and merged in the same window. Phase 13 is the cutover itself, ordered rather than per-repo-independent: docs, then servuo-plugins and link together (a protocol bump has three declaration sites and a `main` holding a v5 sidecar with a v4 overlay cannot pair), then website, module-uo, Integration-kit, android-app, and runicgateway.com last because every fact it fetches has to be true on `main` first. Adds an eighth open question (fix the Android CI trigger, or accept the cutover as its first build) and a Phase -1 to the sequencing diagram for the edge fast-forward. Co-Authored-By: Claude <noreply@anthropic.com>
111 KiB
The Engagement System — findings and plan
Status: design of record for the next workstream. No code written yet. The five scope decisions below are settled; the eight questions in §7.1 are open and none of them block Phase 1 or Phase 2. 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 — every existing edge is stale and three repos
have none.
Scope decisions, settled by the org lead (2026-08-28):
- The in-app channel is in scope. It does not exist today and has to be built, not adapted.
- The Teams notification pipeline is generalized and migrated onto the new system, not built beside it.
- The
house.decayprotocol enrichment is in scope, as a coordinated four-repoPROTOCOL_VERSIONbump. - 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.
- 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.
Part 0 — Five findings that contradict the brief
Stated up front because the rest of the document is shaped by them.
0.1 There is no In-app channel. The target diagram's "existing In-app / Push" is one channel, not two
Core has exactly one notification sink: content-free push tickles to ntfy/UnifiedPush
(website/server/src/utils/pushDispatch.js). There is no notification table, no read API, no
mark-read, and no in-app list on either client. android-app/.../ui/notifications/NotificationsScreen.kt
is a preferences screen. module-uo's SSE stream (server/utils/shardBroadcast.js) is a live game
feed, not an inbox.
So "add Email as a third channel" is really add two channels to a system that has one, and the in-app one needs its own storage, read API and two client surfaces.
0.2 Email is already two-thirds of an engagement system — scoped to Teams
The brief frames the current email implementation as "the Gmail OAuth2 code". Gmail OAuth2 is only the
transport. Sitting on top of it, utils/teamNotify.js + utils/teamDigestWorker.js +
team_notification_prefs already implement:
| Engagement concern | Where it already lives |
|---|---|
| Trigger → recipient selection | teamNotify.js:recipientIds / emailRecipients via the Team access resolver |
| Per-user opt-in, per-scope | team_notification_prefs.email_mode ENUM('off','digest','immediate') (schema.sql:1285) |
| Immediate vs. digest scheduling | teamNotify.emailImmediate and teamDigestWorker.tick |
| Digest windowing + clamping | teamDigestWorker.clampSince, MAX_LOOKBACK_MS, MAX_ITEMS |
| One-click unsubscribe | utils/unsubscribeToken.js (stateless HMAC) + RFC 8058 List-Unsubscribe-Post |
| "Off unless configured" gate | mailer.isConfigured() checked before the recipient query |
| Multi-sink fan-out from one computed audience | teamNotify.forumPost → push + email + Discord bridge |
This is the prototype of the system being scoped. Building beside it would give the deployment two unsubscribe mechanisms and two digest workers. Hence decision 2.
0.3 The uo.house.idoc_warning payload does not exist on the wire, and one field of it is not knowable
house.decay carries (docs/link/INTEGRATION.md:207,
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs:199):
serial, from, to, map, x, y, z, region, name, ownerSerial, ownerAcct, ban{x,y,z}, builtOn, lastRefreshed
Against the brief's example payload:
| Brief field | Reality |
|---|---|
house |
✅ name (from the house sign) |
location |
✅ map, x/y/z, region, plus ban (where you stand to read the sign) |
decay_status |
✅ to |
character |
❌ not emitted — but trivially available: WriteDecay already reads house.Owner, so owner.Name is one line |
shard |
❌ not an event field, and should not become one — one deployment is one shard; core already has ctx.settings.getInstanceName() |
next_stage |
❌ not emitted — obtainable, see below |
estimated_collapse |
❌ not emitted, and not exactly knowable in advance — see below |
Why estimated_collapse is the hard one. ServUO runs the dynamic decay system on any modern
shard (Scripts/Multis/DynamicDecay.cs: Enabled => Core.ML). Under it, each stage's duration is
drawn at random when that stage is entered (GetRandomDuration, e.g. Greatly = 1–2 days,
IDOC = 12–24 h). BaseHouse.NextDecayStage (BaseHouse.cs:37) is therefore an exact, already-persisted
timestamp for the next transition — but a collapse time two stages out does not exist yet, even
inside the game.
Consequences for the design:
next_stageis exact and cheap — emitNextDecayStage.estimated_collapseis exact only once the house is at IDOC, whereNextDecayStageis the collapse time. Before that it can only be an envelope (min/max from the remaining stage table). Emit it asestimatedCollapseonly whento == "IDOC", plus an optionalestimatedCollapseMin/Maxenvelope earlier — never a single number that reads as a promise.- Under the legacy static path (
GetOldDecayLevel,BaseHouse.cs:205) decay is a pure function oflastRefreshed + DecayPeriod, andlastRefreshedis already on the wire. ThedecayPeriodis not, and should be added so a consumer can compute stages without hardcoding 5 days.
And the current mapping fires at the wrong stage for the brief's own example. The brief's story is
"house becomes greatly damaged". module-uo/server/config/shardStreams.js maps house.decay to a
stream only when String(event.to).toUpperCase() === 'IDOC' — the final stage, which is 12–24 h from
collapse. A "greatly damaged" warning needs a Greatly transition mapping. That part needs no
protocol change at all and can ship long before the bump lands.
0.4 Event-name collision handling already exists — the brief's §6 forward-compat note is already satisfied
The brief asks for a one-line note that a module-id prefix would be needed if multi-module ever happens. It already happened:
modules/registries.js:namespaced()requires every stream id and announce-leg id to start with<owner>., with a small grandfathering allowlist (LEGACY_STREAM_IDS,LEGACY_LEGS) for the seven pre-module-system ids.registries.apply()throws on any collision, naming the current holder, before it commits a single claim.modules/loader.jscross-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.
Part 1 — Current-state map
1.1 Notification system, end to end
registerNotificationStreams() [modules/registries.js]
│
config/coreStreams.js (news.post, team.*) ──┤ ← core, via registerCore()
module-uo/config/shardStreams.js (7 ids) ──┘ ← module, via api.*
catalog read back through registries.allStreams()
│
GET /auth/me/notifications/streams ──────────────────► web + Android
GET·PUT /auth/me/notifications/subscriptions ─────────► notification_subscriptions
│
shard event ──► module-uo/utils/shardPush.js ─┤
news publish ─► posts controller ─────────────┤──► pushDispatch.publish(streamId,{ref,ownerUserId})
team event ───► utils/teamNotify.js ──────────┘──► pushDispatch.publishToUsers(streamId,{ref,userIds})
│
push_devices rows (SSRF-gated endpoints)
│
POST {stream, ref} ──► ntfy / UnifiedPush ──► app wakes, PULLS content
Files.
| Concern | File |
|---|---|
| Catalog registry (core + modules) | server/src/modules/registries.js — registerNotificationStreams, allStreams, isValidStream, personalStreams |
| Core's own streams | server/src/config/coreStreams.js — news.post + four team.* |
| Module streams | module-uo/server/config/shardStreams.js — seven grandfathered ids + mapShardEvent |
| Fan-out | server/src/utils/pushDispatch.js — publish (all-subscribers / one-owner), publishToUsers (computed set), isAllowedEndpoint (SSRF gate) |
| Shard → push adapter | module-uo/server/utils/shardPush.js — resolves ownerAcct → website user via shardLinks |
| Team fan-out | server/src/utils/teamNotify.js — one audience, three sinks |
| Self-service API | server/src/router/v1/auth/notifications.controller.js + notifications.routes.js |
| Subscriptions model | server/src/model/notificationSubs/ |
| Devices model | server/src/model/pushDevices/ |
| Android surface | android-app/.../data/api/NotificationsApi.kt, dto/NotificationsDto.kt, ui/notifications/ |
Data model.
-- 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_subscriptionshas 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.
NotificationSubscriptionsDtois{ 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_prefsdocuments the asymmetry in its own DDL comment: push is opt-out (muteddefaults 0), email is opt-IN (email_modedefaults'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 fromNTFY_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) andteamNotify.js's header comment is the standing argument for why the asymmetry is the security model rather than an inconsistency. The engagement system must preserve this per channel, not flatten it.
1.2 Current email implementation
Transport. server/src/utils/mailer.js (267 lines). nodemailer over smtp.gmail.com:465 with
auth.type: 'OAuth2'. Client id/secret are reused from the google auth_providers row; only the
refresh token is email-specific. buildTransport() returns null when unconfigured, and every sender
handles that itself.
Config. email_config singleton (schema.sql:336) — provider (already a VARCHAR(20) defaulting
'gmail_oauth2', so the column is ready for a second provider), enabled, sender_email, sender_name,
refresh_token_enc (AES-256-GCM via utils/secretBox.js), status, status_detail, last_verified_at.
Admin-managed, never env — docs/website/BACKEND_DESIGN.md §7.
Admin API. router/v1/admin/email.router.js — six routes: GET·PUT /config, GET /connect/start,
GET /connect/callback, POST /test, POST /disconnect. Client: client/src/routes/admin/views/EmailDelivery.jsx.
Every caller — the full migration surface. Six call sites, five sender functions:
| Caller | Function | Failure contract |
|---|---|---|
router/v1/public/public.controller.js:156 (contact form) |
sendContactMessage |
Never throws when unconfigured — returns {sent:false, fallback:'mailto', email} and the client renders a mailto: link |
router/v1/admin/emailConfig.controller.js:188 (admin "Send test") |
sendTest |
Throws NOT_CONFIGURED / NO_RECIPIENT; 502 to the admin |
router/v1/admin/invites.controller.js:46 |
sendInvite |
Returns {sent:false, reason:'NOT_CONFIGURED'} so the admin gets the accept link to share by hand |
router/v1/auth/passwordReset.controller.js:49 |
sendPasswordReset |
Returns {sent:false, …}; caller still answers a generic 200 to avoid account enumeration |
utils/teamNotify.js:234 (immediate) |
sendTeamNotification |
Never throws at all — logged and swallowed; the forum write already returned |
utils/teamDigestWorker.js:73 (digest) |
sendTeamNotification |
Same; return value gates the last_digest_at stamp |
Nothing in website/bot, module-uo, or any other repo sends mail. mailer is not on ctx — modules
already cannot send email, which matches the target architecture.
Hardcoded assumptions the abstraction has to remove:
- One provider, compiled in. The Gmail host, port and OAuth2 auth type are literals in
buildTransport().email_config.providerexists but nothing reads it. - The credential shape is Gmail's. One
refresh_token_enccolumn 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. - 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 inemailImmediateis explicitly a rate-limit workaround for it. - Text-only, composed inline. Every body is a template literal inside
mailer.js. There is no HTML part anywhere and no template storage.sendTeamNotificationbuilds its body by pushing lines into an array. - Synchronous send, no queue, no retry. A send either succeeds inside the request/tick or is lost.
recordStatuswrites the last outcome to a singleton column — there is no per-message record, so "did user X get the IDOC mail?" is unanswerable today. recordStatusis global. One transient failure setsemail_config.status='error'for the whole deployment, from any of six unrelated call sites.- No suppression, no bounce handling, no verification gate.
users.emailis not unique (SSO addresses repeat) andusers.email_verifiedis 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:
- SSO is unaffected. The
googleauth_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. sender_emailchanges meaning. Today it is read back from Google'suserinfoand 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 rejectedFrom.- The live deployment goes dark at cutover unless the operator acts. UOMysticmoon is connected via
Gmail OAuth2 today. On upgrade,
transportbackfills tosmtpwith no credentials, soisConfigured()returns false and every sink politely does nothing — the contact form falls back tomailto, 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 whentransport='smtp'and credentials are absent, a release note naming the required action, andINSTALL.md-style operator guidance. Gmail itself remains usable as plain SMTP (smtp.gmail.com:587with 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:
- A way to declare a domain event and its data contract — nothing like this exists.
registerNotificationStreamsdeclares a subscription toggle; it carries a label and two booleans, and no statement whatsoever about payload. A module cannot tell core what auo.house.idoc_warningcontains. - 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 throughpushDispatch, which will not carry the data.
So this needs a new registration surface, not a reuse. It should be modelled on registerNotificationStreams
(shape-checked at the call, collision-checked at apply(), <owner>.-prefixed) rather than on
registerAnnounceLeg (which is a core-calls-module dispatch with retry classification — the wrong
direction: a module reports an event, it does not deliver one).
What module-uo exposes to core today. Only what the registries take: seven stream ids, one announce
leg, one extension router, a Team provider, one slash command, five route mounts. Everything else — 27
shard_* tables, the sidecar client, the visibility framework — is module-internal (MODULE_API.md §1.2).
There is no data channel from a module into core carrying structured game data. ctx.teams.activity.push
is the nearest thing, and it is instructive: core stores summary already rendered by the module,
because core cannot phrase a sentence in a vocabulary it does not know, and kind/payload are opaque.
The engagement system deliberately takes the opposite position — core does interpolate module data into a template — which is only safe because the operator authors the template and the module declares the variables. That is the whole reason §4.3's variable contract has to exist rather than being optional.
1.4 Job, scheduling and queue infrastructure that already exists
There is no cron. There is no Redis, no BullMQ. The stack has exactly two patterns:
(a) In-process setInterval + unref() + stop(), wired into server.js start/shutdown. Six of them:
announceWorker, teamDigestWorker, teamActivityPrune, teamForumUploadSweep, teamVoiceSync, and
middleware/botScore's sweeper.
(b) A durable job table with per-leg backoff — announce_jobs + announce_job_legs (schema.sql:737/757),
swept by announceWorker.tick. This is a real outbox: status, attempts, last_error,
next_attempt_at, INDEX idx_announce_leg_due (status, next_attempt_at), and a classify() that maps a
delivery result to done / retry / terminal. It was explicitly designed so a module can add a delivery
leg without altering a core table — the child-table shape and the VARCHAR (not ENUM) leg column
are both justified in the DDL comment on exactly those grounds.
Which of the brief's two scheduling use cases each pattern serves:
- (a) Delayed send per event — "wait 30 min in case the player fixes it". Needs a durable row with a
due_at, and — the part the brief does not name but which is the actual point — the ability to cancel a pending row when a later event resolves the condition.announce_jobsis the exact precedent; this needs its own table because a module cannot alter a core one and the payload differs. - (b) Batched / digest —
teamDigestWorkeralready 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 "setmutedfor 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 drivingpages.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+ thesettingskey/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', // email opt-IN, push opt-OUT — G9, expressed here once
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_configis DB-backed and admin-managed, never env (BACKEND_DESIGN.md§7). There is no default host, no default sender, andstatusstarts'unconfigured'.pushDispatch.allowedOrigins()readsNTFY_ALLOWED_ORIGINS/NTFY_BASE_URLand returns empty when neither is set. No Runic Gateway host appears anywhere in it.mailer.isConfigured()gates every sink, andteamNotify.emailImmediatechecks it before the recipient query so an unconfigured deployment pays nothing.
Rules to carry into the abstraction:
- No transport may ship a default host, endpoint, API base or sender. A transport with no operator
configuration is
unconfiguredand its channel is off, not defaulting to anything. - No engagement code may read an env var naming an external service that the operator did not set.
- The "off unless configured" gate is checked before recipient resolution, per channel.
- A CI guardrail: extend
scripts/checkModuleIdentifiers.js's sibling pattern with a check that no file underserver/src/engagement/contains a bare external hostname literal. (Cheap; the check pattern and its self-test discipline already exist — seetest/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 KEYcolumn is coercedNOT NULL, so "NULL means the default row" is unrepresentable in a PK.team_integration_config(schema.sql:1335) andteams.active_keyboth 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 is INSERT … ON DUPLICATE KEY UPDATE guarded on the interval, in one statement, so two
concurrent emits cannot both pass a read-then-write check.
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,
UNIQUE KEY uq_engo_dedupe (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_atis the 30-minute grace window. The worker sweepsstatus='scheduled' AND due_at <= now.status='cancelled'is the actual point of that window.idx_engo_cancelis what a resolving event queries: ahouse.decayback up toLikeNewcancels every scheduled row for that (rule, user, house). Without cancellation, a delay is just a late mail.dedupe_key UNIQUEmakes 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.
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 carriesversion(a prop-schema version, bumped when props change so a migration can transform older blocks),schema: (props) => [errors], andsanitize: (props) => propsrun 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-uocarries its ownroutes.manifest.jsonfor the same reason, and ships a prebuiltswagger-fragment.jsonbecause 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.',
subjectKey: 'house', // which variable identifies the subject, for cooldowns
audience: 'owner', // 'owner' | 'subscribers' | 'computed'
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:
- Validated at emit, not at render.
ctx.events.emitchecks the payload against the declaration. A missingrequiredvariable or a wrong type is dropped and logged in production, thrown in development — the same posturectx.teams.activity.pushtakes ("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. - The editor reads it, so autocomplete is real.
GET /admin/engagement/triggersserves 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. exampleis 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.- Drift is caught by a committed manifest.
npm run engagement:manifestwritesserver/engagement-triggers.json(core's) and CI runs it with--check, exactly asroutes:manifest -- --checkalready 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 prebuiltengagement-triggers.jsonin its bundle, for the same reason it ships a prebuilt swagger fragment: core never has its sources.
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',
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;
-- 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_subscriptions → notification_channel_prefs follows the precedent
already in schema.sql:770 (the announce_jobs → announce_job_legs migration): an INSERT IGNORE … SELECT guarded so replay on every boot is a no-op after the first.
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:
notify.eventandnotify.digestare 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."- They are branded from data, not hardcoded.
BRAND_*env and thetheme_visual/brand_assetssettings 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. - A later release can improve a default without stealing an operator's work.
seed_version+customizedis the whole mechanism: on boot, a seed whose version is newer updates rows wherecustomized = 0and skips rows where it is 1, surfacing "an updated default is available" in the admin list instead. Same posturesettingsJsontakes — 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
examplevalues. No live game event needed. This is the reasonexampleis 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_sendslike 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
protectedtemplate safely: duplicate, edit, point the rule at the copy, leave the original intact.
Security posture, stated because this is the one new place operator HTML reaches a rendered surface:
blocks are validated and sanitized on write (the existing validateBlocks → sanitize order), the
preview renders in a sandboxed iframe with no allow-scripts, and variable interpolation is
HTML-escaped by default with no raw-HTML variable type in v1. A module supplies data; it does not
supply markup.
Part 5 — The module registration mechanism
5.1 Two additions to the contract, both 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 })
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.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, 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.jscarries the same number and a test asserts they agree.integration-kit/ci/core-ref.jsonpins a websitemainsha andscripts/checkCoreApi.jsasserts equality withMODULE_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 onedge" paragraph needs correcting (§0.5).
Part 6 — The phased plan
Same shape as the API v2 router-split plan: grouped, reviewable increments, one acceptance check per phase, and an explicit note on which guardrails apply.
Every phase carries the standing obligations — npm test --prefix server green, npm run swagger
regenerated when a route changes, npm run routes:manifest -- --check clean, npm run check:modules
clean, the documentation edits §6.0b assigns it, Conventional Commits, the AI-disclosure trailer,
and a branch cut from a freshly-pulled base.
Stage A (1–2) is prerequisite. Stage B (3–6) is the engagement system. Stage C (7–8) is the in-app channel. Stage D (9) is deliverability. Stage E (10–11) is the shard enrichment and runs in parallel from day one. Stage F (12–13) is the public site and the cutover.
6.0a The branching model — everything lands on edge, then one cutover to main
Every phase PR in every repo targets edge. main is touched exactly once, by the cutover
(Phase 13). This is the model the module system, protocol v3, Teams and the M12 theming workstream
each used, and it is the right one here for a specific reason: this workstream changes a wire protocol,
a module API version and the mail path simultaneously, and those three land in different repos on
different days. main must never hold a half-applied set of them.
Two operational findings, both checked on 2026-08-28 and both blocking before Phase 1:
- Every existing
edgeis stale.git rev-list --left-right --count origin/main...origin/edgesaysedgeis 0 ahead and behindmainby:docs16,module-uo9,installer7,servuo-plugins7,website5,link3. They are leftovers from previous cutovers that were never refreshed after merging. Fast-forward eachedgetomainbefore 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. - Three repos have no
edgeat all and need one cut frommain:android-app(its M12 branch was deleted after that cutover),runicgateway.com, andIntegration-kit.
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 |
| 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), §7.3's dormant-rule note · website/ENGAGEMENT.md §4.3 kept true |
Integration-kit: ci/core-ref.json re-pinned (the equality check goes red on purpose) + chapter 2 gains a "registering a trigger" section · runicgateway.com: platform.json.moduleApi → 1.7.0 |
| 3 Channel preferences | website/BACKEND_DESIGN.md route table · android/PLAN.md §11 |
— |
| 4 Engine | website/ENGAGEMENT.md (rules/cooldown/outbox as built) · BACKEND_DESIGN.md table inventory |
— |
| 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 |
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 — Q5 before Phase 1, Q1/Q3/Q6/Q7 before Phase 2, Q2 before
Phase 4, Q4 before Phase 5b.
Phase 1 — Remove Gmail OAuth2; DeliveryChannel + 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.
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.
Acceptance: core's triggers appear in GET /admin/engagement/triggers; a module registering an
un-namespaced trigger fails to load with the holder named; 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.
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 (§4.5's defaultMode).
Guardrails: swagger + route manifest; a test pinning the legacy wire shape byte-for-byte.
Phase 4 — The engine: rules, cooldowns, outbox
engagement_rules, 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. Admin → Engagement → Rules.
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; a named test for the multi-house cooldown case (§4.1).
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, bounces, verification
engagement_suppressions, bounce/complaint capture per transport (SMTP has none — this is where the
API-based providers earn their place), and an email-verification flow so users.email_verified finally
has a writer for self-registered addresses. Policy decision needed: may an unverified address receive
engagement mail at all? (Recommendation: transactional yes, engagement no.)
Acceptance: a suppressed address is skipped with status='suppressed' in engagement_sends and no
transport call; a hard bounce suppresses the address; an unverified address is excluded from engagement
rules but still receives password resets.
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 = 4 → 5, in the same PR as the emitter |
link/ |
PROTOCOL_VERSION: u32 = 4 → 5 (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: 4→ 5 (Phase 10) — asserted three times over: the sidecar, the overlay, the bundle.moduleApi: "1.6.0"→ "1.7.0" (Phase 2).bundle.tag/bundle.sidecar/bundle.overlay— whatever Phase 10's republished bundle carries.verifiedOnmoved, and the page that quotes each value re-read rather than the JSON edited to make the check pass — which is what the file's own header comment tells you in as many words.
The content half — the site currently documents the opposite of what Phase 1 ships.
src/content/docs/docs/administration/notifications-and-email.mdx carries an
<Aside type="note" title="There is no SMTP option"> and the sentence "Gmail over OAuth2 is the only
supported delivery path today." Also configuration.mdx:62 ("it is Gmail over OAuth2, it reuses the
Google authentication client"), troubleshooting.mdx:101 ("Connect Gmail in Settings → Email
delivery"), and system-architecture.mdx:117 ("the Gmail refresh token"). All four become false the
day Phase 1 merges to main.
The capability claim to fix while here. src/data/capabilities.mjs's Notifications item already
says "Web, push and email, chosen per stream by each person" — "Web" is not true today (§0.1) and
becomes true at Phase 7. One list feeds /, /features/ and /modules/, so this is one edit, and
assertDetailCoverage() will make sure the detail line comes with it.
Legal and Play Data Safety. PLAY_DATA_SAFETY.md and /privacy are generated from one
inventory (scripts/playDataSafety.mjs, src/data/legal.mjs), and an engagement mailer changes what
that inventory has to say: an email address is now used for more than account function, there is a
send log (engagement_sends, address hashes) and a suppression list. Play's Data Safety form
distinguishes "app functionality" from "communications/marketing", and getting that wrong is a store
review problem rather than a doc nit. Phase 9's decisions are the input, so this lands with or after
Phase 9 — and it is the one part of Phase 12 with an external deadline attached to it.
New docs pages for the admin docs journey: the engagement rules screen, the template editor, and
per-channel notification preferences. scripts/checkSidebar.mjs and checkQuickstart.mjs both hold
this to the actual site, and checkQuickstart is a two-way drift check against the website repo's
own setup — Phase 1 changes email setup, so expect it to have an opinion.
Timing — this is the sequencing trap. Because checkFacts reads main, the site stays green for
the entire edge period and goes red the instant the cutover lands. So Phase 12's work must be
written and reviewed before the cutover and merged inside the same window — not "after we ship".
Left until afterwards, the public site is broken and publishing false statements about the product at
exactly the moment anyone would look at it.
Acceptance: node scripts/checkFacts.mjs, checkLinks, checkSidebar, checkQuickstart,
checkReference, checkA11y, checkCsp, checkBrand and npm test all green against the
post-cutover main of every source repo; grep -ri "gmail" src/ returns only historical/legal
references; /privacy and PLAY_DATA_SAFETY.md regenerate from the amended inventory with no manual
edit; the Notifications capability line is true of the shipped system.
Phase 13 — The cutover: edge → main, in order
One PR per repo, all in one window. The order is not cosmetic — a main holding a v5 sidecar and a
v4 overlay is a shard that cannot pair, and the installer refuses it by design.
docs— the design of record and every doc the phases produced, so the reference exists before the code that needs it.servuo-pluginsandlinktogether — the emitter andoverlay.tomlandPROTOCOL_VERSIONare one protocol bump with three declaration sites (CLAUDE.md). Then CI publishes the paired bundle.website— core: the transport abstraction, the trigger registry,MODULE_API_VERSION1.7.0, the engine, the templates, the in-app channel, the Teams migration.module-uo— itscoreApirange and its triggers, after the core it declares against.Integration-kit—ci/core-ref.jsonto the new websitemainsha. Its equality check is red until this lands, on purpose; moving the pin is the acknowledgement that someone re-read the chapters.android-app— the in-app inbox. First real CI run (§6.0a).runicgateway.com— last, because every fact it fetches has to be true onmainfirst..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 ── 2
Stage B └─ 3 ── 4 ── 5a ── 5b ── 6
Stage C └─ 7 ── 8 (8 = app-store cadence)
Stage D └─ 9
Stage E 10 ──────────────────────────────────── 11 (10 parallel from day one; 11 needs 6 + 10)
Stage F 12 ── 13 (12 written before 13, merged in its window)
── all of the above onto `edge` ──
13 is the only thing that touches `main`
Phase -1 is blocking and takes minutes. Every edge is 0 ahead / 3–16 behind main (§6.0a), so
the fast-forward is lossless; skipping it means the cutover diff carries other workstreams' leftovers.
Phases 1, 2 and 10 can start immediately and in parallel. 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 (not blocking Phase 1 or 2)
- May unverified addresses receive engagement mail? Recommendation: no — transactional only. This decides whether Phase 9 blocks Phase 11 or merely follows it.
- Multi-instance. Do we commit to single-instance (status quo) and document it, or add
SELECT … FOR UPDATE SKIP LOCKEDto the outbox sweep in Phase 4? Recommendation: add it in Phase 4 — it is cheap there and expensive to retrofit after mail has doubled once. - Rules as data vs. rules as code. The plan makes rules operator-editable rows. The alternative is
rules registered in code by whoever owns the trigger, with only enable/disable in the DB. Data is more
flexible; code is far easier to test and impossible to misconfigure into a mail storm.
Recommendation: data, but with
enableddefaulting to 0 and a hard per-rule hourly send ceiling. - Does the engagement admin surface belong under Admin → Settings, or its own top-level section? It is three screens (Triggers, Rules, Templates) plus a send log.
- Which SMTP posture is the documented default for operators? Their own mail server, a relay (Mailgun/SES/Postmark) over SMTP, or Gmail-with-an-app-password. Recommendation: document all three, lead with a relay, and name Gmail-app-password explicitly as the migration path off OAuth2 for the existing deployment (§1.2a).
- Time-based triggers (G25) — design now, build when? Recommendation: design the declaration in
Phase 2 so
kind: 'scheduled'exists in the contract, build the evaluator after Phase 9. 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. - Manual/operator-authored sends (§8.5, last paragraph) — in or out? It is the one campaign-shaped
feature, it is genuinely useful, and it is also the thin end of the wedge the brief rules out.
Recommendation: in, but as a
scheduledtrigger with an explicit staff audience and no list building — never a separate "campaigns" surface. - Android CI on
edge(§6.0a).android-app/.gitea/workflows/pr-checks.ymltriggers only on PRs intomain, 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: two namespaces through Phase 8, then converge in a later workstream — the shipped Android client reads the stream catalog and the seven ids are already grandfathered once. Decide this in Phase 2; it is cheap then and expensive in Phase 6.
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_idis a plainVARCHAR, deliberately, so a module can be removed and reinstalled without the operator's rules being destroyed — the same decisionannounce_job_legstook 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 borrowedgoogleclient and themail.google.comscope 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.mdcurrently 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-edgecorrection), §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.jsonmoved 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 toMODULE_API.mdrather 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(protocol4→5,moduleApi1.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 3–5 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) | instant | |
| Account invite | user_invites |
✅ | instant | |
| Email verification | Phase 9 | ❌ no verification flow yet | 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 |
⚠ | instant | |
| Your account was locked by the bot-score / IP-ban layer | middleware/botScore |
⚠ | 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 | digest | |
| A wiki page you watch changed | — | ❌ needs a watch table | digest | |
| Weekly "what happened" digest | posts + forum + activity | ⚠ digest machinery lands in Phase 6 | 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 |
⚠ | 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 | 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 | 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 |
⚠ | delayed | |
| Account dormant, scheduled for cleanup | policy | ❌ no dormancy policy exists | scheduled, staged |
These need a trigger kind the design does not yet have: a time-based trigger, not an event-based one.
Everything in Parts 3–5 is "a thing happened → maybe send". "Nothing happened for 30 days → send" is a
periodic evaluator over a query. That is a real addition — a registerScheduledTrigger({ id, cron, evaluate })
whose evaluate returns candidate (user, subject) pairs, with the cooldown table doing exactly the job
it already does. It should be designed in Phase 2 even if it is built later, because retrofitting a
second trigger kind into the rule model afterwards is the expensive version.
The same mechanism gives the operator a manual send: "announce this to everyone who opted into news", which is the one legitimately campaign-shaped use and the one most likely to be asked for first.
8.6 Module events — module-uo, grounded in what the shard actually emits
Every kind below is real (docs/link/INTEGRATION.md). The trigger id is what module-uo would register;
core stays ignorant of every word in it.
| Trigger | Wire source | Data | Why anyone cares |
|---|---|---|---|
uo.house.idoc_warning |
house.decay → Greatly |
⚠ mapping only (the enrichment is Phase 10) | The flagship. Your house is decaying; log in or lose it and its contents |
uo.house.collapsed |
house.decay → Collapsed, house.remove |
✅ | The bad news, so it is not a surprise |
uo.vendor.sale |
vendor.sale |
✅ (ownerAcct → linked user) |
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 3–5:
uo.market.item_listedis a saved search, not a plain trigger. The audience is "users whose stored query matches this listing", whichaudience: '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/downis the cooldown table's real stress test. A flapping shard emits both kinds repeatedly; withoutengagement_cooldownskeyed 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.detectedanduo.audit.*must never be publicly subscribable.shardStreams.jsalready 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 anaudienceit 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 isfalsestructurally rather than by convention.