docs(link): protocol 6 as built — a guarantee, and the kind that pays for it

`link/v6.md` is the spec of record. It is the first bump that adds a promise
rather than data: a command carrying an `idempotencyKey` is executed at most once,
and a repeat is answered with the original reply.

Also in this PR:

- `INTEGRATION.md` — §2 gains v6, the write plane gains a "retrying a command
  safely" section with the four rules a caller needs, §7 gains 425 and the
  paragraph on why it is not 409, and the event catalog gains `champ.boss.killed`.
- `PLAN.md` — §5.5 records that a per-KILL damage table needs no override even
  though a per-HIT event does, which is the observation the new kind rests on;
  §7 documents the key on the inbound frames.
- `EVENTS.md` — §A row 7 and two §G capability rows close, and the failure table's
  "an action succeeds but the ack is lost" loses its "does not exist today".
- `EVENTS_PLAN.md` — Phase 11 splits into 11a and 11b, and `installer` leaves the
  phase: PLAN.md §7.4 made sure no protocol version is hardcoded there, so the
  bundle gate is version-agnostic and needed no change for 5 either.

The live walk's defect is written up in v6.md §6.1 because the naive check
confirms it: an active ChampionSpawn registers an unnamed region over its own
spawn area, so the innermost region containing a champion boss is guaranteed to
have no name — and region registration is deferred, so a lookup at spawn time
answers "Britain" while one at the kill does not.

CRLF preserved throughout; links and anchors checked by hand, this repo has no CI.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:57:42 -05:00
parent 6cb54ecdb7
commit 8b4096dd18
5 changed files with 569 additions and 25 deletions

View File

@@ -55,14 +55,14 @@ That is the same set of values Admin → Shard asks for — base URL and WS URL
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. The 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. - Every response carries an **`X-UOLink-Version: 6`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 5`. - `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 6`.
- **Optionally**, send `X-UOLink-Version: 5` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: - **Optionally**, send `X-UOLink-Version: 6` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
```json ```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. Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
@@ -97,6 +97,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 - `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". 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 **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 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 closed connection on the `ws.hello` mismatch. So update the pinned version at the same time you
@@ -295,6 +310,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.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.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: `status` is one of:
- **`active`** — running (or, for `sea`, the boss is alive). - **`active`** — running (or, for `sea`, the boss is alive).
@@ -322,6 +338,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. 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 (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. 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 +931,45 @@ 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 `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"`. 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.
### Help-page (support) queue ### Help-page (support) queue
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
@@ -1075,6 +1167,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
| 401 | Missing or invalid auth token | | 401 | Missing or invalid auth token |
| 404 | Not found (unknown account / character / id, or a not-linked account) | | 404 | Not found (unknown account / character / id, or a not-linked account) |
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` | | 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` | | 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
| 500 | Internal error (e.g. database) | | 500 | Internal error (e.g. database) |
| 503 | Shard not connected — the query needs the live game and it's down | | 503 | Shard not connected — the query needs the live game and it's down |
@@ -1082,6 +1175,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." `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 ## 8. Putting it together
@@ -1089,7 +1187,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
A typical character page: A typical character page:
```js ```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "5" }; const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "6" };
// 1. render the roster // 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); 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 | | | Player death | `PlayerDeath` | low | |
| Murder | `PlayerMurdered` | low | High-signal for the website. | | Murder | `PlayerMurdered` | low | High-signal for the website. |
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. | | 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. | | 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. > ⚑ **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 ### 5.6 Progression & activity
@@ -295,6 +301,22 @@ Newline-delimited JSON, one object per line, `serial` as the primary key.
Every inbound handler marshals to the Core thread before touching world state. 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.
### `server.hello` is per-connection, not per-boot ### `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. 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.

331
link/v6.md Normal file
View File

