diff --git a/modules/uo/API.md b/modules/uo/API.md index af69c1a..6067a3a 100644 --- a/modules/uo/API.md +++ b/modules/uo/API.md @@ -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. diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 8d905e6..25b72bc 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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 diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 38646c7..c333830 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -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` | `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`, 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` — **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.