feat(bridge): lease deadlines and the participation ledger (Phase 11b) #22

Merged
whitlocktech merged 1 commits from feature/events-p11b-leases-participation into edge 2026-09-05 04:11:44 +00:00
Member

EVENTS_PLAN.md Phase 11b, the plugin half. Protocol 6 amended in placeoverlay.toml stays 6, and §7 of v6.md says why that is safe on edge and would not be on main.

Two mechanisms, one new gate, and the phase's own deferral found a bug in 11a.

Bridge.EventsEnabled, and why it is not AdminWriteEnabled

Its own switch, default off (org lead). 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.

Leases

lease.apply / lease.release / lease.list. The two mechanisms EVENTS.md names are the whole of the file:

  • The deadline lives here. A lease arms a timer and the shard restores baseline when it passes whether or not the website is ever heard from again. Core drives the normal restore; this is the backstop. The naive design, where restoration depends on core dispatching a cleanup step, fails open if core dies mid-event. This fails safe.
  • Restore is compare-and-set. If the value is not what the event applied, somebody moved it: report drifted, leave the world alone. Blindly restoring would silently revert a staff member's change, which is the one failure that would make operators distrust the feature.

Three shapes where the obvious alternative is subtly wrong:

  • holdMs is authoritative, untilMs is display. An absolute deadline is measured against two clocks, and a shard running ten minutes fast would restore a ten-minute lease the instant it took it.
  • Values cross as TEXT and compare as parsed numbers. 1200 and 1200.0 are one number to a JSON parser and two strings to a diff, and a drift check comparing formatted numbers would report drift on a value nobody touched — refusing to restore, leaving the world changed, and blaming an innocent operator.
  • A lease longer than the shard's ceiling is REFUSED, not clamped. A clamp gives the website a shorter lease than it believes it has, and the website schedules the restore — the two would then disagree about when the world comes back. The ceiling exists for the case where the website is wrong; being loud is the entire value.

Two measurements that shaped the catalog

Of the 158 non-Bridge Config.Get call sites in Scripts/, roughly EIGHT are read live.

EVENTS.md §D frames it as two patterns; it is nearer 95/5. The allowlist is not a curated subset of a large pool — it is nearly the whole of what exists. 11b ships PlayerCaps.SkillCap, read live in CharacterCreation.cs, which is both live and observable.

Config.Set has exactly ONE caller in the whole of ServUO 57.4Server/ScriptCompiler.cs, for Compiler.Dynamic.

No in-game command, gump or console verb writes a config key, so on a stock shard a GM cannot drift a configuration lease even deliberately. The compare-and-set is still required — Phase 12's object-property leases are trivially driftable — but proving it needed the new configset verb in tools/scaffolding/BridgeRigDriver.cs.

Memory-only, and that is a decision. Nothing calls Config.Save(), so a restart is a free restore — and it is also why lease.list reports an empty hand afterwards, which is exactly what lets the website's reconcile notice the lease is gone.

Participation

participation.open / .snapshot / .close. Presence in a declared area plus kill credit inside it, keyed by character serial to match this org's Teams memberKey.

  • A map, a point and a radius, not a region name. Protocol 6's own walk established that the most specific region containing an event is routinely anonymous — an active champion spawn registers a nameless region over its own area — so a region-named venue would be undeclarable for exactly the places events happen.
  • Presence accrues in SECONDS, not sample counts, and the kill weight is frozen per run at open. Both are config keys an operator may change halfway through a five-hour run, and a count multiplied at read time would silently rewrite the first half of the tally.
  • Kill credit goes to every damager standing in the area, not the killer. A last hit is a poor description of who fought something. The area test is applied to the damager, so someone shooting in from outside is not attending.

The Bridge's first persisted state

Nothing here has ever persisted anything. A ledger has to: a run spans hours and a restart mid-event is an ordinary Tuesday. Server.Persistence + EventSink.WorldSave writes a companion file rather than a persistence item — no world object, nothing for a GM to delete by accident. Hooks attach in Configure(), because EventSink.WorldLoad fires inside World.Load() and Initialize() is too late. They attach unconditionally, before the gate: an operator who switches the plane off for an afternoon must not come back to a truncated file.

The defect the deferral found, in 11a's own code

participation.snapshot chunks its walk across Core ticks, which makes it the first handler to complete after OnInboundLine returned — and the first that can produce bridge.busy. On its first collision it answered 200, not 425.

BridgeIdempotency.Busy built its frame with Begin("bridge.busy") — which writes "kind":"bridge.busy" — and then appended a diagnostic .Str("kind", prior.Kind). The object carried two kind fields, and every JSON parser takes the last. The sidecar read participation.snapshot, matched nothing, and returned an ordinary 200 whose body said nothing had happened — the worst of the three possible answers, because a retry loop reads it as success.

It shipped in 11a and could not be seen there: with only synchronous handlers a repeat can never arrive mid-flight, and the sidecar test covering the mapping was correctly feeding it a hand-built frame. Renamed busyKind. This is 11a's own sentence — "11b's leases are the first thing that can actually produce it, and proving it belongs in that walk" — collected.

