docs(engagement): MODULE_API 1.8.0, the UO trigger catalogue, the news path

The documentation half of Phase 11a (website#177, Module-uo#22). Three files,
each carrying one obligation from 6.0b's Phase 11a row.

**MODULE_API.md -> 1.8.0.** A seventh audience ceiling, `admin`, as a child of
`staff`. One addition, no removal, so minor -- every declaration valid under
1.7.0 is valid now and no stored value changes. 2.4's ceiling bullet now names
seven values rather than six, and says the thing a module author actually needs:
`admin` is the ONLY pair in the whole lattice with real containment, so
`permits('staff','admin')` holds and it is the only place `permits` is true
between two values below `authenticated`.

Also corrects two 2.3 rows that were stale rather than wrong: `ctx.inbox.push`
has not thrown since Phase 7, and both members now name the consumer that
actually calls them.

**modules/uo/API.md gains 5**, the full trigger catalogue -- which 6.0b asked for
in as many words ("the full trigger catalogue, its audiences and its ceilings,
not one entry"). The 24 triggers grouped by ceiling with the reason for each
ceiling; the four rows that deliberately do NOT ship with their recorded reasons;
the two that need a patch tier or a v5 overlay; the three things a rule cannot
express and are therefore done in the mapper; the three registered audiences; and
the ingest ORDERING, which is load-bearing rather than incidental.

**BACKEND_DESIGN.md -- the news publish path.** It described a direct
`pushDispatch.publish('news.post', ...)` from the create/publish-post path. That
call is gone: it is an emit through the engagement engine now, so news push rides
a rule, and core seeds that rule disabled. The note says so as an operator-visible
upgrade step, names the separate one-shot settings key and states what did NOT
change -- the announce legs and the post hooks still fire exactly as they did.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-31 20:34:31 -05:00
parent 7866bd7ab8
commit 561e5308e4
3 changed files with 152 additions and 9 deletions

View File

@@ -153,3 +153,107 @@ changes something — with deliberate exceptions, which are the leaks it was wri
`/public/shard/guilds`, `/public/shard/governors` and `/public/shard/feed` previously returned the raw `/public/shard/guilds`, `/public/shard/governors` and `/public/shard/feed` previously returned the raw
stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` returned the flattened stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` returned the flattened
`ownerAcct`. All are now stripped for every caller below admin. `ownerAcct`. All are now stripped for every caller below admin.
## 5. Engagement triggers and audiences (ENGAGEMENT.md Phase 11)
Not an HTTP surface, and it is here anyway: it is the other thing this module registers with core, and
it is the one an operator interacts with by name. `module-uo` declares **24 event triggers** and
**3 audiences** through `api.registerEventTriggers` / `api.registerAudiences`
([`MODULE_API.md`](../../website/MODULE_API.md) §2.4). Core never learns a word of the vocabulary —
it holds an id, a label, a variable list, a ceiling and, for an audience, a `resolve` it may call.
**What a trigger is, and what it is not.** It is a *payload contract*: what a rule may fire on, what a
template may interpolate, and — the part that is a security boundary — the widest audience an operator
may ever give it. Declaring one sends nobody anything. An operator has to write a rule, and every rule
core or this module seeds ships `enabled = 0`.
The declarations live in
[`server/config/shardTriggers.js`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/server/config/shardTriggers.js);
the wire-kind mapping that fires them is `server/utils/shardEngagement.js`, hung off `shardIngest`
beside the SSE broadcast and the push tickle.
### 5.1 The catalogue, by ceiling
The **ceiling** is the widest audience a rule may ever be given for that trigger. It is checked when a
rule is saved *and* again at send time, and it is ordered by **containment, not size** — a `staff`
ceiling does not permit `owner`, because fewer people is not less exposure.
| Ceiling | Triggers | Why that ceiling |
| --- | --- | --- |
| `owner` | `uo.house.idoc_warning`, `uo.house.collapsed`, `uo.vendor.expiring`, `uo.vendor.sale`, `uo.account.login_failed`, `uo.account.unlinked`, `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered` | Each is about one person's own property, account or character. All ten resolve through the frame's `ownerAcct` to `shard_account_links`; an unlinked game account is nobody to notify |
| `members` | `uo.guild.left`, `uo.guild.disbanded` | The guild's roster, resolved to website users through `shard_account_links` and carried on the emit as `recipientUserIds` — "the members of *this* guild" is a different answer every firing, which a saved segment cannot express |
| `authenticated` | `uo.governor.elected`, `uo.election.opened`, `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down`, `uo.points.rank_changed` | Public shard news. Each defaults to `subscribers`; the ceiling permits an operator to widen to everyone signed in, which for "the shard is back up" is a defensible thing to want |
| `staff` | `uo.page.new`, `uo.cheat.detected` | `uo.cheat.detected` is the declaration the lattice was written for: under a flat "fewer people is narrower" ordering, a `staff` ceiling would also permit `owner`, and the rule an operator could then save mails the cheat report to the player who was detected |
| `admin` | `uo.audit.staff_action`, `uo.economy.milestone`, `uo.world.saved` | `staff` means admin, editor **and** moderator, so a digest of what staff did in game must not ceiling there. `admin` was added to the lattice for these three (MODULE_API 1.8.0) |
A `staff`- or `admin`-ceilinged id **does not appear by name in a player's preferences catalogue**
core filters the catalog on the ceiling, so a control that could do nothing is never offered and the
event's existence is not disclosed.
### 5.2 Four rows that are deliberately absent
[`ENGAGEMENT.md`](../../website/ENGAGEMENT.md) §8.6 catalogues the candidate events and Phase 11 commits
to shipping every one of them, so a row that does *not* ship needs a recorded reason. There are four:
| Not shipped | Reason |
| --- | --- |
| `uo.market.item_listed` | A saved **search**, not a trigger — its audience is "users whose stored query matches this listing", and no per-user query store exists. Its own workstream |
| `uo.guild.joined` | Core's `team.member.joined` already fires for it: a UO guild **is** a Team and this module is the deployment's Team provider, so the roster reconcile emits on every join. A second trigger is two mails for one event |
| `uo.link.requested` | No addressable recipient **by construction** — the account is not yet linked, which is the point of the event — and a ~5-minute `ttlSec` no channel can beat |
| `uo.points.rank_changed`'s personal half | `points.board`'s `top[]` names a mobile **serial** and `shard_account_links` is keyed by **account**. The board-change feed ships; "you were pushed out" would reach some players and silently not others |
### 5.3 Two triggers that need a running patch tier or a v5 overlay
- **`uo.vendor.sale` requires the opt-in ServUO patch tier.** `vendor.sale` is emitted by a
`PlayerVendorSale` EventSink that lives in `servuo-plugins/patches/`, not in `overlay/`. A shard that
declined the tier emits the kind never, so a rule on it is **silently dormant rather than broken**
which is why the declaration's own operator-facing description says so.
- **`uo.house.idoc_warning`'s schedule and `uo.vendor.expiring` need protocol 5.** Both read fields the
v5 overlay added ([`link/v5.md`](../../link/v5.md)). The warning still fires on a v4 shard, simply
without `nextStage` / `estimatedCollapse`; `uo.vendor.expiring` needs the `fees` block and does not
fire at all without it. **An absent `estimatedCollapse` means "not knowable", never "not yet read"**
— under dynamic decay ServUO draws each stage's duration at random, so the mapper passes the absence
through rather than computing a guess the shard refused to publish.
### 5.4 Three things a rule cannot express, done in the mapper instead
Most rows are a field mapping. Three are not, and each is in the mapper rather than in a rule condition
because `conditions.js` compares a declared variable against a **literal** — no arithmetic, no relative
time, no previous value.
- **Transitions.** `champ.update` and `city.update` are full-state upserts re-emitted on any change, so
without a per-process tracker a sidecar reconnect reads as twenty champion spawns starting at once. A
**first** sighting is never a transition.
- **Thresholds.** `uo.vendor.expiring` fires on the crossing into a 48-hour window and not on every
sweep frame (a shop is re-emitted whenever anyone reprices an item); a deposit that leaves the window
re-arms it. `uo.economy.milestone` crosses a gold or account line, in either direction, never on
first sight. Both declare an int (`hoursRemaining`, `value`) so an operator can still narrow with
"is at most".
- **`uo.server.up` / `down` is the cooldown table's stress test.** `server.hello` arrives on every
*sidecar* reconnect, not only a shard restart, so the tracker suppresses a hello while the shard is
already believed up — and the seeded rule carries a hard cooldown for a shard genuinely flapping.
### 5.5 The three audiences
Named sets of **people** an operator points a rule at or composes into a saved segment with and/or/not.
A different mechanism from the `members` audience the guild triggers use: a registered audience answers
the same question every time it is asked, which is what makes it storable.
| Audience | Params | Ceiling | Resolves to |
| --- | --- | --- | --- |
| `uo.guild.members` | `guildId` (int) | `members` | Everyone with a linked game account on that guild's roster |
| `uo.governors` | — | `members` | Everyone with a linked account holding a city governorship |
| `uo.linked.accounts` | — | `members` | Every website user with at least one linked game account — and, composed under `not`, the audience for the message asking the rest to link one |
Each resolver returns **user ids and nothing else** — never an address, a channel or a template — and
each fails to the **empty set** rather than throwing, because an audience that cannot resolve is a rule
that reaches nobody rather than one that breaks the engine.
### 5.6 Where the ordering matters
The engagement fan-out runs **before** `shardIngest` applies the frame's state change, and that is
load-bearing. Three mappings read a row the state write is about to delete or replace:
`account.unlinked` drops the `shard_account_links` row that names the one person who needs to be told;
`house.remove` drops the house whose stored `ownerAcct` is the only place a collapsed house's owner
appears (the frame carries a serial alone); and `guild.leave` / `guild.remove` need the roster and
board mirrors to name who left and which guild it was. Resolving afterwards finds nobody, every time.

View File

@@ -1217,8 +1217,27 @@ registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscr
**content-free tickle**`{ stream, ref }`, no sensitive data — POSTed to each subscribed device's **content-free tickle**`{ stream, ref }`, no sensitive data — POSTed to each subscribed device's
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership- self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
checked content over the authenticated API. Two producers fan out through the one publisher: the shard checked content over the authenticated API. Two producers fan out through the one publisher: the shard
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and — from
create/publish-post path for `news.post`. The catalog is assembled at boot by **ENGAGEMENT.md Phase 11** — the **engagement engine** for `news.post`.
> **The news publish path changed in Phase 11 and it is an operator-visible change.** Publishing a
> news post used to call `pushDispatch.publish('news.post', …)` directly from
> `admin.controller.js`'s `announceIfNewlyPublished`; it now calls
> `utils/newsNotify.emitNewsPost`, which emits core's declared `news.post` trigger and lets the engine
> decide. Push therefore rides a **rule** like every other channel, and core seeds that rule
> `enabled = 0` beside the four Team ones — so **news push stops on upgrade** until an operator enables
> it in Admin → Engagement → Rules, where a banner says so. The `news.post` seed carries its own
> one-shot settings key (`engagement_news_rule_seeded`) rather than joining the Team group's, because
> the Team key is already stamped on exactly the deployments this affects.
>
> **The other two things a publish fires are untouched.** `announceJobs.enqueueIfNeeded` (a one-shot
> delivery to a channel of the deployment — the in-game town crier, Discord #news, with retry) and
> `registries.dispatchPostHook('onSaved')` (idempotent state mirroring, which also runs on delete) are
> different kinds of thing and still fire exactly as they did. The emit is gated on the same
> `enqueueIfNeeded` job id the push was gated on — the single "newly published news" transition
> signal — so an edit or a re-publish still does not re-fire.
The catalog is assembled at boot by
`modules/registries.js` from core's own streams (`config/coreStreams.js` — just `news.post`) plus `modules/registries.js` from core's own streams (`config/coreStreams.js` — just `news.post`) plus
each installed module's. The seven shard streams and their event→stream mapping left with each installed module's. The seven shard streams and their event→stream mapping left with
`module-uo` in Phase 3 and are registered by it; their ids are grandfathered to that module `module-uo` in Phase 3 and are registered by it; their ids are grandfathered to that module

View File

@@ -26,13 +26,31 @@ 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`: Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js ```js
const MODULE_API_VERSION = '1.7.0' const MODULE_API_VERSION = '1.8.0'
``` ```
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two 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 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. module chunk evaluates, which is earlier than any network round trip could answer.
**1.8.0 — a seventh audience ceiling: `admin`** (`website/ENGAGEMENT.md` Phase 11, decision 1). One
addition and no removal, so minor; every declaration valid under 1.7.0 is valid now and no stored
value changes. `admin` is a **child of `staff`**, so a module may declare `ceiling: 'admin'` on a
trigger or an audience and a `staff`-ceilinged trigger accepts an `admin` audience as a narrowing.
It exists because the narrowest role-shaped value the lattice had was `staff`, which means **admin,
editor AND moderator**. Phase 11's operator-facing triggers — a digest of what staff did in game, the
economy thresholds, the world-save counts — are admin-audience everywhere they are described, and
ceilinging them at `staff` would have let an operator save a rule that mails the staff audit digest to
every moderator in it.
**What a module author has to know beyond the new name.** `admin` is the **only pair in the whole
lattice with real containment** — every admin is staff, which is exactly what every other pair of
branches lacks — so it is the only place `permits` is true between two values below `authenticated`.
`permits('staff', 'admin')` holds; `permits('admin', 'staff')` does not, and neither direction holds
between `admin` and `owner`, `members` or `subscribers`. `permits`, `meet` and `meetAll` are otherwise
unchanged, and so is every rule about composition narrowing rather than widening.
**1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals **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. and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves.
`api.registerEventTriggers([...])` and `api.registerAudiences([...])` (§2.4) · `api.registerEventTriggers([...])` and `api.registerAudiences([...])` (§2.4) ·
@@ -257,8 +275,8 @@ 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.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.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.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.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | `module-uo`'s `utils/shardEngagement.js`, off the shard feed (1.7.0) |
| `ctx.inbox.push` | `(userId, item) => Promise<void>`**throws until Phase 7** | the in-app channel | not yet (1.7.0) | | `ctx.inbox.push` | `(userId, item) => void`, fire-and-forget | the in-app channel (live since Phase 7) | `module-uo` reaches both through `server/core.js` (1.7.0) |
**`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with **`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 `api.registerEventTriggers` (§2.4). It is the push half of the engagement seam
@@ -669,10 +687,12 @@ Six things about it are contract rather than implementation:
because under one namespace they are the same ids. 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), - **`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` 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`, would silently widen a staff-only event. The seven values are `everyone`, `authenticated`,
`subscribers`, `members`, `staff` and `owner`, ordered by **containment and not by size** — a `subscribers`, `members`, `staff`, **`admin`** (1.8.0, a child of `staff`) and `owner`, ordered by
`staff` ceiling does NOT permit `owner`, because fewer people is not less exposure. A default **containment and not by size** — a `staff` ceiling does NOT permit `owner`, because fewer people is
`audience` wider than, or incomparable with, the ceiling is refused at registration. not less exposure. `staff``admin` is the single true refinement in the tree and the only pair
below `authenticated` that `permits` accepts. 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 - **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 test-sending a template possible without a live game event, which is the reason template systems go
untested. A variable without one is refused. untested. A variable without one is refused.