feat(events): targeted leases, value sets and searchable sources (Phase 12b)
Some checks failed
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Failing after 5m46s
PR Checks / bot-tests (pull_request) Successful in 8m28s

The core half of Phase 12b, and the half Phase 12a did not need. A targeted
lease is a shape `core.lease` did not have.

Every lease before this named a SINGLE value, so the lease id WAS the target and
none of the four callables took one. `Spawner.MaxCount` is not that shape: it 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 a lease may declare a `target`, the callables are handed it, and the
ledger ref becomes `<lease id>#<target>` -- which puts the two-events-one-target
refusal at the granularity the world actually has while leaving 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
decided in 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 that objection no longer
holds -- the target check comes free from the index whichever verb reserves the
row -- and the other half still does.

Three readers of a lease ref, not one. `cleanup.restoreLease` and
`ledger.normalise` both looked a lease up by the whole `row.ref`, and both were
correct for exactly as long as a ref was a bare id. Left alone, a targeted row
would have missed in both -- cleanup reporting "no module registers the lease"
and refusing to restore a world that really was changed, which is the worst
failure this table has. All three now go through `eventLeaseForRef`.

`values` closes a `string` lease's set. `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.

Option sources become searchable, and the first one that needed it forced this
phase's shape. `resolveOptionSource(id)` took no argument and every source
answered a flat list bounded at 2,000; module-uo's spawner target is 6,707 spawn
points, so a flat list would have dropped two thirds of the world and said
nothing about which two thirds -- the 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, because inferring it
from a truncated answer reads correctly right up until a small deployment's list
happens to fit.

`MODULE_API_VERSION` stays 1.10.0, amended IN PLACE (org lead, 2026-09-07) -- the
shape every phase since P10 has used while this workstream sits on `edge`.

The swagger regeneration carries one incidental change: the committed spec said
the session cookie is `rg_rig`, which is neither the documented default nor what
this repo's own `server/.env` sets. It was generated somewhere with that env var
set. The regeneration corrects it to `rg_token`.

2010 pass, 0 fail (89 DB-skipped), with `modules/uo` parked as the core suite
requires. Six new tests cover the targeted-lease shape, both refusal directions,
the value set, and the search term.

Refs: docs/link/v7.md §11, docs/website/MODULE_API.md, EVENTS_PLAN.md Phase 12b

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:06:37 -05:00
parent d0178c6419
commit 37f4623068
9 changed files with 512 additions and 21 deletions

View File

