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 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());