Files
docs/modules/uo/API.md
wtclaude 9d50e5cacf docs(engagement): Phase 11b — the seed API, the in-universe voice, the governor's letter
Four decisions settled by the org lead before any 11b code, plus MODULE_API
1.9.0 and what building it found.

DECISION 7 is the one 11a could not have discovered, because 11a seeded nothing.
The plan's own sentence — "24 rules, all enabled = 0; bespoke template bodies" —
described work with NO MECHANISM to land in: templateSeeds.js and coreRules.js
are core files with core arrays, and registries.js has no registerTemplates.
Adds api.registerEngagementSeeds({ templates, ruleGroups }). Templates re-ensure
every boot; rule groups are one-shot per named group, which is 11a's seed-key
finding stated as an API rather than as a warning.

DECISION 8: the game-powered families read in-universe, with a per-family sender
rather than one voice. The crown writes about the crown's business and nothing
else. This is also why the bodies had to be the module's: §5.2 says core stays
ignorant of a module's domain, and check:modules reads identifiers, never prose —
so the boundary is honoured deliberately here rather than leaned on.

DECISION 9: nine bodies stay plain. A failed-login notice written as a letter is
indistinguishable in register from the phishing mail it warns about.

DECISION 10 corrects a fact the plan had wrong by omission. §8.6 records that
uo.points.rank_changed cannot address a person, and the same reasoning was
silently assumed to cover the governor. city.update's `governor` is written by
BridgeJson.Actor(), which emits acct and webId — the new governor is addressable
today, no protocol change. uo.governor.appointed is the 25th trigger.

Also records what building it found: the no-conditionals/empty-string collision
that the presentational fragments exist for, the render sweep that found three
defects no check could see (an optional in a subject line, multi-optional detail
lines, a trailing fragment used as a leading one), the trigger-bound template
that could not reference its own unsubscribe link, and the trigger-id/template-key
grammar mismatch.

docs/modules/uo/API.md gains §5.7 and §5.8.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 01:14:53 -05:00

334 lines
30 KiB
Markdown

