docs(link): protocol 7 part b -- what an event borrows, and the one-shots

`link/v7.md` gains §11-§14: the two targeted lease planes, the two one-shots, the
routes, and what the build found in already-merged code. `EVENTS.md` §G's five
part-b rows are marked built, three of them carrying a correction. `MODULE_API.md`
records the three contract members 12b amends into 1.10.0. `EVENTS_PLAN.md` has
Phase 12b as built.

THE DESIGN POINT, WRITTEN DOWN

A borrowed value whose home is the world save does not come back on a restart the
way a config value does. 11b's fail-safe -- a lease that never reaches disk makes
a restart a free restore -- depends entirely on the leased value being
memory-only too, and for a spawner property or a seasonal status it is not: a
restart preserves the CHANGE and destroys only the timer that would have undone
it. So those two planes' holds are persisted and their deadlines re-armed, and
the config plane's still are not. The same argument, applied where its premise is
false.

FIVE CORRECTIONS TO EVENTS.md

- `Spawner.Amount` does not exist. The property is `MaxCount`, and
  `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries seconds.
- The seasonal toggle is not "small and safe". Safe, yes -- ServUO does it to
  itself from a staff gump -- but `OnStatusChange()` generates or removes world
  content for six of the eight permitted types.
- It is a THREE-value enum over nine named events, not a nine-value enum.
  (Caught in 12a's survey; the row is corrected here now it is built.)
- `TreasuresOfTokuno` is excluded, because `IsActive()` reads its own `DropEra`
  rather than `Status`. A lease on it applies cleanly, reads back, restores
  cleanly and changes nothing -- §N10's "capability that lies", and the one
  instance no runtime probe can catch.
- The grant row said failure aborts rather than retries. Protocol 6 changed that:
  an idempotency key means a repeat is answered by the original reply, so a
  retried grant cannot be one winner receiving two.

And the config-lease row is closed at one key. Counted on ServUO 57.4: 156
non-Bridge `Config.Get` call sites, 82 outside a field declaration, all but four
of those inside a `Configure()` or static constructor and cached at boot anyway.
The self-check ships regardless -- it exists for the operator whose OWN scripts
read config live.

MODULE_API 1.10.0, AMENDED IN PLACE

`target` on a lease declaration, `values` on a string lease, and `searchable` +
`{ q }` on option sources. Amended rather than bumped for the reason every phase
since P10 has: 1.10.0 has never reached `main`, so no deployment can tell the
difference, and the cutover is what publishes the whole of it.

The `target` entry records why this extends `core.lease` rather than giving the
module a lease verb of its own: §F settled that in Phase 8, and half its
objection no longer holds (the two-events-one-target refusal comes from the
ledger's unique index whichever verb reserves the row) while the other half --
`maxDurationMs` re-implemented per module -- still does.

§14 records the protocol-pin defect 11a and 12a both shipped, and why the test
that guards it passed anyway: it asserts the three declarations agree with each
other, which all three being equally stale satisfies.

CHECKS

`docs` has no CI. Every relative link in the new sections was resolved by hand;
the files are CRLF in the working tree and each diff is content-sized
(`--numstat` matches the real change), so nothing carries the `\r\r\n` full-file
rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-07 08:08:35 -05:00
parent 8415581a72
commit 4169a0df83
4 changed files with 450 additions and 20 deletions

View File

@@ -1,7 +1,7 @@
# Protocol 7 — What an event owns
**Status:** In review on `edge`. **12a and 12b land as one protocol version** — see §8.
**Date:** 2026-09-07 (12a)
**Date:** 2026-09-07 (12a and 12b)
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`v6.md`](v6.md) (6.0 idempotency, leases, participation), [`INTEGRATION.md`](INTEGRATION.md) (website API).
**Driven by** [`../website/EVENTS.md`](../website/EVENTS.md) and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 12.
@@ -248,8 +248,8 @@ The three declaration sites, all bumped in 12a: `link/sidecar/src/main.rs`
(`PROTOCOL_VERSION`), `servuo-plugins/overlay.toml` (`protocol`), and the website's admin-managed
`uoLinkConfig`. `installer` has none, for the reason Phase 11 records.
**12b adds** object-property leases and their boot-time self-check, the seasonal-event toggle, a
world save, and the item grant.
**12b amends 7 in place** with object-property leases and their boot-time self-check, the
seasonal-event toggle, a world save and the item grant. It is specified in §11 to §14 below.
---
@@ -262,8 +262,27 @@ world save, and the item grant.
| `module-uo` | Five actions, five budget dimensions, the decoration option source and the atlas index behind it |
| `docs` | This file; `EVENTS.md` §G; `EVENTS_PLAN.md` Phase 12 |
`MODULE_API_VERSION` does **not** move. Budget dimensions and option sources are things a module
*declares* through an API that already exists; nothing in the contract core reads has changed.
**Part b adds to every row of it, and adds a fifth.** `servuo-plugins`: `BridgeLeaseTargets.cs`,
`BridgeOneShots.cs`, the persisted `Leases.bin` and the boot self-check. `link`: `target` on the
lease family, `GET /items`, `POST /items/grant`, `POST /world/save`. `module-uo`: five targeted
leases, two actions, `uo.rewards`, three option sources, the atlas's `unique_id`, **and the
protocol pin 11a and 12a both missed** (§14). `docs`: this file, `EVENTS.md` §D/§G,
`MODULE_API.md`.
**`website` is in part b and was not in part a.** A targeted lease is a shape `core.lease` did not
have: the declaration gains a `target` and a `values` set, the four callables gain the target, and
the reservation ref becomes `<lease id>#<target>` so the two-events-one-target index bites at the
granularity the world actually has. Extending core rather than giving the module its own lease verb
is what `EVENTS.md` §F decided in Phase 8 — *"the verb is core's"* — and the alternative would have
re-implemented `maxDurationMs` per module, advisory everywhere and wrong in the first one that
forgot.
`MODULE_API_VERSION` does **not** move — in part a because budget dimensions and option sources are
things a module *declares* through an API that already exists, and in part b because **1.10.0 is
amended in place**, the shape every phase since P10 has used while the workstream sits on `edge`.
Part b really does change the contract (targeted leases, lease value sets, searchable option
sources); nothing is released from `edge`, so the version is amended rather than bumped, and the
cutover is what publishes it.
---
@@ -293,3 +312,260 @@ the shard can build, because a creature is constructed from a ServUO class name
not one. The atlas's `name` **is** the raw type token from the spawn files, so the fix was to stop
discarding the half that works. Safe to change because 12a is the source's first consumer — the file
said so when it shipped.
---
## 11. Part b — what an event BORROWS
12a was the first half of §G's rule. This is the second: **an event may borrow what it changes, and a
borrowed value carries a deadline the game itself enforces.**
Two planes, and both of them are **targeted** — a lease names a key *and* the thing it applies to.
Every lease before protocol 7 named a single value (a config key, a rate), so the key *was* the
target. `Spawner.MaxCount` is not that: it is one capability over thousands of spawners, and two runs
turning up two different spawners must both be allowed while two runs turning up the same one must
not.
| Plane | Key | Target | Holds |
|---|---|---|---|
| Object property | `Spawner.MaxCount` | a spawner | how many it keeps alive |
| Object property | `Spawner.MinDelay` / `Spawner.MaxDelay` | a spawner | its respawn window, in seconds |
| Object property | `Spawner.Running` | a spawner | whether it runs at all |
| Seasonal | `Seasonal.Status` | an `EventType` | `Inactive` / `Active` / `Seasonal` |
The wire is the **same `lease.apply` / `lease.release` / `lease.list` family** protocol 6 introduced,
with a `target` field. One deadline, one compare-and-set, one grace window, one set of counters —
three planes rather than three protocols.
### 11.1 A lease here must be PERSISTED, and the config plane's must not
[`v6.md`](v6.md) states the config lease's fail-safe plainly: *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`. It 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 of them 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 on the
shard remembering that it is borrowed. That is the exact failure the lease framing exists to make
impossible, so the hold is persisted, in the Bridge's **third** save file (`Saves/Bridge/Leases.bin`,
beside `Participation.bin` and `Owned.bin`) and written by the same `EventSink.WorldSave` that writes
what it describes.
**A deadline that passed while the shard was down fires at once**, rather than being dropped or
extended. The promise the website was given is "back at baseline by then"; a shard that was off for
the whole hold has not kept it, and restoring immediately is the only reading of that promise still
available. Extending it would silently turn a two-hour lease into however long the outage was.
Config holds are still **not** written down, and the asymmetry is the point rather than an
inconsistency: the same argument, applied to planes where its premise is false.
### 11.2 A target is a serial or a UniqueId, and both are needed
- A **serial** is what `[props` shows a GM and what a rig can type.
- An **`XmlSpawner.UniqueId`** is what the shard's own `Spawns/*.xml` carry and what the live spawner
keeps.
The second is not a convenience. A dropdown built from serials is **impossible**: serials are
assigned when the world is built and nothing off the shard knows them, so a lease addressable only by
serial could have no authoring list at all — it would be the free-text box the option-source contract
exists to replace. The atlas already read `<UniqueId>` and discarded it; Phase 12b keeps it.
The UniqueId lookup is a scan of `World.Items`, and stays one: it runs once per lease apply, which is
a rare human-scheduled operation, and a cache would be a second copy of the world to keep correct
across `[add` and deletion.
### 11.3 The allowlist is checked against the object's own type
A serial is a number a caller chooses. The only thing standing between `Spawner.MaxCount` and any
item on the shard is the check that the object found **is** one of the types the entry names — so
that check reads the object's own type rather than anything the caller sent, and it is
`IsInstanceOfType` rather than equality so a shard's own subclass of `Spawner` is leasable.
Properties are read and written by reflection, bounded three ways: the (type, property) pair must be
in the shipped catalog, the property must carry `CommandProperty` (ServUO's own marker for *a staff
member may set this*, so this plane can never reach further into an object than `[set` could), and
its CLR type must be the one the entry knows how to render. Reflection rather than a hand-written
switch is what lets the boot self-check actually verify a pair; a switch would compile happily
against a property ServUO had renamed.
**`Spawner` and `XmlSpawner` share all four property names**, which is a fact about this tree rather
than a convenience: the shard's own spawn files load as XmlSpawners while `[add spawner` makes the
native one, so a catalog naming only one of them would work until the day it did not.
### 11.4 The boot self-check, and what it cannot do
[`EVENTS.md`](../website/EVENTS.md) §N10: *the allowlist ships with the plugin and each key
self-checks at boot, dropping itself from the advertised catalog if the write does not take.* Better
a capability that disappears loudly than one that lies.
**A config key is probed live** — written, read back, restored, all in one synchronous call — because
there is exactly one of it.
**A property cannot be**, and that is a property of the thing rather than a shortcut: there are
thousands of instances and no canonical one, so probing would mean picking somebody's spawner at boot
and writing to it. What is verified instead is everything verifiable without touching the world: the
type still resolves, the property still exists on it, it is still public and settable, it still
carries `CommandProperty`, and its CLR type is still the one this plane can render. That is exactly
the failure N10 was written for — a property a later ServUO renamed or made read-only — caught at
boot rather than at 3am inside an unattended run.
**And one failure no probe can catch is excluded by name.** `TreasuresOfTokuno` is left out of the
seasonal catalog because `SeasonalEventEntry.IsActive()` special-cases it and reads
`TreasuresOfTokuno.DropEra` rather than `Status`. Setting its status writes a field nothing consults:
the write succeeds, the value reads back, a compare-and-set restore passes, and every mechanism in
the plane reports a working lease over a capability that does nothing at all. It is N10's "capability
that lies" in its purest form, and the only way to find it is to read the source.
### 11.5 What the seasonal toggle actually costs
[`EVENTS.md`](../website/EVENTS.md) §G called this *"small and safe"* and got two things wrong.
**It is a three-value enum over nine named events, not a nine-value enum.** `EventStatus` has three
values (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries. That is a
different form to author and a different one to cap. (Corrected in 12a's survey; built here.)
**And it is not small.** `SeasonalEventEntry.Status`'s setter fires `OnStatusChange()`, which calls a
`CheckEnabled()` that generates or removes world content for six of the eight permitted types —
Doom, Khaldun, Sorcerer's Dungeon, Krampus, Rising Tide and Fellowship. It is *safe*: ServUO does
exactly this to itself from a staff gump. But an author scheduling one is scheduling more than a flag
flip, and the label says so.
### 11.6 `lease.list` answers the catalog and one row
A targeted key has no single `current``Spawner.MaxCount` is worth something different on every
spawner — so a catalog walk cannot fill one in, while the website's `read()` needs exactly one value
for exactly one target before it applies anything. So the frame narrows: naming a `key` and a
`target` answers that row with its value.
The frame also carries **`holds`**: every lease the shard is actually holding, whatever key or target
it is on. A catalog walk can enumerate the *keys* but never the *holds* on a targeted one — there is
no list of spawners to walk — so without it a reconcile after an outage would have no way to ask
"what are you still holding?". `inForce()` reads that.
`current` is **omitted rather than defaulted** when it means nothing (a targeted row listed with no
target), and an unresolvable target answers `unreadable` with a reason. Sending `""` would make the
website record an empty baseline and later try to restore it.
### 11.7 A vanished target is a success, not a failure
Somebody deletes the spawner mid-run. There is nothing to restore and nothing owed, so the release
answers `targetGone: true` and the website records the row **reverted**. It is 12a's `gone` in the
lease plane's vocabulary, and for the same reason: reported as a failure it would sit in the ledger
unresolved for ever, over an object that no longer exists, retried by every sweep.
Drift is unchanged and still not an error: the shard compared, declined to overwrite somebody's
deliberate change, and says so.
---
## 12. The one-shots: neither owned nor borrowed
Two verbs that cannot be taken back. Nothing is ledgered, because there is nothing core could come
back for.
### 12.1 The item grant, and who receives it
`ADMIN_CONTROLS.md` §8 cut item grants along with world creation, and §N1 reopened both —
deliberately as **two** reversals, because permitting an event to create a creature says nothing
about permitting it to hand out loot. The four properties that make this a different proposition from
the one §8 refused: it is **declared** (an allowlist, never a free-text type reaching
`Activator.CreateInstance`), **bounded** (`EventsMaxGrantPerRun`, `EventsMaxGrantStack`, both
refusing rather than clamping), **attributable** (the run id rides on every grant), and **idempotent**.
**The recipients are not sent, and that is the interesting decision.** A grant needs a list of
people, and the website has one in `event_run_participants` — but a module cannot read core's tables,
so the alternative was a new core surface handing participants to a module's `perform()`. It is not
needed: **the shard already has the list**, in protocol 6 part b's run-scoped participation ledger,
keyed by the same character serials the website's `member_key` holds. So the grant names a run and
the shard resolves who was there — no new core surface, no participant list crossing the wire twice,
and no window in which the two disagree.
Two answers that look alike and are not:
- **A run with no ledger open** is a `404`. The caller named something that does not exist here.
- **A run whose ledger is open and empty** is a `200` with `granted: 0`. An event nobody attended
still happened, and retrying against a ledger that will be just as empty next time would pause a
run for ever.
**It is retryable, and protocol 6 is why.** [`EVENTS.md`](../website/EVENTS.md) §G called a grant
un-retryable because a lost acknowledgement and a grant that never applied were the same event —
exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey`
closes it: a repeat is answered by the original reply, so a retried grant cannot be one winner
receiving two. §G was written before 11a and is corrected here.
A non-stackable item in quantity is refused at **both** ends: five cloaks would be five items, five
chances to overflow a backpack halfway through with no way to say which half landed. An undeliverable
grant is **deleted rather than dropped**`AddItem` failing on a full backpack would otherwise leave
the item in the world at (0,0), and an event that quietly littered the map with undeliverable rewards
would be worse than one that reported a miss.
### 12.2 The world save
`ADMIN_CONTROLS.md` §3.6 catalogued it Tier B and it was never built. It is useful as a phase
boundary — the point in an event after which what has happened is safe from a crash — and
`world.save.before` / `world.save.after` have been on the event stream since protocol 2, so the
acknowledgement it needs already exists. The reply says only that the save was **started**; a caller
that needs the completion watches the stream it is already connected to.
**A save stops the world, so it is rate-limited rather than capped.**
`Bridge.EventsMinSaveIntervalSec` refuses a save that comes too soon after the last one — counting
ServUO's *own* autosave as the last one, because an event save thirty seconds after the hourly one is
the same freeze twice and the shard is the only half that can see both. **Refused, never queued:** a
queued save would land at a moment nobody chose, in the middle of whatever the next step is doing.
It is the one refusal on this plane that waiting fixes, so it is a **429** rather than the 400 every
other refusal is — which keeps it out of the module's permanent-status set and makes a phase boundary
retried rather than abandoned.
---
## 13. Routes and commands added by part b
| Route | Command | Notes |
|---|---|---|
| `GET /lease?key=&target=` | `lease.list` | narrows to one row and fills `current`; always carries `holds` |
| `POST /lease` | `lease.apply` | `target` added |
| `POST /lease/release` | `lease.release` | `target` added; may answer `targetGone` |
| `GET /items` | `item.catalog` | the shard's grant allowlist and its bounds |
| `POST /items/grant` | `item.grant` | names a run, never a recipient list |
| `POST /world/save` | `world.save` | 429 when it comes too soon |
New shard config: `Bridge.EventsMaxGrantPerRun` (200), `Bridge.EventsMaxGrantStack` (1000),
`Bridge.EventsMinSaveIntervalSec` (300).
---
## 14. What part b found in already-merged code
**The website's protocol pin never left 5.** `uo_link_config.protocol` reaches the sidecar as
`X-UOLink-Version` on every REST call, and the sidecar answers an exact mismatch with a `409`. Phase
11a took the wire to 6 and 12a took it to 7; **neither moved the pin**, in either of the two places
module-uo declares it (`db/schema.sql`'s `CREATE`/`MODIFY` defaults and `DEFAULT_PROTOCOL` in
`uoLinkConfig.model.js`). Every sidecar call on a real deployment would have been refused — the whole
event plane dead, loudly, for a reason nobody would look there for.
It survived two phases because **both live walks set the column by hand while standing the rig up**,
which is exactly what makes a migration nobody runs invisible. 12b carries all three sites to 7.
The test that guards them is worth understanding before trusting it: `schemaFragment.test.js` asserts
the three declarations agree **with each other**. That is a real check — they drifted apart once, in
the bug `DEFAULT_PROTOCOL` was introduced to fix — but *all three being equally stale passes it*, and
nothing in that repo can anchor it to the wire, which lives in `link` and `servuo-plugins`. The thing
that actually pairs them is the installer's bundle check at deploy time. Bumping the pin in the same
change as the emitters remains the discipline; no test replaces it.
**A search term could not reach an option source.** `resolveOptionSource(id)` took no argument and
every source answered a flat list bounded at 2,000 entries. The spawner target is the first source
with more than that — 6,707 spawn points — so a flat list would have dropped two thirds of the world
and said nothing about which two thirds, which is precisely the failure 12a named for decoration.
`resolve({ q })` is additive: every source is passed a term and none is required to read one, and a
`searchable` flag says which do so the form renders a typeahead rather than a select that appears to
filter and does not.
**`Spawner.Amount` does not exist.** [`EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) named it; on
ServUO 57.4 the property is `MaxCount`, and `MinDelay`/`MaxDelay` are `TimeSpan` rather than numbers
— so the wire carries seconds and the shard converts. Seconds rather than minutes because the spawn
files' own `DelayInSec` flag proves both units are in use on a real tree, and a unit that cannot
express five seconds cannot express the shard's own data.