From eabaf2635f770fa6c9ade493a7176c600fb87b9a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 4 Sep 2026 19:32:11 -0500 Subject: [PATCH] docs(link): protocol 6 part b -- leases, participation, and what the walk found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six files. `v6.md` gains sections 7-10: one version with two halves, the lease plane, the participation ledger, and 11b's verification. Three corrections to what the plan and the design of record assumed. Phase 11b is FIVE repos, not four, and in the opposite direction from 11a's correction: 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. `website` joins. EVENTS.md's §D frames the 258 `Config.Get` call sites as splitting into two patterns. Measured on 57.4: of the 158 non-Bridge sites in `Scripts/`, roughly eight are read live. The allowlist is not a curated subset of a large pool, it is nearly the whole of what exists. And `Config.Set` has exactly one caller in the entire tree, so on a stock shard a GM cannot drift a configuration lease even deliberately -- which is why proving `drifted` needed a scaffolding verb. §G's "participation attribution is now the largest piece of new UO work" closes, and the live-config-lease row goes to built-with-one-key. §10.1 records the defect the phase's own deferral found in 11a's shipped code: `bridge.busy` answered 200 instead of 425 because the frame carried two `kind` fields and parsers take the last. Unreachable in 11a by construction; produced on the first collision here. Co-Authored-By: Claude --- link/INTEGRATION.md | 92 +++++++++++- link/PLAN.md | 35 +++++ link/v6.md | 329 +++++++++++++++++++++++++++++++++++++++-- website/EVENTS.md | 39 ++++- website/EVENTS_PLAN.md | 60 ++++++++ website/MODULE_API.md | 14 +- 6 files changed, 546 insertions(+), 23 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 4e70515..3a054cd 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -69,6 +69,12 @@ Pin the version you built against and compare it to the header (or `/health.prot **v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above. +**v6 (Protocol 6)** is the first bump that adds a **promise** rather than data: a command carrying +an `idempotencyKey` is executed at most once (see *Retrying a command safely* in §6). It also adds +`champ.boss.killed`, and the **event plane** — leases and the run-scoped participation ledger, six +endpoints, all of them gated on the shard by `Bridge.EventsEnabled` and answering **403** when an +operator has not switched it on. See [`v6.md`](v6.md). + **v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` / `vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that @@ -970,6 +976,89 @@ human just pressed a button for and can see the result of. The DELETE forms (`/towncrier/{id}`, `/news/{id}`) take no key: their idempotency is inherent — the second removal of an entry is a no-op the shard is already happy to perform. +### The event plane — leases (Protocol 6) + +**Off by default.** Every endpoint below answers **403** unless the operator has set +`Bridge.EventsEnabled` on the shard. That is deliberately not the admin write plane's switch: +enabling admin writes is consenting to staff moderation from a screen a human is looking at, and +enabling this is consenting to your world being changed and watched on a schedule, unattended. + +A **lease** is a live configuration value held at a new setting for a bounded time. The shard +restores the baseline when the deadline passes **whether or not you are ever heard from again** — +so the worst case is a world back at baseline early, never one stuck changed indefinitely. + +```json +GET /lease +→ 200 { "kind":"lease.list.ok", "leases":[ + { "key":"PlayerCaps.SkillCap", "label":"Starting skill cap", "type":"float", + "min":1000, "max":1500, "default":"1000", "current":"1000", "held":false } ] } + +POST /lease +{ "key":"PlayerCaps.SkillCap", "value":"1200", "holdMs":600000, + "untilMs":1788567000000, "runId":"77", "idempotencyKey":"…" } +→ 200 { "kind":"lease.ok", "baseline":"1000", "applied":"1200", "untilMs":1788567000000 } + +POST /lease/release +{ "key":"PlayerCaps.SkillCap", "expected":"1200", "baseline":"1000" } +→ 200 { "kind":"lease.ok", "released":true, "current":"1000" } +``` + +Five things a caller needs: + +- **The catalog is an allowlist and it is short.** A shard advertises only keys it has verified take + effect. Most of ServUO's configuration is cached at type initialisation, where a lease would apply + cleanly and do nothing — the worst failure this feature has — so `lease.list` is the authority and + "any config key" is not offered. +- **`holdMs` is what the shard honours; `untilMs` is for display.** Send both. An absolute deadline + is measured against two clocks, and a shard whose clock runs fast would restore your lease the + moment it took it. +- **Values cross as text, whatever `type` says.** `"1200"`, not `1200`. Comparison is done on parsed + values at the other end; the text is so a compare-and-set is comparing what you sent. +- **`released` can answer `lease.drifted` at 200.** That means somebody moved the value while you + held it, the shard **did not overwrite them**, and `current` is what is there now. It is not an + error: the mechanism did its job, and only a human can decide what should happen next. +- **`held` means the shard still has a record of the lease, not that the value is still overridden.** + A lease whose deadline has fired stays listed with `expired: true` until you release it, so a + reconcile in that window does not read a working backstop as a lost resource. A shard restart, by + contrast, reverts every lease and clears the record — `held: false` is how you learn that. + +### The event plane — participation (Protocol 6) + +A run-scoped tally of who took part: presence in a declared area, plus kill credit inside it, keyed +by **character serial**. The shard computes a score and you store it; the components ride along so +you can explain it. + +```json +POST /participation +{ "runId":"99", "map":"Felucca", "x":1496, "y":1628, "radius":40, "holdMs":3600000 } +→ 200 { "kind":"participation.ok", "runId":"99", "members":0, "closed":false } + +POST /participation/99/snapshot +{ "idempotencyKey":"…" } +→ 200 { "kind":"participation.snapshot.ok", "runId":"99", "members":2, "killWeight":5, + "participants":[ { "serial":"0xCB20", "name":"Jarvis", "acct":"seed_001", "webId":"17", + "seconds":3600, "minutes":"60.00", "kills":3, "score":"75.0000", + "firstMs":1788550182074, "lastMs":1788553782074 } ] } + +POST /participation/99/close +→ 200 { "kind":"participation.ok", "closed":true, "known":true, "members":2 } +``` + +- **The area is a point and a radius, not a region name.** The most specific region containing an + event is routinely anonymous on a UO map — 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. +- **`snapshot` is a POST for a read**, because it carries your `idempotencyKey`. On a well-attended + run the shard walks its members across game ticks rather than in one call, so a repeat arriving + mid-walk is answered **425**. Come back. +- **The tally is persisted in the world save**, so it survives a restart mid-event. `close` on a run + the shard has forgotten answers `known: false` at 200 rather than an error: nothing is being + counted for it either way. +- **`refused`** on a snapshot is the number of members the shard's cap turned away. A truncated tally + says so rather than quietly being short. +- **`webId`** is present only where the character's game account is linked to a website user. Most + characters carry none; treat its absence as ordinary. + ### Help-page (support) queue Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own @@ -1165,7 +1254,8 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's | 200 | OK | | 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) | | 401 | Missing or invalid auth token | -| 404 | Not found (unknown account / character / id, or a not-linked account) | +| 403 | Refused by the operator — the admin write plane, or the event plane (`Bridge.EventsEnabled`), is switched off on the shard | +| 404 | Not found (unknown account / character / id, a not-linked account, an unoffered lease key, or a run the shard is not counting) | | 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` | | 425 | Too Early — a command with this `idempotencyKey` is still in flight on the shard (Protocol 6). Nothing ran; retry | | 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` | diff --git a/link/PLAN.md b/link/PLAN.md index 7c06674..c72498a 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -317,6 +317,41 @@ kind including ones a later protocol adds. A command with no key behaves exactly One new outbound kind comes with it, **`bridge.busy`**: a repeat arrived while the original is still in flight, nothing ran, come back. +### The event plane, and the Bridge's first persisted state (Protocol 6) + +```jsonc +{"kind":"lease.apply","key":"PlayerCaps.SkillCap","value":"1200","holdMs":600000,"runId":"77"} +{"kind":"participation.open","runId":"99","map":"Felucca","x":1496,"y":1628,"radius":40} +``` + +Six commands behind one new gate, `Bridge.EventsEnabled`, default off and deliberately not the admin +write plane's switch. Two facts about ServUO shaped both halves and are worth recording here rather +than only in the spec. + +**`Server/Config.cs` is a real runtime store, and almost nothing reads it live.** `Config.Set` +mutates the in-memory entry table and `Config.Load()` is guarded by `_Initialized`, so a Set survives +every later Get — but of the **158** non-Bridge `Config.Get` call sites in `Scripts/`, roughly +**eight** are read at the call site. The rest are cached at type initialisation, where a lease +applies cleanly and does nothing at all. That is why the lease catalog is a verified allowlist and +never "any config key". + +Two consequences, both deliberate: **nothing calls `Config.Save()`**, so a lease never reaches disk +and a shard restart is a free restore; and **`Config.Set` has exactly one caller in the whole tree** +(`Server/ScriptCompiler.cs`), so on a stock shard no GM can drift a configuration lease even +deliberately — the compare-and-set is still required for Phase 12's object-property leases, and +proving it needs the scaffolding driver. + +**The participation ledger is the first thing this plugin has ever persisted.** A run spans hours and +a restart mid-event is ordinary, so an in-memory tally would silently regress every attendee's score. +`Server.Persistence` plus `EventSink.WorldSave` writes `Saves/Bridge/Participation.bin` beside the +world save — no persistence *item*, so no world object and nothing for a GM to delete by accident. +The hooks attach in `Configure()`, because `EventSink.WorldLoad` fires inside `World.Load()` and +`Initialize()` is too late. + +**And the first handler that defers.** `participation.snapshot` walks a large run's members across +Core ticks rather than in one inbound call, which makes it the first handler to complete after +`OnInboundLine` returned — and therefore the first that can actually produce `bridge.busy`. + ### `server.hello` is per-connection, not per-boot The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to. diff --git a/link/v6.md b/link/v6.md index 3b94aaf..24b6739 100644 --- a/link/v6.md +++ b/link/v6.md @@ -1,7 +1,7 @@ # 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 +**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, ``, 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. @@ -175,6 +175,25 @@ means here specifically: **the sidecar makes no idempotency promise of its own.* 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 @@ -206,6 +225,17 @@ ranked roll of who was strong enough to fell it. The kill is an event in the wor 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 @@ -221,7 +251,8 @@ touching the visibility config at all. | `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` | +| `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 | @@ -233,6 +264,11 @@ refuses a mismatch. The check is version-agnostic, so it needs no change for 6 e 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. @@ -308,24 +344,285 @@ a consumer reading `region: ""` cannot tell "nowhere in particular" from "the sh --- -## 7. What 11b adds to protocol 6 +## 7. One version, two halves -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. +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. -What 11b will add here: +Sections 8 and 9 are 11b. Everything above is 11a except where a section says otherwise. -- `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`. +--- + +## 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`. diff --git a/website/EVENTS.md b/website/EVENTS.md index 4f8079e..eb9c0b0 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -205,6 +205,18 @@ live-read — never "any config key". A module must not advertise a lease it can because *"the setting applied and nothing happened"* is the worst failure this feature has. That is a testable obligation, and the test is mechanical: for each key in the catalog, apply, observe, restore. +> **Measured in Phase 11b, and it is far more lopsided than "two patterns" suggests.** Of the 158 +> non-Bridge `Config.Get` call sites in `Scripts/` on ServUO 57.4, roughly **eight** are read live. +> The rest are cached at type initialisation. So the allowlist is not a curated subset of a large +> pool — it is nearly the whole of what is available, and the catalog Phase 12 inherits will be +> short for reasons no amount of care can change. +> +> A second measurement, which decides how `drifted` gets tested at all: **`Config.Set` has exactly +> one caller in the whole tree** (`Server/ScriptCompiler.cs`). 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 the config half of it is proved with scaffolding, not by a GM. + Beyond configuration the same pattern covers any per-object property whose current value is readable before it is written — an existing spawner's `Amount` / `MinDelay` / `MaxDelay`, a named creature's stats, a `SeasonalEventSystem` entry's status. Loot stays excluded for the reason it always was: it @@ -807,6 +819,10 @@ api.registerEventLeases([{ // not an error: answer { ok: false, drifted: true, current }. return { ok: true } }, + // Optional (Phase 11b). "Does the game side still have any record of this + // hold?" — a DIFFERENT question from `read`, and the only thing that takes a + // lease's ledger row out at reconcile. + async inForce() { return { ok: true, held: true } }, }]) ``` @@ -829,6 +845,16 @@ api.registerEventLeases([{ renders. A dimension an action prices but nobody declares is **shown** on that screen rather than filtered out, because the action is refused and the operator needs to be told which module is incomplete. +- **`inForce()` is the fourth, it is optional, and it is not `read()` with a comparison** + (Phase 11b). It answers *"does the game side still have any record of this hold?"*, which none of + the other three do — and it had to exist because a lease's ledger row has no reconcile path + otherwise: the step that made it names `core.lease`, which is core's own action, so there is + nowhere a module could hang the answer. A value that DIFFERS from what the run applied is drift, + which `restore()` reports so the row lands `drifted` with the current value beside it; inferring + absence from a changed value would orphan the row first and destroy that signal. Only an explicit + `{ ok: true, held: false }` takes a row out; a throw, a timeout and a lease with no `inForce()` + all leave the ledger alone. It matters most for exactly the case core could not see before: a + config lease is memory-only on the shard, so a restart reverts it *and* clears the record. - **A lease declares all three callables, and `restore` is not optional even though `read` could stand in for it.** They answer different questions: `read` is *"what is it now"*, `restore` is *"put this back, and tell me if someone else has moved it"* — the drift check, which is the one @@ -1095,13 +1121,13 @@ a capability exists. | Online population, per region | ✅ | `presence.online` with `byFacet` and `byRegion`. | | Name landmarks, regions, creatures for authoring | ✅ | The spawn atlas — and it answers the "meeting location" field every EM listing carries. | | Detect a boss defeated | ✅ **built (protocol 6)** | `champ.boss.killed`, fired from `EventSink.CreatureDeath` and detected by type, with the altar attributed from the sweep. The inference this replaces was more fragile than "slightly": `bossUp` also drops when a GM resets a spawn, when a boss despawns, and after a sidecar reconnect clears the diff cache. And it was silent about who fought — the new kind carries the damage table, which exists at the death and nowhere else. | -| **Participation attribution** | 🔗 weak + 🔧 | Composable only loosely from `region.enter` + `mob.killed`, 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, nothing survives a relog. A run-scoped participation ledger on the plugin side is the honest answer — and with points cut, this is now the *largest* remaining piece of new UO work. | +| **Participation attribution** | ✅ built | Protocol 6 part b. Presence in a declared area plus kill credit inside it, keyed by character serial, **persisted in the world save** so a restart mid-event does not lose it. The area is a map, a point and a radius rather than a region name — the most specific region containing an event is routinely anonymous. 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. | | Oracle NPC with scripted dialogue | 🔧 📡 | PEC caps this at 5 NPCs × 5 lines. **This is literally a web form** — arguably a better fit for browser authoring than spawning is, and it is how most story events actually work. | | Temporary gate to a venue | 🔧 📡 | PEC caps at 4 hours and forbids cross-facet gating to restricted areas. Inherently temporary, so it maps onto a run's lifetime and the ledger with no friction. | | Temporary decoration lockdown | 🔧 📡 | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. | | Named, hued creatures from an allowlist | 🔧 📡 | PEC's core capability, and its cap is the useful part: common creatures, custom name and hue, **capped at 30**. A bounded one-shot spawn with each serial ledgered — **not a spawner**, which PEC withholds precisely because it is unbounded over time. | | "Simple" boss variants | 🔧 📡 | An enhanced regular mob, capped at 2–4. The defensible form is an **event-owned creature template** — the event declares what it spawns, stats included, and never touches a creature it did not create. | -| **Lease a live config value** — rates, toggles, caps | 🔧 📡 | `Server/Config.cs` is a runtime typed key-value store, so this works — **for keys read live**. The plugin ships a verified allowlist, because a lease on one of the `static readonly` keys applies cleanly and does nothing. The single most transferable action in the whole set. | +| **Lease a live config value** — rates, toggles, caps | ✅ built (one key) | Protocol 6 part b: the registry, the deadline timer, compare-and-set restore and `lease.list`, proved end to end against one verified live-read key. **The allowlist is far shorter than this table assumed** — of the 158 non-Bridge `Config.Get` call sites in `Scripts/`, roughly *eight* are read live, so the split below is nearer 95/5 than half and half. Phase 12 adds the rest with the boot-time self-check. | | Lease a property on an existing object | 🔧 📡 | Practical, and an earlier revision was wrong to rule it out. The before-image lives in the website's database and survives a shard restart; a save just persists current state; a deleted target makes restore a no-op. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state. | | Grant an event item | 🔧 📡 | An ordinary action, not a special contract member. Admin-gated and capped like any other, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. The constructible allowlist is the plugin's; every label and icon comes from `shard_clilocs` and `item_id`. Failure aborts rather than retries: a retried grant is one winner receiving two. | | Toggle a ServUO seasonal event | 🔧 📡 | Small and safe: `SeasonalEventSystem.GetEntry(type).Status` over a nine-value enum, already persisted across saves. | @@ -1973,10 +1999,13 @@ the platform's largest missing safety property. Today the moderation write plane happened and nothing about what it produced. It is also what makes the reward audit answerable — who received what, from which step, in which run. -**Participation attribution is now the largest piece of new UO work.** -With points cut, spawning is no longer the hard part — attribution is. Results, profile history, the +**Participation attribution was the largest piece of new UO work.** ✅ *Built in Phase 11b.* +With points cut, spawning was never the hard part — attribution was. Results, profile history, the calendar's "what happened" and any future recognition all rest on trustworthy "who took part", and UO -gives no run-scoped attribution that can be synthesised from the website side. +gives no run-scoped attribution that can be synthesised from the website side. So the shard counts it: +presence in a declared area plus kill credit inside it, keyed by character serial, and **persisted in +the world save** — which made it 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. **The GM cue step makes the system useful before any protocol change.** "Post the instruction, wait for a human to confirm, advance" needs no module, no protocol and no world diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index acc2cd8..a4518c6 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1358,6 +1358,66 @@ deliberate mid-lease GM edit producing `drifted` rather than a silent overwrite; a shard restart mid-run; and the first live `bridge.busy`, which a deferring handler finally makes reachable. +> **Built.** All four verifications passed on a real ServUO with the release sidecar. Protocol 6 +> amended in place; `MODULE_API_VERSION` amended in place at 1.10.0. See +> [`../link/v6.md`](../link/v6.md) §§7–10. +> +> **It is FIVE repos, not four, and the plan was wrong about this in the opposite direction from +> 11a.** A lease's ledger row had no reconcile path anywhere, 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 hunting a baseline nobody was holding. +> `website` joins the phase: `core.lease` gains a `reconcile()` and `registerEventLeases` gains an +> optional **`inForce()`**. Deliberately not `read()` plus a comparison — a changed value is DRIFT, +> which teardown must report so the row lands `drifted`, and inferring absence from it would orphan +> the row first and tell the operator the lease vanished rather than that somebody moved it. +> +> **Ten decisions (org lead, 2026-09-04), all as recommended.** The five-repo correction and +> `inForce()`; `PlayerCaps.SkillCap` as the one proven key; a scaffolding write verb to make +> `drifted` reachable at all; leases memory-only, so a restart is a free restore; a separate +> `Bridge.EventsEnabled` gate rather than `AdminWriteEnabled`; map + point + radius for the area; +> presence-plus-weighted-kills for the score; the shard-side bounds and grace window; chunking +> `participation.snapshot` as the thing that defers; and no `MODULE_API` bump. +> +> **The catalog is far shorter than §D expected, and the measurement is the finding.** §D frames the +> 258 `Config.Get` call sites as splitting into two patterns. Measured: of the **158** non-Bridge +> sites in `Scripts/`, roughly **eight** are read live. The allowlist is not a curated subset of a +> large pool — it is nearly the whole of what exists. And **`Config.Set` has exactly one caller in +> the entire tree** (`Server/ScriptCompiler.cs`), so no in-game command, gump or console verb writes +> a config key: on a stock shard a GM cannot drift a *configuration* lease even deliberately, which +> is why proving `drifted` needed a `configset` verb in the rig driver. +> +> **The walk found a defect in 11a's shipped code, which is the argument for the ordering.** +> `bridge.busy` came back **200**, not 425, the first time anything produced it: +> `BridgeIdempotency.Busy` built its frame with `Begin("bridge.busy")` and then appended a diagnostic +> `.Str("kind", prior.Kind)`, so the object carried **two `kind` fields** and every JSON parser takes +> the last. The sidecar read `participation.snapshot`, matched nothing, and answered a 200 whose body +> said nothing had happened — the worst of the three possible answers, because a retry loop reads it +> as success. Unreachable in 11a by construction, and the first deferring handler produced it on its +> first collision. Renamed `busyKind`. +> +> **One resource in `module-uo` must NOT reconcile by boot stamp, and it is this one.** Every other +> resource wave 1 ships is stamped with the shard boot that created it, because a crier line and a +> news article live in shard memory and a restart is definitionally the loss of both. The +> participation ledger is written into the world save *specifically* so it survives a restart, so +> the stamp would orphan the one resource the phase went to the trouble of persisting. It asks +> instead, and only a 404 takes a row out. +> +> **What the rig could not drive: 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 the credit path, the accrual, the persistence, the +> chunking and the replay were all driven; the one unexercised line is the presence accrual itself, +> and it is named rather than assumed. +> +> **Two rig traps, both of which faked a defect.** `Core.Kill` does **not** save the world, so the +> first restart test reloaded an empty `Participation.bin` and looked exactly like a persistence bug +> — `save` then `shutdown` is the sequence. And a probe that means 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. + --- ### Phase 12 — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`) diff --git a/website/MODULE_API.md b/website/MODULE_API.md index a3452d1..4fd9c63 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -114,6 +114,7 @@ api.registerEventLeases([{ async read() { return { ok: true, value: 1.0 } }, async apply(v, until) { return { ok: true } }, async restore(baseline, { expected }) { return { ok: true } }, + async inForce() { return { ok: true, held: true } }, // optional }]) ``` @@ -129,6 +130,17 @@ field: showing what it had, because staleness is cheap, whereas an action that half-ran and was recorded as done is a world change nothing will ever come back for. `retry` is opted OUT of — a module that means "this will never work" says `retry: false`. +- **`inForce()` is a fourth question, not a fourth spelling of `read()`.** Optional, and answering + `{ ok: true, held: false }` is the only thing that takes a lease's ledger row out — everything + else, including a throw and a lease that declares no `inForce()` at all, leaves the row alone. + Core needs it because a reconcile after an outage asks *"does the game side still have any record + of this hold?"*, and none of the other three answers that: a value that DIFFERS from what the run + applied is drift, which `restore()` reports so the row lands `drifted` with the current value + beside it, and a reconcile that inferred absence from a changed value would orphan the row first + and tell the operator the lease vanished rather than that somebody moved it. The two questions + have different answers on purpose. Without it a lease row has no reconcile path at all — a lease's + step names `core.lease`, which is core's own action, so there is nowhere else a module could hang + the answer. - **A module cannot spend a budget it did not declare.** A `cost()` naming a dimension no module registered is REFUSED — at save, at the dry run and at dispatch, with its own refusal code, because the fix is a module's declaration and not a deployment's cap. Declaring a dimension is not the same @@ -652,7 +664,7 @@ api.registerAudiences([{ id, label, params, ceiling, resolve }]) // api.registerEngagementSeeds({ templates, ruleGroups }) // 1.9.0 api.registerEventActions([{ id, label, risk, reversible, cost, params, perform, revert, reconcile }]) // 1.10.0 api.registerEventBudgets([{ id, label, unit }]) // 1.10.0 -api.registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore }]) // 1.10.0 +api.registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0 api.registerEventOptionSources([{ id, label, resolve }]) // 1.10.0 api.onBoot(async (ctx) => {}) api.onShutdown(async () => {})