# module-uo — its HTTP surface
The **72 URLs** `module-uo` serves, and the audience boundary that gates them. Frozen in the module's
own [`routes.manifest.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/routes.manifest.json)
and documented operation-by-operation in its
[`swagger-fragment.json`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/swagger-fragment.json),
which core merges into `/api/docs.json` while the module is running — so the live Swagger UI is
always the most complete answer.
This page moved out of [`BACKEND_DESIGN.md`](../../website/BACKEND_DESIGN.md) §4 and §6.5 when
Phase 4 closed ([`MODULE_SYSTEM.md`](../../website/MODULE_SYSTEM.md) §2.7.2). Core's API contract
describes core's routes; these are the module's, and core cannot answer them with the module absent.
The tables and the reasoning are unchanged.
**Every URL is byte-identical to the one core served before the extraction** — that is §1.2 of the
module plan, and it is what lets the shipped Android app keep calling
`POST /api/v1/admin/shard/kick` and the Discord bot keep reading `/api/v1/public/shard/*` without
knowing a module answers now.
## 1. The mounts
| Mount | Routes | Tier and gate |
|---|---|---|
| `/api/v1/public/shard` | 19 | Anonymous. **Never site-mode gated** — the shard surface stays readable during maintenance, per feature audience. |
| `/api/v1/public/atlas` | 6 | Anonymous, and unlike `/shard` it **is** site-mode gated: nothing here touches the sidecar, it is parsed shard content. |
| `/api/v1/admin/shard` | 26 | Behind core's `isLoggedIn + noindex + staffOnly` group gate, then **mixed per route** — see below. |
| `/api/v1/admin/uo-link` | 7 | `adminOnly`. The sidecar connection config, its live status, the admin SSE stream and the town crier. |
| `/api/v1/player/shard` | 8 | `requireAuth`, **any role** — staff are a superset of players — and every handler is self-scoped to `req.user.id`. |
| `/api/v1/admin/users/:id/shard/*` | 6 | `adminOnly`. The module's routes hanging off a **core** resource, through core's `admin.users.detail` extension slot: core owns the user, the module owns what it knows about their game accounts. |
**`/admin/shard` is the one mixed prefix**, and it is mixed because it carries three different jobs:
- the **self-service account-linking** routes carry no gate beyond `staffOnly` — they are the same
handlers `/player/shard` serves, reached from the admin surface;
- the **in-game staff operations** (kick, ban, unban, broadcast, pages) carry `modAccess`
(admin + moderator, so editors are excluded);
- **`GET`/`PUT /admin/shard/visibility` are `adminOnly`**, a third tier above `modAccess`, because
they decide what *anonymous* visitors can see (§4). A moderator can ban a player but cannot decide
what the public internet reads.
Every admin write logs to core's one `activity_log`, through `ctx.activity.log`
([`MODULE_API.md`](../../website/MODULE_API.md) §2.7) — an admin action a module performs is not
allowed its own audit trail.
## 2. Public routes worth their own note
The full list is in the manifest and the merged spec. These are the ones that carried a design note
in core's contract before the extraction; the pre-3.0 ingest routes (`/shard/status`, `/feed`,
`/online`, `/economy`, `/houses`, `/idoc`, `/champs`, `/guilds`, `/governors` and the `/stream` SSE
feed) are described where their wire frames are, in
[`link/PLAN.md`](../../link/PLAN.md) §5 and
[`link/INTEGRATION.md`](../../link/INTEGRATION.md).
| Method | Path | Notes |
|---|---|---|
| GET | `/shard/ruleset` | the shard's own published ruleset (Protocol 3.0 `world.ruleset`): expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule. Served from `shard_ruleset`, so it renders while the shard is down; live via `world.ruleset` on `/shard/stream`. Behind `requireFeature('ruleset')`. **`null`** means the shard has never published one — a real answer, distinct from a published ruleset. `caps.skill` / `caps.totalSkill` are in **tenths** (1000 = 100.0). |
| GET | `/shard/points` | every points/loyalty leaderboard the shard publishes (Protocol 3.0 `points.board`) — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, … Served from `shard_points_boards`, so it renders while the shard is down; live via `points.board` on `/shard/stream`. Behind `requireFeature('leaderboards')`, ordered by display name. **`maxPoints: 0` means uncapped** (the common case), and `nameString` is usually `null` with `nameNumber` holding a cliloc — resolve client-side or humanise the `system` key. |
| GET | `/shard/points/:system` | one board by the shard's `PointsType` name (e.g. `QueensLoyalty`); `:system` must match `/^[A-Za-z][A-Za-z0-9_]{0,47}$/` or **400** before any query runs. **404** = the shard has never published that system, which is distinct from a published board nobody has scored in yet (**200** with an empty `top`). |
| GET | `/shard/market?q=&minPrice=&maxPrice=&itemId=&map=&region=&sort=&limit=&offset=` | search the player-vendor marketplace (Protocol 3.0 `vendor.listing`). Returns **listings**, not vendors — "who sells X and for how much" is the question, and a vendor-shaped result would make every caller flatten the shops back out. Served from `shard_vendors` + `shard_vendor_items`, so it renders while the shard is down. Behind `requireFeature('market')` **and rate-limited** — the first genuinely expensive public read on the site (a `LIKE` scan plus a `COUNT` over what is typically the largest `shard_*` table, reachable with no session). `sort ∈ {price_asc, price_desc, recent}`. `q` matches the resolved display name **or** the item's literal name, with `%`/`_` escaped: they are `LIKE` metacharacters, not SQL ones, so parameterization alone would let `?q=%` match every listing on the shard. Every response repeats `staleAt` (the oldest vendor row) because the shard sweeps round-robin — a banner that ages with the results it labels, not one fetched once. |
| GET | `/shard/market/meta` | index size, staleness (`staleAt`/`freshAt`) and which facets and regions actually hold vendors, so a client builds its filters without running a search it will discard. |
| GET | `/shard/market/vendors/:serial` | one shop and its listings; `:serial` must match `/^0x[0-9A-Fa-f]{1,16}$/` or **400** before any query runs. **404** = a serial the index has never seen, which also covers a vendor since dismissed or hidden — to an anonymous caller those are the same answer, and distinguishing them would leak that a hidden vendor exists. `truncated` (with `total` exceeding `count`) means the shop holds more than the shard publishes per frame. |
| GET | `/shard/features` | the shard features **this caller** may reach plus the audience rung they resolved to (§4 below), so a client hides nav it can't follow. Reports only what the caller can see — the list itself never discloses a gated feature. Consumed by the SPA header and (pending) the Android nav. |
| GET | `/atlas/creatures?q=&facet=&limit=&offset=` | the bestiary, most numerous first, with an unpaginated `total`. Static content parsed from the shard's ServUO tree — **not** sidecar-backed, which is why the atlas sits outside `/shard`, and unlike `/shard/*` it **is** site-mode gated. Behind `requireFeature('atlas')`. `?facet=` is matched exactly and never validated against a list (no facet name exists in the code); the filter is an `EXISTS` over the points rather than a JSON path or `JSON_SEARCH` built from caller input, whose `%`/`_` wildcards would make `?facet=%` match everything. |
| GET | `/atlas/creatures/:slug` | one creature: `places` (the point-in-rect aggregate — "lizardman → Shrines, Isamu-Jima, Yew"), `spawners` (the bounded raw list, with `spawnersTruncated`), `alsoHere`. **`points` is a COUNT and `spawners` is the LIST** — named apart so one key never means a number on one route and an array on another. `minDelay`/`maxDelay` are in **seconds**, normalised at parse time from the source's per-record minutes-or-seconds. 404 = no such creature in this atlas. |
| GET | `/atlas/regions?facet=&q=` | named regions and the rectangles that placed each spawner |
| GET | `/atlas/landmarks?facet=&q=` | points of interest, labelled by `group` ("Covetous", not "Level 1") |
| GET | `/atlas/champions?facet=` | the **configured** altar roster. Not `/shard/champs`, which is the live board. |
| GET | `/atlas/meta` | facets, counts and when the atlas was parsed. Game-world facts only — the ServUO path, source hashes and any pending refresh are operator detail and live on the admin route. |
## 3. Admin routes worth their own note
The two content imports — the spawn atlas and the cliloc table — whose behaviour is a decision
rather than a passthrough. The shard-ops routes (`kick`, `ban`, `unban`, `broadcast`, `pages`), the
account-linking routes and the sidecar config under `/admin/uo-link` are in the merged spec.
| Method | Path | Purpose |
|---|---|---|
| GET | `/shard/atlas` | spawn-atlas status (`adminOnly`): the ServUO path, whether the tree is readable, whether it has drifted from what is loaded, counts, facets, and any refresh staged for review. The public `/atlas/meta` reports the game world only; the filesystem detail is here. |
| POST | `/shard/atlas/import` | re-import without restarting; `{force}` ignores the hash gate. **An unreadable tree answers 200 with `status:"unavailable"`, not 500**`refresh()` reports outcomes rather than throwing (the boot path must never be blocked by a bad tree) and that contract is preserved at the API. |
| POST | `/shard/atlas/approve` · `/shard/atlas/reject` | answer a refresh staged because it would REMOVE a facet. Approving **re-parses** the tree, so what lands matches it at approval time; rejecting is remembered against those source hashes so it does not re-prompt every restart. 404 when nothing is staged. |
| PUT | `/shard/atlas/path` | point the atlas at a different tree (persisted as `spawn_atlas_servuo_path`, which wins over `SERVUO_PATH`). Blank clears it. Deliberately **does not import** — moving the mount and reloading the world are separate decisions — and returns fresh status so the panel can offer the import next. |
| GET | `/shard/clilocs` | cliloc-table status (`adminOnly`): every source found now (base first, then `custom/` overlays in merge order), what each contributed at the last import, readability, drift across the set, the entry count, and `missingSources`. `configured:false` is a supported state — item names then render as ids. No public counterpart: the table is never served *as* a table. |
| POST | `/shard/clilocs/import` | reload after a client patch or an overlay edit; `{force}` ignores the hash gate, `{approve}` accepts a **vanished** source (refused by default — see the table notes above). **A missing path — or the likely mistake of pointing at the client's own COMPRESSED `Cliloc.enu` — answers 200 with `status:"unavailable"` and a `code`, not 500.** `COMPRESSED` is called out by name: a 500 would say only "something broke", and the operator needs to be told which file to convert. |
| PUT | `/shard/clilocs/path` | point the site at a different cliloc base file or directory (persisted as `cliloc_client_path`, which wins over `UO_CLIENT_PATH`). Overlays are read from `custom/` beside it either way. Blank clears it. Deliberately **does not import**, same reasoning as the atlas path. |
## 4. Shard visibility — the audience boundary (Protocol 3.0)
Every shard-derived surface is gated by an **admin-configurable, per-feature and per-field** audience
setting. This **replaces** the static `PUBLIC_KINDS` allowlist that used to be the whole boundary.
Policy lives in `utils/shardVisibility.js`; rows live in `shard_feature_visibility`; the admin surface
is `GET`/`PUT /admin/shard/visibility` (`adminOnly`). Admin-facing guide:
[`SHARD_VISIBILITY.md`](../../website/SHARD_VISIBILITY.md). Design: [`link/v3.md`](../../link/v3.md) §3.
**The ladder.** `anonymous < logged_in < player < staff < admin`, each rung implying the ones below.
`viewerLevel(req)` resolves it: no session ⇒ `anonymous`; authenticated ⇒ `logged_in`; authenticated
with a linked game account ⇒ `player`; moderator ⇒ `staff`; admin ⇒ `admin`. **Staff satisfy the
`player` rung without a linked account** (consistent with `/player/*` being role-agnostic).
**`editor` gets no shard privilege** — it is a content role, and mapping it to `staff` would silently
widen what editors see.
**Two invariants that are code, not configuration.** Both are enforced server-side and both reject
rather than silently ignore:
1. **`acct` and `webId` are admin-only, always.** They are not exposed as configurable fields, and a
stored row attempting to loosen them is discarded on read as well as rejected on write. A character
name is visible in game; the account behind it and the website user it links to are not.
The lock is on the field's **meaning, not one spelling**: `isLockedField(key)` matches a key that
*is* or *ends in* `acct`/`webId`, case-insensitively, so the flattened forms the read models emit
(`shapeHouse``ownerAcct`, `shapeGuild``leaderWebId`) are covered too. An exact-key check was
the original implementation and it let `GET /public/shard/idoc` serve `ownerAcct` anonymously.
2. **A kind absent from `KIND_FEATURE` is never broadcast below `admin`.** Fail closed. This is what
keeps the kind map a security boundary rather than a convenience filter, and it means a shard that
starts emitting an unknown event degrades to staff-only, never to public.
**Fail-closed everywhere else too.** An unreadable visibility config withholds every public frame; a
DB failure falls back to the compiled defaults (pre-3.0 behavior), not to open; an unresolvable viewer
subscribes as `anonymous`. The ladder comparison uses **asymmetric** fallbacks by design — an unknown
*viewer* level floors to the bottom rung and an unknown *requirement* ceils to admin, so an
unrecognised value loses on both sides. (A single shared fallback cannot do that: whichever direction
it picks, it fails open on one side.)
**Three enforcement points, one config:**
| Where | Mechanism |
|---|---|
| Routes | `requireFeature(name)`**404** when the feature is disabled (don't leak that it exists), **403** when the caller is below its audience. `projectFeature` then strips out-of-rung fields from the body. |
| SSE (`utils/shardBroadcast.js`) | Per-connection filtering. A subscriber's rung is resolved **once at subscribe time and frozen** for that connection, so a long-lived stream can't gain privilege; each frame is then mapped kind→feature, gated, and field-projected per viewer. Two subscribers can legitimately receive different versions of one event, or one of them nothing. |
| Nav | `GET /public/shard/features` returns only what the caller may reach, so the SPA never renders a link that would 403. Presentation only. |
Config reads are cached ~5s, so admin changes take effect within seconds **including on already-open
streams**. `PUBLIC_KINDS` still exists and is still exported (`utils/shardBroadcast.js`) but is now
**derived** from the kind map rather than hand-maintained, so the two cannot drift.
**`PUBLIC_KINDS` is a module-load constant and must not be used to answer "may this caller read this
kind?"** — it is computed from the compiled *defaults*, so it cannot see an admin's changes. Use
`visibleKinds(level, config)`, which resolves against the live config. `/feed` uses it; it originally
used `PUBLIC_KINDS` and consequently kept serving `guild.join` to anonymous callers after an admin had
moved `guilds` to `staff`. `visibleKinds` deliberately ignores the `stream` flag: that governs SSE
fan-out only, so a feature whose live firehose ships off (market) stays readable from stored history.
**Every read path that returns shard data must call `projectFeature`.** The stored-history endpoints
are not exempt — `/feed` returns the same events the stream does, and returning them unprojected
reopens on the REST side exactly what the stream closes. Relatedly, `shardEvents.db.list` treats an
**empty** `kinds` array as "serve nothing", never "no filter"; the fall-through it used to take would
have turned a fully-gated config into a dump of the entire event log.
`projectFeature` walks **arrays and plain objects only**. A `Date`, `Buffer` or other class instance
is passed through as a value — rebuilding one key-by-key yields `{}`, which is the difference between
the pure-JSON wire frames and the DB-backed read models whose rows carry real `Date` columns.
**Defaults reproduce pre-3.0 behavior exactly**, so installing the framework is a no-op until an admin
changes something — with deliberate exceptions, which are the leaks it was written to close.
`/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 **25 event triggers** and
**3 audiences** through `api.registerEventTriggers` / `api.registerAudiences`, and ships
**32 message bodies and 25 rules** through `api.registerEngagementSeeds`
([`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`, `uo.governor.appointed` | Each is about one person's own property, account, character or office. All eleven resolve through an account on the frame 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.
### 5.7 The shipped bodies (Phase 11b)
Declaring a trigger says what an event IS. It says nothing about what the message reads like, and
until Phase 11b there was no way for a module to say: `templateSeeds.js` and `coreRules.js` are core
files with core arrays in them. `api.registerEngagementSeeds({ templates, ruleGroups })`
([`MODULE_API.md`](../../website/MODULE_API.md) §1.1, **1.9.0**) is the mechanism; this module is its
first caller, with **32 bodies and 25 rules** in
[`server/config/engagementSeeds.js`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/server/config/engagementSeeds.js).
**Sixteen families read from inside Britannia, with a per-family sender.** The org lead's decision
(ENGAGEMENT.md Phase 11b, decision 8) was a sender per family rather than one voice across all of
them: a shard where Lord Blackthorn writes to you personally about a champion spawn is a shard where
the letter about your governorship means nothing.
| Sender | Families |
| --- | --- |
| **Lord Blackthorn's court** | the governorship, the elections — the crown's business and nothing else |
| **the Office of Deeds** | houses |
| **the Merchants' Guild** | vendors |
| **a guild herald** | guild departures and dissolutions |
| **the town crier** | champion spawns |
| **a guildmaster** | skills, quests |
| **the Chronicler of the Dead** | deaths and murders |
| **the keeper of the rolls** | leaderboards |
Each ships an `email` body and an `inapp` body **in the same voice** — one rule fires on both at once,
and a player who reads the inbox item and then the mail must not meet two different narrators. The
**digest** stays core's generic `notify.digest`: a day of events rolled into a list is not a letter
from anybody.
**Nine stay plain, and the line is where fiction costs something real** (decision 9). Both
account-security triggers, `uo.server.up`/`down`, and the five staff- and admin-ceilinged ones point
at core's `notify.event` / `inapp.event` and author nothing — which is also §4.6.1 property 1 being
exercised at scale. A failed-login notice written as *"a stranger sought entry to thy account"* is
indistinguishable in register from the phishing mail it warns about, and a moderator reading
`uo.cheat.detected` at two in the morning wants a name, a rule and a timestamp rather than a scroll.
**An operator whose shard is not Blackthorn's Britannia edits these rows.** The template editor is
where, and `customized = 1` then protects the edit from every later seed — the bodies are defaults,
not fixtures.
Two mechanical notes that will bite whoever adds the twenty-sixth trigger:
- **All 25 rules are in ONE seed group, `triggers-v1`, and a group is seeded once.** A rule appended
to it later reaches **fresh installs only** — never a deployment already stamped. A rule that must
reach existing deployments takes a new group key.
- **A trigger id and a template key have different grammars.** `uo.champ.boss_up` is a legal trigger
id and an illegal template key (core's key pattern admits `.` and `-`, not `_`), so its body is
keyed `uo.champ.boss-up`. Registration refuses the mistake at boot.
### 5.8 The presentational fragments, and why they exist
A template has **no conditionals**, by design, and an unset optional interpolates to the **empty
string**. That is right for a structural body and wrong for a sentence: *"Be it known that ,
recorded to thy name, is this day found ."* So the ternary stays in `shardEngagement.js` and its
result arrives as a declared optional — Phase 5a's `forWhom` precedent. Two shapes, and the `example`
on each declaration shows which it is:
- a **LABEL** always has a value and carries a sentence's spine. `houseLabel` is the name and region,
falling back to the seal number, because a warning has to name something the owner can act on.
- a **TRAILING FRAGMENT** may be empty and leads with **its own space**, so `{{slainBy}}.` closes as
*"has fallen."* either way.
They are declared `required: false` deliberately: a required variable missing **refuses the emit**,
and a dropped notification is worse than a cosmetic hole. Nothing at runtime therefore notices a
mapper that forgot one, so `server/test/engagementSeeds.test.js` asserts every label is supplied on
every path that emits its trigger.
A whole **detail line** works the same way one level up (`ledgerLine`, `whereLine`): four optional
numbers assembled into a sentence by the mapper, and absent entirely when the frame carried none of
them — the same argument `place()` makes for coordinates. A pre-v5 vendor frame otherwise renders
*"On hand: gold. Charged each period: gold."*