`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>
332 lines
22 KiB
Markdown
332 lines
22 KiB
Markdown
# 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`.
|