Files
docs/modules/uo/API.md
wtclaude a4b63d87d2 docs(link): the hue belongs where the files are, and the cache poisons it (Phase 5)
§11.1 is new and carries what phase 5 measured: 49,152 addressable static ids
(not the 81,884 `artidx.mul` declares -- `FileIndex` sizes its table from its
length ARGUMENT), 39,189 with art, 4,244 land tiles, 9,963 + 12,140 empty index
slots, and the whole set at 81 MB decoding in 34 s. That last number reopens the
bulk question and the answer is still no: 108 MB of base64 through a 512 KB
single-slot channel to store 43,433 pictures a shard displays a few hundred of.

Two traps, both §4.5's failure mode -- a confident, plausible, wrong picture:

- `Art.GetStatic` hands back the SAME cached Bitmap and `Hue.ApplyTo` repaints in
  place, so hueing edits the library's own copy: the plain key comes back hued
  from then on, and the next hue stacks. `Files.CacheData` off process-wide fixes
  it and also stops a game server retaining 74 MB of Bitmap. Copying instead does
  not solve the retention, and `new Bitmap(src)` throws on ARGB1555 anyway.

- `PartialHue` (13,259 of 65,536 ids) decides whether a hue repaints every pixel
  or only the grey ones, from a file only the shard has. Item 597 is a wooden
  screen with painted flowers; one mode reddens the flowers, the other the whole
  screen, and both decode. Hence land takes no hue segment and `h0` is not a key.

Plus the namespace trap that compiled: unqualified `TileData` binds to ServUO's
own `Server.TileData`, because the enclosing namespace beats `using Ultima;`.

§14 records what the wire gained -- the `static` and `land` families, `families`
on `assets.sources`, and `assets.fetch` becoming shared plumbing whose family is
DERIVED from the keys (§5 made the key the address; a request naming its family
too would have two places to be wrong and one of them silent). Additive, so the
protocol stays 8 and EXTRACTOR_VERSION stays 2. §15 records that `link` needed
nothing in phases 4 or 5: it forwards verbatim in both directions.

§17.10 is the four org-lead decisions. §12 and modules/uo/SCHEMA.md carry the
website side: `uploads/items/`, per-row `catalog` staleness, and why a key with
no art writes no row at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-11 06:16:15 -05:00

35 KiB

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 and documented operation-by-operation in its 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 §4 and §6.5 when Phase 4 closed (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 28 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 §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 §5 and 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. As of Protocol 8 phase 5 each listing also carries art: the FILENAME of the item's picture under uploads/items/, already hued, or null where this site does not hold one. null is ordinary — the picture is fetched behind the page and never by it, so a newly listed item shows text first and gains its icon a pass later, and some item ids have no art in any client. The same field appears on a character sheet's equipment entries.
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 500refresh() 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.
GET /shard/assets client-asset import status (adminOnly, Protocol 8): the imported body catalogue, how many sprites are on disk, how many atlas creatures resolved to a body id, and the shard's own client files beside them. drift:true means the client was patched. shard.imaging.ok:false is the named NO_IMAGING state — a Linux shard host with no libgdiplus cannot decode a sprite at all, and the reason names the package. No public counterpart: the pictures are served as ordinary files under /uploads.
POST /shard/assets/import import creature artwork from the shard's UO client; {force} ignores the hash gate, {approve} accepts a catalogue that no longer offers assets this site holds (refused by default — an unmounted client volume and a deliberate downgrade are indistinguishable, and the wrong guess deletes artwork). This is the ONLY thing that imports — boot deliberately never calls the shard. An operator's spawnAtlas.art.json always wins over an imported sprite. A body this client has no art for is not a failure: about half the addressable body range is in that state on a stock client.
POST /shard/assets/warm fetch item and land pictures this site is missing, now, instead of waiting for the warm timer (adminOnly, Protocol 8 phase 5). The pass works out which item pictures the site's own rows name — every distinct (ItemID, hue) on a player vendor, plus anything a character sheet has shown since the last pass — and fetches the ones it does not hold, hued on the shard, into uploads/items/. There is deliberately no manifest and no bulk import: the client addresses 49,152 item graphics times 3,000 hues, so the working set is what the site displays. {force} re-fetches pictures already held (how an operator recovers a wiped uploads volume); {limit} bounds one pass, default 400, because the shard serves one asset request at a time. A plugin overlay older than phase 5 answers status:"unavailable", code:"UNSUPPORTED" with a sentence naming the fix.
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. Design: 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 (shapeHouseownerAcct, shapeGuildleaderWebId) 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 26 event triggers and 3 audiences through api.registerEventTriggers / api.registerAudiences, and ships 34 message bodies and 26 rules through api.registerEngagementSeeds (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; 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.refreshed, 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 twelve 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 §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). 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 §1.1, 1.9.0) is the mechanism; this module is its first caller, with 34 bodies and 26 rules in 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 26 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.7a uo.house.refreshed — the trigger that exists to cancel one

Added by the live walk (ENGAGEMENT.md Phase 11b, decision 11), and it is the only trigger in this module whose primary job is not to say something.

uo.house.idoc_warning's seeded rule carries delay_seconds: 900 so that a player who repairs the house inside the quarter-hour is never told it is in peril. That is only true if something CANCELS the pending row, and until this trigger existed nothing could: cancel_on named uo.house.collapsed — the outcome where the warning is pointless — and the mapper returned early on every transition that was not a late decay stage, so a refresh reached the engine as silence. The wire had carried the transition all along.

house.decay  Greatly -> Ageless        (the owner logged back in)
   -> uo.house.refreshed               (owner-audienced, subject = the house serial)
      -> cancels every scheduled engagement_outbox row for
         (the warning's rule, that house, that owner)
      -> and, if the operator enabled its own rule, sends the Office of Deeds'
         one non-warning letter

Three things about it are load-bearing:

  • Its subjectKey is houseSerial, the same as the warning's. outboxDb.cancel matches on (rule, subject_key), so a refresh carrying any other subject would cancel nothing at all.
  • It fires on Ageless as well as LikeNew, and Ageless is the common case. A condemned house cannot be refreshed — BaseHouse.RefreshDecay() refuses DecayType.Condemned outright — so the rescue is the owner logging in. Their newest house then becomes AutoRefresh and reads Ageless; an older one becomes ManualRefresh and reads LikeNew. Reading only the second misses most rescues.
  • The cancellation does not depend on its own rule. cancel_on is read off the WARNING's rule, so an operator who wants the cancellation without the reassurance letter simply leaves the new rule disabled — which, every seeded rule shipping disabled, is what a fresh install already does.

A notification's call-to-action is a path into this module's own SPA routes, and there is exactly one place that knows them. The live walk found every one of them wrong, in two independent ways:

  • the declared examples read /shard/…, taken from module.json's mounts — but registry.registerRoutes prefixes a module's client routes with its ID and nothing else, so the real paths are /uo/houses, /uo/market, /player/uo/characters. Every example was a 404, and an example is what the template editor previews and test-sends with;
  • and no url variable was ever populated by the mapper, so the buttons rendered with an empty href and dropped out of the text part entirely.

clientPaths.js is now the single source for both the declarations and the bodies. client/src/entry.jsx's own registerNav is the cross-check: the hrefs it hands the sidebar are these, and if the two ever disagree the sidebar is right.

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."