feat(bridge): protocol 7 — the Event System's shard half (Phase 16b cutover, 1 of 6) #26

Merged
whitlocktech merged 10 commits from edge into main 2026-09-09 19:53:49 +00:00
Member

The Event System cutover, step 1 of 6 (EVENTS_PLAN.md Phase 16b). edgemain, a clean fast-forwardmain is 0 ahead.

Merge this and link#40 together

installer's bundle.yml Gate 1 reads the protocol number out of both released artefacts and refuses a pair that disagrees. Whichever of these two merges first releases a component speaking 7 against a sibling still on 5, so the bundle run behind it composes nothing. That is transient and self-healing — the second merge dispatches the job again, and the nightly would catch it regardless — but it means these two are one step, not two.

Nothing has been released or bundled from this work at all: everything below has been on edge since Phase 11a.

What lands (5 commits, protocol 5 → 7)

Phase
11a An idempotency key on every inbound command, executed at most once, a repeat answered by the original reply restamped for the retry's correlation id. Plus champ.boss.killed with its damage table.
11b Lease deadlines with CAS restore and lease.list; the run-scoped participation ledger — the Bridge's first persisted state; the first handler that defers, which produced the first live bridge.busy.
12a The world verbs an event owns — one command family (world.spawn/despawn/owned) behind five authorable verbs — and the Bridge's second persisted file, an ownership registry.
12b What an event borrows: allowlisted object-property leases on Spawner/XmlSpawner, the seasonal toggle, the boot self-check, the item grant and the world save. The third persisted file.
16a A refusal does not spend its key — the at-most-once store had cases for a handler that threw and one that returned, and none for one that ran and deliberately refused, so a refusal that waiting fixes was frozen for ever.

overlay.toml declares protocol = 7. It has been bumped in the same PR as its emitters throughout, which is the rule that keeps a bundle composable.

The contract this release rests on

Written into docs/link/v6.md in Phase 16a, and the reason the refusal fix is safe:

Do not answer *.error after changing the world. The shard cannot verify "nothing happened"; it takes the .error kind as the claim. Report a partial change in an ok reply, as the item grant does with granted/missed.

Every Err(reqId, …) call site across BridgeWorld, BridgeOneShots, BridgeLeases and BridgeParticipation was audited against that rule before the change — each is a pre-mutation guard or follows a failed Write that left the value alone.

Verification

Every phase in here was walked on a real ServUO 57.4 (208k items, 42k mobiles) against a cargo --release sidecar, and Phase 16a walked the whole of it once more end to end from artefacts built exactly the way CI builds them: the overlay installed from a tarball into a tree with Scripts/Custom/Bridge, Config/Bridge.cfg and Saves/Bridge/ deleted first, so it was a first install rather than an upgrade, then Scripts.csproj built offline with the server stopped.

All three of Phase 16's deliberate failures pass. A mid-teardown process kill resumed with all 15 steps still at attempts = 1; a sidecar killed mid-phase left the run degraded with its world writes parked at attempts = 0 and the shard reconnected on its own; a cap breach answered refused in the author's own words.

16b re-verifies this from the released overlay tarball — installer → composed bundle → fresh ServUO copy → boot → one event with a real teardown — which is the leg 16a could not do, since a locally built bundle cannot go through core's host-allowlisted installer.

  • AI-assisted: written with Claude Code (Claude Opus 5).

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

