Files
website/server/scripts/engagementManifest.js
wtclaude 563199a096
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
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>
2026-08-29 06:40:28 -05:00

133 lines
5.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Engagement trigger manifest — the machine-readable freeze of core's event
* contract (ENGAGEMENT.md §4.3, property 4).
*
* Why this exists: a trigger declaration is what a template interpolates and what
* a rule is written against. Renaming a variable, changing its type, or widening
* a ceiling breaks stored templates and stored rules — and does it silently, at
* send time, in an email someone already received. `routes.manifest.json` freezes
* the URL surface for exactly this reason and this is its twin: a generated
* artifact committed to the repo, whose DIFF is the review signal. Changing a
* declaration without regenerating is a red build; changing one deliberately puts
* the change in front of a reviewer instead of letting it pass as a comment edit.
*
* **Core's only.** A module ships its own `engagement-triggers.json` in its
* bundle, for the same reason it ships a prebuilt swagger fragment: core never
* has its sources to analyse (MODULE_API.md §6.1a). So this loads
* `config/coreTriggers.js` through the real `registerCore()` — the declarations
* as VALIDATED, not as authored — which means a shape error is a failure here
* rather than a surprise at boot.
*
* The `resolve` half of an audience cannot be frozen (it is a function over a
* module's own store), so audiences are deliberately absent: what a manifest can
* usefully freeze is the payload contract, and freezing half a declaration would
* suggest the other half was checked.
*
* Usage:
* npm run engagement:manifest # write server/engagement-triggers.json
* npm run engagement:manifest -- --check # exit 1 if the committed file is stale
*/
// registries.js -> config/coreStreams + utils/discordAnnounce, which reach
// utils/db and build a mariadb pool at require time. Point it at a closed port
// (the same trick routeManifest.js and the test suite use) so generating a
// manifest never opens a connection or hangs on a missing database.
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
process.env.DB_PORT = process.env.DB_PORT || '59999'
const fs = require('fs')
const path = require('path')
const registries = require('../src/modules/registries')
const db = require('../src/utils/db')
const { MODULE_API_VERSION } = require('../src/modules/version')
const SERVER_ROOT = path.join(__dirname, '..')
const MANIFEST_PATH = path.join(SERVER_ROOT, 'engagement-triggers.json')
const MANIFEST_COMMENT =
'Generated event-trigger inventory - the authoritative freeze of CORE\'s engagement ' +
'contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` ' +
'in website/server. A renamed variable, a changed type or a widened ceiling breaks stored ' +
'templates and rules, so the diff here is the review signal. A module ships its own copy ' +
'in its bundle; this file never contains one.'
function build() {
// Through registerCore(), not by reading the array: what a reviewer needs
// frozen is what the registry ACCEPTED — defaults filled in, audience resolved
// against the ceiling, variables normalised — because that is what the editor
// will read and the emit path will check against.
registries.registerCore()
const triggers = registries
.allTriggers()
.filter((t) => t.owner === 'core')
// Sorted by id rather than left in registration order, like the route
// manifest: reordering a declaration in the source is not a contract change
// and must not produce a diff that looks like one.
.sort((a, b) => a.id.localeCompare(b.id))
.map((t) => ({
id: t.id,
owner: t.owner,
label: t.label,
description: t.description,
kind: t.kind,
subjectKey: t.subjectKey,
audience: t.audience,
ceiling: t.ceiling,
version: t.version,
// Variables keep their DECLARED order. Here it is contract: it is the
// order the template editor lists them in, and an author reading the
// manifest should see what the editor will show.
variables: t.variables.map((v) => ({
name: v.name,
type: v.type,
required: v.required,
example: v.example,
description: v.description,
})),
}))
return {
_comment: MANIFEST_COMMENT,
// The contract version these declarations are shaped by. A reader looking at
// a stale manifest needs to know which API's rules produced it.
moduleApiVersion: MODULE_API_VERSION,
triggers,
}
}
function main() {
const check = process.argv.includes('--check')
const next = `${JSON.stringify(build(), null, 2)}\n`
if (!check) {
fs.writeFileSync(MANIFEST_PATH, next)
process.stdout.write(`wrote ${path.relative(SERVER_ROOT, MANIFEST_PATH)}\n`)
return
}
const current = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : ''
if (current === next) {
process.stdout.write('engagement-triggers.json is current\n')
return
}
process.stderr.write(
'engagement-triggers.json is stale.\n' +
'A trigger declaration changed without the manifest being regenerated.\n' +
'Run `npm run engagement:manifest` in website/server and commit the result —\n' +
'the diff is what a reviewer reads to see the contract change.\n',
)
process.exitCode = 1
}
if (require.main === module) {
main()
// The mariadb pool never connects here, but it keeps the loop alive even
// pointed at a dead port — the same exit routeManifest.js takes.
db.close().finally(() => process.exit(process.exitCode || 0))
}
module.exports = { build }