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>
This commit is contained in:
107
server/src/modules/ceilings.js
Normal file
107
server/src/modules/ceilings.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// ── Audience ceilings ──────────────────────────────────────────────────────
|
||||
//
|
||||
// G24, and the one piece of ENGAGEMENT.md that was named everywhere and defined
|
||||
// nowhere: §5.1a says a composed segment takes "the narrowest ceiling it
|
||||
// contains" and §4.3 says a trigger declares "the widest audience a rule may
|
||||
// ever give it", but neither says what narrower MEANS. This file is that
|
||||
// answer, settled by the org lead at the start of Phase 2.
|
||||
//
|
||||
// **It is a subset lattice, not a size ordering.** The tempting model is a flat
|
||||
// total order — self < owner < staff < members < authenticated < everyone,
|
||||
// compared with `<=` — and it is wrong in a way that matters. Under a total
|
||||
// order a trigger ceilinged at `staff` also permits `owner`, so a rule could
|
||||
// mail `uo.cheat.detected` to the player who was detected. "Fewer people" is not
|
||||
// "less exposure"; the question is always WHICH people.
|
||||
//
|
||||
// So the order is containment, and it is a TREE:
|
||||
//
|
||||
// everyone anyone at all, signed in or not
|
||||
// └── authenticated any logged-in user
|
||||
// ├── subscribers logged-in users who opted into this id
|
||||
// ├── members a module-declared list (a Team, the governors)
|
||||
// ├── staff admin / editor / moderator
|
||||
// └── owner the one user the event is about
|
||||
//
|
||||
// The four leaves are mutually INCOMPARABLE, deliberately. `owner` is not a
|
||||
// subset of `subscribers` (an owner need not have subscribed), `staff` is not a
|
||||
// subset of `members`, and no pair of them has a common descendant. That is what
|
||||
// makes `meet()` below return null rather than guessing, and a null meet is a
|
||||
// refused save (§5.1a rule 3) rather than a silent widening.
|
||||
//
|
||||
// Nothing here reaches the database, the network or a user record. It is
|
||||
// arithmetic over six constants, so it is safe to require anywhere.
|
||||
|
||||
// child → parent. A tree, which is what makes `permits` a walk to the root and
|
||||
// `meet` a comparison rather than a search: two nodes in a tree have a greatest
|
||||
// lower bound only when one of them IS the bound.
|
||||
const PARENT = {
|
||||
everyone: null,
|
||||
authenticated: 'everyone',
|
||||
subscribers: 'authenticated',
|
||||
members: 'authenticated',
|
||||
staff: 'authenticated',
|
||||
owner: 'authenticated',
|
||||
}
|
||||
|
||||
// Operator-facing text. Lives beside the lattice rather than in the admin client
|
||||
// so the rule editor and the trigger catalog describe a ceiling the same way.
|
||||
const LABELS = {
|
||||
everyone: 'Everyone, including signed-out visitors',
|
||||
authenticated: 'Any signed-in user',
|
||||
subscribers: 'Signed-in users subscribed to this event',
|
||||
members: 'Members of a module-declared list',
|
||||
staff: 'Staff only',
|
||||
owner: 'Only the user the event is about',
|
||||
}
|
||||
|
||||
const CEILINGS = Object.keys(PARENT)
|
||||
|
||||
/** Is this one of the six? The gate every registration and every rule save runs. */
|
||||
const isCeiling = (value) => Object.prototype.hasOwnProperty.call(PARENT, value)
|
||||
|
||||
/**
|
||||
* May `ceiling` reach as widely as `candidate`?
|
||||
*
|
||||
* True when `candidate` is `ceiling` itself or sits below it — i.e. walking
|
||||
* `candidate` up the tree reaches `ceiling`. Everything else is false, including
|
||||
* every incomparable pair, so this FAILS CLOSED on an id it does not know.
|
||||
*/
|
||||
function permits(ceiling, candidate) {
|
||||
if (!isCeiling(ceiling) || !isCeiling(candidate)) return false
|
||||
for (let at = candidate; at; at = PARENT[at]) {
|
||||
if (at === ceiling) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrower of two ceilings, or `null` when they are incomparable.
|
||||
*
|
||||
* This is the greatest lower bound, and in a tree it exists only when one node
|
||||
* is an ancestor of the other — so `meet('authenticated', 'staff')` is `staff`
|
||||
* and `meet('staff', 'owner')` is `null`. Returning null is the point:
|
||||
* §5.1a rule 3 says composition must never widen, and the intuitive
|
||||
* union-widens implementation is the wrong one. A caller that cannot name a
|
||||
* bound must refuse the save, not pick a side.
|
||||
*/
|
||||
function meet(a, b) {
|
||||
if (!isCeiling(a) || !isCeiling(b)) return null
|
||||
if (permits(a, b)) return b
|
||||
if (permits(b, a)) return a
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold `meet` across a whole expression's ceilings.
|
||||
*
|
||||
* `A OR B` takes the tighter of the two, and so does `A AND B` — the direction
|
||||
* of the boolean operator is irrelevant, because the ceiling is a statement
|
||||
* about what the operator is ALLOWED to reach, not about what it will resolve
|
||||
* to. An empty list has no bound to state and is null, not `everyone`.
|
||||
*/
|
||||
function meetAll(list) {
|
||||
if (!Array.isArray(list) || !list.length) return null
|
||||
return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0])
|
||||
}
|
||||
|
||||
module.exports = { CEILINGS, LABELS, isCeiling, permits, meet, meetAll }
|
||||
Reference in New Issue
Block a user