docs(events): the Event System record — the cutover step 16b missed (edge → main) #232

Merged
whitlocktech merged 46 commits from edge into main 2026-09-09 21:55:11 -05:00
12 changed files with 5205 additions and 197 deletions

View File

@@ -66,6 +66,8 @@ particular game; a module is what makes it a site *for* one.
| [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` |
| [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,6 +661,8 @@ 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:
@@ -672,6 +674,15 @@ 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
@@ -692,6 +703,11 @@ 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
@@ -1128,6 +1144,169 @@ 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

View File

@@ -21,6 +21,12 @@ 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
@@ -284,6 +290,11 @@ 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.
@@ -292,6 +303,70 @@ 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,20 +55,26 @@ 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 **5**, shipped on 2026-09-01 in sidecar **v2.1.0** and overlay **v1.1.0** — resolve them as bundle **2026.09.01**, never as "latest of each". The pairing before it was protocol 4, sidecar **v2.0.0** + overlay **v1.0.0**.
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".
- Every response carries an **`X-UOLink-Version: 5`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`.
- **Optionally**, send `X-UOLink-Version: 5` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
- 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**:
```json
{ "error": "protocol version mismatch", "sidecar_protocol": 5, "client_protocol": "4" }
{ "error": "protocol version mismatch", "sidecar_protocol": 6, "client_protocol": "5" }
```
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
@@ -97,6 +103,21 @@ both about ABSENCE rather than presence, because both are easy to read as an err
- `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
@@ -295,6 +316,7 @@ 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).
@@ -322,6 +344,43 @@ 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.
@@ -878,6 +937,128 @@ 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
@@ -1073,8 +1254,10 @@ 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 |
| 404 | Not found (unknown account / character / id, or a not-linked account) |
| 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) |
| 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 |
@@ -1082,6 +1265,11 @@ 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
@@ -1089,7 +1277,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
A typical character page:
```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "5" };
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "6" };
// 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());

View File

@@ -178,10 +178,16 @@ 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. |
| 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. |
| 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
@@ -295,6 +301,57 @@ 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.

649
link/v6.md Normal file
View File

@@ -0,0 +1,649 @@
# 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`.

571
link/v7.md Normal file
View File

@@ -0,0 +1,571 @@
# 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

@@ -1117,6 +1117,190 @@ Design of record: [`TEAMS.md`](TEAMS.md) Parts 2 and 5. The contract surface a m
---
### 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
Base path `/api/v1`. JSON in/out. Auth via httpOnly cookie (`isLoggedIn` reads it; also
@@ -1512,8 +1696,34 @@ file a route sits in — that is the property the route manifest freezes.
| 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 | `/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 |
| 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 |
| — | `/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`.
### The SPA HTML shell (`app.js` → `utils/htmlShell.js`)

View File

@@ -1676,7 +1676,7 @@ deferred to this phase on purpose:
| | Question | Decision |
|---|---|---|
| §7.2 | one namespace, or two? | **ONE.** A trigger is a payload contract attached to an id that may also carry a subscription toggle |
| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9** — `kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today; no evaluator yet |
| §7.1 Q6 | time-based triggers | **declare now, build after Phase 9** — `kind: 'scheduled'` is in the contract, the manifest and every stored declaration from today. **The evaluator was assigned on 2026-09-01** ([EVENTS.md §N3](EVENTS.md#n--decisions)): the Event System runner becomes it, built in that workstream P2 |
| — | what "narrower" means for a ceiling | an explicit **subset lattice**; two incomparable ceilings have no bound and the save is REFUSED, never guessed |
**One namespace was the more invasive of the two options and it is worth stating what it bought and
@@ -4078,6 +4078,14 @@ day it ships.
caller's — so `kind` means something from the moment it is declarable rather than from the moment
it is honoured. The lifecycle uses in §8.5 are the highest-value non-game triggers on the list and
the first thing anyone will ask for after the IDOC mail works.
> **The evaluator now has an owner, decided 2026-09-01** ([`EVENTS.md`](EVENTS.md#n--decisions)
> §N3). The **Event System runner** becomes the `kind: 'scheduled'` evaluator rather than a ninth
> poller being written for it — the runner already materialises due occurrences on a tick, and a
> second timer doing the same shape of work is how a codebase acquires two schedulers that
> disagree. It is built in that workstream's P2. Nothing in this phase's contract changes: a
> `scheduled` declaration means today exactly what it meant on 2026-08-29, and `ctx.events.emit`
> still refuses to fire one.
7. ✅ **ANSWERED — manual/operator-authored sends.** *"There is no campaign in the normal sense of email
marketing. But admins can create all sorts of trigger conditions"*, and separately: *"lists can be
built if they are powered by game data — say team X members or governors or whatever — thru the uo
@@ -4325,7 +4333,7 @@ worth confirming in Phase 4 rather than discovering in Phase 11.
| **Welcome / first steps** after registration | `users.created_at` | ⚠ needs a time-based evaluator | email | delayed (e.g. +1 h) |
| **Finish setting up** — no linked game account after N days | `shard_links` absence | ⚠ module-supplied predicate | email, in-app | delayed |
| **We miss you** — no login in N days | `users.last_login_at` | ⚠ needs a scheduled sweep | email | monthly, hard cap |
| **Come back for X** — a scheduled event is starting | operator-authored | ❌ needs a manual/scheduled trigger type | email, push, in-app | scheduled |
| **Come back for X** — a scheduled event is starting | `event.run.started`, emitted by the event runner | ✅ **built — `EVENTS_PLAN.md` Phase 10**. It is a trigger AND a stream, which is what gives it push: the push channel is stream-keyed, so the six other `event.` triggers are email and in-app only | email, push, in-app | on the run |
| **Your invite is about to expire** | `user_invites.expires_at` | ⚠ | email | delayed |
| **Account dormant, scheduled for cleanup** | policy | ❌ no dormancy policy exists | email | scheduled, staged |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -26,13 +26,223 @@ 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.9.0'
const MODULE_API_VERSION = '1.10.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.
@@ -142,6 +352,64 @@ branches lacks — so it is the only place `permits` is true between two values
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
@@ -489,6 +757,10 @@ api.registerSlashCommands([{ name, description, options, access, handler }]) //
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 () => {})
```
@@ -861,6 +1133,88 @@ are in §1.1 under **1.9.0**; four things are contract rather than implementatio
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
@@ -1103,6 +1457,17 @@ 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