# 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 and 12b) **Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. **Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`v5.md`](v5.md) (5.0 decay schedule, vendor fees, login result), [`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 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. --- ## 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 | **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 `#` 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. --- ## 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. --- ## 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 `` 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.