The Event System cutover, step 1 of 6 (`EVENTS_PLAN.md` Phase 16b). **`edge` → `main`, a clean fast-forward** — `main` is 0 ahead. ## Merge this and `link#40` together `installer`'s `bundle.yml` **Gate 1 reads the protocol number out of both released artefacts and refuses a pair that disagrees.** Whichever of these two merges first releases a component speaking 7 against a sibling still on 5, so the bundle run behind it composes nothing. That is transient and self-healing — the second merge dispatches the job again, and the nightly would catch it regardless — but it means these two are one step, not two. Nothing has been released or bundled from this work at all: everything below has been on `edge` since Phase 11a. ## What lands (5 commits, protocol 5 → 7) | Phase | | |---|---| | **11a** | An **idempotency key on every inbound command**, executed at most once, a repeat answered by the original reply restamped for the retry's correlation id. Plus `champ.boss.killed` with its damage table. | | **11b** | **Lease deadlines** with CAS restore and `lease.list`; the run-scoped **participation ledger** — the Bridge's first persisted state; the first handler that defers, which produced the first live `bridge.busy`. | | **12a** | The world verbs an event **owns** — one command family (`world.spawn`/`despawn`/`owned`) behind five authorable verbs — and the Bridge's **second** persisted file, an ownership registry. | | **12b** | What an event **borrows**: allowlisted object-property leases on `Spawner`/`XmlSpawner`, the seasonal toggle, the boot self-check, the item grant and the world save. The **third** persisted file. | | **16a** | A **refusal does not spend its key** — the at-most-once store had cases for a handler that threw and one that returned, and none for one that ran and deliberately refused, so a refusal that *waiting fixes* was frozen for ever. | `overlay.toml` declares `protocol = 7`. It has been bumped in the same PR as its emitters throughout, which is the rule that keeps a bundle composable. ## The contract this release rests on Written into `docs/link/v6.md` in Phase 16a, and the reason the refusal fix is safe: > **Do not answer `*.error` after changing the world.** The shard cannot verify "nothing happened"; it takes the `.error` kind as the claim. Report a partial change in an `ok` reply, as the item grant does with `granted`/`missed`. Every `Err(reqId, …)` call site across `BridgeWorld`, `BridgeOneShots`, `BridgeLeases` and `BridgeParticipation` was audited against that rule before the change — each is a pre-mutation guard or follows a failed `Write` that left the value alone. ## Verification Every phase in here was walked on a **real ServUO 57.4** (208k items, 42k mobiles) against a `cargo --release` sidecar, and Phase 16a walked the whole of it once more end to end from artefacts built exactly the way CI builds them: the overlay installed from a tarball into a tree with `Scripts/Custom/Bridge`, `Config/Bridge.cfg` and `Saves/Bridge/` deleted first, so it was a **first install** rather than an upgrade, then `Scripts.csproj` built offline with the server stopped. All three of Phase 16's deliberate failures pass. A mid-teardown process kill resumed with all 15 steps still at `attempts = 1`; a sidecar killed mid-phase left the run `degraded` with its world writes parked at `attempts = 0` and the shard reconnected on its own; a cap breach answered `refused` in the author's own words. **16b re-verifies this from the released overlay tarball** — installer → composed bundle → fresh ServUO copy → boot → one event with a real teardown — which is the leg 16a could not do, since a locally built bundle cannot go through core's host-allowlisted installer. - [x] AI-assisted: written with Claude Code (Claude Opus 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 10 commits 2026-09-09 15:34:11 +00:00
A command carrying an `idempotencyKey` is now executed at most once: a repeat is
answered with the original reply rather than re-run. That is the precondition
every world verb in Phase 12 is waiting on, and it is what let `uo.broadcast`
stop being un-retryable.

The gate sits in BridgeBoot's inbound dispatch, not in each handler, so it covers
every kind including ones a later protocol adds. A command with no key behaves
exactly as it did before, which leaves the admin screens unchanged.

Four rules, each a decision rather than an implementation detail: reserve on
receipt (so a handler that defers is covered, answering `bridge.busy` to a repeat
in flight); a key that has begun is never released, not even when the handler
throws; a replay is stamped with the REPEAT's correlation id, because the
sidecar's reqId is fresh per call and replaying the original would hang the retry;
and the bound is loud, because an evicted key is the guarantee's one hole.

