feat(events): open the event contract to modules (Phase 7)
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Failing after 8m41s

MODULE_API 1.10.0. Four names forwarded on the module-facing `api` --
registerEventActions, registerEventBudgets, registerEventLeases and
registerEventOptionSources -- one new route, and one rule made real: a
`cost()` naming a dimension no module declared is refused.

Only one of the four is new machinery. The action registry has staged
core's three actions on every boot since Phase 1; what it never had was a
way in, because loader.js builds its own `api` facade and had no method
that delegated to it. So the registry a module now reaches is one that has
been exercised on every boot for six phases.

Four decisions, settled 2026-09-03, all as recommended:

- Option sources are their own registration, modelled on registerAudiences,
  because a catalog has more than one consumer.
- An undeclared dimension is refused -- at save, at the dry run and at
  dispatch -- with its own code, because the fix is a module's declaration
  and not a deployment's cap.
- A lease is declared here and acquired by nothing; the ledger is Phase 8.
- Core registers core.options.legs, so an announce leg is a dropdown rather
  than the free-text box whose typo Phase 6's walk caught mid-run.

Proved with a throwaway module through the real loader, not with module-uo:
eventModuleContract.test.js writes a module to a real directory and lets the
loader scan it, covering all five envelope failure shapes, verify: true, the
four id spaces and dormancy on uninstall.

The live walk found the one defect nothing else could: the option-source
loader wrote its "already asked?" guard inside a setState updater and read
it on the next line, so the request was never made and the field sat on
"Reading the list..." for ever. It is a useRef now.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
2026-09-03 14:15:04 -05:00
parent 429e657239
commit fd9fb50351
22 changed files with 1825 additions and 117 deletions

View File