@@ -50,6 +50,12 @@
const registries = require('../modules/registries')
// `event_run_resources.ref` is VARCHAR(190). A targeted lease composes its ref
// from the lease id and the target, so this is the one place a caller can push a
// ref past the column — and the ledger's own rule applies: refuse, never
// truncate, because a truncated ref is a restore pointed at another object.
const MAX_LEASE_REF = 190
/**
* Turn the `value` param's text into whatever the named lease says it holds.
@@ -60,7 +66,19 @@ const registries = require('../modules/registries')
*/
function coerceLeaseValue(lease, raw) {
const text = String(raw === undefined || raw === null ? '' : raw).trim()
if (lease.type === 'string') return { ok: true, value: text }
if (lease.type === 'string') {
// A string lease with a declared value set is bounded here, at authoring
// time, exactly as a numeric one is by its range (Phase 12b). Without it the
// only check on the value is the game side's, and that refusal arrives
// unattended, mid-run, from a step nobody is watching.
if (lease.values && !lease.values.includes(text)) {
return {
ok: false,
error: `${lease.label} accepts ${lease.values.join(', ')}, and "${raw}" is none of them`,
}
}
return { ok: true, value: text }
}
if (lease.type === 'bool') {
if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true }
if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false }
@@ -305,6 +323,25 @@ const ACTIONS = [
example: '3.0',
description: 'What to hold it at, in whatever type the lease declares.',
},
{
// **Optional here, required by the LEASE** (Phase 12b), and the two are
// not the same statement. A param's `required` is a property of the
// action, and this action serves both a config key (which has no target)
// and an object property (which cannot be named without one) — so the
// field is declared optional and `perform` refuses a targeted lease with
// nothing in it, in the lease's own words.
//
// It carries no `source` for the same reason: the values behind it are
// the chosen LEASE's, and a param declares one source for all time. The
// lease's own `target.source` is what the authoring form reads once the
// author has picked a lease, which is the only moment the right list is
// knowable.
name: 'target',
type: 'string',
required: false,
example: '003f11b8-9bfa-4587-991e-ca263004efe6',
description: 'Which one, for a value that exists on many things. Leave empty otherwise.',
},
{
name: 'minutes',
type: 'int',
@@ -350,6 +387,26 @@ const ACTIONS = [
const coerced = coerceLeaseValue(lease, params.value)
if (!coerced.ok) return { ok: false, retry: false, error: coerced.error }
// **The target is checked before anything else about the world is read**
// (Phase 12b), because both of its failures are authoring mistakes rather
// than outages: a targeted lease with no target names nothing, and a target
// on a lease that has none is an author who has confused two fields. Both
// are `retry: false` — the second attempt has the same params.
const targetRaw = params.target === undefined || params.target === null ? '' : String(params.target).trim()
if (lease.target && !targetRaw) {
return { ok: false, retry: false, error: `${lease.label} needs a ${lease.target.label.toLowerCase()}` }
}
if (!lease.target && targetRaw) {
return { ok: false, retry: false, error: `${lease.label} is a single value and takes no target` }
}
const target = lease.target ? targetRaw : null
const ref = registries.leaseRef(lease.id, target)
// Refused rather than truncated, on the ledger's own rule for a resource
// ref: a truncated ref is a restore pointed at the wrong object.
if (ref.length > MAX_LEASE_REF) {
return { ok: false, retry: false, error: `that target is too long to record (${ref.length} of ${MAX_LEASE_REF})` }
}
const minutes = Number(params.minutes)
if (!Number.isFinite(minutes) || minutes <= 0) {
return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` }
@@ -368,11 +425,22 @@ const ACTIONS = [
// allowed. What it deliberately does not do is reserve the target — a
// verify that took a lease would be a dry run that changed something, and
// it would then refuse the real run that followed it.
//
// It also does not check that the TARGET exists, and that is the same
// rule rather than an exception: asking the game side whether a spawner is
// there is a live read the shard may be down for, and a dry run that fails
// because a shard is restarting would make `verified_at` a property of the
// moment rather than of the version (§K).
if (verify) return { ok: true }
const baseline = await lease.read()
const baseline = await lease.read({ target })
if (!baseline || baseline.ok !== true) {
return { ok: false, error: `could not read the current value of ${lease.label}` }
return {
ok: false,
error: baseline && baseline.error
? String(baseline.error)
: `could not read the current value of ${lease.label}`,
}
}
const until = new Date(Date.now() + ms)
@@ -381,8 +449,20 @@ const ACTIONS = [
stepId,
owner: lease.owner || 'core',
kind: 'override',
ref: lease.id,
payload: { target: lease.id, baseline: baseline.value, applied: coerced.value, until: until.toISOString() },
// **The ref carries the target, and that is what makes the unique index
// right rather than merely present.** Reserved under the lease id alone,
// an event turning up one spawner would lock every other run out of every
// other spawner — a conflict check that refuses correct work is as wrong
// as one that permits a collision, and on a shard with 6,707 spawners it
// is the failure an operator would actually meet.
ref,
payload: {
target: ref,
leaseTarget: target,
baseline: baseline.value,
applied: coerced.value,
until: until.toISOString(),
},
leaseUntil: until,
})
if (!reserved.ok) {
@@ -401,7 +481,7 @@ const ACTIONS = [
// rather than one stuck changed indefinitely.
let applied
try {
applied = await lease.apply(coerced.value, until)
applied = await lease.apply(coerced.value, until, { target })
} catch (err) {
applied = { ok: false, error: err.message }
}
@@ -451,7 +531,11 @@ const ACTIONS = [
for (const row of resources || []) {
if (row.kind !== 'override') continue
const lease = registries.eventLease(row.ref)
// Resolved through the ref parser rather than by a bare map lookup: a
// targeted row's ref is `<id>#<target>` and `eventLease` would miss it,
// which would silently report every property lease still in force.
const found = registries.eventLeaseForRef(row.ref)
const lease = found && found.lease
if (!lease || typeof lease.inForce !== 'function') {
inForce.push(row.ref)
@@ -460,7 +544,7 @@ const ACTIONS = [
let answer
try {
answer = await lease.inForce({ ref: row.ref, payload: row.payload || null })
answer = await lease.inForce({ ref: row.ref, target: found.target, payload: row.payload || null })
} catch (err) {
answer = null
}