`champ.boss.killed` rides along because a bump costs a release, a bundle and an
operator update on every shard. It fires from EventSink.CreatureDeath, detected
by type so a boss that popped and died inside one sweep is still reported, and it
carries the damage table that exists at the death and nowhere else.

overlay.toml protocol = 6, in this commit rather than a later one.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #21
Protocol 6 amended in place. Two mechanisms behind one new default-off gate,
`Bridge.EventsEnabled` -- deliberately not `AdminWriteEnabled`, because enabling
the admin plane is consenting to staff moderation from a screen a human is
looking at, and this is consenting to the world being changed and watched on a
schedule, unattended.

BridgeLeases: a live config value held for a bounded time, with the deadline
honoured on the shard whether or not the website is heard from again, and a
compare-and-set restore that reports `drifted` rather than overwriting a GM's
deliberate change. Memory-only -- nothing calls Config.Save() -- so a restart is
a free restore.

BridgeParticipation: presence in a declared area plus kill credit inside it,
keyed by character serial, persisted in the world save. The Bridge's first
persisted state, because a run spans hours and an in-memory tally would regress
every attendee's score after one restart. Its snapshot is also the first handler
that DEFERS, which makes `bridge.busy` reachable for the first time.

And it immediately found a defect in 11a: BridgeIdempotency.Busy built its frame
with Begin("bridge.busy") and then appended a diagnostic `.Str("kind", ...)`, so
the object carried two `kind` fields and every JSON parser takes the last. The
sidecar answered 200 instead of 425. Renamed `busyKind`.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #22
Five verbs an author sees -- creatures, an enhanced "boss", an oracle NPC, a
temporary gate, decoration -- and ONE command family underneath them, because
every one of them ends in the same sentence: an object exists, and this run
owns it. `world.spawn` / `world.despawn` / `world.owned` carry a `what`
discriminator, and the per-verb differences are fields rather than kinds.

The ownership registry is PERSISTED, and that is forced rather than chosen. A
spawned creature is in the world save, so it survives the restart that proves a
town-crier line gone -- which already rules out reconcile-by-boot-stamp. But the
record of which run owns which serial has nowhere else to live: in memory it is
lost in the restart the creatures survive, and only in the website's ledger it
is not held here at all, so `world.despawn` would delete whatever serial it was
handed and "never touches a creature it did not create" would have no mechanism
behind it. So the Bridge gains its second persisted file beside
`Participation.bin` -- written by the same world save as the objects it
describes, so the two cannot get out of step.

The oracle is ours rather than `XmlSpawner2.XmlDialog`'s, and that engine is the
reason for both halves of the decision. Its `SpeechEntry` is the evidence the
shape is right -- `Text` plus comma-separated `Keywords`, a keyword-less entry
as the greeting, a proximity range, a conversation lock. It is also why not to
build on it: `SpeechEntry` carries an `Action` string, XmlSpawner's
command-scripting language, which would leave an arbitrary-command field one
step from an event author. `Mobile.OnMovement` (delivered to every mobile in
range -- the `HandlesOnMovement` filter applies only to Items) and
`Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals and are all it needs.

Every `Bridge.EventsMax*` REFUSES rather than clamps, on `LeaseMaxDurationSec`'s
argument from 11b: the shard's bound exists for the case where the website is
wrong. `Bridge.EventsEnabled` gates all of it -- spawning is the same consent
11b introduced that switch for, not a third one.

Decoration carries an `itemId`, because `Static` accounts for 5031 of the tree's
decoration placements under 1992 different graphics: for that class the graphic
IS the identity. Never applied to a `BaseAddon`, whose own ItemID is not what a
player sees. Containers are refused outright -- teardown would delete whatever a
player had left inside.

`tools/scaffolding` gains `worldgone <serial>`, which deletes an object behind
the registry's back. It is the one outcome the rig cannot reach by asking the
bridge -- every bridge verb that removes an object also drops its row -- and it
is what a player's sword does every time they kill an event creature.

Verified on a real ServUO 57.4 world (206k items, 42k mobiles) against the
release sidecar: all five verbs place; every ceiling refuses; a container and an
unknown type refuse; one run cannot despawn another's object; the registry and
its objects both survive a save and a clean restart (`pruned: 0`); a creature
deleted behind the registry's back comes back `gone` rather than `removed`; and
a five-second gate is collected by the shard's own deadline with `world.expired`
on the wire.

Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #23
The shard half of protocol 7 part b. Two lease planes whose value lives on
something already in the world, and two verbs that cannot be taken back.

A LEASE HERE MUST BE PERSISTED, AND THE CONFIG PLANE'S MUST NOT

11b's fail-safe is stated plainly in its own header: a lease that never reaches
disk means a shard restart is a FREE restore. That argument depends entirely on
the leased value being memory-only too, and here it is not. A spawner is an Item
and is in the world save; a seasonal entry is written to
`Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. So a
restart does not put either back -- it puts the CHANGE back and throws away the
deadline timer that was going to undo it, leaving the world at the leased value
with nothing here remembering it is borrowed.

