docs(website): event triggers, audiences and the ceiling lattice (engagement Phase 2)

MODULE_API.md gets a real 1.7.0 — and §1.1's "1.6.0 has only ever been on
`edge`" paragraphs are now marked historical rather than current, which is the
correction §0.5 asked for: 1.6.0 reached `main` with the Teams cutover, so the
in-place-amendment rule those paragraphs invoke no longer applies and every
addition from here takes a version of its own.

§2.3 gains `ctx.events.emit` and `ctx.inbox.push` (present and throwing until
Phase 7, with the reason stated). §2.4 gains `registerEventTriggers` and
`registerAudiences`. A new §6.8 carries the forward-compat note ENGAGEMENT.md
§7.3 asked this document to hold: a rule, a template and an audience outlive
the module that declared them, so `trigger_id` is a plain VARCHAR with no
cascade and an unregistered id shows DORMANT rather than erroring or being
auto-deleted. The failure that prevents is specific — an id that stops
resolving must never silently become a send to a different set of people.

ENGAGEMENT.md records Phase 2 as built, and three things it did not previously
say:

§5.1a now DEFINES the ceiling lattice. The document named "narrowest" and
"tightest" throughout and never said what narrower meant, and the obvious
reading is a security defect: under a flat total order a `staff`-ceilinged
trigger permits an `owner` audience, i.e. a rule that mails cheat detection to
the player it detected. It is containment, not size, and incomparable ceilings
have no bound at all.

§7.2 is answered — ONE namespace, against the recommendation in its own text —
with the two knock-on effects that only appeared once it was built (a relaxed
id grammar, a shared legacy allowlist) and the risk that did not materialise
(the push catalog is untouched, so the shipped app sees no change).

§7.1 Q6 is answered: declare `kind: 'scheduled'` now, build the evaluator after
Phase 9.

§4.3 is kept true to what shipped: `audience` and `ceiling` are two fields
rather than one, `'computed'` is gone from the audience vocabulary, and
`example` is enforced rather than encouraged.

§6.0b's Phase 2 row is corrected. Its two "other repos" cells are cutover-window
work, not this window: the integration kit pins a website `main` sha and
runicgateway.com FETCHES its facts from `main`, so doing either now would turn a
green repo red for the whole edge period — for the kit, against the explicit
rule in its own pin ("written against what shipped, never what is in flight").

BACKEND_DESIGN.md gains the two admin routes, the router-tree entry and the
adminOnly line; api-route-inventory.json regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 06:40:48 -05:00
parent 88de7e3331
commit 58c0797fc2
4 changed files with 384 additions and 22 deletions

View File

