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:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user