feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2) #168
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/engagement-trigger-registry"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Engagement Phase 2 — the contract half. A module (and core) can now declare an event with a payload contract and fire it. Nothing delivers yet:
emitvalidates, logs and stops, and Phase 4 replaces that log line with the engine call.Design of record:
docs/website/ENGAGEMENT.mdPhase 2. Docs companion: docs#181 (same review window).Three decisions, approved before any code
One namespace
A trigger is a payload contract attached to an id that may also carry a subscription toggle. They stay two registrations with two shapes, but 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 buys
notification_channel_prefs.stream_idstaying single-keyed in Phase 3 — two namespaces would have forced akinddiscriminator into its primary key and leftnews.postnaming two things forever.Two knock-on effects appeared only once it was implemented:
STREAM_IDdid not admit_, and §4.3's own worked example isuo.house.idoc_warning. Relaxation only — every id valid before is valid now.idoc.warningis a single id, so ifuomay hold it unprefixed as a stream it may hold it unprefixed as a trigger. Any other answer means those seven ids could never gain a payload contract.And the risk the "two namespaces" recommendation was hedging against does not materialise:
allStreams()still serves the stream facet only, so the shipped Android client's catalog is untouched. A trigger-only id gets email/in-app preferences in Phase 3 and no push toggle, which is correct — there is nothing to push it to.The ceiling lattice (G24)
The plan named this everywhere and defined it nowhere. It is containment, not size:
The four leaves are mutually incomparable. The tempting flat order (
self < owner < staff < members < authenticated < everyone) lets astaff-ceilinged trigger be given anowneraudience — a rule that mailsuo.cheat.detectedto 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.What landed
server/src/modules/ceilings.js— the six-value lattice,permits/meet/meetAllregisterEventTriggers+registerAudienceson the existingstage()/apply()validate-then-commit discipline, with the cross-facet collision checksctx.events.emit(utils/engagementEmit.js) — fire-and-forget; throws in dev, drops+logs in prod; the owner is bound by core and never read from the arguments, so a module fires its own triggers and no one else'sctx.inbox.push— present and throws until Phase 7, the shape 1.6.0 settled on for a member that arrives a phase lateconfig/coreTriggers.js— core's five, throughregisterCore()GET /admin/engagement/{triggers,audiences}— admin-only, served from the registries so an uninstalled module simply stops appearing (no table, so no "delete rows on uninstall?" question to get wrong)npm run engagement:manifest+ CI--check, and the committedengagement-triggers.jsonMODULE_API_VERSION→ 1.7.0 on both halves. Additions only;module-uo'scoreApi: "^1.3.0"still resolves (verified).Verification
check:modules,check:hosts,routes:manifest --check,engagement:manifest --checkall green.routes.guards.jsonregenerated; the only URL diff is the two new routes.module-uoinstalled: 12 streams (5 core + 7 uo), 5 triggers, no collision, both routes mounted.Two things deliberately NOT in this PR
§6.0b assigns Phase 2 two other-repo obligations. Both are cutover-window work and doing either now would turn a green repo red for the whole
edgeperiod:integration-kit'sci/core-ref.jsonpins a websitemainsha;mainstill says 1.6.0, so the equality check is green and must stay so. Re-pinning now would pin the book to a commit still in flight — which its own prose forbids.runicgateway.com'splatform.json.moduleApi—checkFacts.mjsfetches the authority frommain. It is Phase 12's, in the cutover window, exactly as that phase says.One acceptance criterion is only half-dischargeable here and it is flagged in the doc: "a rule cannot be saved with an audience wider than its trigger's ceiling" needs
engagement_rules, which is Phase 4's. This PR ships the arithmetic that check will call, plus the same rule applied where a row already exists (a declaration whose defaultaudienceexceeds its ownceilingis refused at registration). Phase 4 adds the second call site, not the second implementation.Co-Authored-By: Claude <noreply@anthropic.com>.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>