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

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