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

@@ -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 () => {})
```