feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2)
The contract half of the engagement system: a module (and core) can DECLARE an
event with a payload contract and fire it. Nothing delivers yet — `emit`
validates, logs and stops, and Phase 4 replaces that log line with the engine.
`api.registerEventTriggers` and `api.registerAudiences` ride the existing
stage()/apply() validate-then-commit discipline, so a registrant that throws
halfway leaves nothing behind. `ctx.events.emit` is fire-and-forget and binds
the owner from the calling module — a module fires its own triggers and no one
else's. `ctx.inbox.push` is present and throws until Phase 7, the shape 1.6.0
settled on for a member that arrives a phase late.
MODULE_API_VERSION 1.7.0 on both halves. Additions only; module-uo's
`coreApi: "^1.3.0"` still resolves.
Three design decisions, approved by the org lead before any code:
ONE NAMESPACE for trigger ids and notification-stream ids (ENGAGEMENT.md §7.2,
against the recommendation in the text). A trigger is a payload contract
attached to an id that may also carry a subscription toggle, so an id has
exactly one owner across both facets, checked in both directions. Core's five
trigger ids ARE its five stream ids, so the same-owner upgrade case is
exercised on every boot rather than only by a module. It keeps
notification_channel_prefs single-keyed in Phase 3, where two namespaces would
have forced a `kind` discriminator into its primary key.
Two knock-on effects appeared only once it was implemented. The id grammar had
to be RELAXED to admit `_` inside a segment — §4.3's own worked example is
`uo.house.idoc_warning`, and two grammars over one namespace would mean an id
legal as a trigger and illegal as the stream it is the same event as. And the
seven grandfathered `uo.*` ids had to share their legacy allowlist with
triggers, because under one namespace `idoc.warning` is a single id. The push
catalog is untouched either way: allStreams() still serves the stream facet
only, so the shipped Android client sees exactly what it saw before.
THE CEILING LATTICE (G24), which the plan named everywhere and defined nowhere.
It is containment, not size: everyone ⊃ authenticated ⊃ {subscribers, members,
staff, owner}, with the four leaves mutually incomparable. The flat total order
the plan's wording invites would let a `staff`-ceilinged trigger be given an
`owner` audience — a rule that mails cheat detection to the player it detected.
Fewer people is not less exposure. Two incomparable ceilings have no meet at
all, so a composition is refused rather than guessed; union-widens is the
intuitive implementation and it is the wrong one.
`kind: 'event' | 'scheduled'` is declarable now and no evaluator exists (§7.1
Q6). Registration accepts `scheduled` and emit refuses to fire one, so `kind`
means something from the moment it can be written rather than from the moment
it is honoured.
Also: `GET /admin/engagement/{triggers,audiences}`, served from the registries
rather than a table so an uninstalled module simply stops appearing;
`npm run engagement:manifest` plus its CI `--check`, the twin of the route
manifest, because renaming a variable breaks stored templates silently, at send
time, in mail someone already received.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -121,6 +121,7 @@ function buildCtx(id, moduleRoot) {
|
||||
const users = require('../model/users/users.model')
|
||||
const teams = require('../model/teams/teamSync.model')
|
||||
const teamActivity = require('../model/teams/teamActivity.model')
|
||||
const engagementEmit = require('../utils/engagementEmit')
|
||||
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -207,6 +208,40 @@ function buildCtx(id, moduleRoot) {
|
||||
),
|
||||
},
|
||||
},
|
||||
// Engagement (API 1.7.0, ENGAGEMENT.md §5.1). The push half of the trigger
|
||||
// contract the module registered with `api.registerEventTriggers`.
|
||||
//
|
||||
// `id` is bound here and is never taken from the arguments, exactly as
|
||||
// `teamActivity.push(id, …)` binds its source: a module fires its OWN
|
||||
// triggers. Without that binding, emit would be a way to fire another
|
||||
// module's event with a payload of your choosing, and every rule an operator
|
||||
// wrote against it would fire on that.
|
||||
//
|
||||
// Fire-and-forget and returns undefined. `emit()` answers a result its core
|
||||
// callers want; a module gets nothing back on purpose, because there is
|
||||
// nothing it could correctly do with a failure from inside a game-event
|
||||
// handler — and "never throws in production" is only true if there is also
|
||||
// nothing to await. The dev-time throw is inside `emit`, where the stack
|
||||
// still points at the module's own call.
|
||||
events: {
|
||||
emit: (triggerId, envelope) => {
|
||||
engagementEmit.emit(id, triggerId, envelope)
|
||||
},
|
||||
},
|
||||
// The in-app sink (§5.1) — a module writing the inbox directly, without a
|
||||
// rule. It is PRESENT AND THROWS until Phase 7 builds the channel and the
|
||||
// `user_notifications` table behind it.
|
||||
//
|
||||
// Present-and-throwing rather than absent is the shape 1.6.0 settled on for
|
||||
// exactly this situation (`ctx.teams.activity.push` before its phase landed):
|
||||
// the version number states a whole surface, so a member of 1.7.0 that is
|
||||
// missing would make the version a lie, and one that silently accepted data
|
||||
// into a table that does not exist would be the worst of the three.
|
||||
inbox: {
|
||||
push: () => {
|
||||
throw new Error('ctx.inbox.push is not available until the in-app channel lands (ENGAGEMENT.md Phase 7)')
|
||||
},
|
||||
},
|
||||
// One function, for one caller: the `admin.users.detail` slot router needs
|
||||
// the user its prefix names. Narrowed like `ctx.posts` — the users model
|
||||
// exports creation, role changes and password handling, none of which is a
|
||||
@@ -293,6 +328,24 @@ function buildApi(record) {
|
||||
once('registerSlashCommands')
|
||||
record.staged.registerSlashCommands(commands)
|
||||
},
|
||||
// The engagement contract (API 1.7.0, ENGAGEMENT.md §4.3 / §5.1a). Both
|
||||
// STAGE, like the registries above them, and both take `once` for the same
|
||||
// reason `registerNotificationStreams` does: a batch is a module's complete
|
||||
// statement about what it declares, and a second call is a module changing
|
||||
// its mind halfway through register() rather than adding to it.
|
||||
//
|
||||
// A trigger id and a stream id share one namespace (§7.2), so a module that
|
||||
// calls both may legitimately name the same id in each — that is one event
|
||||
// with a subscription toggle and a payload contract, and it is the case core
|
||||
// itself exercises on every boot.
|
||||
registerEventTriggers(triggers) {
|
||||
once('registerEventTriggers')
|
||||
record.staged.registerEventTriggers(triggers)
|
||||
},
|
||||
registerAudiences(audiences) {
|
||||
once('registerAudiences')
|
||||
record.staged.registerAudiences(audiences)
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
|
||||
Reference in New Issue
Block a user