So the Bridge gains its THIRD save file, `Saves/Bridge/Leases.bin`, written by
the same `EventSink.WorldSave` that writes what it describes, with deadlines
re-armed at load. A deadline that passed while the shard was down fires AT ONCE:
the promise was "back at baseline by then", and extending it would silently turn
a two-hour lease into however long the outage was. Config holds are still not
written down -- the same argument, applied to planes where its premise is false.

A TARGET IS A SERIAL OR A UniqueId, AND BOTH ARE NEEDED

A serial is what `[props` shows a GM. An `XmlSpawner.UniqueId` is what the
shard's own `Spawns/*.xml` carry -- and it is not a convenience: a dropdown built
from serials is IMPOSSIBLE, because serials are assigned when the world is built
and nothing off-shard knows them. A lease addressable only by serial could have
no authoring list at all.

`Spawner` and `XmlSpawner` share all four property names, which is a fact about
this tree rather than a convenience: the spawn files load as XmlSpawners while
`[add spawner` makes the native one. And it is `MaxCount`, not the `Amount`
EVENTS_PLAN.md named -- there is no such property. `MinDelay`/`MaxDelay` are
TimeSpans, so the wire carries seconds.

The allowlist is checked against the object's OWN type, which is the sentence the
whole plane rests on: a serial is a number a caller chooses, so that check is all
that stands between `Spawner.MaxCount` and any item on the shard. Reflection is
bounded three ways -- the pair must be in the catalog, the property must carry
`CommandProperty` (so this can never reach further than `[set` could), and its
CLR type must be one this file renders.

THE SELF-CHECK, AND THE ONE FAILURE NO PROBE CAN CATCH

§N10 in full: a config key is probed live (write, read back, restore) because
there is exactly one of it. A property CANNOT be -- thousands of instances and no
canonical one, so probing would mean writing to somebody's spawner at boot. What
is verified instead is everything verifiable without touching the world.

And `TreasuresOfTokuno` is excluded by name, because `IsActive()` reads its own
`DropEra` rather than `Status`: the write succeeds, the value reads back, a
compare-and-set restore passes, and the capability does nothing at all. That is
N10's "capability that lies" in its purest form and the only way to find it is to
read the source. §G also called this toggle "small and safe" -- it is safe, but
`OnStatusChange()` generates or removes world content for six of the eight.

THE ONE-SHOTS

Who receives a grant is answered HERE. The website has the list too, but a module
cannot read core's tables, so the alternative was a new core surface handing
participants to a module. Not needed: 11b's participation ledger already holds
them, keyed by the same serials. A run with no ledger is a 404; a run whose
ledger is open and empty is a 200 with `granted: 0`, because an event nobody
attended still happened. An undeliverable grant is DELETED rather than dropped --
`AddItem` failing on a full backpack would otherwise leave it at (0,0).