@@ -138,6 +138,44 @@ const audiences = new Map()
// order is what the admin catalog renders in.
const eventActions = new Map()
// budget id → { owner, id, label, unit, description } (EVENTS.md §F, Phase 7).
//
// A dimension of consumption — "creatures spawned", "gate uptime" — declared so
// that the switchboard's cap editor has a NAME and a UNIT to put beside a number.
// Phase 6 discovered these by pricing an action's declared `example` values,
// which was a stand-in that could name a dimension and never label it.
//
// **Its own id space**, like `eventActions` above, and §F says why in one line:
// an action names a VERB and a budget names a RESOURCE. Nothing cross-checks the
// two maps, and nothing should.
//
// Data only. There is no function on a budget and nothing here is ever called —
// the module says a dimension exists and what to call it, `cost()` says how much
// of it a step spends, and core owns every piece of arithmetic in between.
const eventBudgets = new Map()
// lease id → { owner, id, label, type, min, max, maxDurationMs, description,
// read, apply, restore } (§F "Leases: one more declaration", Phase 7).
//
// **Phase 7 registers a lease and nothing acquires one.** Core owns the duration
// and the conflict check, the module owns reading the current value and writing a
// new one — and both halves of that live in the resource ledger, which is Phase
// 8's. What is here is the declaration, its validation and its catalog entry, so
// that the module contract is one version rather than two.
const eventLeases = new Map()
// source id → { owner, id, label, description, resolve } (§F "Param option
// sources", Phase 7).
//
// What turns an authoring field from a text box into a dropdown of real
// landmarks. Modelled on `audiences` rather than on anything else here, because
// it is the same shape of thing: an id, a label, and a `resolve()` core calls and
// waits for. What differs is the meaning of a refusal — an audience that refuses
// mails nobody, while a source that refuses degrades its field to free text with
// a warning, because refusing to let an operator type a value they already know
// is worse than the typo the dropdown existed to prevent.
const eventOptionSources = new Map()
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
// decision 7). What a module ships as CONTENT rather than as contract: the
// bodies its triggers render through, and the rules an operator switches on.
@@ -181,6 +219,13 @@ const AUDIENCE_ID = EVENT_ID
// spaces"). One grammar, three namespaces — the constant is what makes the
// namespace visible at every use site.
const ACTION_ID = EVENT_ID
// And three more id spaces on the same grammar, arriving with the module
// contract in Phase 7. Three constants rather than three uses of ACTION_ID, for
// the reason AUDIENCE_ID gets its own: the constant is what makes the namespace
// visible at the use site, so a future divergence has one place to happen.
const BUDGET_ID = EVENT_ID
const LEASE_ID = EVENT_ID
const OPTION_SOURCE_ID = EVENT_ID
// A module's claim must carry its id. Core's ids are its own namespace, and the
// grandfathered names are the ones that predate all of this.
@@ -412,6 +457,86 @@ const eventAction = (id) => eventActions.get(id) || null
*/
const isEventAction = (id) => eventActions.has(id)
// ── Event budgets, leases and option sources (§F, Phase 7) ─────────────
/** Every declared budget dimension, in registration order. */
const allEventBudgets = () => [...eventBudgets.values()]
/** One dimension's declaration, or null. The label-and-unit lookup. */
const eventBudget = (id) => eventBudgets.get(id) || null
/**
* Does anyone declare this dimension right now?
*
* The fail-closed question (org lead, 2026-09-03): a `cost()` naming a dimension
* nobody registered is REFUSED — at save, at the dry run and at dispatch. §F's
* *"a module cannot spend a budget it did not declare"* is only true if something
* asks, and this is what asks.
*/
const isEventBudget = (id) => eventBudgets.has(id)
/**
* Every lease declaration WITHOUT its callables — what the catalog serves.
*
* Stripped for the reason `perform` is stripped from an action: this object
* leaves the process, and the browser's whole relationship with a lease is naming
* one by id.
*/
const allEventLeases = () =>
[...eventLeases.values()].map(({ read, apply: applyValue, restore, ...rest }) => rest)
/** One lease, callables included. Phase 8's lookup; nothing calls it yet. */
const eventLease = (id) => eventLeases.get(id) || null
/** Every option source WITHOUT its resolver — the authoring form's list. */
const allEventOptionSources = () =>
[...eventOptionSources.values()].map(({ resolve, ...rest }) => rest)
/**
* Resolve one option source, or say why not. Never throws.
*
* **A refusal is not an error here, and that is the design.** §F: a source that
* cannot answer degrades its field to free text with a visible warning rather
* than blocking the form. So every failure shape — no such source, a throw, a
* rejected promise, a non-array — comes back as `{ ok: false, reason }` and the
* caller renders a text box. The alternative is an authoring screen that a
* module's outage can make unusable, for a field whose value the operator very
* often already knows.
*
* The options are normalised rather than trusted. This array is rendered into a
* `<select>`, so an entry with no `value` is dropped instead of becoming an
* option that submits the string "undefined", and `label` falls back to the value
* rather than to nothing — a dropdown of blank rows is a worse field than the
* text box it replaced.
*/
async function resolveOptionSource(id) {
const entry = eventOptionSources.get(id)
if (!entry) return { ok: false, reason: `no module registers the option source "${id}"` }
let raw
try {
raw = await entry.resolve()
} catch (err) {
log.error('option source resolver failed', {
source: id,
owner: entry.owner,
message: err.message,
})
return { ok: false, reason: `"${entry.label}" could not be read` }
}
if (!Array.isArray(raw)) {
return { ok: false, reason: `"${entry.label}" answered with no option list` }
}
const options = []
for (const o of raw) {
if (!o || typeof o !== 'object') continue
if (o.value === undefined || o.value === null || o.value === '') continue
const option = { value: String(o.value), label: String(o.label ?? o.value) }
if (o.group) option.group = String(o.group)
options.push(option)
}
return { ok: true, id, label: entry.label, owner: entry.owner, options }
}
// ── Shape checks, run the moment a registrant calls ────────────────────────
//
// Split from the collision checks below on the same line PR 3 drew through
@@ -954,6 +1079,140 @@ function checkEventActionShape(entry) {
}
}
// A lease's value type. Closed, like `risk` and `reversible`, and for the same
// reason: core validates an operator's input against it at authoring time, so a
// type core does not know is a lease core cannot bound.
const LEASE_TYPES = ['int', 'float', 'bool', 'string']
// Thirty days. A lease is a promise the game side keeps WITHOUT being asked again
// (§F), so its ceiling is the longest outage a restore may have to survive rather
// than a scheduling convenience. Past that, "temporary" has stopped meaning
// anything an operator can hold in their head.
const MAX_LEASE_MS = 30 * 24 * 60 * 60 * 1000
/**
* `registerEventBudgets([{ id, label, unit, description }])`.
*
* A dimension of consumption core can bound. Data only — the module says a
* dimension exists and what to call it, `cost()` says how much of it a step
* spends, and core owns the arithmetic in between (§F, *"cost is declared by the
* module and enforced by core"*).
*
* **`unit` is required, and its vocabulary is open.** Required because a bare
* number on a cap box is ambiguous in exactly the case that matters — 30 of
* what? — and open because core never interprets it. It is a display word beside
* a number, and closing the set would make "kilometres" a MODULE_API bump for a
* noun core does not read.
*/
function checkEventBudgetShape(entry) {
const b = entry || {}
if (!BUDGET_ID.test(b.id || '')) {
throw new Error(`registerEventBudgets: bad budget id "${b.id}"`)
}
if (!b.label) throw new Error(`registerEventBudgets: budget "${b.id}" has no label`)
if (!b.unit) {
throw new Error(`registerEventBudgets: budget "${b.id}" has no unit (it is rendered beside the cap)`)
}
return { id: b.id, label: b.label, unit: String(b.unit), description: b.description || '' }
}
/**
* `registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore }])`.
*
* A value a run may borrow and must give back. Core owns the duration and the
* conflict check; the module owns reading the current value and writing a new one
* — the split §F draws, and the reason all three callables are required rather
* than one of them.
*
* **`restore` is required even though `read` could stand in for it.** They answer
* different questions: `read` is *"what is it now"*, `restore` is *"put this back,
* and tell me if someone else has moved it"* — the drift check, which is the one
* thing a module must not be allowed to skip. A lease whose restore writes blindly
* is a lease that silently reverts an operator's manual fix.
*
* **Nothing acquires a lease in Phase 7.** This registers, validates and serves
* one; the ledger that holds it, the deadline that goes down the wire and the
* drift answer are Phase 8's. Declaring it now is what keeps the module contract
* one version rather than two.
*/
function checkEventLeaseShape(entry) {
const l = entry || {}
if (!LEASE_ID.test(l.id || '')) throw new Error(`registerEventLeases: bad lease id "${l.id}"`)
if (!l.label) throw new Error(`registerEventLeases: lease "${l.id}" has no label`)
if (!LEASE_TYPES.includes(l.type)) {
throw new Error(`registerEventLeases: ${l.id} needs a type, one of ${LEASE_TYPES.join(', ')}`)
}
// Only the numeric types carry a range, and for those it is REQUIRED. A lease
// on a rate multiplier with no bounds is an operator one keystroke away from
// setting a shard's skill gain to 5000, which is the class of accident the
// whole cap machinery exists to make impossible — and unlike a cap, a bad lease
// value is in force the moment it is applied.
let min = null
let max = null
if (l.type === 'int' || l.type === 'float') {
min = Number(l.min)
max = Number(l.max)
if (!Number.isFinite(min) || !Number.isFinite(max)) {
throw new Error(`registerEventLeases: ${l.id} is ${l.type} and needs a numeric min and max`)
}
if (l.type === 'int' && (!Number.isInteger(min) || !Number.isInteger(max))) {
throw new Error(`registerEventLeases: ${l.id} is int and needs whole-number min and max`)
}
if (min > max) throw new Error(`registerEventLeases: ${l.id} has min ${min} above max ${max}`)
}
const maxDurationMs = l.maxDurationMs
if (!Number.isInteger(maxDurationMs) || maxDurationMs <= 0 || maxDurationMs > MAX_LEASE_MS) {
throw new Error(
`registerEventLeases: ${l.id} maxDurationMs must be 1..${MAX_LEASE_MS} ms, got "${l.maxDurationMs}"`,
)
}
for (const fn of ['read', 'apply', 'restore']) {
if (typeof l[fn] !== 'function') throw new Error(`registerEventLeases: ${l.id} has no ${fn}()`)
}
return {
id: l.id,
label: l.label,
description: l.description || '',
type: l.type,
min,
max,
maxDurationMs,
read: l.read,
apply: l.apply,
restore: l.restore,
}
}
/**
* `registerEventOptionSources([{ id, label, description, resolve }])`.
*
* The values behind a param's `source` (§F, *Param option sources*). Its own
* registration rather than a field on the action that names it, because a catalog
* has more than one consumer: `uo.options.items` is the allowlist for granting an
* item and for taking one back, and two actions declaring it separately would be
* two allowlists that can disagree.
*
* `resolve()` answers `[{ value, label, group? }]`. It may be async, it may talk
* to a sidecar, and it may fail — the failure is handled at the call
* (`resolveOptionSource`) rather than here, because the answer to a source that
* cannot answer is a text box, not a broken form.
*/
function checkEventOptionSourceShape(entry) {
const s = entry || {}
if (!OPTION_SOURCE_ID.test(s.id || '')) {
throw new Error(`registerEventOptionSources: bad option source id "${s.id}"`)
}
if (!s.label) throw new Error(`registerEventOptionSources: source "${s.id}" has no label`)
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 }
}
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
//
// **Two mechanisms, and the asymmetry between them is the whole design.**
@@ -1194,6 +1453,9 @@ function stage(owner) {
audiences: [],
eventActions: [],
engagementSeeds: [],
eventBudgets: [],
eventLeases: [],
eventOptionSources: [],
}
return {
staged,
@@ -1235,6 +1497,23 @@ function stage(owner) {
if (!Array.isArray(entries)) throw new Error('registerEventActions: expected an array')
for (const e of entries) staged.eventActions.push(checkEventActionShape(e))
},
// The three that arrive WITH the module-facing seam (Phase 7). Unlike
// `registerEventActions` above, these have never had a core-only period:
// `loader.js` forwards all four from the boot this lands on, and core
// registers through them on the same boot, which is the posture
// `registerCore()` has taken since the module system's Phase 3.
registerEventBudgets(entries) {
if (!Array.isArray(entries)) throw new Error('registerEventBudgets: expected an array')
for (const e of entries) staged.eventBudgets.push(checkEventBudgetShape(e))
},
registerEventLeases(entries) {
if (!Array.isArray(entries)) throw new Error('registerEventLeases: expected an array')
for (const e of entries) staged.eventLeases.push(checkEventLeaseShape(e))
},
registerEventOptionSources(entries) {
if (!Array.isArray(entries)) throw new Error('registerEventOptionSources: expected an array')
for (const e of entries) staged.eventOptionSources.push(checkEventOptionSourceShape(e))
},
registerEngagementSeeds(entry) {
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
},
@@ -1261,6 +1540,9 @@ function apply({
triggers: newTriggers = [],
audiences: newAudiences = [],
eventActions: newEventActions = [],
eventBudgets: newEventBudgets = [],
eventLeases: newEventLeases = [],
eventOptionSources: newEventOptionSources = [],
engagementSeeds: newSeeds = [],
}) {
// ── validate ──
@@ -1334,6 +1616,48 @@ function apply({
seenActions.add(a.id)
}
// Budgets, leases and option sources: three more id spaces, checked against
// their own maps and against nothing else, for the reason the actions loop
// above gives. No legacy allowlist on any of the three — nothing predates them,
// so the prefix rule has no exceptions and should never grow one.
//
// The one cross-facet check that would be wrong here is budget-against-action:
// §F puts them in separate id spaces deliberately, and `uo.creatures` as a
// dimension beside `uo.creature.spawn` as a verb is the most natural pair of
// names a module will ever write.
const seenBudgets = new Set()
for (const b of newEventBudgets) {
const held = eventBudgets.get(b.id)
if (held) throw new Error(`event budget "${b.id}" is already registered by "${held.owner}"`)
if (seenBudgets.has(b.id)) throw new Error(`event budget "${b.id}" registered twice`)
if (!namespaced(owner, b.id, {})) {
throw new Error(`event budget "${b.id}" is not namespaced "${owner}."`)
}
seenBudgets.add(b.id)
}
const seenLeases = new Set()
for (const l of newEventLeases) {
const held = eventLeases.get(l.id)
if (held) throw new Error(`event lease "${l.id}" is already registered by "${held.owner}"`)
if (seenLeases.has(l.id)) throw new Error(`event lease "${l.id}" registered twice`)
if (!namespaced(owner, l.id, {})) {
throw new Error(`event lease "${l.id}" is not namespaced "${owner}."`)
}
seenLeases.add(l.id)
}
const seenSources = new Set()
for (const s of newEventOptionSources) {
const held = eventOptionSources.get(s.id)
if (held) throw new Error(`option source "${s.id}" is already registered by "${held.owner}"`)
if (seenSources.has(s.id)) throw new Error(`option source "${s.id}" registered twice`)
if (!namespaced(owner, s.id, {})) {
throw new Error(`option source "${s.id}" is not namespaced "${owner}."`)
}
seenSources.add(s.id)
}
const seenLegs = new Set()
for (const l of newLegs) {
const held = legs.get(l.leg)
@@ -1399,6 +1723,9 @@ function apply({
for (const t of newTriggers) triggers.set(t.id, { owner, ...t })
for (const a of newAudiences) audiences.set(a.id, { owner, ...a })
for (const a of newEventActions) eventActions.set(a.id, { owner, ...a })
for (const b of newEventBudgets) eventBudgets.set(b.id, { owner, ...b })
for (const l of newEventLeases) eventLeases.set(l.id, { owner, ...l })
for (const s of newEventOptionSources) eventOptionSources.set(s.id, { owner, ...s })
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
}
@@ -1437,6 +1764,13 @@ function registerCore() {
// module, so the registry is exercised on every boot long before a module uses
// it — the argument registerCore() has made since the module system's Phase 3.
api.registerEventActions(coreEventActions.ACTIONS)
// And core's own option source (Phase 7, org lead 2026-09-03). `core.announce`
// takes a leg id, and until now that was a free-text box whose typo was caught
// at DISPATCH, mid-run — which is exactly the defect Phase 6's walk hit, an
// announce leg "site" no module registers. The legs are already in a registry
// with their labels, so the dropdown costs nothing new, and core registering an
// option source means the seam's first exercise is not a module's.
api.registerEventOptionSources(coreEventActions.OPTION_SOURCES)
// The three lines that used to follow — the shard stream catalog, the town
// crier leg and the `admin.users.detail` filling — were shard CONTENT held
@@ -1451,6 +1785,7 @@ function registerCore() {
streams: streams.length,
eventTriggers: triggers.size,
eventActions: eventActions.size,
eventOptionSources: eventOptionSources.size,
announceLegs: legs.size,
extensions: [...slots.keys()].filter(slotFilledBy),
})
@@ -1483,6 +1818,9 @@ function _reset() {
triggers.clear()
audiences.clear()
eventActions.clear()
eventBudgets.clear()
eventLeases.clear()
eventOptionSources.clear()
engagementSeeds.clear()
coreRegistered = false
}
@@ -1514,6 +1852,13 @@ module.exports = {
allEventActions,
eventAction,
isEventAction,
allEventBudgets,
eventBudget,
isEventBudget,
allEventLeases,
eventLease,
allEventOptionSources,
resolveOptionSource,
allEngagementSeeds,
engagementSeedsFor,
SEEDABLE_CHANNELS,
@@ -1522,6 +1867,8 @@ module.exports = {
ACTION_RISKS,
ACTION_REVERSIBLE,
ACTION_PARAM_TYPES,
LEASE_TYPES,
MAX_LEASE_MS,
DEFAULT_BUDGET_MS,
stage,
apply,