Verification

Compiles clean against the real ServUO 57.4 reference assemblies (msbuild Scripts.csproj, 0 errors), built offline with the shard stopped — a running ServUO holds Scripts.dll and fails MSB3027 rather than reporting a compile error.

The live rig, all four of the phase's verifications:

Claim Evidence
baseline returns with nobody asking A 20-second lease, then silence: [Bridge] lease PlayerCaps.SkillCap: deadline passed, restored to 1000 without being asked, plus lease.expired on the feed
an expired lease still yields a verdict lease.list reported held: true, expired: true, restored: true; the later release answered alreadyRestored: true
a mid-lease GM edit produces drifted configset … 1350 under a live lease → {"kind":"lease.drifted","current":"1350"}, and a read afterwards still showed 1350. The world was left alone
a restart reverts a config lease held: false, current: "1000" — which is what makes the website's inForce() correct
kill credit is per damager Two players damaging one creature at the venue: both kills: 1, score: 5.0
the tally survives a restart mid-run [Bridge] participation: 1 run(s) restored from the world save, both members with identical firstMs
the first live bridge.busy Two concurrent snapshots under one key, chunk 1: 200 then 425 {"kind":"bridge.busy","busyKind":"participation.snapshot"}
a deferred key replays A third request answered replayed: true under its own reqId and the first attempt's t

What the rig could not drive, and two traps that faked defects

Presence. 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. There is no way to produce a NetState short of writing a client. Kill credit needs none, so accrual, persistence, chunking and replay were all driven; the one unexercised line is member.Seconds += seconds, named here rather than assumed.

  • Core.Kill does not save the world. The rig driver's shutdown is a clean shutdown, which is the only kind that emits — and it writes no save. The first restart test reloaded an empty Participation.bin and looked exactly like a persistence bug. save then shutdown.

  • A probe meaning to produce two damagers must not kill with the first blow. 40 damage on a Mongbat killed it where it stood, the second damager never landed a hit, and the ledger correctly credited one player while reading as a plugin that credits only the killer. Scaled to HitsMax.

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

Sidecar: RunicGateway/link#37 · Core: RunicGateway/website#193 · Module: RunicGateway/Module-uo#30 · Docs: RunicGateway/docs#220

🤖 Generated with Claude Code

