docs: the engagement workstream — cutover 1 of 7 (edgemain) #200

Merged
whitlocktech merged 50 commits from edge into main 2026-09-01 13:54:15 +00:00
3 changed files with 385 additions and 10 deletions
Showing only changes of commit c9873eff7d - Show all commits

View File

@@ -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."*

View File

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

View File

@@ -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 "<moduleId>."
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:<owner>:<group>`). **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 `<moduleId>.`, 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