Compare commits

..

1 Commits

Author SHA1 Message Date
2fd5d065b7 docs(website): §7.1 Q9 — core's own news.post emitter
`news.post` is a declared trigger with no caller: `coreTriggers.js` says so in
as many words, and Phase 6 migrated only the four `team.*` ones. A rule naming
it can never fire, so on a real deployment the only in-app or email items the
engine can produce today come from Teams. Phase 7 flagged it in passing; this
writes it down properly as an open question.

The substance is not the call — it is the three other things a news publish
already fires, and which of them the engine has any business replacing:

- the announce leg (`announce_job_legs`, `module-uo` owns `towncrier`) — a
  one-shot delivery to a channel of the deployment, with retry. NOT the
  engine's.
- a module's post hook (`registerPostHook`) — idempotent state mirroring that
  also runs on delete and refreshes on a silent edit. NOT the engine's.
- the raw `pushDispatch.publish('news.post', …)` — a per-person notification.
  THIS is the one that becomes an emit.

So modules keep both doors onto a news publish and neither changes. What a
module does not get is the ability to fire `news.post` itself — the id's owner
is core, `emit` binds the owner at the call, and §7.2's one namespace gives an
id exactly one owner across both facets. A module wanting its own person-facing
news notification declares its own trigger.

Three things to settle first, recorded rather than decided: continuity (the
emit replacing the tickle stops push silently until a rule is enabled — G22's
shape, and Phase 6 decision 3's), reusing the job-id transition signal rather
than re-deriving it, and which phase owns it. Recommended home: Phase 11, whose
title understates it — a pointer and an extra acceptance line land there too.

Code: RunicGateway/website#175

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:27:48 -05:00
17 changed files with 128 additions and 9160 deletions

View File

@@ -64,10 +64,7 @@ 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`) |
| [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**, shipped 2026-09-01 as bundle 2026.09.01 (sidecar v2.1.0 + overlay v1.1.0) |
| [v6.md](link/v6.md) | Protocol 6 — idempotent commands, config leases with a shard-side deadline, and the run-scoped participation ledger. On `edge`; reaches `main` at the events cutover |
| [v7.md](link/v7.md) | Protocol 7 — the world verbs an event OWNS: creatures, bosses, oracle NPCs, temporary gates, decoration, and the persisted ownership registry behind them. On `edge` |
| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`). **The current protocol** |
| [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

@@ -661,8 +661,6 @@ not rank).
| Contact | everyone | `/public/contact` |
| **My Account** | signed-in | `/auth/me/account/*` — one surface for every role (see §6.4) |
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
| **Events** | everyone, *if core serves events* | `/public/events`, `/public/events/:slug`, `/public/events/series/:slug` (M13) |
| **My Events** | signed-in, any role | `/player/events/history` (M13) |
| Sign in / Sign out | toggles on session | `/auth/mobile/*` |
Guidelines:
@@ -674,15 +672,6 @@ Guidelines:
the caller may actually reach. While that answer is unknown (in flight, or the lookup failed) the app
shows everything: the server gates regardless, and a nav that flickers in on every load is worse than
a link that briefly `403`s.
- **Nor is it the only gate on a MODULE's surfaces (M13).** A feature flag says whether a shard
publishes a surface to this viewer; it cannot say whether the module that owns it is installed at
all. `GET /public/modules` and core's own `capabilities` in the `version` block answer that, per
host rather than per viewer, and an entry may declare a capability alongside its feature. The two
compose and both must pass. **The two failure directions differ, and deliberately:** an unknown
*feature* answer shows everything, because the server gates every call regardless; an unknown
*capability* answer shows everything too, but a **known** one that does not name the capability
hides the rows — absence of an answer is not an answer of absence. Without that distinction a site
with no `uo` module renders five shard rows that each `404`.
- Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public
groups and a "Sign in" affordance.
- The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still
@@ -703,11 +692,6 @@ Guidelines:
- **CMS pages** — `GET /public/pages/:slug` (block-based; render the block types the site uses).
- **Wiki** — list/categories/tags/detail as above.
- **Contact** — `POST /public/contact` (rate-limited; handle 429/502).
- **Events (M13)** — `GET /public/events` (the calendar; entries are a materialised `run` or a
`projected` forecast and say which), `GET /public/events/:slug` (one event, `?run=` selecting
which occurrence a results table is about), `GET /public/events/series/:slug` (an arc). Gated by
core's `events` capability, not by a module's. A draft, an archived and an *unlisted* definition
all answer `404`, indistinguishable from a slug that never existed.
### 6.2 Public shard (live)
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
@@ -775,18 +759,6 @@ 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.
---
@@ -1144,169 +1116,6 @@ push, and Play (M6–M8) follow the designed app.
rather than absorb it. Phase 8 also narrowed a stale `ui/theme/**` Sonar coverage glob that was
discarding three 98–100%-covered resolvers. **477 tests green.**
14. **M13 — Events in the app** (post-v1; scoped 2026-09-08). The website's Event System reached its
public surface in events Phase 14a (website#196 / docs#224): a public calendar, event pages, arcs
and participation history, plus an `events` capability string in the `version` block. This
milestone is **events Phase 14b** — the app's half — and the phase was split on the repo line for
the reason 12a/12b was: the app cannot be walked against a page that does not exist yet.
**Design of record: [`../website/EVENTS.md`](../website/EVENTS.md)** §I and its API surface table,
and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 14b. The contract is normative
there; this entry records what the app does about it.
**No backend work.** All four routes are live on the website's `edge`:
`GET /public/events`, `/public/events/:slug` (with `?run=`), `/public/events/series/:slug`, and
`GET /player/events/history`. `MODULE_API_VERSION` is untouched — nothing about the module
contract changes here.
#### The prerequisite, which is in this milestone and not after it
**The app cannot tell a module that is not installed from a lookup that failed, and it renders a
broken drawer because of it.** `ShardFeaturesRepository.refresh()` maps *every* failure to `null`
and `canSee(null, …)` answers `true`, so on a site with no `uo` module — which is any site running
a different game — `GET /public/shard/features` 404s, all five shard rows render, and every one of
them 404s when tapped. Failing open is right *within* a module that is installed (an admin may
switch a surface off between two refreshes, and the server gates every call regardless) and wrong
for one that is not there at all.
Three decisions, taken by the org lead on 2026-09-08:
- **Absence of an answer is not an answer of absence.** A *successful* read of
`GET /public/modules` that does not name a capability **hides** the rows that need it — that is
an answer. A read that *failed* keeps the last answer this host gave, and when the host has
never answered the gate stays open and the drawer renders as it does today. The bug is that the
current code collapses those three into one `null`.
- **The capability gate composes with the feature gate; it does not replace it.** They answer
different questions and both must pass. `capability` asks *is this module installed at all*, and
is per-host — it changes when an operator installs or removes a module. `feature` asks *does this
shard publish this surface to this viewer*, and is per-viewer — it changes on sign-in. That is
also why the two refresh on different clocks: capabilities with the appearance (per host),
features with the session (per viewer).
- **One capability gates all five shard rows: `shard`.** module-uo also declares `atlas`, `market`,
`champs`, `guilds`, `governors`, `houses` and `cliloc`, and gating each row on its own string
would be a second, worse copy of what `feature` already decides. The two new events rows gate on
core's own `events`.
Core's list and a module's are **separate lists**, deliberately, and the app reads both:
`capabilities` in the `version` block of `GET /public/status` for core, and `GET /public/modules`
for the installed modules. See [`../website/MODULE_API.md`](../website/MODULE_API.md) §2.9 — an
unknown string is treated as absent, and **no route may be inferred from one**.
#### The website path → route table was stale, and that is a second live defect
Found while scoping this milestone, and **fixed here** rather than logged (org lead, 2026-09-08).
[`NavPaths.kt`](#) quotes the website's `export const NAV` verbatim as sixteen rows. Since the
module-system cutover on 2026-08-12 core's `NAV` is **eight** rows and the nine shard rows belong
to module-uo, which registers them at `/uo/shard`, `/uo/champs`, `/uo/guilds`, `/uo/governors`,
`/uo/houses`, `/uo/rules`, `/uo/atlas`, `/uo/leaderboards` and `/uo/market` — core mounts a
module's public pages at `/<id>/<path>`. Three things follow, all of them true of the shipped app:
- a nav override an admin sets on any shard row is **ignored**, because the merge intersects with
the app's own menu through a table that no longer resolves those paths;
- an added link to a shard page **hands off to a Custom Tab** instead of opening natively, and
`/uo` alone resolves to a CMS page that does not exist;
- `WEB_ROUTE_ORDER` is the number line a stored `order` is compared against, and a sixteen-row
line against a nav that now numbers core 0–7 and appends the module's rows after it puts a
partly-overridden drawer in the wrong order. `/site/events` is a new core row at index 2, so
every row after News shifts.
**The nine `/uo/*` paths are hardcoded, and documented as coupling to one module** (org lead). The
alternative — deriving `/<id>/…` from the module list — infers a route from a capability, which
§2.9 forbids, and would hardcode the same path shape less visibly. A site running a different game
module maps none of the nine and its links hand off to the browser, which is the correct answer.
#### What ships
Four screens, one per 14a surface, and the arc is included because the event page links to it —
omitting it would make that link a dead end or a hand-off out of a native screen:
| Screen | Route | Reads |
| --- | --- | --- |
| **Events** (the calendar) | `events` | `GET /public/events` |
| **Event** | `events/{slug}?run={run}` | `GET /public/events/:slug` |
| **Arc** | `events/series/{slug}` | `GET /public/events/series/:slug` |
| **My Events** | `account/events` | `GET /player/events/history` |
- **The calendar is a list, and a projection is drawn differently from a run.** Both are §I's
rules, one tier along: an entry past the materialisation horizon is arithmetic with nothing
committed to it, so it is labelled rather than drawn as a booking.
- **The day heading is the reader's, the time beside it is the event's.** The split is §I's and it
is the one thing about event times that is easy to get wrong. A player in Berlin told an American
shard's event is at 02:00 has been told something true and useless.
- **The status word follows the clock, not the status.** The server publishes `failed` and `missed`
as `cancelled`; *did not happen* is right for a past occurrence and false for a future one, and a
run four days out that an operator called off is the common case. This is 14a's own defect, and
the app must not re-introduce it in Kotlin.
- **`?run=` is carried, not dropped.** The page lives at the definition's slug so a weekly event
has one address, and the occurrence is in the query.
**My Events is one drawer row at `SIGNED_IN`, not two** (org lead). The website mounts
participation history twice — `/account/events` and `/admin/events/mine` — only because
`RequirePlayer` guards `/account` and the route behind it is role-agnostic. The app's drawer has no
such wall: `GET /player/events/history` is `requireAuth` alone and self-scoped on the caller's own
id, so one row serves players and staff alike. It is deliberately **not** at `PLAYER` access, which
would imply event history is game-linked data; it is not, and a shard with no game module still has
events.
#### An inbox link to an event opens the app, not the browser
**Departing from the recommendation** (org lead, 2026-09-08). Engagement Phase 8's contract is that
an inbox item's `url` is site-relative and hands off to a Custom Tab. Phase 14a gave the six public
`event.` triggers an `eventUrl` of exactly the form `/site/events/<slug>?run=<id>`, so an inbox row
about an event now has a native destination and should use it.
The mechanism is the one that already exists — `resolveWebPath` — rather than a second one. That
forces one rule to change, and it is worth stating because it is the exact trap the rule was
written to avoid: **a path carrying a query hands off**, because no app route took one and a
native match would silently drop what the admin wrote. The event page is the first app route that
takes a query, so the rule becomes *a query hands off, except `run` on an event page*. Anything
else in the query string, and any fragment, still hands off untouched.
- **Excluded**, in the same class as M10's, M11's and M12's exclusions: the events **authoring**
surface. The app does not gain an event editor, a run console or the cap meter; Phase 13 built
those for the web and they are admin *configuration*, which the app is a consumer of and not an
editor for.
**`account/events`, not `events/mine`**, and the reason is a bug one tier along: `events/mine` and
`events/{slug}` are both two segments, and a static-versus-argument race between two NavHost
patterns is exactly what events Phase 13 found in React Router, where a static `events/new`
outranked `events/:id` and made creating an event impossible for seven phases. Under `account/`
there is no dynamic sibling and no race to lose.
**Verified:** the app suite (**570 tests, 0 failures**), and an emulator walk against a local
website running the 14a code, at three rungs — anonymous, player and admin.
#### The walk found three defects, and 563 green tests found none of them
The rig's own site had **no `uo` module installed**, which made it the exact case the prerequisite
fix is for, and is why two of the three were visible at all.
- **The three player game-data rows were not gated.** `My characters`, `My vendors` and `My houses`
read `/player/shard/*` — the same module's *player* mount — and rendered on a backend with no
module, answering *"This content couldn't be found"* with a Retry button. The gate had been
applied to the five public shard rows only. The test that was meant to catch this asked whether
every row **with a `feature`** declared the capability, which is true and insufficient: these
three carry no feature, because they are gated by ownership rather than by the visibility
framework. It now asks by **route**.
- **`score` is `DECIMAL(18,4)`, and declaring it an integer breaks the whole response.** The first
history row the walk read scored `318.5`. kotlinx does not round a fraction into a `Long` — it
refuses the entire body, so a `200` reached the screen as *"Something went wrong on the server."*
The same field is on the public results table, so **any** event whose module scores fractionally
would have broken the event page for every anonymous visitor.
- **A drawer route's view model outlives a sign-out, and this one is per-account.**
`navigateTopLevel` saves and restores back-stack state, so the entry keeps its `ViewModelStore`
and a view model that loads only in `init` never runs again. Signing out of the admin and back in
as a player showed the **player the admin's participation history**, with no request made at all.
It now keys on the signed-in account id, and the shell sends a signed-out caller home rather than
letting the screen ask and render a 401.
What the walk **confirmed working**: every shard row absent on a module-less backend at all three
rungs while Events remained; a cancelled occurrence three days out reading *Cancelled* rather than
*Did not happen*, on both the calendar and the event page; projections labelled; the event's own
zone beside the reader's own day; an arc; `?run=` surviving the NavHost pattern to reach the server;
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
participation history self-scoped, proved by two accounts rather than asserted.
### Deferred (not a milestone)
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
@@ -1498,48 +1307,9 @@ Four properties the UI should be built on rather than around:
`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.
**Phase 8** (`ENGAGEMENT.md`) is where the app grows the in-app inbox and this screen gains the
per-channel toggles. Until then the existing per-stream screen keeps working against
`/notifications/subscriptions` unmodified.
## 12. Build & CI (Gitea Actions)

View File

@@ -52,9 +52,6 @@ android-app/
│ │ │ │ │ │ ├── SessionManager.kt
│ │ │ │ │ │ ├── TokenStore.kt
│ │ │ │ │ │ └── TrustTokenStore.kt
│ │ │ │ │ ├── inbox/
│ │ │ │ │ │ ├── DataStoreInboxCache.kt
│ │ │ │ │ │ └── InboxCache.kt
│ │ │ │ │ ├── net/
│ │ │ │ │ │ ├── AuthInterceptor.kt
│ │ │ │ │ │ ├── BaseUrlHolder.kt
@@ -172,12 +169,8 @@ android-app/
│ │ │ │ │ │ ├── PostScreen.kt
│ │ │ │ │ │ └── PostViewModel.kt
│ │ │ │ │ ├── notifications/
│ │ │ │ │ │ ├── InboxBadgeViewModel.kt
│ │ │ │ │ │ ├── InboxFormatting.kt
│ │ │ │ │ │ ├── InboxScreen.kt
│ │ │ │ │ │ ├── InboxViewModel.kt
│ │ │ │ │ │ ├── NotificationSettingsScreen.kt
│ │ │ │ │ │ └── NotificationSettingsViewModel.kt
│ │ │ │ │ │ ├── NotificationsScreen.kt
│ │ │ │ │ │ └── NotificationsViewModel.kt
│ │ │ │ │ ├── page/
│ │ │ │ │ │ ├── BlockRenderer.kt
│ │ │ │ │ │ ├── PageScreen.kt
@@ -335,7 +328,6 @@ android-app/
│ │ │ │ │ └── WikiDtoTest.kt
│ │ │ │ └── fake/
│ │ │ │ ├── FakeAdminApi.kt
│ │ │ │ ├── FakeNotificationsApi.kt
│ │ │ │ ├── FakePlayerShardApi.kt
│ │ │ │ ├── FakePublicApi.kt
│ │ │ │ └── FakeShardStream.kt
@@ -363,10 +355,7 @@ android-app/
│ │ │ │ ├── NavPathsTest.kt
│ │ │ │ └── NavTreeTest.kt
│ │ │ ├── notifications/
│ │ │ │ ├── InboxFormattingTest.kt
│ │ │ │ ├── InboxViewModelTest.kt
│ │ │ │ ├── NotificationRoutingTest.kt
│ │ │ │ └── NotificationSettingsViewModelTest.kt
│ │ │ │ └── NotificationRoutingTest.kt
│ │ │ ├── player/
│ │ │ │ ├── CharacterSheetHelpersTest.kt
│ │ │ │ ├── CharactersViewModelTest.kt
@@ -388,7 +377,6 @@ android-app/
│ │ │ └── UiStateTest.kt
│ │ ├── util/
│ │ │ ├── FakeApiSupport.kt
│ │ │ ├── FakeInboxCache.kt
│ │ │ └── MainDispatcherRule.kt
│ │ └── ScaffoldSanityTest.kt
│ ├── build.gradle.kts

View File

@@ -21,12 +21,6 @@ None of these are *moderation*. A staff member who wants to kick a cheater, ban
**The thesis up front:** a small, well-guarded set of account/session-moderation verbs plus the help-page queue covers the overwhelming majority of "why do I have to log in to the game for this" moments. World-building and object manipulation (`[add`, `[set`, `[dupe`, decorate, spawners) should stay in the game client — they are target-driven, high-blast-radius, and gain nothing from a web form.
> ⚠ **Amended 2026-09-01 — see [§8a](#8a-amendment--2026-09-01).** That thesis holds for *ad-hoc staff
> action*, and the Event System does not contest it: a GM still does the target-driven parts in-client.
> What was reopened is narrower — declared, cap-bounded, ledgered, idempotent creation and **leased**
> mutation, both owned by an event run and both undone at teardown. `[set` / `[get` / `[add` and
> loot-table changes stay excluded exactly as this paragraph argues.
---
## 2. How ServUO admin controls actually work
@@ -290,11 +284,6 @@ All resolved by source inspection (ServUO checkout at `<servuo>`). No live-shard
## 8. Decisions — locked 2026-07-12
> ⚠ **Partly amended on 2026-09-01. Read [§8a](#8a-amendment--2026-09-01) with this section.** Two of
> the exclusions in the first bullet below — world creation, and item/gold grants — were reopened for
> the Event System under conditions that did not exist when this was locked. The bullet is left
> standing verbatim because it is the decision that was actually taken; §8a is what changed and why.
- **Scope:** Phase 1 (kick / ban / unban / broadcast) + Phase 2 (help-page queue) + Phase 3 second-wave. **The former Tier-H verbs (firewall, kill/res, jail, item/gold grants, set-access-level) are cut entirely** — not now, not later.
- **Authorization:** enforced on the **website** (admin-only + moderator roles). Inbound sidecar commands are applied on the shard as **CoOwner-level** authority, with a hard floor that refuses any target at `AccessLevel >= CoOwner` (Owner-only shield). Write plane defaults **off** in `Bridge.cfg`.
- **Attribution:** `web:<actor>` in `CommandLogging` and the `BanDealer` tag; no core edits.
@@ -303,70 +292,6 @@ All resolved by source inspection (ServUO checkout at `<servuo>`). No live-shard
---
## 8a. Amendment — 2026-09-01
**What changed:** §8's blanket exclusion of world writes is narrowed, in **two** places, and only for
the Event System. Everything §8 decided about the moderation write plane — its verbs, its
CoOwner-level authority, its Owner-only shield, its `Bridge.cfg` default-off switch, its attribution
and its logging — stands unchanged.
**Decided by:** the org lead, 2026-09-01, answering
[`../website/EVENTS.md`](../website/EVENTS.md) §N1. The design of record is `EVENTS.md`; this section
records the reversal against the decision it reverses, so §8 is never read alone.
### What is now permitted
| | Permitted | Under what conditions |
|---|---|---|
| **1** | **Event-owned creation** — creatures, "simple" boss variants, oracle NPCs, temporary gates, temporary decoration | Created by an event run, counted against a module-declared cap dimension, recorded in the resource ledger with the run, step and actor that produced it, and **deleted by serial at teardown** on every terminal path — completion, cancellation and abort alike |
| **2** | **Leased mutation** of a plugin-declared allowlist of config values and object properties | Baseline recorded before the write, drift-checked on read-back, and **deadline-enforced by the game itself** — the shard restores baseline when the lease expires whether or not the website ever comes back |
| **3** | **Item and gold grants** | As (1): cap-bounded, ledgered and attributable — but `reversible: 'none'`. A granted item is not clawed back |
### What stays excluded, permanently
Unchanged by this amendment and not reopened: **loot-table changes**, and **arbitrary `[set` / `[get`
/ `[add`**. Both are the "change something with no baseline recorded" case, which is the thing §8 was
actually objecting to; no cap makes them safe. `firewall`, `kill`/`res`, `jail` and `set-access-level`
also stay cut — this amendment is about what an *event* may create and borrow, not about restoring the
Tier-H moderation verbs.
### Why the reversal, and why item grants are called out separately
**§8's objection was to an unrecorded change with a large blast radius, made from a web form.** It was
locked when the bridge was a read plane with three inbound verbs, none of which were moderation, and
when the only way to express "change the world" would have been a passthrough command string. In that
setting the objection was correct and it would still be correct today for anything shaped that way —
which is exactly why `[set` / `[get` / `[add` remain excluded.
**The Event System proposes something with four properties that shape did not have**, all of which are
required before P12 ships and none of which are optional:
1. **Declared, not typed.** A module declares a verb with its params and their types; the browser posts
an action id and a params object. There is no passthrough field and no place a request body can name
a sidecar route.
2. **Cap-bounded.** Every dimension is enforced in a conditional `UPDATE` with the cap in the `WHERE`
clause — a control a stolen admin session cannot talk its way past, because a role check is exactly
what a stolen session has already passed.
3. **Ledgered.** Every created object and every borrowed value is recorded in `event_run_resources`
before it is confirmed, with its run, step and actor, and with a declared revert.
4. **Idempotent on the wire.** A key minted once per step rides core → module → sidecar → plugin, and
the plugin answers a repeat with the original result rather than acting twice — so a lost
acknowledgement cannot double a spawn or a grant.
**Item grants are named as their own row because they are a second reversal, not a consequence of the
first.** §8's cut list names "item/gold grants" explicitly and separately from world building; reading
the creation reopening as carrying grants along with it would be the silent reversal this amendment
exists to avoid. They are permitted on the same four conditions as creation — with the difference that
they are **not reversible**, which is why the cap and the ledger carry the entire weight for this row
and why the action ships declared as `reversible: 'none'` rather than pretending at a revert.
**What has not changed:** the module boundary is still not a security boundary, and none of the above
defends against a hostile module. It defends against a compromised session and an operator mistake —
both of which *scheduling* makes larger, because a scheduled change happens while nobody is watching.
That is the reason the caps and the leases matter more here than the role check does.
---
## 9. Where the code goes
| File | Responsibility |

View File

@@ -55,26 +55,20 @@ 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 **6**. It is not released yet — it lives on `edge` and ships with the event system's cutover; the last released pairing is protocol **5**, sidecar **v2.1.0** + overlay **v1.1.0**, resolved as bundle **2026.09.01**, never as "latest of each".
The current version is **4**, shipped in sidecar **v2.0.0** and overlay **v1.0.0**.
- Every response carries an **`X-UOLink-Version: 6`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 6`.
- **Optionally**, send `X-UOLink-Version: 6` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
- 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**:
```json
{ "error": "protocol version mismatch", "sidecar_protocol": 6, "client_protocol": "5" }
{ "error": "protocol version mismatch", "sidecar_protocol": 4, "client_protocol": "3" }
```
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
**v6 (Protocol 6)** is the first bump that adds a **promise** rather than data: a command carrying
an `idempotencyKey` is executed at most once (see *Retrying a command safely* in §6). It also adds
`champ.boss.killed`, and the **event plane** — leases and the run-scoped participation ledger, six
endpoints, all of them gated on the shard by `Bridge.EventsEnabled` and answering **403** when an
operator has not switched it on. See [`v6.md`](v6.md).
**v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
`vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
@@ -88,36 +82,6 @@ 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".
**v6 (Protocol 6)** is the first bump that is mostly about a **guarantee** rather than about data
([`v6.md`](v6.md)). A command may now carry an **`idempotencyKey`**, and the shard promises to
execute a key **at most once**: a repeat is answered with the original reply rather than re-run. That
is what makes a world-writing command safe to retry at all — before it, a lost acknowledgement and a
command that never applied were the same event as seen from the caller. See §6's write plane for how
to send one, and §7 for the one new status code it introduces.
It also adds **`champ.boss.killed`**, a champion's defeat with the damage table only the shard ever
sees. Previously this was inferable from `champ.update` losing its `bossUp` alongside a nearby
`mob.killed` — a signal that also fires when a GM resets a spawn and that says nothing about who did
the work.
Additive again: no existing field changed shape, **no new endpoint**, and a client that sends no key
behaves exactly as it did under v5.
**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
@@ -204,8 +168,7 @@ 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, 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 |
| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) |
#### Economy & commerce
| kind | fields | notes |
@@ -241,36 +204,15 @@ 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`, `ownerName`*, `ownerAcct`, `schedule:{...}`*, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `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":"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"}
{"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"}
```
#### Economy supply (periodic)
@@ -316,7 +258,6 @@ Champion spawns have no in-game event either, so they're polled (`ChampSweepSeco
|------|--------|-------|
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
| `champ.boss.killed` (Protocol 6) | `category`, `bossSerial`, `boss`, `bossType`, `map`, `x`,`y`,`z`, `region`, `killer`, `damagers` — plus `serial`, `type`, `level` naming the ALTAR when the kill could be attributed to one | The boss went down. A real event, not a polled diff — see below. |
`status` is one of:
- **`active`** — running (or, for `sea`, the boss is alive).
@@ -344,43 +285,6 @@ Category-specific fields on `champ.update`:
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
##### `champ.boss.killed` (Protocol 6)
The one champion frame that is **not** polled. It fires on the death itself, so unlike everything
above it is an event rather than a difference between two snapshots — which means a first sighting
of it is the thing being reported rather than a baseline to compare against.
```json
{"kind":"champ.boss.killed","category":"champion","bossSerial":"0xD8D","boss":"Semidar",
"bossType":"Semidar","map":"Felucca","x":1496,"y":1628,"z":-5,"region":"Britain",
"serial":"0x400150E8","type":"Abyss","level":0,
"killer":{"serial":"0x2E0","name":"tester","player":true},
"damagers":[{"serial":"0x2E0","name":"tester","player":true,"damage":100240},
{"serial":"0x24C","name":"Darrow","player":true,"damage":120}],
"t":1788551315000}
```
- **`serial` means the ALTAR here**, matching `champ.update`, so the two join without a rule about
which of two serials means what. It is **absent** — with `type` and `level` — when the boss could
not be attributed to a spawn, which happens when one pops and dies inside a single sweep interval.
The kill is still reported; it simply arrives without its altar.
- **`damagers` is every player who damaged it, highest first**, each the standard actor object plus
a `damage` total. Totals are summed per player, so nobody appears twice. Entries are included
whether or not the shard still considers them valid for **looting rights** — someone who fought
two thirds of the fight and then died took part in it. Capped at 20.
- **`region` is the nearest NAMED region** and is **absent** in open countryside. It is not the most
specific region containing the boss: an active champion altar registers an unnamed region of its
own over its spawn area, so the innermost answer is always nameless. Absent means "nowhere with a
name", never "the shard would not say".
- `category` is `champion` or `sea`. There is no `mini` — a `MiniChamp` has no boss.
**This does not replace `champ.remove`.** A slain `sea` boss still produces one, because it also
leaves the board. A `champion` altar stays on the board and goes to `cooldown` as usual.
**`damagers` names players and ranks them.** The sidecar serves it verbatim, as it serves
everything; deciding who may see a damage table is the consuming site's job. The website's own
answer is `staff` by default with the kill itself public — see [`v6.md`](v6.md) §4.
#### Guilds (Protocol 2.0)
Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board.
@@ -619,46 +523,14 @@ shop name or location actually change.
| kind | fields | notes |
|------|--------|-------|
| `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` | `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.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},
@@ -937,128 +809,6 @@ Each applied action also emits an unsolicited **`admin.audit`** frame on the Web
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
by staff in the game client surfaces the same way with `origin:"in-game"`.
### Retrying a command safely — `idempotencyKey` (Protocol 6)
Any command in this section may carry an **`idempotencyKey`**, and the shard promises to execute a
key **at most once**. A repeat is not re-run: it is answered with the **original reply**, restamped
with the repeat's own correlation id and marked `"replayed": true`.
```json
POST /admin/broadcast
{ "actor":"event:412", "text":"The gates open at dusk.", "idempotencyKey":"5f2c…" }
→ 200 { "kind":"admin.ok", "reqId":"r-1", "action":"broadcast", "t":1788550182074 }
→ 200 { "kind":"admin.ok", "reqId":"r-2", "action":"broadcast", "t":1788550182074, "replayed":true }
```
Note the second reply's `t`: it is the **first** attempt's, because it is the stored answer rather
than a fresh execution. The world write happened once.
This is what makes a command safe to retry after a timeout. Without a key, a lost acknowledgement
and a command that never applied are the same event as seen from here, and the only safe policy is
to give up on the announcement rather than risk sending it twice.
Four rules for a caller:
- **A key belongs to your unit of work, not to the attempt.** Derive it from something stable — the
website's event runner uses `sha256(runId|stepId)` — so every retry of one action carries the
same key and a different action never collides with it. A fresh value per call satisfies the field
and defeats the entire mechanism.
- **A key is remembered for one hour**, bounded at 4096 keys per shard. Retry inside that window.
- **A repeat that arrives while the original is still in flight** is answered **425 Too Early**
(`{"kind":"bridge.busy"}`). Nothing ran; come back. It is transient by construction.
- **A replayed reply is an ordinary 200.** Treat it exactly as you would have treated the answer you
lost; `replayed` is for your log.
Sending no key is exactly the pre-protocol-6 behaviour, which is the right choice for a command a
human just pressed a button for and can see the result of.
The DELETE forms (`/towncrier/{id}`, `/news/{id}`) take no key: their idempotency is inherent — the
second removal of an entry is a no-op the shard is already happy to perform.
### The event plane — leases (Protocol 6)
**Off by default.** Every endpoint below answers **403** unless the operator has set
`Bridge.EventsEnabled` on the shard. That is deliberately not the admin write plane's switch:
enabling admin writes is consenting to staff moderation from a screen a human is looking at, and
enabling this is consenting to your world being changed and watched on a schedule, unattended.
A **lease** is a live configuration value held at a new setting for a bounded time. The shard
restores the baseline when the deadline passes **whether or not you are ever heard from again** —
so the worst case is a world back at baseline early, never one stuck changed indefinitely.
```json
GET /lease
→ 200 { "kind":"lease.list.ok", "leases":[
{ "key":"PlayerCaps.SkillCap", "label":"Starting skill cap", "type":"float",
"min":1000, "max":1500, "default":"1000", "current":"1000", "held":false } ] }
POST /lease
{ "key":"PlayerCaps.SkillCap", "value":"1200", "holdMs":600000,
"untilMs":1788567000000, "runId":"77", "idempotencyKey":"…" }
→ 200 { "kind":"lease.ok", "baseline":"1000", "applied":"1200", "untilMs":1788567000000 }
POST /lease/release
{ "key":"PlayerCaps.SkillCap", "expected":"1200", "baseline":"1000" }
→ 200 { "kind":"lease.ok", "released":true, "current":"1000" }
```
Five things a caller needs:
- **The catalog is an allowlist and it is short.** A shard advertises only keys it has verified take
effect. Most of ServUO's configuration is cached at type initialisation, where a lease would apply
cleanly and do nothing — the worst failure this feature has — so `lease.list` is the authority and
"any config key" is not offered.
- **`holdMs` is what the shard honours; `untilMs` is for display.** Send both. An absolute deadline
is measured against two clocks, and a shard whose clock runs fast would restore your lease the
moment it took it.
- **Values cross as text, whatever `type` says.** `"1200"`, not `1200`. Comparison is done on parsed
values at the other end; the text is so a compare-and-set is comparing what you sent.
- **`released` can answer `lease.drifted` at 200.** That means somebody moved the value while you
held it, the shard **did not overwrite them**, and `current` is what is there now. It is not an
error: the mechanism did its job, and only a human can decide what should happen next.
- **`held` means the shard still has a record of the lease, not that the value is still overridden.**
A lease whose deadline has fired stays listed with `expired: true` until you release it, so a
reconcile in that window does not read a working backstop as a lost resource. A shard restart, by
contrast, reverts every lease and clears the record — `held: false` is how you learn that.
### The event plane — participation (Protocol 6)
A run-scoped tally of who took part: presence in a declared area, plus kill credit inside it, keyed
by **character serial**. The shard computes a score and you store it; the components ride along so
you can explain it.
```json
POST /participation
{ "runId":"99", "map":"Felucca", "x":1496, "y":1628, "radius":40, "holdMs":3600000 }
→ 200 { "kind":"participation.ok", "runId":"99", "members":0, "closed":false }
POST /participation/99/snapshot
{ "idempotencyKey":"…" }
→ 200 { "kind":"participation.snapshot.ok", "runId":"99", "members":2, "killWeight":5,
"participants":[ { "serial":"0xCB20", "name":"Jarvis", "acct":"seed_001", "webId":"17",
"seconds":3600, "minutes":"60.00", "kills":3, "score":"75.0000",
"firstMs":1788550182074, "lastMs":1788553782074 } ] }
POST /participation/99/close
→ 200 { "kind":"participation.ok", "closed":true, "known":true, "members":2 }
```
- **The area is a point and a radius, not a region name.** The most specific region containing an
event is routinely anonymous on a UO map — an active champion spawn registers a nameless region
over its own area — so a region-named venue would be undeclarable for exactly the places events
happen.
- **`snapshot` is a POST for a read**, because it carries your `idempotencyKey`. On a well-attended
run the shard walks its members across game ticks rather than in one call, so a repeat arriving
mid-walk is answered **425**. Come back.
- **The tally is persisted in the world save**, so it survives a restart mid-event. `close` on a run
the shard has forgotten answers `known: false` at 200 rather than an error: nothing is being
counted for it either way.
- **`refused`** on a snapshot is the number of members the shard's cap turned away. A truncated tally
says so rather than quietly being short.
- **`webId`** is present only where the character's game account is linked to a website user. Most
characters carry none; treat its absence as ordinary.
### Help-page (support) queue
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
@@ -1254,10 +1004,8 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
| 200 | OK |
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
| 401 | Missing or invalid auth token |
| 403 | Refused by the operator — the admin write plane, or the event plane (`Bridge.EventsEnabled`), is switched off on the shard |
| 404 | Not found (unknown account / character / id, a not-linked account, an unoffered lease key, or a run the shard is not counting) |
| 404 | Not found (unknown account / character / id, or a not-linked account) |
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
| 425 | Too Early — a command with this `idempotencyKey` is still in flight on the shard (Protocol 6). Nothing ran; retry |
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
| 500 | Internal error (e.g. database) |
| 503 | Shard not connected — the query needs the live game and it's down |
@@ -1265,11 +1013,6 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
`425` vs `409`: both are conflicts of a sort and they want **opposite** responses. A `409` is a
deployment fault — your pinned protocol version disagrees with the sidecar's — and retrying it will
never help. A `425` is a retry that will succeed on its own. They are deliberately different codes
so a retry loop cannot quietly swallow a mismatched deployment.
---
## 8. Putting it together
@@ -1277,7 +1020,7 @@ so a retry loop cannot quietly swallow a mismatched deployment.
A typical character page:
```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "6" };
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "4" };
// 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 | 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. |
| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. |
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
@@ -133,7 +133,6 @@ 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.**
@@ -145,7 +144,6 @@ 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.
@@ -155,20 +153,6 @@ 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
@@ -178,16 +162,10 @@ Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanL
| Player death | `PlayerDeath` | low | |
| Murder | `PlayerMurdered` | low | High-signal for the website. |
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. |
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. **Protocol 6 uses it for `champ.boss.killed`**: the handler's first act is to decide this is not a champion, by type, which is one `is` check per death. |
| **Damage table at a kill** | `Mobile.DamageEntries`, read inside `CreatureDeath` | — | Not a stream: the accumulated per-attacker totals, readable **only at the death**, since ServUO discards them with the creature. This is what makes a boss kill attributable without a per-hit event. |
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. |
> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink.
>
> But per-*kill* attribution needs no override at all, and Protocol 6 is where that was noticed:
> `Mobile.DamageEntries` already holds a running total per attacker, and a `CreatureDeath` handler
> reads it before the creature is disposed of. A whole participation signal, for free, at the one
> moment it is worth having.
### 5.6 Progression & activity
@@ -255,13 +233,6 @@ 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
@@ -301,57 +272,6 @@ Newline-delimited JSON, one object per line, `serial` as the primary key.
Every inbound handler marshals to the Core thread before touching world state.
### Any inbound command may carry an `idempotencyKey` (Protocol 6)
```jsonc
{"kind":"admin.broadcast","reqId":"r-17","actor":"event:412","text":"…","idempotencyKey":"5f2c…"}
```
The shard executes a key **at most once** and answers a repeat with the original reply, restamped
with the repeat's correlation id and marked `"replayed": true`. That is what makes a world-writing
command safe to retry after a lost acknowledgement, and it is a precondition for every world verb
the event system adds later — see [`v6.md`](v6.md) §2.1.
The gate sits in `BridgeBoot`'s inbound dispatch rather than in each handler, so it covers every
kind including ones a later protocol adds. A command with no key behaves exactly as it did before.
One new outbound kind comes with it, **`bridge.busy`**: a repeat arrived while the original is still
in flight, nothing ran, come back.
### The event plane, and the Bridge's first persisted state (Protocol 6)
```jsonc
{"kind":"lease.apply","key":"PlayerCaps.SkillCap","value":"1200","holdMs":600000,"runId":"77"}
{"kind":"participation.open","runId":"99","map":"Felucca","x":1496,"y":1628,"radius":40}
```
Six commands behind one new gate, `Bridge.EventsEnabled`, default off and deliberately not the admin
write plane's switch. Two facts about ServUO shaped both halves and are worth recording here rather
than only in the spec.
**`Server/Config.cs` is a real runtime store, and almost nothing reads it live.** `Config.Set`
mutates the in-memory entry table and `Config.Load()` is guarded by `_Initialized`, so a Set survives
every later Get — but of the **158** non-Bridge `Config.Get` call sites in `Scripts/`, roughly
**eight** are read at the call site. The rest are cached at type initialisation, where a lease
applies cleanly and does nothing at all. That is why the lease catalog is a verified allowlist and
never "any config key".
Two consequences, both deliberate: **nothing calls `Config.Save()`**, so a lease never reaches disk
and a shard restart is a free restore; and **`Config.Set` has exactly one caller in the whole tree**
(`Server/ScriptCompiler.cs`), so on a stock shard no GM can drift a configuration lease even
deliberately — the compare-and-set is still required for Phase 12's object-property leases, and
proving it needs the scaffolding driver.
**The participation ledger is the first thing this plugin has ever persisted.** A run spans hours and
a restart mid-event is ordinary, so an in-memory tally would silently regress every attendee's score.
`Server.Persistence` plus `EventSink.WorldSave` writes `Saves/Bridge/Participation.bin` beside the
world save — no persistence *item*, so no world object and nothing for a GM to delete by accident.
The hooks attach in `Configure()`, because `EventSink.WorldLoad` fires inside `World.Load()` and
`Initialize()` is too late.
**And the first handler that defers.** `participation.snapshot` walks a large run's members across
Core ticks rather than in one inbound call, which makes it the first handler to complete after
`OnInboundLine` returned — and therefore the first that can actually produce `bridge.busy`.
### `server.hello` is per-connection, not per-boot
The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to.

View File

@@ -1,279 +0,0 @@
# Protocol 5 — Three enrichments, one bump
**Status:** Released. Cut over to `main` on 2026-09-01 and shipped in sidecar **v2.1.0** + overlay **v1.1.0**, paired as bundle **2026.09.01**.
**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

@@ -1,649 +0,0 @@
# Protocol 6 — A guarantee, and the kind that pays for it
**Status:** In review on `edge`. **Both halves, 11a and 11b, as one protocol version** — see §7.
**Date:** 2026-09-04 (11a), amended 2026-09-05 (11b)
**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), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`INTEGRATION.md`](INTEGRATION.md) (website API).
**Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 11.
---
## 1. Why this bump is not like the other five
Every protocol version so far added **data**: a field, a frame, a board. This one adds a **promise**,
and the data it also carries is along for the ride because the ride is expensive.
The promise is one sentence:
> **A command carrying an `idempotencyKey` is executed at most once. A repeat is answered with the
> original reply.**
Nothing on the wire could say that before, and [`EVENTS.md`](../website/EVENTS.md) §A row 7 records
what that cost: *"a command whose ack is lost is indistinguishable from one that never applied."*
The website's event runner retries a step that did not come back — that is what a runner is for —
so every world-writing verb had to be declared un-retryable, and Phase 9 shipped `uo.broadcast`
answering `retry: false` to *everything*, including a 503 from a shard that was merely restarting.
The reasoning was sound and the trade was real: a lost announcement is cheaper than a doubled one.
It is not a trade that survives contact with the rest of the workstream. An event that spawns
creatures, grants an item, or holds a lease on a live config value cannot be built on a wire where
"did that apply?" has no answer. §G's capability table says so in as many words: *"Required before
any world write ships."*
**So the guarantee had to come before the verbs, and this is the version that has nothing else to
do.** Protocol 12's world verbs are the payload; protocol 6 is the floor they stand on.
### 1.1 Why `champ.boss.killed` rides along
The same argument v5 §1 made: a bump costs a sidecar release, a republished bundle and an operator
update on every shard, so a field left out costs a *second* bump rather than a follow-up commit.
`champ.boss.killed` is the one piece of new data the events workstream is known to need and can
state now. §G's capability table had a boss defeat as 🔗 — *inferable* from `champ.update` going
`bossUp` true then false, correlated against a nearby `mob.killed` — and called that
*"workable and slightly fragile"*. It is more fragile than that: `bossUp` also drops when a GM
resets a spawn, when a boss despawns, and after a sidecar reconnect clears the sweep's diff cache.
And the inference is silent about the thing an event most wants to know, which is **who fought it**.
A phase condition is written over a trigger firing, so a kind nothing declares is a kind no event
can wait on. *"Advance when the boss falls"* is the canonical UO event phase boundary, and it did
not exist.
---
## 2. The shard side
### 2.1 The idempotency key
**One new optional field on every inbound command**, and no new command kinds:
```json
{"kind":"admin.broadcast","reqId":"r-17","actor":"event:412","text":"...",
"idempotencyKey":"5f2c…40 hex"}
```
The gate is in `BridgeBoot.OnInboundLine`, before the handler is dispatched, so it covers **every**
inbound kind including the ones a later protocol adds — which is the half that is easy to forget. A
command with no key behaves exactly as it did before protocol 6, which is what leaves the admin
screens unchanged: a human pressing a button can see whether the thing happened, and sends none.
The store is `BridgeIdempotency`. Four rules, each of which is a decision rather than an
implementation detail.
**1. Reserve on receipt, not on completion.** The key is recorded *before* the handler runs. A
handler that finishes inside its own inbound call can never see a repeat — the Core thread processes
one line at a time — but a handler that defers (a lease that arms a timer, a spawn that waits for a
save) completes long after `OnInboundLine` has returned, and that is precisely the window a lost
acknowledgement opens. Reserving late would leave it uncovered.
A repeat of a key still in flight is answered **`bridge.busy`**: nothing runs, and the caller is
told to come back. It is deliberately not spelled `bridge.busy.error` — nothing is wrong, the work
is happening.
**2. A key that has begun is never released — except on a refusal.** Not when the handler throws.
Releasing it would let a retry re-run a command that may have applied half of itself, which is the
exact failure this file exists to prevent. A handler that throws stores a `bridge.error` reply
instead, so the retry gets a definite answer and the step fails once rather than looping.
**A REFUSAL is the third case**, added by the Phase 16 acceptance walk and amending protocol 7 in
place. A handler that ran to completion and answered `*.error` did not do anything — every refusal
on this plane is a guard: a missing `runId`, an unknown item, a cap, a rate limit, a write that
failed and left the value alone. Remembering it froze the answer for ever, so a refusal that
*waiting fixes* could never be retried past. `uo.world.save` is the case that found it: the shard
saves at most every 300 seconds, the module documents that as "the one refusal on this plane that
waiting fixes", and six attempts over four minutes all replayed one frozen sentence — "the last save
was 227 seconds ago" — because the number was the first reply's, not the clock's. A step's key is
one value for the life of the step, so the operator's retry control could not escape it either.
So a refusal releases the key: nothing happened, and the caller may ask again. The refusal is still
**emitted** to the caller, which is what ends that attempt; it is simply not remembered as the key's
answer. A refusal is recognised by its `kind` ending in `.error`, matched on the suffix so a handler
family added later is covered without extending a list. `bridge.error` is excluded deliberately —
that is the reply the shard writes when a handler THREW, which is the case whose key must be kept.
**This puts a rule on handlers, and it is the rule the release rests on: do not answer `*.error`
after changing the world.** Report a partial change in an `ok` reply, as `item.grant` does with
`granted`/`missed` and `world.despawn` with `removed`/`gone`/`refused`. The shard cannot verify
"nothing happened"; it takes the `.error` kind as the claim.
**3. A replay is stamped with the REPEAT's correlation id.** The sidecar's `reqId` is a fresh
per-process counter, so a retry is waiting on an id the first attempt never used. Replaying the
original id verbatim would leave the call hanging until the reply timeout — the very failure being
answered. The stored reply is re-emitted with its correlation field rewritten and `"replayed": true`
appended; everything else is byte-for-byte the original, including its original `t`.
`reqId`, `code` and `id` are all recognised, in the order `rpc.rs` tries them. A repeat that
correlates on a *different* field than the original did is refused with a `bridge.error` rather than
answered under an id nobody is waiting on.
**4. The bound is loud.** TTL one hour, cap 4096 keys, drop-oldest. The hour is not a tuned number:
core's step lease is fifteen minutes and its retry backoff is bounded well inside that, so expiry
should never be the thing that ends a key's life. The cap is the guarantee's **one hole** — an
evicted key's repeat *would* be applied a second time — so an eviction that drops a key still inside
its TTL prints a console warning naming the count. If the promise is ever actually breached, an
operator reads it here rather than discovering a doubled spawn in the world.
`[bridge status` reports `idem(keys= seen= replayed= busy= evicted= uncorrelated= refused=)`.
#### 2.1.1 How the reply is captured
The handlers emit their answers through `BridgeLink.Emit` like everything else, so there is nothing
to return. While a keyed handler runs, `Emit` offers each line to the store, and the store keeps
**only the line the sidecar would correlate with this command** — the one whose `reqId`/`code`/`id`
equals the command's.
That filter is the point. An `admin.audit` frame emitted alongside the reply is a fact about the
world and must **not** be replayed to a retry; the reply is an answer to a caller and must be. The
capture happens before `Emit`'s `_running` check, too, because a reply the link was too dead to
deliver is exactly the one a retry will come back for.
A keyed command that produces no correlated reply at all is treated as a defect rather than as a
state to model: it is logged by kind and key, and a definite `bridge.error` is stored so the retry
terminates instead of timing out forever.
### 2.2 `champ.boss.killed`
A new kind, emitted from `EventSink.CreatureDeath` in `BridgeChamps`.
```json
{"t":1788551315,"kind":"champ.boss.killed","category":"champion",
"bossSerial":"0xD8D","boss":"Semidar","bossType":"Semidar",
"map":"Felucca","x":1496,"y":1628,"z":-5,
"serial":"0x400150E8","type":"Abyss","level":0,
"region":"Britain",
"killer":{"serial":"0x2E0","name":"tester","acct":"wttest","webId":"1","player":true},
"damagers":[{"serial":"0x2E0","name":"tester","acct":"wttest","webId":"1","player":true,"damage":100240},
{"serial":"0x24C","name":"Darrow","acct":"whitlocktech","webId":"14","player":true,"damage":120}]}
```
| Field | Notes |
|---|---|
| `category` | `champion` or `sea`. There is no `mini`: a `MiniChamp` has no boss |
| `bossSerial`, `boss`, `bossType` | The creature. `boss` prefers its display name and falls back to its type |
| `serial`, `type`, `level` | **The ALTAR**, present only when the kill could be attributed to one. `serial` means the spawn here, matching `champ.update`, so a consumer joins the two without a rule about which of two serials means what |
| `map`, `x`, `y`, `z`, `region` | Where it fell. `region` is the nearest **named** region and is absent in open countryside — see §6.1 |
| `killer` | The standard actor object, and the last blow only |
| `damagers` | Every player who damaged it, **highest first**, each an actor object plus `damage` |
**Detection is by TYPE, not by the sweep.** `BaseChampion` — which `BaseSeaChampion` derives from —
so one check covers both families, and a boss that popped and died inside one sweep interval is
still reported. The sweep's `bossSerial → spawnSerial` map supplies only the altar *context*; a kill
it cannot attribute arrives without `serial`, `type` and `level` rather than not arriving.
**The damage table exists here and nowhere else.** ServUO discards a creature's damage entries with
the creature, so the shard is the only party that ever sees them. Totals are summed per damager
rather than trusted to be one entry each: ServUO folds repeat damage into an existing entry, but an
entry that expired and was re-created leaves two, and a table listing the same player twice reads as
two participants. Entries are reported **whether or not ServUO considers them expired** — expiry
governs looting rights, and someone who fought the first two thirds of a champion fight and then
died took part in it regardless of what they are owed from the corpse.
Capped at 20 entries so the frame stays one line on the wire.
---
## 3. The sidecar side
`PROTOCOL_VERSION: u32 = 5` → `6`, and **one behaviour**: `bridge.busy` maps to HTTP **425 Too
Early** in all three responders.
Everything else is free. The key rides in the command body, which every write endpoint already
passes through verbatim; `champ.boss.killed` lands in `events` and on the feed through the generic
forward path with no arm of its own. There is **no store migration** — nothing gains a column.
That is the dumb-forwarder property doing its job again (v3 §3), and it is worth naming what it
means here specifically: **the sidecar makes no idempotency promise of its own.** It does not dedupe,
does not cache, and does not know what a key means. The guarantee is the shard's, end to end, which
is the only place it can be — the shard is where the world write happens.
11b adds six routes and no new machinery. `event_call` is `admin_call` without the required
`actor`: an event verb's author is a **run**, which the body carries as `runId`, and demanding a
human name for something no human is doing would have the runner inventing one.
| Route | Command | Note |
|---|---|---|
| `GET /lease` | `lease.list` | The whole catalog with current values. One read serves both `read()` and `inForce()` on the website's side |
| `POST /lease` | `lease.apply` | |
| `POST /lease/release` | `lease.release` | A drifted answer is a **200** — see §8.3 |
| `POST /participation` | `participation.open` | |
| `POST /participation/:runId/snapshot` | `participation.snapshot` | **A POST for a read**, because it carries the caller's `idempotencyKey` and can be refused as a repeat in flight. A read that can legitimately answer 425 is not a GET |
| `POST /participation/:runId/close` | `participation.close` | |
`respond_event` is the fourth responder, and it exists for two mappings the generic one gets wrong:
a drifted lease is a 200 rather than a reason-sniffed 400, and the event plane being **switched off**
is a 403 rather than a 400 — `Bridge.EventsEnabled` is an operator's deliberate refusal, and telling
the website it sent a bad request would send an administrator hunting a bug in a step that is
written correctly.
### 3.1 Why 425 and not 409
409 is already the protocol-version gate's answer, and the two want **opposite dispositions** from a
client: a version mismatch is a deployment fault nobody should retry, and a busy shard is a retry
that will succeed on its own. Sharing a status would make the difference readable only by inspecting
the body, which is how a retry loop ends up hiding a mismatched deployment.
425 is what that status is for — a server unwilling to risk processing a request that might be a
replay — and it is unambiguous here because nothing else on this surface uses it.
A **replayed** reply, by contrast, is an ordinary **200**. The caller must be able to treat it
exactly as it would have treated the answer it lost; `replayed: true` is for the log.
---
## 4. Visibility
One classification, made on the website in `module-uo`'s `shardVisibility.js`, never in the sidecar.
| Field / kind | Audience | Why |
|---|---|---|
| `champ.boss.killed` | **`anonymous`**, on the existing `champs` feature | A champion falling is announced in-world and is the content the public board is *for*. Mapping it is required, not optional: rule 2 fails an unmapped kind closed to admin-only |
| `champ.boss.killed` → `damagers` | **`staff`**, configurable | Nested, so one rule covers the whole table |
| `champ.boss.killed` → `killer` | **not listed** | One actor, whose blow everyone present saw, and the same disclosure `mob.killed` has published on the public activity feed since before this framework existed |
| `damagers[].acct` / `.webId` | **admin, locked** | Rule 1, automatically, by suffix — inside the array, exactly as it already works inside a guild roster |
**The split is the whole point.** A shard announces that its champion fell without publishing a
ranked roll of who was strong enough to fell it. The kill is an event in the world; the damage table
is a performance record of named players that nobody consented to publish. A shard that wants a
public "who slew the champion" board lowers **one** field rule.
**11b's two frames are deliberately unmapped.** `lease.applied` and `lease.expired` are operational
records of the WEBSITE changing this shard's configuration — which key, from what to what, on whose
run, and whether the shard's own deadline had to put it back because nobody asked. Rule 2 fails an
unmapped kind closed to admin-only, which is where an audit trail of the site's own writes belongs;
mapping them would mean choosing a feature an operator could then widen, and there is no rung below
admin these belong on. Same reasoning as `account.login.result` in v5.
The participation ledger emits no stream frame at all. A tally is read on request, not broadcast:
a live feed of who is standing where would be a location tracker, and the ledger's whole justification
is that it answers one bounded question about one run.
**The trigger carries the count, never the names.** `uo.champ.boss_killed` exposes `damagerCount`
and a `damagerNote` sentence and no damager identity at all. A trigger variable is interpolated into
mail an operator may address to every subscriber, so a name reaching the trigger's data would undo
the field rule one layer up — a distinct hole from the SSE path the rule guards, reachable without
touching the visibility config at all.
---
## 5. Cross-repo obligations
| Repo | Change |
|---|---|
| `servuo-plugins` | `BridgeIdempotency.cs` (new) · `BridgeBoot.OnInboundLine` gate · `BridgeLink.Emit` capture hook · `BridgeJson.RewriteStringField` / `WithTrueFlag` / `Damagers` · `BridgeChamps` boss-kill emitter · **`overlay.toml` `protocol = 6`, in the same PR as the emitters** |
| `link` | `PROTOCOL_VERSION` → 6 · `bridge.busy` → 425 in all three responders |
| `module-uo` | `uoLinkClient` ×3 writes carry the key · `uoEventActions` passes it and `uo.broadcast` becomes retryable · `shardVisibility` (kind + field rule) · `shardEngagement` mapper · `shardTriggers` + `engagementSeeds` for `uo.champ.boss_killed` |
| `website` | **11b only.** `core.lease` gains a `reconcile()` and `registerEventLeases` gains an optional `inForce()` — see §8.4 |
| `docs` | this file · `INTEGRATION.md` · `PLAN.md` §5/§7 · `EVENTS.md` §A/§G · `EVENTS_PLAN.md` · `MODULE_API.md` (11b) |
| `installer` | **nothing.** See below |
| `runicgateway.com` | `platform.json.protocol` → 6 — deferred to the events cutover, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately |
**`installer` has no work, and `EVENTS_PLAN.md` Phase 11 was wrong to list it.** The plan put it in
the phase *"because of the pairing"*, but `PLAN.md` §7.4 made that unnecessary on purpose:
**no protocol version is hardcoded anywhere in the installer.** `bundle.rs` reads the number out of
the bundle document and `bundle.yml`'s Gate 1 reads both halves out of the released artefacts and
refuses a mismatch. The check is version-agnostic, so it needs no change for 6 exactly as it needed
none for 5. And `link` and `servuo-plugins` are on `edge` for this workstream, so nothing is
released or bundled until the events cutover in any case.
**`EVENTS_PLAN.md` was wrong about the repo count in the other direction too.** Phase 11 is four
repos for 11a and **five** for 11b: a lease's ledger row had no reconcile path anywhere, because the
step that made it names `core.lease`, and that is core's own action rather than a module's. There is
nowhere on that path a module could hang an answer, so core had to grow one. §8.4.
**The pin still has three declaration sites** — `overlay.toml`, `PROTOCOL_VERSION`, and
`module-uo`'s `uo_link_config` default — and `module-uo`'s schema test asserts that they *agree*
rather than that they equal a literal. See v5 §5 for why that phrasing is load-bearing.
---
## 6. Verification
Unit tests: 571 in `module-uo/server` (571 pass / 0 fail, 9 new), 43 in the sidecar (4 new), 42 in
`module-uo/client`; the C# compiles against the real ServUO 57.4 reference assemblies. `cargo fmt`,
`cargo clippy -D warnings` and `check:imports` clean.
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/BridgeProtocol6Probe.cs`.
| Claim | Evidence |
|---|---|
| a repeat is not re-executed | Three `POST /admin/broadcast`, two under key `K1` and one under `K2`. The shard's own audit trail holds **two** `admin.audit` rows, not three |
| a replay is the ORIGINAL reply | Attempt 2 answered `{"kind":"admin.ok","replayed":true,"reqId":"r-2","t":1788550182074}` — the first attempt's `t`, so it is stored rather than re-run |
| a replay is stamped with the REPEAT's id | The same reply carries `reqId: r-2`, not the `r-1` the first attempt used. Without this the retry hangs until the reply timeout |
| the `id`-correlated plane works too | Two `POST /towncrier` under one key → `{"kind":"towncrier.ok","id":"evt-CRIERKEY","replayed":true}` on the second, correlated on `id` rather than `reqId` |
| a different key still executes | Attempt 3 answered a fresh `r-3` with a new `t`, and produced the second audit row |
| the shard says what it did | `[Bridge] idempotency: replaying the original reply for key K1-… (admin.broadcast)` |
| `champ.boss.killed` fires for a real champion | A real `ChampionSpawn` driven through its own `SpawnChampion()`, damaged from two seeded players and killed: `boss: Semidar`, `category: champion` |
| the altar is attributed | `serial: 0x400150E8`, `type: Abyss`, `level: 0` — from the sweep's map, so the kill names the altar and not only the creature |
| the damage table ranks and SUMS | `[("tester", 100240), ("Darrow", 120)]` — descending, and `tester`'s 240 registered damage plus the 100000 killing blow folded into one entry rather than two |
| the region resolves | `region: "Britain"` — see §6.1, this is the one that took four rig cycles |
| the website's half agrees, on the real bytes | The captured frame fed through `module-uo`: trigger `uo.champ.boss_killed` with `location: "Felucca 1496, 1628 (Britain)"`, `damagerCount: 2`; projected `damagers` **absent** for `anonymous` and `player`, present for `staff`, and `acct` stripped from every actor at every rung below admin |
| the sidecar needed no change beyond the constant and one status | `GET /health` → `"protocol":6`; the key and the new kind both arrived through the generic paths |
**One thing the rig could not prove: `bridge.busy`.** With today's synchronous handlers a repeat can
never arrive while the original is still running, because the Core thread processes one inbound line
at a time — so the state is unreachable on a live shard until a handler defers. It is implemented,
`BridgeIdempotency.Hold`/`Complete` are the door a deferring handler uses, and the sidecar's 425
mapping is unit-tested on all three responders. **Phase 11b's leases are the first thing that can
actually produce it**, and proving it belongs in that walk rather than being claimed here.
### 6.1 The rig trap: the innermost region has no name
Recorded because it is the sort of defect that ships, and because the naive check *confirms* it.
The first emitter read `boss.Region` and a champion killed in the middle of Britain produced a frame
with **no region at all**. Three things were true at once:
- An active `ChampionSpawn` registers a `ChampionSpawnRegion` over its spawn area, constructed with
a **null name** and with the town region as its `Parent`. So the most specific region containing a
champion boss is, by construction, the one region on the map guaranteed to have no name.
- `Mobile.Region` never returns null: with no cached region it falls back to the map's **unnamed
default region**. So the failure looks identical to "outdoors, nowhere in particular".
- `ChampionSpawn.SpawnChampion` places its boss at `Z - 15`, which was a plausible-looking second
explanation and a red herring. It cost a rig cycle.
And the reason it survived a first look: **region registration is deferred**, so a lookup taken
immediately after the altar is placed still answers `"Britain"` and one taken at the kill twenty
seconds later does not. The probe printed `region "Britain"` at spawn on every run, including the
ones whose frame carried nothing. A read at spawn time would have confirmed the bug into the design.
The fix is general rather than a special case for champions: walk outward to the nearest **named**
ancestor. A house region, a dungeon sub-region and a guarded-zone overlay are all anonymous children
of somewhere a player would name. The map's own default region terminates the walk with its
parentless empty name, so open countryside answers null — and the field is then **omitted**, because
a consumer reading `region: ""` cannot tell "nowhere in particular" from "the shard would not say".
### 6.2 Two smaller ones
- **`deploy.ps1` overwrites `Bridge.cfg`,** so every deploy strips the scaffolding flags —
`Protocol6ProbeOnStart` and, here, `AdminWriteEnabled` as well. The probe then silently does
nothing on the next boot. The scaffolding README warns about this and it still cost a cycle.
- **A running ServUO holds `Scripts.dll`,** so an offline `msbuild` fails with `MSB3027` rather than
a compile error. Stop the shard, build, restart — and read the boot log's `0 Error(s)` to confirm
the new code is live rather than trusting a clean boot, which is what
`servuo-plugin-build-and-run` already records.
---
## 7. One version, two halves
Phase 11 is split. 11a bumped `PROTOCOL_VERSION` to 6; **11b amends 6 in place** rather than bumping
to 7 — 6 will not have landed on `main` until the events cutover, and the org lead's 2026-09-03 rule
is that a protocol owes a bump once it has shipped and is amended in place before that.
Which means: **an overlay and a sidecar both declaring `6` are only interchangeable within one side
of the 11b merge.** That is tolerable exactly because nothing is released from `edge` — the bundle
CI never sees either half until the cutover, by which time 6 means one thing. It would not be
tolerable on `main`, and this paragraph exists so nobody discovers that the hard way.
Sections 8 and 9 are 11b. Everything above is 11a except where a section says otherwise.
---
## 8. The lease plane (11b)
> An event does not edit the world. It holds a lease, and baseline is what is true when no lease is
> held.
>
> — `EVENTS.md`, *Leases: the primitive underneath everything*
Three commands, one new configuration gate, and two properties that are the whole reason the
framing is worth having.
### 8.1 `Bridge.EventsEnabled`, and why it is not `AdminWriteEnabled`
**Its own switch, default off** (org lead, 2026-09-04). Enabling the admin plane is an operator
consenting to staff moderation driven from the website — a human pressing kick or ban on a screen
they are looking at. A lease and a participation ledger are the website changing and watching the
world on a **schedule**, unattended, at four in the morning. Those are different consents and one
switch cannot honestly express both; an operator who wanted the first and got the second would be
right to be angry.
### 8.2 The commands
```json
{"kind":"lease.apply","reqId":"r-7","key":"PlayerCaps.SkillCap","value":"1200",
"holdMs":600000,"untilMs":1788567000000,"runId":"77","idempotencyKey":"…"}
```
| Command | Answers | |
|---|---|---|
| `lease.list` | `lease.list.ok` | Every allowlisted key: `current`, `default`, `min`/`max`, and where held, `baseline` / `applied` / `untilMs` / `runId` / `expired` |
| `lease.apply` | `lease.ok` | `baseline`, `applied`, `untilMs` |
| `lease.release` | `lease.ok` or `lease.drifted` | Compare-and-set |
Three shapes are worth stating because the obvious alternative is subtly wrong in each.
**`holdMs` is authoritative and `untilMs` is for display.** An absolute deadline computed on the
website and honoured on the shard is a deadline measured against **two clocks**, and a shard running
ten minutes fast would restore a ten-minute lease the instant it took it. A duration is immune. The
absolute time still crosses, because a console that can say when the hold ends in terms the
operator's own clock agrees with is worth one field.
**Values cross as TEXT, whatever the lease's declared type**, and comparison is done on the parsed
values. JSON would otherwise decide for us: `1200` and `1200.0` are one number to a parser and two
different strings to a compare-and-set, and a drift check that compared formatted numbers would
report drift on a value nobody had touched — refusing to restore, leaving the world changed, and
blaming an innocent operator.
**A lease held longer than the shard's ceiling is REFUSED, never clamped.** A clamp would quietly
give the website a shorter lease than it believes it has, and the website is the half that schedules
the restore; the two would then disagree about when the world comes back. `Bridge.LeaseMaxDurationSec`
is the shard's independent bound rather than a mirror of core's — it exists for the case where the
website is wrong, and being loud about it is the entire value.
### 8.3 The two mechanisms, and one thing a stock shard cannot do
**The deadline lives on the shard.** A lease arms a timer, and when it passes the shard restores
baseline **whether or not the website is ever heard from again**. Core drives the normal restore;
this is the backstop. It inverts the naive design, where restoration depends on core dispatching a
cleanup step and therefore fails *open* if core dies mid-event. A lease fails *safe*, and the worst
case is a world back at baseline early rather than one stuck changed indefinitely. The shard emits
`lease.expired` so the website learns what happened without being asked.
**Restore is compare-and-set, never a blind write.** If the current value is not what the event
applied, somebody moved it deliberately: answer `lease.drifted` with the current value, leave the
world alone, and let an operator decide. A **200**, not a 409 — the shard did exactly what it was
asked, and 409 is the version gate's with the opposite disposition. Blindly restoring would silently
revert a staff member's change, which is the one failure that would make operators distrust the
whole feature.
> **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4** — `Server/ScriptCompiler.cs`,
> for `Compiler.Dynamic`. There is no in-game command, no gump and no console verb that writes a
> config key.
So on a stock shard a GM **cannot drift a configuration lease even deliberately**. The mechanism is
still correct and still required — Phase 12's object-property leases are trivially driftable, and a
shard with custom scripts may well write config at runtime — but proving it needs the `configset`
verb in `tools/scaffolding/BridgeRigDriver.cs`, which exists for exactly that reason.
**A lease is memory-only, and that is a decision.** `Config.Set` mutates the in-memory entry table;
`Config.Load()` is guarded by `_Initialized` and so runs once at boot, which is what makes a Set
survive every later Get. **Nothing ever calls `Config.Save()`**, so a shard restart is a *free*
restore — the strongest fail-safe available, at no cost. It is also why `lease.list` reports an empty
hand after a restart, which is precisely what lets the website's reconcile notice the lease is gone.
A pleasant consequence of `Config.Entry.Set`: restoring the baseline restores the entry's original
default marker too, because the entry compares against the value it was loaded with. Restoring a key
that was `@`-defaulted in a cfg file leaves it `@`-defaulted.
### 8.4 The reconcile hole, and `inForce()`
**A lease's ledger row had no reconcile path at all, and nothing failed to say so.** `cleanup.js`
resolves a resource to the action of the step that made it, and for a lease that action is
`core.lease` — a **core** action, on a path a module cannot register anything on. So every `override`
row came back `unanswered` for the life of the run, and a lease the shard had quietly dropped stayed
in the ledger as live until teardown went looking for a baseline nobody was holding.
11b closes it in two pieces, both in `website`:
- `core.lease` gains a `reconcile()`.
- `registerEventLeases` gains an **optional `inForce()`** — *"does the game side still have any
record of this hold?"*
It is deliberately not `read()` plus a comparison. A value that differs from what the run applied is
**drift**, which teardown must deliver through `restore()` so the row lands `drifted` with the
current value beside it; a reconcile that inferred absence from a changed value would orphan the row
first and destroy that signal — telling the operator the lease vanished rather than that somebody
moved it. Only an explicit `{ ok: true, held: false }` takes a row out; a throw, a timeout, an
unrecognised shape and a lease with no `inForce()` all leave it alone.
`MODULE_API_VERSION` stays **1.10.0**, amended in place, by the same rule §7 states for the protocol.
### 8.5 The catalog is short, and shorter than `EVENTS.md` expected
§D describes the 258 `Config.Get` call sites as splitting into two patterns — cached at type
initialisation, where a lease applies cleanly and does **nothing**, and read live, where it takes
effect at once. Measured on 57.4 the split is not near even: of the **158** non-Bridge call sites in
`Scripts/`, roughly **eight** are live reads.
11b ships **one** key: `PlayerCaps.SkillCap`, read live inside `CharacterCreation.cs`'s per-character
path and divided by ten to give the per-skill cap. It is both live and observable, which is what
"proven" has to mean here — the failure the allowlist exists to prevent is a key that applies
cleanly and changes nothing at all. Phase 12 adds the rest, with the boot-time self-check that drops
a key from the advertised catalog if it does not take.
---
## 9. The participation ledger (11b)
§G rates participation attribution as the largest remaining piece of new UO work, and says why
nothing composed out of the existing streams stands in for it: `region.enter` plus `mob.killed` is
loosely composable and **not trustworthy enough to publish results on**. Nothing scopes a kill or an
arrival to a run, nothing separates a passer-by from an attendee, and nothing survives a relog.
| Command | Answers | |
|---|---|---|
| `participation.open` | `participation.ok` | `{ runId, map, x, y, radius, holdMs }` |
| `participation.snapshot` | `participation.snapshot.ok` | The tally, resolved to names and accounts |
| `participation.close` | `participation.ok` | Stops counting; the tally stays readable through the grace window |
**The area is a map, a point and a radius** (org lead, 2026-09-04). Not a region name: §6.1 above
established that the most specific region containing an event is routinely **anonymous**, so a
region-named area would be undeclarable for exactly the venues events use. Not a rectangle either —
an author picks the spot the event happens at, not two opposite corners of it.
**Members are keyed by character serial**, matching `module-uo`'s existing Teams `memberKey`, so one
module speaks one member vocabulary and a participant joins to a roster without a translation table.
A player who attends on two characters is two members, which is the answer Teams already gives.
**The shard computes the score and core stores an opaque number it never interprets.** *"A minute
present plus five a kill"* is a sentence about Ultima Online, and the sentence has to live on the
Ultima Online side of the seam. The components ride along in the frame anyway, because a results
table that can say "forty minutes and three kills" beside a number is one an operator can defend
when a player argues with it.
Two shapes that look like details and are not:
- **Presence accrues in SECONDS, not sample counts.** A count would have to be multiplied by the
sweep interval to mean anything, and the interval is a config key an operator may change halfway
through a five-hour run — silently rewriting the first half of the tally. The kill weight is frozen
per run at open for the same reason.
- **Kill credit goes to every damager standing in the area, not to the killer.** A last hit is a poor
description of who fought something: the player who held it for four minutes and died to it took
part more than the one who landed the blow that finished it. The area test is applied to the
**damager**, so someone shooting in from outside is not attending and someone who has since walked
away accrues nothing more. Entries are summed per damager for the reason `champ.boss.killed`'s
table is (§2.2): an expired-and-recreated entry leaves two.
### 9.1 The Bridge's first persisted state
Nothing in this bridge has ever persisted anything. A ledger has to: a run spans hours and a restart
mid-event is an ordinary Tuesday, and an in-memory tally would silently regress every attendee's
score to whatever they earned after the restart. The only ways to paper over that from the other side
are a high-water rule in core — which must stay game-agnostic and cannot have one — or a per-run
offset in the module, which is the same bug with more moving parts.
`Server.Persistence` plus `EventSink.WorldSave` writes `Saves/Bridge/Participation.bin` beside the
world save, rather than a persistence **item**: no world object, no serial, nothing for a GM to find
and delete by accident, and a wipe of custom items leaves the ledger intact. The save and load hooks
are attached **unconditionally**, before the enabled gate is consulted — an operator who switches the
plane off for an afternoon must not come back to a truncated file where a run's tally used to be.
Bounds, all `Bridge.*` keys: eight runs counted at once, 2000 members per run, a 300-tile ceiling on
the area, and a 24-hour grace window after a run closes. A member the cap turns away is **counted**
and the count rides on every snapshot: a truncated tally that says it is truncated is usable, and one
that does not is a leaderboard with people missing from it for no stated reason.
### 9.2 The first handler that defers
`participation.snapshot` resolves every member serial to a mobile and an account, so a well-attended
run is hundreds of world lookups in one inbound call — exactly the work the Core thread must not be
handed in one piece. Above `Bridge.ParticipationSnapshotChunk` members it walks in chunks across
ticks, using `BridgeIdempotency.Hold` / `Complete`.
That makes it the first handler in the bridge to complete **after** its inbound call returns, and
therefore the first that can genuinely answer `bridge.busy`. 11a built that door and had nothing to
walk through it.
---
## 10. Verification (11b)
Unit tests: **588** in `module-uo/server` (17 new), **42** in `module-uo/client`, **2003** in
`website/server` (5 new), **47** in the sidecar (4 new). `cargo fmt`, `cargo clippy -D warnings` and
`check:imports` clean; the C# compiles against the real ServUO 57.4 reference assemblies with the
shard stopped.
The rig: a real ServUO with a seeded world, the **release** Rust sidecar, and
`tools/scaffolding/BridgeRigDriver.cs` (`configset` / `configread` / `partprobe`) plus
`BridgeParticipationProbe.cs`.
| Claim | Evidence |
|---|---|
| a lease applies, and the change is visible through the shard's own reader | `lease.apply` → `{"baseline":"1000","applied":"1200"}`; an independent `Config.Get` from a different class, long after every type initialiser, read `1200` |
| **the shard restores baseline with nobody asking** | A 20-second lease, and then silence. `[Bridge] lease PlayerCaps.SkillCap: deadline passed, restored to 1000 without being asked`, and `lease.expired` on the feed with `restored: true` |
| an expired lease still yields a verdict to teardown | `lease.list` reported `held: true, expired: true, restored: true`; the later `lease.release` answered `alreadyRestored: true` rather than an error |
| **a mid-lease GM edit produces `drifted`, and the world is LEFT ALONE** | `configset PlayerCaps.SkillCap 1350` under a live lease → `lease.release` answered `{"kind":"lease.drifted","current":"1350"}` at **200**, and a read afterwards still showed `1350` |
| a restart reverts a config lease, and the catalog says so | After a restart: `held: false`, `current: "1000"` — which is what makes `inForce()` correct |
| kill credit is per damager, inside the area | Two seeded players damaging one creature at the venue: both credited `kills: 1`, `score: 5.0` |
| **the tally survives a shard restart mid-run** | `save`, `shutdown`, boot: `[Bridge] participation: 1 run(s) restored from the world save`, and the snapshot returned both members with identical `firstMs` |
| **the first live `bridge.busy`** | Two concurrent `POST /participation/:runId/snapshot` under one key, chunk size 1: the first answered 200 with the tally, the second **425** `{"kind":"bridge.busy","busyKind":"participation.snapshot"}` |
| a deferred key replays like any other | A third request under the same key answered `replayed: true` under its own `reqId` and the **first attempt's** `t` |
### 10.1 The defect the deferral found, in 11a's own code
`bridge.busy` came back **200**, not 425, the first time anything produced it.
`BridgeIdempotency.Busy` built its frame with `BridgeJson.Begin("bridge.busy")` — which writes
`"kind":"bridge.busy"` — and then appended a diagnostic `.Str("kind", prior.Kind)` naming the command
that was in flight. **The object carried two `kind` fields, and every JSON parser worth the name
takes the last.** The sidecar matched on `bridge.busy` to decide the 425, read
`participation.snapshot` instead, and answered an ordinary 200 with a body saying nothing had
happened — the single worst of the three possible answers, since a retry loop would treat it as
success.
It shipped in 11a and **could not be seen there**: with only synchronous handlers a repeat can never
arrive mid-flight, so the arm was unreachable on a live shard, and the sidecar test that covers the
mapping was — correctly — feeding it a frame built by hand. The first deferring handler produced it
on its first collision. Renamed to `busyKind`.
This is the argument for the phase's ordering, stated as a fact rather than a hope: 11a said
*"11b's leases are the first thing that can actually produce it, and proving it belongs in that
walk"*, and the walk found a real bug in shipped code.
### 10.2 What the rig could not drive: presence
The participation ledger counts two things and only one of them is reachable headlessly.
**Presence needs a connected client.** The sweep credits online players — `NetState != null` — which
is the correct test and not one a probe should loosen: a character parked in Britain and logged out
for eight hours did not attend anything, and a ledger that said otherwise would put people at the top
of a leaderboard for being AFK. There is no way to produce a NetState short of writing a client, and
ClassicUO cannot be driven from this machine.
**Kill credit needs none**, so the whole of the credit path — the damager filter, the per-damager
fold, the area test on the damager, the member cap — ran exactly as it would in a fight, and the
accrual, persistence, snapshot, chunking and replay paths were driven by it. The one line the walk
did not exercise is `member.Seconds += seconds`, and it is named here rather than left to be assumed.
### 10.3 Two rig traps
- **`Core.Kill` does not save the world.** The rig driver's `shutdown` verb is a clean shutdown, which
is the only kind that *emits* — and it emits `server.shutdown` without writing a save. The first
restart test therefore reloaded an **empty** `Participation.bin` and looked exactly like a
persistence bug. `save` then `shutdown` is the sequence; the file's length is the check.
- **A probe that means to produce two damagers must not kill with the first blow.** An opening 40
damage on a Mongbat (around thirty hit points) killed it where it stood, so the second damager never
landed a hit, `DamageEntries` held one name, and the ledger correctly credited one player. It read
as a plugin crediting only the killer. Scaled to the creature's `HitsMax`.

View File

@@ -1,571 +0,0 @@
# Protocol 7 — What an event owns
**Status:** In review on `edge`. **12a and 12b land as one protocol version** — see §8.
**Date:** 2026-09-07 (12a and 12b)
**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), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`v6.md`](v6.md) (6.0 idempotency, leases, participation), [`INTEGRATION.md`](INTEGRATION.md) (website API).
**Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 12.
---
## 1. The payload protocol 6 was the floor for
[`v6.md`](v6.md) §1 said it plainly: *"Protocol 12's world verbs are the payload; protocol 6 is the
floor they stand on."* This is that payload.
Five verbs an event author sees — creatures, an enhanced "boss", an oracle NPC, a temporary gate,
decoration — and every one of them is the same sentence underneath:
> **An object exists in the world, and this run owns it.**
Ownership is the whole of why [`EVENTS.md`](../website/EVENTS.md) §G can exclude arbitrary `[add`
and `[set` and then permit all of this in the same table. The rule it draws is:
> An event may **own** what it creates and **borrow** what it changes, and may never change
> something with no baseline recorded.
12a is the first half. 12b is the second.
---
## 2. One command family, not five
| Command | Answers | |
|---|---|---|
| `world.spawn` | `world.ok` | Places `count` of `what` at a map/x/y; replies with the serials |
| `world.despawn` | `world.ok` | `removed` / `gone` / `refused`, per serial |
| `world.owned` | `world.owned.ok` | What the run still owns, pruning dead rows as it walks |
Plus one unsolicited frame, `world.expired`, when the shard collects something on its own.
**`what` is a field, not five kinds** (org lead, 2026-09-07). The per-verb differences — a boss's
multipliers, an oracle's lines, a gate's destination and deadline — ride alongside the discriminator
rather than justifying their own command. That buys one ledger shape, one teardown path and one
reconcile instead of five near-identical ones across three repos, and it costs nothing an author can
see: `module-uo` still declares **five actions**, because five is what they are. The discriminator
is a wire detail, and a dropdown of `what` would be a worse authoring form than five named steps.
```json
{"kind":"world.spawn","reqId":"r-4","runId":"77","idempotencyKey":"…",
"what":"creature","map":"Felucca","x":1496,"y":1628,
"count":8,"type":"Orc","name":"Rotting Orc","hue":1157,"spread":6}
```
```json
{"kind":"world.ok","reqId":"r-4","action":"spawn","runId":"77","what":"creature",
"placed":8,"serials":["0x40001A2B","…"],"owned":8}
```
Serials cross as `"0x…"` text, matching every other serial this bridge writes.
---
## 3. The ownership registry, and why it is persisted
**This is the design decision of the phase, and it was forced rather than chosen.**
Start from what is already known. A spawned creature is a **world object**, so unlike a town-crier
line it *survives a restart*. That alone settles one question: `reconcileByBootId` — the trick
Phase 9 uses for the crier and the news gump, where a changed `bootId` **is** proof the thing is
gone — is wrong here. It would report gone the one class of resource still standing in Britain.
Reconcile has to ask.
The harder question is where the record of *which run owns which serial* lives, and the two obvious
answers are both wrong:
- **In memory on the shard.** Lost in the restart the creatures themselves survive. The website then
holds serials the shard will not vouch for, and thirty orcs stand in Britain until somebody
deletes them by hand.
- **Only in the website's ledger.** Then it is not held on the shard at all, and `world.despawn`
would have to delete whatever serial it was handed. *"An event never touches a creature it did not
create"* — the sentence the boss verb is built on — would be an intention with no mechanism behind
it, and a bug in a step or a stolen sidecar token would be a delete-anything primitive.
So the Bridge gains its **second** persisted file, `Saves/Bridge/Owned.bin`, beside 11b's
`Participation.bin`. Same mechanism (`Server.Persistence` plus `EventSink.WorldSave`), same rule
about attaching the hooks in `Configure()` rather than `Initialize()` — `EventSink.WorldLoad` fires
*inside* `World.Load()` — and the same unconditional attachment ahead of the enabled gate, so an
operator who switches the plane off for an afternoon does not come back to a truncated registry and
a world full of creatures nothing admits to owning.
### 3.1 The registry and the objects cannot get out of step
Worth stating because a reader will reach for it as an objection: if the registry were lost while
the objects survived, `world.owned` would answer with an empty hand and the website would orphan
live creatures.
It cannot happen. The registry is written by `EventSink.WorldSave` — **the same save** that writes
the objects it describes. They are saved together and lost together. That is what makes "anything
the shard does not list is gone" a safe reading rather than a hopeful one, and it is why
`world.owned` on a run the shard has never heard of is an **empty list and a 200**, not a 404:
*"this run owns nothing"* and *"I have never heard of this run"* are the same fact once the registry
is the only record of ownership.
### 3.2 Three answers to a despawn, and only one is a failure
| | Meaning |
|---|---|
| `removed` | Owned by this run, found, deleted. |
| `gone` | Owned by this run and already absent. **A success.** |
| `refused` | Not this run's to delete. The row stays; nothing is touched. |
**`gone` being a success is not a technicality.** Players kill event creatures — that is what
spawning them is *for*. §L already settles it (*"gone, and that is fine"* is a successful revert),
and a run that ended `incomplete` because its event worked would be a report nobody could read.
`refused` is the only answer here that means somebody asked for something they should not have, and
it is reported back to core as a per-row `failed` so the row lands unresolved **with a reason**
rather than being quietly marked reverted. Nothing will ever delete it through this path.
---
## 4. The oracle is ours, and ServUO's own dialogue engine is why
ServUO ships a complete dialogue system in `XmlSpawner2.XmlDialog`, and its `SpeechEntry` is the
evidence that the shape this verb wants is right rather than invented:
| `XmlDialog.SpeechEntry` | This verb |
|---|---|
| `Text` | the line |
| `Keywords`, comma-separated | the keywords |
| an entry with **no** keywords fires automatically | the greeting |
| `defProximityRange = 3` | `Bridge.EventsOracleGreetRange` |
| `LockConversation` | a per-player cooldown |
| **`Action` — XmlSpawner's command-scripting language** | **nothing** |
That last row is the whole reason not to build **on** it. Routing authored dialogue through
XmlDialog would leave an arbitrary-command field one field away from an event author on a web form:
the `[set` §G excludes, arriving through the back door, in a subsystem this overlay does not own and
an operator can switch off.
What the verb actually needs are two native virtuals on `Server.Mobile`:
- **`OnMovement(m, oldLocation)`**, delivered to **every** mobile in range. The `HandlesOnMovement`
filter applies only to *Items* — `Server/Mobile.cs:3369` against `:3375` — so a greeting hook is
free. The old location is compared as well as the new one, which makes it fire once per approach
rather than on every step of a passer-by.
- **`HandlesOnSpeech(from)` → `OnSpeech(e)`** (`Server/Mobile.cs:5150`), the keyword hook.
Nothing executable crosses the wire: keywords and text.
**The oracle cannot be killed, moved or looted.** `CanBeDamaged()` is false as `TownCrier`'s is, and
it is `Blessed`, `Frozen` and `CantWalk`. An event NPC a player can drag out of the venue is one
that stops being where the run's ledger says it is, and teardown deleting something that has
wandered two screens away is worse than it not moving.
**Keywords are lower-cased on the website side, not at match time.** The comparison on the shard is
then ordinal, between two strings already in the same case — culture-aware casing at match time
would make an oracle answer differently on a Turkish shard.
---
## 5. Decoration comes from the shard's own decoration files
`Data/Decoration/**/*.cfg` names every item type the shard already uses as scenery, with its item
id. The atlas build indexes them, so the authoring dropdown is derived from the operator's own tree,
resolves with the shard down, and is *decoration* by the shard's own definition rather than by ours.
Two measurements shaped the wire:
- **120 files, nested two deep** (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`, `Old/Britannia`), so
the read is recursive. A flat read would index a fraction of it while looking like it worked —
the failure being a dropdown quietly missing whole expansions rather than an error anyone notices.
- **313 distinct types, and the item id is not decoration.** `Static` alone accounts for **5031 of
the placements under 1992 different graphics**, because for that class the graphic *is* the
identity: a bare `new Static()` is never the switch or the paving stone the author picked. 131 of
the 313 types carry more than one id (a door has one per facing).
So `world.spawn` carries an optional **`itemId`** for decoration, resolved on the website from the
atlas row rather than typed by an author — which also means the verb places only what this shard's
decoration files actually name, a tighter boundary than "any item that is not a container".
**The id is never applied to a `BaseAddon`.** An addon is a group of components and its own `ItemID`
is not what a player sees; writing a graphic over it would leave a stone oven rendering as one
arbitrary tile of itself. Addons construct with the right appearance already.
**Containers are refused outright.** Deleting one at teardown would delete whatever a player had
left inside it. Everything else here is safe to delete because the event made it; a container's
contents are not.
---
## 6. Ceilings refuse, they do not clamp
`Bridge.EventsMaxCreatures` (30), `EventsMaxBosses` (4), `EventsMaxNpcs` (5), `EventsMaxDecor` (60),
`EventsMaxGateMinutes` (240), `EventsMaxOwnedPerRun` (200), `EventsMaxSpread` (40),
`EventsMaxBossMultiplier` (10.0), and the oracle's own five. Defaults are the EM Program's published
quotas, because they are the only numbers anyone has defended in public.
These are the shard's **independent** bounds, not mirrors of the module's budget dimensions —
`Bridge.LeaseMaxDurationSec`'s argument from [`v6.md`](v6.md) §8.2 unchanged: the bound exists for
the case where the website is wrong, and being loud about it is its whole value. A refusal is a
**400**, and permanently so: retrying *"you asked for 80 creatures and this shard places 30"* gets
the same answer forever, so a module that classified it as transient would put a run in a loop
against a limit that will never move.
`EventsMaxOwnedPerRun` is the one that is not a per-call ceiling. The others bound one request; that
one bounds a run calling a verb in a loop, which is the shape a runaway schedule actually takes.
**The gate deadline lives on the shard**, for [`v6.md`](v6.md) §8.3's reason about leases: a gate the
shard closes by itself closes whether or not the website is ever heard from again. A run whose
engine died leaves a world that comes back early rather than one stuck open. And it crosses as a
**duration** (`holdMs`), never an absolute time — an absolute deadline is measured against two
clocks, and a shard ten minutes fast would collect the gate the instant it opened.
**`Bridge.EventsEnabled` gates all of it** (org lead, 2026-09-07). Spawning creatures and opening
gates is the same consent 11b introduced that switch for — unattended, scheduled world change — not
a third one. A switch per verb family is a setting nobody can reason about.
---
## 7. Visibility
`world.expired` is **deliberately unmapped**, exactly as `lease.applied` and `lease.expired` are and
for the reason [`v6.md`](v6.md) §4 gives: it is an operational record of the *website* changing this
shard's world, and rule 2 fails an unmapped kind closed to admin-only, which is where an audit trail
of the site's own writes belongs. Mapping it would mean choosing a feature an operator could then
widen, and there is no rung below admin it belongs on.
**A spawn emits no stream frame at all**, and the asymmetry is deliberate. A reply correlated on
`reqId` already tells the website everything it needs, and eight creatures placed in one call would
otherwise be eight frames announcing what the caller already knows. `world.expired` exists precisely
because it is the *only* thing here that happens without the website asking.
---
## 8. One version, two halves
Phase 12 is split. **12a bumps `PROTOCOL_VERSION` to 7; 12b amends 7 in place** on `edge` — the
11a/11b shape, and it applies for the same reason: 7 will not reach `main` until the events cutover,
so the bundle CI never sees two numbers.
It carries the same hazard [`v6.md`](v6.md) §7 states, and it is worth restating rather than
assuming anyone will follow the link: **an overlay and a sidecar both declaring 7 are interchangeable
only within one side of the 12b merge.** That is tolerable for one reason and no other — nothing is
released from `edge`. It would not be tolerable on `main`.
The three declaration sites, all bumped in 12a: `link/sidecar/src/main.rs`
(`PROTOCOL_VERSION`), `servuo-plugins/overlay.toml` (`protocol`), and the website's admin-managed
`uoLinkConfig`. `installer` has none, for the reason Phase 11 records.
**12b amends 7 in place** with object-property leases and their boot-time self-check, the
seasonal-event toggle, a world save and the item grant. It is specified in §11 to §14 below.
---
## 9. Cross-repo obligations
| Repo | Change |
|---|---|
| `servuo-plugins` | `BridgeWorld.cs` (registry, three handlers, the sweep), `BridgeOracle.cs`, the `EventsMax*` keys, `overlay.toml` → 7 |
| `link` | `POST /world`, `GET /world/:runId`, `POST /world/:runId/despawn`; `PROTOCOL_VERSION` → 7 |
| `module-uo` | Five actions, five budget dimensions, the decoration option source and the atlas index behind it |
| `docs` | This file; `EVENTS.md` §G; `EVENTS_PLAN.md` Phase 12 |
**Part b adds to every row of it, and adds a fifth.** `servuo-plugins`: `BridgeLeaseTargets.cs`,
`BridgeOneShots.cs`, the persisted `Leases.bin` and the boot self-check. `link`: `target` on the
lease family, `GET /items`, `POST /items/grant`, `POST /world/save`. `module-uo`: five targeted
leases, two actions, `uo.rewards`, three option sources, the atlas's `unique_id`, **and the
protocol pin 11a and 12a both missed** (§14). `docs`: this file, `EVENTS.md` §D/§G,
`MODULE_API.md`.
**`website` is in part b and was not in part a.** A targeted lease is a shape `core.lease` did not
have: the declaration gains a `target` and a `values` set, the four callables gain the target, and
the reservation ref becomes `<lease id>#<target>` so the two-events-one-target index bites at the
granularity the world actually has. Extending core rather than giving the module its own lease verb
is what `EVENTS.md` §F decided in Phase 8 — *"the verb is core's"* — and the alternative would have
re-implemented `maxDurationMs` per module, advisory everywhere and wrong in the first one that
forgot.
`MODULE_API_VERSION` does **not** move — in part a because budget dimensions and option sources are
things a module *declares* through an API that already exists, and in part b because **1.10.0 is
amended in place**, the shape every phase since P10 has used while the workstream sits on `edge`.
Part b really does change the contract (targeted leases, lease value sets, searchable option
sources); nothing is released from `edge`, so the version is amended rather than bumped, and the
cutover is what publishes it.
---
## 10. Verification
The whole rig: a run that spawns one of each of the five, a restart mid-run proving the ownership
registry survives it and reconcile still answers, a creature killed by a player proving `gone` is an
ordinary teardown outcome, and a despawn **refused** for a serial the run does not own.
### 10.1 What the build already found
**An action must be performable from its own required params, and `uo.npc.place` was not.** Both
ends refuse an oracle with neither a greeting nor a line — it would stand there in silence — but
both fields were declared optional, so the verb could not be performed from its own required set and
no authoring form could render it as valid either. A cross-field *"at least one of these"* rule is
the wrong shape for a declaration core reads as data. The greeting is now `required`, which says the
same thing in the contract itself; the `perform()` check remains, for the field holding nothing but
spaces.
It was caught by `module-uo`'s existing dry-run sweep — a test that performs every action with only
its required params — which is a better argument for that test than anything written about it when
it shipped.
**The creature option source answered with something unusable.** `uo.options.creatures` shipped in
Phase 9, before anything consumed it, carrying the atlas **slug**: unique, stable, and not a thing
the shard can build, because a creature is constructed from a ServUO class name and `orc-brute` is
not one. The atlas's `name` **is** the raw type token from the spawn files, so the fix was to stop
discarding the half that works. Safe to change because 12a is the source's first consumer — the file
said so when it shipped.
---
## 11. Part b — what an event BORROWS
12a was the first half of §G's rule. This is the second: **an event may borrow what it changes, and a
borrowed value carries a deadline the game itself enforces.**
Two planes, and both of them are **targeted** — a lease names a key *and* the thing it applies to.
Every lease before protocol 7 named a single value (a config key, a rate), so the key *was* the
target. `Spawner.MaxCount` is not that: it is one capability over thousands of spawners, and two runs
turning up two different spawners must both be allowed while two runs turning up the same one must
not.
| Plane | Key | Target | Holds |
|---|---|---|---|
| Object property | `Spawner.MaxCount` | a spawner | how many it keeps alive |
| Object property | `Spawner.MinDelay` / `Spawner.MaxDelay` | a spawner | its respawn window, in seconds |
| Object property | `Spawner.Running` | a spawner | whether it runs at all |
| Seasonal | `Seasonal.Status` | an `EventType` | `Inactive` / `Active` / `Seasonal` |
The wire is the **same `lease.apply` / `lease.release` / `lease.list` family** protocol 6 introduced,
with a `target` field. One deadline, one compare-and-set, one grace window, one set of counters —
three planes rather than three protocols.
### 11.1 A lease here must be PERSISTED, and the config plane's must not
[`v6.md`](v6.md) states the config lease's fail-safe plainly: *a lease that never reaches disk means
a shard restart is a free restore.* That argument depends entirely on the leased value being
memory-only too, and here it is not.
- A spawner is an `Item`. It is in the **world save**.
- A seasonal entry is written to `Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`.
So a restart does not put either of them back. It puts the **change** back and throws away the
deadline timer that was going to undo it — leaving the world at the leased value with nothing on the
shard remembering that it is borrowed. That is the exact failure the lease framing exists to make
impossible, so the hold is persisted, in the Bridge's **third** save file (`Saves/Bridge/Leases.bin`,
beside `Participation.bin` and `Owned.bin`) and written by the same `EventSink.WorldSave` that writes
what it describes.
**A deadline that passed while the shard was down fires at once**, rather than being dropped or
extended. The promise the website was given is "back at baseline by then"; a shard that was off for
the whole hold has not kept it, and restoring immediately is the only reading of that promise still
available. Extending it would silently turn a two-hour lease into however long the outage was.
Config holds are still **not** written down, and the asymmetry is the point rather than an
inconsistency: the same argument, applied to planes where its premise is false.
### 11.2 A target is a serial or a UniqueId, and both are needed
- A **serial** is what `[props` shows a GM and what a rig can type.
- An **`XmlSpawner.UniqueId`** is what the shard's own `Spawns/*.xml` carry and what the live spawner
keeps.
The second is not a convenience. A dropdown built from serials is **impossible**: serials are
assigned when the world is built and nothing off the shard knows them, so a lease addressable only by
serial could have no authoring list at all — it would be the free-text box the option-source contract
exists to replace. The atlas already read `<UniqueId>` and discarded it; Phase 12b keeps it.
The UniqueId lookup is a scan of `World.Items`, and stays one: it runs once per lease apply, which is
a rare human-scheduled operation, and a cache would be a second copy of the world to keep correct
across `[add` and deletion.
### 11.3 The allowlist is checked against the object's own type
A serial is a number a caller chooses. The only thing standing between `Spawner.MaxCount` and any
item on the shard is the check that the object found **is** one of the types the entry names — so
that check reads the object's own type rather than anything the caller sent, and it is
`IsInstanceOfType` rather than equality so a shard's own subclass of `Spawner` is leasable.
Properties are read and written by reflection, bounded three ways: the (type, property) pair must be
in the shipped catalog, the property must carry `CommandProperty` (ServUO's own marker for *a staff
member may set this*, so this plane can never reach further into an object than `[set` could), and
its CLR type must be the one the entry knows how to render. Reflection rather than a hand-written
switch is what lets the boot self-check actually verify a pair; a switch would compile happily
against a property ServUO had renamed.
**`Spawner` and `XmlSpawner` share all four property names**, which is a fact about this tree rather
than a convenience: the shard's own spawn files load as XmlSpawners while `[add spawner` makes the
native one, so a catalog naming only one of them would work until the day it did not.
### 11.4 The boot self-check, and what it cannot do
[`EVENTS.md`](../website/EVENTS.md) §N10: *the allowlist ships with the plugin and each key
self-checks at boot, dropping itself from the advertised catalog if the write does not take.* Better
a capability that disappears loudly than one that lies.
**A config key is probed live** — written, read back, restored, all in one synchronous call — because
there is exactly one of it.
**A property cannot be**, and that is a property of the thing rather than a shortcut: there are
thousands of instances and no canonical one, so probing would mean picking somebody's spawner at boot
and writing to it. What is verified instead is everything verifiable without touching the world: the
type still resolves, the property still exists on it, it is still public and settable, it still
carries `CommandProperty`, and its CLR type is still the one this plane can render. That is exactly
the failure N10 was written for — a property a later ServUO renamed or made read-only — caught at
boot rather than at 3am inside an unattended run.
**And one failure no probe can catch is excluded by name.** `TreasuresOfTokuno` is left out of the
seasonal catalog because `SeasonalEventEntry.IsActive()` special-cases it and reads
`TreasuresOfTokuno.DropEra` rather than `Status`. Setting its status writes a field nothing consults:
the write succeeds, the value reads back, a compare-and-set restore passes, and every mechanism in
the plane reports a working lease over a capability that does nothing at all. It is N10's "capability
that lies" in its purest form, and the only way to find it is to read the source.
### 11.5 What the seasonal toggle actually costs
[`EVENTS.md`](../website/EVENTS.md) §G called this *"small and safe"* and got two things wrong.
**It is a three-value enum over nine named events, not a nine-value enum.** `EventStatus` has three
values (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries. That is a
different form to author and a different one to cap. (Corrected in 12a's survey; built here.)
**And it is not small.** `SeasonalEventEntry.Status`'s setter fires `OnStatusChange()`, which calls a
`CheckEnabled()` that generates or removes world content for six of the eight permitted types —
Doom, Khaldun, Sorcerer's Dungeon, Krampus, Rising Tide and Fellowship. It is *safe*: ServUO does
exactly this to itself from a staff gump. But an author scheduling one is scheduling more than a flag
flip, and the label says so.
### 11.6 `lease.list` answers the catalog and one row
A targeted key has no single `current` — `Spawner.MaxCount` is worth something different on every
spawner — so a catalog walk cannot fill one in, while the website's `read()` needs exactly one value
for exactly one target before it applies anything. So the frame narrows: naming a `key` and a
`target` answers that row with its value.
The frame also carries **`holds`**: every lease the shard is actually holding, whatever key or target
it is on. A catalog walk can enumerate the *keys* but never the *holds* on a targeted one — there is
no list of spawners to walk — so without it a reconcile after an outage would have no way to ask
"what are you still holding?". `inForce()` reads that.
`current` is **omitted rather than defaulted** when it means nothing (a targeted row listed with no
target), and an unresolvable target answers `unreadable` with a reason. Sending `""` would make the
website record an empty baseline and later try to restore it.
### 11.7 A vanished target is a success, not a failure
Somebody deletes the spawner mid-run. There is nothing to restore and nothing owed, so the release
answers `targetGone: true` and the website records the row **reverted**. It is 12a's `gone` in the
lease plane's vocabulary, and for the same reason: reported as a failure it would sit in the ledger
unresolved for ever, over an object that no longer exists, retried by every sweep.
Drift is unchanged and still not an error: the shard compared, declined to overwrite somebody's
deliberate change, and says so.
---
## 12. The one-shots: neither owned nor borrowed
Two verbs that cannot be taken back. Nothing is ledgered, because there is nothing core could come
back for.
### 12.1 The item grant, and who receives it
`ADMIN_CONTROLS.md` §8 cut item grants along with world creation, and §N1 reopened both —
deliberately as **two** reversals, because permitting an event to create a creature says nothing
about permitting it to hand out loot. The four properties that make this a different proposition from
the one §8 refused: it is **declared** (an allowlist, never a free-text type reaching
`Activator.CreateInstance`), **bounded** (`EventsMaxGrantPerRun`, `EventsMaxGrantStack`, both
refusing rather than clamping), **attributable** (the run id rides on every grant), and **idempotent**.
**The recipients are not sent, and that is the interesting decision.** A grant needs a list of
people, and the website has one in `event_run_participants` — but a module cannot read core's tables,
so the alternative was a new core surface handing participants to a module's `perform()`. It is not
needed: **the shard already has the list**, in protocol 6 part b's run-scoped participation ledger,
keyed by the same character serials the website's `member_key` holds. So the grant names a run and
the shard resolves who was there — no new core surface, no participant list crossing the wire twice,
and no window in which the two disagree.
Two answers that look alike and are not:
- **A run with no ledger open** is a `404`. The caller named something that does not exist here.
- **A run whose ledger is open and empty** is a `200` with `granted: 0`. An event nobody attended
still happened, and retrying against a ledger that will be just as empty next time would pause a
run for ever.
**It is retryable, and protocol 6 is why.** [`EVENTS.md`](../website/EVENTS.md) §G called a grant
un-retryable because a lost acknowledgement and a grant that never applied were the same event —
exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey`
closes it: a repeat is answered by the original reply, so a retried grant cannot be one winner
receiving two. §G was written before 11a and is corrected here.
A non-stackable item in quantity is refused at **both** ends: five cloaks would be five items, five
chances to overflow a backpack halfway through with no way to say which half landed. An undeliverable
grant is **deleted rather than dropped** — `AddItem` failing on a full backpack would otherwise leave
the item in the world at (0,0), and an event that quietly littered the map with undeliverable rewards
would be worse than one that reported a miss.
### 12.2 The world save
`ADMIN_CONTROLS.md` §3.6 catalogued it Tier B and it was never built. It is useful as a phase
boundary — the point in an event after which what has happened is safe from a crash — and
`world.save.before` / `world.save.after` have been on the event stream since protocol 2, so the
acknowledgement it needs already exists. The reply says only that the save was **started**; a caller
that needs the completion watches the stream it is already connected to.
**A save stops the world, so it is rate-limited rather than capped.**
`Bridge.EventsMinSaveIntervalSec` refuses a save that comes too soon after the last one — counting
ServUO's *own* autosave as the last one, because an event save thirty seconds after the hourly one is
the same freeze twice and the shard is the only half that can see both. **Refused, never queued:** a
queued save would land at a moment nobody chose, in the middle of whatever the next step is doing.
It is the one refusal on this plane that waiting fixes, so it is a **429** rather than the 400 every
other refusal is — which keeps it out of the module's permanent-status set and makes a phase boundary
retried rather than abandoned.
---
## 13. Routes and commands added by part b
| Route | Command | Notes |
|---|---|---|
| `GET /lease?key=&target=` | `lease.list` | narrows to one row and fills `current`; always carries `holds` |
| `POST /lease` | `lease.apply` | `target` added |
| `POST /lease/release` | `lease.release` | `target` added; may answer `targetGone` |
| `GET /items` | `item.catalog` | the shard's grant allowlist and its bounds |
| `POST /items/grant` | `item.grant` | names a run, never a recipient list |
| `POST /world/save` | `world.save` | 429 when it comes too soon |
New shard config: `Bridge.EventsMaxGrantPerRun` (200), `Bridge.EventsMaxGrantStack` (1000),
`Bridge.EventsMinSaveIntervalSec` (300).
---
## 14. What part b found in already-merged code
**The website's protocol pin never left 5.** `uo_link_config.protocol` reaches the sidecar as
`X-UOLink-Version` on every REST call, and the sidecar answers an exact mismatch with a `409`. Phase
11a took the wire to 6 and 12a took it to 7; **neither moved the pin**, in either of the two places
module-uo declares it (`db/schema.sql`'s `CREATE`/`MODIFY` defaults and `DEFAULT_PROTOCOL` in
`uoLinkConfig.model.js`). Every sidecar call on a real deployment would have been refused — the whole
event plane dead, loudly, for a reason nobody would look there for.
It survived two phases because **both live walks set the column by hand while standing the rig up**,
which is exactly what makes a migration nobody runs invisible. 12b carries all three sites to 7.
The test that guards them is worth understanding before trusting it: `schemaFragment.test.js` asserts
the three declarations agree **with each other**. That is a real check — they drifted apart once, in
the bug `DEFAULT_PROTOCOL` was introduced to fix — but *all three being equally stale passes it*, and
nothing in that repo can anchor it to the wire, which lives in `link` and `servuo-plugins`. The thing
that actually pairs them is the installer's bundle check at deploy time. Bumping the pin in the same
change as the emitters remains the discipline; no test replaces it.
**A search term could not reach an option source.** `resolveOptionSource(id)` took no argument and
every source answered a flat list bounded at 2,000 entries. The spawner target is the first source
with more than that — 6,707 spawn points — so a flat list would have dropped two thirds of the world
and said nothing about which two thirds, which is precisely the failure 12a named for decoration.
`resolve({ q })` is additive: every source is passed a term and none is required to read one, and a
`searchable` flag says which do so the form renders a typeahead rather than a select that appears to
filter and does not.
**`Spawner.Amount` does not exist.** [`EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) named it; on
ServUO 57.4 the property is `MaxCount`, and `MinDelay`/`MaxDelay` are `TimeSpan` rather than numbers
— so the wire carries seconds and the shard converts. Seconds rather than minutes because the spawn
files' own `DelayInSec` flag proves both units are in use on a real tree, and a unit that cannot
express five seconds cannot express the shard's own data.

View File

@@ -153,231 +153,3 @@ 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

@@ -508,7 +508,7 @@ is only harmless while the default is off. Existing subscriptions are carried ac
| audience_segment_id | INT NULL | a composed segment (§5.1a). **Deliberately no FK** — see below |
| max_sends_per_hour | INT NOT NULL DEFAULT 100 | the hard per-rule ceiling (§7.1 Q3), counted in `engagement_sends` and enforced before an outbox row is written |
| channels | JSON NOT NULL | `['email','inapp']` — a rule may span channels |
| template_keys | JSON NOT NULL | `{ email: 'idoc-warning' }`. Keys are shape-checked, not existence-checked: templates are Phase 5. Every key is one of the rule's `channels` **plus one that is not a channel at all**: `digest` names the body the digest worker renders for a rule whose email channel an individual set to digest mode, so it belongs to a mode and can never appear in `channels`. The rules validator rejected it until website#181, which made every rule shipping one — core's own Team and news rules included — unsaveable from the Rules screen |
| template_keys | JSON NOT NULL | `{ email: 'idoc-warning' }`. Keys are shape-checked, not existence-checked: templates are Phase 5 |
| conditions | JSON NULL | a small closed and/or/not grammar over the trigger's **declared** variables |
| cooldown_seconds | INT NOT NULL DEFAULT 0 | 0 = no cooldown |
| delay_seconds | INT NOT NULL DEFAULT 0 | the grace window (§4.2a) |
@@ -545,35 +545,21 @@ The ceiling is a **stored column rather than a runtime computation** so an audit
was allowed to reach without re-resolving it, and so a module that later widens its own audience's
ceiling cannot retroactively widen a segment saved under the old one.
### engagement_cooldowns — one fire per (rule, user, subject, channel) (engagement phase 4a; `channel` added 11b)
### engagement_cooldowns — one fire per (rule, user, subject) (engagement phase 4a)
| col | type | notes |
|---|---|---|
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | opaque to core: a house serial, a vendor id. `''` = this rule cools per user, not per subject |
| channel | VARCHAR(32) NOT NULL DEFAULT '' | the delivery channel. VARCHAR like `engagement_outbox.channel`, and for the same reason: the channel set is data a module can extend |
| last_fired_at | DATETIME NOT NULL | |
| fire_count | INT NOT NULL DEFAULT 1 | |
`PRIMARY KEY(rule_id, user_id, subject_key, channel)`, `INDEX(last_fired_at)` for a prune.
`PRIMARY KEY(rule_id, user_id, subject_key)`, `INDEX(last_fired_at)` for a prune.
**`subject_key` is why this is not a per-user counter.** "One IDOC mail per player per day" is the
wrong rule: a player with four houses decaying should hear about all four, once each, and cooling on
(rule, user) alone silently drops three of them.
**`channel` is why a two-channel rule delivers on both, and it was added after a live walk found that
it did not** (ENGAGEMENT.md Phase 11b, decision 12). The engine claims INSIDE its per-channel loop, so
without the channel in the key the first channel of a rule claimed the cooldown and every later one
was refused as still cooling — and `inapp` is ranked first deliberately, so a rule naming email and
in-app delivered the inbox item and silently never the mail. A cooldown is per delivery, not per
occasion: an operator who says "one a day about this house" means one mail and one inbox item.
Migrated in place behind a guarded `DROP PRIMARY KEY`, because **MariaDB has no conditional form of a
key change** — replaying `schema.sql` on every boot would fail after the first run without the
`information_schema` guard that reads whether the key already carries the column. Rows written before
the migration keep `channel = ''` and expire on their own interval; dropping the table instead would
let a storm through the window.
**The claim is two statements, not the one §4.1 originally described** — a guarded `UPDATE` (the
interval in a WHERE clause) falling back to `INSERT IGNORE` for a first fire. The single
`INSERT … ON DUPLICATE KEY UPDATE` form reads its answer out of `affectedRows`, and the mariadb
@@ -588,15 +574,14 @@ cooldown passes, always. See `ENGAGEMENT.md` Phase 4a.
| trigger_id | VARCHAR(96) NOT NULL | denormalized; survives a rule edit |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| channel | VARCHAR(32) NOT NULL | VARCHAR, never ENUM: the channel set is data, and a module must not require an ALTER |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | what a COOLDOWN counts, from the trigger's declared `subjectKey`. A display string is fine here: it is only ever compared with itself |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | what a COOLDOWN counts, from the trigger's declared `subjectKey`. A display string is fine here: it is only ever compared with itself |
| scope_key | VARCHAR(190) NULL | what a PREFERENCE and an UNSUBSCRIBE are keyed on (engagement phase 6), e.g. `team:12`. Deliberately **not** `subject_key`: an unsubscribe token is signed over this and sits in a mailbox for months, so it has to be a stable identifier — signing over a display name orphans every link the first time somebody renames a Team. NULL means an unscoped event; `''` is reserved for "deployment-wide" in `engagement_digest_state` |
| payload | JSON NOT NULL | the declared variables, snapshotted at emit |
| dedupe_key | VARCHAR(190) NULL | the emitter's replay guard; NULL never collides |
| status | ENUM('scheduled','sending','sent','failed','cancelled','suppressed') | |
| due_at | DATETIME NOT NULL | the grace window's clock, and the retry backoff's |
| created_at / updated_at | DATETIME | `updated_at` is what a stale-claim reclaim measures; `created_at` is what the retention sweep measures |
| attempts / last_error / sent_at | | |
| created_at / updated_at | DATETIME | `updated_at` is what a stale-claim reclaim measures |
`UNIQUE(rule_id, user_id, channel, dedupe_key)`, `INDEX(status, due_at)`,
@@ -638,35 +623,6 @@ a rule's budget and mute it.
**It is deliberately not a second address book.** The address is a hash; the values of a payload never
appear here, and neither do they appear in the engagement log lines, which carry variable *names* and
counts only.
Phase 9 gave two of those statuses their first writers. `suppressed` means the address was on the
suppression list and **no transport call was made**; `bounced` means one was, and the mailbox does
not exist. `complained` still has none — it needs a provider feedback loop, which SMTP has not got.
### engagement_suppressions — addresses we have stopped mailing (engagement phase 9)
| col | type | notes |
|---|---|---|
| address_hash | CHAR(64) NOT NULL PK | sha256 of the **lower-cased, trimmed** address |
| address_masked | VARCHAR(190) NULL | `d***@example.com`. Phase 9's one addition to the planned DDL |
| channel | VARCHAR(32) NOT NULL DEFAULT 'email' | |
| reason | ENUM('bounce','complaint','manual','unverified') | |
| detail | VARCHAR(500) NULL | e.g. `hard bounce: 5.1.1` |
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | the admin, for a manual row; **NULL for an automatic one**, which is what separates the two |
| created_at | DATETIME | |
`INDEX(created_at)`, `INDEX(reason, created_at)` — the screen's two orderings.
G16. **Keyed on the address, not the user**, and after Phase 1b made addresses unique that is a
choice rather than a workaround: a bounce arrives as an address, it does not know which account was
behind it, and it stays true after that account changed its address or was deleted.
Writes are `INSERT IGNORE`, so **the first reason an address was suppressed is the one that
survives** — an address that hard-bounced in March and was manually re-added in June still reads
`bounce`, because that is the fact explaining why the mail stopped. An upsert would let the most
recent write overwrite the diagnosis.
`address_masked` exists because a hash-only table cannot be operated; the reasoning and the routes
are in §7's *Deliverability* subsection.
### engagement_digest_state — how far each digest has got (engagement phase 6)
| col | type | notes |
@@ -763,58 +719,6 @@ drops unread items is one whose badge means nothing. The horizon is `settings.us
drops unread items is one whose badge means nothing. The horizon is `settings.user_notifications_retain_days`
(default 90), so an operator tightens a busy shard without a deploy — `team_activity`'s posture, in the
worker that file is modelled on.
### Engagement retention — three sweeps and one recorded refusal (engagement phase 14)
Four engagement tables grow, and until Phase 14 nothing deleted from any of them.
`utils/engagementRetentionPrune.js` is one nightly worker over three of them —
`setInterval` + `unref` + `stop()`, wired into `server.js` beside `teamActivityPrune` and
`inboxPrune`, batched 1000 × 50 per table, each table's failure caught on its own so a lock
timeout on one does not leave the other two unbounded.
| table | horizon | setting | what is eligible |
|---|---|---|---|
| `engagement_sends` | 180 days | `engagement_sends_retain_days` (7–3650) | every row; they are all terminal |
| `engagement_cooldowns` | 30 days | `engagement_cooldowns_retain_days` (2–3650) | every row, by `last_fired_at` |
| `engagement_outbox` | 30 days | `engagement_outbox_retain_days` (2–3650) | **terminal rows only** — `sent`, `failed`, `cancelled`, `suppressed` — by `created_at` |
| `engagement_suppressions` | **never** | — | nothing. See below |
**The outbox sweep is terminal-only, and that is a correctness rule, not a preference.** A
`scheduled` row is a send this deployment still intends to make — `delay_seconds` can legitimately
put one a day out — and a `sending` row may be a worker mid-flight. A sweep by age alone would
cancel sends nobody cancelled, and the only symptom would be mail that never arrived.
**The floors are not UI niceties.** Below two days, a pruned cooldown row makes the next fire a
FIRST fire — the rule sends twice; `MAX_COOLDOWN_SECONDS` is a validated 86 400, so two days is the
smallest provably-safe value against any rule that can be saved. The send log's floor is a week
because `engagement_sends` has two live readers: the per-rule hourly ceiling counts it
(§7.1 Q3), and Admin → Engagement → Send Log is the operator's only answer to "was this person
told".
**The cooldown horizon is checked, not assumed.** `engagementRules.db.maxEnabledCooldownSeconds()`
(enabled rules only — a disabled rule writes no cooldown row) is compared against the horizon on
every read of the policy and on every sweep. A horizon that does not clear it produces a warning on
the screen and in the log, **and the sweep runs anyway**: refusing to prune would trade a bounded,
describable fault for the unbounded one this phase exists to end.
**`engagement_suppressions` does not expire, and that is the recorded decision** (org lead,
2026-09-01). A suppression is a standing decision, not a record of something that happened; ageing
out a hard bounce re-mails an address that already bounced, which is how a sender loses a domain's
reputation. The way out stays deliberate — see the per-row lift in the route table.
**Retention has a screen**, unlike `team_activity` and `user_notifications`, whose horizons are
invisible settings rows. The send-log horizon changes what an operator-facing page is *able to show*,
so it has to be visible and settable; having made one visible, hiding the other two would split one
question ("what does this deployment keep") across two places. `EngagementSendLog.jsx` reads the
policy and prints *"entries older than N days are removed automatically"* beneath its pager, so the
total it shows stops being quietly wrong.
**One shipped defect this phase had to fix to be a bound at all.** `outboxDb.reclaimStale` returned
every stale `sending` row to `scheduled`, and `MAX_ATTEMPTS` is consulted only on a graceful `retry`
outcome — so a send that killed the process mid-flight cycled sending → scheduled → sending forever,
never reached a terminal status, and was therefore never eligible for any sweep. It now fails a row
that has burned its attempts **before** reclaiming the rest; the order is the fix, and reversing it
hands the exhausted row straight back to `findDue`.
### The two block registries — pages and mail (engagement phase 5a)
@@ -1117,190 +1021,6 @@ Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a m
[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it.
---
### The six event tables — the engine's, game-agnostic (events phase 1)
Design of record: [`EVENTS.md`](EVENTS.md) §D, which is where the full column list of every event
table lives. **Six land in Phase 1** — the ones that do not depend on the module contract — and are
spelled out below. The rest arrive with the phase that gives each a writer, rather than as empty
tables nothing reads: `event_run_phase_gates` in Phase 5, `event_action_settings` and
`event_run_budget` in Phase 6, `event_run_resources` in Phase 8, and **`event_run_participants` in
Phase 10** — `run_id` + a module-opaque `member_key` that is `UNIQUE` together, a nullable `user_id`
that `SET NULL`s so a record of what happened survives an account deletion, a `DECIMAL(18,4)` score
and a `rank_at` written only when results are published. Core writes it and sources none of it: a
`member_key` → account mapping is one game's, and a module reports both halves on its action's
success envelope.
Phase 10 also put two columns on tables that already existed — `event_runs.results_published_at`
(below) and a nullable `announce_jobs.run_id`, which is what lets an event announce a post the news
pipeline has already announced without either job standing on the other's toes. Everything that
means *"the post's job"* — the post admin panel, its retry button, `posts.announced_at` — still means
the one with a NULL `run_id`.
Core owns the engine; a module owns the meaning. No column below carries a game noun: an action id,
a `scope`, a resource kind and a budget dimension are opaque strings core stores and never
interprets.
#### event_series — the arc
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| name / slug | VARCHAR(160) NOT NULL, `UNIQUE(slug)` | |
| description | TEXT NULL | |
| ordering | INT NOT NULL DEFAULT 0 | where this series sits among the others. **Not** a position within it — that is `event_definitions.series_order`, which is the column an editor drags |
| created_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
Read-only through Phase 1: a definition may be pointed at a series, and creating or ordering one
arrives with the calendar.
#### event_definitions — the thing that is listed, scheduled and audited
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| title | VARCHAR(200) NOT NULL | |
| slug | VARCHAR(200) NOT NULL, `UNIQUE` | derived from the title **once** and frozen, like a Team's: the public event page lives at it |
| summary | VARCHAR(500) NULL | |
| body | MEDIUMTEXT NULL | the storyline. Sanitized on write through `utils/sanitizeHtml.cleanBody`, exactly as a wiki page is |
| image_url | VARCHAR(500) NULL | |
| owner_module | VARCHAR(64) NULL | the module that SHIPPED this definition as content — not the module whose actions its steps call. A definition may call three modules' verbs and belong to none of them; NULL is the ordinary case |
| state | ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft' | three states, not five. An admin publishes their own work, so there is nobody to submit it to |
| current_version_id | INT NULL | **no foreign key**, the one column in this group without one: `event_versions.definition_id` already points back here, and a second FK the other way makes the pair a chicken and an egg on insert |
| spec | JSON NOT NULL | **the working copy** — phases and their steps, as the author last saved it. Not in §D's column list; see below |
| series_id | INT NULL FK→event_series(id) ON DELETE SET NULL | |
| series_order | INT NOT NULL DEFAULT 0 | this definition's place within its arc |
| concurrency_key | VARCHAR(190) NULL | stored as the **template** (`invasion:{region}`), rendered from a run's own params at materialisation. A flat definition-id key would wrongly stop one definition running in two regions at once |
| grace_seconds | INT NOT NULL DEFAULT 900 | a schedule that passed this long ago while the process was down is `missed`, never a late silent start. Validated 60..86 400 |
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | IANA, and it belongs to the **event**: every listing this replaces is written in the shard's local zone, and a recurrence computed in UTC puts a Friday-8pm event at 7pm for half the year. Validated against the platform's own tzdata via `Intl.DateTimeFormat` |
| created_by / updated_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| created_at / updated_at | DATETIME | |
`INDEX(state, updated_at)` — the admin list's ordering and the public calendar's filter.
`INDEX(series_id, series_order)` — the arc.
**`spec` is Phase 1's one addition to §D's column list, and it is forced by the versioning rule.**
"Editing a draft is free; no version exists yet" means the working copy has to live somewhere, and it
cannot be an `event_versions` row: that table is immutable and a run pins one, so a mutable
unpublished row in it would be exactly what versioning exists to prevent. Publishing copies this
column into a version and leaves it standing as the next draft.
#### event_versions — the immutable snapshot a run pins
| col | type | notes |
|---|---|---|
| id | INT AUTO_INCREMENT PK | |
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
| version | INT NOT NULL, `UNIQUE(definition_id, version)` | two publishes racing for version 4 is one 1062, not two rows called 4 |
| spec | JSON NOT NULL | phases, steps, schedule — the whole authored tree |
| published_at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| published_by | INT NULL FK→users(id) ON DELETE SET NULL | |
**Nothing updates a row here and nothing deletes one.** Editing a `ready` definition creates the
*next* version on publish; a live run keeps the version it pinned and is unaffected. That pin is what
makes a run reproducible and an audit answerable after the definition has moved on.
#### event_runs — one occurrence, in one scope
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| definition_id | INT NOT NULL FK→event_definitions(id) ON DELETE CASCADE | |
| version_id | INT NOT NULL FK→event_versions(id) | **no ON DELETE clause, so it RESTRICTs**: a run whose pinned spec had been deleted could not be explained afterwards, which is the one thing this table is for |
| scope | VARCHAR(190) NOT NULL DEFAULT `''` | module-opaque; core never parses it. `''` and **not NULL**, because it is part of a UNIQUE key and multiple NULLs do not collide in MariaDB — a NULL scope would silently permit two runs of one occurrence |
| status | ENUM('scheduled','starting','running','paused','ending','completed','cancelled','failed','missed') NOT NULL DEFAULT 'scheduled' | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal |
| health | ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok' | separate from `status`, because a run can be genuinely running *and* degraded — announcements landing, world writes parked — and one column cannot say both |
| cleanup_status | ENUM('not_required','pending','complete','incomplete') NOT NULL DEFAULT 'not_required' | also separate: a run reaches `completed` with `incomplete` cleanup rather than being held open, and stays on the admin screen until a human resolves it |
| current_phase | VARCHAR(64) NULL | |
| scheduled_for | DATETIME NOT NULL | **UTC**. The definition's zone is what an occurrence is computed *in*; what is stored is the instant |
| timezone | VARCHAR(64) NOT NULL DEFAULT 'UTC' | copied from the definition at materialisation |
| concurrency_key | VARCHAR(190) NULL | the definition's template, rendered against this run's params |
| params | JSON NULL | |
| rehearsal | TINYINT(1) NOT NULL DEFAULT 0 | dispatches for real; excluded from the public calendar and from participation history |
| started_at / ended_at | DATETIME NULL | |
| results_published_at | DATETIME NULL | when the run's results table was last ranked and published (phase 10). A stamp rather than a status: *"may I show this table"* and *"when was it settled"* are the same column. `core.results.publish` re-stamps rather than guarding on NULL, because a second publication after a late correction is a real one |
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | the lease. Written by the runner |
| started_by | INT NULL FK→users(id) ON DELETE SET NULL | |
| last_error | VARCHAR(500) NULL | |
| created_at / updated_at | DATETIME | |
`UNIQUE(definition_id, scope, scheduled_for)` — **and it, not the claim, is what makes "one run per
occurrence per scope" true.** The claim decides *who* advances an occurrence; this index is what
stops two of them existing. `scope` is inside the key so a worldwide event fans out to many servers
without colliding with itself. Materialisation is `INSERT IGNORE` against it, so asking twice for one
occurrence answers with the existing row rather than raising a duplicate-key error a caller has to
interpret.
`INDEX(status, scheduled_for)` the runner's scan · `INDEX(definition_id, scheduled_for)` the run list
· `INDEX(concurrency_key, status)` the overlap check.
#### event_run_steps — the work queue
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
| phase / seq | VARCHAR(64) NOT NULL / INT NOT NULL | |
| action_id | VARCHAR(96) NOT NULL | a declared action id. **No FK and no existence check**, for the reason `engagement_rules.trigger_id` has none: an action is declared in code, so a step naming one no module currently registers is *dormant*, never deleted |
| params | JSON NULL | validated against the action's declared params at save |
| action_version | INT NOT NULL DEFAULT 1 | what the step was AUTHORED against. A bump makes the editor warn rather than dispatch a mistyped parameter |
| status | ENUM('pending','running','done','failed','skipped','refused','cancelled') NOT NULL DEFAULT 'pending' | **`refused` is the cap breach and is deliberately not `failed`**: nothing is wrong with the system, an author asked for more than this deployment allows |
| due_at | DATETIME NULL | |
| attempts | INT NOT NULL DEFAULT 0 | |
| on_failure | VARCHAR(32) NOT NULL DEFAULT 'pause' | `skip` · `pause` · `abort_run`, defaulted from the action's risk class at save: `notify`/`inspect` → skip, `change` → pause, `irreversible` → abort_run |
| idempotency_key | CHAR(40) NOT NULL | `sha256(runId\|stepId)` truncated to 40 hex, the shape `shardEvents.dedupeKey` uses. **Minted once at materialisation and it does not vary by attempt** — a retry re-sends the same key so the game side can recognise the repeat |
| claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | |
| last_error | VARCHAR(500) NULL | |
| started_at / finished_at | DATETIME NULL | |
| created_at / updated_at | DATETIME | |
`UNIQUE(run_id, phase, seq)` — materialisation is `INSERT IGNORE` against it, so a tick that overran
into the next one cannot double-materialise a phase. `INDEX(status, due_at)` the drain scan ·
`INDEX(run_id, phase, seq)` the run console.
#### event_run_log — "why didn't phase 3 start?" must be a query
| col | type | notes |
|---|---|---|
| id | BIGINT AUTO_INCREMENT PK | |
| run_id | BIGINT NOT NULL FK→event_runs(id) ON DELETE CASCADE | |
| step_id | BIGINT NULL FK→event_run_steps(id) ON DELETE SET NULL | |
| kind | VARCHAR(48) NOT NULL | a **closed set enforced in `eventRunLog.db.js`, not an ENUM**: the set grows with almost every later phase, and an ENUM change is a table alter this project has no migration system for. Phase 1's five: `run.created`, `run.status`, `phase.entered`, `step.status`, `note` |
| phase | VARCHAR(64) NULL | |
| detail | JSON NULL | structured, and that is the whole point — `activity_log.detail` is TEXT and unqueryable |
| at | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
`INDEX(run_id, at)` the console · `INDEX(at)` the retention sweep the runner phase adds.
**This table sits beside `activity_log`, not instead of it.** Both are written: the administrative
audit of *who published what* goes to the activity log, the diagnosis of *why a run did what it did*
goes here. They are different questions with different readers and different retention. The writer
**never throws** — a failure to record why something went wrong must not become a second failure on
top of the first.
**The log is high-cardinality and grows per event, so it needs a retention sweep from the start.**
`engagementRetentionPrune` is the pattern and the rule that work learned is that only *terminal* rows
are eligible; the sweep lands with the runner, and the index it needs is in the DDL from the
beginning.
#### The action registry — declared, never stored
Actions, budget dimensions and conditions are **registry entries, not tables** (§D "Not tables,
deliberately"): a module declares them at `register()`, like streams and audiences, and a stored one
would outlive the module that can perform it. `modules/registries.js` gained
`registerEventActions` in this phase, with core as its first registrant —
`config/coreEventActions.js` declares `core.announce`, `core.wait` and `core.cue`, so the seam is
exercised on every boot long before a module uses it.
The declaration is shape-checked at the call: the id grammar (its **own** namespace — an action names
a verb and a trigger names an event, so one id may legitimately be both), a required `risk` over the
closed four-value set, a required `reversible` over its own four, `revert()` required **iff and only
iff** `reversible: 'ledger'`, a bounded `budgetMs`, and a param list whose every entry needs a type
and an `example`. `perform`, `revert` and `cost` are stripped from everything the admin catalog
serves, exactly as an audience's `resolve` is: the browser's whole relationship with an action is
naming one by id.
`registerEventActions` is on the staging area and is reached **only** by `registerCore()`. `loader.js`
builds its own `api` facade for a module and has no method that delegates to it, so no module can
call it yet and `MODULE_API_VERSION` is untouched — the module contract, and the bump, are a later
phase's.
---
## 4. API contract
@@ -1467,27 +1187,8 @@ registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscr
registers device endpoints (`/auth/me/devices`); nothing is pushed unless subscribed. Delivery is a
**content-free tickle** — `{ stream, ref }`, no sensitive data — POSTed to each subscribed device's
self-hosted **ntfy** endpoint (`utils/pushDispatch`); the app wakes and pulls the real, ownership-
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and — from
**ENGAGEMENT.md Phase 11** — the **engagement engine** for `news.post`.
> **The news publish path changed in Phase 11 and it is an operator-visible change.** Publishing a
> news post used to call `pushDispatch.publish('news.post', …)` directly from
> `admin.controller.js`'s `announceIfNewlyPublished`; it now calls
> `utils/newsNotify.emitNewsPost`, which emits core's declared `news.post` trigger and lets the engine
> decide. Push therefore rides a **rule** like every other channel, and core seeds that rule
> `enabled = 0` beside the four Team ones — so **news push stops on upgrade** until an operator enables
> it in Admin → Engagement → Rules, where a banner says so. The `news.post` seed carries its own
> one-shot settings key (`engagement_news_rule_seeded`) rather than joining the Team group's, because
> the Team key is already stamped on exactly the deployments this affects.
>
> **The other two things a publish fires are untouched.** `announceJobs.enqueueIfNeeded` (a one-shot
> delivery to a channel of the deployment — the in-game town crier, Discord #news, with retry) and
> `registries.dispatchPostHook('onSaved')` (idempotent state mirroring, which also runs on delete) are
> different kinds of thing and still fire exactly as they did. The emit is gated on the same
> `enqueueIfNeeded` job id the push was gated on — the single "newly published news" transition
> signal — so an edit or a re-publish still does not re-fire.
The catalog is assembled at boot by
checked content over the authenticated API. Two producers fan out through the one publisher: the shard
ingest dispatcher (`utils/shardIngest`, beside the SSE broadcast) for shard-derived streams, and the
create/publish-post path for `news.post`. The catalog is assembled at boot by
`modules/registries.js` from core's own streams (`config/coreStreams.js` — just `news.post`) plus
each installed module's. The seven shard streams and their event→stream mapping left with
@@ -1696,34 +1397,8 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/teams/:id/leader-override` · DELETE `…/:memberKey` | set or clear a staff leadership decision, applied **on top of** the synced value at read time. Not gated: it publishes no game-sourced string |
| GET | `/moderation/reports` · POST `…/:id/handle` | the member-raised content-report queue (phase 5, [`TEAMS.md`](TEAMS.md) §5.6) and the staff decision on one. Mounted under **moderation**, not under Teams: a staffer working a queue should have one place to work, and `target_type` is open-ended so the next reportable thing arrives as a row rather than as a screen. Each row carries its target already resolved — a post's excerpt and author, a thread's title, or an upload's uploader, byte size and **sniffed** mimetype — in three batched reads, never one per row. A target hard-deleted since reporting comes back `null` and the row still lists. **There is no leader-facing counterpart to either route**, deliberately |
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
| GET | `/events` | every definition with its state and current version. `?state=` filters to `draft`/`ready`/`archived` |
| GET | `/events/:id` | one definition **including its working spec** — the list serves a summary, this is the authored tree the editor renders |
| POST | `/events` | **admin, editor.** Create a draft. The slug is derived from the title once and frozen: the public event page lives at it, so a retitle must not break a posted link. A step naming an action no module registers is refused |
| PUT | `/events/:id` | **admin, editor.** Editing never touches a published version — a live run keeps the one it pinned. A step whose module has since been uninstalled is **kept and marked dormant**, not refused: the rule `engagement_rules` established for a dormant trigger, because an uninstall must not be destructive after the fact. `409` on an archived definition |
| GET | `/events/:id/versions` | the version history. Nothing edits a version; the row flagged `current` is what a new run pins |
| POST | `/events/:id/publish` | **admin only** ([`EVENTS.md`](EVENTS.md) §N2) — publishing commits a definition that a schedule will later start unattended, which is deliberately not the same gate as the live run controls. Snapshots the working spec into an immutable version. **Re-validates against the registries as they stand right now**, not from the save that wrote it: `409` naming the action when a step went dormant in between, `400` when no phase has any steps |
| DELETE | `/events/:id` | **admin only.** Archive — there is no hard delete at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide. `409` while a run of it is still in flight |
| POST | `/events/:id/runs` | **admin only**, on the same reasoning as publish. Creates an occurrence. `INSERT IGNORE` against `UNIQUE(definition_id, scope, scheduled_for)`, so asking twice answers `200` with `created: false` and the existing row rather than creating a second. Optional `scope`, `scheduledFor`, `rehearsal`, `params` |
| GET | `/events/runs` · `/events/runs/:runId` · `/events/runs/:runId/log` | the run list, the run console (steps, their params and their idempotency keys, plus status counts) and the diagnostic log |
| GET | `/events/catalog` | the registered actions with their param schemas, risk classes and reversibility, plus the closed vocabularies the authoring form renders. **Served from the registries, not from a table** — a module that was uninstalled simply stops appearing |
| GET | `/events/series` | the arcs a definition may belong to. Read-only in this phase |
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
> **The `/events/*` rows above are the surface as of Phase 1**, and they are not the whole of it.
> The live run controls (Phase 3), the calendar and series writes (Phase 4), `advance` (Phase 5),
> the dry run and the action switchboard (Phase 6), the option-source route (Phase 7), `cleanup`
> (Phase 8), the cap meter (Phase 13) and the public/player reads (Phase 14a) are not listed here.
> **[`EVENTS.md`](EVENTS.md) § API surface is the canonical table** and carries every one of them
> with the reasoning for its gate; re-listing them here would be a second copy of a contract that
> file owns, and the copy that drifts is always the second one.
>
> The Phase 14a reads are the only ones outside this tier: `GET /public/events`,
> `/public/events/:slug` and `/public/events/series/:slug` on the anonymous surface, and
> `GET /player/events/history` on the self-service one. What makes something visible there is
> `listed` **and** `ready` **and** not a rehearsal, and all three are predicates in SQL rather than
> checks a caller performs — a draft, an archived definition and an unlisted one all answer 404,
> indistinguishable from a slug that never existed.
Every admin write logs to `activity_log`.
@@ -1926,74 +1601,6 @@ credentials.
`transport='smtp'` with no credentials, so every sink above politely does nothing and **mail simply
stops**. The admin dashboard warns whenever the deprecated Gmail token is present and no replacement
credential is; see [`UPGRADE_NOTES.md`](UPGRADE_NOTES.md).
### Deliverability: suppression, bounces and the verification gate *(engagement phase 9)*
Engagement Phase 9 ([`ENGAGEMENT.md`](ENGAGEMENT.md) Phase 9). Two mechanisms decide that a person
who is *in* a rule's audience does not get the mail, and they are deliberately at different points
in the pipeline.
**`engagement_suppressions` — checked at DELIVERY.** Keyed on `address_hash` (sha256 of the
lower-cased address), because a bounce arrives as an address and stays true after the account behind
it changed its address or was deleted. An outbox row can sit through a rule's `delay_seconds` grace
window and an address can bounce inside it, so the only correct check is the one taken immediately
before the transport call — which is also what produces the `status='suppressed'` row in
`engagement_sends` with no transport call at all.
**The verification gate — applied at ENQUEUE.** With the `email_verification_required` setting on
(seeded in Phase 1b: `on` for a fresh install, `off` for an upgrade), an unverified address is
excluded before an outbox row is written. It hangs off a channel's optional **`eligible(userIds)`**
registration rather than living in the engine: being unverified is an *email* fact, and a rule
spanning email and in-app must still reach that person's inbox. Only `email` declares one. The
excluded count comes back so the admin reach preview reports it instead of quietly promising a
number the engine will not deliver.
**Scope: engagement rules only.** Password resets, invites, verification mails and the contact form
still attempt to a suppressed or unverified address. This is the posture `passwordReset.controller.js`
already took — user-initiated mail must not be blocked by a background system's opinion, and one
reset to a dead mailbox is not a reputation problem, whereas a rule mailing thousands of people
weekly is.
**What may write a `bounce` row is narrower than "the send failed".** `src/engagement/bounceClassify.js`
is the only judge, and it is deliberately **not** `mailer.PERMANENT_CODES` — that set answers "is
retrying pointless?" and contains `EAUTH` and `554`, so reusing it would mean one stale SMTP password
suppressing every address the worker touched, silently. The classifier reads the **RFC 3463 enhanced
status** first (`5.1.1`, `5.1.2`, `5.1.3`, `5.1.6`, `5.1.10`, `5.2.1` suppress; `5.3.x`, `5.5.x` and
`5.7.x` never do, being about the server or our standing with it), and falls back — only for `550`,
`551` and `553`, and only past a veto list — to a phrase match. **Anything it is unsure about is not
suppressed:** a false negative costs one retry next month, a false positive costs a person who
silently stops hearing from the deployment.
SMTP has no *asynchronous* bounce or complaint feed — that is where an API-based provider would earn
its place — but a single-recipient send refused at `RCPT TO` throws synchronously with the reply
code intact, which is the highest-value signal there is and is what this reads. `sendNotification`
therefore returns an `smtp: { code, responseCode, response }` triple alongside its classification;
`retry` and `detail` cannot answer "was this the recipient's fault", since `550 5.1.1` and
`550 5.7.1` are an identical `retry: false`.
**Statuses.** `engagement_sends.status` gains two real writers: `suppressed` (declined to try) and
`bounced` (tried, the mailbox does not exist). `engagement_outbox.status` records `bounced` as
`failed` — its ENUM has no such value and, from the queue's point of view, a bounced row is one that
finished unsuccessfully. `complained` still has no writer: it needs a provider feedback loop.
**Routes** (all `adminOnly`, under `/api/v1/admin/engagement`):
| Route | Notes |
| --- | --- |
| `GET /suppressions` | Paged, filterable by `reason` / `channel` / `search`, plus unfiltered `byReason` totals. **Returns `address_hash` from Phase 14 on**, reversing Phase 9's decision to strip it: without a handle the only way out of the list was a `window.prompt` asking the operator to retype an address the screen has never shown them. The trade — this route is admin-only, and an admin can already suppress and unsuppress any address they can name, so the hash grants no capability they lack. `GET /sends` still strips its own hash: nothing there needs to act on a row |
| `POST /suppressions` | `reason` is forced to `manual` — an admin typing an address is not evidence of a bounce. An address already listed answers 200 with `created: false`, not 409 |
| `DELETE /suppressions` | The way out for an address the operator can type. It goes in the **body**, not the path: a path parameter lands in the access log, the browser history and every proxy in front of the deployment |
| `DELETE /suppressions/by-hash/:hash` | The per-row Lift button (Phase 14). Same effect, different input — the operator is looking at a mask and knows only the row's handle. The handle **is** safe in the path where an address is not: a sha256 already served only to an admin session leaks nothing further by being logged. Shape-validated to 64 hex characters before it reaches a WHERE clause; `404` rather than `200` when nothing matched, so a stale screen says so instead of claiming success |
| `GET · PUT /retention` | The three sweep horizons in days, with the bounds each is validated against and a `warnings` array carrying the one check that is not a static bound — a cooldown horizon shorter than the longest cooldown on an ENABLED rule. `engagement_suppressions` is deliberately absent: it does not expire. The `PUT` is **sparse** (saving one select cannot clobber another admin's concurrent change) and **refuses out-of-range rather than clamping**, because storing something other than what was typed would leave the screen describing a policy the deployment is not running |
**Neither route ever returns `address_hash`**, the same rule `GET /sends` follows: a sha256 of every
address on the deployment, handed to a browser, is an offline dictionary attack. What the list
returns is `address_masked` — `d***@example.com` — which Phase 9 added to §4.5's DDL because a
hash-only table cannot be operated: an operator has to be able to see a whole domain refusing mail
and to let back in somebody who fixed their mailbox. The domain survives intact for the first; the
local part is destroyed rather than shortened, so the column can never be read back as an address
book. The consequence is that **lifting a suppression needs the full address typed in** — the screen
genuinely does not have it, which is the privacy design working rather than a rough edge.
---

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -26,394 +26,16 @@ 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.10.0'
const MODULE_API_VERSION = '1.7.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.10.0 — the event contract opens to modules: `api.registerEventActions(...)`,
`api.registerEventBudgets(...)`, `api.registerEventLeases(...)` and
`api.registerEventOptionSources(...)`** (`website/EVENTS.md` §F, `EVENTS_PLAN.md` Phases 7 and 8).
Four additions and no removal, so minor; a module written against 1.9.0 registers no actions and its
deployment simply has fewer verbs an event can use — which is §F's own posture stated as a version
rule, because core with none of this installed is still an event engine that can announce, wait, cue
a human and publish results.
> **Phase 8 added `reconcile()` and `ctx.events.reconcile()` to this same version rather than to a
> new one** (org lead, 2026-09-03). A protocol owes a bump once it has landed on `main`; while it is
> on `edge` it is amended in place — the rule the Teams workstream arrived at, applied to a module
> API for the first time. 1.10.0 has not shipped, so the whole module contract reaches an author as
> one version they read once, which was the argument for putting the lease declaration here in the
> first place.
>
> **Phase 10 amended it a second time, under the same rule**, with `participants` on the success
> envelope and an optional narrowing `ceiling` on `ctx.events.emit`'s. `main` still declares 1.9.0,
> so 1.10.0 remains unshipped and the whole event contract — actions, budgets, leases, option
> sources, reconcile, participants — still reaches an author as one number. The `ceiling` member is
> the one of the two that widens something 1.9.0 already shipped, and it is additive and optional:
> a module that never passes it is emitting exactly what it emitted before.
**Only one of the four is new machinery.** The ACTION registry has staged core's `core.announce`,
`core.wait` and `core.cue` on every boot since Events Phase 1; what it never had was a way in —
`loader.js` built its own `api` facade and had no method that delegated to it. So the seam a module
now reaches is one that has been exercised on every boot for six phases, rather than one whose first
registrant is a stranger. That is the same argument `registerCore()` has made since the module
system's Phase 3, and 1.10.0 is when it pays.
```js
api.registerEventBudgets([
{ id: 'uo.creatures', label: 'Creatures spawned', unit: 'count' }, // a DIMENSION core can bound
])
api.registerEventActions([{
id: 'uo.creature.spawn', // <moduleId>.-prefixed; its OWN id space
label: 'Spawn creatures',
risk: 'change', // closed: notify | inspect | change | irreversible
reversible: 'ledger', // closed: none | self | ledger | override
version: 1,
budgetMs: 10000,
cost: (p) => ({ 'uo.creatures': p.count }), // what ONE invocation consumes
params: [
{ name: 'creature', type: 'string', required: true,
example: 'Orc', source: 'uo.options.creatures' }, // a `source` makes it a dropdown
{ name: 'count', type: 'int', required: true, example: 12 },
],
async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {
if (verify) return { ok: true } // dry run: validate, change NOTHING
return { ok: true, resources: [{ kind: 'creature', ref: '0x40001234' }] }
},
// Required iff reversible: 'ledger'. Called by core's cleanup sweep at teardown,
// over the rows this action's `resources` produced — a LIST, so twelve creatures
// are one round trip. `{ ok: true }` reverts the group; `failed: ['0x...']` names
// the ones that did not come back.
async revert({ runId, resources, idempotencyKey }) { return { ok: true } },
// OPTIONAL, and only on an action that ledgers. "Which of these does the game
// still have?" — asked after something outside core restarted.
async reconcile({ runId, resources }) { return { ok: true, inForce: ['0x40001234'] } },
}])
// The module says WHEN, because core cannot: core has no concept of the game
// being up. module-uo already watches `bootId` to tell a shard restart from a
// sidecar reconnect, and that is the moment a ledger of live spawns has become a
// claim about a world that no longer exists.
ctx.events.reconcile()
api.registerEventOptionSources([{
id: 'uo.options.creatures', label: 'Creatures',
async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] },
}, {
// A catalog bigger than a dropdown holds. Core passes `q` to EVERY source and
// requires it of none, so a resolver that ignores it is unchanged; `searchable`
// is what tells the authoring form to render a typeahead rather than a select.
id: 'uo.options.spawners', label: 'Spawners', searchable: true,
async resolve({ q } = {}) { return search(q).map((r) => ({ value: r.id, label: r.name })) },
}])
// A value a run may borrow. The module ships the three callables; the VERB an
// author puts in a step is core's `core.lease`, so the duration bound and the
// two-events-one-target conflict check live in one place.
api.registerEventLeases([{
id: 'uo.rate.skillgain', label: 'Skill gain rate',
type: 'float', min: 0.5, max: 5, maxDurationMs: 86400000,
async read() { return { ok: true, value: 1.0 } },
async apply(v, until) { return { ok: true } },
async restore(baseline, { expected }) { return { ok: true } },
async inForce() { return { ok: true, held: true } }, // optional
}, {
// A TARGETED lease: one capability over many things. Core adds the target to
// the reservation ref (`<lease id>#<target>`) so two runs may hold the same key
// on two different objects, and hands it to all four callables.
id: 'uo.spawner.maxcount', label: 'Spawner: how many at once',
type: 'int', min: 0, max: 100, maxDurationMs: 43200000,
target: { label: 'Which spawner', source: 'uo.options.spawners' },
async read({ target }) { return { ok: true, value: '3' } },
async apply(v, until, { target }) { return { ok: true } },
async restore(baseline, { expected, target }) { return { ok: true } },
async inForce({ target }) { return { ok: true, held: true } },
}, {
// A string lease may close its value set. `min`/`max` bound the numeric types
// and nothing bounded `string`, so without this the only check on the value is
// the game side's -- a refusal arriving unattended, mid-run, rather than on the
// authoring form.
id: 'uo.seasonal.status', label: 'Seasonal event status',
type: 'string', values: ['Inactive', 'Active', 'Seasonal'], maxDurationMs: 43200000,
target: { label: 'Which seasonal event', source: 'uo.options.seasonal' },
async read({ target }) { return { ok: true, value: 'Inactive' } },
async apply(v, until, { target }) { return { ok: true } },
async restore(baseline, { expected, target }) { return { ok: true } },
}])
```
**What a module author has to know beyond the four names**, because each is a rule rather than a
field:
- **No shape a failure can take reads as success.** A rejected promise, a throw, a `budgetMs`
timeout, a non-object and a missing `ok` are all `{ ok: false, retry: true }`. **A module that
needs the `retry: false` half of that to be reachable must declare a `budgetMs` longer than its
own transport's timeout** — see §2.4's rule, which exists because the first module to register an
action did not, and its one non-retryable verb was retried anyway. That is
`registerTeamProvider`'s default *inverted*, deliberately: a team provider that refuses leaves core
showing what it had, because staleness is cheap, whereas an action that half-ran and was recorded
as done is a world change nothing will ever come back for. `retry` is opted OUT of — a module that
means "this will never work" says `retry: false`.
- **`inForce()` is a fourth question, not a fourth spelling of `read()`.** Optional, and answering
`{ ok: true, held: false }` is the only thing that takes a lease's ledger row out — everything
else, including a throw and a lease that declares no `inForce()` at all, leaves the row alone.
- **A lease that declares a `target` is a family of values, and core changes what it reserves.**
Without one, the lease id *is* the target and the ledger reserves it alone — which is right for a
config key and wrong for a property, because `Spawner.MaxCount` is one capability over thousands
of spawners and reserving the id would let one run turning up one spawner refuse every other run
every other spawner. With one, the ref is `<lease id>#<target>`, the two-events-one-target index
bites at the granularity the world actually has, and the target reaches all four callables.
**Core refuses a targeted lease with no target and an untargeted one with a target**, both
`retry: false`: the second attempt has the same params. `target.source` names an option source
for the authoring form, and is not resolved by core at registration — a source registered by a
module that boots later must not make this one throw.
- **`values` closes a `string` lease's set, and belongs to no other type.** `min`/`max` bound the
numeric types; a set on an int lease would be a second bound beside them with no rule about
which wins, so it is refused.
- **A source is passed `{ q }` and may ignore it.** Additive: a resolver written before this
existed behaves identically. Declare `searchable: true` when the term actually narrows the
answer — the form reads that to decide between a typeahead and a select, and inferring it from a
truncated list would read correctly right up until a small deployment's list happened to fit.
Core needs it because a reconcile after an outage asks *"does the game side still have any record
of this hold?"*, and none of the other three answers that: a value that DIFFERS from what the run
applied is drift, which `restore()` reports so the row lands `drifted` with the current value
beside it, and a reconcile that inferred absence from a changed value would orphan the row first
and tell the operator the lease vanished rather than that somebody moved it. The two questions
have different answers on purpose. Without it a lease row has no reconcile path at all — a lease's
step names `core.lease`, which is core's own action, so there is nowhere else a module could hang
the answer.
- **A module cannot spend a budget it did not declare.** A `cost()` naming a dimension no module
registered is REFUSED — at save, at the dry run and at dispatch, with its own refusal code, because
the fix is a module's declaration and not a deployment's cap. Declaring a dimension is not the same
as bounding it: a declared dimension with no operator cap is counted and unbounded.
- **`verify: true` must change nothing and must answer honestly.** It rides the same dispatcher a
real run uses, because a dry run down a second code path is a dry run of the second path.
- **`example` is required on every param, optional ones included** — the same rule
`registerEventTriggers` makes of a variable's example, for the same reason: it is the authoring
form's placeholder, it is one word at declaration time, and it is unreconstructable afterwards.
- **Actions, budgets, leases and option sources are four separate id spaces**, each namespaced
`<moduleId>.`. An action names a VERB, a budget a RESOURCE, a lease a VALUE and an option source a
CATALOG, so `uo.creatures` may legitimately appear in more than one — reading that as a collision
would forbid the most natural set of names a module will ever write.
- **A lease is declared by a module and acquired by CORE.** The verb is `core.lease`, and the
module never writes one: core reads the baseline, reserves the target in the resource ledger —
which is where "two events cannot hold one target" comes from, as a unique index rather than as a
check — applies the value with the deadline, and restores it at teardown through the module's own
`restore()`. A lease verb per module would be that bound re-implemented once per module, advisory
everywhere, and wrong in the first one that forgot it.
- **`until` goes down the wire, and the game side must honour it without being asked again.** A
module that treats it as advisory has produced a lease that outlives an outage, which is the one
thing a lease exists to prevent. Core's copy of the deadline is for the console; the game's copy is
the fail-safe.
- **`revert` must be idempotent, and reverting something that does not exist is a SUCCESS.** Core
records a resource BEFORE it is confirmed (`EVENTS.md` §D rule 1), so a dispatch whose answer was
lost leaves a row for something that may never have existed — and cleanup will ask about it. A
module never has to tell "I deleted it" from "it was not there". This is also what a Rust-style
monthly wipe needs, and the second reason a lease's restore must be idempotent too.
- **`revert` is also called with NO resources and only an idempotency key.** That is the lost-answer
case: core knows a dispatch went out under that key and never learned what it made. A module that
can undo by key answers honestly; one that cannot answers `{ ok: false }` and the row stays visible
to an operator, which is the correct outcome rather than a silent one.
- **`reconcile` is optional where `revert` is required**, and the asymmetry is the design. A module
that cannot say what the game still has is not broken — core keeps believing its own ledger, which
is the behaviour before this version — whereas one that created something and cannot undo it has
made a promise core has no way to keep. Anything that is not an explicit `{ ok: true, inForce: [...] }`
leaves the ledger alone: **"I do not know" is never read as "it is gone"**, and a resource a module
reports missing becomes `orphaned` rather than `reverted`, because nobody asked for it to go.
**A third joined in Phase 15, also on an envelope:**
- **`detail` on an action's SUCCESS envelope** (`EVENTS.md` §F). An optional object a module may
answer with, carried to the run log as a `step.detail` line and **never interpreted by core** —
nothing reads a key out of it in the dispatcher, the runner or the browser. It exists because a
module knows things about its own verb core cannot compute and had no other way to say them:
`uo.item.grant` reaches the players a run's participation ledger holds, and *which of them missed
out* was reported nowhere at all. On both success shapes, like `resources` and `participants`,
because `await: 'human'` is a success and a cue's confirm finishes the step without a second
dispatch.
Objects only, 4KB of serialised JSON, dropped rather than truncated, and **anything wrong with it
is dropped and logged rather than failing the step** — a step that did what it was asked must not
be re-run because its module's commentary was malformed, which would be a world write repeated for
a log line. It is additive and optional: a module that never answers one is behaving exactly as
before. **Found by writing the integration kit's chapter 5** (`EVENTS_PLAN.md` Phase 15), whose
template made the same mistake `module-uo` had — see §F.
**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.10.0 — the event contract** (`website/EVENTS.md` §F). Four additions, no removals and no changed
signature, so minor; `module-uo`'s `coreApi: "^1.9.0"` still resolves and it registers no actions
until `EVENTS_PLAN.md` Phase 9. `api.registerEventActions([...])`, `api.registerEventBudgets([...])`,
`api.registerEventLeases([...])` and `api.registerEventOptionSources([...])` (§2.4). Almost nothing
was added to `ctx`: an action is called BY core, so what a module needs from this contract it is
handed in the envelope rather than reaching for — `ctx.events.reconcile()` (Phase 8) is the one
exception, because only the module knows when the game it talks to has restarted.
Two members joined it in Phase 10, both on an envelope:
- **`participants` on an action's SUCCESS envelope** (`EVENTS.md` §D, §J). An action may answer
`{ ok: true, participants: [{ memberKey, userId?, score?, meta?, joinedAt? }] }` and core records
them against the run, on both success shapes, beside `resources`. `memberKey` is required and
module-opaque; `userId` is optional and is the module's own answer to "is this player a website
account", because core cannot map one and a core that guessed would be one game's identity model
compiled into core. A bad entry is dropped and logged, never a retry: a retried step re-dispatches
a world write that already happened.
- **`ceiling` on `ctx.events.emit`'s envelope** (`EVENTS.md` §I). An optional audience ceiling for
THIS firing, which may only ever narrow: the send-time G24 gate applies `meet(declared, emitted)`,
so a rule wider than the meet is refused and one narrower is unaffected. Two incomparable ceilings
meet to null and every rule is refused, which is §5.1a rule 3's posture rather than a guess about
which branch was meant. The case that forced it is core's own — a rehearsal fires the same
lifecycle triggers as a real run and must not mail every subscriber — and it is on the shared
envelope rather than in `events/` because "this particular firing is narrower than the kind
usually is" is a fact any emitter can have.
Three more joined it in **Phase 12b**, all on declarations rather than envelopes, and all amended
into 1.10.0 in place for the reason the two above were: 1.10.0 has never reached `main`, so there
is no deployment that could tell the difference, and the events cutover is what publishes the
whole of it. `module-uo`'s `coreApi` is unaffected; the integration kit is already red on purpose
and stays so until the cutover re-pins `ci/core-ref.json`.
- **`target` on a lease declaration** (`EVENTS.md` §F, `link/v7.md` §11). A lease with one is a
FAMILY of values rather than a single value, and core reserves `<lease id>#<target>` rather than
the id — so two runs may hold the same key on two different objects while two runs holding one
object still collide on the unique index. The target reaches `read`, `apply`, `restore` and
`inForce`. Every lease before this named one value, so the id *was* the target and none of the
four needed an argument; a property does not have that shape.
**The verb stays core's**, which is the whole reason this is an extension rather than a lease
verb of the module's own. §F settled that in Phase 8: a lease verb per module would
re-implement `maxDurationMs` and the conflict check once per module, advisory everywhere and
wrong in the first one that forgot. Half of that objection no longer holds — the
two-events-one-target refusal comes from the ledger's unique index whichever verb reserves the
row — and the other half still does.
- **`values` on a `string` lease.** The closed set an author may choose from, checked by
`core.lease` at authoring time. `min`/`max` bound the numeric types and nothing bounded
`string`, so the only check on a string lease's value was the game side's — a refusal arriving
unattended, mid-run, from a step nobody is watching. Refused on any other type: a set beside
`min`/`max` would be a second bound with no rule about which wins.
- **`searchable` on an option source, and `{ q }` passed to every `resolve()`.** A source whose
catalog is larger than a dropdown can hold narrows its answer by the term; one that ignores the
argument answers exactly as it did before this existed, which is what makes it additive. The
first source that needed it is `module-uo`'s spawner target — 6,707 spawn points against the
2,000-entry bound — and a truncated list is not an answer: it drops most of the world and says
nothing about which part. `searchable` is declared rather than inferred, because inferring it
from a truncated answer reads correctly right up until a small deployment's list happens to fit.
**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) ·
`api.registerEventTriggers([...])` and `api.registerAudiences([...])` (§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
@@ -635,8 +257,8 @@ module-uo does not need is on the list.
| `ctx.teams.publish` | `(event) => Promise<void>` | `model/teams/teamSync` | the Team provider's module (1.6.0) |
| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) |
| `ctx.teams.activity.push` | `(items) => Promise<void>`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) |
| `ctx.events.emit` | `(triggerId, envelope) => void`, fire-and-forget | `utils/engagementEmit` | `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) => void`, fire-and-forget | `utils/engagementEmit` | a module's own event mapper (1.7.0) |
| `ctx.inbox.push` | `(userId, item) => Promise<void>` — **throws until Phase 7** | the in-app channel | not yet (1.7.0) |
**`ctx.events.emit(triggerId, envelope)`** fires an event the module DECLARED with
`api.registerEventTriggers` (§2.4). It is the push half of the engagement seam
@@ -756,11 +378,6 @@ api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.
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.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 1.10.0
api.registerEventBudgets([{ id, label, unit }]) // 1.10.0
api.registerEventLeases([{ id, label, type, min, max, values, target, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0
api.registerEventOptionSources([{ id, label, searchable, resolve }]) // 1.10.0
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
@@ -1052,12 +669,10 @@ Six things about it are contract rather than implementation:
because under one namespace they are the same ids.
- **`ceiling` is required and has no default.** It is the audience ceiling (`ENGAGEMENT.md` §5.1a),
and there is no safe value to guess: `owner` would silently break a broadcast and `authenticated`
would silently widen a staff-only event. The 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.
would silently widen a staff-only event. The six values are `everyone`, `authenticated`,
`subscribers`, `members`, `staff` and `owner`, ordered by **containment and not by size** — a
`staff` ceiling does NOT permit `owner`, because fewer people is not less exposure. A default
`audience` wider than, or incomparable with, the ceiling is refused at registration.
- **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.
@@ -1110,111 +725,6 @@ Four things about it are contract rather than implementation:
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.
- **`digest` is a slot, not a channel.** It names the body the digest worker renders for a rule whose
email channel an individual set to digest mode, so it is legal in `template_keys` and never appears
in `channels`. Every other key must be one of the rule's channels.
- **It is not a send path.** Every value on the object is data. Core still decides who is told.
**`registerEventActions([...])` / `registerEventBudgets([...])` / `registerEventLeases([...])` /
`registerEventOptionSources([...])`** (1.10.0) are the event contract (`EVENTS.md` §F). The full
shapes and the ten rules that come with them are in §1.1 under **1.10.0**; six things are contract
rather than implementation and belong here:
- **An action is core CALLING THE MODULE**, like `registerTeamProvider` and `registerAnnounceLeg`'s
dispatch, and unlike everything above them — but from further away than either, because the thing
on the other end may be a shard. That is why `budgetMs` is declared per action and enforced by the
dispatcher: without it a `perform()` awaiting a socket that never answers holds a step's claim
until its lease expires, and the reclaim then re-dispatches it, which is how one wedged sidecar
becomes an infinite loop rather than a failed step.
- **`budgetMs` must EXCEED the timeout of whatever the action talks to** (Events Phase 9). The
dispatcher classifies a budget timeout as `retry` unconditionally and does not ask the action —
it cannot, the action is still awaiting a socket. So an action whose own client gives up *after*
core's deadline never gets to classify its own failure, and `retry: false` in its envelope is
unreachable code. The default `budgetMs` is 10s and `module-uo`'s sidecar client waits 12s, which
is the wrong way round: every slow shard produced a retry the module had explicitly refused. The
rule generalises past that one pairing — an action is the near end of a call with a far end, and
the near end has to outlive it. This is why `uo.broadcast`, whose whole safety property is that it
is attempted once, declares 15000.
- **A module's `detail` is carried and never read.** An optional object on either success shape,
bounded at the dispatcher and written to the run log verbatim beside the action id. Core reads no
key out of it — a switch on known keys anywhere in core would be core learning one module's
vocabulary, which is the thing this whole contract exists to prevent. It is the answer to *"what
actually happened"* for a verb whose answer is neither a resource nor a participant, and before
Phase 15 there was no such answer: `EVENTS.md` §H named the member, `classify()` had never read
one, and a module that used it wrote into nothing.
- **A module reports who took part on the envelope, and there is no other door.** `participants`
rides back from `perform()` exactly as `resources` does, on both success shapes — including
`await: 'human'`, because a cue's confirm finishes the step without a second dispatch and that is
therefore the only moment its participants can be recorded. There is deliberately no
`ctx.events.participants` and no route: a second write path into a run core is mid-tick on would be
a second thing that can race the step claim. One step may report at most 5000, the same bound the
engagement engine puts on a list of users a caller may assert, and a member reported twice in one
step is recorded once with the duplicate named.
- **A declaration's ceiling bounds the kind of event; a firing's `ceiling` bounds the occasion.** An
emitter that already knows this particular firing must not reach as far as the declaration allows
passes one, and the gate takes the meet. It only narrows — passing a wider value changes nothing —
and passing something incomparable with the declaration refuses every rule rather than resolving
to either. See §1.1 under 1.10.0.
- **`once`, on all four.** A batch is a module's complete statement about what it declares; a second
call is a module changing its mind halfway through `register()` rather than adding to it. And they
STAGE, like every registration above: a module that registers two budgets and then throws has left
nothing behind.
- **Everything is optional, and §F says so once because it governs every member.** A module may
register no actions, no budgets, no leases and no option sources. Each registration *adds* what an
author can reach for; a module that omits one costs its deployment a capability rather than a boot,
exactly as a module with no `onBoot` still reaches `started`.
- **An option source that refuses degrades its field to free text with a warning.** It never blocks
the authoring form and it never raises. The alternative is a screen a module's outage can take
away, for a field whose value the operator very often already knows — which is a worse failure than
the typo the dropdown exists to prevent.
- **Core records what an action made BEFORE the action is dispatched, not after** (`EVENTS.md` §D
rule 1). A module's `resources` are the refs core did not know until the answer arrived; what core
wrote beforehand is a placeholder keyed by the step's idempotency key, so a dispatch whose answer
never came back is still something cleanup can act on. The consequence for a module author is the
whole reason `revert` takes `idempotencyKey` as well as `resources`: it will sometimes be called
with the key and an EMPTY list, meaning *"a command went out under this key and core never learned
what it did"*. Answering that honestly is what makes an unattended world write recoverable; a
module that cannot answer it says so, and the row stays visible to an operator.
- **That key IDENTIFIES a dispatch; it is not a key to send on the undo.** It names the command core
lost the answer to, so the module can ask the game about it. Forwarding it as the outgoing key of
the reverting command is a different thing entirely, and on a game whose at-most-once store keys on
the key alone — as the uo-link shard's does — the undo is then recognised as a repeat of the DO and
answered with the original reply. `module-uo` made exactly this mistake: teardown of all five world
verbs was a no-op that reported success, because every despawn carried the key its spawn had gone
out under. Found by the Phase 16 acceptance walk, with the ledger reading `reverted` and the shard
still holding every object. A command that undoes needs a key of its own or none at all; a repeated
undo is usually harmless by construction ("already gone" is a success), which is what makes *none*
the right answer more often than not.
- **Core owns cleanup, and it is derived rather than authored.** There is no `on_teardown` on an
action and no cleanup phase in a spec: an operator cannot be relied on to write the undo, and an
aborted run never reaches the phase they wrote it in. Cleanup is one sweep over the ledger and it
runs on every terminal path — completion, cancellation and abort alike — so a module's only job is
to answer `revert` correctly however many times it is asked.
**An action whose module is uninstalled goes dormant, never an error.** A step already in a saved
spec keeps it and a new step may not add one — the shape `engagement_rules` established for a dormant
trigger — and a dormant step blocks the PUBLISH, because a version is what a run pins and a run
cannot dispatch a verb nobody registers. A step that reaches dispatch naming one fails `terminal`
with the module named and the run degrades: never a silent skip.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle
@@ -1457,17 +967,6 @@ Four fields, in the loader's scan order (§4.2). What is *not* there is the desi
string. A client must treat an unknown capability as absent and must not infer a route from one — the
mount prefixes are `module.json`'s business (§2.3), not the capability list's.
**Core publishes a capability list of its own, and it is deliberately not this one.** Since events
Phase 14a, `GET /public/version` — and `GET /public/status`, which embeds the same block — carries a
`capabilities` array naming what CORE serves beyond the baseline every backend has. It is the same
idea and the same word so that a client feature-detects one way rather than two, and a **separate
list** because core is not a module: publishing core here as a pseudo-module would leave a client
unable to tell *"this backend has events"* from *"a module called core happens to be installed"*,
which is exactly the distinction this endpoint exists to make. The value in core's list is in what is
absent — a backend released before a capability existed omits the key entirely, which is how a client
tells an older site from one that simply has nothing to show. The same rule applies to both: an
unknown string is absent, and no route may be inferred from one.
The endpoint owns the `/modules` prefix on the public tier, which is *why* it is a router of its own
rather than a fifth singleton beside `/settings` and `/version`. The loader's collision probe reads
the live tier stack and skips root-mounted layers (a `use('/', …)` matches every path), so a route

View File

@@ -137,7 +137,6 @@ website/
│ │ │ └── registry.js
│ │ ├── components/
│ │ │ ├── security/
│ │ │ │ ├── EmailAddressPanel.jsx
│ │ │ │ ├── RecoveryCodesDisplay.jsx
│ │ │ │ ├── RecoveryCodesPanel.jsx
│ │ │ │ ├── TrustedDevicesPanel.jsx
@@ -148,7 +147,6 @@ website/
│ │ │ ├── Modal.jsx
│ │ │ ├── MoonDot.jsx
│ │ │ ├── NavDropdown.jsx
│ │ │ ├── NotificationBell.jsx
│ │ │ ├── PageHeader.jsx
│ │ │ ├── PageState.jsx
│ │ │ ├── ProviderIcon.jsx
@@ -162,18 +160,12 @@ website/
│ │ ├── contexts/
│ │ │ ├── AuthContext.jsx
│ │ │ └── SiteContext.jsx
│ │ ├── emailBlocks/
│ │ │ ├── index.js
│ │ │ ├── registry.js
│ │ │ └── types.jsx
│ │ ├── lib/
│ │ │ ├── adminNav.js
│ │ │ ├── engagementRules.js
│ │ │ ├── format.js
│ │ │ ├── heroLayout.js
│ │ │ ├── moduleAdmin.js
│ │ │ ├── navOverrides.js
│ │ │ ├── notificationPaths.js
│ │ │ ├── pageShell.js
│ │ │ ├── settingsJson.js
│ │ │ ├── teamActivity.js
@@ -209,13 +201,6 @@ website/
│ │ │ │ │ ├── Dashboard.jsx
│ │ │ │ │ ├── DiscordBotAdmin.jsx
│ │ │ │ │ ├── EmailDelivery.jsx
│ │ │ │ │ ├── EngagementAudiences.jsx
│ │ │ │ │ ├── EngagementRetention.jsx
│ │ │ │ │ ├── EngagementRules.jsx
│ │ │ │ │ ├── EngagementSendLog.jsx
│ │ │ │ │ ├── EngagementSuppressions.jsx
│ │ │ │ │ ├── EngagementTemplates.jsx
│ │ │ │ │ ├── EngagementTriggers.jsx
│ │ │ │ │ ├── HeroEditor.jsx
│ │ │ │ │ ├── InvitesAdmin.jsx
│ │ │ │ │ ├── Moderation.jsx
@@ -246,15 +231,13 @@ website/
│ │ │ │ ├── ForgotPassword.jsx
│ │ │ │ ├── PlayerAccount.jsx
│ │ │ │ ├── PlayerAppeals.jsx
│ │ │ │ ├── PlayerInbox.jsx
│ │ │ │ ├── PlayerLogin.jsx
│ │ │ │ ├── PlayerNotifications.jsx
│ │ │ │ ├── PlayerPortalLayout.jsx
│ │ │ │ ├── PlayerRegister.jsx
│ │ │ │ ├── PlayerShell.jsx
│ │ │ │ ├── ResetPassword.jsx
│ │ │ │ ├── Unsubscribe.jsx
│ │ │ │ └── VerifyEmail.jsx
│ │ │ │ └── Unsubscribe.jsx
│ │ │ ├── public/
│ │ │ │ ├── About.jsx
│ │ │ │ ├── CmsPage.jsx
@@ -277,8 +260,6 @@ website/
│ ├── test/
│ │ ├── adminNav.test.js
│ │ ├── apiClient.test.js
│ │ ├── emailTemplates.test.js
│ │ ├── engagementRules.test.js
│ │ ├── featureGate.test.js
│ │ ├── format.test.js
│ │ ├── heroLayout.test.js
@@ -287,7 +268,6 @@ website/
│ │ ├── moduleRegistry.test.js
│ │ ├── moduleSlots.test.js
│ │ ├── navOverrides.test.js
│ │ ├── notificationPaths.test.js
│ │ ├── pageShell.test.js
│ │ ├── settingsJson.test.js
│ │ ├── teamActivity.test.js
@@ -312,14 +292,12 @@ website/
│ │ ├── sso-bridge-smoketest.js
│ │ └── stub-idp.js
│ ├── checkModuleIdentifiers.js
│ ├── checkNoExternalHosts.js
│ └── sonar-test-reporter.mjs
├── server/
│ ├── db/
│ │ ├── schema.sql
│ │ └── seed.js
│ ├── scripts/
│ │ ├── engagementManifest.js
│ │ └── routeManifest.js
│ ├── src/
│ │ ├── auth/
@@ -353,46 +331,9 @@ website/
│ │ ├── config/
│ │ │ ├── brand.js
│ │ │ ├── coreStreams.js
│ │ │ ├── coreTriggers.js
│ │ │ ├── csp.js
│ │ │ ├── themePresets.js
│ │ │ └── version.js
│ │ ├── emailBlocks/
│ │ │ ├── types/
│ │ │ │ ├── button.js
│ │ │ │ ├── divider.js
│ │ │ │ ├── heading.js
│ │ │ │ ├── image.js
│ │ │ │ ├── itemList.js
│ │ │ │ └── text.js
│ │ │ ├── index.js
│ │ │ ├── interpolate.js
│ │ │ ├── registry.js
│ │ │ ├── render.js
│ │ │ └── variables.js
│ │ ├── engagement/
│ │ │ ├── transports/
│ │ │ │ ├── index.js
│ │ │ │ └── smtp.js
│ │ │ ├── audiences.js
│ │ │ ├── bounceClassify.js
│ │ │ ├── channels.js
│ │ │ ├── conditions.js
│ │ │ ├── coreChannels.js
│ │ │ ├── coreRules.js
│ │ │ ├── coreScopePrefs.js
│ │ │ ├── emailChannel.js
│ │ │ ├── engine.js
│ │ │ ├── inappChannel.js
│ │ │ ├── index.js
│ │ │ ├── moduleSeeds.js
│ │ │ ├── projection.js
│ │ │ ├── pushChannel.js
│ │ │ ├── scopedPrefs.js
│ │ │ ├── segments.js
│ │ │ ├── suppressions.js
│ │ │ ├── templates.js
│ │ │ └── templateSeeds.js
│ │ ├── middleware/
│ │ │ ├── botScore.js
│ │ │ ├── loginProtection.js
@@ -422,26 +363,6 @@ website/
│ │ │ ├── emailConfig/
│ │ │ │ ├── emailConfig.db.js
│ │ │ │ └── emailConfig.model.js
│ │ │ ├── emailDedupe/
│ │ │ │ ├── emailDedupe.db.js
│ │ │ │ └── emailDedupe.model.js
│ │ │ ├── emailVerifications/
│ │ │ │ ├── emailVerifications.db.js
│ │ │ │ └── emailVerifications.model.js
│ │ │ ├── engagement/
│ │ │ │ ├── engagementCooldowns.db.js
│ │ │ │ ├── engagementDigest.db.js
│ │ │ │ ├── engagementOutbox.db.js
│ │ │ │ ├── engagementRecipients.db.js
│ │ │ │ ├── engagementRetention.model.js
│ │ │ │ ├── engagementRules.db.js
│ │ │ │ ├── engagementRules.model.js
│ │ │ │ ├── engagementSegments.db.js
│ │ │ │ ├── engagementSegments.model.js
│ │ │ │ ├── engagementSends.db.js
│ │ │ │ ├── engagementSuppressions.db.js
│ │ │ │ ├── engagementTemplates.db.js
│ │ │ │ └── engagementTemplates.model.js
│ │ │ ├── invites/
│ │ │ │ ├── invites.db.js
│ │ │ │ └── invites.model.js
@@ -461,9 +382,6 @@ website/
│ │ │ ├── modules/
│ │ │ │ ├── modules.db.js
│ │ │ │ └── modules.model.js
│ │ │ ├── notificationChannelPrefs/
│ │ │ │ ├── notificationChannelPrefs.db.js
│ │ │ │ └── notificationChannelPrefs.model.js
│ │ │ ├── notificationSubs/
│ │ │ │ ├── notificationSubs.db.js
│ │ │ │ └── notificationSubs.model.js
@@ -522,8 +440,6 @@ website/
│ │ │ ├── userIdentities/
│ │ │ │ ├── userIdentities.db.js
│ │ │ │ └── userIdentities.model.js
│ │ │ ├── userNotifications/
│ │ │ │ └── userNotifications.db.js
│ │ │ ├── users/
│ │ │ │ ├── users.db.js
│ │ │ │ └── users.model.js
@@ -534,7 +450,6 @@ website/
│ │ │ └── singletonConfigDb.js
│ │ ├── modules/
│ │ │ ├── archive.js
│ │ │ ├── ceilings.js
│ │ │ ├── declared.js
│ │ │ ├── install.js
│ │ │ ├── lifecycle.js
@@ -546,6 +461,8 @@ website/
│ │ ├── router/
│ │ │ ├── v1/
│ │ │ │ ├── admin/
│ │ │ │ │ ├── account.controller.js
│ │ │ │ │ ├── account.router.js
│ │ │ │ │ ├── activity.router.js
│ │ │ │ │ ├── admin.controller.js
│ │ │ │ │ ├── authProviders.controller.js
@@ -557,8 +474,6 @@ website/
│ │ │ │ │ ├── discordBot.router.js
│ │ │ │ │ ├── email.router.js
│ │ │ │ │ ├── emailConfig.controller.js
│ │ │ │ │ ├── engagement.controller.js
│ │ │ │ │ ├── engagement.router.js
│ │ │ │ │ ├── imageUpload.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── invites.controller.js
@@ -578,10 +493,7 @@ website/
│ │ │ │ │ ├── users.router.js
│ │ │ │ │ └── wiki.router.js
│ │ │ │ ├── auth/
│ │ │ │ │ ├── account.controller.js
│ │ │ │ │ ├── auth.controller.js
│ │ │ │ │ ├── emailVerify.controller.js
│ │ │ │ │ ├── emailVerify.router.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── invite.controller.js
│ │ │ │ │ ├── invite.router.js
@@ -605,6 +517,7 @@ website/
│ │ │ │ │ ├── internal.controller.js
│ │ │ │ │ └── internal.routes.js
│ │ │ │ ├── player/
│ │ │ │ │ ├── account.router.js
│ │ │ │ │ ├── appeals.controller.js
│ │ │ │ │ ├── appeals.router.js
│ │ │ │ │ ├── index.js
@@ -613,8 +526,6 @@ website/
│ │ │ │ │ ├── teams.controller.js
│ │ │ │ │ └── teams.router.js
│ │ │ │ ├── public/
│ │ │ │ │ ├── engagement.controller.js
│ │ │ │ │ ├── engagement.router.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── modules.controller.js
│ │ │ │ │ ├── modules.router.js
@@ -643,15 +554,11 @@ website/
│ │ │ ├── brandAssets.js
│ │ │ ├── db.js
│ │ │ ├── discordAnnounce.js
│ │ │ ├── engagementEmit.js
│ │ │ ├── engagementRetentionPrune.js
│ │ │ ├── engagementWorker.js
│ │ │ ├── forumHtml.js
│ │ │ ├── htmlShell.js
│ │ │ ├── logger.js
│ │ │ ├── mailer.js
│ │ │ ├── navOverrides.js
│ │ │ ├── newsNotify.js
│ │ │ ├── pushDispatch.js
│ │ │ ├── reservedNames.js
│ │ │ ├── sanitizeHtml.js
@@ -668,8 +575,7 @@ website/
│ │ │ ├── themeResolve.js
│ │ │ ├── totp.js
│ │ │ ├── trustProxy.js
│ │ │ ├── unsubscribeToken.js
│ │ │ └── userNotificationsPrune.js
│ │ │ └── unsubscribeToken.js
│ │ ├── app.js
│ │ ├── internalApp.js
│ │ └── server.js
@@ -698,33 +604,15 @@ website/
│ │ ├── botScore.test.js
│ │ ├── brandAssets.test.js
│ │ ├── checkModuleIdentifiers.test.js
│ │ ├── checkNoExternalHosts.test.js
│ │ ├── contentReports.test.js
│ │ ├── csp.test.js
│ │ ├── emailCollisionSurfaces.test.js
│ │ ├── emailConfig.model.test.js
│ │ ├── emailTemplates.test.js
│ │ ├── emailUniqueness.test.js
│ │ ├── emailVerification.test.js
│ │ ├── engagementAdmin.test.js
│ │ ├── engagementCeilings.test.js
│ │ ├── engagementDeliverability.test.js
│ │ ├── engagementEmail.test.js
│ │ ├── engagementEngine.test.js
│ │ ├── engagementEngineSql.test.js
│ │ ├── engagementInapp.test.js
│ │ ├── engagementManifest.test.js
│ │ ├── engagementRetention.test.js
│ │ ├── engagementRetentionSql.test.js
│ │ ├── engagementTemplatesAdmin.test.js
│ │ ├── engagementTriggers.test.js
│ │ ├── honeypot.test.js
│ │ ├── htmlShell.test.js
│ │ ├── inviteController.test.js
│ │ ├── invites.test.js
│ │ ├── loginProtection.test.js
│ │ ├── mailer.test.js
│ │ ├── mailTransports.test.js
│ │ ├── mobileAuthBridge.model.test.js
│ │ ├── mobileDeviceSessions.test.js
│ │ ├── mobileSession.test.js
@@ -735,7 +623,6 @@ website/
│ │ ├── moduleClientChunk.test.js
│ │ ├── moduleDeclared.test.js
│ │ ├── moduleDocsSpec.test.js
│ │ ├── moduleEngagementSeeds.test.js
│ │ ├── moduleInstall.test.js
│ │ ├── moduleLifecycle.test.js
│ │ ├── moduleLoader.test.js
@@ -743,8 +630,6 @@ website/
│ │ ├── modules.model.test.js
│ │ ├── moduleSchema.test.js
│ │ ├── navOverrides.test.js
│ │ ├── newsNotify.test.js
│ │ ├── notificationChannelPrefs.test.js
│ │ ├── notificationsRoutes.test.js
│ │ ├── pages.model.test.js
│ │ ├── passwordResetController.test.js
@@ -769,7 +654,6 @@ website/
│ │ ├── settingsTheming.test.js
│ │ ├── slashCommands.test.js
│ │ ├── ssoCallback.test.js
│ │ ├── ssoEmailVerified.test.js
│ │ ├── ssoState.test.js
│ │ ├── ssoTrustedDevice.test.js
│ │ ├── teamAccess.test.js
@@ -790,15 +674,13 @@ website/
│ │ ├── totp.test.js
│ │ ├── trustedDevices.test.js
│ │ ├── trustProxy.test.js
│ │ ├── usernamePolicy.test.js
│ │ └── userNotificationsSql.test.js
│ │ └── usernamePolicy.test.js
│ ├── tools/
│ │ └── cliloc-export/
│ │ ├── clilocexport.csproj
│ │ ├── Program.cs
│ │ └── README.md
│ ├── .env.example
│ ├── engagement-triggers.json
│ ├── package-lock.json
│ ├── package.json
│ ├── routes.guards.json

View File

@@ -13,111 +13,6 @@ 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.