docs(link): protocol 7 -- what an event owns, and the split that carries it

`link/v7.md` is the spec of record: one command family for five verbs, the
persisted ownership registry and why it is forced rather than chosen, the oracle
that reuses ServUO's own dialogue vocabulary without its command-scripting
field, and the decoration index.

`EVENTS_PLAN.md` splits Phase 12 into 12a and 12b (org lead, 2026-09-07), on the
line section G already draws between what an event OWNS and what it BORROWS. Ten
verbs, four repos and a protocol bump is one review and one walk too many, and
the halves prove different things. One protocol version across both, the 11a/11b
shape.

Three corrections the survey forced on `EVENTS.md`:

The config lease catalog does NOT grow in 12b, and the plan's promise of "the
rest of the allowlist" was written before anyone counted. Measured on ServUO
57.4: 156 non-Bridge `Config.Get` call sites; 82 sit outside a field
declaration, but all but four of those are inside a `Configure()` or a static
constructor and are cached at boot exactly as the field initialisers are. The
live, event-useful reads are `PlayerCaps.SkillCap` (11b shipped it) and vendor
bribe decay. So 12b's lease work is object-property leases.

Section G calls the seasonal toggle "a nine-value enum". `EventStatus` has THREE
values; it is `EventType` that has nine entries. The verb is a three-state
toggle over nine named events, which is a different form to author and to cap.

Section G costed the reopening at one protocol bump. It is two: the idempotency
guarantee had to land before the verbs that depend on it, which is v6's own
argument.

`README.md` gains rows for v6 and v7. v6's was missing -- the index has been one
protocol behind since 11a.

Refs: EVENTS_PLAN.md Phase 12a

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 01:52:34 -05:00
parent 5fe4305502
commit c53cf9fce5
4 changed files with 415 additions and 20 deletions

View File

@@ -66,6 +66,8 @@ particular game; a module is what makes it a site *for* one.
| [v3.md](link/v3.md) | Protocol 3.0 design — shard content/standings streams + the visibility framework |
| [v4.md](link/v4.md) | Protocol 4.0 — guild membership on the wire (`guild.roster`, `guild.leave`) |
| [v5.md](link/v5.md) | Protocol 5 — three enrichments in one bump: `house.decay`'s decay schedule, `vendor.listing`'s fee state, and `account.login.result`. **The current protocol**, shipped 2026-09-01 as bundle 2026.09.01 (sidecar v2.1.0 + overlay v1.1.0) |
| [v6.md](link/v6.md) | Protocol 6 — idempotent commands, config leases with a shard-side deadline, and the run-scoped participation ledger. On `edge`; reaches `main` at the events cutover |
| [v7.md](link/v7.md) | Protocol 7 — the world verbs an event OWNS: creatures, bosses, oracle NPCs, temporary gates, decoration, and the persisted ownership registry behind them. On `edge` |
| [ADMIN_CONTROLS.md](link/ADMIN_CONTROLS.md) | Staff write-plane (kick/ban/broadcast, page queue) |
| [SHARD_PREREQS.md](link/SHARD_PREREQS.md) | Shard-side prerequisites for the bridge |
| [PLAN.md](link/PLAN.md) | uo-link build plan |

295
link/v7.md Normal file
View File

