Files
docs/link/v6.md
wtclaude 6647287037 docs(events): the acceptance walk, and the three contracts it moved (Phase 16a)
Phase 16 is split into 16a (the walk), 16b (the cutover) and 16c
(runicgateway.com + .profile), because the phase as written asked for a walk
"against released artefacts" BEFORE the cutover and all three component repos
release on push to `main`. The walk therefore runs against artefacts built from
`edge` the way a release builds them, and 16b re-verifies against the real bundle.

`EVENTS_PLAN.md` gains the 16a record: the rig, all three deliberate failures
passing, the six defects, the one finding withdrawn, and what each fix was
verified against.

Three contracts move, each because the walk proved the built thing did not match
the written one:

**`link/v6.md` — a refusal does not spend its key.** Rule 2 had two cases, throw
and return, and needed a third: a handler that ran to completion and deliberately
refused did nothing, so freezing that refusal as the key's answer made a refusal
that WAITING FIXES impossible to retry past. The section now carries the case
`uo.world.save` found it with, and the rule the release rests on — do not answer
`*.error` after changing the world. `[bridge status` gains `refused=`.

**`website/MODULE_API.md` — `revert`'s `idempotencyKey` identifies a dispatch; it
is not a key to send on the undo.** The paragraph explained what the key is FOR
and never said what it is not, and `module-uo` read it the other way: every
despawn went out under the key its spawn had used, so a store that keys on the key
alone answered the undo with the DO's reply and teardown became a no-op that
reported success.

**`website/EVENTS.md` §I — the public calendar matches a run that OVERLAPS the
window.** The row promised "upcoming, live and recent" and the built route served
only the first, because it read the start instant and a live run has already
started. The default window now reaches back so "recent" has somewhere to live,
and projections are forecast from now rather than into that tail.

Pairs with `website#`, `Module-uo#` and `servuo-plugins#`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 08:31:06 -05:00

