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

@@ -51,10 +51,17 @@ const ACTIONS = [
// A leg id, checked against the announce-leg registry at dispatch rather
// than here: legs are registered by modules, and this file is evaluated
// before any module has registered anything.
//
// **`source` is what moves that check earlier** (Phase 7). The dispatch
// check stays — a module can boot between authoring and the run — but
// until now a typo here was caught mid-run and nowhere else, which is the
// defect Phase 6's walk hit: an announce leg "site" no module registers,
// found by a dry run rather than by the form that accepted it.
name: 'leg',
type: 'string',
required: true,
example: 'discord',
source: 'core.options.legs',
description: 'The announce leg to publish on. Registered legs only.',
},
{
@@ -209,4 +216,27 @@ const ACTIONS = [
},
]
module.exports = { ACTIONS }
// ── Core's own param option sources (§F, Phase 7) ──────────────────
//
// One, and it is core's half of the seam it hands a module on the same boot: a
// param's `source` names a registered option source, core asks it for values, and
// the authoring form renders a dropdown instead of a text box.
//
// **The legs are already a registry with labels in it**, so this costs nothing
// new — which is what makes it the right first exercise. `resolve()` is called
// per request rather than read once, for the same reason `core.announce` looks a
// leg up inside `perform()`: a leg registered by a module that booted after this
// file was evaluated must still appear, and a module uninstalled since must stop
// appearing.
const OPTION_SOURCES = [
{
id: 'core.options.legs',
label: 'Announce legs',
description: 'Every delivery leg registered on this deployment right now.',
async resolve() {
return registries.announceLegs().map((l) => ({ value: l.leg, label: l.label || l.leg }))
},
},
]
module.exports = { ACTIONS, OPTION_SOURCES }

View File

