docs(engagement): Phase 11b — the seed API, the in-universe voice, the governor's letter

Four decisions settled by the org lead before any 11b code, plus MODULE_API
1.9.0 and what building it found.

DECISION 7 is the one 11a could not have discovered, because 11a seeded nothing.
The plan's own sentence — "24 rules, all enabled = 0; bespoke template bodies" —
described work with NO MECHANISM to land in: templateSeeds.js and coreRules.js
are core files with core arrays, and registries.js has no registerTemplates.
Adds api.registerEngagementSeeds({ templates, ruleGroups }). Templates re-ensure
every boot; rule groups are one-shot per named group, which is 11a's seed-key
finding stated as an API rather than as a warning.

DECISION 8: the game-powered families read in-universe, with a per-family sender
rather than one voice. The crown writes about the crown's business and nothing
else. This is also why the bodies had to be the module's: §5.2 says core stays
ignorant of a module's domain, and check:modules reads identifiers, never prose —
so the boundary is honoured deliberately here rather than leaned on.

DECISION 9: nine bodies stay plain. A failed-login notice written as a letter is
indistinguishable in register from the phishing mail it warns about.

DECISION 10 corrects a fact the plan had wrong by omission. §8.6 records that
uo.points.rank_changed cannot address a person, and the same reasoning was
silently assumed to cover the governor. city.update's `governor` is written by
BridgeJson.Actor(), which emits acct and webId — the new governor is addressable
today, no protocol change. uo.governor.appointed is the 25th trigger.

Also records what building it found: the no-conditionals/empty-string collision
that the presentational fragments exist for, the render sweep that found three
defects no check could see (an optional in a subject line, multi-optional detail
lines, a trailing fragment used as a leading one), the trigger-bound template
that could not reference its own unsubscribe link, and the trigger-id/template-key
grammar mismatch.

docs/modules/uo/API.md gains §5.7 and §5.8.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 01:14:53 -05:00
parent dc7d7c9a40
commit 9d50e5cacf
3 changed files with 385 additions and 10 deletions

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