docs: the engagement workstream — cutover 1 of 7 (edge → main) #200

Merged
whitlocktech merged 50 commits from edge into main 2026-09-01 08:54:15 -05:00
13 changed files with 7755 additions and 1396 deletions

View File

@@ -25,10 +25,12 @@ sidecar as a service, and hands you the values the website needs.
| [BACKEND_DESIGN.md](website/BACKEND_DESIGN.md) | API contract, DB schema, security model |
| [ARCHITECTURE.md](website/ARCHITECTURE.md) | The system diagram — how core, an installed module, the sidecar and the clients fit together |
| [TEAMS.md](website/TEAMS.md) | Teams as a platform primitive: roster, forums, notifications, Discord slash commands and voice — design of record |
| [ENGAGEMENT.md](website/ENGAGEMENT.md) | The engagement system: module-declared event triggers, rules, cooldowns, templates and the email / push / in-app delivery channels — design of record |
| [HERO_EDITOR.md](website/HERO_EDITOR.md) | Hero canvas editor feature spec |
| [THEMING_AND_NAV.md](website/THEMING_AND_NAV.md) | Admin-configurable theme, brand assets and navigation — build contract |
| [MODULE_SYSTEM.md](website/MODULE_SYSTEM.md) | Making the site game-agnostic: game logic becomes an installable module — design of record |
| [MODULE_API.md](website/MODULE_API.md) | The module ↔ core contract: `ctx`, the `register*` calls, the client registry and the loader's obligations |
| [UPGRADE_NOTES.md](website/UPGRADE_NOTES.md) | **Operator-facing, newest first** — the upgrades that need an operator to do something, or that change behaviour quietly enough to be discovered by accident |
| [WIKI_UPGRADE.md](website/WIKI_UPGRADE.md) | Wiki subsystem upgrade notes |
| [SHARD_VISIBILITY.md](website/SHARD_VISIBILITY.md) | Who sees which shard data — the admin-configurable audience framework |
| [TRUSTED_DEVICES_MFA.md](website/TRUSTED_DEVICES_MFA.md) | TOTP two-factor, trusted devices and recovery codes |
@@ -62,7 +64,8 @@ particular game; a module is what makes it a site *for* one.
| [INTEGRATION.md](link/INTEGRATION.md) | How the website integrates with the uo-link sidecar |
| [PROTOCOL_2.md](link/PROTOCOL_2.md) | Protocol 2.0 / 2.1 design |
| [v3.md](link/v3.md) | Protocol 3.0 design — shard content/standings streams + the visibility framework |
| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`). **The current protocol** |
| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`) |
| [v5.md](link/v5.md) | Protocol 5 — three enrichments in one bump: `house.decay`'s decay schedule, `vendor.listing`'s fee state, and `account.login.result`. **The current protocol**; built on `edge`, not yet released |
| [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) |
| [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge |
| [PLAN.md](link/PLAN.md) | uo-link build plan |

View File

@@ -659,7 +659,7 @@ not rank).
| **Rules / Leaderboards / Market** | everyone, *if the shard publishes them* | `/public/shard/{ruleset,points,market}` (M11) |
| **Atlas** (bestiary) | everyone, *if the shard publishes it* | `/public/atlas/*` (M11) |
| Contact | everyone | `/public/contact` |
| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) |
| **My Account** | signed-in | `/auth/me/account/*` — one surface for every role (see §6.4) |
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
| Sign in / Sign out | toggles on session | `/auth/mobile/*` |
@@ -707,8 +707,10 @@ Guidelines:
and `GET /public/atlas/*` are the new reads. Full contract and traps in §9 M11.
### 6.3 Player self-service & game data (bearer)
- **Account** — `GET /player/account`; `PATCH /player/account/username`;
`PATCH /player/account/password`; TOTP `setup`/`enable`/`disable`; identities `GET` / `DELETE`.
- **Account** — `GET /auth/me/account`; `PATCH /auth/me/account/username`;
`PATCH /auth/me/account/password`; TOTP `setup`/`enable`/`disable`; identities `GET` / `DELETE`.
Role-agnostic — see §6.4. (These were `/player/account/*` when this section was written; that
surface was deleted on 2026-08-29. The app was already calling `/auth/me/*` and needed no change.)
- **Game account linking** — `POST /player/shard/link` (one-time `[link` code),
`POST /player/shard/account` (hybrid signup, when enabled), `GET /player/shard/accounts`.
- **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`,
@@ -724,13 +726,16 @@ Guidelines:
art/asset work on the platform side) and is explicitly out of the first release.
### 6.4 Self-service is role-agnostic under `/auth/**` (decided)
Player self-service is under `/player/account/*` (gated to `role='player'`) and staff use the *same*
handlers under `/admin/account/*`. Rather than have the app branch by role (and touch `/admin`), we
**add a role-agnostic self surface under `/auth/**`** — the canonical "me" endpoints for every role.
The app calls these regardless of role. This is an **additive v1**
change (see §8): the existing `/player/account/*` and `/admin/account/*` routes stay for web
back-compat; `/auth/me/*` reuses the same `account.controller` handlers behind `requireAuth` (any
authenticated role), so there's no logic duplication.
Self-service account security lives under `/auth/me/account/*` — the canonical "me" endpoints for
every role, behind `requireAuth` (any authenticated role). The app calls these regardless of role and
never touches `/admin`.
> **Update (2026-08-29).** When this was decided it was an *additive* change: `/player/account/*`
> (gated to `role='player'`) and `/admin/account/*` served the same `account.controller` handlers and
> stayed for web back-compat. **Those 14 routes have since been deleted** — `/auth/me/account` was
> already a strict superset of both, so the web client moved onto it and `/auth/me/*` is now the only
> self-service surface. See `website/ENGAGEMENT.md` Phase 1a. **The app required no change**: `MeApi.kt`
> was already 100% `/auth/me/account/*`.
> **M10 update (2026-07-21):** *self-service* stays role-agnostic under `/auth/me/*` as above. Separately,
> the **operational** admin subset (§1, §10 — moderation, support queue, dashboard/site-mode, content)
@@ -754,6 +759,18 @@ Mirrors the website's "degrade gracefully" invariant:
loading/error/retry states; it does **not** ship a Room cache in v1. Cached read-only content can be
added later without reworking the repository layer (its typed results already isolate the UI from the
data source). No `Room` dependency in the initial build.
- **Amended 2026-08-31 (engagement Phase 8): one named exception, and still no Room.** The in-app
inbox keeps an offline snapshot - `core/inbox/InboxCache`, one JSON blob in the DataStore the push
code already uses, capped at the server's own default page size. An inbox is a short, read-only,
newest-first list with a server-side cursor and no joins, so what "works offline" needs is the
newest page and the badge, not a database. **Every snapshot is scoped to (base URL, user id)** and
handed back only to that pair, which is what stops one account's notifications surfacing under
another's session on the teardown paths that never reach a logout (a dead refresh token, a server
switch); the clear-on-logout beside the push deregistration is the tidy-up, not the safeguard.
**The known limit: this serves a running app, not a cold start.** `MainActivity` gates the whole of
`RunicApp` on loading the site's appearance, so an offline launch still shows the shell's "Can't
reach the site / Retry" and never reaches the drawer. Changing that is a change to the shell's
startup model, and it was left for the org lead rather than widened into Phase 8.
---
@@ -784,8 +801,9 @@ maintenance cost. Reserve v2 for a real breaking re-shape if one ever arises.
1. **Role-agnostic self-service under `/auth/**` (§6.4, decided).**
✅ **DONE (2026-07-19, RunicGateway/website#76 (+ this docs PR)).** A `me.routes.js`
sub-router mounts the existing `account.controller` self handlers behind `requireAuth` (any role) at
`/auth/me/*`, so the app has one self surface and never touches `/admin`. The old
`/player/account/*` + `/admin/account/*` routes stay for web back-compat. Shipped routes:
`/auth/me/*`, so the app has one self surface and never touches `/admin`. (The old
`/player/account/*` + `/admin/account/*` routes stayed for web back-compat at the time; they were
deleted on 2026-08-29 — see §6.4.) Shipped routes:
- `GET /auth/me` — current `{ id, username, role }` (already existed; the app's role source).
- `GET /auth/me/account` — full self account.
- `PATCH /auth/me/account/username`, `PATCH /auth/me/account/password`.
@@ -1244,6 +1262,106 @@ The ntfy relay is treated as **untrusted infrastructure**, and the design makes
> `fix/notifications-empty-subscriptions`). The same trap applies to any "replace the full set"
> `PUT`/`POST` whose empty value equals a DTO default — prefer no default on required request fields.
### Per-channel preferences — the superset endpoint (engagement phase 3, 2026-08-29)
Push is no longer the only channel a preference can name. `docs/website/ENGAGEMENT.md` phase 3 added
`notification_channel_prefs` and, with it, `GET · PUT /auth/me/notifications/channels`.
**Nothing above changed.** `/notifications/streams` and `/notifications/subscriptions` keep their
exact wire shapes, including the `{"streams":[]}` gotcha, and the shipped APK needs no update to keep
working — `notification_subscriptions` is now the **push projection** of the new table, and every
write to either fans out to the other. That was the acceptance criterion the phase was built against,
with the empty-array case tested explicitly.
**What the new endpoint adds, for whenever the app adopts it:**
```jsonc
// GET /auth/me/notifications/channels
{
"channels": [ // the delivery-channel registry
{ "id": "push", "label": "Push", "carriesContent": false,
"defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] },
{ "id": "email", "label": "Email", "carriesContent": true,
"defaultMode": "off", "supportsDigest": true, "modes": ["off", "instant", "digest"] },
{ "id": "inapp", "label": "On the site", "carriesContent": true,
"defaultMode": "off", "supportsDigest": false, "modes": ["off", "instant"] }
],
"items": [ // every subscribable id, streams AND triggers
{ "id": "news.post", "label": "News posts", "description": "…",
"personal": false, "requiresLinkedAccount": false, "ceiling": "authenticated",
"channels": ["push", "email", "inapp"],
"modes": { "push": "instant", "email": "off", "inapp": "off" } }
]
}
```
Four properties the UI should be built on rather than around:
- **`items` is the union of streams and triggers**, one namespace. An id can be a push stream, an
event trigger with a payload contract, or both. A trigger-only id (`uo.house.idoc_warning`) carries
no `push` in its `channels` and no `push` key in `modes` — there is nothing registered to push it —
so **render the toggles from `channels`, never from a hardcoded three**.
- **`modes` is the *effective* mode, not the stored one.** Where the user has expressed nothing, the
server substitutes that channel's `defaultMode`. The client never has to know which it is looking
at, and must not re-implement the defaulting.
- **The PUT is sparse, and this is the one place it diverges from every other `/auth/me` PUT.** Send
only the pairs you changed: `{"prefs":[{"id":"news.post","channel":"email","mode":"digest"}]}`.
Everything not named is left alone, so the notifications screen can save one toggle without holding
the whole table. `off` is a mode, never an omission — **so the empty-array gotcha above does not
apply here at all**: there is no "clearing the last one" case, because turning something off is a
row like any other. `prefs` is still required, so a DTO field with no default is still the right
shape.
- **Entries the server cannot accept are dropped, not refused** — an unknown id, a channel that does
not apply to that id, a `digest` on a channel that cannot batch. The response is the full stored
state, so re-render from it rather than assuming the request took.
**One id may be missing from `items` that the app expects.** A trigger whose declared audience
`ceiling` is `staff` is not offered to a non-staff caller — it can never reach them, and listing it
would disclose that the event exists. `GET /notifications/streams` is unfiltered and unchanged.
### The inbox and the per-channel screen - as built (engagement Phase 8, 2026-08-31)
**The drawer's "Notifications" is the INBOX now**, and the preferences are one tap away behind its
gear - the arrangement Phase 7 shipped on the web (`/account/notifications` is the content,
`.../settings` the preferences), and what a person means when they tap the word. `Routes.NOTIFICATIONS`
is unchanged and `Routes.NOTIFICATIONS_SETTINGS` is new, so an admin's nav override pointing at the
old route still lands somewhere sensible.
**The inbox** (`ui/notifications/InboxScreen` + `InboxViewModel`) reads the four routes Phase 7
shipped: a keyset page on `before` (never an offset - the list gains rows at the top while it is being
read), the unread count that rides along on every page, and the two mark-read writes. Reads are
optimistic and deliberately not rolled back on failure; a local read also rewrites the snapshot, or
going offline right after reading everything would bring the badge back on the next cold open. The
drawer badge has its own view model on `/notifications/unread-count`, refreshed on resume rather than
on a timer - the tickle is what says "something happened", so polling would be a second, worse copy
of push.
**Two things the app has to do that the backend contract does not state:**
- **Resolve the item's `url`.** Phase 7 specifies it is **relative-only** (`/guilds/.../forum/403`),
which is right for a browser already on the site and a dead link on a phone.
`InboxViewModel.linkFor` resolves it against the configured base with OkHttp's `HttpUrl.resolve`,
which absolutises the path *and* returns null for anything that would not end up http(s) - so a
`javascript:` or `intent:` url in a notification body opens nothing. The live rig is what caught
this: the first cut only opened `http(s)`-prefixed strings, so every link in the inbox did nothing
at all.
- **Route the tickle on its `ref`, not its stream.** An engagement rule's tickle carries the TRIGGER
id as `stream` (ENGAGEMENT.md section 7.2's one namespace) and `PushStreams` knows only the eight
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint
that a row exists, and the contract stays wake-and-pull.
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:
one row per subscribable id, a control per channel in **that item's** `channels`, and its shape from
**that channel's** `modes` - a switch for two modes, chips for three, so email's `digest` reaches the
app and a fourth channel would too, without a release. A trigger-only id shows no push control rather
than a dead switch, and on a shard with no push relay the push controls are absent with the reason in
a note beside the list (email and on-site preferences are still worth setting there). Each change is
one sparse PUT of one pair, and the screen re-renders from the response, so an entry the server drops
shows up as the control springing back.
## 12. Build & CI (Gitea Actions)
Builds run on the org's existing self-hosted runners (`runs-on: ubuntu-latest`, same label the other

View File

@@ -55,14 +55,14 @@ That is the same set of values Admin → Shard asks for — base URL and WS URL
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
The current version is **4**, shipped in sidecar **v2.0.0** and overlay **v1.0.0**.
The current version is **5**. It is built but **not yet released** — the last shipped pairing is protocol 4, in sidecar **v2.0.0** and overlay **v1.0.0**.
- Every response carries an **`X-UOLink-Version: 4`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 4`.
- **Optionally**, send `X-UOLink-Version: 4` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
- Every response carries an **`X-UOLink-Version: 5`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`.
- **Optionally**, send `X-UOLink-Version: 5` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
```json
{ "error": "protocol version mismatch", "sidecar_protocol": 4, "client_protocol": "3" }
{ "error": "protocol version mismatch", "sidecar_protocol": 5, "client_protocol": "4" }
```
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
@@ -82,6 +82,21 @@ the wire. Additive in the same shape as the previous two bumps — nothing that
so a v3 consumer that ignores the new kinds and the new key keeps working against a v4 sidecar, once
it declares `4`.
**v5 (Protocol 5)** adds three things at once ([`v5.md`](v5.md)) — `house.decay` gains `ownerName`
and a `schedule`, `vendor.listing` gains `ownerAcct` and a `fees` block, and `account.login.result`
is a new kind carrying the verdict its long-standing `account.login.attempt` companion fires too
early to know. Three at once because a bump costs a release, a bundle and an operator update on
every shard, so a field left out costs a whole second round of that.
Additive again: no existing field changed shape, and **no new endpoint** — every v5 addition rides
kinds that already existed or a kind that behaves like any other on the feed. Two consumer notes,
both about ABSENCE rather than presence, because both are easy to read as an error:
- `schedule.estimatedCollapse` is **omitted whenever it is not exactly knowable** — which, on a
dynamic-decay shard, is every stage before IDOC.
- `fees` is omitted entirely by a pre-v5 overlay, and reduces to `{"exempt": true}` for a
commission vendor. Neither means "this vendor has no money".
**Upgrading a pinned client.** Every bump is an operator-visible hard break in one direction only: a
client still declaring the old number gets a 409 on every protected route and, on the WebSocket, a
closed connection on the `ws.hello` mismatch. So update the pinned version at the same time you
@@ -168,7 +183,8 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
|------|--------|
| `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
| `mob.logout` | `who` |
| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) |
| `account.login.attempt` | `acct`, `ip` — an authentication attempt, fired from a sink that runs **before** the auth decision, so it fires on successful logins too. Use `account.login.result` for the verdict. No password ever leaves the shard |
| `account.login.result` | `acct`, `ip`, `accepted`, `reason` — **Protocol 5.** The verdict of the attempt above, which the attempt structurally cannot carry. `reason` is an `ALRReason` (`BadPass`, `Invalid`, `Blocked`, `InUse`, `BadComm`) and is **present only when `accepted` is false**, because the enum's zero value would read as a failure reason on an accept. Build "someone tried to get into your account" on THIS kind |
#### Economy & commerce
| kind | fields | notes |
@@ -204,15 +220,36 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
#### Housing / IDOC
| kind | fields |
|------|--------|
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerName`*, `ownerAcct`, `schedule:{...}`*, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
`from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign.
\* **Protocol 5.** `ownerName` is the owner's character name (`ownerAcct` is the game account, and
the only one of the two that identifies a person). `schedule` is a nested object:
| field | meaning |
|---|---|
| `dynamicDecay` | whether this shard runs ServUO's dynamic decay (`Core.ML`). Always present |
| `nextStage` | ISO-8601 UTC: when the house leaves its current stage. Absent under static decay, which keeps no stage clock |
| `decayPeriodSec` | seconds from a full refresh to collapse; lets a reader turn `lastRefreshed` into a percentage |
| `estimatedCollapse` | ISO-8601 UTC — **present only when it is exact**, see below |
**`estimatedCollapse` is absent far more often than not, and that is deliberate.** Under dynamic
decay ServUO draws each stage's duration at *random* when the stage is entered, so collapse is
exactly knowable only once the house is already at `IDOC` — at which point the next transition is
the collapse. Under static decay it is a pure function of `lastRefreshed + decayPeriodSec` and is
exact at every stage. It is omitted rather than approximated, because an absent field is honest
where a wrong date is a dated promise. **Treat its absence as "not knowable", never as "not yet
read"** — and never fall back to computing one yourself under dynamic decay.
```json
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC",
"map":"Felucca","x":1480,"y":1600,"z":0,"region":null,"name":"Millrace",
"ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002",
"schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z",
"decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"},
"ban":{"x":1482,"y":1604,"z":0},
"builtOn":"2026-06-03T14:02:44Z","lastRefreshed":"2026-08-25T17:21:14Z"}
```
#### Economy supply (periodic)
@@ -523,14 +560,46 @@ shop name or location actually change.
| kind | fields | notes |
|------|--------|-------|
| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `location{}`, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. |
| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `ownerAcct`*, `location{}`, `fees{}`*, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. |
| `vendor.listing.remove` | `serial` | The shop is gone from the index: dismissed, expired, or its owner switched off the in-game Vendor Search flag. |
\* **Protocol 5.** `ownerAcct` is the owner's game account — `ownerName` is a character name and
identifies nobody, so this is the field that makes a shop resolvable to a person at all.
`fees` describes ServUO's vendor dismissal rule (`PlayerVendor.PayTimer`: at each tick the charge
is compared with the funds, and the vendor is destroyed when the charge wins):
| field | meaning |
|---|---|
| `exempt` | `true` for a commission vendor, which has no pay timer and is **never** dismissed for fees. When true, no other field is present |
| `newVendorSystem` | which of ServUO's two vendor systems is in force; it decides all three quantities below |
| `chargePerPeriod` | what is deducted at each tick |
| `funds` | gold available to pay it (`holdGold` and `bankAccount` are the raw parts) |
| `payIntervalSec` | seconds between ticks: 86400 under the new system, **one UO day (≈2 real hours)** under the old |
| `nextPayAt` | ISO-8601 UTC: the next tick |
| `periodsRemaining` | ticks survived before the one that finds the charge unpayable |
| `dismissalAt` | ISO-8601 UTC: the tick the vendor is destroyed on. **This is the field to build on** |
**There is deliberately no `daysRemaining`**: under the old vendor system a pay period is a UO day,
so a "days" field would be wrong by a factor of twelve on exactly the shards least likely to notice.
`dismissalAt` is an instant and needs no units. It assumes no further sales or deposits — but
unlike `house.decay`'s `estimatedCollapse` there is no randomness in it: given the current funds
it is exact.
**Treat `exempt: true` and a distant `dismissalAt` as different things.** "Never dismissed" and
"dismissed in 400 days" render differently, and conflating them is how a vendor that cannot expire
ends up in an expiry warning.
```json
{"kind":"vendor.listing","serial":"0x40001234",
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
"ownerAcct":"darrow_acct",
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
"region":"Britain","house":"Darrow's Villa"},
"fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548,
"funds":82504,"holdGold":82504,"bankAccount":0,"payIntervalSec":86400,
"nextPayAt":"2026-09-01T21:01:21Z","periodsRemaining":7,
"dismissalAt":"2026-09-08T21:01:21Z"},
"count":2,"total":2,"truncated":false,
"items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
"price":25000,"name":null,"cliloc":1023721},
@@ -1020,7 +1089,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
A typical character page:
```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "4" };
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "5" };
// 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());

View File

@@ -103,7 +103,7 @@ Read config in `Configure`. Subscribe events in `Initialize`. Open the socket an
| Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
| Player offline | `EventSink.Logout` | low | Pair with Login. |
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. |
| Auth attempts | `AccountLogin`, `GameLogin` | low | IP signals for the website. **`AccountLogin` cannot give you a FAILED login**, which this row assumed it could: it is a veto hook that fires *before* the auth decision, and `AccountLoginEventArgs` constructs with `Accepted = true`, so a handler reading the verdict there reports every login as accepted. Protocol 5 adds `account.login.result`, read one Core slice later — [`v5.md`](v5.md) §2.3. |
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
@@ -133,6 +133,7 @@ Read config in `Configure`. Subscribe events in `Initialize`. Open the socket an
| **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. |
| Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. |
| Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. |
| **Vendor fee state** | vendor sweep (Protocol 5) | periodic | The dismissal deadline, not just the balance. `PlayerVendor.PayTimer` destroys a vendor when `pay > totalGold`, and **both halves differ between ServUO's two vendor systems** — `ChargePerRealWorldDay` vs `ChargePerDay`, `HoldGold` vs `BankAccount + HoldGold`, a real day vs a UO day. Resolved on the shard into a `dismissalAt` instant; see [`v5.md`](v5.md) §2.2. `IsCommission` vendors have no pay timer at all. |
| Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. |
> ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.**
@@ -144,6 +145,7 @@ Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is
| Signal | Hook | Freq | Notes |
|--------|------|:----:|-------|
| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. |
| **Decay schedule** | same sweep (Protocol 5) | with the above | `NextDecayStage`, `DecayPeriod`, and a collapse time **only where one exists** — see the note below. |
Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot.
@@ -153,6 +155,20 @@ Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerS
- **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest.
- Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`.
**What that model means for publishing a collapse time (Protocol 5).** Because each stage's
duration is drawn at random *when the stage is entered*, `NextDecayStage` is exact for the next
transition and **nothing beyond it is known at all**. A collapse time is therefore exact only once
the house is already at IDOC, where the next transition is the collapse. `house.decay` omits
`estimatedCollapse` everywhere else rather than approximating it — the field's absence means "not
knowable", never "not yet read". On a shard where `DynamicDecay.Enabled` is false the dead code
above is live instead, and collapse is exact at every stage; the frame carries `dynamicDecay` so a
consumer can tell which regime it is reading. [`v5.md`](v5.md) §2.1.
**The `CanDecay` rule above is also the main trap when TESTING this.** Forcing a stage with
`SetDynamicDecay` on an `AutoRefresh` house does nothing observable: the `DecayLevel` getter calls
`ResetDynamicDecay()` and reports `Ageless`, so the forced stage is wiped before the next sweep
reads it and **no frame is emitted at all** — which looks exactly like a broken emitter.
Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`.
### 5.5 Combat, death, PvP
@@ -233,6 +249,13 @@ Three edits, then the bridge stays pure-subscription:
Newline-delimited JSON, one object per line, `serial` as the primary key.
> **The frames below are 1.0's design sketch, not the shipped wire.** They have drifted in the four
> versions since — `house.decay` in particular ships a flattened `ownerSerial`/`ownerAcct`/`ownerName`
> rather than an `owner` object, and from Protocol 5 its `nextStage` lives inside a nested `schedule`
> alongside `decayPeriodSec`, `dynamicDecay` and a conditional `estimatedCollapse`. The normative
> field lists are [`INTEGRATION.md`](INTEGRATION.md) §Event catalog, with each bump's rationale in
> [`PROTOCOL_2.md`](PROTOCOL_2.md), [`v3.md`](v3.md), [`v4.md`](v4.md) and [`v5.md`](v5.md).
### Outbound (shard → sidecar)
```jsonc

279
link/v5.md Normal file
View File

@@ -0,0 +1,279 @@
# Protocol 5 — Three enrichments, one bump
**Status:** Built, on `edge`. Not yet cut over to `main`, so not yet released.
**Date:** 2026-08-31
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`INTEGRATION.md`](INTEGRATION.md) (website API).
**Driven by** [`../website/ENGAGEMENT.md`](../website/ENGAGEMENT.md) Phase 10.
---
## 1. Why three things at once
Every previous bump did one thing. This one does three, and the reason is a property of the bump
itself rather than of the features:
**A protocol bump costs a sidecar release, a republished bundle, and an operator update on every
shard.** A field left out does not cost a follow-up commit — it costs a *second* bump with the same
three-part lead time, and an operator population split across two protocol versions until the second
one finishes propagating. So everything the engagement workstream is known to need from the wire was
decided before any of it was written, and all of it rides v5.
The three were not picked from a wish list. Each is a field that a **specific Phase 11 trigger cannot
be built without**, and in two of the three cases that was discovered by reading the emitters rather
than by reading the plan, which had recorded both as already possible.
| # | Enrichment | The trigger that needs it | What was actually there |
|---|---|---|---|
| a | `house.decay` gains `ownerName` + a `schedule` | `uo.house.idoc_warning` — the flagship | The frame could say a house had *become* IDOC and nothing about when it ends |
| b | `vendor.listing` gains `ownerAcct` + `fees` | `uo.vendor.expiring` | `ownerSerial` and `ownerName` only, and **no fee data anywhere on the wire** — the plan had this as "needs a mapper" |
| c | `account.login.result`, a new kind | `uo.account.login_failed` | `account.login.attempt` fires **before the auth decision**, so it fires on successful logins too |
### 1.1 What was deliberately left out
**`vendor.sale` stays in the opt-in patch tier.** It is real, and it already carries `ownerAcct` —
but it lives in `servuo-plugins/patches/`, verified only against ServUO 57.4, because it needs a
`PlayerVendorSale` EventSink that the patches *add* to core. Moving it into `overlay/` was considered
and declined: the emitter cannot exist without the core event, so "moving" it means shipping a core
patch as though it were an overlay file. The consequence is a documentation obligation, not a
protocol one — `uo.vendor.sale` is a trigger that is **silently dormant on a shard that declined the
tier**, and the rule that names it has to say so.
---
## 2. The shard side
### 2.1 `house.decay` — a schedule, and only where it is knowable
`BridgeSweeps.WriteDecay` gains `ownerName` and one nested object:
```json
{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC",
"name":"Millrace","ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002",
"schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z",
"decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"},
"map":"Felucca","x":1480,"y":1600,"z":0,"lastRefreshed":"2026-08-25T17:21:14.5725317Z"}
```
**`estimatedCollapse` is present only when it is exact**, and that is the whole design of this field.
ServUO has two decay implementations and they differ in how knowable the future is:
- **Dynamic decay** (`DynamicDecay.Enabled`, i.e. `Core.ML`) draws each stage's duration at *random*
when the stage is entered — `BaseHouse.SetDynamicDecay` calls `DynamicDecay.GetRandomDuration`. So
`NextDecayStage` is exact for the **next** transition and nothing beyond it is known at all.
Collapse becomes exact only once the house is already at IDOC, because then the next transition
*is* the collapse.
- **Static decay** (`GetOldDecayLevel`) is a pure function of `LastRefreshed` and `DecayPeriod`, so
collapse is exact at **every** stage — there is no randomness to wait out.
Emitting a dynamic-decay house's collapse time at, say, `Fairly` would publish a guess as a fact, and
on the website's side that becomes a dated promise in a player's mail. So it is **omitted rather than
approximated**. An absent field is honest where a wrong date is not, and the consumer's
`required: false` declaration already permits the absence.
`dynamicDecay` is emitted unconditionally so a reader can tell the two regimes apart, rather than
having to infer them from which fields arrived.
**The nesting is load-bearing, not cosmetic.** The website's visibility projection matches literal
JSON keys, so a nested group is one admin rule that hides the whole schedule where four sibling keys
would be four rules that drift apart — the same argument that made `vendor.listing`'s `location`
nested in v3.
### 2.2 `vendor.listing` — an owner who can be found, and a deadline
Two additions, and the first matters more than it looks:
**`ownerAcct`.** The frame has carried `ownerName` since v3, but a character name joins to nothing:
the website's `shard_account_links` is keyed by the game **account**. Until now a vendor row named an
owner the site could not resolve to a person, which is why a per-owner rule was impossible however
much fee data existed.
**`fees`**, a nested object describing `PlayerVendor.PayTimer`'s dismissal rule. That rule is
`if (pay > totalGold) Destroy()` at each tick — and *both halves of the comparison differ between
ServUO's two vendor systems*:
| | charge | funds | interval |
|---|---|---|---|
| `NewVendorSystem` | `ChargePerRealWorldDay` | `HoldGold` | 1 real day |
| old system | `ChargePerDay` | `BankAccount + HoldGold` | 1 UO day (`Clock.MinutesPerUODay`, ≈2 real hours) |
Both are resolved on the shard rather than left for the sidecar or the website to reconstruct,
because reconstructing them anywhere else is a second implementation of a rule that lives in
`PlayerVendor`.
```json
"fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548,"funds":82504,
"holdGold":82504,"bankAccount":0,"payIntervalSec":86400,
"nextPayAt":"2026-09-01T21:01:21.6883320Z","periodsRemaining":7,
"dismissalAt":"2026-09-08T21:01:21.6883320Z"}
```
Two naming decisions worth recording, because the obvious spellings are both wrong:
- **There is no `daysRemaining`.** On an old-system shard a pay period is a UO day, so a field with
"days" in its name would be off by a factor of twelve on exactly the shards least likely to notice.
The wire carries `periodsRemaining` plus the interval that gives it meaning, and resolves the
arithmetic into `dismissalAt` — an *instant*, which needs no units at all.
- **`exempt` is not "a very long time left".** A commission vendor (`IsCommission`) has no `PayTimer`
and is never dismissed for fees. It reports `{"exempt": true}` and no schedule, so a surface
rendering "never" can tell it apart from one rendering "in 400 days".
`dismissalAt` assumes no further sales or deposits, exactly as a bank-balance projection does. Unlike
a dynamic-decay house, though, there is **no randomness in it**: given the current funds it is the
exact tick the vendor is destroyed on.
**The market sweep has to DIFF the fee state, and originally it did not.** `BridgeMarket.Signature()`
compares shop name, owner, map, coordinates and the item/price list — the things a *listing* is made
of — so adding `fees` to the frame did not by itself make a frame arrive when the fees changed. A
vendor quietly running down its gold altered nothing the sweep compared, emitted nothing, and the
consumer that exists for exactly that event could fire only by coincidence: when somebody happened to
reprice an item on a shop that was already broke. Found on the engagement Phase 11b live walk and
fixed there (ENGAGEMENT.md, decision 13).
The signature carries the **derived** values — `exempt` and `periodsRemaining` — not the raw ones. An
integer division moves only when the shard's own answer to "is this vendor in danger" moves, while
`holdGold` changes on every sale and `nextPayAt` on every pay tick; keying on either would re-emit a
fat listing frame for a shop whose listings had not changed. This is emit CADENCE, not frame shape:
no field was added and `PROTOCOL_VERSION` is untouched.
**The general form is worth carrying to the next enrichment.** A sweep-based kind has a change
detector, and a field added to the frame that is not added to the detector is a field that ships
correct and arrives never.
### 2.3 `account.login.result` — a new kind, because the old one cannot be fixed
`account.login.attempt` (protocol 1) is emitted from `EventSink.AccountLogin`, which
`PacketHandlers.AccountLogin` invokes **before** it branches on `e.Accepted`. The emitter's own
comment has said so since it was written: *"Fires before the auth decision, so this is an attempt,
not a result."*
The verdict is set by the handlers themselves — `Server.Misc.AccountHandler` is the one that checks
the password and sets `Accepted`/`RejectReason` — so inside our handler the verdict does not exist
yet. `AccountLoginEventArgs` constructs with `Accepted = true`, which means a naive read there
reports **every** login as accepted, including the ones about to be rejected.
That is why "someone tried to log into your game account" could not be built on the attempt: a rule
on it would have mailed a security alert every time the player logged in successfully — the exact
inversion that makes people stop trusting security mail.
```json
{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":false,"reason":"BadPass"}
{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":true}
```
**How it reads the verdict.** `Timer.DelayCall(TimeSpan.Zero, …)` over the args object: one Core
slice later, `InvokeAccountLogin` has returned and the verdict is final. This needs no core patch,
and — the reason it is preferred over simply subscribing late — it does not depend on handler
**subscription order**, which ServUO does not define and which a shard's own scripts can change.
Three details that are deliberate:
- **`reason` is omitted on an accept.** `ALRReason`'s zero value is `Invalid`, so emitting it
unconditionally would put a plausible-looking failure reason on every successful login.
- **The IP is resolved inside the handler**, not in the deferred call: `AccountLogin_ReplyRej`
disposes the `NetState` before the deferred read runs.
- **The password is never read, never logged, never emitted.** The args object carries it, so it is
held for one extra Core slice and no longer, and exactly two properties are read off it.
---
## 3. The sidecar side
`PROTOCOL_VERSION: u32 = 4` → `5` in `sidecar/src/main.rs`, and **nothing else**.
There is **no store migration this time**, unlike v4. Every frame is persisted whole and the board
tables index only the columns they already had, so the new fields ride inside the stored JSON and the
new kind lands in `events` like any other. There is no kind allowlist to extend, either.
That is the dumb-forwarder property doing its job (v3 §3): the
sidecar defines no schema for a frame's contents, so it needs no change when they grow. A bump that
touches one constant is the *expected* cost of an additive protocol version here; v4 needing a
migration was the exception, because it added a column to a board table.
---
## 4. Visibility
Three classifications, made on the website in `module-uo`'s `shardVisibility.js`, never in the
sidecar. Each had to be chosen: a v5 field nobody classified would either leak, or be silently
invisible with nobody noticing.
| Field / kind | Audience | Why |
|---|---|---|
| `house.decay` → `schedule` | **`anonymous`**, configurable | The countdown *is* the public IDOC page's content, and a house at IDOC is already announced in game. Listed rather than left unconfigurable so a shard that considers a precise collapse time an unfair advantage can raise it |
| `vendor.listing` → `fees` | **`admin`**, configurable | The only default in the `market` feature that does not reproduce prior behaviour, because there is no prior behaviour to reproduce |
| `vendor.listing` → `ownerAcct` | **admin, locked** | Rule 1, automatically: the key ends in `acct` |
| `account.login.result` | **admin, by omission** | Rule 2 — a kind absent from `KIND_FEATURE` reaches nobody below admin |
**Why `fees` breaks the `market` pattern.** A shop's name, owner and location are already visible to
any player through the stock in-game Vendor Search gump, which is the entire argument for publishing
them. A vendor's held gold, daily charge and dismissal date are not: in game they are visible to the
**owner**, on that vendor's own gump. Publishing them anonymously would be a genuinely new disclosure
*and* a targeting aid — it says which shops are about to be abandoned and how much coin is sitting in
each.
**Why `account.login.result` is admin-only by omission rather than by mapping.** `KIND_FEATURE` is
the map of kinds that *may* leave the admin channel, and an admin can widen anything on it. There is
no rung below admin that a frame carrying an IP address and an auth verdict belongs on, so mapping it
at all would create a door that should not exist. The omission is the decision, and there is a test
that says so by name.
**The REST reads are unchanged.** `shard_vendors`' new columns are not in any read model's column
list: they exist for Phase 11's server-side trigger and reach no client at all. The house schedule
*is* on the read model, re-nested under `schedule` on the way out so the stored shape and the live
wire frame spell it identically — otherwise one admin rule would cover only one of the two paths,
which is the §3.6.1 failure mode v3 already recorded once.
---
## 5. Cross-repo obligations
| Repo | Change |
|---|---|
| `servuo-plugins` | `BridgeSweeps.AppendDecaySchedule`, `BridgeMarket.AppendFees`, `BridgeEvents.EmitLoginResult` · **`overlay.toml` `protocol = 5`, in the same PR as the emitters** |
| `link` | `PROTOCOL_VERSION` → 5 |
| `module-uo` | ingest + schema (`shard_houses` ×4, `shard_vendors` ×8) · `shardVisibility.js` ×3 · the `uo_link_config` protocol pin, both declaration sites |
| `docs` | this file · `INTEGRATION.md` §Housing, §Market, §Account · `PLAN.md` §5/§7 |
| `runicgateway.com` | `platform.json.protocol` → 5 — **deferred to ENGAGEMENT.md Phase 12**, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately |
**The pin has three declaration sites and they are checked against each other, not against a
literal.** `module-uo`'s schema test used to assert `DEFAULT 4` at each site, which is how protocol 4
shipped with the emitters moved and one declaration left behind: every site agreed with itself. It
now reads `DEFAULT_PROTOCOL` from the model, so the assertion is "the declarations **agree**" and a
bump that misses one fails in CI instead of on an operator's install.
---
## 6. Verification
Unit tests: 470 in `module-uo/server` (16 new), 39 in the sidecar, all passing; the C# compiles
against the real ServUO 57.4 reference assemblies.
Everything below was proved on the local rig — a real ServUO with a seeded world (43k mobiles,
209k items), the **release** Rust sidecar, and `tools/scaffolding/BridgeProtocol5Probe.cs`.
| Claim | Evidence |
|---|---|
| `estimatedCollapse` appears **only** at IDOC | One house walked Fairly → Greatly → IDOC. The `Fairly` and `Greatly` frames carry `nextStage` and no `estimatedCollapse`; the `IDOC` frame carries both, equal |
| `house.decay` carries the owner's character name | `"ownerName":"Zara Crowe"` on all three frames |
| the fee block is right | `funds/chargePerPeriod` floors to `periodsRemaining` on every observed vendor (82504/10548→7, 63252/6834→9, 115531/10035→11, 43/60→0) |
| a vendor with one tick left says so | `periodsRemaining: 0` with `dismissalAt == nextPayAt` — dismissed at the very next tick, which is the case `uo.vendor.expiring` exists for |
| the login verdict is the FINAL one | A real socket login with a wrong password → `accepted:false reason:BadPass`; with the right one → `accepted:true`, no `reason`. **Both saying `true` is the bug this kind exists to prevent** |
| the sidecar needed no change beyond the constant | `GET /health` → `"protocol":5`; every v5 field arrived through the generic forward path |
| a mismatched pair is refused, not mis-parsed | Unchanged and already tested in `installer`: `bundle.rs::a_protocol_disagreement_inside_one_bundle_is_refused` and `doctor.rs::a_protocol_mismatch_fails_the_row`. The check is version-agnostic, so v5 needs no installer change |
### 6.1 Two rig traps, both of which fake a broken emitter
Recorded because each cost a rebuild and each produces *silence* rather than an error.
- **An in-process login probe can never produce `accepted:true`.** `AccountHandler` calls
`acct.HasAccess(e.State)` *before* it checks the password, and a null `NetState` fails that — so
firing `EventSink.InvokeAccountLogin` directly logs `Access denied` even for a correct password.
Only a real socket proves the accepted half (and it is the better test anyway: it also produces the
real `ip`).
- **Forcing a decay stage on a house that cannot decay emits nothing at all.** Only `Condemned` and
`ManualRefresh` houses decay; an `AutoRefresh` one — and *the owner's newest house is always
`AutoRefresh`* — has a `DecayLevel` getter that calls `ResetDynamicDecay()` and reports `Ageless`,
wiping the forced stage on the very next read. The sweep then sees no change. On the seeded world
exactly **one** house qualified, and it was already at IDOC, so the walk had to put it back down
first.

View File

@@ -153,3 +153,231 @@ 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 **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`](../../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.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`](../../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 **34 bodies and 26 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 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.
### 5.7b Every link a body offers comes from `config/clientPaths.js`
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 `example`s 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."*

View File

@@ -14,6 +14,14 @@ Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md`
> 2. **The domain split** — `admin.routes.js` (1552 lines, 110 routes) broken into one router file per
> business capability, **in place, with every URL unchanged**. This is the actual driver.
> **Later change, recorded here so the tables below are not read as current (2026-08-29).** The router
> inventories in this file are a **record of the split as it landed**, and are deliberately left as
> written. Since then, `admin/account.router.js` (6 routes) and `player/account.router.js` (8 routes)
> were **deleted**: `/auth/me/account/*` was already a strict superset of both, and is now the single
> self-service surface. `account.controller.js` moved to `router/v1/auth/`. See
> [ENGAGEMENT.md](./ENGAGEMENT.md) Phase 1a and [BACKEND_DESIGN.md](./BACKEND_DESIGN.md) §4. The
> authoritative URL list is, as this file argues throughout, the generated manifest — never a table.
---
## Why the auth merge is out

File diff suppressed because it is too large Load Diff

4237
website/ENGAGEMENT.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -26,13 +26,152 @@ 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.6.0'
const MODULE_API_VERSION = '1.9.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.9.0 — a module may ship its own message bodies and rules: `api.registerEngagementSeeds(...)`**
(`website/ENGAGEMENT.md` Phase 11b, decision 7). One addition and no removal, so minor; a module
written against 1.8.0 keeps working and simply seeds nothing.
It exists because 1.7.0 let a module say what an event's payload *is* and gave it no way to say what
the message should *read* like. `engagement/templateSeeds.js` and `engagement/coreRules.js` are core
files with core arrays in them and there was no registration call beside them, so a module's
notification was core's generic `notify.event` body or nothing at all. That is tolerable for one
trigger and not for a catalogue: Phase 11 ships twenty-five, sixteen of which have domain prose that
core must never contain (§5.2 — and `check:modules` reads identifiers, never prose, so this boundary
is honoured deliberately rather than enforced mechanically).
```js
api.registerEngagementSeeds({
templates: [{
key: 'uo.house.idoc-warning', // MUST be namespaced "<moduleId>."
name: 'House — final decay warning',
channel: 'email', // 'email' | 'inapp'
subject: 'Thy house at {{region}} stands in peril',
triggerId: 'uo.house.idoc_warning',
triggerVersion: 2,
seedVersion: 1,
blocks: [ /* the same block objects the template editor writes */ ],
}],
ruleGroups: [{
key: 'triggers-v1', // the one-shot guard's name — see below
note: 'shown in the boot log when it inserts',
rules: [{
trigger_id: 'uo.house.idoc_warning', // MUST be one of this module's own
name: 'House decay warning',
audience: 'owner',
channels: ['email', 'inapp'],
template_keys: { email: 'uo.house.idoc-warning', inapp: 'uo.house.idoc-warning-inapp' },
cooldown_seconds: 86400,
max_sends_per_hour: 200,
delay_seconds: 900, // optional
cancel_on: ['uo.house.refreshed'], // optional
conditions: null, // optional
}],
}],
})
```
**Callable once per module, and validate-then-commit like every other registration.** A module that
got one of thirty templates wrong ships none of them and finds out at boot with the offending key
named, rather than at send time with a half-seeded table.
**The two halves behave differently, and the asymmetry is the contract.**
- **Templates are re-ensured on every boot.** Each row carries `seed_key`, `seed_version` and
`customized`, so re-ensuring is how a better default reaches a deployment *without* stealing an
operator's edit (§4.6.1 property 3 of `ENGAGEMENT.md`), and a template added in a later module
version reaches every deployment rather than only fresh ones. **Bump `seedVersion` when a body
changes; never for a comment.**
- **Rules are one-shot, per named GROUP.** Re-ensuring a rule would resurrect one an operator deleted
and reset one they enabled, so each group carries its own settings guard
(`engagement_module_rules_seeded:<owner>:<group>`). **A rule appended to an existing group therefore
reaches fresh installs ONLY** — never a deployment already stamped. A rule that must reach existing
deployments takes a **new group key**. The module names its groups, so the module makes that choice;
make it knowingly.
**Three things a module may not do, each of which is a shipped mistake that would only surface as
mail somebody received.**
1. **A seeded rule is always `enabled = 0`.** `enabled` is not a parameter — a value passed for it is
ignored rather than refused, because refusing would let a typo take a module offline at boot. This
is `ENGAGEMENT.md` Q3's invariant surviving the largest seed set in the workstream.
2. **A module may not mark a template `protected`.** That flag means *"the system breaks without this
body"*, which is true of a password reset and of nothing a module ships; setting it would take an
operator's delete button away.
3. **A rule may only name its own module's `trigger_id`**, and its `template_keys` may only name this
module's own seeds or **core's** (`notify.event`, `inapp.event`, `notify.digest` — which is
§4.6.1 property 1 in force, and the right answer for any trigger whose message is structural).
A template `key` must be namespaced `<moduleId>.`, because `engagement_templates.key` is UNIQUE
across the table and an unprefixed `notify.event` from a module would collide with core's and win
or lose on boot order.
**When it runs, which is not where the other seeders run.** `server.js` calls `seedDefaults()`
**before** it requires `app.js`, and requiring `app.js` is what scans the volume and runs the loader —
so at the moment core seeds its own templates, no module has registered anything. Module seeds are
therefore written from `modules/lifecycle.js` `boot()`, after the `installed_modules` reconcile and
**before** the `onBoot` dispatch. Two consequences worth relying on: a module the operator **disabled**
(or one that failed to load) is skipped rather than seeded, and a module that warms a cache in
`onBoot` may assume its bodies and rules already exist. Failure is logged and swallowed like every
other step there — a body that would not seed costs the shipped default, never the boot.
**It is not a send path.** Everything on the object is data; nothing on it is a function and nothing
on it names a recipient. A module still cannot mail anyone (§1.2): it declares who an event is
*about*, and core decides who is told, after the ceiling, the preferences, the suppression list and
the verification gate.
**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([...])`, `api.registerAudiences([...])` and
`api.registerEngagementSeeds({...})` (§2.4) ·
`ctx.events.emit(triggerId, envelope)` and `ctx.inbox.push(userId, item)` (§2.3).
**This is a real bump, and 1.6.0's in-place amendments are over.** The rule those amendments invoked
— *a contract owes a bump only once it has landed on `main`* — was true when they were written and is
not any more: 1.6.0 reached `main` with the Teams cutover, so the paragraphs below saying "1.6.0 has
only ever been on `edge`" are **historical, not current**. Everything added from here takes a version
of its own. That is also why the integration kit does not go red until the engagement cutover:
`ci/core-ref.json` pins a `main` sha and `checkCoreApi.js` asserts equality with what that sha
declares, so the kit stays green for the whole `edge` period and must be re-pinned in the cutover
window (`ENGAGEMENT.md` Phase 13).
**As in 1.6.0, the number states the whole surface and the members arrive by phase.**
`ctx.inbox.push` is present and **throws** until the in-app channel exists (`ENGAGEMENT.md` Phase 7);
everything else in 1.7.0 is live. Present-and-throwing is deliberate and is the choice 1.6.0 settled
on: a member of a declared version that were simply absent would make the version a lie, and one that
silently accepted data into a table that does not exist would be worse than either.
**One part of 1.7.0 is not a member, and is contract all the same: a trigger id and a notification
stream id share ONE namespace.** An id has exactly one owner across both facets, so a module cannot
attach a payload contract to another module's stream and cannot claim a stream id another module has
declared a trigger for. Core's own five trigger ids *are* its five stream ids, which is the
same-owner case the rule is written for. Nothing registrable before this bump becomes unregistrable
after it — the id grammar was **relaxed** in the same change, so `_` is now legal inside a segment
(`uo.house.idoc_warning`) — but the ownership check is new and it is a tightening. See
`registerEventTriggers` in §2.4, and `ENGAGEMENT.md` §7.2 for the decision.
**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor;
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
@@ -228,6 +367,45 @@ 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` | `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
(`website/ENGAGEMENT.md` §5.2).
```js
ctx.events.emit('uo.house.idoc_warning', {
subject: '0x40001234', // optional — else read from the declared subjectKey
data: { house: 'The Silver Anvil', decayStatus: 'Greatly' },
ownerUserId: 812, // optional — the module resolves it; core never sees a game account
dedupeKey: 'idoc:0x40001234:greatly', // optional, <= 190 characters
occurredAt: new Date(), // optional, defaults to now
})
```
Six things about it are contract rather than implementation:
- **A module emits its own triggers and nothing else.** The owner is bound by core from the calling
module's id and is never read from the arguments. Without that, `emit` would be a way to fire
another module's event with a payload of your choosing, and every rule an operator wrote against
that trigger would fire on it.
- **The payload is validated against the declaration at EMIT, not at render.** A missing `required`
variable or a wrong type is **thrown in development and dropped-and-logged in production** — the
posture `ctx.teams.activity.push` takes, for the same reason: this is called from inside a
game-event handler, and a contract problem of core's must not become the module's control flow.
Undeclared keys are dropped rather than rejected; they could never be interpolated anyway.
- **It returns `undefined` and never throws in production.** There is nothing a module could
correctly do with a delivery failure from inside an event handler, so there is nothing to await.
- **`ownerUserId` is a website user id, resolved by the module.** Core has no idea what a game
account is and must not learn; the module maps its own account to a user and passes the result.
- **`subject` is what a cooldown is keyed on** — "once per house", not "once per user" — and falls
back to the variable the declaration's `subjectKey` names.
- **A `scheduled` trigger is not emitted.** Its evaluator fires it; a direct emit is refused.
**`ctx.inbox.push(userId, item)`** is the in-app sink, for a module that wants to write a user's
inbox directly without going through a rule. It is **present and throws** until the in-app channel
lands (`ENGAGEMENT.md` Phase 7) — see §1.1 for why a declared member throws rather than being absent.
**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers*
questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a
@@ -308,6 +486,9 @@ api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.registerPostHook({ onSaved, onDeleted })
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
api.registerEventTriggers([{ id, label, kind, subjectKey, audience, ceiling, version, variables }]) // 1.7.0
api.registerAudiences([{ id, label, params, ceiling, resolve }]) // 1.7.0
api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
@@ -567,6 +748,116 @@ but its own reply. `ok` is core's verdict and sits outside the envelope, so a ha
claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who
switches a module off does not leave a live handler behind it.
**`registerEventTriggers(triggers)`** (1.7.0) declares the events a module can fire and the payload
contract behind each. The catalog it builds is what `GET /api/v1/admin/engagement/triggers` serves,
what a rule is written against, and what a template may interpolate
(`website/ENGAGEMENT.md` §4.3).
```js
api.registerEventTriggers([{
id: 'uo.house.idoc_warning', // <owner>.-prefixed, one namespace with stream ids
label: 'House approaching collapse',
description: 'A player house dropped into a late decay stage.',
kind: 'event', // 'event' | 'scheduled'; default 'event'
subjectKey: 'house', // which variable identifies the cooldown subject
audience: 'owner', // the DEFAULT a rule is created with
ceiling: 'owner', // the widest a rule may EVER be given
version: 1, // bumped on a rename or a type change
variables: [
{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' },
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-08-30T04:00:00Z' },
],
}])
```
Six things about it are contract rather than implementation:
- **A trigger id and a notification stream id are ONE namespace.** An id has exactly one owner across
both facets. A module may declare both for the same id — that is one event with a subscription
toggle *and* a payload contract, and it is what core does with its own five — but it may not attach
a contract to another owner's stream, and the refusal names the holder and the facet. The seven
grandfathered ids (§6.5) are exempt from the prefix rule here exactly as they are for streams,
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 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.
- **The type set is closed:** `string`, `int`, `float`, `boolean`, `datetime`, `url`. No `object` and
no `array` — a template that has to walk a structure has outgrown interpolation. A `url` is
validated **site-relative**, like `pageUrlTemplate`, because it ends up in an href.
- **A `subjectKey` must name a declared variable.** Otherwise the cooldown is keyed on `undefined`,
which looks like the feature working right up until two subjects share it.
- **`version` is the prop-schema version a block carries** (§4.3), bumped on a rename or a type
change; a template records what it was authored against and renders with a warning rather than
interpolating `undefined`.
A module ships a prebuilt `engagement-triggers.json` in its bundle, for the same reason it ships a
prebuilt swagger fragment (§6.1a): core never has its sources to analyse. Core's own is generated by
`npm run engagement:manifest` and gated in CI with `--check`.
**`registerAudiences(audiences)`** (1.7.0) declares named sets of users a module can resolve over its
own data, for an operator to point a rule at (`ENGAGEMENT.md` §5.1a). "Team X's members" and "the
governors" are audiences; "everyone who opened the last mail" is not, and nothing here builds it.
```js
api.registerAudiences([{
id: 'uo.team.members',
label: 'Members of a team',
params: [{ id: 'teamId', type: 'int', required: true }], // 'int' | 'string' only
ceiling: 'members',
resolve: async (params) => [/* user ids */],
}])
```
Four things about it are contract rather than implementation:
- **The resolver returns user ids and nothing else.** It is not handed a template, a channel or an
address and it cannot enumerate them. A module still cannot send mail (§2.7), and this must not
become the back door that lets it — core maps ids to addresses on its own side, after preferences,
suppression and the verification gate.
- **Core learns no game vocabulary.** Core never knows what a governor is; it knows an id, a label
and a `resolve` it may call. The same boundary `registerNotificationStreams` holds.
- **An audience whose module is uninstalled goes DORMANT, never an error.** It resolves to the empty
set and a rule referring to it shows as dormant — never auto-deleted, and never a silent send to a
*different* set of people because the id stopped resolving. Same rule as §7.3's dormant trigger. A
resolver that throws or answers a non-array costs an empty set too, not a wrong one, and the ids it
does return are filtered to positive integers before core uses them.
- **A composed segment takes the NARROWEST ceiling it contains, never the widest**, and is still
checked against the trigger's own ceiling before a rule using it can be saved. Union-widens is the
intuitive implementation and it is the wrong one; two incomparable ceilings have no bound at all
and the save is refused rather than guessed. Composition UI is Phase 4's.
**Audiences are their own id space**, unlike triggers and streams: an audience names a set of PEOPLE
and a trigger names an EVENT, so the two may share a name. They carry no legacy allowlist — nothing
predates them.
**`registerEngagementSeeds({ templates, ruleGroups })`** (1.9.0) ships the module's own message
BODIES and its seeded rules (`ENGAGEMENT.md` Phase 11b, decision 7). It is the content behind the two
declarations above: they say what an event IS and who it is about, and this says what the mail reads
like. Callable once per module. The full shape, the three prohibitions and the boot-path placement
are in §1.1 under **1.9.0**; four things are contract rather than implementation and belong here:
- **Templates re-ensure; rule groups are one-shot.** A body is offered again on every boot under
`seed_key` / `seed_version` / `customized`, so improving a default reaches deployments that did not
edit it. A rule is offered ONCE per named group, because re-offering would resurrect a rule an
operator deleted and reset one they enabled. **A rule appended to an existing group reaches fresh
installs only** — that is not a limitation to work around, it is the guarantee; a rule that must
reach existing deployments takes a new group key.
- **A seeded rule is always disabled.** `enabled` is not a parameter. An operator turns a module's
mail on; installing a module never does.
- **Core's generic bodies are a first-class answer.** A rule may point `template_keys` at
`notify.event` / `inapp.event` / `notify.digest` and author nothing — §4.6.1 property 1. Ship a
bespoke body when the message has something to say that the structural projection cannot; a trigger
whose message is "this happened, here is the link" should not have one.
- **It is not a send path.** Every value on the object is data. Core still decides who is told.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle
@@ -1746,6 +2037,33 @@ injected chunk executes and calls `window.__rg.registry` (§3.3).
---
### 6.8 A trigger, a rule and an audience outlive the module that declared them
The forward-compat note `ENGAGEMENT.md` §7.3 asks this document to carry, and the reason it is here
rather than there: it is a rule about **the contract**, not about the engagement system.
A trigger id is namespaced by its owner and collision-checked at registration, exactly as a
notification stream is. What is *not* expressed by that is a rule or a template referring to a
trigger whose module has been uninstalled.
> `engagement_rules.trigger_id` is a plain `VARCHAR`, deliberately — no foreign key, no cascade — so
> a module can be removed and reinstalled without an operator's rules being destroyed. It is the same
> decision `announce_job_legs` took for a leg whose module is gone: *"Leave it alone: failing it would
> make the job roll up terminal on the strength of a leg that no longer exists, and reinstalling the
> module should resume it."*
>
> A rule whose trigger is unregistered must therefore show as **dormant** in the admin UI — never as
> an error, and never auto-deleted. The same holds for a rule whose *audience* is unregistered: it
> resolves to the empty set and shows dormant, which is not the same answer as "resolved to nobody"
> and must not be rendered as if it were. The failure this prevents is specific: an id that stops
> resolving must never silently become a send to a **different** set of people.
`engagement_templates.trigger_id` is the same, for the same reason, with one addition: a template
records the trigger `version` it was authored against, so a declaration that has since been bumped
produces a warning in the admin list rather than silently interpolating `undefined`.
---
## Part 7 — What the spike proved
The Phase 1 spike ran on `website` branch `spike/module-atlas`, cut from `edge` and **deliberately

