feat(engagement): the engagement system — cutover 3 of 7 (edge → main)
#180
Reference in New Issue
Block a user
No description provided.
Delete Branch "edge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Engagement Phase 13, step 3 of 7. Core: the transport abstraction, the trigger registry,
MODULE_API_VERSION1.9.0, the engine, the templates, the in-app channel and the Teams migration.Merge steps 1 and 2 first (
docs, thenservuo-plugins+linktogether). Steps 4–7 depend on this one —module-uodeclarescoreApi ^1.9.0against it, andIntegration-kit's pin moves to the sha this produces.What lands
36 commits, 194 files, a clean fast-forward (
mainis 0 commits ahead). Eleven phases:/admin/email/connect/*routes and email's reuse of thegoogleSSO client secret are gone — that reuse was a coupling nobody could see, where rotating the SSO secret silently broke outbound mail/auth/me/accountemail.*block family, a server-side renderer, nine seeded bodies, and the editoradminceiling, core'snews.postemitter, andregisterEngagementSeeds(1.9.0)Two things that stop sending on upgrade, both deliberate
Every rule is seeded
enabled = 0, including the four that replace Team email and the one that replaces the news push tickle. A deployment that upgrades and does nothing else loses both, silently. The mitigations are in the tree — two banners on Admin → Engagement → Rules that read the rules rather than a flag — and the operator note isdocs/website/UPGRADE_NOTES.md(docs#199), which step 1 carries.The alternative, keeping the old direct paths beside the new triggers for one release, was refused: an exception with a deadline nobody owns is one nobody removes.
The security boundary this adds
Every trigger declares an audience ceiling and a rule is checked against it, so a rule cannot be given an audience wider than — or incomparable with — its trigger allows. Without it a rule could have given
uo.cheat.detecteda public audience. The lattice is not a ladder: astaffceiling does not permitowner, because "one person" for a cheat-detection event is the player it was detected on.Gates
server-tests,client-buildandbot-testswere green on every phase PR; the last (#179) ran green atc8d4573. This PR runs them again againstmain.`npm run swagger` cannot run on this tree. It dies with swagger-autogen's "FATAL ERROR: invalid array length - Allocation failed", generating nothing, and it reproduces on a pristine checkout under both Node 20 and Node 24 — so the committed spec cannot be regenerated by anyone, and any PR that adds or changes a route is unable to meet the standing obligation to update it. Bisected to one statement in `teams.router.js`: param('teamId').custom((v) => v === 'default' || TEAM_ID.test(v)) Hoisting that arrow to a named const fixes it outright. Nothing else changes and the regenerated spec is byte-identical to the committed one, so this is a generator fix, not a spec change. The diagnosis worth keeping, because the file's own comment recorded a different one. Phase 8 shipped a bare regex LITERAL before `.test(` and phase 9 hoisted the regex, blaming a per-file route limit measured at twenty statements; the file has sat at nineteen ever since on the theory that it was one under the edge. That theory is wrong. Probing every router file individually, `teams.router.js` at nineteen statements dies while a THREE-route file carrying only this one route also dies — so the trigger is the inline arrow reaching `.test(`, not the count. Hoisting the regex was half the fix; the predicate around it needed hoisting too. The comments in `teams.router.js`, `teamsVoice.router.js` and `admin/index.js` are corrected to say so, since all three currently tell the next person to keep counting statements. Co-Authored-By: Claude <noreply@anthropic.com>Self-service account security had three URL surfaces onto one controller. All three mounted the same `admin/account.controller.js` handlers; each of the three router files carried a header comment apologising for the arrangement. `/auth/me/account` was already a strict superset, which settles which to keep: /admin/account 6 routes noindex, isLoggedIn, staffOnly /player/account 8 routes noindex, requireAuth /auth/me/account 10 routes noindex, requireAuth Neither of the deleted surfaces carried recovery codes, and /admin/account carried no username or password change at all — so client.js already called /auth/me/account/recovery-codes/* for two operations on a screen it otherwise served from /admin/account. The split was leaking before this change. Gating is equivalent where it overlapped: /player and /auth/me apply identical `noindex, requireAuth`, and `staffOnly` on /admin/account was strictly narrower while buying nothing, since every handler is self-scoped to req.user.id. There is no CSRF layer to differ. - 14 routes deleted, 0 added, no handler changed. - account.controller.js moves router/v1/admin/ -> router/v1/auth/, beside the one router that still reaches it. - Web client: 14 call sites move onto a root-level api.myAccount / api.changeUsername / ... group, matching the /auth/me methods already there. - Android app: no change. MeApi.kt was already 100% /auth/me/account/*. - Two swagger tags, `Admin · Account` and `Player`, were declared only by the deleted routes and go with them. The orphaned `AccountStatus` schema goes too; `PlayerAccount` is re-described as the any-role /auth/me/account shape (the name is kept so existing $refs resolve). Breaking to the published OpenAPI surface, accepted deliberately: both consumers are in this org, and deprecate-then-delete would leave the next phase deciding whether to add routes to surfaces already marked for removal. Verification: routes.manifest.json shows exactly 14 deletions and 0 additions. The OpenAPI spec loses the same 14 paths with zero surviving path definitions changed; its large textual diff is pure reordering, because removing the first-mounted router shifts every later path. 1203 server tests, 288 client tests, 53 bot tests green; check:modules, check:hosts and routes:manifest --check all pass. Design of record: docs/website/ENGAGEMENT.md Phase 1a. This lands ahead of engagement Phase 1b, which adds a self-service email field — written once here rather than three times. Co-Authored-By: Claude <noreply@anthropic.com>The contract half of the engagement system: a module (and core) can DECLARE an event with a payload contract and fire it. Nothing delivers yet — `emit` validates, logs and stops, and Phase 4 replaces that log line with the engine. `api.registerEventTriggers` and `api.registerAudiences` ride the existing stage()/apply() validate-then-commit discipline, so a registrant that throws halfway leaves nothing behind. `ctx.events.emit` is fire-and-forget and binds the owner from the calling module — a module fires its own triggers and no one else's. `ctx.inbox.push` is present and throws until Phase 7, the shape 1.6.0 settled on for a member that arrives a phase late. MODULE_API_VERSION 1.7.0 on both halves. Additions only; module-uo's `coreApi: "^1.3.0"` still resolves. Three design decisions, approved by the org lead before any code: ONE NAMESPACE for trigger ids and notification-stream ids (ENGAGEMENT.md §7.2, against the recommendation in the text). A trigger is a payload contract attached to an id that may also carry a subscription toggle, so an id has exactly one owner across both facets, checked in both directions. Core's five trigger ids ARE its five stream ids, so the same-owner upgrade case is exercised on every boot rather than only by a module. It keeps notification_channel_prefs single-keyed in Phase 3, where two namespaces would have forced a `kind` discriminator into its primary key. Two knock-on effects appeared only once it was implemented. The id grammar had to be RELAXED to admit `_` inside a segment — §4.3's own worked example is `uo.house.idoc_warning`, and two grammars over one namespace would mean an id legal as a trigger and illegal as the stream it is the same event as. And the seven grandfathered `uo.*` ids had to share their legacy allowlist with triggers, because under one namespace `idoc.warning` is a single id. The push catalog is untouched either way: allStreams() still serves the stream facet only, so the shipped Android client sees exactly what it saw before. THE CEILING LATTICE (G24), which the plan named everywhere and defined nowhere. It is containment, not size: everyone ⊃ authenticated ⊃ {subscribers, members, staff, owner}, with the four leaves mutually incomparable. The flat total order the plan's wording invites would let a `staff`-ceilinged trigger be given an `owner` audience — a rule that mails cheat detection to the player it detected. Fewer people is not less exposure. Two incomparable ceilings have no meet at all, so a composition is refused rather than guessed; union-widens is the intuitive implementation and it is the wrong one. `kind: 'event' | 'scheduled'` is declarable now and no evaluator exists (§7.1 Q6). Registration accepts `scheduled` and emit refuses to fire one, so `kind` means something from the moment it can be written rather than from the moment it is honoured. Also: `GET /admin/engagement/{triggers,audiences}`, served from the registries rather than a table so an uninstalled module simply stops appearing; `npm run engagement:manifest` plus its CI `--check`, the twin of the route manifest, because renaming a variable breaks stored templates silently, at send time, in mail someone already received. Co-Authored-By: Claude <noreply@anthropic.com>`notification_subscriptions` answers one question — which streams a user wants PUSHED — because that is the only question the shipped Android client can ask. This adds the general one: which subscribable ids, on which channel, in which mode. The old table becomes the push projection of the new one and keeps its exact wire shape, so the shipped APK needs no update and no delivery path is touched. What lands: - `engagement/channels.js` — `registerDeliveryChannel` (ENGAGEMENT.md §3.1), the declarative half only: id, label, `carriesContent`, `defaultMode`, `supportsDigest`. `addressFor`/`render`/`deliver` wait for Phases 6 and 7, for the reason `transports/index.js` deferred this file at all. `coreChannels.js` declares push / email / inapp through the subsystem's one door. - `notification_channel_prefs` + a replay-safe `INSERT IGNORE … SELECT` backfill, copying the `announce_jobs → announce_job_legs` precedent. - `GET · PUT /auth/me/notifications/channels`. The PUT is SPARSE — only the `(id, channel)` pairs named are written — deliberately unlike the two whole-set PUTs beside it. `off` is a mode rather than an omission, so this endpoint has no empty-array case and the kotlinx DTO gotcha cannot arise here. Three decisions the org lead settled before any code, and one corrects the phase's own acceptance criterion: push's `defaultMode` is `off`, not `instant`. The plan borrowed "push is opt-OUT" from `team_notification_prefs`, where no row does mean notified — but stream subscriptions have never worked that way, so `instant` would have projected the whole catalog into the legacy GET for every existing user and switched every toggle on in the shipped app after an upgrade nobody asked for. A test pins the legacy GET at `{streams:[]}` for a fresh user. One thing not named by the phase, and it is a G24 consequence rather than scope creep: a trigger ceilinged at `staff` can never reach a non-staff user, so offering the toggle would be offering a dead control AND disclosing the event exists — `uo.cheat.detected` would otherwise appear in every player's screen the moment Phase 11 declared it. Filtered from the catalog and gated on write. That gave the `staff` label its first consumer, now written down as `ceilings.STAFF_CEILING_ROLES` (the admin tier's three, deliberately not `teamGrants.STAFF_ROLES`, which answers a different question). 15 new tests; swagger, route manifest and guards regenerated. No web or app surface — those are Phases 7 and 8, where a preference governs something visible. Refs: docs/website/ENGAGEMENT.md Phase 3, §3.1, §4.5 Co-Authored-By: Claude <noreply@anthropic.com>The admin surface over the Phase 4a engine: two screens, twelve routes and the reach preview. Nothing in the engine changed; what changed is that an operator can now reach it. Four decisions settled by the org lead before any code: - segments get their OWN nav entry, "Audiences", not a tab of the rules screen - the on/off switch is its own PATCH route, not a full PUT - the reach preview is a count only, on demand - a rule can be hard-deleted; the send log survives it The switch is the one with real content in it. A PUT re-validates against the registries as they are NOW, so the rules a re-validating toggle cannot switch off are exactly the three an operator most wants stopped: a rule whose module was uninstalled, one naming a channel that is gone, and one whose trigger has since narrowed its ceiling under a saved audience. PATCH .../enabled writes one column and always works. Switching ON unvalidated is safe because the engine re-checks the ceiling at send time. The preview calls the engine's own resolver rather than a second query that agrees with it today, and answers a count and nothing else - the resolver's output for a module-declared segment is a set of players derived from game data. It reports `capped` at the 5000-row bound (the count is a floor, not a total), `reason` for an `owner` audience (which resolves per event and has no advance answer), and `permitted` so the editor cannot show a healthy number beside a save the server will refuse. Two defects found by walking it against a live server, both in Phase 4a's code: 1. A rule pointing at a DORMANT segment read as healthy. listAnnotated asked only whether the segment ROW existed. The other shape of the same failure is a segment sitting exactly where it was whose every audience belongs to an uninstalled module: same outcome, nothing deleted. Uninstalling a module under an enabled rule produced a rule the screen showed as on and firing. The expression walk now lives in engagement/segments.js as `missingAudiences` and both lists ask it. 2. "1 rule still use this segment" - the delete refusal pluralised the noun and not the verb, in the sentence an operator reads when told no. Also: a rule's trigger is now a stated rule rather than an omission in the UPDATE statement (its cooldowns, queued sends and history are all about one trigger id); a condition tree the editor cannot render is shown read-only rather than flattened, because flattening changes which events fire the rule; and literals are coerced client-side to the type the trigger declared, with anything that does not parse passed through unchanged so the server's refusal names the variable. Tests: 21 new server tests (test/engagementAdmin.test.js) and 25 client ones (client/test/engagementRules.test.js), all green. The single failure in the server suite (`the committed manifest matches the declarations in the tree`) is the known Windows CRLF artifact and fails identically on clean edge. Companion docs PR: docs#184. - [x] AI-assisted: written with Claude Code (Opus) Co-Authored-By: Claude <noreply@anthropic.com>Driving the two screens in Chrome, after the API walk had already found the two in Phase 4a's code. None of these is visible from a test or from curl. Two cost an operator something real: - The Audience dropdown rendered EMPTY before a trigger was chosen. There is genuinely nothing it may offer without a ceiling, but a select with zero options reads as broken rather than as waiting. It now says "Choose a trigger first..." and is disabled. - A `members` audience with no saved audience reaches NOBODY, and only the preview button said so. That is the design, but it is also the default the instant a members-ceiling trigger is picked - so the rule saves, gets switched on, and mails nobody with nothing on screen saying so. The editor now says it inline, and stands down once a preview has answered the same question more precisely. One the server was already refusing, just too late: - The composer offered "exclude" on the only row, building an `and` whose every child is a complement. The server refuses it correctly but only after a save, and it is one checkbox away at all times. Now refused inline, in the operator's words. Three wording and layout: - the template-key input truncated its placeholder, and said "optional until Phase 5" - a sentence about the plan document, not about the deployment - "segment" leaked into a screen that says "saved audience" everywhere else. The API, schema and docs keep saying segment (one word for one table); translated at the point of display only - the composer repeated its AUDIENCE heading above every row Client only - no server change, so swagger and the route manifest are untouched. Client suite 316/316; all six verified in the browser after the fix. - [x] AI-assisted: written with Claude Code (Opus) Co-Authored-By: Claude <noreply@anthropic.com>Phase 5a gave templates a table, a renderer and nine seeded rows; nothing could change one. This is the screen that lets an operator change one without being able to break the mail the system depends on — plus the two screens Q4 promised Phase 5: Triggers (read-only, from the registries) and the Send Log, which closes G15. The shape follows from one fact: a mail body is rendered by the SERVER, so the preview is too, and framed rather than redrawn in React. A client-side renderer would be a second implementation of the one artifact that matters, agreeing with the send path on the day it was written and drifting from the first Outlook fix on. Settled with the org lead before any code: a shipped default is edited IN PLACE (`protected` blocks deletion and nothing else, `customized = 1` keeps the edit); duplicate is the only way to a new template; `renderByKey` now requires `published`; a test send is logged under a synthetic `core.admin.test-send`; and a template a rule points at refuses deletion with a 409 naming the rules. Three things the plan did not know, found by building it: - The undeclared-variable check cannot be a token scan. `email.itemList.variable` holds a BARE name, so a digest pointed at `itmes` would have saved clean and arrived empty. Blocks now declare `variables(props)`; the editor makes that field a select over the trigger's list variables so the typo is unavailable. - A duplicate that drops `seed_key` loses its variable palette, so duplicating `notify.event` would have been refused for the tokens it was copied with — the one action §4.6.2 offers, refusing itself. The copy inherits it; `customized` is what the seeder actually reads. - `validateEmailBlocks` returns `{ valid, errors }`, not an array, and the first version tested it with `.length` — so block validation never ran at all. Also fixes a Phase 4a defect the live walk found, with the org lead's approval: a rule's template key was checked against a pattern with no dot in it, so no rule could name any template that exists — §4.6.2's whole duplicate-and-point-a-rule-at-it workflow was unreachable. Both models now read one pattern. Verified against the running stack: real multipart mail into a mailpit catcher including an unsaved draft, the draft/published arms both ways through the real mailer path, every refusal, and the end-to-end duplicate → rule → 409 walk. Server 1428 tests green, client 324. Co-Authored-By: Claude <noreply@anthropic.com>Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is mailed, through which template, and how often at most. One walk goes forum write -> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP. Seven decisions settled by the org lead before any code: - email only moves; the push tickle and the Discord bridge stay direct calls - the EVENT carries its access-checked audience, and `members` resolves to it - the four Team rules are seeded DISABLED, with an admin banner and a note - team_notification_prefs stays, read by the engine as a scoped preference - the payload wins and a structural projection fills the gaps - the digest keeps computing at send time; only its state generalizes - an unsubscribe token turns off the channel it names, and nothing else Three defects found while building it: - `email.button` never absolutized its href, while image and itemList both did. Every rule-driven CTA would have been a dead relative link, because a trigger's url variables are validated site-relative by construction. - Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided not to build. An outbox row snapshots the payload and so has none of the three properties the digest design exists for, including the security one. - the digest's send-log row carried no address_hash while the instant row beside it did, which would have made half the mail uncorrelatable in Phase 9. Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key, a v2 unsubscribe token that still verifies v1 forever, and the canonical /public/engagement/unsubscribe pair with the old /public/teams path kept permanently — mail is not editable once sent. Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB + Mailpit + a real Team) covering the instant mail, the digest, the generic template, a pre-migration unsubscribe link and the backfill's replay-safety. Docs: RunicGateway/docs#TBD Co-Authored-By: Claude <noreply@anthropic.com>ENGAGEMENT.md Phase 7. `user_notifications`, the in-app DeliveryChannel, the four inbox routes, and the web surface — plus the two pieces earlier phases assigned here that Phase 7's own acceptance line omits. Four decisions settled by the org lead before any code: 1. `inapp` defaults to `instant` — the only channel that does. Push wakes a device somebody is holding and email leaves the building, so both are asked for; an inbox item is a row on a page the user chose to open. Left `off` the channel ships dead. 2. The phase takes push's `deliver` (§2603) and the web per-channel preferences screen (Phase 3's as-built), neither of which its own bullets mention. 3. The inbox takes `/auth/me/notifications` and `/account/notifications`; the preferences screen moves to `…/settings`. The plain word belongs to the content, which is what the bell opens. 4. `ctx.inbox.push` honours the user's in-app preference when `triggerId` names a registered trigger, and writes when it does not. Server - `user_notifications` + `model/userNotifications/`. The dedupe UNIQUE is scoped to the USER, narrower than the outbox's `(rule, user, channel)`: an inbox has no channel dimension, so two rows for one event would be one item shown twice. - `engagement/inappChannel.js` — renders by block ROLE (first heading → title, first button → url, the rest → body) and inserts. `pushChannel.js` — a content-free `{stream, ref}` tickle whose ref deep-links the inbox row. - `engine.liveChannels` orders `inapp` first (`CHANNEL_ORDER`) so that ref resolves on the first sweep. An ordering, not a dependency. - `templates.renderInappByKey` + `resolveTemplate` extracted from `renderByKey`, so both channels take the same fallback chain. - `inapp.event` seed → seedVersion 2: it named `body`/`url`, which nothing supplies. Renamed to the structural vocabulary the projection fills in. - `utils/userNotificationsPrune.js` — nightly, READ items only, horizon in `settings.user_notifications_retain_days` (default 90). - `GET /auth/me/notifications`, `…/unread-count`, `POST …/:id/read`, `POST …/read-all`. Swagger + route manifest + four component schemas. Web - `NotificationBell` in all three headers, polling its badge once a minute and pausing while the tab is hidden. `PlayerInbox` at `/account/notifications`. - The preferences screen becomes a channel matrix over `/auth/me/notifications/channels` — a strict superset of the push-only stream list it replaces. The two legacy endpoints are untouched, so the shipped Android app keeps its wire shape. - Staff get the same two screens at `/admin/notifications…`: `RequirePlayer` keeps them out of `/account`, so without this the inbox was unreachable for every non-player account. `lib/notificationPaths.js` is the one mapping. Verified: 28 new server tests (5 of them against a real MariaDB, for the three index/statement properties that are a server contract rather than a reading of this code) + 3 client. Server suite green, client 327 green. A live rig walked the whole path: two rules on one event produced three outbox rows and exactly one inbox item, the tickle carried `ref: notification:2`, and the retention sweep dropped an aged read row while keeping an equally aged unread one. Docs: RunicGateway/docs#TBD, RunicGateway/runicgateway.com#TBD Co-Authored-By: Claude <noreply@anthropic.com>ENGAGEMENT.md Phase 9, closing gap G16. Two mechanisms decide that somebody in a rule's audience does not get the mail, and they sit at deliberately different points in the pipeline. `engagement_suppressions` is checked at DELIVERY: an outbox row can sit through a rule's `delay_seconds` grace window and an address can bounce inside it, so the only correct check is the one taken immediately before the transport call — which is also what produces the `status='suppressed'` row with no transport call at all. The Phase 1b verification gate is applied at ENQUEUE, through a new optional `registerDeliveryChannel({ eligible })` that only `email` declares. Filtering the shared audience would have silenced the wrong sink: a rule spanning email and in-app must still put an item in an unverified user's inbox. The excluded counts reach `summary.ineligible` and the admin reach preview, which until now reported an audience size that was never the number of people who would be mailed. `bounceClassify.js` is the only thing that may write a `bounce` row, and it is deliberately NOT `mailer.PERMANENT_CODES`. That set answers "is retrying pointless?" and contains EAUTH and 554 — an auth failure and a relay-wide policy refusal, neither of which is a fact about the recipient. Reusing it would mean one stale SMTP password suppressing every address the worker touched, silently. The classifier reads the RFC 3463 enhanced status first, falls back to a phrase match only past a veto list and only for 550/551/553, and does not suppress anything it is unsure about. Scope is engagement rules only: resets, invites, verification and the contact form still attempt, matching the posture passwordReset.controller.js already stated. Found on the live rig, against a real MariaDB and a real SMTP conversation: a hard bounce was being recorded as `failed`, so the Send Log's "Bounced" filter — a status `engagement_sends` has carried since §4.5 — matched nothing and always would have. It is now its own outcome; the outbox row stays `failed`, since that ENUM has no `bounced` and a bounced row is one that finished unsuccessfully. `address_masked` is this phase's one addition to §4.5's DDL. A hash-only table cannot be operated — an operator cannot tell three typos from a whole domain refusing mail — and the domain survives while the local part is destroyed, so the column can never be read back as an address book. - schema: `engagement_suppressions` (+ `address_masked`, `created_by`) - `GET/POST/DELETE /api/v1/admin/engagement/suppressions`, and Admin → Engagement → Suppressions, the only way out of the list - `sendNotification` returns `smtp: { code, responseCode, response }` - 26 new tests; swagger, routes manifest and guards regenerated Docs: RunicGateway/docs#191. Co-Authored-By: Claude <noreply@anthropic.com>adminceiling and core'snews.postemitter (Phase 11a)' (#177) from feature/engagement-uo-triggers into edge 81e0338a69Phase 11a declared 24 triggers and stopped where the plan said it would. Standing 11b up found that the next sentence — "24 rules, all enabled = 0; bespoke template bodies" — described work with no mechanism to land in: templateSeeds.js and coreRules.js are core files with core arrays in them, and there was no registerTemplates or registerRules anywhere in registries.js. So a module could say what an event's payload was and could never say what the mail should read like. That is tolerable for one trigger and not for a catalogue, and it is decisive once the bodies carry domain prose core must not contain (§5.2). Adds api.registerEngagementSeeds({ templates, ruleGroups }) — MODULE_API 1.9.0. The module supplies data; core keeps seedOne's customized skip, its seed_version comparison and the block registry's validation, which is the whole argument for a registry over the ctx.query a module already holds: a copy of any of those living outside engagement/ would drift the first time core improved the original, and the drift would surface as a mail somebody already received. The two halves behave differently, deliberately: - Templates re-ensure on every boot, so a bumped seedVersion reaches every deployment except the ones where an operator edited that row. - Rule groups are ONE-SHOT, each under its own settings guard — re-ensuring would resurrect a rule an operator deleted and reset one they enabled. This is 11a's seed-key finding stated as an API rather than as a warning: a rule appended to an existing group reaches fresh installs only, and one that must reach stamped deployments takes a new group key. Three prohibitions, each a shipped mistake that would only surface as mail: a seeded rule is always enabled = 0 (Q3's invariant, ignored rather than refused so a typo cannot take a module offline at boot); a module may not mark a template protected; and a rule may only name its own trigger ids and its own or core's template keys, with template keys namespaced because the key column is UNIQUE. Runs from modules/lifecycle.js boot() rather than seedDefaults(), and that is forced rather than chosen: server.js seeds before it requires app.js, and requiring app.js is what runs the loader — at the moment core seeds, no module has registered anything. Placed after the installed_modules reconcile (so a disabled or failed module is skipped) and before the onBoot dispatch (so a module warming a cache may assume its rules exist). 16 new tests; 1549 core tests green; check:modules clean. Refs docs#/ENGAGEMENT.md Phase 11b decision 7. Co-Authored-By: Claude <noreply@anthropic.com>Found building module-uo's sixteen in-universe bodies, which are the first trigger-bound templates in the system to carry an unsubscribe line of their own. `emailChannel.deliver` computes an unsubscribe token per recipient and merges it LAST over the projection, so `{{unsubscribeUrl}}` has always RENDERED correctly. But `variablesFor` takes a trigger-bound template's variable list from the trigger's declaration, and a trigger has no business declaring a fact about how the mail was delivered — so the token was undeclared, and the save-time undeclared-variable check would have refused the first operator who tried to EDIT one of those bodies. Rendering right and then refusing the edit is the worst of both. Nothing had ever taken this path: core's generic `notify.event` declares `unsubscribeUrl` in its own seed and is bound to no trigger, so `seedByKey` supplied it there. Adds DELIVERY_VARIABLES beside AMBIENT_VARIABLES — declared separately because they apply to a different set of templates. Ambient facts are about the deployment and reach every body; delivery facts are about the send and reach the trigger-bound ones, which is exactly the set that is engagement mail. Co-Authored-By: Claude <noreply@anthropic.com>