@@ -0,0 +1,331 @@
# Protocol 6 — A guarantee, and the kind that pays for it
**Status:** In review on `edge`. Part **a** of two: see §7 for what protocol 6 gains in 11b before it cuts over.
**Date:** 2026-09-04
**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.** Not even 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.
**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=)`.
#### 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.
### 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.
**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` |
| `docs` | this file · `INTEGRATION.md` · `PLAN.md` §5/§7 · `EVENTS.md` §A/§G · `EVENTS_PLAN.md` |
| `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.
**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. What 11b adds to protocol 6
Phase 11 is split. **This document covers 11a**; 11b adds lease deadlines and the run-scoped
participation ledger to the *same* protocol version, amended in place rather than bumped 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.
What 11b will add here:
- `lease.apply` / `lease.release` / `lease.list`, with a deadline the shard honours **without being
asked again** and a compare-and-set restore that reports `drifted` rather than overwriting a GM's
deliberate change.
- `participation.open` / `participation.snapshot` / `participation.close`, keyed by character serial
to match `module-uo`'s existing `memberKey`, and **persisted in the world save** — the Bridge's
first persisted state, so a tally survives a restart mid-event.
- The first handlers that **defer**, and therefore the first that can actually answer `bridge.busy`.

View File

@@ -272,7 +272,7 @@ What is genuinely not there, stated as what the codebase says rather than as ass
| 4 | **No core live channel to the browser.** | `grep -rn "text/event-stream" website/server/src` returns nothing; both SSE endpoints are module-uo's. *Discrepancy:* `ARCHITECTURE.md`'s diagram still shows `sse` inside core. | | 4 | **No core live channel to the browser.** | `grep -rn "text/event-stream" website/server/src` returns nothing; both SSE endpoints are module-uo's. *Discrepancy:* `ARCHITECTURE.md`'s diagram still shows `sse` inside core. |
| 5 | **No transaction helper.** | `utils/db.js` exports `{ pool, query, ensureSchema, close }`; `getConnection()` appears twice in core, both inside `db.js`. Atomicity is compare-and-set plus `INSERT IGNORE` against unique indexes — never a transaction. | | 5 | **No transaction helper.** | `utils/db.js` exports `{ pool, query, ensureSchema, close }`; `getConnection()` appears twice in core, both inside `db.js`. Atomicity is compare-and-set plus `INSERT IGNORE` against unique indexes — never a transaction. |
| 6 | **The sidecar has no generic command envelope.** | `link/sidecar/src/web.rs` declares ~30 explicit typed routes and no passthrough. Every action verb costs a route, a plugin handler, a protocol bump, an `overlay.toml` bump and a docs edit — the strongest argument for a *small set of parameterised* actions over many narrow ones. | | 6 | **The sidecar has no generic command envelope.** | `link/sidecar/src/web.rs` declares ~30 explicit typed routes and no passthrough. Every action verb costs a route, a plugin handler, a protocol bump, an `overlay.toml` bump and a docs edit — the strongest argument for a *small set of parameterised* actions over many narrow ones. |
| 7 | **The command path is not durable and has no idempotency.** | `rpc.rs`: an in-memory pending map, a flat 10-second timeout, three correlation fields, no replay. A command whose ack is lost is indistinguishable from one that never applied. | | 7 | ~~**The command path is not durable and has no idempotency.**~~ **Closed by protocol 6** (Phase 11a). `rpc.rs` is unchanged and still an in-memory pending map with a flat timeout the replay was never the sidecar's to do. A command may now carry an `idempotencyKey`, and the SHARD executes a key at most once, answering a repeat with the original reply restamped for the retry's correlation id. Which is the only place the promise can live: the shard is where the world write happens. See [`../link/v6.md`](../link/v6.md) §2.1. |
| 8 | **No module→core mechanism for "do a thing".** | A module registers *data* or *answers questions*. The only two members that cause an effect are `registerAnnounceLeg.dispatch` and `registerSlashCommands.handler`, both single-purpose. There is no action registry. | | 8 | **No module→core mechanism for "do a thing".** | A module registers *data* or *answers questions*. The only two members that cause an effect are `registerAnnounceLeg.dispatch` and `registerSlashCommands.handler`, both single-purpose. There is no action registry. |
| 9 | **No quota or budget concept anywhere.** | Rate limits exist (`ctx.middleware.rateLimit`) and bound requests per window. Nothing bounds *cumulative effect* — which is what every PEC constraint is. | | 9 | **No quota or budget concept anywhere.** | Rate limits exist (`ctx.middleware.rateLimit`) and bound requests per window. Nothing bounds *cumulative effect* — which is what every PEC constraint is. |
| 10 | **No versioning pattern for operator-authored config.** | `engagement_templates` carries `seed_version` / `customized`; `engagement_rules` carries nothing. Nothing snapshots an authored object so a running process can pin what it started against. | | 10 | **No versioning pattern for operator-authored config.** | `engagement_templates` carries `seed_version` / `customized`; `engagement_rules` carries nothing. Nothing snapshots an authored object so a running process can pin what it started against. |
@@ -1094,7 +1094,7 @@ a capability exists.
| Detect a boss appearing | ✅ | `champ.update` carries `bossUp`; `uo.champ.boss_up` is already a declared trigger. | | Detect a boss appearing | ✅ | `champ.update` carries `bossUp`; `uo.champ.boss_up` is already a declared trigger. |
| Online population, per region | ✅ | `presence.online` with `byFacet` and `byRegion`. | | Online population, per region | ✅ | `presence.online` with `byFacet` and `byRegion`. |
| Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. | | Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. |
| Detect a boss defeated | 🔗 | Inferable from `champ.update` going `bossUp` true → false plus `mob.killed`. Workable and slightly fragile; a dedicated `champ.boss.killed` kind is a small honest addition worth doing alongside anything else on the wire. | | Detect a boss defeated | **built (protocol 6)** | `champ.boss.killed`, fired from `EventSink.CreatureDeath` and detected by type, with the altar attributed from the sweep. The inference this replaces was more fragile than "slightly": `bossUp` also drops when a GM resets a spawn, when a boss despawns, and after a sidecar reconnect clears the diff cache. And it was silent about who fought — the new kind carries the damage table, which exists at the death and nowhere else. |
| **Participation attribution** | 🔗 weak + 🔧 | Composable only loosely from `region.enter` + `mob.killed`, 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, nothing survives a relog. A run-scoped participation ledger on the plugin side is the honest answer — and with points cut, this is now the *largest* remaining piece of new UO work. | | **Participation attribution** | 🔗 weak + 🔧 | Composable only loosely from `region.enter` + `mob.killed`, 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, nothing survives a relog. A run-scoped participation ledger on the plugin side is the honest answer — and with points cut, this is now the *largest* remaining piece of new UO work. |
| Oracle NPC with scripted dialogue | 🔧 📡 | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. | | Oracle NPC with scripted dialogue | 🔧 📡 | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. |
| Temporary gate to a venue | 🔧 📡 | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. | | Temporary gate to a venue | 🔧 📡 | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. |
@@ -1106,7 +1106,7 @@ a capability exists.
| Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. | | Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. |
| Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. | | Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. |
| Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. | | Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. |
| Idempotent command application | 🔧 📡 | Nothing on the wire carries an idempotency key and `rpc.rs` has no replay. **Required before any world write ships.** | | Idempotent command application | **built (protocol 6)** | Phase 11a, and deliberately ahead of every verb below it rather than alongside one. A key is executed at most once; a repeat gets the original reply. Its immediate dividend is that `uo.broadcast` stopped being un-retryable — Phase 9 had to answer `retry: false` even to a 503 from a restarting shard, because a lost ack and a command that never applied were the same event. |
| Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. | | Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. |
| Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. | | Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. |
@@ -1434,7 +1434,7 @@ controller stamps it from the session.
| **Game server restarts mid-run** | `server.hello` arrives with a changed `bootId`, which module-uo already uses to tell a shard restart from a sidecar reconnect. The run goes `degraded`, world-write steps park, announce steps continue. On reconnect the runner asks each ledgered resource's module to **reconcile**; a resource the game no longer has becomes `orphaned`, never silently `reverted`. | | **Game server restarts mid-run** | `server.hello` arrives with a changed `bootId`, which module-uo already uses to tell a shard restart from a sidecar reconnect. The run goes `degraded`, world-write steps park, announce steps continue. On reconnect the runner asks each ledgered resource's module to **reconcile**; a resource the game no longer has becomes `orphaned`, never silently `reverted`. |
| **The sidecar is unreachable** | Every client call returns `{ ok: false }` rather than throwing — `uoLinkClient` never throws, by contract. World-write steps retry on a flat backoff; the run degrades; the public page renders last-known state. | | **The sidecar is unreachable** | Every client call returns `{ ok: false }` rather than throwing — `uoLinkClient` never throws, by contract. World-write steps retry on a flat backoff; the run degrades; the public page renders last-known state. |
| **The module is disabled or fails to boot** | `installed_modules` records it and the dispatch guard 503s its routes. Its actions go dormant: a step naming one fails `terminal` with the module named, and the run degrades rather than claiming success. **Never a silent skip.** | | **The module is disabled or fails to boot** | `installed_modules` records it and the dispatch guard 503s its routes. Its actions go dormant: a step naming one fails `terminal` with the module named, and the run degrades rather than claiming success. **Never a silent skip.** |
| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe, and it does not exist today.* | | **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe*, and as of protocol 6 (Phase 11a) it exists: proved on the rig with three broadcasts under two keys producing **two** rows in the shard's own audit trail. It has one bound worth knowing — the plugin remembers a key for an hour, capped at 4096, and says so loudly if the cap ever evicts a live one. |
| **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. | | **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. |
| **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. | | **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. |
| **A phase's advance condition never fires** | The phase is **held**, indefinitely, and nothing advances it but a human (org lead, 2026-09-02). There is no authored timeout, because "what should happen when the world did not cooperate" is a decision an operator makes live rather than one an author guesses at months earlier. What the engine owes is visibility: after `EVENT_PHASE_STALL_MS` the run's `health` becomes `stalled` and it says so on the run list, logged once. This matters more than it looks — a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. | | **A phase's advance condition never fires** | The phase is **held**, indefinitely, and nothing advances it but a human (org lead, 2026-09-02). There is no authored timeout, because "what should happen when the world did not cooperate" is a decision an operator makes live rather than one an author guesses at months earlier. What the engine owes is visibility: after `EVENT_PHASE_STALL_MS` the run's `health` becomes `stalled` and it says so on the run list, logged once. This matters more than it looks — a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. |
@@ -1768,7 +1768,8 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **
| **P8** ✓ | The resource ledger, leases and generated cleanup | `website` `docs` | | **P8** ✓ | The resource ledger, leases and generated cleanup | `website` `docs` |
| **P9** | UO wave 1 — the actions that need no protocol change | `module-uo` `docs` | | **P9** | UO wave 1 — the actions that need no protocol change | `module-uo` `docs` |
| **P10** | Integrations — the `event.` triggers, participants, results, announce legs | `website` `docs` | | **P10** | Integrations — the `event.` triggers, participants, results, announce legs | `website` `docs` |
| **P11** | *N1 answered.* Protocol: idempotency key, lease deadline, participation ledger | `servuo-plugins` `link` `module-uo` `installer` `docs` | | **P11a** | *N1 answered.* Protocol 6: the idempotency key, and `champ.boss.killed` | `servuo-plugins` `link` `module-uo` `docs` |
| **P11b** | Protocol 6 amended in place: lease deadlines, the participation ledger | `servuo-plugins` `link` `module-uo` `docs` |
| **P12** | *N1 answered.* UO wave 2 — the world verbs, owned or borrowed | `servuo-plugins` `link` `module-uo` `docs` | | **P12** | *N1 answered.* UO wave 2 — the world verbs, owned or borrowed | `servuo-plugins` `link` `module-uo` `docs` |
| **P13** | The authoring UI proper — timeline, schema-driven steps, cap meter | `website` | | **P13** | The authoring UI proper — timeline, schema-driven steps, cap meter | `website` |
| **P14** | Public calendar, player history, mobile — and the Android module-path fix | `website` `android-app` `docs` | | **P14** | Public calendar, player history, mobile — and the Android module-path fix | `website` `android-app` `docs` |

View File

@@ -1025,7 +1025,7 @@ site, cued to a GM, completed, recorded.
**Trap worth writing into the action declarations.** These three verbs have *different* idempotency. **Trap worth writing into the action declarations.** These three verbs have *different* idempotency.
`towncrier` and `news` are keyed by id and re-posting replaces, so a retry is safe. **`broadcast` is `towncrier` and `news` are keyed by id and re-posting replaces, so a retry is safe. **`broadcast` is
not** — a retry is a second announcement to everyone online — and there is no idempotency key on the not** — a retry is a second announcement to everyone online — and there is no idempotency key on the
wire until P11. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the wire until P11a. So `uo.broadcast` ships with `on_failure: 'skip'` rather than a retry, and the
declaration says why. declaration says why.
> **Built 2026-09-04** (`Module-uo#28` + `docs#217`). Three actions, one budget dimension, three option > **Built 2026-09-04** (`Module-uo#28` + `docs#217`). Three actions, one budget dimension, three option
@@ -1246,25 +1246,117 @@ push; the ceiling on `run.failed` proved to exclude a moderator.
--- ---
### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `installer` + `docs`) ### Phase 11 — Protocol: idempotency, lease deadlines, participation (`servuo-plugins` + `link` + `module-uo` + `docs`)
**§N1 answered 2026-09-01 — no longer gated.** A five-repo protocol bump, and the shape TEAMS Phase 1 already walked. **§N1 answered 2026-09-01 — no longer gated.** A protocol bump, and the shape TEAMS Phase 1 already walked.
- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers a **Split into 11a and 11b (org lead, 2026-09-04).** Three independent mechanisms across four repos,
repeat with the original result; a lease deadline timer that restores baseline **without being each with a different thing to prove on a live ServUO, is one review and one walk too many. They land
asked**; a run-scoped participation ledger; `champ.boss.killed` as a first-class kind. as **one protocol version**: 11a bumps to 6, and 11b amends 6 **in place** on `edge` — the same rule
the org lead set for `MODULE_API_VERSION` on 2026-09-03, and it applies because 6 will not reach
`main` until the events cutover. The bundle CI therefore never sees two numbers.
**`installer` is NOT in this phase, and the line above that said so was wrong.** `PLAN.md` §7.4 made
sure of it deliberately: 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 — version-agnostic, so it needed no change for 5 either.
And `link`/`servuo-plugins` are on `edge`, so nothing is released or bundled until the cutover.
#### Phase 11a — the idempotency key, and `champ.boss.killed`
- **Plugin:** an idempotency key on every inbound command with a bounded recent-key set that answers
a repeat with the original result; `champ.boss.killed` as a first-class kind.
- **`overlay.toml`** protocol version bumped **in this PR** — the installer refuses to pair a sidecar - **`overlay.toml`** protocol version bumped **in this PR** — the installer refuses to pair a sidecar
and an overlay that disagree, so a bump in a later PR means the next bundle silently fails to and an overlay that disagree, so a bump in a later PR means the next bundle silently fails to
compose. compose.
- **Sidecar:** `PROTOCOL_VERSION` bumped, the new fields carried, the new kind stored and served. - **Sidecar:** `PROTOCOL_VERSION` bumped; the key carried; the new kind stored and served.
- **`installer`:** joins the phase because of the pairing above. - **`module-uo`:** the key sent on every event-driven write, `uo.broadcast` made retryable, and the
new kind mapped for visibility and declared as a trigger.
- **`docs`:** a new `docs/link/v6.md` as the spec of record, plus `INTEGRATION.md`. - **`docs`:** a new `docs/link/v6.md` as the spec of record, plus `INTEGRATION.md`.
**Ships:** the game side can refuse a duplicate command and can put the world back on its own. Both **Ships:** the game side can refuse a duplicate command. A safety property, not a feature — and the
are safety properties, not features. precondition every world verb in Phase 12 is waiting on.
**Verify:** against the local ServUO tree with a **build-offline-first** step and confirmation the new **Verify:** against the local ServUO tree with a **build-offline-first** step and confirmation the new
code is live; a deliberate duplicate command; a lease whose website is killed before it expires, code is live; a deliberate duplicate command, checked against the shard's own audit trail rather than
proving baseline returns anyway. against the reply.
> **Built.** `PROTOCOL_VERSION` and `overlay.toml` both **6**, in the same pair of PRs. The whole
> sidecar change is one constant and one status mapping; the whole guarantee lives on the shard,
> which is the only place it can, because the shard is where the world write happens.
>
> **Seven decisions (org lead, 2026-09-04), all as recommended.** The 11a/11b split; participation
> keyed by character serial; the ledger persisted in the world save; leases as machinery plus one
> proven key; reserve-on-receipt with `bridge.busy` for an in-flight repeat; a TTL-plus-cap with a
> loud eviction; and top damagers at a `staff` ceiling. The last three are 11a's.
>
> **What the phase bought immediately:** `uo.broadcast` stopped being un-retryable. Phase 9 shipped it
> answering `retry: false` to *everything* including a 503 from a restarting shard, with a comment
> naming the line that would change. This is that line. It now defers to `sidecarFailure` — the same
> helper its two siblings already used — so the hand-rolled variant that forced every outcome
> terminal is gone rather than merely re-tuned.
>
> **One verb was less idempotent than its own `id` made it look.** Both keyed verbs post under a
> run-scoped id and a repeat REPLACES, which is why Phase 9 called them safe to retry. But
> `news.add` with `announce: true` makes the criers proclaim the article's title on every post, so a
> retry replaced the article silently and proclaimed it **again**. The key stops the second
> proclamation. Read in the plugin, not inferred: `BridgeNews` removes the old entry, inserts the
> new one, and then announces.
>
> **The live walk found one defect, and the naive check confirms it.** A champion killed in the
> middle of Britain emitted a frame with **no region**. An active `ChampionSpawn` registers a
> `ChampionSpawnRegion` over its spawn area with a **null name** and the town region as its parent,
> so the most specific region containing a champion boss is the one region on the map guaranteed to
> be nameless — and `Mobile.Region` hides that by falling back to the map's unnamed default rather
> than to null. Region registration is deferred, too, so a lookup at spawn time answers `"Britain"`
> and one at the kill does not: the probe printed `"Britain"` on every run, including the ones whose
> frame carried nothing. Fixed by walking outward to the nearest NAMED ancestor, which is the general
> answer — house regions, dungeon sub-regions and guarded-zone overlays are all anonymous children of
> somewhere a player would name.
>
> **Two findings that are rules rather than facts about events:**
>
> - **A trigger is not a stream, and in this module they are disjoint sets.** Push delivery is keyed
> on the subscription id, which core's catalog grants only to registered STREAMS. `module-uo`'s
> stream ids (`champ.start`, `idoc.warning`, …) and its trigger ids (`uo.champ.started`, …) share
> no member, so **no** engagement rule in this module can push — the tickle resolves to zero
> endpoints while the send log records success. That is the Phase 10 defect, pre-existing here in
> twenty rules. This phase does not fix them; it declines to add a twenty-first, so its rule ships
> `['email','inapp']` with a comment saying why. **Flagged for the org lead as its own change.**
> - **A new rule needs a new seed GROUP, never an appended one.** `triggers-v1` is stamped once under
> a settings guard, so a twenty-seventh entry would reach fresh installs and nothing else.
> `champ-boss-killed-v1` is its own group — the same remedy core applied in Phase 10, and the third
> application of Engagement Phase 11's seed-key finding.
>
> **What the rig could not prove: `bridge.busy`.** With today's synchronous handlers a repeat cannot
> arrive while the original runs — the Core thread takes one inbound line at a time — so the state is
> unreachable on a live shard until a handler defers. Implemented, with `Hold`/`Complete` as the door,
> and unit-tested at the sidecar's mapping on all three responders. **11b's leases are the first
> thing that can produce it**, and proving it belongs in that walk.
>
> See [`../link/v6.md`](../link/v6.md) for the spec of record.
#### Phase 11b — lease deadlines and the participation ledger
- **Plugin:** a lease deadline timer that restores baseline **without being asked**, with
compare-and-set restore reporting `drifted`; a run-scoped participation ledger.
- **Leases land as machinery plus ONE proven key** (org lead): the generic registry, the deadline
timer, CAS restore and `lease.list` reconcile, proved end to end against one verified live-read
`Config.Get` key. The curated allowlist and its boot-time self-check stay in Phase 12, where the
rest of the lease surface is.
- **The participation ledger counts presence in a declared area plus kill credit inside it, keyed by
character serial** — matching `module-uo`'s existing Teams `memberKey`, so one module speaks one
member vocabulary. The plugin computes the score; core stores an opaque number it never interprets.
- **The ledger is persisted in the world save**, which makes it the Bridge's first persisted state
ever. A run spans hours and a restart mid-event is realistic; an in-memory tally would regress the
score after one, and the only ways to paper over that are a high-water rule in core (which must
stay game-agnostic) or a per-run offset in the module.
- **Protocol 6 is amended in place**, not bumped to 7. See the split note above.
**Ships:** the game side can put the world back on its own, and can say who took part.
**Verify:** a lease whose website is killed before it expires, proving baseline returns anyway; a
deliberate mid-lease GM edit producing `drifted` rather than a silent overwrite; a tally that survives
a shard restart mid-run; and the first live `bridge.busy`, which a deferring handler finally makes
reachable.
--- ---
@@ -1396,7 +1488,7 @@ flowchart TD
P8 --> P9["P9 · UO wave 1<br/>no protocol change"] P8 --> P9["P9 · UO wave 1<br/>no protocol change"]
P5 --> P10["P10 · integrations"] P5 --> P10["P10 · integrations"]
P9 --> P10 P9 --> P10
P0 -.->|"§N1 answered"| P11["P11 · protocol bump"] P0 -.->|"§N1 answered"| P11["P11a+b · protocol 6"]
P8 --> P11 P8 --> P11
P11 --> P12["P12 · UO world verbs"] P11 --> P12["P12 · UO world verbs"]
P7 --> P13["P13 · authoring UI"] P7 --> P13["P13 · authoring UI"]