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
}

View File

@@ -100,7 +100,12 @@ function classifyRevert(raw, what) {
/** Call a lease's `restore`, under a deadline, never throwing. */
async function restoreLease(row) {
const lease = registries.eventLease(row.ref)
// Through the ref parser, because a targeted lease's ref is `<id>#<target>`
// (Phase 12b). A bare map lookup would miss every property lease and report
// "no module registers" for one that is registered — leaving a spawner turned
// up for good and blaming an uninstalled module for it.
const found = registries.eventLeaseForRef(row.ref)
const lease = found && found.lease
if (!lease) {
// The module that owned it is uninstalled or failed to boot. Not a failure to
// retry away — nothing will change until an operator reinstalls it — and not
@@ -113,7 +118,12 @@ async function restoreLease(row) {
let raw
try {
raw = await withDeadline(
() => lease.restore(payload.baseline, { expected: payload.applied, runId: row.run_id }),
() =>
lease.restore(payload.baseline, {
expected: payload.applied,
runId: row.run_id,
target: found.target,
}),
DEFAULT_REVERT_BUDGET_MS,
row.ref,
)

View File

@@ -98,7 +98,10 @@ function normalise(entry, actionId) {
// an `override` through the lease registry — that is the split §F draws — so a
// ref naming nothing registered is a resource core would be recording with no
// way to undo it, which is the promise rule 2 exists to stop core making.
if (kind === 'override' && !registries.eventLease(ref)) {
// Through the ref parser: a targeted lease's ref carries its target after a
// `#` (Phase 12b), and matching the whole string against the registry would
// reject a lease that IS registered.
if (kind === 'override' && !registries.eventLeaseForRef(ref)) {
return { ok: false, reason: `${actionId} reported a lease "${ref}" no module registers` }
}

View File

@@ -488,10 +488,49 @@ const allEventLeases = () =>
/** One lease, callables included. `core.lease` and the cleanup sweep read it. */
const eventLease = (id) => eventLeases.get(id) || null
// How a targeted lease's reservation ref is written, and the one place that
// knows it. `#` is safe as the separator because `EVENT_ID` admits only
// `[a-z0-9_.]`, so no lease id can contain one and the split is unambiguous
// however odd a module's target string is.
const LEASE_TARGET_SEP = '#'
/** Compose the ref an `override` row is reserved under. */
const leaseRef = (id, target) =>
target === null || target === undefined || target === '' ? id : `${id}${LEASE_TARGET_SEP}${target}`
/**
* Split an `override` row's ref back into the lease and the target it names.
*
* **Every reader of a ledgered lease needs this, not just the one that wrote
* it.** `cleanup.restoreLease` and `ledger.normalise` both look a lease up by
* `row.ref`, and both were correct for exactly as long as a ref was a bare lease
* id. A targeted row would have missed in both — cleanup reporting a lease no
* module registers and refusing to restore a world that really was changed,
* which is the worst failure this table has.
*/
function parseLeaseRef(ref) {
const text = String(ref === undefined || ref === null ? '' : ref)
const at = text.indexOf(LEASE_TARGET_SEP)
if (at < 0) return { id: text, target: null }
return { id: text.slice(0, at), target: text.slice(at + 1) }
}
/** The lease an `override` row names, target included. Null when nothing registers it. */
function eventLeaseForRef(ref) {
const { id, target } = parseLeaseRef(ref)
const lease = eventLeases.get(id) || null
return lease ? { lease, target } : null
}
/** Every option source WITHOUT its resolver — the authoring form's list. */
const allEventOptionSources = () =>
[...eventOptionSources.values()].map(({ resolve, ...rest }) => rest)
// The longest search term a source is asked to honour. Bounded rather than
// refused: a term this long is a paste rather than a search, and truncating it
// still answers something, where refusing would blank a form mid-keystroke.
const MAX_OPTION_QUERY = 120
/**
* Resolve one option source, or say why not. Never throws.
*
@@ -509,12 +548,25 @@ const allEventOptionSources = () =>
* rather than to nothing — a dropdown of blank rows is a worse field than the
* text box it replaced.
*/
async function resolveOptionSource(id) {
async function resolveOptionSource(id, { q = '' } = {}) {
const entry = eventOptionSources.get(id)
if (!entry) return { ok: false, reason: `no module registers the option source "${id}"` }
// **The search term is passed to every source and required of none** (Phase
// 12b). A source answers a list; one whose catalog is larger than a dropdown
// can hold answers a list NARROWED BY a term, and the two are the same
// callable because the difference is the source's business rather than the
// form's. A resolver that ignores the argument behaves exactly as it did
// before this existed, which is what made this additive.
//
// It exists because the first source with more entries than `MAX_OPTIONS` is
// Phase 12b's spawner target — 6,707 spawn points against a 2,000 bound. Every
// source before it fitted, so the bound had only ever truncated in theory; a
// dropdown quietly missing two thirds of the world is the same failure 12a
// named for decoration, and truncation cannot be the answer to it.
const term = String(q || '').trim().slice(0, MAX_OPTION_QUERY)
let raw
try {
raw = await entry.resolve()
raw = await entry.resolve({ q: term })
} catch (err) {
log.error('option source resolver failed', {
source: id,
@@ -534,7 +586,11 @@ async function resolveOptionSource(id) {
if (o.group) option.group = String(o.group)
options.push(option)
}
return { ok: true, id, label: entry.label, owner: entry.owner, options }
// `searchable` is reported so the authoring form knows which field is a
// typeahead and which is a plain select. Without it P13 would have to infer it
// from whether a truncated list came back, which is a guess that reads
// correctly right up until a small shard's spawner list fits.
return { ok: true, id, label: entry.label, owner: entry.owner, searchable: entry.searchable, options, q: term }
}
// ── Shape checks, run the moment a registrant calls ────────────────────────
@@ -1212,6 +1268,61 @@ function checkEventLeaseShape(entry) {
throw new Error(`registerEventLeases: ${l.id} inForce must be a function`)
}
// **A TARGETED lease is a family of values rather than one** (Phase 12b).
//
// Every lease before this one named a single value — a config key, a rate —
// and the lease id WAS the target, which is why the four callables took none
// and why the reservation ref was the id alone. An object property is not that
// shape: `Spawner.MaxCount` is one lease 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.
//
// So a lease may declare a target, and when it does core adds one to the
// reservation ref (`<id>#<target>`) and hands it to every callable. The
// two-events-one-target refusal then comes from the same unique index it
// always did, at the granularity the world actually has. **Core still owns the
// duration bound and the conflict check** — which is the whole of why §F put
// the verb in core, and the reason this is an extension of `core.lease` rather
// than a lease verb of the module's own.
let target = null
if (l.target !== undefined && l.target !== null) {
const t = l.target
if (typeof t !== 'object' || Array.isArray(t)) {
throw new Error(`registerEventLeases: ${l.id} target must be an object`)
}
if (!t.label) throw new Error(`registerEventLeases: ${l.id} target has no label`)
// The source is checked for shape only, never for existence, on
// `resolveOptionSource`'s own argument: a source that cannot answer degrades
// its field to free text rather than blocking the form, and a source
// registered by a module that boots later must not make this one throw.
if (t.source !== undefined && !OPTION_SOURCE_ID.test(t.source || '')) {
throw new Error(`registerEventLeases: ${l.id} target names a bad option source "${t.source}"`)
}
target = {
label: String(t.label),
source: t.source || null,
example: t.example === undefined ? null : String(t.example),
description: t.description || '',
}
}
// **A closed set of values, for the type that has no range** (Phase 12b).
// `min`/`max` bound the numeric types and nothing bounded `string`, so a string
// lease was free text validated only by the game side — which means an invalid
// value is a refusal at DISPATCH, unattended, halfway through a run, instead of
// a refusal on the form. Optional, because a genuinely free-text lease still
// exists; enforced by `coerceLeaseValue` when present.
let values = null
if (l.values !== undefined && l.values !== null) {
if (!Array.isArray(l.values) || !l.values.length) {
throw new Error(`registerEventLeases: ${l.id} values must be a non-empty array`)
}
if (l.type !== 'string') {
throw new Error(`registerEventLeases: ${l.id} is ${l.type}; values is for string leases`)
}
values = l.values.map((v) => String(v))
}
return {
id: l.id,
label: l.label,
@@ -1219,6 +1330,8 @@ function checkEventLeaseShape(entry) {
type: l.type,
min,
max,
values,
target,
maxDurationMs,
read: l.read,
apply: l.apply,
@@ -1250,7 +1363,18 @@ function checkEventOptionSourceShape(entry) {
if (typeof s.resolve !== 'function') {
throw new Error(`registerEventOptionSources: ${s.id} has no resolve()`)
}
return { id: s.id, label: s.label, description: s.description || '', resolve: s.resolve }
// **A declaration, not an inference** (Phase 12b). `resolve({ q })` is passed a
// term whether or not the source reads it, so nothing here can tell from the
// callable alone whether a term would narrow the answer. The module says, and
// the authoring form renders a typeahead rather than a select for the ones
// that do.
return {
id: s.id,
label: s.label,
description: s.description || '',
searchable: s.searchable === true,
resolve: s.resolve,
}
}
@@ -1898,6 +2022,9 @@ module.exports = {
isEventBudget,
allEventLeases,
eventLease,
eventLeaseForRef,
parseLeaseRef,
leaseRef,
allEventOptionSources,
resolveOptionSource,
allEngagementSeeds,

View File

@@ -533,7 +533,12 @@ exports.actions = async (_req, res) => {
* accepts. The registry answers it, so it names no game noun here.
*/
exports.options = async (req, res) => {
const result = await registries.resolveOptionSource(String(req.params.sourceId || ''))
// `q` is passed straight through and bounded by the registry, not here: the
// registry is what every caller of a source goes through, and a bound written
// on the route would be a bound the next caller does not have.
const result = await registries.resolveOptionSource(String(req.params.sourceId || ''), {
q: String(req.query.q || ''),
})
return res.json(result)
}

View File

@@ -72,9 +72,13 @@ eventsRouter.get(
'/catalog/options/:sourceId',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Resolve the values behind a param option source'
// #swagger.description = 'EVENTS.md F, Param option sources (Phase 7). A param may declare a `source`, and this is what answers it: the module that registered the source resolves the list, so an authoring field is a dropdown of real landmarks or creatures rather than a text box an operator can typo. A refusal comes back as a 200 with `ok: false` and a `reason` -- deliberately, because a source that cannot answer degrades its field to free text with a visible warning rather than blocking the form, and an authoring screen a sidecar outage can make unusable is a worse failure than the typo the dropdown prevents. Values are resolved per request rather than cached in the catalog, because a source can be slow or down and must not take the whole catalog with it.'
// #swagger.description = 'EVENTS.md F, Param option sources (Phase 7). A param may declare a `source`, and this is what answers it: the module that registered the source resolves the list, so an authoring field is a dropdown of real landmarks or creatures rather than a text box an operator can typo. A refusal comes back as a 200 with `ok: false` and a `reason` -- deliberately, because a source that cannot answer degrades its field to free text with a visible warning rather than blocking the form, and an authoring screen a sidecar outage can make unusable is a worse failure than the typo the dropdown prevents. Values are resolved per request rather than cached in the catalog, because a source can be slow or down and must not take the whole catalog with it. Phase 12b adds the optional `q`: a source whose catalog is larger than a dropdown can hold (the first is the spawner target, 6,707 entries against a 2,000 bound) narrows its answer by it, and one that ignores it answers exactly as before. `searchable` on the response says which is which, so the form renders a typeahead rather than a select.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */
// A single-key `schema` on purpose: swagger-autogen renders a two-key one as an
// object schema whose properties are `type` and `maxLength`, which documents a
// query parameter that takes a JSON object. The bound is stated in the description.
/* #swagger.parameters['q'] = { in: 'query', description: 'Narrow the list. Honoured only by a source that declares itself searchable; ignored, never refused, by the rest. Bounded to 120 characters.', required: false, schema: { type: 'string' } } */
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, searchable: { type: "boolean" }, q: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.options,
)