docs(website): event triggers, audiences and the ceiling lattice (engagement Phase 2)

MODULE_API.md gets a real 1.7.0 — and §1.1's "1.6.0 has only ever been on
`edge`" paragraphs are now marked historical rather than current, which is the
correction §0.5 asked for: 1.6.0 reached `main` with the Teams cutover, so the
in-place-amendment rule those paragraphs invoke no longer applies and every
addition from here takes a version of its own.

§2.3 gains `ctx.events.emit` and `ctx.inbox.push` (present and throwing until
Phase 7, with the reason stated). §2.4 gains `registerEventTriggers` and
`registerAudiences`. A new §6.8 carries the forward-compat note ENGAGEMENT.md
§7.3 asked this document to hold: a rule, a template and an audience outlive
the module that declared them, so `trigger_id` is a plain VARCHAR with no
cascade and an unregistered id shows DORMANT rather than erroring or being
auto-deleted. The failure that prevents is specific — an id that stops
resolving must never silently become a send to a different set of people.

ENGAGEMENT.md records Phase 2 as built, and three things it did not previously
say:

§5.1a now DEFINES the ceiling lattice. The document named "narrowest" and
"tightest" throughout and never said what narrower meant, and the obvious
reading is a security defect: under a flat total order a `staff`-ceilinged
trigger permits an `owner` audience, i.e. a rule that mails cheat detection to
the player it detected. It is containment, not size, and incomparable ceilings
have no bound at all.

§7.2 is answered — ONE namespace, against the recommendation in its own text —
with the two knock-on effects that only appeared once it was built (a relaxed
id grammar, a shared legacy allowlist) and the risk that did not materialise
(the push catalog is untouched, so the shipped app sees no change).

§7.1 Q6 is answered: declare `kind: 'scheduled'` now, build the evaluator after
Phase 9.

§4.3 is kept true to what shipped: `audience` and `ceiling` are two fields
rather than one, `'computed'` is gone from the audience vocabulary, and
`example` is enforced rather than encouraged.

§6.0b's Phase 2 row is corrected. Its two "other repos" cells are cutover-window
work, not this window: the integration kit pins a website `main` sha and
runicgateway.com FETCHES its facts from `main`, so doing either now would turn a
green repo red for the whole edge period — for the kit, against the explicit
rule in its own pin ("written against what shipped, never what is in flight").

BACKEND_DESIGN.md gains the two admin routes, the router-tree entry and the
adminOnly line; api-route-inventory.json regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 06:40:48 -05:00
parent 88de7e3331
commit 58c0797fc2
4 changed files with 384 additions and 22 deletions

View File

@@ -180,6 +180,13 @@ server/
from a manifest URL, enable,
disable, uninstall, purge, restart
and the source allowlist
engagement.router.js (2) /admin/engagement — adminOnly,
the declared event catalog. Read
only and table-free: it serves the
module registries. Rules, templates
and the send log land under this
same prefix in ENGAGEMENT.md
Phases 4 and 5
email.router.js (4) /admin/email — outbound mail:
transport + credentials + send
test — adminOnly. The two
@@ -1011,7 +1018,7 @@ These rows are configuration that happens to need a login.
`users`, `invites`, `auth/providers` and `bot-activity` add `adminOnly` on top, and `moderation` adds
`modAccess` (admin + moderator, so editors are excluded). The content capabilities — `posts`,
`uploads`, `wiki`, `pages` — add nothing: managing content is the editor tier's job, so `staffOnly` is
the whole gate. The ops/config capabilities — `modules`, `email`, `discord-bot`, `settings`, and
the whole gate. The ops/config capabilities — `modules`, `email`, `engagement`, `discord-bot`, `settings`, and
`PUT /site-mode` — are `adminOnly`. There is no residual file: every admin route is declared in a
capability router.
@@ -1059,6 +1066,8 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/modules/:id/disable` | the one module action that takes effect immediately — dispatches that module's `onShutdown`, then its routes, nav and client chunk answer 404. A real kill switch, not a visibility flag |
| POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` |
| DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted |
| GET | `/engagement/triggers` | every **declared** event trigger, its payload contract (each variable with a type, a `required` flag and an `example`) and its audience **ceiling** — plus the ceiling vocabulary itself and the closed variable-type set. Served from the module registries, **not from a table**: a trigger is declared in code by core or an installed module, so this is whatever registered on this boot and a module that was uninstalled simply stops appearing. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §4.3 |
| GET | `/engagement/audiences` | every declared audience a rule may be pointed at, with its params and ceiling. The `resolve` function is **never served** — an audience answers with user ids on the server side only, so a module still cannot enumerate addresses. `adminOnly`. See [ENGAGEMENT.md](ENGAGEMENT.md) §5.1a |
| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said |
| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact |
| POST | `/teams/resync` | run a reconciliation now, **awaited**, so the response carries the outcome including the provider's own refusal reason. The four refusal gates still apply: a manual resync cannot make core act on an answer it does not trust |

