feat(modules): event triggers, audiences and the ceiling lattice (engagement Phase 2) #168

Merged
whitlocktech merged 1 commits from feature/engagement-trigger-registry into edge 2026-08-29 11:48:25 +00:00
Member

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: emit validates, logs and stops, and Phase 4 replaces that log line with the engine call.

Design of record: docs/website/ENGAGEMENT.md Phase 2. Docs companion: docs#181 (same review window).

Three decisions, approved before any code

Question Decision
§7.2 one namespace, or two? ONE — against the recommendation in the plan's own text
§7.1 Q6 time-based triggers declare now, build the evaluator after Phase 9
what "narrower" means for a ceiling an explicit subset lattice; incomparable ⇒ the save is refused, never guessed

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_id staying single-keyed in Phase 3 — two namespaces would have forced a kind discriminator into its primary key and left news.post naming two things forever.

Two knock-on effects appeared only once it was implemented:

  • The id grammar had to be relaxed, not just shared. STREAM_ID did not admit _, and §4.3's own worked example is uo.house.idoc_warning. Relaxation only — every id valid before is valid now.
  • The legacy allowlist had to be shared. Under one namespace idoc.warning is a single id, so if uo may 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:

everyone → authenticated → { subscribers | members | staff | owner }

The four leaves are mutually incomparable. The tempting flat order (self < owner < staff < members < authenticated < everyone) lets a staff-ceilinged trigger be given an owner audience — a rule that mails uo.cheat.detected 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.

What landed

  • server/src/modules/ceilings.js — the six-value lattice, permits / meet / meetAll
  • registerEventTriggers + registerAudiences on the existing stage()/apply() validate-then-commit discipline, with the cross-facet collision checks
  • ctx.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's
  • ctx.inbox.pushpresent and throws until Phase 7, the shape 1.6.0 settled on for a member that arrives a phase late
  • config/coreTriggers.js — core's five, through registerCore()
  • 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 committed engagement-triggers.json
  • MODULE_API_VERSION → 1.7.0 on both halves. Additions only; module-uo's coreApi: "^1.3.0" still resolves (verified).

Verification

  • 1280/1280 server tests, 288/288 client tests. 33 new tests across three files.
  • check:modules, check:hosts, routes:manifest --check, engagement:manifest --check all green.
  • Swagger + route manifest + routes.guards.json regenerated; the only URL diff is the two new routes.
  • Booted the whole app with module-uo installed: 12 streams (5 core + 7 uo), 5 triggers, no collision, both routes mounted.
  • The manifest check is proved live, not just green — a test registers an extra core declaration and asserts the output changes, because a check that silently stops checking is worse than no check.

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 edge period:

  • integration-kit's ci/core-ref.json pins a website main sha; main still 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's platform.json.moduleApicheckFacts.mjs fetches the authority from main. 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 default audience exceeds its own ceiling is refused at registration). Phase 4 adds the second call site, not the second implementation.


  • AI-assisted: written with Claude Code (Claude Opus 5). Commits carry Co-Authored-By: Claude <noreply@anthropic.com>.
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**: `emit` validates, logs and stops, and Phase 4 replaces that log line with the engine call. Design of record: [`docs/website/ENGAGEMENT.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/website/ENGAGEMENT.md) Phase 2. Docs companion: **docs#181** (same review window). ## Three decisions, approved before any code | | Question | Decision | |---|---|---| | §7.2 | one namespace, or two? | **ONE** — against the recommendation in the plan's own text | | §7.1 Q6 | time-based triggers | **declare now, build the evaluator after Phase 9** | | — | what "narrower" means for a ceiling | an explicit **subset lattice**; incomparable ⇒ the save is refused, never guessed | ### 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_id` staying single-keyed in Phase 3 — two namespaces would have forced a `kind` discriminator into its primary key and left `news.post` naming two things forever. Two knock-on effects appeared only once it was implemented: - **The id grammar had to be relaxed**, not just shared. `STREAM_ID` did not admit `_`, and §4.3's own worked example is `uo.house.idoc_warning`. Relaxation only — every id valid before is valid now. - **The legacy allowlist had to be shared.** Under one namespace `idoc.warning` is a single id, so if `uo` may 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**: ``` everyone → authenticated → { subscribers | members | staff | owner } ``` The four leaves are mutually incomparable. The tempting flat order (`self < owner < staff < members < authenticated < everyone`) lets a `staff`-ceilinged trigger be given an `owner` audience — a rule that mails `uo.cheat.detected` 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. ## What landed - `server/src/modules/ceilings.js` — the six-value lattice, `permits` / `meet` / `meetAll` - `registerEventTriggers` + `registerAudiences` on the existing `stage()`/`apply()` validate-then-commit discipline, with the cross-facet collision checks - `ctx.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's - `ctx.inbox.push` — **present and throws** until Phase 7, the shape 1.6.0 settled on for a member that arrives a phase late - `config/coreTriggers.js` — core's five, through `registerCore()` - `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 committed `engagement-triggers.json` - **`MODULE_API_VERSION` → 1.7.0** on both halves. Additions only; `module-uo`'s `coreApi: "^1.3.0"` still resolves (verified). ## Verification - **1280/1280** server tests, 288/288 client tests. 33 new tests across three files. - `check:modules`, `check:hosts`, `routes:manifest --check`, `engagement:manifest --check` all green. - Swagger + route manifest + `routes.guards.json` regenerated; the only URL diff is the two new routes. - **Booted the whole app with `module-uo` installed**: 12 streams (5 core + 7 uo), 5 triggers, no collision, both routes mounted. - The manifest check is proved **live**, not just green — a test registers an extra core declaration and asserts the output changes, because a check that silently stops checking is worse than no check. ## 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 `edge` period: - **`integration-kit`'s `ci/core-ref.json`** pins a website **`main`** sha; `main` still 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`'s `platform.json.moduleApi`** — `checkFacts.mjs` *fetches* the authority from `main`. 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 default `audience` exceeds its own `ceiling` is refused at registration). Phase 4 adds the second call site, not the second implementation. --- - [x] **AI-assisted**: written with Claude Code (Claude Opus 5). Commits carry `Co-Authored-By: Claude <noreply@anthropic.com>`.
wtclaude added 1 commit 2026-08-29 11:41:37 +00:00
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
563199a096
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>
whitlocktech merged commit ea3499e70b into edge 2026-08-29 11:48:25 +00:00
whitlocktech deleted branch feature/engagement-trigger-registry 2026-08-29 11:48:26 +00:00
Sign in to join this conversation.
No description provided.