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.

View File

@@ -1127,23 +1127,28 @@ a capability exists.
| Temporary decoration lockdown | ✅ **built (protocol 7)** | Permanent decoration prohibited in the program and should be prohibited here. Ledgered and reverted like anything else. Phase 12a places it from the shard's OWN `Data/Decoration/**/*.cfg` vocabulary, carrying the item id: `Static` alone accounts for 5031 placements under **1992 different graphics**, so a bare type name places the wrong thing. Containers are refused — teardown would delete what a player left inside. |
| Named, hued creatures from an allowlist | ✅ **built (protocol 7)** | 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. Phase 12a; the ledger is a PERSISTED shard-side registry, because a spawned creature survives the restart that proves a crier line gone. |
| "Simple" boss variants | ✅ **built (protocol 7)** | An enhanced regular mob, capped at 24. 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. Phase 12a: an allowlisted type plus hits/damage/stat multipliers, each bounded. |
| **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`, already persisted across saves. **Corrected in Phase 12a's survey:** `EventStatus` is a **three**-value enum (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries. So the verb is a three-state toggle over nine named events — a different form to author and to cap than one nine-way choice. Phase 12b. |
| Trigger a world save | 🔧 📡 | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built. `AutoSave.Save()`; emits the `world.save.*` events already streamed. Useful as a phase boundary. |
| **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 12b closed it at that one key and built the self-check anyway.** Counted on ServUO 57.4: of 156 non-Bridge `Config.Get` call sites, 82 sit outside a field declaration and all but four of *those* are inside a `Configure()` or static constructor, cached at boot exactly as the field initialisers are. The genuinely live, event-useful reads are this key and vendor bribe decay, which no event would lease. The self-check ships regardless — it exists for the operator whose OWN scripts read config live, and it is what keeps a capability that lies out of the catalog. |
| Lease a property on an existing object | **built (protocol 7)** | Practical, and an earlier revision was wrong to rule it out. Phase 12b: an allowlist of (type, property) pairs — `MaxCount`, `MinDelay`, `MaxDelay`, `Running`, on both `Spawner` and `XmlSpawner`, which share all four names — addressed by a serial or by an `XmlSpawner.UniqueId`. **`Spawner.Amount` does not exist**; the plan named it and the property is `MaxCount`. The hold is PERSISTED, unlike a config lease's: a spawner is in the world save, so a restart preserves the change and destroys only the timer that would undo it. The one real hazard — a GM editing the same property mid-event — is answered by compare-and-set restore and the `drifted` state, and this is the first lease a GM can actually drift. |
| Grant an event item | **built (protocol 7)** | An ordinary action, not a special contract member. Admin-gated, capped by `uo.rewards`, ledgered, and `reversible: 'none'` *for UO specifically* — an object in a backpack cannot be recalled. Phase 12b resolves the RECIPIENTS on the shard, from protocol 6's participation ledger, keyed by the same serials core stores as `member_key`: the website has the list too, but reaching it would have meant a core surface handing a module core's own participants. **This row said failure aborts rather than retries; protocol 6 changed that** — an idempotency key means a retried grant is answered by the original reply, so it is retryable like every other verb. |
| Toggle a ServUO seasonal event | **built (protocol 7)** | **Corrected twice.** `EventStatus` is a **three**-value enum (`Inactive`, `Active`, `Seasonal`); it is `EventType` that has nine entries — so the verb is a three-state toggle over named events. And it is not "small and safe": the setter fires `OnStatusChange()`, which generates or removes world content for six of them. Safe (ServUO does it to itself from a staff gump), but not small. **Eight of the nine**: `TreasuresOfTokuno` is excluded because `IsActive()` reads its own `DropEra` rather than `Status`, so a lease on it would apply cleanly, read back, restore cleanly and do nothing — §N10's "capability that lies", and the one instance no runtime probe can catch. |
| Trigger a world save | **built (protocol 7)** | Catalogued Tier B in `ADMIN_CONTROLS.md` §3.6, never built until Phase 12b. `AutoSave.Save()`; the `world.save.*` events it emits are already streamed, so the reply says only that the save STARTED. A save stops the world, so it is RATE-LIMITED rather than capped — `Bridge.EventsMinSaveIntervalSec`, counting ServUO's own autosave as the last one, refusing rather than queueing, and answering 429 because it is the one refusal on this plane that waiting fixes. |
| Idempotent command application | ✅ **built (protocol 6)** | Phase 11a, and deliberately ahead of every verb below it rather than alongside one. A key is executed at most once; a repeat gets the original reply. Its immediate dividend is that `uo.broadcast` stopped being un-retryable — Phase 9 had to answer `retry: false` even to a 503 from a restarting shard, because a lost ack and a command that never applied were the same event. |
| Loot-table changes | ⛔ | Per-creature-type and shard-wide, so it cannot be scoped to one run at all — the one thing on this list a lease genuinely cannot express. Attach loot to an event-owned creature template instead. |
| Arbitrary `[set` / `[get` / `[add` | ⛔ | `ADMIN_CONTROLS.md` §8's exclusion of these should survive the reopening unchanged. Sharp, privilege-escalating, and expressible only as "trust the caller" — which is precisely a change with no baseline, no cap and no ledger entry. |
**What the reopening actually costs.** Nine new plugin verbs and **two** protocol bumps: 6
for the idempotency key, the lease deadline and the participation ledger, and 7 for the world
verbs themselves. (One bump was the estimate; the guarantee had to land before the verbs that
depend on it, which is [`../link/v6.md`](../link/v6.md) §1's whole argument.) Bounded work, and every verb is
cap-bounded, ledgered and attributable by construction. **None of it is `[add`.** The distinction that
makes it defensible: an event may **own** what it creates and **borrow** what it changes — and a
borrowed value carries a deadline the game itself enforces. What it may never do is change something
with no baseline recorded, which is exactly what `[set` is.
**What the reopening actually cost.** Two protocol bumps — 6 for the idempotency key, the lease
deadline and the participation ledger, 7 for the world verbs and the two borrowed planes. (One bump
was the estimate; the guarantee had to land before the verbs that depend on it, which is
[`../link/v6.md`](../link/v6.md) §1's whole argument.) Bounded work, and every verb is cap-bounded,
ledgered and attributable by construction. **None of it is `[add`.** The distinction that makes it
defensible: an event may **own** what it creates and **borrow** what it changes — and a borrowed
value carries a deadline the game itself enforces. What it may never do is change something with no
baseline recorded, which is exactly what `[set` is.
**One thing the survey changed about "borrow".** A borrowed value whose home is the world save — an
object property, a seasonal status — does not come back on a restart the way a config value does, so
its HOLD is persisted on the shard and its deadline re-armed at load. The fail-safe still holds; it
just has to be written down for the planes where a restart is not itself the restore.
---

View File

@@ -1440,6 +1440,8 @@ anywhere in it.
#### Phase 12a — what an event OWNS
**Built and merged to `edge`**`servuo-plugins#23`, `link#38`, `Module-uo#31`, `docs#221`.
Five verbs that put something in the world, ledger its serial, and delete it at teardown.
| Verb | Cap dimension | Author's action |
@@ -1540,6 +1542,76 @@ caller in the whole tree.
three-state toggle over nine named events, which is a different form to author and a different one
to cap.
**Built**`website#—`, `servuo-plugins#—`, `link#—`, `Module-uo#—`, `docs#—`, all onto `edge`.
Spec of record: [`../link/v7.md`](../link/v7.md) §11§14.
**It is FIVE repos, and `website` is the one 12a did not need** (org lead, 2026-09-07: *"you can do
the 5 repos and no API bump since it is still on edge"*). A targeted lease is a shape `core.lease`
did not have. Every lease before it named a single value, so the lease id WAS the target and none of
the four callables took one; `Spawner.MaxCount` is one capability over thousands of spawners, and a
reservation on the id alone would let one run turning up one spawner refuse every other run every
other spawner. So the declaration gains a `target`, the callables gain it, and the ledger ref becomes
`<lease id>#<target>` — which puts the two-events-one-target refusal at the granularity the world
actually has, and leaves it coming from the same unique index it always did.
**Extending core rather than giving the module a lease verb of its own is what §F already decided**
(Phase 8, *"the verb is core's"*): a lease verb per module would re-implement `maxDurationMs` and the
conflict check once per module, advisory everywhere and wrong in the first one that forgot. Half of
that objection no longer holds — the target check comes free from the index whichever verb reserves
the row — and the other half still does. **`MODULE_API_VERSION` stays 1.10.0, amended in place**, the
shape every phase since P10 has used while this workstream sits on `edge`.
Two more contract members came with it, both forced by this phase rather than chosen: **`values` on a
`string` lease** (the seasonal status is a three-value enum and nothing bounded `string`, so its only
check was the game side's — a refusal arriving unattended, mid-run) and **searchable option sources**
(see below).
**The spawner dropdown was the phase's one genuine blocker, and the answer was a core change** (org
lead, 2026-09-07). `resolveOptionSource(id)` took no argument and every source answered a flat list
bounded at 2,000. This tree has **6,707 spawn points**, so a flat list would have dropped two thirds
of the world and said nothing about which two thirds — the exact failure 12a named for decoration,
arriving for real. `resolve({ q })` is additive: every source is passed a term, none is required to
read one, and a `searchable` flag says which do. The atlas also keeps `<UniqueId>` again (parser
version 4), because it is the only name for one particular spawner that exists off the shard — a
serial is assigned when the world is built, so a lease addressable only by serial could have had no
dropdown at all.
**Three plan assumptions the tree disproved.**
- **`Spawner.Amount` does not exist.** The property is `MaxCount`, and `MinDelay`/`MaxDelay` are
`TimeSpan` rather than numbers, so the wire carries seconds. `Spawner` and `XmlSpawner` share all
four names, which is why one catalog covers both — and why a catalog naming only one of them would
have worked until the day it did not.
- **A property lease's hold must be PERSISTED**, and the config lease's must not. 11b's fail-safe is
*"a lease that never reaches disk means a restart is a free restore"*, which depends on the leased
value being memory-only too. A spawner is in the world save and a seasonal status is in
`Saves/Misc/SeasonalEvents.bin`, so a restart preserves the CHANGE and destroys only the timer that
would undo it. The Bridge gains its **third** save file, written by the same `EventSink.WorldSave`
as what it describes; a deadline that passed while the shard was down fires at once.
- **The seasonal toggle is not "small and safe", and one of the nine lies.** `OnStatusChange()`
generates or removes world content for six of them — safe, since ServUO does it to itself from a
staff gump, but not small. And `TreasuresOfTokuno` is excluded: `IsActive()` reads its own
`DropEra` rather than `Status`, so a lease on it applies cleanly, reads back, restores cleanly and
does nothing at all. That is §N10's "capability that lies" in its purest form and the one instance
no runtime probe can catch, so it is excluded by name at both ends.
**Who receives a grant is answered on the SHARD.** The website has the list 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: protocol 6 part b's
participation ledger already holds them, keyed by the same character serials core stores as
`member_key`. A run with no ledger open is a 404; a run whose ledger is open and empty is a 200 with
`granted: 0`, because an event nobody attended still happened. And **the grant is retryable** — §G
called it un-retryable before protocol 6 existed, and an idempotency key means a repeat is answered
by the original reply.
**One defect in already-merged code, and it would have broken everything.** The website's protocol
pin never left **5**: `uo_link_config.protocol` reaches the sidecar as `X-UOLink-Version` and an exact
mismatch is a `409`, so on any real deployment every sidecar call would have been refused from Phase
11a onward. It survived two phases because both live walks set the column by hand while standing the
rig up. 12b carries all three declaration sites to 7. The test that guards them asserts they agree
WITH EACH OTHER, which is a real check they once failed — but all three being equally stale passes
it, and nothing in `module-uo` can anchor it to the wire.
**Ships:** the invasion.
**Verify:** the whole rig, running a real multi-phase event with spawns, a lease, and a full teardown
back to baseline — plus a deliberate mid-event GM edit of a leased property, confirming `drifted`

View File

@@ -103,6 +103,12 @@ ctx.events.reconcile()
api.registerEventOptionSources([{
id: 'uo.options.creatures', label: 'Creatures',
async resolve() { return [{ value: 'Orc', label: 'Orc', group: 'Humanoid' }] },
}, {
// A catalog bigger than a dropdown holds. Core passes `q` to EVERY source and
// requires it of none, so a resolver that ignores it is unchanged; `searchable`
// is what tells the authoring form to render a typeahead rather than a select.
id: 'uo.options.spawners', label: 'Spawners', searchable: true,
async resolve({ q } = {}) { return search(q).map((r) => ({ value: r.id, label: r.name })) },
}])
// A value a run may borrow. The module ships the three callables; the VERB an
@@ -115,6 +121,28 @@ api.registerEventLeases([{
async apply(v, until) { return { ok: true } },
async restore(baseline, { expected }) { return { ok: true } },
async inForce() { return { ok: true, held: true } }, // optional
}, {
// A TARGETED lease: one capability over many things. Core adds the target to
// the reservation ref (`<lease id>#<target>`) so two runs may hold the same key
// on two different objects, and hands it to all four callables.
id: 'uo.spawner.maxcount', label: 'Spawner: how many at once',
type: 'int', min: 0, max: 100, maxDurationMs: 43200000,
target: { label: 'Which spawner', source: 'uo.options.spawners' },
async read({ target }) { return { ok: true, value: '3' } },
async apply(v, until, { target }) { return { ok: true } },
async restore(baseline, { expected, target }) { return { ok: true } },
async inForce({ target }) { return { ok: true, held: true } },
}, {
// A string lease may close its value set. `min`/`max` bound the numeric types
// and nothing bounded `string`, so without this the only check on the value is
// the game side's -- a refusal arriving unattended, mid-run, rather than on the
// authoring form.
id: 'uo.seasonal.status', label: 'Seasonal event status',
type: 'string', values: ['Inactive', 'Active', 'Seasonal'], maxDurationMs: 43200000,
target: { label: 'Which seasonal event', source: 'uo.options.seasonal' },
async read({ target }) { return { ok: true, value: 'Inactive' } },
async apply(v, until, { target }) { return { ok: true } },
async restore(baseline, { expected, target }) { return { ok: true } },
}])
```
@@ -133,6 +161,23 @@ field:
- **`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.
- **A lease that declares a `target` is a family of values, and core changes what it reserves.**
Without one, the lease id *is* the target and the ledger reserves it alone — which is right for a
config key and wrong for a property, because `Spawner.MaxCount` is one capability over thousands
of spawners and reserving the id would let one run turning up one spawner refuse every other run
every other spawner. With one, the ref is `<lease id>#<target>`, the two-events-one-target index
bites at the granularity the world actually has, and the target reaches all four callables.
**Core refuses a targeted lease with no target and an untargeted one with a target**, both
`retry: false`: the second attempt has the same params. `target.source` names an option source
for the authoring form, and is not resolved by core at registration — a source registered by a
module that boots later must not make this one throw.
- **`values` closes a `string` lease's set, and belongs to no other type.** `min`/`max` bound the
numeric types; a set on an int lease would be a second bound beside them with no rule about
which wins, so it is refused.
- **A source is passed `{ q }` and may ignore it.** Additive: a resolver written before this
existed behaves identically. Declare `searchable: true` when the term actually narrows the
answer — the form reads that to decide between a typeahead and a select, and inferring it from a
truncated list would read correctly right up until a small deployment's list happened to fit.
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
@@ -315,6 +360,38 @@ Two members joined it in Phase 10, both on an envelope:
envelope rather than in `events/` because "this particular firing is narrower than the kind
usually is" is a fact any emitter can have.
Three more joined it in **Phase 12b**, all on declarations rather than envelopes, and all amended
into 1.10.0 in place for the reason the two above were: 1.10.0 has never reached `main`, so there
is no deployment that could tell the difference, and the events cutover is what publishes the
whole of it. `module-uo`'s `coreApi` is unaffected; the integration kit is already red on purpose
and stays so until the cutover re-pins `ci/core-ref.json`.
- **`target` on a lease declaration** (`EVENTS.md` §F, `link/v7.md` §11). A lease with one is a
FAMILY of values rather than a single value, and core reserves `<lease id>#<target>` rather than
the id — so two runs may hold the same key on two different objects while two runs holding one
object still collide on the unique index. The target reaches `read`, `apply`, `restore` and
`inForce`. Every lease before this named one value, so the id *was* the target and none of the
four needed an argument; a property does not have that shape.
**The verb stays core's**, which is the whole reason this is an extension rather than a lease
verb of the module's own. §F settled that in Phase 8: a lease verb per module would
re-implement `maxDurationMs` and the conflict check once per module, advisory everywhere and
wrong in the first one that forgot. Half of that objection no longer holds — the
two-events-one-target refusal comes from the ledger's unique index whichever verb reserves the
row — and the other half still does.
- **`values` on a `string` lease.** The closed set an author may choose from, checked by
`core.lease` at authoring time. `min`/`max` bound the numeric types and nothing bounded
`string`, so the only check on a string lease's value was the game side's — a refusal arriving
unattended, mid-run, from a step nobody is watching. Refused on any other type: a set beside
`min`/`max` would be a second bound with no rule about which wins.
- **`searchable` on an option source, and `{ q }` passed to every `resolve()`.** A source whose
catalog is larger than a dropdown can hold narrows its answer by the term; one that ignores the
argument answers exactly as it did before this existed, which is what makes it additive. The
first source that needed it is `module-uo`'s spawner target — 6,707 spawn points against the
2,000-entry bound — and a truncated list is not an answer: it drops most of the world and says
nothing about which part. `searchable` is declared rather than inferred, because inferring it
from a truncated answer reads correctly right up until a small deployment's list happens to fit.
**1.7.0 — the engagement contract** (`website/ENGAGEMENT.md` Phase 2). Four additions, no removals
and no changed signature, so minor; `module-uo`'s `coreApi: "^1.3.0"` still resolves.
`api.registerEventTriggers([...])`, `api.registerAudiences([...])` and
@@ -664,8 +741,8 @@ 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, inForce }]) // 1.10.0
api.registerEventOptionSources([{ id, label, resolve }]) // 1.10.0
api.registerEventLeases([{ id, label, type, min, max, values, target, maxDurationMs, read, apply, restore, inForce }]) // 1.10.0
api.registerEventOptionSources([{ id, label, searchable, resolve }]) // 1.10.0
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```