docs(engagement): Phase 11b — the seed API, the in-universe voice, the governor's letter #196
@@ -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."*
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user