`EVENTS_PLAN.md` Phase 11b, the plugin half. Protocol 6 **amended in place** — `overlay.toml` stays `6`, and §7 of `v6.md` says why that is safe on `edge` and would not be on `main`. Two mechanisms, one new gate, and the phase's own deferral found a bug in 11a. ## `Bridge.EventsEnabled`, and why it is not `AdminWriteEnabled` **Its own switch, default off** (org lead). 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. ## Leases `lease.apply` / `lease.release` / `lease.list`. The two mechanisms `EVENTS.md` names are the whole of the file: - **The deadline lives here.** A lease arms a timer and the shard restores baseline when it passes **whether or not the website is ever heard from again**. Core drives the normal restore; this is the backstop. The naive design, where restoration depends on core dispatching a cleanup step, fails *open* if core dies mid-event. This fails *safe*. - **Restore is compare-and-set.** If the value is not what the event applied, somebody moved it: report `drifted`, leave the world alone. Blindly restoring would silently revert a staff member's change, which is the one failure that would make operators distrust the feature. Three shapes where the obvious alternative is subtly wrong: - **`holdMs` is authoritative, `untilMs` is display.** An absolute deadline is measured against **two clocks**, and a shard running ten minutes fast would restore a ten-minute lease the instant it took it. - **Values cross as TEXT and compare as parsed numbers.** `1200` and `1200.0` are one number to a JSON parser and two strings to a diff, and a drift check comparing formatted numbers would report drift on a value nobody touched — refusing to restore, leaving the world changed, and blaming an innocent operator. - **A lease longer than the shard's ceiling is REFUSED, not clamped.** A clamp gives the website a shorter lease than it believes it has, and the website schedules the restore — the two would then disagree about when the world comes back. The ceiling exists for the case where the website is wrong; being loud is the entire value. ### Two measurements that shaped the catalog > **Of the 158 non-Bridge `Config.Get` call sites in `Scripts/`, roughly EIGHT are read live.** `EVENTS.md` §D frames it as two patterns; it is nearer 95/5. The allowlist is not a curated subset of a large pool — it is nearly the whole of what exists. 11b ships `PlayerCaps.SkillCap`, read live in `CharacterCreation.cs`, which is both live and *observable*. > **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4** — `Server/ScriptCompiler.cs`, for `Compiler.Dynamic`. No in-game command, gump or console verb writes a config key, so **on a stock shard a GM cannot drift a configuration lease even deliberately**. The compare-and-set is still required — Phase 12's object-property leases are trivially driftable — but proving it needed the new `configset` verb in `tools/scaffolding/BridgeRigDriver.cs`. **Memory-only, and that is a decision.** Nothing calls `Config.Save()`, so a restart is a *free* restore — and it is also why `lease.list` reports an empty hand afterwards, which is exactly what lets the website's reconcile notice the lease is gone. ## Participation `participation.open` / `.snapshot` / `.close`. Presence in a declared area plus kill credit inside it, keyed by **character serial** to match this org's Teams `memberKey`. - **A map, a point and a radius, not a region name.** Protocol 6's own walk established that the most specific region containing an event is routinely **anonymous** — an active champion spawn registers a nameless region over its own area — so a region-named venue would be undeclarable for exactly the places events happen. - **Presence accrues in SECONDS, not sample counts**, and the kill weight is frozen per run at open. Both are config keys an operator may change halfway through a five-hour run, and a count multiplied at read time would silently rewrite the first half of the tally. - **Kill credit goes to every damager standing in the area, not the killer.** A last hit is a poor description of who fought something. The area test is applied to the **damager**, so someone shooting in from outside is not attending. ### The Bridge's first persisted state Nothing here has ever persisted anything. A ledger has to: a run spans hours and a restart mid-event is an ordinary Tuesday. `Server.Persistence` + `EventSink.WorldSave` writes a companion file rather than a persistence *item* — no world object, nothing for a GM to delete by accident. Hooks attach in `Configure()`, because `EventSink.WorldLoad` fires inside `World.Load()` and `Initialize()` is too late. They attach **unconditionally**, before the gate: an operator who switches the plane off for an afternoon must not come back to a truncated file. ## The defect the deferral found, in 11a's own code `participation.snapshot` chunks its walk across Core ticks, which makes it the first handler to complete *after* `OnInboundLine` returned — and the first that can produce `bridge.busy`. On its first collision it answered **200, not 425**. `BridgeIdempotency.Busy` built its frame with `Begin("bridge.busy")` — which writes `"kind":"bridge.busy"` — and then appended a diagnostic `.Str("kind", prior.Kind)`. **The object carried two `kind` fields, and every JSON parser takes the last.** The sidecar read `participation.snapshot`, matched nothing, and returned an ordinary 200 whose body said nothing had happened — the worst of the three possible answers, because a retry loop reads it as success. It shipped in 11a and **could not be seen there**: with only synchronous handlers a repeat can never arrive mid-flight, and the sidecar test covering the mapping was correctly feeding it a hand-built frame. Renamed `busyKind`. This is 11a's own sentence — *"11b's leases are the first thing that can actually produce it, and proving it belongs in that walk"* — collected. ## Verification Compiles clean against the real ServUO 57.4 reference assemblies (`msbuild Scripts.csproj`, 0 errors), **built offline with the shard stopped** — a running ServUO holds `Scripts.dll` and fails `MSB3027` rather than reporting a compile error. The live rig, all four of the phase's verifications: | Claim | Evidence | |---|---| | **baseline returns with nobody asking** | A 20-second lease, then silence: `[Bridge] lease PlayerCaps.SkillCap: deadline passed, restored to 1000 without being asked`, plus `lease.expired` on the feed | | an expired lease still yields a verdict | `lease.list` reported `held: true, expired: true, restored: true`; the later release answered `alreadyRestored: true` | | **a mid-lease GM edit produces `drifted`** | `configset … 1350` under a live lease → `{"kind":"lease.drifted","current":"1350"}`, and a read afterwards still showed `1350`. The world was left alone | | a restart reverts a config lease | `held: false`, `current: "1000"` — which is what makes the website's `inForce()` correct | | kill credit is per damager | Two players damaging one creature at the venue: both `kills: 1`, `score: 5.0` | | **the tally survives a restart mid-run** | `[Bridge] participation: 1 run(s) restored from the world save`, both members with identical `firstMs` | | **the first live `bridge.busy`** | Two concurrent snapshots under one key, chunk 1: 200 then **425** `{"kind":"bridge.busy","busyKind":"participation.snapshot"}` | | a deferred key replays | A third request answered `replayed: true` under its own `reqId` and the **first attempt's** `t` | ### What the rig could not drive, and two traps that faked defects **Presence.** 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. There is no way to produce a NetState short of writing a client. Kill credit needs none, so accrual, persistence, chunking and replay were all driven; the one unexercised line is `member.Seconds += seconds`, named here rather than assumed. - **`Core.Kill` does not save the world.** The rig driver's `shutdown` is a clean shutdown, which is the only kind that *emits* — and it writes no save. The first restart test reloaded an **empty** `Participation.bin` and looked exactly like a persistence bug. `save` then `shutdown`. - **A probe meaning to produce two damagers must not kill with the first blow.** 40 damage on a Mongbat killed it where it stood, the second damager never landed a hit, and the ledger correctly credited one player while reading as a plugin that credits only the killer. Scaled to `HitsMax`. - [x] AI-assisted: Claude Code (Opus 5). Sidecar: RunicGateway/link#37 · Core: RunicGateway/website#193 · Module: RunicGateway/Module-uo#30 · Docs: RunicGateway/docs#220 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-05 00:33:24 +00:00
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>
whitlocktech merged commit b9a27a2de5 into edge 2026-09-05 04:11:44 +00:00
whitlocktech deleted branch feature/events-p11b-leases-participation 2026-09-05 04:11:45 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

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