feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 25s
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 10m29s

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:
2026-08-29 06:40:28 -05:00
parent 6016b325bb
commit 563199a096
21 changed files with 2132 additions and 6 deletions

View File

@@ -0,0 +1,223 @@
// ── ctx.events.emit — the validating half of the engagement seam ────────────
//
// ENGAGEMENT.md §4.3 and §5.2, Phase 2. A registrant fires a declared event with
// a payload; this checks the payload against the declaration and stops there.
// **There is no delivery in this phase** — no rules, no cooldowns, no outbox, no
// mail. Phase 4 replaces the log line at the bottom with the engine call, and
// every validation rule below is already the one it will need.
//
// Landing the contract a phase before the engine is deliberate, and it is the
// same argument registerCore() has always made: a seam whose first real exercise
// is the thing that depends on it is a seam that has already drifted. Phase 6
// migrates the Team mail onto this, and it should be migrating onto a validator
// that has been running against core's own five triggers since Phase 2.
//
// **Two postures, one switch.** A malformed emit THROWS in development and is
// DROPPED AND LOGGED in production, which is `ctx.teams.activity.push`'s posture
// and it is not a compromise: this is called from inside a game-event handler,
// and a contract problem of core's must not become the module's control flow at
// three in the morning. In development it must be loud, because a payload that
// silently loses a variable is a template that silently renders `undefined`.
const registries = require('../modules/registries')
const createLogger = require('./logger')
const log = createLogger('engagement')
// The same character class `pageUrlTemplate` is validated with (registries.js),
// for the same reason: a `url` variable is a string that ends up in an href.
// Relative only — one leading slash, and the second character may not be
// another, because `//evil.test/x` passes an "is it rooted" check and is a
// PROTOCOL-RELATIVE url that would send a recipient off-site.
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
// A dedupe key is stored in a VARCHAR(190) (§4.5 user_notifications.dedupe_key),
// so it is bounded here rather than at the insert — a truncated key silently
// collides with a different event, which is the one failure mode dedupe exists
// to prevent.
const DEDUPE_KEY_MAX = 190
const isProd = () => process.env.NODE_ENV === 'production'
/** Coerce and check one declared variable. Returns `{ value }` or `{ error }`. */
function coerce(variable, raw) {
switch (variable.type) {
case 'string':
return typeof raw === 'string' ? { value: raw } : { error: 'expected a string' }
case 'int':
return Number.isInteger(raw) ? { value: raw } : { error: 'expected an integer' }
case 'float':
return typeof raw === 'number' && Number.isFinite(raw)
? { value: raw }
: { error: 'expected a finite number' }
case 'boolean':
return typeof raw === 'boolean' ? { value: raw } : { error: 'expected a boolean' }
// Normalised to an ISO string at the boundary, so a template, a manifest
// example and a stored outbox row all hold the same representation of a
// moment. A Date and its ISO string are the same value everywhere downstream
// only if one of them stops existing here.
case 'datetime': {
const d = raw instanceof Date ? raw : new Date(raw)
if (!(d instanceof Date) || Number.isNaN(d.getTime())) return { error: 'expected a date' }
return { value: d.toISOString() }
}
case 'url':
if (typeof raw !== 'string') return { error: 'expected a string' }
return RELATIVE_URL.test(raw)
? { value: raw }
: { error: 'expected a site-relative path beginning with a single "/"' }
default:
// Unreachable — registerEventTriggers refuses an undeclared type — and it
// fails CLOSED anyway rather than passing an unchecked value through.
return { error: `unsupported type "${variable.type}"` }
}
}
/**
* Check a payload against a trigger declaration.
*
* Returns `{ ok: true, data }` with a NEW object holding only declared
* variables, or `{ ok: false, errors }` listing every problem rather than the
* first — a module author fixing one emit at a time is a module author making
* six round trips through a game server restart.
*
* Undeclared keys are dropped rather than rejected. They can never be
* interpolated (the editor only offers declared names, §4.3 property 2), so
* refusing the whole emit over one would be strictness with no safety behind it;
* they are named in a debug line so a typo is still findable.
*/
function validatePayload(declaration, raw) {
const input = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
const errors = []
const data = {}
for (const variable of declaration.variables) {
const present = Object.prototype.hasOwnProperty.call(input, variable.name)
const value = input[variable.name]
if (!present || value === undefined || value === null) {
if (variable.required) errors.push(`${variable.name}: required`)
continue
}
const { value: coerced, error } = coerce(variable, value)
if (error) errors.push(`${variable.name}: ${error}`)
else data[variable.name] = coerced
}
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const declared = new Set(declaration.variables.map((v) => v.name))
const extra = Object.keys(raw).filter((k) => !declared.has(k))
if (extra.length) log.debug('emit carried undeclared variables', { trigger: declaration.id, extra })
}
return errors.length ? { ok: false, errors } : { ok: true, data }
}
/**
* Emit a declared event. Core's implementation; `ctx.events.emit` wraps it.
*
* `owner` is bound by the CALLER — the loader passes the module's own id and
* core passes `'core'` — and is never taken from the arguments. A module emits
* its own triggers and nothing else: without that, `ctx.events.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.
*
* @returns {{ ok: true, event: object } | { ok: false, reason: string }}
*/
function emit(owner, triggerId, envelope = {}) {
const fail = (reason, detail) => {
if (!isProd()) {
const suffix = detail ? ` (${detail})` : ''
throw new Error(`ctx.events.emit: ${reason}${suffix}`)
}
log.warn('emit dropped', { owner, trigger: triggerId, reason, detail })
return { ok: false, reason }
}
const declaration = registries.eventTrigger(triggerId)
if (!declaration) {
// Names the holder when the id is taken by the OTHER facet, because under
// one namespace "there is no such trigger" and "that id is a stream nobody
// gave a payload contract to" are different problems with the same symptom.
const heldBy = registries.eventOwner(triggerId)
return fail(
`unknown event trigger "${triggerId}"`,
heldBy ? `the id is registered as a notification stream by "${heldBy}"` : null,
)
}
if (declaration.owner !== owner) {
return fail(`"${triggerId}" belongs to "${declaration.owner}"`, `emitted by "${owner}"`)
}
// A scheduled trigger is fired by the periodic evaluator, not by a caller
// (§7.1 Q6). There is no evaluator yet, and this is still the right refusal:
// it keeps `kind` meaning something from the day it is declarable.
if (declaration.kind !== 'event') {
return fail(`"${triggerId}" is kind "${declaration.kind}" and is not emitted directly`)
}
const { subject, data, ownerUserId, dedupeKey, occurredAt } = envelope || {}
const payload = validatePayload(declaration, data)
if (!payload.ok) return fail(`payload for "${triggerId}" is invalid`, payload.errors.join('; '))
// The subject is what a cooldown is keyed on (§4.1): "once per house", not
// "once per user". An explicit `subject` wins; otherwise it is read from the
// variable the declaration named, which is why checkTriggerShape insists that
// variable exists.
let resolvedSubject = null
if (subject !== undefined && subject !== null) {
if (typeof subject !== 'string' && typeof subject !== 'number') {
return fail('subject must be a string or a number')
}
resolvedSubject = String(subject)
} else if (declaration.subjectKey && payload.data[declaration.subjectKey] !== undefined) {
resolvedSubject = String(payload.data[declaration.subjectKey])
}
if (ownerUserId !== undefined && ownerUserId !== null) {
if (!Number.isInteger(ownerUserId) || ownerUserId < 1) {
return fail('ownerUserId must be a positive integer')
}
}
if (dedupeKey !== undefined && dedupeKey !== null) {
if (typeof dedupeKey !== 'string' || !dedupeKey || dedupeKey.length > DEDUPE_KEY_MAX) {
return fail(`dedupeKey must be a string of 1-${DEDUPE_KEY_MAX} characters`)
}
}
let at = new Date()
if (occurredAt !== undefined && occurredAt !== null) {
const parsed = occurredAt instanceof Date ? occurredAt : new Date(occurredAt)
if (Number.isNaN(parsed.getTime())) return fail('occurredAt is not a date')
at = parsed
}
const event = {
triggerId,
owner,
version: declaration.version,
subject: resolvedSubject,
ownerUserId: ownerUserId === undefined ? null : ownerUserId,
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
occurredAt: at.toISOString(),
data: payload.data,
}
// Phase 2 ends here: validated, recorded, and deliberately undelivered.
//
// The values are NOT logged. A payload carries player names, house locations
// and forum excerpts, and an event log that reproduces them is a second copy
// of exactly the content §4.5 was careful to keep out of `engagement_sends`
// (which hashes the address rather than storing it). The keys are enough to
// debug a contract problem, which is what this line is for.
log.info('event emitted', {
trigger: triggerId,
owner,
subject: resolvedSubject,
variables: Object.keys(event.data),
})
return { ok: true, event }
}
module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX }