Files
docs/link/v6.md
wtclaude 8b4096dd18 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>
2026-09-04 14:57:42 -05:00

22 KiB
Raw Blame History

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 (1.0 read/event plane), PROTOCOL_2.md (2.0 provisioning + world-state streams), v3.md (3.0 shard content + the visibility framework), v4.md (4.0 guild membership), v5.md (5.0 decay schedule, vendor fees, login result), INTEGRATION.md (website API). Driven by ../website/EVENTS.md and ../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 §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:

{"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.

{"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 = 56, 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.killeddamagers staff, configurable Nested, so one rule covers the whole table
champ.boss.killedkiller 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 sitesoverlay.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.