From 8b4096dd18f399c55a5381b7d0645ba022e0709d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 14:57:42 -0500 Subject: [PATCH] =?UTF-8?q?docs(link):=20protocol=206=20as=20built=20?= =?UTF-8?q?=E2=80=94=20a=20guarantee,=20and=20the=20kind=20that=20pays=20f?= =?UTF-8?q?or=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- link/INTEGRATION.md | 110 +++++++++++++- link/PLAN.md | 24 ++- link/v6.md | 331 +++++++++++++++++++++++++++++++++++++++++ website/EVENTS.md | 11 +- website/EVENTS_PLAN.md | 118 +++++++++++++-- 5 files changed, 569 insertions(+), 25 deletions(-) create mode 100644 link/v6.md diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 9f4ab85..4e70515 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -55,14 +55,14 @@ That is the same set of values Admin → Shard asks for — base URL and WS URL The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. -The current version is **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. @@ -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 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 +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.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 +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. +##### `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 +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 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 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 | | 404 | Not found (unknown account / character / id, or a not-linked account) | | 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` | +| 425 | Too Early — a command with this `idempotencyKey` is still in flight on the shard (Protocol 6). Nothing ran; retry | | 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` | | 500 | Internal error (e.g. database) | | 503 | Shard not connected — the query needs the live game and it's down | @@ -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." +`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 +1187,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()); diff --git a/link/PLAN.md b/link/PLAN.md index b9ebd1b..7c06674 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -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,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. +### 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 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. diff --git a/link/v6.md b/link/v6.md new file mode 100644 index 0000000..3b94aaf --- /dev/null +++ b/link/v6.md @@ -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, ``, 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`. diff --git a/website/EVENTS.md b/website/EVENTS.md index cd2cf0b..4f8079e 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -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. | | 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. | -| 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. | | 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. | @@ -1094,7 +1094,7 @@ a capability exists. | 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`. | | 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. | | 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. | @@ -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. | | 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. | -| 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. | | 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`. | | **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.** | -| **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. | | **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. | @@ -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` | | **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` | -| **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` | | **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` | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 9f109e2..acc2cd8 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1025,7 +1025,7 @@ site, cued to a GM, completed, recorded. **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 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. > **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 - repeat with the original result; a lease deadline timer that restores baseline **without being - asked**; a run-scoped participation ledger; `champ.boss.killed` as a first-class kind. +**Split into 11a and 11b (org lead, 2026-09-04).** Three independent mechanisms across four repos, +each with a different thing to prove on a live ServUO, is one review and one walk too many. They land +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 and an overlay that disagree, so a bump in a later PR means the next bundle silently fails to compose. -- **Sidecar:** `PROTOCOL_VERSION` bumped, the new fields carried, the new kind stored and served. -- **`installer`:** joins the phase because of the pairing above. +- **Sidecar:** `PROTOCOL_VERSION` bumped; the key carried; the new kind stored and served. +- **`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`. -**Ships:** the game side can refuse a duplicate command and can put the world back on its own. Both -are safety properties, not features. +**Ships:** the game side can refuse a duplicate command. A safety property, not a feature — and the +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 -code is live; a deliberate duplicate command; a lease whose website is killed before it expires, -proving baseline returns anyway. +code is live; a deliberate duplicate command, checked against the shard's own audit trail rather than +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
no protocol change"] P5 --> P10["P10 · integrations"] P9 --> P10 - P0 -.->|"§N1 answered"| P11["P11 · protocol bump"] + P0 -.->|"§N1 answered"| P11["P11a+b · protocol 6"] P8 --> P11 P11 --> P12["P12 · UO world verbs"] P7 --> P13["P13 · authoring UI"] -- 2.49.1