@@ -26,13 +26,42 @@ 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.6.0'
const MODULE_API_VERSION = '1.7.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.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) ·
`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
*a contract owes a bump only once it has landed on `main`* — was true when they were written and is
not any more: 1.6.0 reached `main` with the Teams cutover, so the paragraphs below saying "1.6.0 has
only ever been on `edge`" are **historical, not current**. Everything added from here takes a version
of its own. That is also why the integration kit does not go red until the engagement cutover:
`ci/core-ref.json` pins a `main` sha and `checkCoreApi.js` asserts equality with what that sha
declares, so the kit stays green for the whole `edge` period and must be re-pinned in the cutover
window (`ENGAGEMENT.md` Phase 13).
**As in 1.6.0, the number states the whole surface and the members arrive by phase.**
`ctx.inbox.push` is present and **throws** until the in-app channel exists (`ENGAGEMENT.md` Phase 7);
everything else in 1.7.0 is live. Present-and-throwing is deliberate and is the choice 1.6.0 settled
on: a member of a declared version that were simply absent would make the version a lie, and one that
silently accepted data into a table that does not exist would be worse than either.
**One part of 1.7.0 is not a member, and is contract all the same: a trigger id and a notification
stream id share ONE namespace.** An id has exactly one owner across both facets, so a module cannot
attach a payload contract to another module's stream and cannot claim a stream id another module has
declared a trigger for. Core's own five trigger ids *are* its five stream ids, which is the
same-owner case the rule is written for. Nothing registrable before this bump becomes unregistrable
after it — the id grammar was **relaxed** in the same change, so `_` is now legal inside a segment
(`uo.house.idoc_warning`) — but the ownership check is new and it is a tightening. See
`registerEventTriggers` in §2.4, and `ENGAGEMENT.md` §7.2 for the decision.
**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor;
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
@@ -228,6 +257,45 @@ module-uo does not need is on the list.
| `ctx.teams.publish` | `(event) => Promise<void>` | `model/teams/teamSync` | the Team provider's module (1.6.0) |
| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) |
| `ctx.teams.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
| `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | a module's own event mapper (1.7.0) |
| `ctx.inbox.push` | `(userId, item) => Promise<void>`**throws until Phase 7** | the in-app channel | not yet (1.7.0) |
**`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with
`api.registerEventTriggers` (§2.4). It is the push half of the engagement seam
(`website/ENGAGEMENT.md` §5.2).
```js
ctx.events.emit('uo.house.idoc_warning', {
subject: '0x40001234', // optional — else read from the declared subjectKey
data: { house: 'The Silver Anvil', decayStatus: 'Greatly' },
ownerUserId: 812, // optional — the module resolves it; core never sees a game account
dedupeKey: 'idoc:0x40001234:greatly', // optional, <= 190 characters
occurredAt: new Date(), // optional, defaults to now
})
```
Six things about it are contract rather than implementation:
- **A module emits its own triggers and nothing else.** The owner is bound by core from the calling
module's id and is never read from the arguments. Without that, `emit` would be a way to fire
another module's event with a payload of your choosing, and every rule an operator wrote against
that trigger would fire on it.
- **The payload is validated against the declaration at EMIT, not at render.** A missing `required`
variable or a wrong type is **thrown in development and dropped-and-logged in production** — the
posture `ctx.teams.activity.push` takes, for the same reason: this is called from inside a
game-event handler, and a contract problem of core's must not become the module's control flow.
Undeclared keys are dropped rather than rejected; they could never be interpolated anyway.
- **It returns `undefined` and never throws in production.** There is nothing a module could
correctly do with a delivery failure from inside an event handler, so there is nothing to await.
- **`ownerUserId` is a website user id, resolved by the module.** Core has no idea what a game
account is and must not learn; the module maps its own account to a user and passes the result.
- **`subject` is what a cooldown is keyed on** — "once per house", not "once per user" — and falls
back to the variable the declaration's `subjectKey` names.
- **A `scheduled` trigger is not emitted.** Its evaluator fires it; a direct emit is refused.
**`ctx.inbox.push(userId, item)`** is the in-app sink, for a module that wants to write a user's
inbox directly without going through a rule. It is **present and throws** until the in-app channel
lands (`ENGAGEMENT.md` Phase 7) — see §1.1 for why a declared member throws rather than being absent.
**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers*
questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a
@@ -308,6 +376,8 @@ api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.registerPostHook({ onSaved, onDeleted })
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
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.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
@@ -567,6 +637,94 @@ but its own reply. `ok` is core's verdict and sits outside the envelope, so a ha
claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who
switches a module off does not leave a live handler behind it.
**`registerEventTriggers(triggers)`** (1.7.0) declares the events a module can fire and the payload
contract behind each. The catalog it builds is what `GET /api/v1/admin/engagement/triggers` serves,
what a rule is written against, and what a template may interpolate
(`website/ENGAGEMENT.md` §4.3).
```js
api.registerEventTriggers([{
id: 'uo.house.idoc_warning', // <owner>.-prefixed, one namespace with stream ids
label: 'House approaching collapse',
description: 'A player house dropped into a late decay stage.',
kind: 'event', // 'event' | 'scheduled'; default 'event'
subjectKey: 'house', // which variable identifies the cooldown subject
audience: 'owner', // the DEFAULT a rule is created with
ceiling: 'owner', // the widest a rule may EVER be given
version: 1, // bumped on a rename or a type change
variables: [
{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' },
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' },
],
}])
```
Six things about it are contract rather than implementation:
- **A trigger id and a notification stream id are ONE namespace.** An id has exactly one owner across
both facets. A module may declare both for the same id — that is one event with a subscription
toggle *and* a payload contract, and it is what core does with its own five — but it may not attach
a contract to another owner's stream, and the refusal names the holder and the facet. The seven
grandfathered ids (§6.5) are exempt from the prefix rule here exactly as they are for streams,
because under one namespace they are the same ids.
- **`ceiling` is required and has no default.** It is the audience ceiling (`ENGAGEMENT.md` §5.1a),
and there is no safe value to guess: `owner` would silently break a broadcast and `authenticated`
would silently widen a staff-only event. The six values are `everyone`, `authenticated`,
`subscribers`, `members`, `staff` and `owner`, ordered by **containment and not by size** — a
`staff` ceiling does NOT permit `owner`, because fewer people is not less exposure. A default
`audience` wider than, or incomparable with, the ceiling is refused at registration.
- **Every variable needs an `example`, and it is not decoration.** It is what makes previewing and
test-sending a template possible without a live game event, which is the reason template systems go
untested. A variable without one is refused.
- **The type set is closed:** `string`, `int`, `float`, `boolean`, `datetime`, `url`. No `object` and
no `array` — a template that has to walk a structure has outgrown interpolation. A `url` is
validated **site-relative**, like `pageUrlTemplate`, because it ends up in an href.
- **A `subjectKey` must name a declared variable.** Otherwise the cooldown is keyed on `undefined`,
which looks like the feature working right up until two subjects share it.
- **`version` is the prop-schema version a block carries** (§4.3), bumped on a rename or a type
change; a template records what it was authored against and renders with a warning rather than
interpolating `undefined`.
A module ships a prebuilt `engagement-triggers.json` in its bundle, for the same reason it ships a
prebuilt swagger fragment (§6.1a): core never has its sources to analyse. Core's own is generated by
`npm run engagement:manifest` and gated in CI with `--check`.
**`registerAudiences(audiences)`** (1.7.0) declares named sets of users a module can resolve over its
own data, for an operator to point a rule at (`ENGAGEMENT.md` §5.1a). "Team X's members" and "the
governors" are audiences; "everyone who opened the last mail" is not, and nothing here builds it.
```js
api.registerAudiences([{
id: 'uo.team.members',
label: 'Members of a team',
params: [{ id: 'teamId', type: 'int', required: true }], // 'int' | 'string' only
ceiling: 'members',
resolve: async (params) => [/* user ids */],
}])
```
Four things about it are contract rather than implementation:
- **The resolver returns user ids and nothing else.** It is not handed a template, a channel or an
address and it cannot enumerate them. A module still cannot send mail (§2.7), and this must not
become the back door that lets it — core maps ids to addresses on its own side, after preferences,
suppression and the verification gate.
- **Core learns no game vocabulary.** Core never knows what a governor is; it knows an id, a label
and a `resolve` it may call. The same boundary `registerNotificationStreams` holds.
- **An audience whose module is uninstalled goes DORMANT, never an error.** It resolves to the empty
set and a rule referring to it shows as dormant — never auto-deleted, and never a silent send to a
*different* set of people because the id stopped resolving. Same rule as §7.3's dormant trigger. A
resolver that throws or answers a non-array costs an empty set too, not a wrong one, and the ids it
does return are filtered to positive integers before core uses them.
- **A composed segment takes the NARROWEST ceiling it contains, never the widest**, and is still
checked against the trigger's own ceiling before a rule using it can be saved. Union-widens is the
intuitive implementation and it is the wrong one; two incomparable ceilings have no bound at all
and the save is refused rather than guessed. Composition UI is Phase 4's.
**Audiences are their own id space**, unlike triggers and streams: an audience names a set of PEOPLE
and a trigger names an EVENT, so the two may share a name. They carry no legacy allowlist — nothing
predates them.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle
@@ -1746,6 +1904,33 @@ injected chunk executes and calls `window.__rg.registry` (§3.3).
---
### 6.8 A trigger, a rule and an audience outlive the module that declared them
The forward-compat note `ENGAGEMENT.md` §7.3 asks this document to carry, and the reason it is here
rather than there: it is a rule about **the contract**, not about the engagement system.
A trigger id is namespaced by its owner and collision-checked at registration, exactly as a
notification stream is. What is *not* expressed by that is a rule or a template referring to a
trigger whose module has been uninstalled.
> `engagement_rules.trigger_id` is a plain `VARCHAR`, deliberately — no foreign key, no cascade — so
> a module can be removed and reinstalled without an operator's rules being destroyed. It is the same
> decision `announce_job_legs` took for a leg whose module is gone: *"Leave it alone: failing it would
> make the job roll up terminal on the strength of a leg that no longer exists, and reinstalling the
> module should resume it."*
>
> A rule whose trigger is unregistered must therefore show as **dormant** in the admin UI — never as
> an error, and never auto-deleted. The same holds for a rule whose *audience* is unregistered: it
> resolves to the empty set and shows dormant, which is not the same answer as "resolved to nobody"
> and must not be rendered as if it were. The failure this prevents is specific: an id that stops
> resolving must never silently become a send to a **different** set of people.
`engagement_templates.trigger_id` is the same, for the same reason, with one addition: a template
records the trigger `version` it was authored against, so a declaration that has since been bumped
produces a warning in the admin list rather than silently interpolating `undefined`.
---
## Part 7 — What the spike proved
The Phase 1 spike ran on `website` branch `spike/module-atlas`, cut from `edge` and **deliberately