650 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Protocol 6 — A guarantee, and the kind that pays for it
**Status:** In review on `edge`. **Both halves, 11a and 11b, as one protocol version** — see §7.
**Date:** 2026-09-04 (11a), amended 2026-09-05 (11b)
**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 — except on a refusal.** Not 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.
**A REFUSAL is the third case**, added by the Phase 16 acceptance walk and amending protocol 7 in
place. A handler that ran to completion and answered `*.error` did not do anything — every refusal
on this plane is a guard: a missing `runId`, an unknown item, a cap, a rate limit, a write that
failed and left the value alone. Remembering it froze the answer for ever, so a refusal that
*waiting fixes* could never be retried past. `uo.world.save` is the case that found it: the shard
saves at most every 300 seconds, the module documents that as "the one refusal on this plane that
waiting fixes", and six attempts over four minutes all replayed one frozen sentence — "the last save
was 227 seconds ago" — because the number was the first reply's, not the clock's. A step's key is
one value for the life of the step, so the operator's retry control could not escape it either.
So a refusal releases the key: nothing happened, and the caller may ask again. The refusal is still
**emitted** to the caller, which is what ends that attempt; it is simply not remembered as the key's
answer. A refusal is recognised by its `kind` ending in `.error`, matched on the suffix so a handler
family added later is covered without extending a list. `bridge.error` is excluded deliberately —
that is the reply the shard writes when a handler THREW, which is the case whose key must be kept.
**This puts a rule on handlers, and it is the rule the release rests on: do not answer `*.error`
after changing the world.** Report a partial change in an `ok` reply, as `item.grant` does with
`granted`/`missed` and `world.despawn` with `removed`/`gone`/`refused`. The shard cannot verify
"nothing happened"; it takes the `.error` kind as the claim.
**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= refused=)`.
#### 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.
11b adds six routes and no new machinery. `event_call` is `admin_call` without the required
`actor`: an event verb's author is a **run**, which the body carries as `runId`, and demanding a
human name for something no human is doing would have the runner inventing one.
| Route | Command | Note |
|---|---|---|
| `GET /lease` | `lease.list` | The whole catalog with current values. One read serves both `read()` and `inForce()` on the website's side |
| `POST /lease` | `lease.apply` | |
| `POST /lease/release` | `lease.release` | A drifted answer is a **200** — see §8.3 |
| `POST /participation` | `participation.open` | |
| `POST /participation/:runId/snapshot` | `participation.snapshot` | **A POST for a read**, because it carries the caller's `idempotencyKey` and can be refused as a repeat in flight. A read that can legitimately answer 425 is not a GET |
| `POST /participation/:runId/close` | `participation.close` | |
`respond_event` is the fourth responder, and it exists for two mappings the generic one gets wrong:
a drifted lease is a 200 rather than a reason-sniffed 400, and the event plane being **switched off**
is a 403 rather than a 400 — `Bridge.EventsEnabled` is an operator's deliberate refusal, and telling
the website it sent a bad request would send an administrator hunting a bug in a step that is
written correctly.
### 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.
**11b's two frames are deliberately unmapped.** `lease.applied` and `lease.expired` are operational
records of the WEBSITE changing this shard's configuration — which key, from what to what, on whose
run, and whether the shard's own deadline had to put it back because nobody asked. Rule 2 fails an
unmapped kind closed to admin-only, which is where an audit trail of the site's own writes belongs;
mapping them would mean choosing a feature an operator could then widen, and there is no rung below
admin these belong on. Same reasoning as `account.login.result` in v5.
The participation ledger emits no stream frame at all. A tally is read on request, not broadcast:
a live feed of who is standing where would be a location tracker, and the ledger's whole justification
is that it answers one bounded question about one run.
**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` |
| `website` | **11b only.** `core.lease` gains a `reconcile()` and `registerEventLeases` gains an optional `inForce()` — see §8.4 |
| `docs` | this file · `INTEGRATION.md` · `PLAN.md` §5/§7 · `EVENTS.md` §A/§G · `EVENTS_PLAN.md` · `MODULE_API.md` (11b) |
| `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.
**`EVENTS_PLAN.md` was wrong about the repo count in the other direction too.** Phase 11 is four
repos for 11a and **five** for 11b: a lease's ledger row had no reconcile path anywhere, because the
step that made it names `core.lease`, and that is core's own action rather than a module's. There is
nowhere on that path a module could hang an answer, so core had to grow one. §8.4.
**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. One version, two halves
Phase 11 is split. 11a bumped `PROTOCOL_VERSION` to 6; **11b amends 6 in place** rather than bumping
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.
Sections 8 and 9 are 11b. Everything above is 11a except where a section says otherwise.
---
## 8. The lease plane (11b)
> An event does not edit the world. It holds a lease, and baseline is what is true when no lease is
> held.
>
> — `EVENTS.md`, *Leases: the primitive underneath everything*
Three commands, one new configuration gate, and two properties that are the whole reason the
framing is worth having.
### 8.1 `Bridge.EventsEnabled`, and why it is not `AdminWriteEnabled`
**Its own switch, default off** (org lead, 2026-09-04). Enabling the admin plane is an operator
consenting to staff moderation driven from the website — a human pressing kick or ban on a screen
they are looking at. A lease and a participation ledger are the website changing and watching the
world on a **schedule**, unattended, at four in the morning. Those are different consents and one
switch cannot honestly express both; an operator who wanted the first and got the second would be
right to be angry.
### 8.2 The commands
```json
{"kind":"lease.apply","reqId":"r-7","key":"PlayerCaps.SkillCap","value":"1200",
"holdMs":600000,"untilMs":1788567000000,"runId":"77","idempotencyKey":"…"}
```
| Command | Answers | |
|---|---|---|
| `lease.list` | `lease.list.ok` | Every allowlisted key: `current`, `default`, `min`/`max`, and where held, `baseline` / `applied` / `untilMs` / `runId` / `expired` |
| `lease.apply` | `lease.ok` | `baseline`, `applied`, `untilMs` |
| `lease.release` | `lease.ok` or `lease.drifted` | Compare-and-set |
Three shapes are worth stating because the obvious alternative is subtly wrong in each.
**`holdMs` is authoritative and `untilMs` is for display.** An absolute deadline computed on the
website and honoured on the shard is a deadline measured against **two clocks**, and a shard running
ten minutes fast would restore a ten-minute lease the instant it took it. A duration is immune. The
absolute time still crosses, because a console that can say when the hold ends in terms the
operator's own clock agrees with is worth one field.
**Values cross as TEXT, whatever the lease's declared type**, and comparison is done on the parsed
values. JSON would otherwise decide for us: `1200` and `1200.0` are one number to a parser and two
different strings to a compare-and-set, and a drift check that compared formatted numbers would
report drift on a value nobody had touched — refusing to restore, leaving the world changed, and
blaming an innocent operator.
**A lease held longer than the shard's ceiling is REFUSED, never clamped.** A clamp would quietly
give the website a shorter lease than it believes it has, and the website is the half that schedules
the restore; the two would then disagree about when the world comes back. `Bridge.LeaseMaxDurationSec`
is the shard's independent bound rather than a mirror of core's — it exists for the case where the
website is wrong, and being loud about it is the entire value.
### 8.3 The two mechanisms, and one thing a stock shard cannot do
**The deadline lives on the shard.** A lease arms a timer, and when it passes the shard restores
baseline **whether or not the website is ever heard from again**. Core drives the normal restore;
this is the backstop. It inverts the naive design, where restoration depends on core dispatching a
cleanup step and therefore fails *open* if core dies mid-event. A lease fails *safe*, and the worst
case is a world back at baseline early rather than one stuck changed indefinitely. The shard emits
`lease.expired` so the website learns what happened without being asked.
**Restore is compare-and-set, never a blind write.** If the current value is not what the event
applied, somebody moved it deliberately: answer `lease.drifted` with the current value, leave the
world alone, and let an operator decide. A **200**, not a 409 — the shard did exactly what it was
asked, and 409 is the version gate's with the opposite disposition. Blindly restoring would silently
revert a staff member's change, which is the one failure that would make operators distrust the
whole feature.
> **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4** — `Server/ScriptCompiler.cs`,
> for `Compiler.Dynamic`. There is no in-game command, no gump and no console verb that writes a
> config key.
So on a stock shard a GM **cannot drift a configuration lease even deliberately**. The mechanism is
still correct and still required — Phase 12's object-property leases are trivially driftable, and a
shard with custom scripts may well write config at runtime — but proving it needs the `configset`
verb in `tools/scaffolding/BridgeRigDriver.cs`, which exists for exactly that reason.
**A lease is memory-only, and that is a decision.** `Config.Set` mutates the in-memory entry table;
`Config.Load()` is guarded by `_Initialized` and so runs once at boot, which is what makes a Set
survive every later Get. **Nothing ever calls `Config.Save()`**, so a shard restart is a *free*
restore — the strongest fail-safe available, at no cost. It is also why `lease.list` reports an empty
hand after a restart, which is precisely what lets the website's reconcile notice the lease is gone.
A pleasant consequence of `Config.Entry.Set`: restoring the baseline restores the entry's original
default marker too, because the entry compares against the value it was loaded with. Restoring a key
that was `@`-defaulted in a cfg file leaves it `@`-defaulted.
### 8.4 The reconcile hole, and `inForce()`
**A lease's ledger row had no reconcile path at all, and nothing failed to say so.** `cleanup.js`
resolves a resource to the action of the step that made it, and for a lease that action is
`core.lease` — a **core** action, on a path a module cannot register anything on. So every `override`
row came back `unanswered` for the life of the run, and a lease the shard had quietly dropped stayed
in the ledger as live until teardown went looking for a baseline nobody was holding.
11b closes it in two pieces, both in `website`:
- `core.lease` gains a `reconcile()`.
- `registerEventLeases` gains an **optional `inForce()`** — *"does the game side still have any
record of this hold?"*
It is deliberately not `read()` plus a comparison. A value that differs from what the run applied is
**drift**, which teardown must deliver through `restore()` so the row lands `drifted` with the
current value beside it; a reconcile that inferred absence from a changed value would orphan the row
first and destroy that signal — telling the operator the lease vanished rather than that somebody
moved it. Only an explicit `{ ok: true, held: false }` takes a row out; a throw, a timeout, an
unrecognised shape and a lease with no `inForce()` all leave it alone.
`MODULE_API_VERSION` stays **1.10.0**, amended in place, by the same rule §7 states for the protocol.
### 8.5 The catalog is short, and shorter than `EVENTS.md` expected
§D describes the 258 `Config.Get` call sites as splitting into two patterns — cached at type
initialisation, where a lease applies cleanly and does **nothing**, and read live, where it takes
effect at once. Measured on 57.4 the split is not near even: of the **158** non-Bridge call sites in
`Scripts/`, roughly **eight** are live reads.
11b ships **one** key: `PlayerCaps.SkillCap`, read live inside `CharacterCreation.cs`'s per-character
path and divided by ten to give the per-skill cap. It is both live and observable, which is what
"proven" has to mean here — the failure the allowlist exists to prevent is a key that applies
cleanly and changes nothing at all. Phase 12 adds the rest, with the boot-time self-check that drops
a key from the advertised catalog if it does not take.
---
## 9. The participation ledger (11b)
§G rates participation attribution as the largest remaining piece of new UO work, and says why
nothing composed out of the existing streams stands in for it: `region.enter` plus `mob.killed` is
loosely composable 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, and nothing survives a relog.
| Command | Answers | |
|---|---|---|
| `participation.open` | `participation.ok` | `{ runId, map, x, y, radius, holdMs }` |
| `participation.snapshot` | `participation.snapshot.ok` | The tally, resolved to names and accounts |
| `participation.close` | `participation.ok` | Stops counting; the tally stays readable through the grace window |
**The area is a map, a point and a radius** (org lead, 2026-09-04). Not a region name: §6.1 above
established that the most specific region containing an event is routinely **anonymous**, so a
region-named area would be undeclarable for exactly the venues events use. Not a rectangle either —
an author picks the spot the event happens at, not two opposite corners of it.
**Members are keyed by character serial**, matching `module-uo`'s existing Teams `memberKey`, so one
module speaks one member vocabulary and a participant joins to a roster without a translation table.
A player who attends on two characters is two members, which is the answer Teams already gives.
**The shard computes the score and core stores an opaque number it never interprets.** *"A minute
present plus five a kill"* is a sentence about Ultima Online, and the sentence has to live on the
Ultima Online side of the seam. The components ride along in the frame anyway, because a results
table that can say "forty minutes and three kills" beside a number is one an operator can defend
when a player argues with it.
Two shapes that look like details and are not:
- **Presence accrues in SECONDS, not sample counts.** A count would have to be multiplied by the
sweep interval to mean anything, and the interval is a config key an operator may change halfway
through a five-hour run — silently rewriting the first half of the tally. The kill weight is frozen
per run at open for the same reason.
- **Kill credit goes to every damager standing in the area, not to the killer.** A last hit is a poor
description of who fought something: the player who held it for four minutes and died to it took
part more than the one who landed the blow that finished it. The area test is applied to the
**damager**, so someone shooting in from outside is not attending and someone who has since walked
away accrues nothing more. Entries are summed per damager for the reason `champ.boss.killed`'s
table is (§2.2): an expired-and-recreated entry leaves two.
### 9.1 The Bridge's first persisted state
Nothing in this bridge has ever persisted anything. A ledger has to: a run spans hours and a restart
mid-event is an ordinary Tuesday, and an in-memory tally would silently regress every attendee's
score to whatever they earned after the restart. The only ways to paper over that from the other side
are a high-water rule in core — which must stay game-agnostic and cannot have one — or a per-run
offset in the module, which is the same bug with more moving parts.
`Server.Persistence` plus `EventSink.WorldSave` writes `Saves/Bridge/Participation.bin` beside the
world save, rather than a persistence **item**: no world object, no serial, nothing for a GM to find
and delete by accident, and a wipe of custom items leaves the ledger intact. The save and load hooks
are attached **unconditionally**, before the enabled gate is consulted — an operator who switches the
plane off for an afternoon must not come back to a truncated file where a run's tally used to be.
Bounds, all `Bridge.*` keys: eight runs counted at once, 2000 members per run, a 300-tile ceiling on
the area, and a 24-hour grace window after a run closes. A member the cap turns away is **counted**
and the count rides on every snapshot: a truncated tally that says it is truncated is usable, and one
that does not is a leaderboard with people missing from it for no stated reason.
### 9.2 The first handler that defers
`participation.snapshot` resolves every member serial to a mobile and an account, so a well-attended
run is hundreds of world lookups in one inbound call — exactly the work the Core thread must not be
handed in one piece. Above `Bridge.ParticipationSnapshotChunk` members it walks in chunks across
ticks, using `BridgeIdempotency.Hold` / `Complete`.
That makes it the first handler in the bridge to complete **after** its inbound call returns, and
therefore the first that can genuinely answer `bridge.busy`. 11a built that door and had nothing to
walk through it.
---
## 10. Verification (11b)
Unit tests: **588** in `module-uo/server` (17 new), **42** in `module-uo/client`, **2003** in
`website/server` (5 new), **47** in the sidecar (4 new). `cargo fmt`, `cargo clippy -D warnings` and
`check:imports` clean; the C# compiles against the real ServUO 57.4 reference assemblies with the
shard stopped.
The rig: a real ServUO with a seeded world, the **release** Rust sidecar, and
`tools/scaffolding/BridgeRigDriver.cs` (`configset` / `configread` / `partprobe`) plus
`BridgeParticipationProbe.cs`.
| Claim | Evidence |
|---|---|
| a lease applies, and the change is visible through the shard's own reader | `lease.apply``{"baseline":"1000","applied":"1200"}`; an independent `Config.Get` from a different class, long after every type initialiser, read `1200` |
| **the shard restores baseline with nobody asking** | A 20-second lease, and then silence. `[Bridge] lease PlayerCaps.SkillCap: deadline passed, restored to 1000 without being asked`, and `lease.expired` on the feed with `restored: true` |
| an expired lease still yields a verdict to teardown | `lease.list` reported `held: true, expired: true, restored: true`; the later `lease.release` answered `alreadyRestored: true` rather than an error |
| **a mid-lease GM edit produces `drifted`, and the world is LEFT ALONE** | `configset PlayerCaps.SkillCap 1350` under a live lease → `lease.release` answered `{"kind":"lease.drifted","current":"1350"}` at **200**, and a read afterwards still showed `1350` |
| a restart reverts a config lease, and the catalog says so | After a restart: `held: false`, `current: "1000"` — which is what makes `inForce()` correct |
| kill credit is per damager, inside the area | Two seeded players damaging one creature at the venue: both credited `kills: 1`, `score: 5.0` |
| **the tally survives a shard restart mid-run** | `save`, `shutdown`, boot: `[Bridge] participation: 1 run(s) restored from the world save`, and the snapshot returned both members with identical `firstMs` |
| **the first live `bridge.busy`** | Two concurrent `POST /participation/:runId/snapshot` under one key, chunk size 1: the first answered 200 with the tally, the second **425** `{"kind":"bridge.busy","busyKind":"participation.snapshot"}` |
| a deferred key replays like any other | A third request under the same key answered `replayed: true` under its own `reqId` and the **first attempt's** `t` |
### 10.1 The defect the deferral found, in 11a's own code
`bridge.busy` came back **200**, not 425, the first time anything produced it.
`BridgeIdempotency.Busy` built its frame with `BridgeJson.Begin("bridge.busy")` — which writes
`"kind":"bridge.busy"` — and then appended a diagnostic `.Str("kind", prior.Kind)` naming the command
that was in flight. **The object carried two `kind` fields, and every JSON parser worth the name
takes the last.** The sidecar matched on `bridge.busy` to decide the 425, read
`participation.snapshot` instead, and answered an ordinary 200 with a body saying nothing had
happened — the single worst of the three possible answers, since a retry loop would treat it as
success.
It shipped in 11a and **could not be seen there**: with only synchronous handlers a repeat can never
arrive mid-flight, so the arm was unreachable on a live shard, and the sidecar test that covers the
mapping was — correctly — feeding it a frame built by hand. The first deferring handler produced it
on its first collision. Renamed to `busyKind`.
This is the argument for the phase's ordering, stated as a fact rather than a hope: 11a said
*"11b's leases are the first thing that can actually produce it, and proving it belongs in that
walk"*, and the walk found a real bug in shipped code.
### 10.2 What the rig could not drive: presence
The participation ledger counts two things and only one of them is reachable headlessly.
**Presence needs a connected client.** The sweep credits online players — `NetState != null` — which
is the correct test and not one a probe should loosen: a character parked in Britain and logged out
for eight hours did not attend anything, and a ledger that said otherwise would put people at the top
of a leaderboard for being AFK. There is no way to produce a NetState short of writing a client, and
ClassicUO cannot be driven from this machine.
**Kill credit needs none**, so the whole of the credit path — the damager filter, the per-damager
fold, the area test on the damager, the member cap — ran exactly as it would in a fight, and the
accrual, persistence, snapshot, chunking and replay paths were driven by it. The one line the walk
did not exercise is `member.Seconds += seconds`, and it is named here rather than left to be assumed.
### 10.3 Two rig traps
- **`Core.Kill` does not save the world.** The rig driver's `shutdown` verb is a clean shutdown, which
is the only kind that *emits* — and it emits `server.shutdown` without writing a save. The first
restart test therefore reloaded an **empty** `Participation.bin` and looked exactly like a
persistence bug. `save` then `shutdown` is the sequence; the file's length is the check.
- **A probe that means to produce two damagers must not kill with the first blow.** An opening 40
damage on a Mongbat (around thirty hit points) killed it where it stood, so the second damager never
landed a hit, `DamageEntries` held one name, and the ledger correctly credited one player. It read
as a plugin crediting only the killer. Scaled to the creature's `HitsMax`.