docs: the engagement workstream — cutover 1 of 7 (edge → main)
#200
@@ -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
|
||||
stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` returned the flattened
|
||||
`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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
|
||||
create/publish-post path for `news.post`. The catalog is assembled at boot by
|
||||
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and — from
|
||||
**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
|
||||
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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
```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
|
||||
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.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
|
||||
and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves.
|
||||
`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.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) => void`, fire-and-forget | `utils/engagementEmit` | `module-uo`'s `utils/shardEngagement.js`, off the shard feed (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
|
||||
`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.
|
||||
- **`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.
|
||||
would silently widen a staff-only event. The seven values are `everyone`, `authenticated`,
|
||||
`subscribers`, `members`, `staff`, **`admin`** (1.8.0, a child of `staff`) and `owner`, ordered by
|
||||
**containment and not by size** — a `staff` ceiling does NOT permit `owner`, because fewer people is
|
||||
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
|
||||
test-sending a template possible without a live game event, which is the reason template systems go
|
||||
untested. A variable without one is refused.
|
||||
|
||||
Reference in New Issue
Block a user