@@ -0,0 +1,295 @@
# 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)
**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.
---
## 1. The payload protocol 6 was the floor for
[`v6.md`](v6.md) §1 said it plainly: *"Protocol 12's world verbs are the payload; protocol 6 is the
floor they stand on."* This is that payload.
Five verbs an event author sees — creatures, an enhanced "boss", an oracle NPC, a temporary gate,
decoration — and every one of them is the same sentence underneath:
> **An object exists in the world, and this run owns it.**
Ownership is the whole of why [`EVENTS.md`](../website/EVENTS.md) §G can exclude arbitrary `[add`
and `[set` and then permit all of this in the same table. The rule it draws is:
> An event may **own** what it creates and **borrow** what it changes, and may never change
> something with no baseline recorded.
12a is the first half. 12b is the second.
---
## 2. One command family, not five
| Command | Answers | |
|---|---|---|
| `world.spawn` | `world.ok` | Places `count` of `what` at a map/x/y; replies with the serials |
| `world.despawn` | `world.ok` | `removed` / `gone` / `refused`, per serial |
| `world.owned` | `world.owned.ok` | What the run still owns, pruning dead rows as it walks |
Plus one unsolicited frame, `world.expired`, when the shard collects something on its own.
**`what` is a field, not five kinds** (org lead, 2026-09-07). The per-verb differences — a boss's
multipliers, an oracle's lines, a gate's destination and deadline — ride alongside the discriminator
rather than justifying their own command. That buys one ledger shape, one teardown path and one
reconcile instead of five near-identical ones across three repos, and it costs nothing an author can
see: `module-uo` still declares **five actions**, because five is what they are. The discriminator
is a wire detail, and a dropdown of `what` would be a worse authoring form than five named steps.
```json
{"kind":"world.spawn","reqId":"r-4","runId":"77","idempotencyKey":"…",
"what":"creature","map":"Felucca","x":1496,"y":1628,
"count":8,"type":"Orc","name":"Rotting Orc","hue":1157,"spread":6}
```
```json
{"kind":"world.ok","reqId":"r-4","action":"spawn","runId":"77","what":"creature",
"placed":8,"serials":["0x40001A2B","…"],"owned":8}
```
Serials cross as `"0x…"` text, matching every other serial this bridge writes.
---
## 3. The ownership registry, and why it is persisted
**This is the design decision of the phase, and it was forced rather than chosen.**
Start from what is already known. A spawned creature is a **world object**, so unlike a town-crier
line it *survives a restart*. That alone settles one question: `reconcileByBootId` — the trick
Phase 9 uses for the crier and the news gump, where a changed `bootId` **is** proof the thing is
gone — is wrong here. It would report gone the one class of resource still standing in Britain.
Reconcile has to ask.
The harder question is where the record of *which run owns which serial* lives, and the two obvious
answers are both wrong:
- **In memory on the shard.** Lost in the restart the creatures themselves survive. The website then
holds serials the shard will not vouch for, and thirty orcs stand in Britain until somebody
deletes them by hand.
- **Only in the website's ledger.** Then it is not held on the shard at all, and `world.despawn`
would have to delete whatever serial it was handed. *"An event never touches a creature it did not
create"* — the sentence the boss verb is built on — would be an intention with no mechanism behind
it, and a bug in a step or a stolen sidecar token would be a delete-anything primitive.
So the Bridge gains its **second** persisted file, `Saves/Bridge/Owned.bin`, beside 11b's
`Participation.bin`. Same mechanism (`Server.Persistence` plus `EventSink.WorldSave`), same rule
about attaching the hooks in `Configure()` rather than `Initialize()``EventSink.WorldLoad` fires
*inside* `World.Load()` — and the same unconditional attachment ahead of the enabled gate, so an
operator who switches the plane off for an afternoon does not come back to a truncated registry and
a world full of creatures nothing admits to owning.
### 3.1 The registry and the objects cannot get out of step
Worth stating because a reader will reach for it as an objection: if the registry were lost while
the objects survived, `world.owned` would answer with an empty hand and the website would orphan
live creatures.
It cannot happen. The registry is written by `EventSink.WorldSave`**the same save** that writes
the objects it describes. They are saved together and lost together. That is what makes "anything
the shard does not list is gone" a safe reading rather than a hopeful one, and it is why
`world.owned` on a run the shard has never heard of is an **empty list and a 200**, not a 404:
*"this run owns nothing"* and *"I have never heard of this run"* are the same fact once the registry
is the only record of ownership.
### 3.2 Three answers to a despawn, and only one is a failure
| | Meaning |
|---|---|
| `removed` | Owned by this run, found, deleted. |
| `gone` | Owned by this run and already absent. **A success.** |
| `refused` | Not this run's to delete. The row stays; nothing is touched. |
**`gone` being a success is not a technicality.** Players kill event creatures — that is what
spawning them is *for*. §L already settles it (*"gone, and that is fine"* is a successful revert),
and a run that ended `incomplete` because its event worked would be a report nobody could read.
`refused` is the only answer here that means somebody asked for something they should not have, and
it is reported back to core as a per-row `failed` so the row lands unresolved **with a reason**
rather than being quietly marked reverted. Nothing will ever delete it through this path.
---
## 4. The oracle is ours, and ServUO's own dialogue engine is why
ServUO ships a complete dialogue system in `XmlSpawner2.XmlDialog`, and its `SpeechEntry` is the
evidence that the shape this verb wants is right rather than invented:
| `XmlDialog.SpeechEntry` | This verb |
|---|---|
| `Text` | the line |
| `Keywords`, comma-separated | the keywords |
| an entry with **no** keywords fires automatically | the greeting |
| `defProximityRange = 3` | `Bridge.EventsOracleGreetRange` |
| `LockConversation` | a per-player cooldown |
| **`Action` — XmlSpawner's command-scripting language** | **nothing** |
That last row is the whole reason not to build **on** it. Routing authored dialogue through
XmlDialog would leave an arbitrary-command field one field away from an event author on a web form:
the `[set` §G excludes, arriving through the back door, in a subsystem this overlay does not own and
an operator can switch off.
What the verb actually needs are two native virtuals on `Server.Mobile`:
- **`OnMovement(m, oldLocation)`**, delivered to **every** mobile in range. The `HandlesOnMovement`
filter applies only to *Items*`Server/Mobile.cs:3369` against `:3375` — so a greeting hook is
free. The old location is compared as well as the new one, which makes it fire once per approach
rather than on every step of a passer-by.
- **`HandlesOnSpeech(from)``OnSpeech(e)`** (`Server/Mobile.cs:5150`), the keyword hook.
Nothing executable crosses the wire: keywords and text.
**The oracle cannot be killed, moved or looted.** `CanBeDamaged()` is false as `TownCrier`'s is, and
it is `Blessed`, `Frozen` and `CantWalk`. An event NPC a player can drag out of the venue is one
that stops being where the run's ledger says it is, and teardown deleting something that has
wandered two screens away is worse than it not moving.
**Keywords are lower-cased on the website side, not at match time.** The comparison on the shard is
then ordinal, between two strings already in the same case — culture-aware casing at match time
would make an oracle answer differently on a Turkish shard.
---
## 5. Decoration comes from the shard's own decoration files
`Data/Decoration/**/*.cfg` names every item type the shard already uses as scenery, with its item
id. The atlas build indexes them, so the authoring dropdown is derived from the operator's own tree,
resolves with the shard down, and is *decoration* by the shard's own definition rather than by ours.
Two measurements shaped the wire:
- **120 files, nested two deep** (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`, `Old/Britannia`), so
the read is recursive. A flat read would index a fraction of it while looking like it worked —
the failure being a dropdown quietly missing whole expansions rather than an error anyone notices.
- **313 distinct types, and the item id is not decoration.** `Static` alone accounts for **5031 of
the placements under 1992 different graphics**, because for that class the graphic *is* the
identity: a bare `new Static()` is never the switch or the paving stone the author picked. 131 of
the 313 types carry more than one id (a door has one per facing).
So `world.spawn` carries an optional **`itemId`** for decoration, resolved on the website from the
atlas row rather than typed by an author — which also means the verb places only what this shard's
decoration files actually name, a tighter boundary than "any item that is not a container".
**The id is never applied to a `BaseAddon`.** An addon is a group of components and its own `ItemID`
is not what a player sees; writing a graphic over it would leave a stone oven rendering as one
arbitrary tile of itself. Addons construct with the right appearance already.
**Containers are refused outright.** Deleting one at teardown would delete whatever a player had
left inside it. Everything else here is safe to delete because the event made it; a container's
contents are not.
---
## 6. Ceilings refuse, they do not clamp
`Bridge.EventsMaxCreatures` (30), `EventsMaxBosses` (4), `EventsMaxNpcs` (5), `EventsMaxDecor` (60),
`EventsMaxGateMinutes` (240), `EventsMaxOwnedPerRun` (200), `EventsMaxSpread` (40),
`EventsMaxBossMultiplier` (10.0), and the oracle's own five. Defaults are the EM Program's published
quotas, because they are the only numbers anyone has defended in public.
These are the shard's **independent** bounds, not mirrors of the module's budget dimensions —
`Bridge.LeaseMaxDurationSec`'s argument from [`v6.md`](v6.md) §8.2 unchanged: the bound exists for
the case where the website is wrong, and being loud about it is its whole value. A refusal is a
**400**, and permanently so: retrying *"you asked for 80 creatures and this shard places 30"* gets
the same answer forever, so a module that classified it as transient would put a run in a loop
against a limit that will never move.
`EventsMaxOwnedPerRun` is the one that is not a per-call ceiling. The others bound one request; that
one bounds a run calling a verb in a loop, which is the shape a runaway schedule actually takes.
**The gate deadline lives on the shard**, for [`v6.md`](v6.md) §8.3's reason about leases: a gate the
shard closes by itself closes whether or not the website is ever heard from again. A run whose
engine died leaves a world that comes back early rather than one stuck open. And it crosses as a
**duration** (`holdMs`), never an absolute time — an absolute deadline is measured against two
clocks, and a shard ten minutes fast would collect the gate the instant it opened.
**`Bridge.EventsEnabled` gates all of it** (org lead, 2026-09-07). Spawning creatures and opening
gates is the same consent 11b introduced that switch for — unattended, scheduled world change — not
a third one. A switch per verb family is a setting nobody can reason about.
---
## 7. Visibility
`world.expired` is **deliberately unmapped**, exactly as `lease.applied` and `lease.expired` are and
for the reason [`v6.md`](v6.md) §4 gives: it is an operational record of the *website* changing this
shard's world, and rule 2 fails an unmapped kind closed to admin-only, which is where an audit trail
of the site's own writes belongs. Mapping it would mean choosing a feature an operator could then
widen, and there is no rung below admin it belongs on.
**A spawn emits no stream frame at all**, and the asymmetry is deliberate. A reply correlated on
`reqId` already tells the website everything it needs, and eight creatures placed in one call would
otherwise be eight frames announcing what the caller already knows. `world.expired` exists precisely
because it is the *only* thing here that happens without the website asking.
---
## 8. One version, two halves
Phase 12 is split. **12a bumps `PROTOCOL_VERSION` to 7; 12b amends 7 in place** on `edge` — the
11a/11b shape, and it applies for the same reason: 7 will not reach `main` until the events cutover,
so the bundle CI never sees two numbers.
It carries the same hazard [`v6.md`](v6.md) §7 states, and it is worth restating rather than
assuming anyone will follow the link: **an overlay and a sidecar both declaring 7 are interchangeable
only within one side of the 12b merge.** That is tolerable for one reason and no other — nothing is
released from `edge`. It would not be tolerable on `main`.
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.
---
## 9. Cross-repo obligations
| Repo | Change |
|---|---|
| `servuo-plugins` | `BridgeWorld.cs` (registry, three handlers, the sweep), `BridgeOracle.cs`, the `EventsMax*` keys, `overlay.toml` → 7 |
| `link` | `POST /world`, `GET /world/:runId`, `POST /world/:runId/despawn`; `PROTOCOL_VERSION` → 7 |
| `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.
---
## 10. Verification
The whole rig: a run that spawns one of each of the five, a restart mid-run proving the ownership
registry survives it and reconcile still answers, a creature killed by a player proving `gone` is an
ordinary teardown outcome, and a despawn **refused** for a serial the run does not own.
### 10.1 What the build already found
**An action must be performable from its own required params, and `uo.npc.place` was not.** Both
ends refuse an oracle with neither a greeting nor a line — it would stand there in silence — but
both fields were declared optional, so the verb could not be performed from its own required set and
no authoring form could render it as valid either. A cross-field *"at least one of these"* rule is
the wrong shape for a declaration core reads as data. The greeting is now `required`, which says the
same thing in the contract itself; the `perform()` check remains, for the field holding nothing but
spaces.
It was caught by `module-uo`'s existing dry-run sweep — a test that performs every action with only
its required params — which is a better argument for that test than anything written about it when
it shipped.
**The creature option source answered with something unusable.** `uo.options.creatures` shipped in
Phase 9, before anything consumed it, carrying the atlas **slug**: unique, stable, and not a thing
the shard can build, because a creature is constructed from a ServUO class name and `orc-brute` is
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.

View File

@@ -1122,22 +1122,24 @@ a capability exists.
| 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** | ✅ 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 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. |
| Oracle NPC with scripted dialogue | **built (protocol 7)** | 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. Phase 12a: a greeting on approach plus keyword rows, on `Mobile.OnMovement` and `Mobile.OnSpeech`. Built rather than layered on `XmlSpawner2.XmlDialog`, which implements exactly this vocabulary **and an `Action` command-scripting field** — the `[set` this table excludes, one field away from an author. |
| Temporary gate to a venue | **built (protocol 7)** | 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. Phase 12a; the deadline is the SHARD's, so a gate closes whether or not the website is heard from again — and it crosses as a duration, never an absolute time. |
| 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` over a nine-value enum, already persisted across saves. |
| 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. |
| 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, one protocol bump carrying an
idempotency key and a lease deadline, and a participation ledger. Bounded work, and every verb is
**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

View File

@@ -1423,26 +1423,122 @@ reachable.
### Phase 12 — UO wave 2: the world verbs (`servuo-plugins` + `link` + `module-uo` + `docs`)
**§N1 answered 2026-09-01 — no longer gated**, and taken in full, so the item grant row below
stands. The capability set the two UO programs demonstrate, each cap-bounded, ledgered, and
either owned or borrowed:
stands.
**Split into 12a and 12b (org lead, 2026-09-07)**, on the line §G already draws: what an event
**owns** and what it **borrows**. Ten verbs, four repos and a protocol bump is one review and one
walk too many, and the two halves prove different things — 12a proves a run can put things in the
world and get all of them back, 12b proves it can change something it did not create and give that
back unchanged. They land as **one protocol version**: 12a bumps to **7**, 12b amends 7 **in place**
on `edge`. That is the 11a/11b shape, and it carries the same hazard §7 of `v6.md` states — an
overlay and a sidecar both declaring 7 are interchangeable only within one side of the 12b merge —
tolerable for the same single reason and no other: nothing is released from `edge`, so the bundle CI
never sees two meanings of 7.
`installer` is not in this phase, for the reason Phase 11 records: no protocol version is hardcoded
anywhere in it.
#### Phase 12a — what an event OWNS
Five verbs that put something in the world, ledger its serial, and delete it at teardown.
| Verb | Cap dimension | Author's action |
| --- | --- | --- |
| Named, hued creatures from the atlas's ~800 constructible types | `uo.creatures` | `uo.creature.spawn` |
| "Simple" boss variants, as event-owned creature templates | `uo.bosses` | `uo.boss.spawn` |
| Oracle NPCs with scripted dialogue | `uo.npcs` | `uo.npc.place` |
| Temporary gates | `uo.gate.minutes` | `uo.gate.open` |
| Temporary decoration | `uo.decor` | `uo.decor.place` |
**One command family, five author verbs** (org lead, 2026-09-07). Every row above ends in "an object
exists and this run owns it", so the wire carries `world.spawn` / `world.despawn` / `world.owned`
with a `what` discriminator, and the per-verb differences — a boss's stat multipliers, an oracle's
lines, a gate's target and deadline — are **fields rather than kinds**. One ledger shape, one
teardown path, one reconcile, instead of five near-identical ones in three repos. An *author* still
sees five verbs, because five is what they are: the discriminator is a wire detail, and a dropdown of
`what` would be a worse form than five clearly-named steps.
**The caps are the module's, never core's** (org lead, 2026-09-07). `uo.creatures` and the rest are
declared by `module-uo` through `registerEventBudgets`, exactly as `uo.broadcasts` already is; core
meters whatever dimensions a module declares and holds no UO knowledge — which is the whole of what
§F means by game-agnostic. The shard additionally carries its own `Bridge.EventsMax*` ceilings and
**refuses rather than clamps**, on `Bridge.LeaseMaxDurationSec`'s argument from 11b unchanged: the
shard's bound exists for the case where the website is wrong, and being loud about it is its value.
**Ownership is persisted, and that is forced rather than chosen.** A spawned creature lives in the
world save, so unlike a crier line it *survives* a restart — which already means
`reconcileByBootId` is wrong here, for the reason it was wrong for the participation ledger, and
reconcile has to ask. But the record of *which run owns which serial* has nowhere else to live. Held
in memory it is lost in the restart the creatures survive, orphaning them. Held only in the website's
ledger it is not held on the shard at all, so `world.despawn` would delete whatever serial it was
handed — and "an event never touches a creature it did not create" is the sentence the boss verb is
built on. So the Bridge gains its **second** persisted file, beside `Participation.bin`.
**The oracle is our own, and ServUO's own dialogue engine is the reason for both halves of that.**
`XmlSpawner2.XmlDialog` already implements exactly the vocabulary this verb wants — `Text` plus a
comma-separated `Keywords` list, an entry with no keywords being the greeting, a proximity range, a
per-player conversation lock — which is evidence the shape is right rather than invented. It is
also the reason not to build **on** it: `SpeechEntry` carries an `Action` string, XmlSpawner's
command-scripting language, and routing authored dialogue through XmlDialog would leave an
arbitrary-command field one step from an event author. That is the `[set` §G excludes, arriving by
the back door, in a subsystem we do not own and a shard can switch off. `Mobile.OnMovement`
(delivered to **every** mobile in range — the `HandlesOnMovement` filter applies only to Items,
`Server/Mobile.cs:3369` against `:3375`) and `Mobile.HandlesOnSpeech`/`OnSpeech` are native virtuals
and are the whole of what the verb needs.
**Decoration comes from the shard's own decoration files.** `Data/Decoration/**/*.cfg` names every
item type the shard already uses as decoration, with its item id (`LargeCrate 0x0E3C`). The atlas
build indexes them, so the dropdown is derived from the operator's own tree and resolves with the
shard down — and the list is "decoration" by the shard's own definition rather than by our taste.
The plugin validates the type independently, because it cannot trust the website.
**Ships:** an event can populate a venue — creatures, a boss, an oracle, a gate to reach it and
decoration around it — every piece cap-bounded, ledgered by serial, and gone at teardown.
**Verify:** the whole rig. A run that spawns one of each of the five; a restart mid-run proving the
ownership registry survives it and reconcile still answers; a creature killed by a player proving
"gone" is an ordinary teardown outcome and not a failure; and a despawn **refused** for a serial the
run does not own.
#### Phase 12b — what it BORROWS, and the one-shots
| Verb | Owned or borrowed | Cap dimension |
| --- | --- | --- |
| Named, hued creatures from the atlas's ~800 constructible types | owned — deleted by serial | `uo.creatures` |
| "Simple" boss variants, as event-owned creature templates | owned | `uo.bosses` |
| Oracle NPCs with scripted dialogue | owned | `uo.npcs` |
| Temporary gates | owned, with a deadline | `uo.gate.minutes` |
| Temporary decoration lockdown | owned | `uo.decor` |
| A live config value | **borrowed** — a lease | — |
| A property on an existing object | **borrowed** — a lease | — |
| A live config value | **borrowed** — a lease | — |
| Seasonal-event toggle | borrowed | — |
| World save | neither — a one-shot | — |
| Item grant | owned, `reversible: 'none'` | `uo.rewards` |
**The lease allowlist ships with a boot-time self-check.** 258 `Config.Get` call sites in ServUO split
between live reads and values cached at type initialisation, and a lease on the second kind applies
cleanly and does nothing. Each key sets, reads back and restores at boot, and drops itself from the
advertised catalog if it does not take — a capability that disappears loudly beats one that lies.
**The config lease catalog does not grow, and the promise of "the rest of the allowlist" was written
before anyone counted.** Measured on ServUO 57.4: 156 non-Bridge `Config.Get` call sites in
`Scripts/`; 82 sit outside a field declaration, but all but four of *those* are inside a
`Configure()` or a static constructor, and so are cached at boot exactly as the field initialisers
are. The genuinely live, event-useful reads are `PlayerCaps.SkillCap` — which 11b already shipped
— and `Vendors.BribeDecayMinTime`/`MaxTime`, which is vendor bribe decay and which no event would
plausibly lease. (`Staff.*` in `GMbody.cs` is live, and is staff-body cosmetics.) So **12b's lease
work is object-property leases**, and the config half of the catalog is finished at one key.
**The boot-time self-check ships anyway** (org lead, 2026-09-07). On a stock shard it guards a
one-key catalog, which is not why it exists: it exists for the operator whose *own* scripts read
config live, and it is the mechanism that keeps a capability that lies out of the advertised catalog.
A key that sets, reads back and restores at boot stays; one that does not drops itself and says so.
§D and §G of `EVENTS.md` carry the measurement, so nobody re-plans against "258 call sites, two
patterns".
**An object-property lease names its target by an allowlist of (type, property) pairs, addressed by
serial** (org lead, 2026-09-07). The plugin ships the catalog — `Spawner.Amount` / `MinDelay` /
`MaxDelay` to start — and refuses any serial whose type is not in it. That mirrors the config
catalog exactly, and keeps §G's "a curated allowlist the plugin ships" true of both halves of the
lease plane; the alternative, a property name and a serial taken on trust, is `[set` with extra
steps. It also finally puts compare-and-set in front of a real hand: a spawner is trivially drifted
with `[props`, which is the test 11b could run only with scaffolding, because `Config.Set` has one
caller in the whole tree.
**§G describes the seasonal toggle wrongly, and 12b corrects it.** It calls
`SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum". `EventStatus` has **three** values
`Inactive`, `Active`, `Seasonal` — and it is `EventType` that has nine entries. The verb is a
three-state toggle over nine named events, which is a different form to author and a different one
to cap.
**Ships:** the invasion.
**Verify:** the whole rig, running a real multi-phase event with spawns, a lease, and a full teardown