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