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

@@ -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