A save stops the world, so it is rate-limited rather than capped, counting
ServUO's own autosave as the last one. Refused, never queued: a queued save would
land at a moment nobody chose.

VERIFIED

Compiles clean (0 warnings, 0 errors). Then a full walk on the real local ServUO
57.4 world against the release sidecar:

- all six catalog keys survive the boot self-check; `current` is correctly absent
  on the targeted rows and filled when a target is named;
- a spawner reads the same by UniqueId and by serial;
- TWO RUNS ON TWO SPAWNERS BOTH SUCCEED while a second run on the SAME spawner is
  refused -- the whole reason for the targeted ref;
- a GM edit behind the plane's back yields `lease.drifted` and the world is left
  at 55, not reverted; a clean release restores to baseline;
- ToT refused with its own reason, a bad status refused, Fellowship toggled;
- grant: no ledger 404, empty ledger 200 `granted: 0`, unknown item 400, over the
  stack bound 400; save 200 then 429 inside the interval;
- BOTH HOLDS AND BOTH LEASED VALUES SURVIVE save + clean shutdown + restart, the
  deadlines re-arm, and a release across the restart still compare-and-sets;
- with only a CONFIG lease held, `Leases.bin` is 8 bytes and names nothing;
- refusals: targeted-with-no-target, untargeted-with-a-target, out of range, over
  30 days, a target that is not there, and a `ChainChest` refused as a spawner;
- 90 seconds becomes `00:01:30` and the baseline reads back as 18000;
- a deleted target reads `unreadable` and releases `targetGone: true`.

The test world was never saved after the deliberate deletion, so it is intact.

Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #24
Protocol 7, amended in place. The at-most-once store had two cases and needed a
third.

A handler that THREW keeps its key and stores the failure — correct, because it
may have applied half of itself, and that is the failure this file exists to
prevent. A handler that RETURNED had its reply stored and replayed for ever. There
was no case for a handler that ran to completion and deliberately REFUSED, and
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. Nothing happened — and
remembering the refusal froze it as the key's answer, so a refusal that WAITING
FIXES could never be retried past.

`uo.world.save` is the case the acceptance walk found it with, and the module says
in as many words what it expected: "429 is the shard's save rate limit, and it is
the one refusal on this plane that waiting fixes … which is what makes a phase
boundary retried rather than abandoned." It was not achievable. Six attempts over
four minutes — three automatic, an operator retry, three more — every one
answering the same frozen sentence, "the last save was 227 seconds ago". The
number could not age because it was the first reply being replayed, and a step's
idempotency 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. The refusal is still emitted to the caller, which
is what ends the attempt; it is simply not remembered as this 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 here.
`bridge.error` is excluded deliberately — that is the reply this file writes when
a handler threw, which is exactly the case whose key must be kept. Both completion
paths are covered, including a deferred handler that refuses after its timer arms.

The safety argument is that "nothing happened" holds for every `*.error` reply
here — audited across BridgeWorld, BridgeOneShots, BridgeLeases and
BridgeParticipation, where every refusal is a pre-mutation guard or follows a
`Write` that failed. It is a property this file cannot verify, so it is written
down as a rule handlers must keep: do not answer `*.error` after changing the
world. Report a partial change in an `ok` reply, as the item grant does with
`granted`/`missed` and the despawn with `removed`/`gone`/`refused`.

Verified on a real shard: the same key 25 seconds apart now answers "the last save
was 15 seconds ago" then "40 seconds ago" — a number that moves, with no
`replayed` marker — while a SUCCESSFUL reply is still replayed unchanged, so the
at-most-once guarantee is intact where it matters.

`Status()` gains a `refused=` counter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #25
whitlocktech approved these changes 2026-09-09 19:53:28 +00:00
whitlocktech merged commit f23a08d449 into main 2026-09-09 19:53:49 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#26
No description provided.