View File

@@ -1,12 +1,13 @@
# The Engagement System — findings and plan
**Status:** design of record. **Phases 1, 1a and 1b are built** (Phase 1: website#165 + docs#178, with
website#164 as its prerequisite; Phase 1a: website#166 + docs#179); everything from Phase 2 on is still
design. The scope decisions below are
settled; **four of the eight questions in §7.1 were answered by the org lead on 2026-08-28** — Q1, Q3, Q5
and Q7, and Q1's answer added a whole phase (**Phase 1b**, unique email addresses). Q2, Q4, Q6 and
Q8 remain open and block Phases 4, 5b, 2 and 8 respectively. Per CLAUDE.md § Conventions, no implementation
starts without the org lead's approval of the phase it belongs to.
**Status:** design of record. **Phases 1, 1a, 1b and 2 are built** (Phase 1: website#165 + docs#178,
with website#164 as its prerequisite; Phase 1a: website#166 + docs#179; Phase 1b: website#167 +
docs#180); everything from Phase 3 on is still design. The scope decisions below are settled; **six of
the eight questions in §7.1 are answered** — Q1, Q3, Q5 and Q7 on 2026-08-28, and Q6 on 2026-08-29 at
the start of Phase 2, which also settled §7.2's namespace question. Q1's answer added a whole phase
(**Phase 1b**, unique email addresses). **Q2, Q4 and Q8** remain open and block Phases 4, 5b and 8
respectively. Per CLAUDE.md § Conventions, no implementation starts without the org lead's approval of
the phase it belongs to.
**Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover
(Phase 13). §6.0a records the blocking precondition — six `edge` branches are stale and two repos have
@@ -736,8 +737,11 @@ api.registerEventTriggers([{
id: 'uo.house.idoc_warning', // <owner>.-prefixed, checked by the existing namespaced()
label: 'House approaching collapse',
description: 'A player house dropped into a late decay stage.',
kind: 'event', // 'event' | 'scheduled' (G25); default 'event'
subjectKey: 'house', // which variable identifies the subject, for cooldowns
audience: 'owner', // 'owner' | 'subscribers' | 'computed'
audience: 'owner', // the DEFAULT a rule is created with
ceiling: 'owner', // the widest a rule may EVER be given (G24, §5.1a)
version: 1, // bumped on a rename or a type change
variables: [
{ name: 'character', type: 'string', required: true, example: 'Darrow' },
{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' },
@@ -769,6 +773,17 @@ Four properties, each with a reason:
prebuilt `engagement-triggers.json` in its bundle**, for the same reason it ships a prebuilt
swagger fragment: core never has its sources.
**Two corrections from building it (Phase 2).** `audience` and `ceiling` are two fields, not one: the
first is the default a rule is created with, the second is the maximum it may be raised to, and the
registry refuses a default that the ceiling does not permit. And `'computed'` is gone from the
`audience` vocabulary — a rule pointing at a composed segment says so by naming the segment, so a
third pseudo-value that means "look elsewhere" would be a value the ceiling arithmetic cannot compare.
Both fields take the same six-value vocabulary (§5.1a).
**`example` is enforced, not encouraged.** A variable without one is refused at registration. Property
3 above is right and a soft version of it is worth nothing: the moment one variable has no example,
previewing that template needs a live game event again.
**Versioning.** A variable's *addition* is additive and needs nothing. A **rename or a type change** breaks
every stored template referencing it, so a trigger declaration carries `version`, bumped like a block's
prop-schema version, and templates store the trigger version they were authored against. A template
@@ -1105,6 +1120,41 @@ api.registerAudiences([{
to the empty set and the rule referring to it shows as dormant — never an error, never auto-deleted,
never a silent send to a *different* set of people because the id stopped resolving.
**The lattice itself — settled in Phase 2, because this document named it everywhere and defined it
nowhere.** "Narrowest" needs an ordering, and the obvious one is wrong:
```
everyone anyone at all, signed in or not
└── authenticated any signed-in user
├── subscribers signed-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
```
**It is containment, not size.** The tempting model is a flat total order — `self < owner < staff <
members < authenticated < everyone`, compared with `<=` — and under it a trigger ceilinged at `staff`
also permits `owner`, so a rule could mail `uo.cheat.detected` to the player it detected. Fewer people
is not less exposure; the question is always *which* people.
So 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. Three consequences:
- `permits(ceiling, candidate)` is "walk `candidate` up the tree and see whether you reach `ceiling`",
and it **fails closed** on anything it does not recognise.
- `meet(a, b)` — the narrower of two — exists only when one is an ancestor of the other. Two
incomparable ceilings have **no bound at all**, and the composition is REFUSED rather than resolved
to a guess. Union-widens is the intuitive implementation and it is the wrong one; picking a side
when there is no answer is the second-wrong one.
- The direction of the boolean operator is irrelevant. `A AND B` takes the tighter ceiling exactly as
`A OR B` does, because a ceiling states what an expression is *allowed* to reach, not what it will
resolve to. An empty composition has no bound and is `null`, never `everyone`.
The vocabulary travels with the trigger catalog (`GET /admin/engagement/triggers` serves it), so the
rule editor never offers an audience the server will refuse. The server is still the boundary: Phase 4
re-checks every rule save.
**Where it lands.** The registration surface and the ceiling arithmetic belong in **Phase 2**, with the
trigger declaration — G24's reasoning applies unchanged, and both are cheap now and expensive to retrofit
into a rule model that already has rows in it. The composition UI belongs with the rules screen in
@@ -1228,7 +1278,7 @@ change is not complete until `docs/` reflects it" — is the floor; this table i
| **1** Remove Gmail OAuth2, SMTP | `website/BACKEND_DESIGN.md` §7 **rewritten** (not amended — it documents Gmail OAuth2 as *the* mechanism); route tables lose `/admin/email/connect/*` | `website/README.md` + `.env.example` wherever they point at Connect Gmail · **`runicgateway.com`**: `notifications-and-email.mdx` (its "There is no SMTP option" aside is now false), `configuration.mdx:62`, `troubleshooting.mdx:101`, `system-architecture.mdx:117` · a release note |
| **1a** One self surface | `website/BACKEND_DESIGN.md` — the `/auth/me` prose and the `/player`+`/admin` router trees · `api-route-inventory.json` regenerated · `website/ENGAGEMENT.md` this phase | `android/PLAN.md` §6.4/§8 — the "routes stay for web back-compat" note is now false · `website/API_V2_PLAN.md` historical tables get a pointer |
| **1b** Unique email | `website/BACKEND_DESIGN.md` — the `users` table (the "not unique" note is now false), the new change/verify routes, and the de-dupe migration as an operator-visible upgrade step | `website/README.md` upgrade notes · a release note naming the admin report and the verification-gate default |
| **2** Trigger registry | `website/MODULE_API.md` §1.1 (**1.7.0** + correct the stale "1.6.0 has only ever been on `edge`" paragraph), §2.3 (`ctx.events`, `ctx.inbox`), §2.4 (`registerEventTriggers`), §7.3's dormant-rule note · `website/ENGAGEMENT.md` §4.3 kept true | `Integration-kit`: `ci/core-ref.json` re-pinned (the equality check goes red **on purpose**) + chapter 2 gains a "registering a trigger" section · **`runicgateway.com`**: `platform.json.moduleApi` → 1.7.0 |
| **2** Trigger registry | `website/MODULE_API.md` §1.1 (**1.7.0** + correct the stale "1.6.0 has only ever been on `edge`" paragraph), §2.3 (`ctx.events`, `ctx.inbox`), §2.4 (`registerEventTriggers`, `registerAudiences`), the dormant-rule note (landed as §6.8) · `website/ENGAGEMENT.md` §4.3 and §5.1a kept true · `BACKEND_DESIGN.md` route table | **Both deferred to the Phase 13 cutover window, deliberately — see Phase 2's as-built.** `Integration-kit`'s `ci/core-ref.json` pins a **`main`** sha, so the equality check stays green (and must stay green) for the whole `edge` period; `runicgateway.com`'s `checkFacts.mjs` *fetches* from `main`, so setting `platform.json.moduleApi` → 1.7.0 now would turn that repo red immediately |
| **3** Channel preferences | `website/BACKEND_DESIGN.md` route table · `android/PLAN.md` §11 | — |
| **4** Engine | `website/ENGAGEMENT.md` (rules/cooldown/outbox as built) · `BACKEND_DESIGN.md` table inventory | — |
| **5a/5b** Templates + editor | `website/ENGAGEMENT.md` §4.6 · a template-authoring section in `BACKEND_DESIGN.md` or its own doc | **`runicgateway.com`**: a new admin docs page for the template editor |
@@ -1255,8 +1305,13 @@ top. No code.
**Acceptance:** merged into `docs/`; §7.1's open questions each answered or explicitly deferred before
the phase that depends on them starts. **Q1, Q3, Q5 and Q7 were answered on 2026-08-28** (§7.1), which
unblocked Phases 1, 2 and 4 and added Phase 1b. Still outstanding: **Q6 before Phase 2**, **Q2 before
Phase 4**, **Q4 before Phase 5b**, **Q8 before Phase 8**.
unblocked Phases 1, 2 and 4 and added Phase 1b; **Q6 and §7.2 were answered on 2026-08-29**, at the
start of Phase 2 and before a line of it was written. Still outstanding: **Q2 before Phase 4**, **Q4
before Phase 5b**, **Q8 before Phase 8**.
This acceptance criterion has now paid for itself twice. Q6 and §7.2 were both tagged "decide in
Phase 2", and §7.2's answer went **against** the recommendation in the text — which is exactly the
outcome that is cheap to act on before the registry exists and expensive afterwards.
---
@@ -1482,7 +1537,7 @@ reconstructing "was this install fresh?" afterwards is guesswork.
---
### Phase 2 — The trigger registry and the variable contract
### Phase 2 — The trigger registry and the variable contract
`api.registerEventTriggers` + `ctx.events.emit` in `modules/registries.js` and `modules/loader.js`;
`MODULE_API_VERSION` → 1.7.0 on both halves; core registers its own triggers (news, the four Team
@@ -1505,6 +1560,94 @@ trigger's ceiling**; `engagement-triggers.json` diffs zero in CI.
**Guardrails:** the new manifest `--check` (this is where the "manifest-style guardrail" the brief asks
about belongs); `check:modules` proves core's own trigger ids name no game concept.
#### As built (2026-08-29)
**Three design questions were settled by the org lead before any code**, and two of them the plan had
deferred to this phase on purpose:
| | Question | Decision |
|---|---|---|
| §7.2 | one namespace, or two? | **ONE.** A trigger is a payload contract attached to an id that may also carry a subscription toggle |
| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9**`kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today; no evaluator yet |
| — | what "narrower" means for a ceiling | an explicit **subset lattice**; two incomparable ceilings have no bound and the save is REFUSED, never guessed |
**One namespace was the more invasive of the two options and it is worth stating what it bought and
what it cost.** It buys `notification_channel_prefs.stream_id` staying single-keyed (§4.5): under two
namespaces its primary key would have needed a `kind` discriminator, and `news.post` would have named
two different things forever. It costs a new rule in `registries.js` — **an id has exactly one owner
across both facets** — enforced in both directions, so a module cannot attach a payload contract to
another module's stream and cannot claim a stream id another module declared a trigger for. 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.
Three consequences fell out of it that the §7.2 text did not anticipate:
- **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`. Two grammars over one namespace would mean an id that is
legal as a trigger and illegal as the stream it is the same event as. It is a relaxation only —
every id valid before is valid now, and no stored id changes.
- **The legacy allowlist had to be shared too.** The seven grandfathered `uo.*` stream ids are exempt
from the prefix rule for triggers as well, and it cannot be otherwise: 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 could never gain a payload contract.
- **The push catalog is unchanged.** `allStreams()` still serves the stream facet only, so a
trigger-only id (`uo.house.idoc_warning`) does not appear in the catalog the shipped Android client
reads. A trigger-only id gets email and in-app preferences in Phase 3 and no push toggle, which is
correct — there is nothing to push it to.
**The ceiling lattice is the one thing this plan named everywhere and defined nowhere**, and getting
it wrong would have been a security defect rather than a rough edge. §5.1a now carries the definition;
the short version is that the tempting flat ordering — self < owner < staff < members < authenticated
< everyone permits a `staff`-ceilinged trigger to be given an `owner` audience, which is a rule that
mails `uo.cheat.detected` to the player it detected. Fewer people is not less exposure.
**What landed:**
- `server/src/modules/ceilings.js` the six-value lattice, `permits`, `meet`, `meetAll`
- `registerEventTriggers` / `registerAudiences` on the existing `stage()` + `apply()` discipline, with
the cross-facet collision checks and the shared legacy allowlist
- `ctx.events.emit` (`utils/engagementEmit.js`) validate, log, **stop**; throws in dev, drops and
logs in prod; the owner is bound by core and never read from the arguments
- `ctx.inbox.push` present and **throws** until Phase 7, the shape 1.6.0 settled on
- `config/coreTriggers.js` core's five, registered through `registerCore()`
- `GET /admin/engagement/{triggers,audiences}` admin-only, served from the registries, no table
- `npm run engagement:manifest` (+ `--check` in CI) and the committed `engagement-triggers.json`
- `MODULE_API_VERSION` **1.7.0** on both halves; `MODULE_API.md` §1.1, §2.3, §2.4 and a new §6.8
**Two things §6.0b's Phase 2 row assigns that deliberately do NOT land in this window**, and neither
is a slip:
- **`integration-kit`'s `ci/core-ref.json`.** The pin names a website **`main`** sha and
`checkCoreApi.js` asserts equality with what that sha declares. 1.7.0 is on `edge`, `main` still
says 1.6.0, so the kit is green and moving the pin now would pin the book to a commit that is still
in flight which the pin's own prose forbids ("the kit is written against what shipped"). The
re-pin and chapter 2's "registering a trigger" section belong in the **Phase 13 cutover window**.
- **`runicgateway.com`'s `platform.json.moduleApi` 1.7.0.** `scripts/checkFacts.mjs` *fetches* the
authority from the source repo's `main`. Setting it to 1.7.0 today turns that repo red immediately
and for the whole `edge` period. It is Phase 12's, in the cutover window, exactly as the phase says.
**Four smaller decisions taken at build time:**
| | Decision | Why |
|---|---|---|
| `ceiling` required, no default | registration fails without one | there is no safe value to guess: `owner` silently breaks a broadcast, `authenticated` silently widens a staff-only event |
| `example` required per variable | registration fails without one | §4.3 property 3 is right and a soft version of it is worthless without an example, preview needs a live game event, which is how template systems ship untested |
| audiences get their own id space | not the trigger/stream namespace | an audience names a set of PEOPLE, a trigger names an EVENT; `uo.team.members` as both is not a collision |
| `url` variables are site-relative | validated like `pageUrlTemplate` | a payload variable ends up in an href in an email; `//evil.test/x` passes an "is it rooted" check and is protocol-relative |
**One acceptance criterion is only half-dischargeable here, and that is not a slip.** "A rule cannot be
saved with an audience wider than its trigger's ceiling" needs `engagement_rules`, which is Phase 4's.
What Phase 2 owes and delivers is the arithmetic that check will call (`permits` / `meet` / `meetAll`,
with tests for the incomparable cases a total order would have waved through) **and** the same rule
applied where a row already exists: a declaration whose default `audience` is wider than or
incomparable with its own `ceiling` is refused at registration. Phase 4 adds the second call site,
not the second implementation.
**Left for later, deliberately:** nothing consumes an emitted event yet. `emit` validates and logs,
and Phase 4 replaces that log line with the engine call. Core's five declarations are registered but
not yet *emitted* the Team pipeline keeps its hardcoded mail until Phase 6 migrates it onto the
engine, and this is what it migrates onto.
---
### Phase 3 — Channel preferences
@@ -1789,7 +1932,7 @@ day it ships.
## Part 7 — Open questions and forward-compat notes
### 7.1 Questions for the org lead — five answered 2026-08-28, four still open
### 7.1 Questions for the org lead — six answered, three still open
1. **ANSWERED — may unverified addresses receive engagement mail?** *"Emails need to be unique and
verification blocking sending is an admin setting."* Combined with the opt-in answer, this settles
@@ -1818,10 +1961,13 @@ day it ships.
relay** (Mailgun/SES/Postmark); name Gmail-with-an-app-password (`smtp.gmail.com:587`) explicitly as
the migration path off OAuth2 for the existing deployment 1.2a). Phase 1 owes all three in
`runicgateway.com`'s `notifications-and-email.mdx` as well as `BACKEND_DESIGN.md` §7.
6. **Time-based triggers (G25) design now, build when?** Recommendation: design the declaration in
Phase 2 so `kind: 'scheduled'` exists in the contract, build the evaluator after Phase 9. The
lifecycle uses in §8.5 are the highest-value non-game triggers on the list and the first thing anyone
will ask for after the IDOC mail works.
6. **ANSWERED — time-based triggers (G25), design now, build when?** As recommended: the
declaration lands in Phase 2 so `kind: 'scheduled'` is in the contract, the manifest and every
stored declaration from day one; the evaluator is built after Phase 9. Registration accepts
`scheduled` today and `ctx.events.emit` **refuses** to fire one an evaluator's trigger is not a
caller's so `kind` means something from the moment it is declarable rather than from the moment
it is honoured. The lifecycle uses in §8.5 are the highest-value non-game triggers on the list and
the first thing anyone will ask for after the IDOC mail works.
7. **ANSWERED — manual/operator-authored sends.** *"There is no campaign in the normal sense of email
marketing. But admins can create all sorts of trigger conditions"*, and separately: *"lists can be
built if they are powered by game data say team X members or governors or whatever thru the uo
@@ -1853,9 +1999,23 @@ and triggers are close enough to collide conceptually — `idoc.warning` (a stre
- **Two namespaces**: streams stay exactly as they are (push only), triggers are new and separate.
Zero risk now, permanent duplication later.
Recommendation: **two namespaces through Phase 8, then converge in a later workstream** — the shipped
Android client reads the stream catalog and the seven ids are already grandfathered once. Decide this in
Phase 2; it is cheap then and expensive in Phase 6.
Recommendation was **two namespaces through Phase 8**, on the grounds that the shipped Android client
reads the stream catalog.
**ANSWERED 2026-08-29 by the org lead: ONE namespace.** A trigger is a payload contract attached to
an id that may also carry a subscription toggle; `news.post` names one event whichever question is
being asked of it. They stay two REGISTRATIONS with two shapes a stream entry is a subscription
toggle and says nothing about payload but **an id has exactly one owner across both facets**, and
that is checked in both directions.
The risk the recommendation was hedging against does not materialise, and that is worth recording
because it is the part that looked expensive: **the push catalog is untouched.** `allStreams()` still
serves the stream facet only, so the shipped app sees exactly the seven-plus-five ids it saw before,
and a trigger-only id simply has no push toggle. What one namespace actually costs is the ownership
check; what it buys is `notification_channel_prefs.stream_id` staying single-keyed, where two
namespaces would have forced a `kind` discriminator into its primary key and left `news.post` naming
two things forever. See Phase 2's as-built for the two knock-on effects (a relaxed id grammar and a
shared legacy allowlist) that only appeared once it was implemented.
### 7.3 Forward-compat: the note the brief asked for, corrected

View File

@@ -26,13 +26,42 @@ here extends the contract first, in this file, before the module is written agai
Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js
const MODULE_API_VERSION = '1.6.0'
const MODULE_API_VERSION = '1.7.0'
```
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer.
**1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals
and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves.
`api.registerEventTriggers([...])` and `api.registerAudiences([...])` (§2.4) ·
`ctx.events.emit(triggerId, envelope)` and `ctx.inbox.push(userId, item)` (§2.3).
**This is a real bump, and 1.6.0's in-place amendments are over.** The rule those amendments invoked
*a contract owes a bump only once it has landed on `main`* — was true when they were written and is
not any more: 1.6.0 reached `main` with the Teams cutover, so the paragraphs below saying "1.6.0 has
only ever been on `edge`" are **historical, not current**. Everything added from here takes a version
of its own. That is also why the integration kit does not go red until the engagement cutover:
`ci/core-ref.json` pins a `main` sha and `checkCoreApi.js` asserts equality with what that sha
declares, so the kit stays green for the whole `edge` period and must be re-pinned in the cutover
window (`ENGAGEMENT.md` Phase 13).
**As in 1.6.0, the number states the whole surface and the members arrive by phase.**
`ctx.inbox.push` is present and **throws** until the in-app channel exists (`ENGAGEMENT.md` Phase 7);
everything else in 1.7.0 is live. Present-and-throwing is deliberate and is the choice 1.6.0 settled
on: a member of a declared version that were simply absent would make the version a lie, and one that
silently accepted data into a table that does not exist would be worse than either.
**One part of 1.7.0 is not a member, and is contract all the same: a trigger id and a notification
stream id share ONE namespace.** An id has exactly one owner across both facets, so a module cannot
attach a payload contract to another module's stream and cannot claim a stream id another module has
declared a trigger for. Core's own five trigger ids *are* its five stream ids, which is the
same-owner case the rule is written for. Nothing registrable before this bump becomes unregistrable
after it — the id grammar was **relaxed** in the same change, so `_` is now legal inside a segment
(`uo.house.idoc_warning`) — but the ownership check is new and it is a tightening. See
`registerEventTriggers` in §2.4, and `ENGAGEMENT.md` §7.2 for the decision.
**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor;
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
@@ -228,6 +257,45 @@ module-uo does not need is on the list.
| `ctx.teams.publish` | `(event) => Promise<void>` | `model/teams/teamSync` | the Team provider's module (1.6.0) |
| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) |
| `ctx.teams.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
| `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | a module's own event mapper (1.7.0) |
| `ctx.inbox.push` | `(userId, item) => Promise<void>`**throws until Phase 7** | the in-app channel | not yet (1.7.0) |
**`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with
`api.registerEventTriggers` (§2.4). It is the push half of the engagement seam
(`website/ENGAGEMENT.md` §5.2).
```js
ctx.events.emit('uo.house.idoc_warning', {
subject: '0x40001234', // optional — else read from the declared subjectKey
data: { house: 'The Silver Anvil', decayStatus: 'Greatly' },
ownerUserId: 812, // optional — the module resolves it; core never sees a game account
dedupeKey: 'idoc:0x40001234:greatly', // optional, <= 190 characters
occurredAt: new Date(), // optional, defaults to now
})
```
Six things about it are contract rather than implementation:
- **A module emits its own triggers and nothing else.** The owner is bound by core from the calling
module's id and is never read from the arguments. Without that, `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.
- **The payload is validated against the declaration at EMIT, not at render.** A missing `required`
variable or a wrong type is **thrown in development and dropped-and-logged in production** — the
posture `ctx.teams.activity.push` takes, for the same reason: this is called from inside a
game-event handler, and a contract problem of core's must not become the module's control flow.
Undeclared keys are dropped rather than rejected; they could never be interpolated anyway.
- **It returns `undefined` and never throws in production.** There is nothing a module could
correctly do with a delivery failure from inside an event handler, so there is nothing to await.
- **`ownerUserId` is a website user id, resolved by the module.** Core has no idea what a game
account is and must not learn; the module maps its own account to a user and passes the result.
- **`subject` is what a cooldown is keyed on** — "once per house", not "once per user" — and falls
back to the variable the declaration's `subjectKey` names.
- **A `scheduled` trigger is not emitted.** Its evaluator fires it; a direct emit is refused.
**`ctx.inbox.push(userId, item)`** is the in-app sink, for a module that wants to write a user's
inbox directly without going through a rule. It is **present and throws** until the in-app channel
lands (`ENGAGEMENT.md` Phase 7) — see §1.1 for why a declared member throws rather than being absent.
**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers*
questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a
@@ -308,6 +376,8 @@ api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.registerPostHook({ onSaved, onDeleted })
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0
api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
@@ -567,6 +637,94 @@ but its own reply. `ok` is core's verdict and sits outside the envelope, so a ha
claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who
switches a module off does not leave a live handler behind it.
**`registerEventTriggers(triggers)`** (1.7.0) declares the events a module can fire and the payload
contract behind each. The catalog it builds is what `GET /api/v1/admin/engagement/triggers` serves,
what a rule is written against, and what a template may interpolate
(`website/ENGAGEMENT.md` §4.3).
```js
api.registerEventTriggers([{
id: 'uo.house.idoc_warning', // <owner>.-prefixed, one namespace with stream ids
label: 'House approaching collapse',
description: 'A player house dropped into a late decay stage.',
kind: 'event', // 'event' | 'scheduled'; default 'event'
subjectKey: 'house', // which variable identifies the cooldown subject
audience: 'owner', // the DEFAULT a rule is created with
ceiling: 'owner', // the widest a rule may EVER be given
version: 1, // bumped on a rename or a type change
variables: [
{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' },
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' },
],
}])
```
Six things about it are contract rather than implementation:
- **A trigger id and a notification stream id are ONE namespace.** An id has exactly one owner across
both facets. A module may declare both for the same id — that is one event with a subscription
toggle *and* a payload contract, and it is what core does with its own five — but it may not attach
a contract to another owner's stream, and the refusal names the holder and the facet. The seven
grandfathered ids (§6.5) are exempt from the prefix rule here exactly as they are for streams,
because under one namespace they are the same ids.
- **`ceiling` is required and has no default.** It is the audience ceiling (`ENGAGEMENT.md` §5.1a),
and there is no safe value to guess: `owner` would silently break a broadcast and `authenticated`
would silently widen a staff-only event. The six values are `everyone`, `authenticated`,
`subscribers`, `members`, `staff` and `owner`, ordered by **containment and not by size** — a
`staff` ceiling does NOT permit `owner`, because fewer people is not less exposure. A default
`audience` wider than, or incomparable with, the ceiling is refused at registration.
- **Every variable needs an `example`, and it is not decoration.** It is what makes previewing and
test-sending a template possible without a live game event, which is the reason template systems go
untested. A variable without one is refused.
- **The type set is closed:** `string`, `int`, `float`, `boolean`, `datetime`, `url`. No `object` and
no `array` — a template that has to walk a structure has outgrown interpolation. A `url` is
validated **site-relative**, like `pageUrlTemplate`, because it ends up in an href.
- **A `subjectKey` must name a declared variable.** Otherwise the cooldown is keyed on `undefined`,
which looks like the feature working right up until two subjects share it.
- **`version` is the prop-schema version a block carries** (§4.3), bumped on a rename or a type
change; a template records what it was authored against and renders with a warning rather than
interpolating `undefined`.
A module ships a prebuilt `engagement-triggers.json` in its bundle, for the same reason it ships a
prebuilt swagger fragment (§6.1a): core never has its sources to analyse. Core's own is generated by
`npm run engagement:manifest` and gated in CI with `--check`.
**`registerAudiences(audiences)`** (1.7.0) declares named sets of users a module can resolve over its
own data, for an operator to point a rule at (`ENGAGEMENT.md` §5.1a). "Team X's members" and "the
governors" are audiences; "everyone who opened the last mail" is not, and nothing here builds it.
```js
api.registerAudiences([{
id: 'uo.team.members',
label: 'Members of a team',
params: [{ id: 'teamId', type: 'int', required: true }], // 'int' | 'string' only
ceiling: 'members',
resolve: async (params) => [/* user ids */],
}])
```
Four things about it are contract rather than implementation:
- **The resolver returns user ids and nothing else.** It is not handed a template, a channel or an
address and it cannot enumerate them. A module still cannot send mail (§2.7), and this must not
become the back door that lets it — core maps ids to addresses on its own side, after preferences,
suppression and the verification gate.
- **Core learns no game vocabulary.** Core never knows what a governor is; it knows an id, a label
and a `resolve` it may call. The same boundary `registerNotificationStreams` holds.
- **An audience whose module is uninstalled goes DORMANT, never an error.** It resolves to the empty
set and a rule referring to it shows as dormant — never auto-deleted, and never a silent send to a
*different* set of people because the id stopped resolving. Same rule as §7.3's dormant trigger. A
resolver that throws or answers a non-array costs an empty set too, not a wrong one, and the ids it
does return are filtered to positive integers before core uses them.
- **A composed segment takes the NARROWEST ceiling it contains, never the widest**, and is still
checked against the trigger's own ceiling before a rule using it can be saved. Union-widens is the
intuitive implementation and it is the wrong one; two incomparable ceilings have no bound at all
and the save is refused rather than guessed. Composition UI is Phase 4's.
**Audiences are their own id space**, unlike triggers and streams: an audience names a set of PEOPLE
and a trigger names an EVENT, so the two may share a name. They carry no legacy allowlist — nothing
predates them.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle
@@ -1746,6 +1904,33 @@ injected chunk executes and calls `window.__rg.registry` (§3.3).
---
### 6.8 A trigger, a rule and an audience outlive the module that declared them
The forward-compat note `ENGAGEMENT.md` §7.3 asks this document to carry, and the reason it is here
rather than there: it is a rule about **the contract**, not about the engagement system.
A trigger id is namespaced by its owner and collision-checked at registration, exactly as a
notification stream is. What is *not* expressed by that is a rule or a template referring to a
trigger whose module has been uninstalled.
> `engagement_rules.trigger_id` is a plain `VARCHAR`, deliberately — no foreign key, no cascade — so
> a module can be removed and reinstalled without an operator's rules being destroyed. It is the same
> decision `announce_job_legs` took for a leg whose module is gone: *"Leave it alone: failing it would
> make the job roll up terminal on the strength of a leg that no longer exists, and reinstalling the
> module should resume it."*
>
> A rule whose trigger is unregistered must therefore show as **dormant** in the admin UI — never as
> an error, and never auto-deleted. The same holds for a rule whose *audience* is unregistered: it
> resolves to the empty set and shows dormant, which is not the same answer as "resolved to nobody"
> and must not be rendered as if it were. The failure this prevents is specific: an id that stops
> resolving must never silently become a send to a **different** set of people.
`engagement_templates.trigger_id` is the same, for the same reason, with one addition: a template
records the trigger `version` it was authored against, so a declaration that has since been bumped
produces a warning in the admin list rather than silently interpolating `undefined`.
---
## Part 7 — What the spike proved
The Phase 1 spike ran on `website` branch `spike/module-atlas`, cut from `edge` and **deliberately

View File

@@ -73,6 +73,14 @@
"method": "POST",
"path": "/api/v1/admin/email/test"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audiences"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/triggers"
},
{
"method": "GET",
"path": "/api/v1/admin/invites"