View File

@@ -1595,8 +1595,13 @@ function in `model/teams/teamNotify.db.js` that returns an unfiltered recipient
would be a refactor away from being used.
**As built**, the column is `email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off'` plus
a `last_digest_at DATETIME NULL` (the digest's only state, see §6.4), and it is surfaced in two
places:
a `last_digest_at DATETIME NULL`, and it is surfaced in two places:
> **`last_digest_at` is no longer read** (engagement Phase 6). The digest's state moved to
> `engagement_digest_state`, keyed `(user_id, channel, scope_key)` so a second digest needs no second
> column here; `schema.sql` backfills it once. The column stays as the backfill's source and as the
> record of what a row meant before the migration. **The two preference columns are unchanged and are
> still the authority for Team notifications** — the engine reads them rather than replacing them.
- **`/account/notifications`**, a new core page in the player portal — stream subscriptions, the
per-Team mute list, and the email mode per Team. `GET|PUT /auth/me/notifications/teams`; the `teams`
@@ -1608,7 +1613,7 @@ places:
which is a privacy property and not a tidiness one: whether a preference *exists* for a Team answers
"is this person in it", and the guild page is public.
### 6.4 Email — the third sink, already built and unused
### 6.4 Email — the third sink
Push needs the Android app. The Discord bridge (§7.2) needs Discord. **A web-only user on a deployment
running neither currently gets no notification that someone replied to their own thread** — which is
@@ -1618,47 +1623,120 @@ Core already has `utils/mailer.js` and an admin-configured `email_config`. The e
notifications is computing the recipient set, and §6.2 builds it; email is a **third consumer of the
same event**, not a fourth pipeline.
- Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is off.
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge
(§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an
unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
> **Rewritten 2026-08-29 — the engagement system's Phase 6 took this sink over.** Everything below the
> line still describes what a recipient receives; what changed is who decides to send it. The design of
> record for the mechanism is now `docs/website/ENGAGEMENT.md` (Phase 6 as built), and this section is
> the Teams-shaped view of it. **Do not re-specify the engine here** — the same rule §6.0b applies to
> every other doc that touches a contract it does not own.
#### What moved, and what did not
`teamNotify.js` had three sinks. **One moved:**
| Sink | Where it lives now |
| --- | --- |
| The content-free push tickle | still `teamNotify.js`, unchanged. Its `deliver` on the engine is the engagement Phase 7's, with the in-app inbox that gives a tickle a `ref` worth deep-linking |
| The Discord bridge (§7.2) | still `teamNotify.js`, unchanged. A bridge is a *leg* — one-shot, to whoever can read a channel — and not a per-recipient *channel*; `ENGAGEMENT.md` §3.1 argues that distinction and it holds here |
| **Email** | **the engagement engine.** `teamNotify.forumPost` emits `team.forum.post` / `team.announcement`; a rule decides who is mailed, through which template, how often at most |
`mailer.sendTeamNotification` and `teamNotify.emailImmediate` no longer exist. The mail body is an
`engagement_templates` row an operator can edit (`notify.team-post` for a post, `notify.digest` for the
digest, `notify.event` for the two roster events and for announcements).
#### The four properties this section always claimed, and where each one lives now
- **Same recipient computation, same per-Team mute, same suppression while `teams_forums_enabled` is
off.** All three still hold, and the first is now explicit rather than incidental:
`teamNotify.recipientIds` computes the access-checked set and it travels on the event envelope as
`recipientUserIds`. A rule whose audience is `members` resolves to exactly that set — still filtered
for `users.status = 'active'`, still under the trigger's ceiling. Core does not learn what a Team is;
the event says who it is about.
- **Unlike a push tickle, an email carries content** — the same reasoning as the Discord bridge (§7.2):
the recipient's mailbox is a destination they chose, not an untrusted relay reached by an unguessable
topic. It carries the thread title, an excerpt and a link; never the full post.
- **The per-Team preference is unchanged and is still the authority.** `team_notification_prefs` stays
exactly where it is, with exactly the meaning §6.3 gives it. The engine reads it through a
**scoped-preference** adapter: for a Team-scoped event that table *is* the preference, `muted`
silences every channel, and `email_mode` decides email and says nothing about the others. The
alternative — intersecting it with the newer per-stream preference — would have silenced every
existing subscriber on the migrating deploy, because nobody has ever expressed a stream-level opinion
about a Team trigger. The argument in full is in ENGAGEMENT.md Phase 6.
- **Off unless email is configured.** No usable `email_config` means the sink is absent, not broken —
and as of Phase 6 there is a second gate above it, below.
#### **Team email is OFF until an operator turns it on**
This is the one live behaviour change and it is deliberate. An engagement rule arrives `enabled = 0` so
that no import, restore or upgrade can start mailing on its own, and core seeds four Team rules under
that same rule. **On upgrade, Team notification emails stop until somebody opens Admin → Engagement →
Rules and switches one on.** The screen carries a banner saying so for as long as every Team rule is
off; the release note says it too. Push and the Discord bridge are unaffected.
#### The digest
**Computes at send time and keeps no queue.** The worker asks what arrived after the last stamp and
re-runs the access resolver. Three properties fall out, and the third is why it was chosen over a
pending-items table — and, in Phase 6, over the engine's own outbox:
1. a deployment down for two days sends **one** correct digest rather than replaying a backlog;
2. a post a moderator hid after it was written is simply not in the query;
3. **a user who lost forum access between the post and the send is no longer in the recipient set**, so
they are not emailed content they can no longer read.
`since` is clamped to at most seven days so a long outage cannot produce one enormous mail, and the
stamp is written **only on a successful send** — stamping first would quietly eat a day of somebody's
notifications every time the mail provider had a bad minute.
**What Phase 6 changed is the state, not the design.** The stamp moved from
`team_notification_prefs.last_digest_at` into `engagement_digest_state`, keyed
`(user_id, channel, scope_key)`, backfilled once by `schema.sql`. A digest-mode recipient gets **no
outbox row** — a row would carry a snapshot taken at publish time and would have none of the three
properties above. The worker is also gated on an enabled email rule, so switching Team email off
switches off both halves of it rather than the instant half only.
- **Digest, not per-event, when email is on at all.** A busy Team forum sending one email per reply is
how a notification feature gets marked as spam. `email_mode ENUM('off','digest','immediate')` in
`team_notification_prefs`.
> **As built, the default is `off` and not `digest`** (org lead, 2026-08-18): digest-by-default
> would start mailing every member of every Team the moment an operator connects Gmail. Email is
> the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already
> has.
- **The digest computes at send time and keeps no queue** (as built). The only state is
`last_digest_at`; the worker asks what arrived after it and re-runs the access resolver. Three
properties fall out, and the third is why it was chosen over a pending-items table: a deployment
down for two days sends **one** correct digest rather than replaying a backlog; a post a moderator
hid after it was written is simply not in the query; and **a user who lost forum access between the
post and the send is no longer in the recipient set**, so they are not emailed content they can no
longer read. `since` is clamped to at most seven days so a long outage cannot produce one enormous
mail, and `last_digest_at` is stamped **only on a successful send** — stamping first would quietly
eat a day of somebody's notifications every time the mail provider had a bad minute.
- **Roster events do not email** (as built). `team.member.joined` and `team.leadership.changed`
tickle and stop there; only `team.forum.post` and `team.announcement` reach this sink.
- **Off unless email is configured.** No `email_config` row means the sink is absent, not broken.
- One-click unsubscribe link honouring the same per-Team mute, so an unsubscribe from the mail client
writes the preference the site shows.
> **As built: a stateless HMAC over `(version, userId, teamId)`, not a token table.** Every property
> that makes a password-reset token a row is absent here — the link sits in a mailbox for months so
> it has no useful expiry, and clicking it twice must mean what clicking it once meant. The
> capability it carries is deliberately the narrowest that does the job: set `muted` for **one**
> (user, Team) pair. It reads nothing, cannot un-mute, and names no other Team. `version` is the
> only revocation a stateless design can offer — bumping it invalidates every outstanding link at
> once — and it exists before it is needed rather than after.
>
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
> `List-Unsubscribe` *header* carries `POST /api/v1/public/teams/unsubscribe/:token`, because RFC
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and
> does not act** — a mail client's link scanner would otherwise silently mute Teams nobody asked to
> leave. The endpoint answers `200` whatever the token was: a response that distinguished a valid
> token from a forgery would be an oracle for which (user, Team) pairs exist, on a surface with no
> session behind it.
> would start mailing every member of every Team the moment an operator connects a mail transport.
> Email is the one opt-IN sink here. Push stays opt-out, because a mute silences something the user
> already has.
- **Roster events do not email by default** (as built, restated by Phase 6). `team.member.joined` and
`team.leadership.changed` do now *emit*, so an operator who wants that mail can have it — but the
rules that would send it are seeded disabled and carry an hour-long cooldown, so §6.4's original
argument survives as the default rather than as a sink the code declines to call.
#### One-click unsubscribe
A link honouring the same per-Team preference, so an unsubscribe from the mail client writes what the
site shows.
> **A stateless HMAC, not a token table.** Every property that makes a password-reset token a row is
> absent here — the link sits in a mailbox for months so it has no useful expiry, and clicking it twice
> must mean what clicking it once meant. The capability is deliberately the narrowest that does the
> job: turn **one channel** off for **one scope** for one account. It reads nothing, cannot turn
> anything back on, and names no other scope. `version` is the only revocation a stateless design can
> offer — retiring one invalidates every outstanding link of it at once — and it exists before it is
> needed rather than after.
>
> **Phase 6 generalized the token from `(userId, teamId)` to `(userId, channel, scopeKey)`, and
> narrowed what it does.** A v1 token set `muted`, which silenced that Team's *push* as well as its
> email — a link labelled "stop these emails" quietly stopping notifications on somebody's phone. A
> token now turns off the channel it names and nothing else. **Old tokens still verify, permanently**,
> and read as the email channel for that Team, which is a reading of what they always meant.
>
> **Two URLs come out of one token, and they are not interchangeable.** The mail *body* carries the
> site's own `/unsubscribe/:token` page, which POSTs once a human is looking at it. The
> `List-Unsubscribe` *header* carries `POST /api/v1/public/engagement/unsubscribe/:token`, because RFC
> 8058 lets a client POST to it without rendering anything. **A GET on the API path redirects and does
> not act** — a mail client's link scanner would otherwise silently unsubscribe people who asked for
> nothing. The endpoint answers `200` whatever the token was: a response that distinguished a valid
> token from a forgery would be an oracle for which (user, scope) pairs exist, on a surface with no
> session behind it.
>
> **`POST|GET /api/v1/public/teams/unsubscribe/:token` still exists and always will.** It hands
> straight to the same handlers. Mail sent before Phase 6 carries that path in its header and in its
> body, mail is not editable once sent, and a route that moves is a person who cannot unsubscribe.
Folded into **Phase 6** rather than getting a phase of its own: the recipient set is the work, and it
is already being built there.

244
website/UPGRADE_NOTES.md Normal file
View File

@@ -0,0 +1,244 @@
# Upgrade notes — website
**Operator-facing. Newest first.** One entry per change that requires an operator to *do* something,
or that changes behaviour they would otherwise discover by accident.
Most upgrades need nothing: the schema is replayed on every boot with `IF NOT EXISTS` migrations, and
a deployment that pulls a new image and restarts is done. This file exists for the exceptions — and
the exceptions worth writing down are almost always the ones that **fail quietly**, because a loud
failure announces itself and a quiet one does not.
**Read the entries between the version you are on and the version you are moving to.** Each names the
action, whether it is required, and what happens if you do nothing.
---
## Engagement: rules, an in-app inbox, and three things that arrive switched off
**Required.** The engagement workstream, Phases 1–12. Three separate actions, and the two that matter
most are the ones nothing will tell you about.
### What changed
The site now has a general notification engine. A **trigger** fires (a forum post, a house going
IDOC, a governor being appointed), a **rule** decides who hears about it and through which
**channel**, and a **template** decides what it says. All of it is admin-editable under
**Admin → Engagement** — Rules, Audiences, Templates, Triggers, Send Log and Suppressions.
Players get an in-app inbox at **Account → Notifications**, with per-channel switches at **Account →
Notification settings**, and the same inbox in the Android app.
### What you must do
Three things, in this order. The first is a required upgrade step for anyone running a shard; the
second and third are only required if you were relying on the behaviour they change.
#### 1. Update the shard — the wire protocol is now 5
**Required if this deployment has a shard connected.** Update in this order:
1. the website (this image),
2. the **UO module to 0.5.0** — **Admin → Modules**, paste the 0.5.0 release install-manifest
URL, then **restart the server**: modules are read from disk at boot, so nothing changes until
you do. It declares `coreApi ^1.9.0`, and a core older than that will refuse to *load* it (the
install itself still succeeds) — the module then shows as failed on that screen, naming the
version it needs. That is the check working, but it means step 1 has to be finished first;
3. the **shard**, with `installer update` on the shard host. That is what pairs a protocol-5 sidecar
with a protocol-5 overlay; the installer refuses to pair a mismatched one by design.
**The order matters, and step 2 is the one that starts the clock.** The protocol pin lives in the UO
module's schema: on the first boot after the module updates, `uo_link_config.protocol` moves to 5 and
the site starts sending `X-UOLink-Version: 5`. A shard still on 4 answers `409` from that moment on,
so shard data stops. Nothing is lost and nothing is corrupted — the site degrades to showing the
shard offline, exactly as it does when the sidecar is down — but it stays that way until the shard is
updated. If you cannot update the shard in the same window, update the site and leave the module on
its current version until you can.
Protocol 5 adds a decay schedule and owner account to `house.decay`, fee and owner state to
`vendor.listing`, and a new `account.login.result`. Those three fields are what the shard-driven
rules are built on; a shard on 4 could not feed them.
#### 2. Team notification email arrives switched off
**Required if your Teams rely on email.** Team email used to send automatically. It now runs through
the engagement engine like everything else, as four rules — and like every rule on the platform, they
are seeded **disabled**. Nothing mails until an operator switches one on.
Open **Admin → Engagement → Rules** and enable the Team rules you want. A banner on that screen says
the same thing while they are all off, and disappears when one is enabled.
**Only email moved.** The push tickle and the Discord bridge are unchanged and kept working through
the upgrade, so the app and your Discord channel behave exactly as before. Per-member Team
preferences and unsubscribe links still apply above the rules.
#### 3. News push stops until you switch it on
**Required if you publish news and expect it to notify.** Publishing a news post used to send a push
tickle directly. That call is now an engagement trigger, seeded — again — as a **disabled** rule. A
deployment that upgrades and does nothing else tickles nobody on the next publish.
Same screen: **Admin → Engagement → Rules**, enable the news rule.
This is deliberate rather than an oversight. The alternative was to keep the old direct tickle beside
the new trigger for one release, and an exception with no owner is one nobody removes.
### Behaviour changes that need no action
- **An unsubscribe link now turns off one channel, not everything.** The old link muted a user
outright, which silenced push as well as mail. It now switches off exactly the channel the link was
in.
- **Email and push are opt-in; the in-app inbox is on.** A user who changes nothing receives in-app
items and no mail. That matches the Team preference model already in use, and it is why enabling a
rule does not by itself produce a mail storm.
- **A rule cannot mail more people than its trigger allows.** Every trigger declares an audience
ceiling, and a rule is checked against it — so a staff-only event cannot be given a public
audience, whatever the rule says.
- **Bounces now suppress an address.** A hard bounce or a rejected recipient stops further mail to
that address and is listed under **Admin → Engagement → Suppressions**, which is also the only
screen that can lift one. An address that has stopped receiving mail is the first thing to check
there.
### If you do nothing
The site upgrades, the in-app inbox works, and no mail is sent — because every rule is off and email
needs both a transport (see the SMTP entry below) and a user who opted in. What you lose silently is
**Team email** and **news push**, both of which were sending before the upgrade and stop at it. If
you also leave the UO module on its current version, the shard keeps working on protocol 4 and none
of the shard-driven rules exist yet.
### Data
Nothing is dropped and no row is deleted. New tables cover rules, segments, templates, the outbox,
the send log, cooldowns, suppressions, per-channel preferences and the in-app inbox.
`team_notification_prefs` is kept and still consulted — per-Team granularity did not move.
**Nothing in this schema expires on its own.** Finished outbox rows, the send log and the suppression
list are kept until you remove them; there is no retention sweep. On a busy deployment those tables
grow without limit, which is worth knowing before it is worth acting on.
---
## Email addresses are now unique, and some accounts may lose theirs
**Required only if the dashboard says so.** Engagement Phase 1b.
### What changed
An account's email address is now the destination for account recovery and, in time, for notification
mail, so an address may belong to **one account only**. Until now it could repeat — SSO sign-up wrote
whatever address the provider returned, without checking — so a database that has been running with
SSO enabled may well hold duplicates.
Users can now also **set and change their own address**, under *Account → Email address*, confirming
it by opening a link. Before this there was no way for anyone to change their own address at all.
### What the upgrade does on first boot
Nothing you need to trigger, and **no account is deleted**. Where several accounts share an address:
- the **earliest-created** account keeps it — not the "verified" one, because SSO used to mark an
address verified merely for existing, so that flag cannot arbitrate anything;
- every later account has its address **cleared** (set to nothing) and is listed in a report;
- the dashboard then shows a warning naming how many accounts were affected.
Case is folded — `Foo@x.com` and `foo@x.com` are the same mailbox — but **accents are not**:
`josé@x.com` and `jose@x.com` are correctly treated as two different addresses.
### What you have to do
If the warning appears, open **Admin → Users** and read the report. It names each affected account and
the address it lost. **Those users are the reason this warning exists:** they can still sign in
normally, but they can no longer receive password-reset or notification email until they set a new
address themselves, and nothing tells them that. Contact them, and point them at *Account → Email
address*.
Dismissing the warning keeps the report — it is the record of what the upgrade did, and the only place
the lost addresses survive.
### Verification email
Confirmation links are sent through the same transport as everything else, so **if outbound email is
not configured, nobody can confirm an address.** The screen says so honestly rather than claiming a
mail was sent. If you have not configured SMTP yet, see the entry below first.
### The verification setting
A new setting, *require a confirmed address before sending notification email*, is seeded **off on an
existing deployment** and **on for a fresh install**. The asymmetry is deliberate: switching it on
retroactively would silently stop mailing every user who had already opted in, on the day you
upgraded. Nothing reads it yet — it takes effect when engagement notifications ship — so there is no
hurry to change it.
### If you do nothing
The de-duplication has already run; it runs on the first boot whether or not you read this. What you
lose by ignoring it is the chance to tell the affected users, who will otherwise discover it the next
time they try to reset a password.
### Data
Nothing is dropped and no row is deleted. `users` gains `email_norm` (a generated lowercase copy of
`email`, which carries the uniqueness index) and `email_pending`. Two tables are added:
`email_verifications` and `email_dedupe_report`. Cleared addresses survive in the report.
---
## Outbound email: the Gmail connect flow is gone; configure SMTP
**Required, if this deployment currently sends email.** Engagement Phase 1.
### What changed
The "Connect Gmail" consent flow has been **removed**. Email now sends through an ordinary mail
transport whose credentials an admin types in — SMTP is the baseline and the only transport shipped.
Removed with it: `GET /api/v1/admin/email/connect/start`, `GET /api/v1/admin/email/connect/callback`,
and email's reuse of the `google` authentication provider's client id and secret. That reuse is worth
a sentence of its own, because it was a coupling nobody could see: rotating the Google **SSO** secret
silently broke outbound **mail**, with nothing on either screen relating the two. Single sign-on is
otherwise unaffected — the `google` provider row exists for SSO in its own right and is untouched.
### What you must do
On the first boot after the upgrade, `email_config.transport` backfills to `smtp` with **no
credentials**. Until you supply some, **no mail is sent** — and nothing errors, because every sender
is written to degrade politely:
- the contact form falls back to a `mailto:` link,
- an invite returns the accept link for you to send by hand,
- a password reset still answers its usual generic success,
- Team notifications are logged and dropped.
That is the risk this note exists for: **email stops and nothing complains.** The admin dashboard
shows a warning while it is true, and the warning goes away by itself once a credential is saved.
Go to **Admin → Settings → Email**, choose the posture that fits, then use **Send test** — which is
now the real verification of the whole configuration, not a formality.
| Posture | Settings | Notes |
| --- | --- | --- |
| **A relay** — recommended | The relay's host, port `587`, *Implicit TLS* **off**, your API key as the password | Mailgun, SES, Postmark and equivalents. Reputation, DKIM and bounce handling are the relay's problem. The right answer for anything with volume |
| **Gmail over SMTP** — the shortest migration | `smtp.gmail.com`, port `587`, *Implicit TLS* **off**, your Google address as the username, and an **app password** | This is the direct replacement for the removed connect flow. It needs an [app password](https://support.google.com/accounts/answer/185833) — 2-Step Verification must be on, and your ordinary account password will not work. Google's per-day send caps apply |
| **Your own MTA** | Its host, port `25`, *Implicit TLS* **off**, username and password left blank | For an operator already running mail on the same host |
Two fields cause most failures:
- **Implicit TLS** is *on* only for port **465**. On port 587 leave it **off** — the connection still
upgrades to TLS, via STARTTLS. Port 587 with it on does not error; it hangs.
- **Send from** is now typed by you rather than read back from Google, so nothing guarantees the
server will accept it. It must be an address the account is allowed to send as, or the mail is
refused for SPF/DMARC reasons that look like nothing at all. Send test names this specifically when
it happens.
### If you do nothing
Mail stays off. Nothing breaks and no data is lost — the site, the admin panel, logins, SSO and the
shard integration all behave exactly as before. The dashboard warning stays up until you either
configure a transport or press **Clear credentials**, which is how you say "this deployment
deliberately sends no mail".
### Data
Nothing is dropped. `email_config` gains `transport`, `credential_enc` and `reply_to`; the old
`provider` and `refresh_token_enc` columns are kept but no longer read. Your stored Gmail refresh
token is not used again and is cleared the first time you press **Clear credentials**.

View File

@@ -17,30 +17,6 @@
"method": "GET",
"path": "/api/health"
},
{
"method": "GET",
"path": "/api/v1/admin/account"
},
{
"method": "GET",
"path": "/api/v1/admin/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/admin/account/identities/:provider"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/setup"
},
{
"method": "GET",
"path": "/api/v1/admin/activity"
@@ -89,14 +65,6 @@
"method": "PUT",
"path": "/api/v1/admin/email/config"
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/callback"
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/start"
},
{
"method": "POST",
"path": "/api/v1/admin/email/disconnect"
@@ -105,6 +73,14 @@
"method": "POST",
"path": "/api/v1/admin/email/test"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/audiences"
},
{
"method": "GET",
"path": "/api/v1/admin/engagement/triggers"
},
{
"method": "GET",
"path": "/api/v1/admin/invites"
@@ -145,6 +121,14 @@
"method": "GET",
"path": "/api/v1/admin/moderation/recent"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/reports"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search"
@@ -293,6 +277,98 @@
"method": "PUT",
"path": "/api/v1/admin/site-mode"
},
{
"method": "GET",
"path": "/api/v1/admin/teams"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/archive"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/display-name"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/forum/moderation"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/:id/grants"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/hide"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/leader-override"
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/:id/leader-override/:memberKey"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/:id/unhide"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/forum/settings"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/forum/uploads"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/integrations"
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/integrations"
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/integrations/:teamId"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/requests"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/requests/:id/decide"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/resync"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/review"
},
{
"method": "GET",
"path": "/api/v1/admin/teams/voice"
},
{
"method": "PUT",
"path": "/api/v1/admin/teams/voice"
},
{
"method": "DELETE",
"path": "/api/v1/admin/teams/voice/:teamId"
},
{
"method": "POST",
"path": "/api/v1/admin/teams/voice/sync"
},
{
"method": "POST",
"path": "/api/v1/admin/uploads"
@@ -333,6 +409,14 @@
"method": "DELETE",
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
},
{
"method": "GET",
"path": "/api/v1/admin/users/email-dedupe-report"
},
{
"method": "POST",
"path": "/api/v1/admin/users/email-dedupe-report/acknowledge"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki"
@@ -389,6 +473,14 @@
"method": "GET",
"path": "/api/v1/admin/wiki/tags"
},
{
"method": "GET",
"path": "/api/v1/auth/email/verify/:token"
},
{
"method": "POST",
"path": "/api/v1/auth/email/verify/:token"
},
{
"method": "GET",
"path": "/api/v1/auth/invite/:token"
@@ -417,6 +509,18 @@
"method": "GET",
"path": "/api/v1/auth/me/account"
},
{
"method": "PATCH",
"path": "/api/v1/auth/me/account/email"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account/email/pending"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/email/resend"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account/identities"
@@ -477,6 +581,14 @@
"method": "PUT",
"path": "/api/v1/auth/me/notifications/subscriptions"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/teams"
},
{
"method": "PUT",
"path": "/api/v1/auth/me/notifications/teams"
},
{
"method": "GET",
"path": "/api/v1/auth/me/sessions"
@@ -557,38 +669,6 @@
"method": "POST",
"path": "/api/v1/auth/sso/totp"
},
{
"method": "GET",
"path": "/api/v1/player/account"
},
{
"method": "GET",
"path": "/api/v1/player/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/player/account/identities/:provider"
},
{
"method": "PATCH",
"path": "/api/v1/player/account/password"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/setup"
},
{
"method": "PATCH",
"path": "/api/v1/player/account/username"
},
{
"method": "GET",
"path": "/api/v1/player/appeals"
@@ -605,6 +685,66 @@
"method": "GET",
"path": "/api/v1/player/appeals/eligible"
},
{
"method": "GET",
"path": "/api/v1/player/teams"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/access"
},
{
"method": "PATCH",
"path": "/api/v1/player/teams/:slug/forum/posts/:id"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/report"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads/:id"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/uploads"
},
{
"method": "DELETE",
"path": "/api/v1/player/teams/:slug/forum/uploads/:id"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/grants"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/grants"
},
{
"method": "DELETE",
"path": "/api/v1/player/teams/:slug/grants/:userId"
},
{
"method": "POST",
"path": "/api/v1/public/contact"
@@ -637,6 +777,34 @@
"method": "GET",
"path": "/api/v1/public/status"
},
{
"method": "GET",
"path": "/api/v1/public/teams"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/activity"
},
{
"method": "GET",
"path": "/api/v1/public/teams/:slug/members"
},
{
"method": "GET",
"path": "/api/v1/public/teams/by-external/:moduleId/:externalId"
},
{
"method": "GET",
"path": "/api/v1/public/teams/unsubscribe/:token"
},
{
"method": "POST",
"path": "/api/v1/public/teams/unsubscribe/:token"
},
{
"method": "GET",
"path": "/api/v1/public/version"
@@ -674,6 +842,14 @@
{
"method": "GET",
"path": "/internal/bot-config"
},
{
"method": "GET",
"path": "/internal/commands"
},
{
"method": "POST",
"path": "/internal/commands/dispatch"
}
]
}