@@ -127,12 +127,14 @@ function priceOf(action, params) {
* The dimensions an action can spend, discovered by pricing its declared
* examples.
*
* **This is a Phase 6 stand-in with a Phase 7 replacement already named.** §F's
* `registerEventBudgets` is what will declare a dimension's id, label and unit,
* and it arrives with the module contract. Until then the switchboard still has
* to render a cap editor, and it cannot offer a box for a dimension it cannot
* name — so core prices each action's own `example` values, which is a use every
* param already has a required `example` for.
* **Phase 7 replaced half of this and deliberately kept the other half.** §F's
* `registerEventBudgets` now declares a dimension's id, label and unit, so the
* switchboard no longer has to invent a name for a box — see `budgetsOf` below,
* which is what the screen reads. What a registry cannot answer is *which*
* dimensions THIS action spends, because `cost` is a function of params (§F) and
* the only honest way to ask it is to call it. So the discovery stays: core
* prices each action's own `example` values, which is a use every param already
* has a required `example` for.
*
* It is honest about its limits: a `cost()` that returns different dimension KEYS
* for different params under-reports here. That costs an operator a cap box on
@@ -148,6 +150,45 @@ function dimensionsOf(action) {
return priced ? Object.keys(priced).sort() : []
}
/**
* The same dimensions, dressed with what the registry says they are called.
*
* The switchboard's read (Phase 7). `registered: false` is the case worth having
* a field for: an action that prices a dimension no module declares is a
* DECLARATION ERROR — `mayInvoke` refuses it, the dry run fails on it, and the
* save refuses it — so the screen has to be able to show the operator the reason
* their action will not run, rather than silently listing one fewer cap box than
* the action has dimensions. Hiding it would make a broken module look like a
* cheap one.
*/
function budgetsOf(action) {
return dimensionsOf(action).map((id) => {
const declared = registries.eventBudget(id)
return declared
? { id, label: declared.label, unit: declared.unit, registered: true }
: { id, label: id, unit: '', registered: false }
})
}
/**
* Which of these dimensions does nobody declare? (§F, org lead 2026-09-03.)
*
* Fail closed. §F's *"a module cannot spend a budget it did not declare"* is a
* rule only if something asks, and this is what asks — from `mayInvoke` at
* dispatch and at the dry run, and from the spec validator at save. Three places
* because they answer at three different moments and only the first of them is
* cheap: catching it at save costs an editor a red line, catching it at dispatch
* costs a run a refused step at two in the morning.
*
* Not folded into `priceOf`, which answers *what does this cost* and should keep
* answering only that: a cost of 12 creatures is a true statement about the
* action whether or not anyone declared the dimension, and the two facts have
* different fixes.
*/
function undeclaredDimensions(cost) {
return Object.keys(cost || {}).filter((d) => !registries.isEventBudget(d))
}
/**
* The effective per-run cap for each dimension a set of steps will spend.
*
@@ -245,6 +286,24 @@ async function mayInvoke({
const dimensions = Object.keys(cost)
if (!dimensions.length) return { ok: true, cost }
// Before any cap arithmetic, because a dimension nobody declared has no cap to
// be under and no meter to draw on — asking "is 12 within the limit" about a
// resource core has never been told the name of would be answering a question
// that has not been asked yet. It is also the honest reading of the refusal:
// this is a module whose declaration is incomplete, not a deployment whose
// allowance is spent, and an operator who is told the second will go and raise
// a cap that changes nothing.
const undeclared = undeclaredDimensions(cost)
if (undeclared.length) {
return {
ok: false,
code: 'undeclared',
reason: `spends "${undeclared[0]}", which no module declares as a budget`,
dimension: undeclared[0],
requested: cost[undeclared[0]],
}
}
if (!run) {
// No run, so nothing to draw on: the question is whether the cost could EVER
// fit, which is what the dry run and the editor are asking. A cost larger
@@ -333,6 +392,8 @@ module.exports = {
isEnabled,
priceOf,
dimensionsOf,
budgetsOf,
undeclaredDimensions,
effectiveCaps,
changesWorld,
WORLD_CHANGING,

View File

@@ -29,6 +29,11 @@
// computes there.
const registries = require('../modules/registries')
// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's
// fail-closed budget rule (Phase 7). Nothing here reaches the database:
// `authorize` requires two `.db.js` modules and requiring one opens no
// connection, which is the same rule this file already lives under.
const authorize = require('./authorize')
const recurrence = require('./recurrence')
const conditionGrammar = require('../engagement/conditions')
const { checkLiteral } = conditionGrammar
@@ -509,6 +514,29 @@ function validate(raw, { knownActionIds = [] } = {}) {
const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath)
errors.push(...paramErrors)
// §F, fail closed (org lead, 2026-09-03): a step may not spend a dimension
// no module declares as a budget. Refused HERE as well as at dispatch
// because this is the cheap moment — the editor is open, the author is
// looking at the step, and the alternative is a run that refuses at two in
// the morning for a reason that was decidable when it was written.
//
// Only when the params validated. Pricing a step whose params were just
// refused would run a module's `cost()` over values core has already said
// are wrong, and report its answer as a second, confusing error about the
// same mistake.
if (!paramErrors.length) {
const priced = authorize.priceOf(declaration, params)
if (priced === null) {
errors.push(`${spath}: "${declaration.label}" could not report what it costs`)
} else {
for (const dimension of authorize.undeclaredDimensions(priced)) {
errors.push(
`${spath}: "${declaration.label}" spends "${dimension}", which no module declares as a budget`,
)
}
}
}
if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) {
errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`)
}

View File

@@ -370,6 +370,41 @@ function buildApi(record) {
once('registerEngagementSeeds')
record.staged.registerEngagementSeeds(seeds)
},
// The event contract (API 1.10.0, EVENTS.md §F). **This is the seam Phase 1
// built and did not open**: `registerEventActions` has staged core's three
// actions on every boot since then and no module could reach it, because
// this facade had no method that delegated. The four lines below are what
// Phase 7 ships — core has been going through the same door for six phases,
// so the registry a module now reaches is one that has been exercised on
// every boot rather than one whose first registrant is a stranger.
//
// `once` on all four, for the reason every batch registration above takes
// it: a batch is a module's complete statement about what it declares, and a
// second call is a module changing its mind halfway through `register()`
// rather than adding to it.
//
// The four id spaces are separate and the loader does not police that —
// `registries.apply()` does, per space. An action names a VERB, a budget
// names a RESOURCE, a lease names a VALUE and an option source names a
// CATALOG, so `uo.creatures` may legitimately appear in more than one of
// them and reading that as a collision would forbid the most natural set of
// names a module will ever write.
registerEventActions(actions) {
once('registerEventActions')
record.staged.registerEventActions(actions)
},
registerEventBudgets(budgets) {
once('registerEventBudgets')
record.staged.registerEventBudgets(budgets)
},
registerEventLeases(leases) {
once('registerEventLeases')
record.staged.registerEventLeases(leases)
},
registerEventOptionSources(sources) {
once('registerEventOptionSources')
record.staged.registerEventOptionSources(sources)
},
// The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply

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,

View File

@@ -9,6 +9,27 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.10.0 — the event contract opens to modules: `api.registerEventActions`,
// `api.registerEventBudgets`, `api.registerEventLeases` and
// `api.registerEventOptionSources` (docs/website/EVENTS.md §F, EVENTS_PLAN.md
// Phase 7). Four names, and only one of them is new machinery: the ACTION
// registry has staged core's `core.announce`, `core.wait` and `core.cue` on every
// boot since Events Phase 1, and `loader.js` simply had no method that delegated
// to it. What Phase 7 adds is the facade, the three declarations beside it, and
// the fail-closed rule they exist for — a `cost()` naming a dimension no module
// registered is REFUSED at save, at the dry run and at dispatch, so a module
// cannot spend a budget it did not declare.
//
// Additions only, so minor: a module written against 1.9.0 registers no actions
// and the deployment simply has fewer verbs an event can use. That is §F's own
// posture stated as a version rule — core with no module installed is still an
// event engine that can announce, wait, cue a human and publish results.
//
// **A lease is DECLARED here and acquired by nothing.** Core owns a lease's
// duration and its conflict check, and both live in the resource ledger, which is
// Phase 8's. It is in 1.10.0 rather than in 1.11.0 so that the module contract is
// one version a module author reads once, not two.
//
// 1.9.0 - a sixth registration call: `api.registerEngagementSeeds({ templates,
// ruleGroups })` (docs/website/ENGAGEMENT.md Phase 11b, decision 7). A module
// could declare a trigger from 1.7.0 and could never say what the mail should
@@ -134,6 +155,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.9.0'
const MODULE_API_VERSION = '1.10.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -180,6 +180,21 @@ exports.catalog = (_req, res) => {
})),
operators: conditionGrammar.vocabulary(),
advanceKinds: spec.ADVANCE_KINDS,
// **The other three registrations of the module contract** (Phase 7). Served
// beside the actions rather than on three routes of their own, because the
// step editor needs all four to render one step: the action says what params
// it takes, a param's `source` names an option source, and a cap the editor
// shows is a budget's label and unit. Four requests to draw one form would
// be four chances for the screen to render half of it.
//
// Each is already stripped of its callables by the registry (`resolve`,
// `read`, `apply`, `restore`) — the same rule that keeps `perform` off an
// action here. A source's VALUES are not in this payload either: they are a
// request of their own (`/options/:sourceId`), because a source can be slow,
// can fail, and would otherwise take the whole catalog down with it.
budgets: registries.allEventBudgets(),
leases: registries.allEventLeases(),
optionSources: registries.allEventOptionSources(),
limits: {
maxPhases: spec.MAX_PHASES,
maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE,
@@ -463,10 +478,16 @@ exports.actions = async (_req, res) => {
configured: Boolean(row),
changesWorld: authorize.changesWorld(full),
// The dimensions this action can spend, so the screen can offer a cap
// box per dimension. Discovered by pricing the action's own declared
// examples until §F's `registerEventBudgets` lands in Phase 7 — see
// `authorize.dimensionsOf`.
dimensions: authorize.dimensionsOf(full),
// box per dimension — each now carrying the label and unit its
// `registerEventBudgets` declaration gives it (Phase 7), because "30" on
// a box is ambiguous in exactly the case that matters: 30 of what?
//
// `registered: false` says a dimension nobody declares, and it is shown
// rather than filtered out: an action that prices an undeclared
// dimension is REFUSED at save and at dispatch, so the screen must be
// able to show the operator why their action will not run instead of
// quietly listing one fewer box than the action has dimensions.
dimensions: authorize.budgetsOf(full),
caps: row?.caps || {},
updatedAt: row?.updated_at || null,
updatedBy: row?.updated_by_username || null,
@@ -478,6 +499,30 @@ exports.actions = async (_req, res) => {
})
}
/**
* GET /api/v1/admin/events/catalog/options/:sourceId — the values behind a param's `source`.
*
* §F's *Param option sources* (Phase 7). Without it the step editor is a JSON
* editor with better fonts: a landmark, a creature and an item are all "a string
* the operator has to spell right", and an unattended world write scheduled with
* a typo in it is the failure this whole feature exists to make unlikely.
*
* **A refusal is a 200 with `ok: false`, not a 4xx or a 5xx.** §F: a source that
* cannot answer degrades its field to free text with a visible warning rather
* than blocking the form. A 502 would be true about the module and wrong about
* the screen — the operator very often knows the value they want to type, and an
* authoring form that a sidecar outage can make unusable is a worse failure than
* the typo the dropdown prevents. The client renders the `reason` beside the box.
*
* `admin, editor`, like the catalog and for the same argument: this is authoring
* data, and an editor who can write the step must be able to see the values it
* 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 || ''))
return res.json(result)
}
/**
* PUT /api/v1/admin/events/actions — set one action's switch and caps.
*

View File

@@ -47,13 +47,36 @@ eventsRouter.get(
'/catalog',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question.'
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step — the action says what params it takes, a param source names a dropdown, and a cap box is a budget label and unit. A source resolves its VALUES on a request of its own (/options/:sourceId), because a source can be slow or down and must not take the catalog with it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.catalog,
)
// ── Param option sources (Phase 7) ────────────────────────────────────────
//
// Nested UNDER `/catalog`, which is where the § API surface table has always put
// it, and the nesting is the right shape rather than a formality: a source's
// values are catalog data fetched on their own request, because a source can be
// slow or down and must not take the catalog with it. It also puts the route
// permanently out of `/:id`'s way — `/:id/anything` is one route away from being
// added, and a source id read as an event id would 404 with the wrong noun.
//
// Staff, not `adminOnly`: this is authoring data, and §N2's narrow gate is about
// committing the deployment to a run, not about seeing which landmarks exist.
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.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" } } } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.options,
)
// ── The switchboard (Phase 6) ──────────────────────────────────────────────
//
// A literal path, so it is declared up here with `/catalog` rather than beside