diff --git a/modules/uo/API.md b/modules/uo/API.md index 6067a3a..a74d48f 100644 --- a/modules/uo/API.md +++ b/modules/uo/API.md @@ -157,8 +157,9 @@ stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` retu ## 5. Engagement triggers and audiences (ENGAGEMENT.md Phase 11) Not an HTTP surface, and it is here anyway: it is the other thing this module registers with core, and -it is the one an operator interacts with by name. `module-uo` declares **24 event triggers** and -**3 audiences** through `api.registerEventTriggers` / `api.registerAudiences` +it is the one an operator interacts with by name. `module-uo` declares **25 event triggers** and +**3 audiences** through `api.registerEventTriggers` / `api.registerAudiences`, and ships +**32 message bodies and 25 rules** through `api.registerEngagementSeeds` ([`MODULE_API.md`](../../website/MODULE_API.md) §2.4). Core never learns a word of the vocabulary — it holds an id, a label, a variable list, a ceiling and, for an audience, a `resolve` it may call. @@ -180,7 +181,7 @@ ceiling does not permit `owner`, because fewer people is not less exposure. | Ceiling | Triggers | Why that ceiling | | --- | --- | --- | -| `owner` | `uo.house.idoc_warning`, `uo.house.collapsed`, `uo.vendor.expiring`, `uo.vendor.sale`, `uo.account.login_failed`, `uo.account.unlinked`, `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered` | Each is about one person's own property, account or character. All ten resolve through the frame's `ownerAcct` to `shard_account_links`; an unlinked game account is nobody to notify | +| `owner` | `uo.house.idoc_warning`, `uo.house.collapsed`, `uo.vendor.expiring`, `uo.vendor.sale`, `uo.account.login_failed`, `uo.account.unlinked`, `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered`, `uo.governor.appointed` | Each is about one person's own property, account, character or office. All eleven resolve through an account on the frame to `shard_account_links`; an unlinked game account is nobody to notify | | `members` | `uo.guild.left`, `uo.guild.disbanded` | The guild's roster, resolved to website users through `shard_account_links` and carried on the emit as `recipientUserIds` — "the members of *this* guild" is a different answer every firing, which a saved segment cannot express | | `authenticated` | `uo.governor.elected`, `uo.election.opened`, `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down`, `uo.points.rank_changed` | Public shard news. Each defaults to `subscribers`; the ceiling permits an operator to widen to everyone signed in, which for "the shard is back up" is a defensible thing to want | | `staff` | `uo.page.new`, `uo.cheat.detected` | `uo.cheat.detected` is the declaration the lattice was written for: under a flat "fewer people is narrower" ordering, a `staff` ceiling would also permit `owner`, and the rule an operator could then save mails the cheat report to the player who was detected | @@ -257,3 +258,76 @@ load-bearing. Three mappings read a row the state write is about to delete or re `house.remove` drops the house whose stored `ownerAcct` is the only place a collapsed house's owner appears (the frame carries a serial alone); and `guild.leave` / `guild.remove` need the roster and board mirrors to name who left and which guild it was. Resolving afterwards finds nobody, every time. + +### 5.7 The shipped bodies (Phase 11b) + +Declaring a trigger says what an event IS. It says nothing about what the message reads like, and +until Phase 11b there was no way for a module to say: `templateSeeds.js` and `coreRules.js` are core +files with core arrays in them. `api.registerEngagementSeeds({ templates, ruleGroups })` +([`MODULE_API.md`](../../website/MODULE_API.md) §1.1, **1.9.0**) is the mechanism; this module is its +first caller, with **32 bodies and 25 rules** in +[`server/config/engagementSeeds.js`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/server/config/engagementSeeds.js). + +**Sixteen families read from inside Britannia, with a per-family sender.** The org lead's decision +(ENGAGEMENT.md Phase 11b, decision 8) was a sender per family rather than one voice across all of +them: a shard where Lord Blackthorn writes to you personally about a champion spawn is a shard where +the letter about your governorship means nothing. + +| Sender | Families | +| --- | --- | +| **Lord Blackthorn's court** | the governorship, the elections — the crown's business and nothing else | +| **the Office of Deeds** | houses | +| **the Merchants' Guild** | vendors | +| **a guild herald** | guild departures and dissolutions | +| **the town crier** | champion spawns | +| **a guildmaster** | skills, quests | +| **the Chronicler of the Dead** | deaths and murders | +| **the keeper of the rolls** | leaderboards | + +Each ships an `email` body and an `inapp` body **in the same voice** — one rule fires on both at once, +and a player who reads the inbox item and then the mail must not meet two different narrators. The +**digest** stays core's generic `notify.digest`: a day of events rolled into a list is not a letter +from anybody. + +**Nine stay plain, and the line is where fiction costs something real** (decision 9). Both +account-security triggers, `uo.server.up`/`down`, and the five staff- and admin-ceilinged ones point +at core's `notify.event` / `inapp.event` and author nothing — which is also §4.6.1 property 1 being +exercised at scale. A failed-login notice written as *"a stranger sought entry to thy account"* is +indistinguishable in register from the phishing mail it warns about, and a moderator reading +`uo.cheat.detected` at two in the morning wants a name, a rule and a timestamp rather than a scroll. + +**An operator whose shard is not Blackthorn's Britannia edits these rows.** The template editor is +where, and `customized = 1` then protects the edit from every later seed — the bodies are defaults, +not fixtures. + +Two mechanical notes that will bite whoever adds the twenty-sixth trigger: + +- **All 25 rules are in ONE seed group, `triggers-v1`, and a group is seeded once.** A rule appended + to it later reaches **fresh installs only** — never a deployment already stamped. A rule that must + reach existing deployments takes a new group key. +- **A trigger id and a template key have different grammars.** `uo.champ.boss_up` is a legal trigger + id and an illegal template key (core's key pattern admits `.` and `-`, not `_`), so its body is + keyed `uo.champ.boss-up`. Registration refuses the mistake at boot. + +### 5.8 The presentational fragments, and why they exist + +A template has **no conditionals**, by design, and an unset optional interpolates to the **empty +string**. That is right for a structural body and wrong for a sentence: *"Be it known that , +recorded to thy name, is this day found ."* So the ternary stays in `shardEngagement.js` and its +result arrives as a declared optional — Phase 5a's `forWhom` precedent. Two shapes, and the `example` +on each declaration shows which it is: + +- a **LABEL** always has a value and carries a sentence's spine. `houseLabel` is the name and region, + falling back to the seal number, because a warning has to name something the owner can act on. +- a **TRAILING FRAGMENT** may be empty and leads with **its own space**, so `{{slainBy}}.` closes as + *"has fallen."* either way. + +They are declared `required: false` deliberately: a required variable missing **refuses the emit**, +and a dropped notification is worse than a cosmetic hole. Nothing at runtime therefore notices a +mapper that forgot one, so `server/test/engagementSeeds.test.js` asserts every label is supplied on +every path that emits its trigger. + +A whole **detail line** works the same way one level up (`ledgerLine`, `whereLine`): four optional +numbers assembled into a sentence by the mapper, and absent entirely when the frame carried none of +them — the same argument `place()` makes for coordinates. A pre-v5 vendor frame otherwise renders +*"On hand: gold. Charged each period: gold."* diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index f54bc3a..4075624 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -3268,11 +3268,192 @@ and 5a/5b precedent: regenerated. The acceptance that belongs here is the catalogue-shaped half: **no staff- or admin-ceiling trigger appears by name in a player's preferences catalogue**, and `check:modules` proves core gained no UO identifier. -- **11b — the seeded templates, the seeded rules, and the live walk.** 24 rules, all `enabled = 0`; - bespoke template bodies for the families that earn one and `notify.event` + the structural - projection for the rest (§4.6.1 property 1 is precisely the claim that most of them need no - authoring, and 24 triggers is the first test of it that means anything). Everything in the - acceptance list below that needs a running shard is 11b's. +- **11b — the seeded templates, the seeded rules, and the live walk.** **25** rules, all + `enabled = 0` — the twenty-fourth is `uo.governor.appointed`, added by decision 10 below. Sixteen + in-universe bodies on two channels each (decisions 8 and 9), and core's `notify.event` + + `inapp.event` + the structural projection for the nine that stay plain — §4.6.1 property 1 is + precisely the claim that a trigger needs no authoring, and the nine are the test of it that + means something. All of it reaches the database through **`registerEngagementSeeds`**, which + decision 7 adds because no mechanism for a module-supplied template or rule existed. Everything + in the acceptance list below that needs a running shard is 11b's. + +#### Four more decisions, settled by the org lead before any 11b code (2026-09-01) + +Phase 11a shipped the declarations and stopped exactly where the plan said it would. Standing it up +raised four questions the plan had not asked, two of which are mechanism and two of which are voice. +All four were settled the same day, before any code, and three of them changed what 11b builds. + +| | Question | Decision | +|---|---|---| +| 7 | a module cannot ship a template or a rule — core's seeders are core's | **add `api.registerEngagementSeeds({ templates, ruleGroups })`**; `MODULE_API_VERSION` → **1.9.0** | +| 8 | the register the game-powered bodies are written in | **in-universe, with a per-family in-fiction sender** — the crown writes about the crown's business, and nothing else | +| 9 | which bodies stay plain | **the nine security and operational ones**; the other sixteen go in-universe | +| 10 | the newly-elected governor is addressable and receives no letter | **add `uo.governor.appointed`** — a 25th trigger, `owner`-audienced, beside the existing broadcast | + +**Decision 7 is the one 11a could not have discovered, because 11a seeded nothing.** `templateSeeds.js` +and `coreRules.js` are core files with core arrays in them, and there is no `registerTemplates` or +`registerRules` anywhere in `registries.js`. So the sentence above — "24 rules, all `enabled = 0`; +bespoke template bodies" — described work with **no mechanism to land in**. A module could have +reached `engagement_templates` through the `ctx.query` it already holds, and that was rejected for the +reason every raw-SQL-into-core-tables shortcut is rejected: it would re-implement `seedOne`'s +`customized` skip, its `seed_version` comparison and its block validation outside the file that owns +them, and the copy would drift the first time core improved the original. The registry keeps every one +of those in core and lets a module supply data — which is the same split `registerEventTriggers` +already makes, and the same reason it makes it. + +> **Templates are re-ensured every boot; rule groups are one-shot.** That asymmetry is not an +> oversight, it is the two mechanisms this system already has, applied unchanged. A template row +> carries `seed_key` + `seed_version` + `customized`, so re-ensuring is how a better default reaches a +> deployment without stealing an operator's edit (§4.6.1 property 3) — and a template added in a later +> module version reaches every deployment, not only fresh ones. A rule is the opposite: re-ensuring one +> would resurrect a rule an operator deleted and reset one they enabled. So rules arrive in **named +> groups**, each with its own one-shot settings guard, which is 11a's seed-key finding stated as an API +> rather than as a warning. A rule added to an existing group reaches fresh installs only; a rule that +> must reach existing deployments takes a new group. The module names its groups, so the module owns +> that choice, and gets a docs paragraph telling it which one it is making. + +**Where it runs is load-bearing, and it is not `seedDefaults()`.** `server.js` calls `seedDefaults()` +**before** it requires `app.js`, and requiring `app.js` is what scans the volume and runs the loader — +so at the moment core seeds its own templates, no module has registered anything. Module seeds +therefore run in `modules/lifecycle.js` `boot()`, after the `installed_modules` reconcile and **before** +the `onBoot` dispatch: a module whose `onBoot` warms a cache may assume its rules exist, and a module +the operator has disabled or that failed to load is skipped rather than seeded. Failure is `safe()`'d +like every other step there — a template that would not seed costs the shipped default, never the boot. + +**Decision 8 is why the bodies are the module's and could not have been core's even with a mechanism.** +"A letter from Lord Blackthorn" is Britannian canon; §5.2's rule is that core stays ignorant of every +word of a module's domain. `check:modules` would not have caught it — it reads identifiers, imports and +route literals, never prose (`checkModuleIdentifiers.js` says so in its own header, and says why) — and +that is precisely the argument for taking the boundary seriously here rather than leaning on the +checker. A `templateSeeds.js` in core containing `uo.house.idoc_warning` and the word *Britannia* would +defeat the extraction while passing every check in the repo. + +**The sender is per family, not one voice across all sixteen.** The crown was the tempting answer and +it is the wrong one: a shard where Blackthorn writes to you personally about a champion spawn is a +shard where the letter about your governorship means nothing. So the court writes about the crown's +business — the governorship, the elections — and everything else has the sender its own subject +implies: + +| Family | In-fiction sender | +| --- | --- | +| `uo.governor.appointed`, `uo.governor.elected`, `uo.election.opened` | **Lord Blackthorn's court** — the seat, the ballot, the realm's offices | +| `uo.house.idoc_warning`, `uo.house.collapsed` | the **Office of Deeds** — a clerk with a ledger and a duty to warn | +| `uo.vendor.expiring`, `uo.vendor.sale` | the **Merchants' Guild** — a factor rendering accounts | +| `uo.guild.left`, `uo.guild.disbanded` | a **guild herald** | +| `uo.champ.started`, `uo.champ.boss_up` | the **town crier** — which is also the leg a module already owns | +| `uo.skill.capped`, `uo.quest.complete` | the **guildmaster of that craft** | +| `uo.character.death`, `uo.character.murdered` | the **Chronicler of the Dead** | +| `uo.points.rank_changed` | the **keeper of the rolls** | + +**Decision 9 draws the line at the two places fiction costs something real.** A security notice must be +distinguishable from flavour — a failed-login mail written as *"a stranger sought entry to thy +account"* is indistinguishable in register from the phishing mail it is warning about, and that is not +a trade worth making for atmosphere. And an operator reading `uo.cheat.detected` at two in the morning +wants a name, a rule, a location and a timestamp, not a scroll. So nine stay plain and use core's +generic bodies: + +``` +PLAIN (9, core's notify.event / inapp.event) + uo.account.login_failed uo.account.unlinked ← security + uo.server.up uo.server.down ← infrastructure + uo.page.new uo.cheat.detected ← staff ceiling + uo.audit.staff_action uo.economy.milestone + uo.world.saved ← admin ceiling + +IN-UNIVERSE (16, module-uo's own bodies) + house ×2 vendor ×2 skill/quest/death ×4 + guild ×2 civic ×3 champ ×2 points ×1 +``` + +**Both channels, and the digest is deliberately not one of them.** Each in-universe trigger ships an +`email` body — the letter — and an `inapp` body in the same voice, because a rule fires on both +channels at once and a player who reads the inbox item and then the mail must not find two different +narrators. Thirty-two rows, which is data rather than design. The **digest** stays core's generic +`notify.digest`: a day of events rolled into one list is not a letter from anyone, and dressing a +bulleted summary as correspondence is where this device stops being charming. + +**Decision 10 exists because a fact in the plan was wrong by omission.** §8.6 records that +`uo.points.rank_changed` cannot address a person, because `points.board.top[]` names a mobile serial +and links are keyed by account — and the same reasoning was silently assumed to cover the governor. +It does not. `city.update`'s `governor` field is written by `BridgeJson.Actor()`, which emits +`serial`, `name`, **`acct`** and even `webId` when the account is linked. The new governor is +addressable **today**, with no protocol change and no new wire field. So the letter the whole voice +decision was chosen to make possible has a recipient, and 11b ships it: + +- **`uo.governor.elected`** stays exactly as 11a declared it — `subscribers`, ceiling + `authenticated`, the town-square announcement that a city has a new governor. +- **`uo.governor.appointed`** is new — `audience: 'owner'`, `ceiling: 'owner'`, `subjectKey: 'city'`, + resolved through `ownerAccount: ev.governor.acct` on the same `city.update` frame, in the same + transition branch, guarded by the same never-on-first-sight rule. It is the letter. + +Widening the existing trigger to two audiences was considered and refused for a reason worth keeping: +one trigger means one rule means **one template**, and the town's bulletin and the governor's letter +are not the same text. Two triggers is also what lets an operator run the announcement and leave the +letter off, or the reverse. + +**This takes the phase to 25 triggers**, and the count in §8.6 and in 11a's summary should be read +that way from here. It does not take another `MODULE_API` bump: a trigger declaration is module data, +and 1.9.0 is decision 7's. + +#### What building it found + +Four things, and the first two are the ones a reader of this section should not have to re-derive. + +**A template has no conditionals, and an in-universe body is a sentence.** That combination is the +whole engineering content of decision 8, and it is not obvious until a body is written. An unset +optional interpolates to the **empty string** (`interpolate.js` — no filters, no ternaries, by +design), which is exactly right for `notify.event`, whose variables are structural and each sit on +their own line. It is wrong for a letter: *"Be it known that , recorded to thy name, is this day +found ."* So the ternary stays at the call site and its result arrives as a declared optional, which +is Phase 5a's `forWhom` precedent unchanged — and the module now declares two shapes of them: + +- a **LABEL** always has a value and can carry a sentence's spine. `houseLabel` is the house's name + and region, falling back to its seal number, because a warning has to name *something* the owner + can act on and a seal number is worse prose and better than a blank. +- a **TRAILING FRAGMENT** may be empty and leads with **its own space**, so the sentence closes + cleanly without it: `{{slainBy}}.` renders "has fallen." or "has fallen at the hands of a lich + lord." + +The labels are declared `required: false` deliberately, and that is a real trade rather than an +oversight: a required variable missing **refuses the emit**, and a dropped notification is worse than +a cosmetic hole. Which leaves nothing at runtime to notice a mapper that forgot one — so +`engagementSeeds.test.js` asserts every label is supplied on every path that emits its trigger. That +test is the guarantee the declaration deliberately does not make. + +**A render sweep over all 32 bodies is what found the defects, and none of them were visible in the +source.** Every body was rendered twice — once with every declared `example`, once with **required +variables only** — and scanned for the punctuation a vanished token leaves behind. Three real +defects, none of which any existing check could see: + +1. **An optional variable in a SUBJECT LINE.** `A notice concerning thy house at {{region}}` renders + *"…thy house at "* for a house outside a named region. A subject is the one place a hole is + unmissable, and it is also the one place no block validator looks. +2. **Multi-optional detail lines.** *"On hand: gold. Charged each period: gold. Periods remaining: + ."* on a pre-v5 vendor frame, where the whole fee block is absent. Fixed the way `place()` already + argues for coordinates: the line is **assembled in the mapper** from the parts actually present, + and a frame with nothing to report produces no line rather than a punctuated empty one. +3. **A trailing fragment used as a leading one**, opening an in-app body with a stray space. + +**A trigger-bound template could not reference its own unsubscribe link.** `emailChannel.deliver` +computes an unsubscribe token per recipient and merges it last over the projection, so +`{{unsubscribeUrl}}` always *rendered* — 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 the sixteen bodies. Rendering right and +then refusing the edit is the worst of both. Core gains `DELIVERY_VARIABLES` beside +`AMBIENT_VARIABLES` — declared separately because they apply to a different set: ambient facts are +about the deployment and reach every body, delivery facts are about the send and reach the +trigger-bound ones, which is precisely the set that is engagement mail. **Nothing had ever taken this +path**, because core's `notify.event` declares the variable in its own seed and is bound to no +trigger. + +**A trigger id and a template key have different grammars, and one of these ids uses both +characters.** `uo.champ.boss_up` is a legal trigger id (§7.2 relaxed the id grammar to admit `_` +inside a segment, precisely so `uo.house.idoc_warning` could exist) and an **illegal** template key — +`engagement_templates.key` is matched by `templates.KEY_RE`, which admits `.` and `-` and not `_`. So +the body for that trigger is keyed `uo.champ.boss-up`. Caught at registration as a boot failure, +which is the right place, and asserted in the module's tests so the next person meets it as a test +name rather than as a puzzle. **Acceptance:** the five-rung shard visibility walk still shows no leak, and **no staff- or admin-ceiling trigger appears by name in a player's preferences catalogue**; a house transitioning to @@ -3287,6 +3468,13 @@ rule switched on, a publish tickles the same subscribers it tickled before this it tickles nobody and the admin screen says why.** Two more the new ceiling owes: **a rule cannot be saved giving an `admin`-ceiling trigger a `staff` audience**, and **an editor is not a recipient of `uo.audit.staff_action`** while an admin is. +Four more the 2026-09-01 decisions owe: **a module's seeded rule group is seeded once and only +once** — an operator who deletes one does not find it back after a restart, and one they enabled +is still enabled — while **a module template whose `seedVersion` moved is re-ensured unless the +operator edited it**; **an election on the live rig produces a letter to the new governor AND the +announcement to subscribers, from two rules either of which can be switched off alone**; and +**`check:modules` is still clean with sixteen Britannian bodies in the tree**, which is the +decision-8 boundary stated as a check rather than as a promise. **Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point — which is a materially stronger claim now that the module registers two dozen UO-named triggers. diff --git a/website/MODULE_API.md b/website/MODULE_API.md index c333830..56e502e 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -26,13 +26,104 @@ here extends the contract first, in this file, before the module is written agai Core exports a single integer-major semver string from `server/src/modules/version.js`: ```js -const MODULE_API_VERSION = '1.8.0' +const MODULE_API_VERSION = '1.9.0' ``` The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first module chunk evaluates, which is earlier than any network round trip could answer. +**1.9.0 — a module may ship its own message bodies and rules: `api.registerEngagementSeeds(...)`** +(`website/ENGAGEMENT.md` Phase 11b, decision 7). One addition and no removal, so minor; a module +written against 1.8.0 keeps working and simply seeds nothing. + +It exists because 1.7.0 let a module say what an event's payload *is* and gave it no way to say what +the message should *read* like. `engagement/templateSeeds.js` and `engagement/coreRules.js` are core +files with core arrays in them and there was no registration call beside them, so a module's +notification was core's generic `notify.event` body or nothing at all. That is tolerable for one +trigger and not for a catalogue: Phase 11 ships twenty-five, sixteen of which have domain prose that +core must never contain (§5.2 — and `check:modules` reads identifiers, never prose, so this boundary +is honoured deliberately rather than enforced mechanically). + +```js +api.registerEngagementSeeds({ + templates: [{ + key: 'uo.house.idoc-warning', // MUST be namespaced "." + name: 'House — final decay warning', + channel: 'email', // 'email' | 'inapp' + subject: 'Thy house at {{region}} stands in peril', + triggerId: 'uo.house.idoc_warning', + triggerVersion: 2, + seedVersion: 1, + blocks: [ /* the same block objects the template editor writes */ ], + }], + ruleGroups: [{ + key: 'triggers-v1', // the one-shot guard's name — see below + note: 'shown in the boot log when it inserts', + rules: [{ + trigger_id: 'uo.house.idoc_warning', // MUST be one of this module's own + name: 'House decay warning', + audience: 'owner', + channels: ['email', 'inapp'], + template_keys: { email: 'uo.house.idoc-warning', inapp: 'uo.house.idoc-warning-inapp' }, + cooldown_seconds: 86400, + max_sends_per_hour: 200, + delay_seconds: 900, // optional + cancel_on: ['uo.house.refreshed'], // optional + conditions: null, // optional + }], + }], +}) +``` + +**Callable once per module, and validate-then-commit like every other registration.** A module that +got one of thirty templates wrong ships none of them and finds out at boot with the offending key +named, rather than at send time with a half-seeded table. + +**The two halves behave differently, and the asymmetry is the contract.** + +- **Templates are re-ensured on every boot.** Each row carries `seed_key`, `seed_version` and + `customized`, so re-ensuring is how a better default reaches a deployment *without* stealing an + operator's edit (§4.6.1 property 3 of `ENGAGEMENT.md`), and a template added in a later module + version reaches every deployment rather than only fresh ones. **Bump `seedVersion` when a body + changes; never for a comment.** +- **Rules are one-shot, per named GROUP.** Re-ensuring a rule would resurrect one an operator deleted + and reset one they enabled, so each group carries its own settings guard + (`engagement_module_rules_seeded::`). **A rule appended to an existing group therefore + reaches fresh installs ONLY** — never a deployment already stamped. A rule that must reach existing + deployments takes a **new group key**. The module names its groups, so the module makes that choice; + make it knowingly. + +**Three things a module may not do, each of which is a shipped mistake that would only surface as +mail somebody received.** + +1. **A seeded rule is always `enabled = 0`.** `enabled` is not a parameter — a value passed for it is + ignored rather than refused, because refusing would let a typo take a module offline at boot. This + is `ENGAGEMENT.md` Q3's invariant surviving the largest seed set in the workstream. +2. **A module may not mark a template `protected`.** That flag means *"the system breaks without this + body"*, which is true of a password reset and of nothing a module ships; setting it would take an + operator's delete button away. +3. **A rule may only name its own module's `trigger_id`**, and its `template_keys` may only name this + module's own seeds or **core's** (`notify.event`, `inapp.event`, `notify.digest` — which is + §4.6.1 property 1 in force, and the right answer for any trigger whose message is structural). + A template `key` must be namespaced `.`, because `engagement_templates.key` is UNIQUE + across the table and an unprefixed `notify.event` from a module would collide with core's and win + or lose on boot order. + +**When it runs, which is not where the other seeders run.** `server.js` calls `seedDefaults()` +**before** it requires `app.js`, and requiring `app.js` is what scans the volume and runs the loader — +so at the moment core seeds its own templates, no module has registered anything. Module seeds are +therefore written from `modules/lifecycle.js` `boot()`, after the `installed_modules` reconcile and +**before** the `onBoot` dispatch. Two consequences worth relying on: a module the operator **disabled** +(or one that failed to load) is skipped rather than seeded, and a module that warms a cache in +`onBoot` may assume its bodies and rules already exist. Failure is logged and swallowed like every +other step there — a body that would not seed costs the shipped default, never the boot. + +**It is not a send path.** Everything on the object is data; nothing on it is a function and nothing +on it names a recipient. A module still cannot mail anyone (§1.2): it declares who an event is +*about*, and core decides who is told, after the ceiling, the preferences, the suppression list and +the verification gate. + **1.8.0 — a seventh audience ceiling: `admin`** (`website/ENGAGEMENT.md` Phase 11, decision 1). One addition and no removal, so minor; every declaration valid under 1.7.0 is valid now and no stored value changes. `admin` is a **child of `staff`**, so a module may declare `ceiling: 'admin'` on a @@ -53,7 +144,8 @@ unchanged, and so is every rule about composition narrowing rather than widening **1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves. -`api.registerEventTriggers([...])` and `api.registerAudiences([...])` (§2.4) · +`api.registerEventTriggers([...])`, `api.registerAudiences([...])` and +`api.registerEngagementSeeds({...})` (§2.4) · `ctx.events.emit(triggerId, envelope)` and `ctx.inbox.push(userId, item)` (§2.3). **This is a real bump, and 1.6.0's in-place amendments are over.** The rule those amendments invoked @@ -396,6 +488,7 @@ api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6. api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0 api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0 api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0 +api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {}) ``` @@ -745,6 +838,26 @@ Four things about it are contract rather than implementation: and a trigger names an EVENT, so the two may share a name. They carry no legacy allowlist — nothing predates them. +**`registerEngagementSeeds({ templates, ruleGroups })`** (1.9.0) ships the module's own message +BODIES and its seeded rules (`ENGAGEMENT.md` Phase 11b, decision 7). It is the content behind the two +declarations above: they say what an event IS and who it is about, and this says what the mail reads +like. Callable once per module. The full shape, the three prohibitions and the boot-path placement +are in §1.1 under **1.9.0**; four things are contract rather than implementation and belong here: + +- **Templates re-ensure; rule groups are one-shot.** A body is offered again on every boot under + `seed_key` / `seed_version` / `customized`, so improving a default reaches deployments that did not + edit it. A rule is offered ONCE per named group, because re-offering would resurrect a rule an + operator deleted and reset one they enabled. **A rule appended to an existing group reaches fresh + installs only** — that is not a limitation to work around, it is the guarantee; a rule that must + reach existing deployments takes a new group key. +- **A seeded rule is always disabled.** `enabled` is not a parameter. An operator turns a module's + mail on; installing a module never does. +- **Core's generic bodies are a first-class answer.** A rule may point `template_keys` at + `notify.event` / `inapp.event` / `notify.digest` and author nothing — §4.6.1 property 1. Ship a + bespoke body when the message has something to say that the structural projection cannot; a trigger + whose message is "this happened, here is the link" should not have one. +- **It is not a send path.** Every value on the object is data. Core still decides who is told. + **`onBoot(fn)` / `onShutdown(fn)`** — §2.5. ### 2.5 Lifecycle