Rust PLAN_REDESIGNS §14.3.12 (D370), stage 9 of §14.3. A definition's published version may carry `starts`: a trigger, a condition and a cooldown. A matching firing creates a run of it, now. - spec: `starts` validated by the phase gate's own function (same grammar, same dormant rule; a dormant start refuses publish), a cooldown from 0m to 7d (30m default), at most ten, event.* refused. - events/starts.js: the emit path's third subscriber, beside dispatch and the gate observer. An unverified version starts nothing; one run at a time per definition and the cooldown are one conditional UPDATE on the new event_trigger_starts table; the occurrence key makes one firing reported twice one run. Ignored firings are counted by why. - event_runs.origin: the firing (trigger, label, time, subject, matched condition, declared variables), also on the run.created audit line. - Editor: a "Started by the game" panel using the gate's condition builder, with the ignored-firings line. Run console: "Started because ... fired". - No module contract change and no MODULE_API bump. Tests: eventStarts (server 21, client 8); eventRunnerSql +7 against a real MariaDB (75/75); Module-uo proof on uo.champ.boss_up against the dev database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
337 lines
16 KiB
JavaScript
337 lines
16 KiB
JavaScript
// ── ctx.events.emit — the validating half of the engagement seam ────────────
|
|
//
|
|
// ENGAGEMENT.md §4.3 and §5.2. A registrant fires a declared event with a
|
|
// payload; this checks the payload against the declaration and, since Phase 4a,
|
|
// hands the validated event to the engine.
|
|
//
|
|
// Landing the contract a phase before the engine was 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. Every
|
|
// validation rule below was written in Phase 2 for a caller that did not exist
|
|
// yet, and the engine needed none of them changed.
|
|
//
|
|
// **The engine call is deliberately not awaited** — see `emit` below. Phase 6
|
|
// migrates the Team mail onto this.
|
|
//
|
|
// **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 ceilings = require('../modules/ceilings')
|
|
const engine = require('../engagement/engine')
|
|
const eventGates = require('../events/gates')
|
|
const eventStarts = require('../events/starts')
|
|
const scopedPrefs = require('../engagement/scopedPrefs')
|
|
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
|
|
|
|
// engagement_outbox.scope_key is VARCHAR(190), same reasoning as above.
|
|
const SCOPE_KEY_MAX = 190
|
|
|
|
// An emitter asserting an audience asserts a BOUNDED one. `MAX_AUDIENCE` (5000)
|
|
// already caps what the engine will load from a query; this is the matching bound
|
|
// on a list a caller built itself, and it is the same number for the same reason.
|
|
const RECIPIENTS_MAX = 5000
|
|
|
|
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, scopeKey, recipientUserIds, ceiling } = 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')
|
|
}
|
|
}
|
|
|
|
// The scope this event is ABOUT: `team:12`, or absent. Distinct from `subject`,
|
|
// which is what a cooldown counts — see the engine's enqueue. It is a stable
|
|
// identifier because an unsubscribe token is signed over it and sits in a
|
|
// mailbox for months; a display name would orphan the link on the first rename.
|
|
let resolvedScope = null
|
|
if (scopeKey !== undefined && scopeKey !== null) {
|
|
if (typeof scopeKey !== 'string' || !scopedPrefs.parse(scopeKey)) {
|
|
return fail('scopeKey must be a string of the form "<kind>:<id>"')
|
|
}
|
|
if (scopeKey.length > SCOPE_KEY_MAX) return fail(`scopeKey must be at most ${SCOPE_KEY_MAX} characters`)
|
|
resolvedScope = scopeKey
|
|
}
|
|
|
|
// **The audience this particular firing is about** (Phase 6, decision 2). An
|
|
// emitter that has already computed an access-checked recipient set — the Team
|
|
// fan-out is the case that forced it — hands it over here, and a rule whose
|
|
// audience is `members` resolves to it. It is a NARROWING input, not a
|
|
// widening one: `audiences.resolveForRule` still filters it through
|
|
// `users.status`, the ceiling is still `members`, and the G24 check still runs.
|
|
// A rule with any other audience ignores it entirely.
|
|
let resolvedRecipients = null
|
|
if (recipientUserIds !== undefined && recipientUserIds !== null) {
|
|
if (!Array.isArray(recipientUserIds)) return fail('recipientUserIds must be an array')
|
|
if (recipientUserIds.length > RECIPIENTS_MAX) {
|
|
// Bounded here rather than at the query, because the bound is about what an
|
|
// emitter may assert. `MAX_AUDIENCE` already caps what the engine will load;
|
|
// this stops a caller building a list that large in the first place.
|
|
return fail(`recipientUserIds must hold at most ${RECIPIENTS_MAX} ids`)
|
|
}
|
|
if (!recipientUserIds.every((n) => Number.isInteger(n) && n > 0)) {
|
|
return fail('recipientUserIds must be positive integers')
|
|
}
|
|
resolvedRecipients = [...new Set(recipientUserIds)]
|
|
}
|
|
|
|
// **A ceiling this ONE firing may not exceed** (MODULE_API 1.11.0, EVENTS.md
|
|
// §I, org lead 2026-09-04). A trigger's declared ceiling is a property of the
|
|
// KIND of event; this is a property of the occasion, and the two are different
|
|
// questions. The case that forced it is the rehearsal: §I promises an event
|
|
// can be "run for real with announcements ceilinged to `staff`", and a
|
|
// rehearsal fires exactly the same trigger as the real thing — so without a
|
|
// per-firing bound, rehearsing a published event mails every subscriber it.
|
|
//
|
|
// **It only ever NARROWS.** The send-time G24 gate takes
|
|
// `meet(declared, emitted)`, so an emitter can tighten a ceiling and can never
|
|
// loosen one, and an emitter that names something incomparable with the
|
|
// declaration — `owner` against a declared `staff` — meets to null and the gate
|
|
// refuses every rule rather than guessing which branch was meant. That is the
|
|
// lattice's existing posture (`segments.js` §5.1a rule 3), reused rather than
|
|
// re-argued.
|
|
//
|
|
// Not stored on the outbox row, deliberately: by the time a row exists the gate
|
|
// has already run, and a second copy of the bound would be a second thing that
|
|
// can disagree with the declaration it was checked against.
|
|
let resolvedCeiling = null
|
|
if (ceiling !== undefined && ceiling !== null) {
|
|
if (!ceilings.isCeiling(ceiling)) {
|
|
return fail(`ceiling must be one of ${ceilings.CEILINGS.join(', ')}`)
|
|
}
|
|
resolvedCeiling = ceiling
|
|
}
|
|
|
|
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,
|
|
scopeKey: resolvedScope,
|
|
recipientUserIds: resolvedRecipients,
|
|
ceiling: resolvedCeiling,
|
|
dedupeKey: dedupeKey === undefined ? null : dedupeKey,
|
|
occurredAt: at.toISOString(),
|
|
data: payload.data,
|
|
}
|
|
|
|
// 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),
|
|
})
|
|
|
|
// **Not awaited, and this is the point of the whole seam.** `emit` is called
|
|
// from inside a game-event handler; the caller's job is to say the event
|
|
// happened, and it must not be made to wait on rule lookups, audience
|
|
// resolution and a dozen inserts to find out whether it is allowed to carry on.
|
|
// That is the same reason the C# side's `Emit()` enqueues and returns rather
|
|
// than touching the socket from the Core thread. `dispatch` catches everything
|
|
// internally and never rejects, and the `.catch` is the belt to that braces.
|
|
//
|
|
// The consequence a test has to know about: `emit` returns before the outbox
|
|
// rows exist. `engine.dispatch(event)` is exported for a caller that needs to
|
|
// await the delivery decision, and the tests use it directly.
|
|
engine.dispatch(event).catch((err) => log.error('dispatch rejected', { trigger: triggerId, message: err.message }))
|
|
|
|
// **The trigger stream's second subscriber** (EVENTS.md §E, Phase 5). An event
|
|
// run whose phase is waiting on `{ on: '<triggerId>', count: n }` counts this
|
|
// firing here, at the moment it fires, because nothing observable survives to
|
|
// the runner's next tick. Same seam, same posture: not awaited, never allowed
|
|
// to reject, and it knows nothing about who emitted.
|
|
//
|
|
// It is a SECOND subscriber and not a leg of `dispatch` because the two
|
|
// decide different things — who gets told, and whether a phase may proceed —
|
|
// and neither must be able to fail the other. A rules lookup that throws must
|
|
// not lose the count, and a gate write that throws must not lose the mail.
|
|
eventGates
|
|
.observe(event)
|
|
.catch((err) => log.error('gate observation rejected', { trigger: triggerId, message: err.message }))
|
|
|
|
// **The third subscriber** (Rust PLAN_REDESIGNS §14.3.12, D370): a definition
|
|
// whose published version starts on this trigger gets a run, now. Separate
|
|
// from the gate observer for the same reason that one is separate from
|
|
// `dispatch` — starting a run and advancing one decide different things, and
|
|
// neither may fail the other.
|
|
eventStarts
|
|
.observe(event)
|
|
.catch((err) => log.error('trigger start rejected', { trigger: triggerId, message: err.message }))
|
|
|
|
return { ok: true, event }
|
|
}
|
|
|
|
module.exports = { emit, validatePayload, RELATIVE_URL, DEDUPE_KEY_MAX, SCOPE_KEY_MAX, RECIPIENTS_MAX }
|