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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user