diff --git a/client/src/api/client.js b/client/src/api/client.js
index 5a1e939..7ffba08 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -486,6 +486,13 @@ export const api = {
archiveEvent: (id) => req(`/admin/events/${id}`, { method: 'DELETE' }),
listEventVersions: (id) => req(`/admin/events/${id}/versions`),
eventCatalog: () => req('/admin/events/catalog'),
+ // Phase 7. The values behind a param's `source` — resolved by the module that
+ // registered the source, on a request of its own rather than inside the
+ // catalog, because a source can be slow or down and must not take the whole
+ // editor with it. A refusal comes back 200 with `ok: false`, so this never
+ // throws for the case the screen is meant to render: the field degrades to
+ // free text with the reason beside it.
+ eventOptions: (sourceId) => req(`/admin/events/catalog/options/${encodeURIComponent(sourceId)}`),
// Phase 6. The dry run is admin+editor: it dispatches nothing, and the author
// who wrote the definition is who should be able to price it against the caps
// before asking an admin to publish it. A report with findings comes back 200
diff --git a/client/src/modules/version.js b/client/src/modules/version.js
index bd6f97b..c2f0754 100644
--- a/client/src/modules/version.js
+++ b/client/src/modules/version.js
@@ -11,6 +11,15 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
+// 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md
+// Phase 7): a module may register event actions, budget dimensions, leases and
+// param option sources. All four are server-side registrations and nothing on
+// `window.__rg` changed — but what they produce is met on this half, in the step
+// editor: an option source is what turns a param from a text box into a dropdown
+// of real values, and a budget's label and unit are what the switchboard's cap
+// box says beside its number. This file bumps for the reason at the top: the two
+// halves state ONE version, and a module declares one `coreApi` range against
+// both.
// 1.9.0 - a module may ship its own message bodies and rules:
// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase
// 11b, decision 7). Nothing on this half changed - a seed is server-side data
@@ -65,4 +74,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
-export const MODULE_API_VERSION = '1.9.0'
+export const MODULE_API_VERSION = '1.10.0'
diff --git a/client/src/routes/admin/views/EventActions.jsx b/client/src/routes/admin/views/EventActions.jsx
index 8d8fd7a..dadfb29 100644
--- a/client/src/routes/admin/views/EventActions.jsx
+++ b/client/src/routes/admin/views/EventActions.jsx
@@ -99,7 +99,7 @@ export default function EventActions() {
await load()
setDrafts((d) => {
const next = { ...d }
- for (const dimension of action.dimensions) delete next[`${action.id}:${dimension}`]
+ for (const d of action.dimensions) delete next[`${action.id}:${d.id}`]
return next
})
setNotice(`Saved ${action.label}.`)
@@ -113,7 +113,7 @@ export default function EventActions() {
/** The caps this row would save: the drafts on top of what is stored. */
const capsOf = (action) => {
const out = {}
- for (const dimension of action.dimensions) {
+ for (const { id: dimension } of action.dimensions) {
const draft = drafts[`${action.id}:${dimension}`]
const value = draft !== undefined ? draft : action.caps[dimension]
if (value === '' || value === undefined || value === null) continue
@@ -130,7 +130,7 @@ export default function EventActions() {
}
const dirty = (action) =>
- action.dimensions.some((d) => drafts[`${action.id}:${d}`] !== undefined)
+ action.dimensions.some((d) => drafts[`${action.id}:${d.id}`] !== undefined)
if (loading) return
if (error) return
@@ -218,20 +218,48 @@ export default function EventActions() {
run gets.
- {action.dimensions.map((dimension) => (
-
- {dimension}
-
- setDrafts((d) => ({ ...d, [`${action.id}:${dimension}`]: e.target.value }))
- }
- />
+ {action.dimensions.map((d) => (
+
+ {/*
+ The LABEL, with the unit beside the box — both from the module's
+ `registerEventBudgets` declaration (Phase 7). Before it, this said
+ `uo.creatures` over an unlabelled number, which is ambiguous in exactly
+ the case that matters: 30 of what?
+ */}
+
+ {d.registered ? d.label : d.id}
+
+
+
+ setDrafts((s) => ({ ...s, [`${action.id}:${d.id}`]: e.target.value }))
+ }
+ />
+ {d.registered && d.unit && (
+ {d.unit}
+ )}
+
+ {/*
+ A dimension nobody declares is SHOWN rather than hidden. The action is
+ refused when it is saved into a step and again if it is ever dispatched,
+ so the operator needs to be told which module is incomplete — hiding the
+ row would make a broken module look like a cheap one.
+ */}
+ {!d.registered && (
+
+ No module declares this as a budget, so a step using this action is
+ refused. It cannot be capped until one does.
+
+ )}
))}
Reading the list…
+ }
+ if (entry.state === 'failed') {
+ return (
+
+ {entry.reason} — type the value by hand.
+
+ )
+ }
+ if (!entry.options.length) {
+ return (
+
+ {label} has nothing to offer right now — type the value by hand.
+
+ )
+ }
+
+ const grouped = entry.options.some((o) => o.group)
+ const groups = grouped
+ ? [...new Set(entry.options.map((o) => o.group || 'Other'))]
+ : []
+
+ return (
+ { if (e.target.value) onPick(e.target.value) }}
+ >
+
+ {disabled ? 'Fix the params JSON to pick a value' : `Pick from ${label}…`}
+
+ {grouped
+ ? groups.map((g) => (
+
+ {entry.options.filter((o) => (o.group || 'Other') === g).map((o) => (
+ {o.label}
+ ))}
+
+ ))
+ : entry.options.map((o) => {o.label} )}
+
+ )
+}
+
const DORMANT_NOTE =
'The module that registered this action is not installed. The step is kept exactly as authored — nothing was dropped — but the definition cannot be published until it is resolved.'
@@ -118,6 +182,94 @@ export default function EventEditor() {
const triggers = useMemo(() => catalog?.triggers || [], [catalog])
const triggerById = useMemo(() => new Map(triggers.map((t) => [t.id, t])), [triggers])
+ // Phase 7. source id -> { state: 'loading' | 'ok' | 'failed', options, reason }.
+ //
+ // Resolved LAZILY, one request per source, and only for the sources the steps
+ // on this page actually name. A definition uses two or three of them; a
+ // deployment with a game module installed may register a dozen, and asking a
+ // shard for eight hundred creature names to draw a form that needs none of
+ // them is a page that opens slowly for no one's benefit.
+ const [sources, setSources] = useState({})
+
+ // **The guard is a ref, and it has to be.** `setSources` QUEUES its updater
+ // rather than running it, so a "have I already asked for this?" check written
+ // inside the updater cannot be read on the next line — it has not run yet. The
+ // first draft did exactly that and the field sat on *Reading the list…* for
+ // ever, having never made the request at all: state is the wrong tool for a
+ // question that must be answered synchronously, at the call.
+ const requested = useRef(new Set())
+ const loadSource = useCallback(async (sourceId) => {
+ if (requested.current.has(sourceId)) return
+ requested.current.add(sourceId)
+ setSources((s) => ({ ...s, [sourceId]: { state: 'loading', options: [] } }))
+ try {
+ const answer = await api.admin.eventOptions(sourceId)
+ setSources((s) => ({
+ ...s,
+ [sourceId]: answer?.ok
+ ? { state: 'ok', label: answer.label, options: answer.options || [] }
+ : { state: 'failed', options: [], reason: answer?.reason || 'this list could not be read' },
+ }))
+ } catch (err) {
+ // The route answers a refusal with a 200, so reaching here means the
+ // REQUEST failed rather than the source — and the field's behaviour is the
+ // same either way: it degrades to free text and says why.
+ setSources((s) => ({
+ ...s,
+ [sourceId]: { state: 'failed', options: [], reason: err.message || 'this list could not be read' },
+ }))
+ }
+ }, [])
+
+ /**
+ * Write one param into a step's JSON box, from a picked option.
+ *
+ * Re-serialising the whole object rather than splicing text: the box holds an
+ * object the save path parses, and a string edit that produced valid-looking
+ * JSON with a duplicate key would be a value the editor and the server read
+ * differently. `2` because that is what `blankStep` writes, so picking a value
+ * does not reformat the box under the author's cursor.
+ */
+ const pickParam = (pi, si, step, name, value) => {
+ let parsed
+ try {
+ parsed = JSON.parse(step.paramsText || '{}')
+ } catch {
+ return
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return
+ setStep(pi, si, { paramsText: JSON.stringify({ ...parsed, [name]: value }, null, 2) })
+ }
+
+ /** Does this step's JSON box currently hold an object we can write into? */
+ const paramsParse = (step) => {
+ try {
+ const parsed = JSON.parse(step.paramsText || '{}')
+ return Boolean(parsed) && typeof parsed === 'object' && !Array.isArray(parsed)
+ } catch {
+ return false
+ }
+ }
+
+ // Phase 7. Every option source the steps on this page name, resolved once.
+ //
+ // An effect rather than a lookup at render time, because resolving one is a
+ // request and a request started during render is a render with a side effect.
+ // `sources` is deliberately NOT a dependency: `loadSource` keeps its own ref of
+ // what it has already asked for, so re-running this on every answer would be a
+ // pass over the same set, changing nothing.
+ useEffect(() => {
+ const wanted = new Set()
+ for (const phase of form?.phases || []) {
+ for (const step of phase.steps || []) {
+ for (const p of actionById.get(step.actionId)?.params || []) {
+ if (p.source) wanted.add(p.source)
+ }
+ }
+ }
+ for (const id of wanted) loadSource(id)
+ }, [form, actionById, loadSource])
+
const set = (patch) => setForm((f) => ({ ...f, ...patch }))
const setPhase = (pi, patch) =>
@@ -742,6 +894,14 @@ export default function EventEditor() {
{p.description}
e.g. {JSON.stringify(p.example)}
+ {p.source && (
+ pickParam(pi, si, step, p.name, v)}
+ />
+ )}
))}
diff --git a/server/routes.guards.json b/server/routes.guards.json
index 6127ca2..63d1265 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -536,6 +536,15 @@
"requireAuth"
]
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/catalog/options/:sourceId",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/runs",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index c115a80..5a85a9e 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -237,6 +237,10 @@
"method": "GET",
"path": "/api/v1/admin/events/catalog"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/events/catalog/options/:sourceId"
+ },
{
"method": "GET",
"path": "/api/v1/admin/events/runs"
diff --git a/server/src/config/coreEventActions.js b/server/src/config/coreEventActions.js
index cb6adc0..1d171f0 100644
--- a/server/src/config/coreEventActions.js
+++ b/server/src/config/coreEventActions.js
@@ -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 }
diff --git a/server/src/events/authorize.js b/server/src/events/authorize.js
index 8e09186..acf88b7 100644
--- a/server/src/events/authorize.js
+++ b/server/src/events/authorize.js
@@ -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,
diff --git a/server/src/events/spec.js b/server/src/events/spec.js
index b20c119..f7beee7 100644
--- a/server/src/events/spec.js
+++ b/server/src/events/spec.js
@@ -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(', ')}`)
}
diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js
index 51bec59..df0024d 100644
--- a/server/src/modules/loader.js
+++ b/server/src/modules/loader.js
@@ -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
diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js
index 950014e..2823c52 100644
--- a/server/src/modules/registries.js
+++ b/server/src/modules/registries.js
@@ -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
+ * ``, 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,
diff --git a/server/src/modules/version.js b/server/src/modules/version.js
index 2aa6743..caa2042 100644
--- a/server/src/modules/version.js
+++ b/server/src/modules/version.js
@@ -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 }
diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js
index 5d7c291..009a46b 100644
--- a/server/src/router/v1/admin/events.controller.js
+++ b/server/src/router/v1/admin/events.controller.js
@@ -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.
*
diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js
index 7699553..f75fbc9 100644
--- a/server/src/router/v1/admin/events.router.js
+++ b/server/src/router/v1/admin/events.router.js
@@ -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
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 46319ca..4210995 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -3958,6 +3958,89 @@
]
}
},
+ "/api/v1/admin/events/catalog/options/{sourceId}": {
+ "get": {
+ "tags": [
+ "Admin · Events"
+ ],
+ "summary": "Resolve the values behind a param option source",
+ "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.",
+ "parameters": [
+ {
+ "name": "sourceId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "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"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not staff",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"/api/v1/admin/events/runs": {
"get": {
"tags": [
diff --git a/server/test/eventActionRegistry.test.js b/server/test/eventActionRegistry.test.js
index d8fc957..a2d4148 100644
--- a/server/test/eventActionRegistry.test.js
+++ b/server/test/eventActionRegistry.test.js
@@ -247,3 +247,137 @@ test('_reset() hands the process back', () => {
assert.equal(registries.allEventActions().length, 0)
assert.equal(registries.isEventAction('core.wait'), false)
})
+
+// ── Budgets, leases and option sources (§F, Phase 7) ───────────────────────
+//
+// The three declarations that arrive WITH the module-facing seam. What is worth
+// a test here is the same thing that was worth one for actions: the closed sets
+// are closed, the required members are required, and each id space is its own.
+// The seam being reachable by a module at all is `eventModuleContract.test.js`,
+// against a real loader; these are the shape rules, at the call.
+
+const registerBudgets = (owner, entries) => {
+ const api = registries.stage(owner)
+ api.registerEventBudgets(entries)
+ registries.apply(api.staged)
+}
+
+const registerLeases = (owner, entries) => {
+ const api = registries.stage(owner)
+ api.registerEventLeases(entries)
+ registries.apply(api.staged)
+}
+
+const lease = (over = {}) => ({
+ id: 'demo.rate.gain',
+ label: 'Gain rate',
+ type: 'float',
+ min: 0.5,
+ max: 5,
+ maxDurationMs: 3_600_000,
+ read: async () => ({ ok: true, value: 1 }),
+ apply: async () => ({ ok: true }),
+ restore: async () => ({ ok: true }),
+ ...over,
+})
+
+test('a budget needs a unit, because a number on a cap box is ambiguous without one', () => {
+ assert.throws(
+ () => registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps' }]),
+ /has no unit/,
+ )
+ // Open vocabulary, deliberately: core never interprets a unit, it renders it.
+ // Closing the set would make "kilometres" a MODULE_API bump for a noun core
+ // does not read.
+ registerBudgets('demo', [{ id: 'demo.road', label: 'Road laid', unit: 'kilometres' }])
+ assert.equal(registries.eventBudget('demo.road').unit, 'kilometres')
+})
+
+test('a budget and an action are different id spaces, so one name may be both', () => {
+ // §F says it in one line — an action names a VERB and a budget names a
+ // RESOURCE — and this is the pair every module will actually write. Reading a
+ // collision here would forbid the most natural set of names there is.
+ const api = registries.stage('demo')
+ api.registerEventBudgets([{ id: 'demo.creatures', label: 'Creatures', unit: 'count' }])
+ api.registerEventActions([ok({ id: 'demo.creatures' })])
+ registries.apply(api.staged)
+ assert.equal(registries.eventBudget('demo.creatures').label, 'Creatures')
+ assert.equal(registries.eventAction('demo.creatures').label, 'Do the thing')
+})
+
+test('a budget is claimed once, and the second claim names who holds it', () => {
+ registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
+ assert.throws(
+ () => registerBudgets('other', [{ id: 'demo.wisps', label: 'Theirs', unit: 'count' }]),
+ /already registered by "demo"/,
+ )
+})
+
+test('a lease declares all three callables, and restore is not optional', () => {
+ // `read` could stand in for `restore`, and that is exactly why it may not:
+ // `read` answers "what is it now" and `restore` answers "put this back, and
+ // tell me if someone else has moved it". The drift check is the one thing a
+ // module must not be allowed to skip — a restore that writes blindly silently
+ // reverts an operator's manual fix.
+ for (const missing of ['read', 'apply', 'restore']) {
+ assert.throws(
+ () => registerLeases('demo', [lease({ [missing]: undefined })]),
+ new RegExp(`has no ${missing}\(\)`),
+ )
+ }
+})
+
+test('a numeric lease is bounded, and an unbounded one is refused', () => {
+ // A lease on a rate multiplier with no range is an operator one keystroke away
+ // from setting a shard's skill gain to 5000 — and unlike a cap, a bad lease
+ // value is in force the moment it is applied.
+ assert.throws(() => registerLeases('demo', [lease({ min: undefined })]), /numeric min and max/)
+ assert.throws(() => registerLeases('demo', [lease({ min: 9, max: 2 })]), /min 9 above max 2/)
+ assert.throws(
+ () => registerLeases('demo', [lease({ type: 'int', min: 0.5, max: 5 })]),
+ /whole-number min and max/,
+ )
+ // A bool holds no range and is not asked for one.
+ registerLeases('demo', [lease({ id: 'demo.seasonal', type: 'bool', min: undefined, max: undefined })])
+ assert.equal(registries.eventLease('demo.seasonal').min, null)
+})
+
+test('a lease may not be held longer than core is willing to promise', () => {
+ assert.throws(
+ () => registerLeases('demo', [lease({ maxDurationMs: registries.MAX_LEASE_MS + 1 })]),
+ /maxDurationMs must be 1\.\./,
+ )
+ assert.throws(() => registerLeases('demo', [lease({ maxDurationMs: 0 })]), /maxDurationMs must be/)
+ assert.throws(() => registerLeases('demo', [lease({ maxDurationMs: 1.5 })]), /maxDurationMs must be/)
+})
+
+test('an unknown lease type is refused, because core validates operator input against it', () => {
+ assert.throws(() => registerLeases('demo', [lease({ type: 'colour' })]), /needs a type, one of/)
+ assert.deepEqual(registries.LEASE_TYPES, ['int', 'float', 'bool', 'string'])
+})
+
+test('an option source needs a resolver, and core registers one of its own', () => {
+ const api = registries.stage('demo')
+ assert.throws(
+ () => api.registerEventOptionSources([{ id: 'demo.options.hues', label: 'Hues' }]),
+ /has no resolve\(\)/,
+ )
+
+ // Core through the same door (Phase 7): `core.announce`'s `leg` param names a
+ // source, and the announce legs are already a registry with labels in them.
+ registries.registerCore()
+ assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['core.options.legs'])
+ const leg = registries.eventAction('core.announce').params.find((p) => p.name === 'leg')
+ assert.equal(leg.source, 'core.options.legs')
+})
+
+test('_reset() hands back the three new registries too', () => {
+ registries.registerCore()
+ registerBudgets('demo', [{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
+ registerLeases('demo', [lease()])
+ assert.equal(registries.allEventBudgets().length, 1)
+ registries._reset()
+ assert.deepEqual(registries.allEventBudgets(), [])
+ assert.deepEqual(registries.allEventLeases(), [])
+ assert.deepEqual(registries.allEventOptionSources(), [])
+})
diff --git a/server/test/eventAuthorize.test.js b/server/test/eventAuthorize.test.js
index 40dad78..beb69ca 100644
--- a/server/test/eventAuthorize.test.js
+++ b/server/test/eventAuthorize.test.js
@@ -68,9 +68,43 @@ afterEach(() => {
registries._reset()
})
-const register = (entries, owner = 'test') => {
+/**
+ * Declare the budget dimensions a batch of actions prices.
+ *
+ * §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered
+ * is refused BEFORE any cap arithmetic runs. Without this, every cap test below
+ * would pass for the WRONG reason — refused by the layer above the one under
+ * test. Discovered the way the switchboard discovers them, by pricing the
+ * action's own declared examples, so a test never has to keep a second list of
+ * its dimensions in step with its `cost()`.
+ *
+ * Written out here rather than borrowed from `authorize.dimensionsOf` so this
+ * file keeps stubbing exactly what it means to stub. The undeclared case is not
+ * an omission; it has its own test.
+ */
+const declaredBudgets = (entries, owner) => {
+ const dimensions = new Set()
+ for (const e of entries) {
+ if (typeof e.cost !== 'function') continue
+ const params = {}
+ for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example
+ let priced
+ try {
+ priced = e.cost(params)
+ } catch {
+ continue
+ }
+ for (const d of Object.keys(priced || {})) dimensions.add(d)
+ }
+ return [...dimensions]
+ .filter((id) => id.startsWith(`${owner}.`))
+ .map((id) => ({ id, label: id, unit: 'count' }))
+}
+
+const register = (entries, { owner = 'test', budgets = true } = {}) => {
const api = registries.stage(owner)
api.registerEventActions(entries)
+ if (budgets) api.registerEventBudgets(declaredBudgets(entries, owner))
registries.apply(api.staged)
}
@@ -192,10 +226,10 @@ test('the refusal names the action by its LABEL, not its id', async () => {
// ── Pricing ────────────────────────────────────────────────────────────────
test('cost() is called with the step params and its answer is what core enforces', async () => {
- register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
+ register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
setSetting('test.spawn', true)
const priced = authorize.priceOf(registries.eventAction('test.spawn'), { count: 12 })
- assert.deepEqual(priced, { 'x.creatures': 12 })
+ assert.deepEqual(priced, { 'test.creatures': 12 })
})
test('a cost() that throws makes the action unpriceable, never free', async () => {
@@ -252,10 +286,10 @@ test('dimensions are discovered by pricing the declared examples', async () => {
action('test.spawn', {
risk: 'change',
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
- cost: (p) => ({ 'x.creatures': p.count, 'x.bosses': 1 }),
+ cost: (p) => ({ 'test.creatures': p.count, 'test.bosses': 1 }),
}),
])
- assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['x.bosses', 'x.creatures'])
+ assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['test.bosses', 'test.creatures'])
})
test('an action whose cost() cannot survive its own examples reports no dimensions', async () => {
@@ -275,44 +309,44 @@ test('an action whose cost() cannot survive its own examples reports no dimensio
test('two actions spending one dimension resolve to the tightest cap, and it says whose', async () => {
register([
- action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
- action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
+ action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
+ action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
])
- setSetting('test.spawn', true, { 'x.creatures': 30 })
- setSetting('test.horde', true, { 'x.creatures': 10 })
+ setSetting('test.spawn', true, { 'test.creatures': 30 })
+ setSetting('test.horde', true, { 'test.creatures': 10 })
const caps = authorize.effectiveCaps(
[{ actionId: 'test.spawn', params: {} }, { actionId: 'test.horde', params: {} }],
await settingsDb.byIds(['test.spawn', 'test.horde']),
)
- assert.deepEqual(caps, { 'x.creatures': { cap: 10, from: 'test.horde' } })
+ assert.deepEqual(caps, { 'test.creatures': { cap: 10, from: 'test.horde' } })
})
test('an action that declines to cap a dimension does not raise a ceiling another one set', async () => {
// `null` is uncapped and must never win a minimum. Otherwise adding a second
// verb to an event would silently remove the bound on the first.
register([
- action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
- action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
+ action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
+ action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
])
- setSetting('test.spawn', true, { 'x.creatures': 30 })
+ setSetting('test.spawn', true, { 'test.creatures': 30 })
setSetting('test.horde', true, {})
const caps = authorize.effectiveCaps(
[{ actionId: 'test.horde', params: {} }, { actionId: 'test.spawn', params: {} }],
await settingsDb.byIds(['test.spawn', 'test.horde']),
)
- assert.deepEqual(caps, { 'x.creatures': { cap: 30, from: 'test.spawn' } })
+ assert.deepEqual(caps, { 'test.creatures': { cap: 30, from: 'test.spawn' } })
})
test('a dimension nobody caps is still a row, uncapped', async () => {
// Seeded so the meter counts it. The distinction it preserves is that a MISSING
// row means something else entirely: a step spending a dimension its own run's
// version never priced.
- register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) })])
+ register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) })])
setSetting('test.spawn', true, {})
const caps = authorize.effectiveCaps([{ actionId: 'test.spawn', params: {} }], await settingsDb.byIds(['test.spawn']))
- assert.deepEqual(caps, { 'x.creatures': { cap: null, from: null } })
+ assert.deepEqual(caps, { 'test.creatures': { cap: null, from: null } })
})
test('a step naming an unregistered action contributes no dimension', async () => {
@@ -325,13 +359,13 @@ test('with no run, the question is whether the cost could EVER fit', async () =>
// The dry run's and the editor's question. A step asking for 40 under a cap of
// 30 is an authoring error answerable before anything is scheduled, which is
// the entire value of catching it here rather than at 2am.
- register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
- setSetting('test.spawn', true, { 'x.creatures': 30 })
+ register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
+ setSetting('test.spawn', true, { 'test.creatures': 30 })
const bad = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 40 } })
assert.equal(bad.ok, false)
assert.equal(bad.code, 'cap')
- assert.equal(bad.dimension, 'x.creatures')
+ assert.equal(bad.dimension, 'test.creatures')
assert.equal(bad.requested, 40)
assert.equal(bad.cap, 30)
@@ -340,9 +374,9 @@ test('with no run, the question is whether the cost could EVER fit', async () =>
})
test('with a run and spend, the check IS the spend', async () => {
- register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
- setSetting('test.spawn', true, { 'x.creatures': 30 })
- setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
+ register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
+ setSetting('test.spawn', true, { 'test.creatures': 30 })
+ setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
const v = await authorize.mayInvoke({
action: registries.eventAction('test.spawn'),
@@ -352,24 +386,24 @@ test('with a run and spend, the check IS the spend', async () => {
})
assert.equal(v.ok, true)
assert.equal(v.spent, true)
- assert.equal(store.budget.get('1:x.creatures').consumed, 12)
+ assert.equal(store.budget.get('1:test.creatures').consumed, 12)
})
test('without spend the cap check is advisory and moves nothing', async () => {
- register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
- setSetting('test.spawn', true, { 'x.creatures': 30 })
- setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
+ register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
+ setSetting('test.spawn', true, { 'test.creatures': 30 })
+ setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN })
assert.equal(v.ok, true)
assert.equal(v.spent, undefined)
- assert.equal(store.budget.get('1:x.creatures').consumed, 0)
+ assert.equal(store.budget.get('1:test.creatures').consumed, 0)
})
test('a refusal names the numbers an operator needs, not just "refused"', async () => {
- register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
- setSetting('test.spawn', true, { 'x.creatures': 30 })
- setBudget(RUN.id, 'x.creatures', { consumed: 28, cap: 30 })
+ register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
+ setSetting('test.spawn', true, { 'test.creatures': 30 })
+ setBudget(RUN.id, 'test.creatures', { consumed: 28, cap: 30 })
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
assert.equal(v.ok, false)
@@ -386,24 +420,24 @@ test('a partial spend across dimensions is given back when a later one is refuse
// costing creatures AND bosses can take the creatures and be refused the
// bosses, and a step that did not run must not have spent anything.
register([
- action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5, 'x.bosses': 2 }) }),
+ action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5, 'test.bosses': 2 }) }),
])
setSetting('test.spawn', true)
- setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
- setBudget(RUN.id, 'x.bosses', { consumed: 1, cap: 2 })
+ setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
+ setBudget(RUN.id, 'test.bosses', { consumed: 1, cap: 2 })
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
assert.equal(v.ok, false)
- assert.equal(v.dimension, 'x.bosses')
- assert.equal(store.budget.get('1:x.creatures').consumed, 0, 'the creatures must have been given back')
- assert.equal(store.budget.get('1:x.bosses').consumed, 1)
+ assert.equal(v.dimension, 'test.bosses')
+ assert.equal(store.budget.get('1:test.creatures').consumed, 0, 'the creatures must have been given back')
+ assert.equal(store.budget.get('1:test.bosses').consumed, 1)
})
test('spending a dimension the run has no row for is refused, and says so in its own words', async () => {
// Fail-closed, and a distinct code: "this run has no budget for that" is a
// different diagnosis from "the cap is spent", and an operator who raises the
// cap in answer to the wrong one has not fixed anything.
- register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })])
+ register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.ghosts': 1 }) })])
setSetting('test.spawn', true)
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
assert.equal(v.ok, false)
@@ -411,13 +445,36 @@ test('spending a dimension the run has no row for is refused, and says so in its
assert.match(v.reason, /no budget for/)
})
-test('an uncapped budget row never refuses', async () => {
- register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 999 }) })])
+test('a dimension no module declares is refused before any cap arithmetic', async () => {
+ // §F, fail closed (org lead, 2026-09-03). The refusal has to be its OWN code:
+ // told "the cap is spent", an operator goes and raises a cap, and nothing
+ // changes, because the problem is a module whose declaration is incomplete.
+ register([action('test.spawn', { risk: 'notify', cost: () => ({ 'test.wisps': 3 }) })], {
+ budgets: false,
+ })
setSetting('test.spawn', true)
- setBudget(RUN.id, 'x.creatures', { consumed: 5, cap: null })
+ setBudget(1, 'test.wisps', { cap: 100 })
+
+ const verdict = await authorize.mayInvoke({
+ action: registries.eventAction('test.spawn'),
+ params: {},
+ run: RUN,
+ spend: true,
+ })
+ assert.equal(verdict.ok, false)
+ assert.equal(verdict.code, 'undeclared')
+ assert.match(verdict.reason, /no module declares/)
+ // And it did not spend: a step refused for this reason never ran.
+ assert.equal(store.budget.get('1:test.wisps').consumed, 0)
+})
+
+test('an uncapped budget row never refuses', async () => {
+ register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 999 }) })])
+ setSetting('test.spawn', true)
+ setBudget(RUN.id, 'test.creatures', { consumed: 5, cap: null })
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
assert.equal(v.ok, true)
- assert.equal(store.budget.get('1:x.creatures').consumed, 1004)
+ assert.equal(store.budget.get('1:test.creatures').consumed, 1004)
})
// ── The layers are ordered ─────────────────────────────────────────────────
@@ -426,7 +483,7 @@ test('the layers answer in order: role before enablement before cap', async () =
// Not cosmetic. An editor told "that action is disabled" would go and ask an
// admin to enable it, and still not be allowed to author the step — the honest
// first answer is the role.
- register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })])
+ register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 99 }) })])
// Disabled AND over cap AND world-changing, all at once.
const v = await authorize.mayInvoke({
user: EDITOR,
@@ -435,7 +492,7 @@ test('the layers answer in order: role before enablement before cap', async () =
})
assert.equal(v.code, 'role')
- setSetting('test.spawn', false, { 'x.creatures': 1 })
+ setSetting('test.spawn', false, { 'test.creatures': 1 })
const asAdmin = await authorize.mayInvoke({
user: ADMIN,
action: registries.eventAction('test.spawn'),
diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js
new file mode 100644
index 0000000..1823400
--- /dev/null
+++ b/server/test/eventModuleContract.test.js
@@ -0,0 +1,515 @@
+// ── The module contract, proved with a module (EVENTS_PLAN.md Phase 7) ─────
+//
+// §F is the seam this phase opens: `registerEventActions`, `registerEventBudgets`,
+// `registerEventLeases` and `registerEventOptionSources`, at MODULE_API 1.10.0.
+// Every one of them was reachable only by `registerCore()` before this phase, and
+// the plan is explicit about how to prove they are reachable now:
+//
+// > **Prove it with a throwaway module, not with module-uo.** A contract
+// > validated only against the module it was carved out of has not been
+// > validated, and P9 should be the *second* consumer of this seam.
+//
+// So every test below writes a real module to a real directory, points
+// MODULES_DIR at it and lets the real loader scan, validate, `register()` and
+// commit it. Nothing here stubs the loader or calls `registries.stage()` by hand:
+// a test that staged directly would pass just as happily against the code before
+// this phase, when `buildApi` forwarded none of these four names.
+//
+// **The half this file cares most about is the failure half.** §F's load-bearing
+// envelope rule is that *no shape a failure can take may read as success* — a
+// rejected promise, a throw, a timeout, a non-object and a missing `ok` are all
+// `{ ok: false, retry: true }`, which is `registerTeamProvider`'s default
+// inverted, because the expensive mistake here is recording a world change that
+// did not happen. Those five shapes are dispatched from a module below, not
+// constructed as literals, so what is under test is the path a module actually
+// takes.
+//
+// The pool points at a closed port before anything is required: the loader's
+// `buildCtx` pulls in the models, which build a mariadb pool at require time. No
+// query is ever run.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+
+const { test, beforeEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const express = require('express')
+
+const db = require('../src/utils/db')
+const registries = require('../src/modules/registries')
+const dispatch = require('../src/events/dispatch')
+const authorize = require('../src/events/authorize')
+const { MODULE_API_VERSION } = require('../src/modules/version')
+
+after(() => db.close())
+
+let tmpRoot
+
+const emptyTiers = () => ({
+ public: express.Router(),
+ admin: express.Router(),
+ player: express.Router(),
+})
+
+/**
+ * Write a module and let the real loader scan it.
+ *
+ * MODULES_DIR is read into a const at require time — it has to be, the scan is
+ * synchronous and happens while app.js is being required — so busting the cache
+ * is the only honest way to point the loader somewhere else.
+ */
+function loadModule(id, source, manifest = {}) {
+ const dir = path.join(tmpRoot, id)
+ fs.mkdirSync(dir, { recursive: true })
+ fs.writeFileSync(
+ path.join(dir, 'module.json'),
+ JSON.stringify({ id, name: id, version: '1.0.0', coreApi: '^1.10.0', server: 'index.js', ...manifest }),
+ )
+ fs.writeFileSync(path.join(dir, 'index.js'), source)
+
+ process.env.MODULES_DIR = tmpRoot
+ registries._reset()
+ delete require.cache[require.resolve('../src/modules/loader')]
+ // eslint-disable-next-line global-require
+ const loader = require('../src/modules/loader')
+ loader.load(emptyTiers())
+ return loader.list().find((m) => m.id === id)
+}
+
+/** The state a module that loaded cleanly is in, with its reason if it did not. */
+const assertRegistered = (record) => {
+ assert.equal(record.state, 'registered', record.reason || 'expected the module to register')
+}
+
+const RUN = { id: 1, scope: '' }
+const step = (actionId, params = {}) => ({
+ id: 1,
+ action_id: actionId,
+ params,
+ idempotency_key: 'k-1',
+})
+
+beforeEach(() => {
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-events-module-'))
+})
+
+// ── The seam is open ───────────────────────────────────────────────────────
+
+test('the version a module declares against is 1.10.0', () => {
+ // Not decoration. `coreApi: "^1.10.0"` on every module below is what makes
+ // these tests fail loudly rather than quietly if the bump is ever reverted —
+ // the loader would refuse the manifest and every assertion would become "the
+ // module did not register", which is the same failure the seam closing would
+ // produce. Asserting the number here says which of the two it was.
+ assert.equal(MODULE_API_VERSION, '1.10.0')
+})
+
+test('a module registers actions, budgets, leases and option sources', () => {
+ const record = loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventBudgets([
+ { id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' },
+ ])
+ api.registerEventActions([{
+ id: 'demo.wisp.summon',
+ label: 'Summon wisps',
+ risk: 'change',
+ reversible: 'ledger',
+ cost: (p) => ({ 'demo.wisps': p.count }),
+ params: [{ name: 'count', type: 'int', required: true, example: 3 }],
+ async perform() { return { ok: true, resources: [{ kind: 'wisp', ref: '0x1' }] } },
+ async revert() { return { ok: true } },
+ }])
+ api.registerEventLeases([{
+ id: 'demo.rate.gain',
+ label: 'Gain rate',
+ type: 'float',
+ min: 0.5,
+ max: 5,
+ maxDurationMs: 3600000,
+ async read() { return { ok: true, value: 1 } },
+ async apply() { return { ok: true } },
+ async restore() { return { ok: true } },
+ }])
+ api.registerEventOptionSources([{
+ id: 'demo.options.hues',
+ label: 'Hues',
+ async resolve() { return [{ value: '1157', label: 'Blood', group: 'Reds' }] },
+ }])
+ }`)
+
+ assertRegistered(record)
+ assert.equal(registries.eventAction('demo.wisp.summon').owner, 'demo')
+ assert.deepEqual(registries.eventBudget('demo.wisps'), {
+ owner: 'demo', id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', description: '',
+ })
+ assert.equal(registries.eventLease('demo.rate.gain').maxDurationMs, 3600000)
+ assert.deepEqual(registries.allEventOptionSources().map((s) => s.id), ['demo.options.hues'])
+})
+
+test('the catalog never carries a callable, whichever registration it came from', async () => {
+ // The rule every registry in this file already keeps, restated for four new
+ // shapes at once: these objects LEAVE THE PROCESS, and the browser's whole
+ // relationship with any of them is naming one by id. A `perform` or a
+ // `resolve` riding out would make §F's "a module registers actions server-side
+ // and adds no routes for them" false in the one direction nobody would notice.
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventActions([{
+ id: 'demo.a', label: 'A', risk: 'notify', reversible: 'none',
+ async perform() { return { ok: true } },
+ }])
+ api.registerEventLeases([{
+ id: 'demo.l', label: 'L', type: 'bool', maxDurationMs: 1000,
+ async read() { return { ok: true } },
+ async apply() { return { ok: true } },
+ async restore() { return { ok: true } },
+ }])
+ api.registerEventOptionSources([
+ { id: 'demo.o', label: 'O', async resolve() { return [] } },
+ ])
+ }`))
+
+ for (const a of registries.allEventActions()) {
+ assert.equal(a.perform, undefined)
+ assert.equal(a.revert, undefined)
+ assert.equal(a.cost, undefined)
+ }
+ for (const l of registries.allEventLeases()) {
+ assert.equal(l.read, undefined)
+ assert.equal(l.apply, undefined)
+ assert.equal(l.restore, undefined)
+ }
+ for (const s of registries.allEventOptionSources()) assert.equal(s.resolve, undefined)
+})
+
+test('a second call is a module changing its mind, and it fails the module alone', () => {
+ // `once`, on all four, for the reason every batch registration takes it: a
+ // batch is a module's complete statement about what it declares. And the
+ // failure is the loader's §4.4 guarantee — recorded against the module, the
+ // site still up, nothing committed.
+ const record = loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventBudgets([{ id: 'demo.a', label: 'A', unit: 'count' }])
+ api.registerEventBudgets([{ id: 'demo.b', label: 'B', unit: 'count' }])
+ }`)
+ assert.equal(record.state, 'startup_failed')
+ assert.match(record.reason, /registerEventBudgets\(\) called twice/)
+ // Validate-then-commit, per registrant: the FIRST batch is gone too.
+ assert.equal(registries.eventBudget('demo.a'), null)
+})
+
+test('a module may not name a budget outside its own prefix', () => {
+ const record = loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventBudgets([{ id: 'uo.creatures', label: 'Creatures', unit: 'count' }])
+ }`)
+ assert.equal(record.state, 'startup_failed')
+ assert.match(record.reason, /not namespaced "demo\."/)
+})
+
+test('a module that registers nothing at all is a module, not a failure', () => {
+ // §F states it once because it governs every member: everything a module
+ // registers is optional, and core with none of it is still an event engine
+ // that can announce, wait, cue a human and publish results.
+ const record = loadModule('quiet', 'module.exports = () => {}')
+ assertRegistered(record)
+ assert.deepEqual(registries.allEventBudgets(), [])
+ assert.deepEqual(registries.allEventActions().map((a) => a.id), [])
+})
+
+// ── The envelope: no failure shape reads as success ────────────────────────
+
+test('every shape a module failure can take is dispatched as a retry, not a success', async () => {
+ // The five §F names, answered by a real module through the real dispatcher.
+ // Written as one module with five verbs rather than five modules, because what
+ // is under test is the CLASSIFIER and a module per shape would be four extra
+ // loader scans saying nothing.
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ const verb = (id, perform) => ({
+ id, label: id, risk: 'notify', reversible: 'none', budgetMs: 200, perform,
+ })
+ api.registerEventActions([
+ verb('demo.rejects', async () => { return Promise.reject(new Error('the socket went away')) }),
+ verb('demo.throws', async () => { throw new Error('a typo in the module') }),
+ verb('demo.hangs', () => new Promise(() => {})),
+ verb('demo.lies', async () => 'fine'),
+ verb('demo.forgets', async () => ({ resources: [] })),
+ ])
+ }`))
+
+ for (const id of ['demo.rejects', 'demo.throws', 'demo.hangs', 'demo.lies', 'demo.forgets']) {
+ const result = await dispatch.dispatchStep(step(id), { run: RUN })
+ assert.equal(result.outcome, 'retry', `${id} must not read as success`)
+ assert.ok(result.error, `${id} must say what went wrong`)
+ }
+})
+
+test('a module that means "never" says so, and only then is it terminal', async () => {
+ // The inverse of the rule above, and the reason `retry` is opted OUT of rather
+ // than into: an envelope that forgot to say anything gets the benefit of the
+ // doubt on the transient question, so only a module that states `retry: false`
+ // gets a step marked as never going to work.
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventActions([
+ { id: 'demo.never', label: 'Never', risk: 'notify', reversible: 'none',
+ async perform() { return { ok: false, retry: false, error: 'there is no such gate' } } },
+ { id: 'demo.later', label: 'Later', risk: 'notify', reversible: 'none',
+ async perform() { return { ok: false, error: 'the shard is restarting' } } },
+ ])
+ }`))
+
+ const never = await dispatch.dispatchStep(step('demo.never'), { run: RUN })
+ assert.equal(never.outcome, 'terminal')
+ assert.equal(never.error, 'there is no such gate')
+
+ const later = await dispatch.dispatchStep(step('demo.later'), { run: RUN })
+ assert.equal(later.outcome, 'retry')
+})
+
+test('budgetMs is the contract’s deadline, and a wedged module does not hold the tick', async () => {
+ // Declared by the module, bounded by the registry, enforced by the dispatcher.
+ // Without it a `perform()` awaiting a socket that never answers holds the
+ // step's claim until the lease expires and the reclaim re-dispatches it, which
+ // is how one wedged sidecar becomes an infinite loop rather than a failed step.
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventActions([{
+ id: 'demo.hangs', label: 'Hangs', risk: 'notify', reversible: 'none',
+ budgetMs: 120,
+ perform: () => new Promise(() => {}),
+ }])
+ }`))
+
+ const started = Date.now()
+ const result = await dispatch.dispatchStep(step('demo.hangs'), { run: RUN })
+ assert.equal(result.outcome, 'retry')
+ assert.match(result.error, /exceeded its 120ms budget/)
+ assert.ok(Date.now() - started < 2000, 'the runner stopped waiting long before any lease would expire')
+})
+
+test('the two success shapes that mean "not finished" reach a module through the same door', async () => {
+ // §F, and the org lead's 2026-09-02 decision: both are ordinary envelope
+ // members rather than special cases keyed on an action id, so the runner never
+ // names a verb — and a module's own long-running action gets `await: 'human'`
+ // and `holdFor` for free, exactly as `core.cue` and `core.wait` do.
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventActions([
+ { id: 'demo.parks', label: 'Parks', risk: 'notify', reversible: 'none',
+ async perform() { return { ok: true, await: 'human' } } },
+ { id: 'demo.holds', label: 'Holds', risk: 'notify', reversible: 'none',
+ async perform() { return { ok: true, holdFor: 300 } } },
+ ])
+ }`))
+
+ const parked = await dispatch.dispatchStep(step('demo.parks'), { run: RUN })
+ assert.equal(parked.outcome, 'parked')
+
+ const held = await dispatch.dispatchStep(step('demo.holds'), { run: RUN })
+ assert.equal(held.outcome, 'done')
+ assert.equal(held.holdSeconds, 300)
+})
+
+// ── verify: true is a parameter a module must honour ───────────────────────
+
+test('verify rides through to the module unchanged, and a dry run changes nothing', async () => {
+ // §F: *"`verify: true` must change nothing and must answer honestly"*. Core
+ // cannot enforce the first half — only the module knows what its own writes
+ // are — so what IS testable is that the flag arrives, and that it arrives down
+ // the same dispatcher the real run uses. A dry run down a second code path is
+ // a dry run of the second path.
+ const written = []
+ global.__rgDemoWrites = written
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventActions([{
+ id: 'demo.writes', label: 'Writes', risk: 'change', reversible: 'none',
+ async perform({ verify, params }) {
+ if (verify) return { ok: true, wouldWrite: params.what }
+ global.__rgDemoWrites.push(params.what)
+ return { ok: true }
+ },
+ }])
+ }`))
+
+ const dry = await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), {
+ run: RUN,
+ verify: true,
+ })
+ assert.equal(dry.outcome, 'done')
+ assert.deepEqual(written, [], 'a dry run wrote something')
+
+ await dispatch.dispatchStep(step('demo.writes', { what: 'a gate' }), { run: RUN })
+ assert.deepEqual(written, ['a gate'])
+ delete global.__rgDemoWrites
+})
+
+test('the whole envelope reaches the module, idempotency key included', async () => {
+ // The key is the module's half of a retry that is safe on the game side, and a
+ // module cannot pass it down its own wire if core does not hand it over. It is
+ // asserted here rather than in the runner's tests because THIS is the surface
+ // a module author reads.
+ global.__rgDemoEnvelope = null
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventActions([{
+ id: 'demo.echo', label: 'Echo', risk: 'notify', reversible: 'none',
+ async perform(envelope) { global.__rgDemoEnvelope = envelope; return { ok: true } },
+ }])
+ }`))
+
+ await dispatch.dispatchStep(step('demo.echo', { a: 1 }), { run: { id: 7, scope: 'atlantic' } })
+ const envelope = global.__rgDemoEnvelope
+ assert.deepEqual(envelope, {
+ runId: 7,
+ stepId: 1,
+ idempotencyKey: 'k-1',
+ scope: 'atlantic',
+ params: { a: 1 },
+ actor: null,
+ verify: false,
+ })
+ delete global.__rgDemoEnvelope
+})
+
+// ── Budgets: a module cannot spend what it did not declare ─────────────────
+
+test('a cost naming a dimension the module never declared is refused', async () => {
+ // §F, fail closed (org lead, 2026-09-03). The module here is not malicious and
+ // not exotic — it is one that declared two budgets and priced a third, which is
+ // what a rename looks like. The refusal has its own code because the fix is a
+ // module's declaration, not a deployment's cap.
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps', unit: 'count' }])
+ api.registerEventActions([{
+ id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
+ cost: () => ({ 'demo.wraiths': 2 }),
+ async perform() { return { ok: true } },
+ }])
+ }`))
+
+ // `settings: null` because this file has no database: passing it explicitly is
+ // what tells `mayInvoke` not to go and read the switchboard row, and the
+ // enablement layer answers before the cap layer either way.
+ const verdict = await authorize.mayInvoke({
+ action: registries.eventAction('demo.summon'),
+ params: {},
+ settings: null,
+ })
+ assert.equal(verdict.ok, false)
+ assert.equal(verdict.code, 'undeclared')
+ assert.match(verdict.reason, /demo\.wraiths/)
+})
+
+test('a declared dimension is priced, named and unbounded until an operator says otherwise', async () => {
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventBudgets([{ id: 'demo.wisps', label: 'Wisps summoned', unit: 'count' }])
+ api.registerEventActions([{
+ id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
+ cost: (p) => ({ 'demo.wisps': p.count }),
+ params: [{ name: 'count', type: 'int', required: true, example: 4 }],
+ async perform() { return { ok: true } },
+ }])
+ }`))
+
+ // What the switchboard renders: the id the action prices, dressed with what the
+ // module called it. Discovered by pricing the declared example, which is why
+ // §F makes `example` required on every param.
+ assert.deepEqual(authorize.budgetsOf(registries.eventAction('demo.summon')), [
+ { id: 'demo.wisps', label: 'Wisps summoned', unit: 'count', registered: true },
+ ])
+
+ // And with no settings row and no run, the answer is yes: a declared budget is
+ // a dimension that can be counted, not a bound that has been set.
+ const verdict = await authorize.mayInvoke({
+ action: registries.eventAction('demo.summon'),
+ params: { count: 4 },
+ settings: null,
+ })
+ assert.deepEqual(verdict, { ok: true, cost: { 'demo.wisps': 4 } })
+})
+
+// ── Option sources ─────────────────────────────────────────────────────────
+
+test('a module answers its own option source, and core normalises what comes back', async () => {
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventOptionSources([{
+ id: 'demo.options.hues', label: 'Hues',
+ async resolve() {
+ return [
+ { value: 1157, label: 'Blood', group: 'Reds' },
+ { value: '2213', label: 'Ice' },
+ { value: '', label: 'a blank nobody can pick' },
+ 'not an option at all',
+ ]
+ },
+ }])
+ }`))
+
+ const answer = await registries.resolveOptionSource('demo.options.hues')
+ assert.equal(answer.ok, true)
+ assert.equal(answer.owner, 'demo')
+ // Coerced to strings, because this array is rendered into a `` and
+ // submitted back as text; and the two entries that could not become an option
+ // are dropped rather than becoming one that submits "undefined".
+ assert.deepEqual(answer.options, [
+ { value: '1157', label: 'Blood', group: 'Reds' },
+ { value: '2213', label: 'Ice' },
+ ])
+})
+
+test('every way an option source can fail degrades the field rather than breaking it', async () => {
+ // §F: a refusal degrades the field to free text with a visible warning. So none
+ // of these throws, and each says something an operator can act on — an
+ // authoring form a shard outage can make unusable is a worse failure than the
+ // typo the dropdown exists to prevent.
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventOptionSources([
+ { id: 'demo.options.throws', label: 'Throws', async resolve() { throw new Error('sidecar down') } },
+ { id: 'demo.options.lies', label: 'Lies', async resolve() { return { nope: true } } },
+ { id: 'demo.options.empty', label: 'Empty', async resolve() { return [] } },
+ ])
+ }`))
+
+ const threw = await registries.resolveOptionSource('demo.options.throws')
+ assert.equal(threw.ok, false)
+ assert.match(threw.reason, /could not be read/)
+
+ const lied = await registries.resolveOptionSource('demo.options.lies')
+ assert.equal(lied.ok, false)
+ assert.match(lied.reason, /no option list/)
+
+ // An EMPTY list is not a failure. A source that legitimately has nothing to
+ // offer today — no landmarks configured yet — must not be reported as broken,
+ // because the two have different fixes.
+ const empty = await registries.resolveOptionSource('demo.options.empty')
+ assert.equal(empty.ok, true)
+ assert.deepEqual(empty.options, [])
+
+ const missing = await registries.resolveOptionSource('demo.options.gone')
+ assert.equal(missing.ok, false)
+ assert.match(missing.reason, /no module registers/)
+})
+
+// ── Uninstall is dormancy, never an error ──────────────────────────────────
+
+test('an action whose module is gone goes dormant, and a step naming it fails terminal with the module named', async () => {
+ // §F and §L, verbatim: *"a step naming one fails terminal with the module named
+ // and the run degrades — never a silent skip"*. The scenario is real and
+ // ordinary: a module was uninstalled between the publish that pinned the
+ // version and the run that dispatches it.
+ assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
+ api.registerEventActions([{
+ id: 'demo.summon', label: 'Summon', risk: 'notify', reversible: 'none',
+ async perform() { return { ok: true } },
+ }])
+ }`))
+ assert.equal(registries.isEventAction('demo.summon'), true)
+
+ // The uninstall: a fresh scan of a directory the module is no longer in.
+ fs.rmSync(path.join(tmpRoot, 'demo'), { recursive: true, force: true })
+ loadModule('other', 'module.exports = () => {}')
+ assert.equal(registries.isEventAction('demo.summon'), false)
+
+ const result = await dispatch.dispatchStep(step('demo.summon'), { run: RUN })
+ assert.equal(result.outcome, 'terminal')
+ assert.equal(result.dormant, true)
+ assert.match(result.error, /no module registers "demo\.summon"/)
+})
diff --git a/server/test/eventRunner.test.js b/server/test/eventRunner.test.js
index 88bd6a7..d441766 100644
--- a/server/test/eventRunner.test.js
+++ b/server/test/eventRunner.test.js
@@ -483,9 +483,43 @@ afterEach(() => {
})
/** Register actions the way a module does, through the real staging area. */
+/**
+ * Declare the budget dimensions a batch of actions prices.
+ *
+ * §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered
+ * is refused BEFORE any cap arithmetic runs. Without this, every cap test below
+ * would pass for the WRONG reason — refused by the layer above the one under
+ * test. Discovered the way the switchboard discovers them, by pricing the
+ * action's own declared examples, so a test never has to keep a second list of
+ * its dimensions in step with its `cost()`.
+ *
+ * Written out here rather than borrowed from `authorize.dimensionsOf` so this
+ * file keeps stubbing exactly what it means to stub. The undeclared case is not
+ * an omission; it has its own test.
+ */
+const declaredBudgets = (entries, owner) => {
+ const dimensions = new Set()
+ for (const e of entries) {
+ if (typeof e.cost !== 'function') continue
+ const params = {}
+ for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example
+ let priced
+ try {
+ priced = e.cost(params)
+ } catch {
+ continue
+ }
+ for (const d of Object.keys(priced || {})) dimensions.add(d)
+ }
+ return [...dimensions]
+ .filter((id) => id.startsWith(`${owner}.`))
+ .map((id) => ({ id, label: id, unit: 'count' }))
+}
+
const register = (entries, owner = 'test') => {
const api = registries.stage(owner)
api.registerEventActions(entries)
+ api.registerEventBudgets(declaredBudgets(entries, owner))
registries.apply(api.staged)
}
@@ -1161,22 +1195,22 @@ test('a step over its cap is refused with the dimension and the numbers on the l
// "You asked for 40 and this deployment allows 30" is an authoring error, and
// it has to arrive as those words rather than as a stack trace.
register([
- scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) }),
+ scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) }),
])
- setSwitch('test.spawn', true, { 'x.creatures': 30 })
+ setSwitch('test.spawn', true, { 'test.creatures': 30 })
const id = seedRun([{ key: 'main', steps: [step('test.spawn', { count: 12 }, 'skip')] }])
- seedBudget(id, 'x.creatures', { consumed: 28, cap: 30 })
+ seedBudget(id, 'test.creatures', { consumed: 28, cap: 30 })
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'refused')
const line = store.log.find((l) => l.runId === id && l.kind === 'step.refused')
assert.equal(line.detail.code, 'cap')
- assert.equal(line.detail.dimension, 'x.creatures')
+ assert.equal(line.detail.dimension, 'test.creatures')
assert.equal(line.detail.requested, 12)
assert.equal(line.detail.cap, 30)
assert.equal(line.detail.consumed, 28)
- assert.equal(budgetOf(id, 'x.creatures').consumed, 28, 'a refused step spends nothing')
+ assert.equal(budgetOf(id, 'test.creatures').consumed, 28, 'a refused step spends nothing')
})
test('two steps drawing on one cap spend it once each, and the second is refused when it will not fit', async () => {
@@ -1184,8 +1218,8 @@ test('two steps drawing on one cap spend it once each, and the second is refused
// spends arriving genuinely at once — is in `eventRunnerSql.test.js`, because
// the guard lives in a WHERE and a stub reproduces the reading rather than the
// server.
- register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
- setSwitch('test.spawn', true, { 'x.creatures': 30 })
+ register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
+ setSwitch('test.spawn', true, { 'test.creatures': 30 })
const id = seedRun([
{
key: 'main',
@@ -1196,7 +1230,7 @@ test('two steps drawing on one cap spend it once each, and the second is refused
],
},
])
- seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
+ seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 })
await runner.tick(T0)
@@ -1204,7 +1238,7 @@ test('two steps drawing on one cap spend it once each, and the second is refused
assert.equal(s[0].status, 'done')
assert.equal(s[1].status, 'refused', 'the second 20 does not fit under 30')
assert.equal(s[2].status, 'done', 'and a later step that DOES fit still runs')
- assert.equal(budgetOf(id, 'x.creatures').consumed, 30)
+ assert.equal(budgetOf(id, 'test.creatures').consumed, 30)
})
test('a retry does not pay the cap twice', async () => {
@@ -1212,36 +1246,36 @@ test('a retry does not pay the cap twice', async () => {
// idempotent operation against the same key, and charging a cap for a flaky
// socket would exhaust a deployment's allowance through unreliability rather
// than through effect.
- register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
- setSwitch('test.spawn', true, { 'x.creatures': 30 })
+ register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
+ setSwitch('test.spawn', true, { 'test.creatures': 30 })
scripted['test.spawn'] = { calls: [], answers: [{ ok: false, retry: true, error: 'shard busy' }] }
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
- seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
+ seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 })
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'pending')
- assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the first attempt spends')
+ assert.equal(budgetOf(id, 'test.creatures').consumed, 5, 'the first attempt spends')
// Past the retry backoff: the second attempt succeeds and must not spend again.
await runner.tick(later(61_000))
assert.equal(stepsOf(id)[0].status, 'done')
- assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the retry must not pay twice')
+ assert.equal(budgetOf(id, 'test.creatures').consumed, 5, 'the retry must not pay twice')
})
test('a step that spent and then failed for good keeps its spend', async () => {
// The corollary, and it is deliberate: the attempt may have half-run, and a
// refund would be core asserting that it did not.
- register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
- setSwitch('test.spawn', true, { 'x.creatures': 30 })
+ register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
+ setSwitch('test.spawn', true, { 'test.creatures': 30 })
scripted['test.spawn'] = { calls: [], answer: { ok: false, retry: false, error: 'no' } }
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
- seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
+ seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 })
await runner.tick(T0)
assert.equal(stepsOf(id)[0].status, 'failed')
- assert.equal(budgetOf(id, 'x.creatures').consumed, 5)
+ assert.equal(budgetOf(id, 'test.creatures').consumed, 5)
})
test('a step spending a dimension its run has no budget row for is refused', async () => {
@@ -1249,7 +1283,7 @@ test('a step spending a dimension its run has no budget row for is refused', asy
// dimension seeded — uncapped ones included, as a row with a NULL cap — so a
// missing row means the step is spending something its own version never
// declared.
- register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })])
+ register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.ghosts': 1 }) })])
setSwitch('test.spawn', true)
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
@@ -1261,15 +1295,15 @@ test('a step spending a dimension its run has no budget row for is refused', asy
})
test('an uncapped dimension counts without ever refusing', async () => {
- register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })])
+ register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 99 }) })])
setSwitch('test.spawn', true)
const id = seedRun([{ key: 'main', steps: [step('test.spawn'), step('test.spawn')] }])
- seedBudget(id, 'x.creatures', { consumed: 0, cap: null })
+ seedBudget(id, 'test.creatures', { consumed: 0, cap: null })
await runner.tick(T0)
assert.deepEqual(stepsOf(id).map((s) => s.status), ['done', 'done'])
- assert.equal(budgetOf(id, 'x.creatures').consumed, 198)
+ assert.equal(budgetOf(id, 'test.creatures').consumed, 198)
})
test('the runner never re-checks the role of whoever started the run', async () => {
diff --git a/server/test/eventVerify.test.js b/server/test/eventVerify.test.js
index 3eb529d..2117485 100644
--- a/server/test/eventVerify.test.js
+++ b/server/test/eventVerify.test.js
@@ -48,9 +48,43 @@ afterEach(() => {
registries._reset()
})
+/**
+ * Declare the budget dimensions a batch of actions prices.
+ *
+ * §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered
+ * is refused BEFORE any cap arithmetic runs. Without this, every cap test below
+ * would pass for the WRONG reason — refused by the layer above the one under
+ * test. Discovered the way the switchboard discovers them, by pricing the
+ * action's own declared examples, so a test never has to keep a second list of
+ * its dimensions in step with its `cost()`.
+ *
+ * Written out here rather than borrowed from `authorize.dimensionsOf` so this
+ * file keeps stubbing exactly what it means to stub. The undeclared case is not
+ * an omission; it has its own test.
+ */
+const declaredBudgets = (entries, owner) => {
+ const dimensions = new Set()
+ for (const e of entries) {
+ if (typeof e.cost !== 'function') continue
+ const params = {}
+ for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example
+ let priced
+ try {
+ priced = e.cost(params)
+ } catch {
+ continue
+ }
+ for (const d of Object.keys(priced || {})) dimensions.add(d)
+ }
+ return [...dimensions]
+ .filter((id) => id.startsWith(`${owner}.`))
+ .map((id) => ({ id, label: id, unit: 'count' }))
+}
+
const register = (entries, owner = 'test') => {
const api = registries.stage(owner)
api.registerEventActions(entries)
+ api.registerEventBudgets(declaredBudgets(entries, owner))
registries.apply(api.staged)
}
@@ -94,8 +128,8 @@ test('every step is dispatched with verify true, and nothing is asked to act', a
test('the cost of the whole plan is added up across steps, and a total over the cap is a finding', async () => {
// The one check that only exists here. Each of the three steps fits under 30 on
// its own; together they do not.
- register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
- setSetting('test.spawn', true, { 'x.creatures': 30 })
+ register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
+ setSetting('test.spawn', true, { 'test.creatures': 30 })
const report = await verifySpec(
spec([
@@ -109,31 +143,31 @@ test('the cost of the whole plan is added up across steps, and a total over the
assert.equal(report.ok, false)
const total = report.findings.find((f) => f.code === 'cap-total')
assert.ok(total, 'the whole-plan total must be its own finding')
- assert.match(total.message, /asks for 45 of "x.creatures" across all its steps, and this deployment allows 30/)
+ assert.match(total.message, /asks for 45 of "test.creatures" across all its steps, and this deployment allows 30/)
assert.deepEqual(report.cost, [
- { dimension: 'x.creatures', total: 45, cap: 30, from: 'test.spawn', over: true },
+ { dimension: 'test.creatures', total: 45, cap: 30, from: 'test.spawn', over: true },
])
})
test('a plan that fits reports its cost without a finding', async () => {
- register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
- setSetting('test.spawn', true, { 'x.creatures': 30 })
+ register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
+ setSetting('test.spawn', true, { 'test.creatures': 30 })
const report = await verifySpec(spec([step('test.spawn', { count: 12 }), step('test.spawn', { count: 8 })]), {
user: ADMIN,
})
assert.equal(report.ok, true)
- assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }])
+ assert.deepEqual(report.cost, [{ dimension: 'test.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }])
})
test('an uncapped dimension is reported with its total and no cap', async () => {
// Worth showing rather than hiding: "this event will spawn 40 creatures and
// nothing bounds that" is exactly what an operator opening the switchboard
// wants to have seen first.
- register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 40 }) })])
+ register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 40 }) })])
setSetting('test.spawn', true, {})
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, true)
- assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 40, cap: null, from: null, over: false }])
+ assert.deepEqual(report.cost, [{ dimension: 'test.creatures', total: 40, cap: null, from: null, over: false }])
})
test('a step naming an action nobody registers is a dormant finding, and the rest are still checked', async () => {
diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js
index c8dd14c..ff3179d 100644
--- a/server/test/eventsAdmin.test.js
+++ b/server/test/eventsAdmin.test.js
@@ -420,9 +420,41 @@ test('the catalog serves the registry, callables stripped, with its vocabularies
for (const action of res.body.actions) assert.equal(action.perform, undefined)
assert.deepEqual(res.body.risks, ['notify', 'inspect', 'change', 'irreversible'])
assert.deepEqual(res.body.onFailure, ['skip', 'pause', 'abort_run'])
- // Phase 1 is honest about what it does not have: budget dimensions arrive with
- // the module contract, so the catalog does not pretend to carry any.
- assert.equal(res.body.budgets, undefined)
+ // The other three registrations of the module contract arrived in Phase 7, and
+ // they are served BESIDE the actions because the editor needs all four to draw
+ // one step. Core declares no budgets and no leases of its own — its three
+ // actions cost nothing and hold nothing — so those are empty here, and that is
+ // the fact worth asserting: present and empty, not absent.
+ assert.deepEqual(res.body.budgets, [])
+ assert.deepEqual(res.body.leases, [])
+ // One option source, and it is core's: `core.announce`'s leg param. It is here
+ // WITHOUT its resolver — the values are a request of their own.
+ assert.deepEqual(
+ res.body.optionSources.map((s) => s.id),
+ ['core.options.legs'],
+ )
+ for (const s of res.body.optionSources) assert.equal(s.resolve, undefined)
+})
+
+test('an option source resolves its values, and a refusal is a 200 the form can render', async () => {
+ // §F: a source that cannot answer degrades its field to free text with a
+ // visible warning rather than blocking the form, so BOTH answers are 200s and
+ // the difference is `ok`. A 4xx here would make an authoring screen something a
+ // module's outage can take away.
+ const ok = await call(ctrl.options, { params: { sourceId: 'core.options.legs' }, user: ADMIN })
+ assert.equal(ok.statusCode, 200)
+ assert.equal(ok.body.ok, true)
+ // Whatever legs this boot registered, each is a { value, label } pair — core
+ // renders no game word, so a leg's own label is the only text on the option.
+ for (const option of ok.body.options) {
+ assert.equal(typeof option.value, 'string')
+ assert.equal(typeof option.label, 'string')
+ }
+
+ const missing = await call(ctrl.options, { params: { sourceId: 'nobody.at.all' }, user: ADMIN })
+ assert.equal(missing.statusCode, 200)
+ assert.equal(missing.body.ok, false)
+ assert.match(missing.body.reason, /no module registers/)
})
// ── Create, edit, slug ─────────────────────────────────────────────────────
@@ -763,6 +795,10 @@ function registerCosting() {
// that is not prefixed with the module registering it, which is what keeps an
// action's id space its own (§F).
const api = registries.stage('test')
+ // The dimension is DECLARED as well as priced (Phase 7): a `cost()` naming a
+ // dimension no module registers is refused at save and at dispatch, so an
+ // action that priced one without declaring it could never be put in a step.
+ api.registerEventBudgets([{ id: 'test.creatures', label: 'Creatures', unit: 'count' }])
api.registerEventActions([
{
id: 'test.spawn',
@@ -770,7 +806,7 @@ function registerCosting() {
risk: 'change',
reversible: 'none',
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
- cost: (p) => ({ 'x.creatures': p.count }),
+ cost: (p) => ({ 'test.creatures': p.count }),
perform: async () => ({ ok: true }),
},
])
@@ -862,7 +898,7 @@ test('a cap that is not a whole number of 0 or more is refused', async () => {
for (const bad of [-1, 2.5, 'lots']) {
const res = await call(ctrl.saveAction, {
user: ADMIN,
- body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': bad } },
+ body: { actionId: 'test.spawn', enabled: true, caps: { 'test.creatures': bad } },
})
assert.equal(res.statusCode, 400, String(bad))
}
@@ -875,19 +911,24 @@ test('a cap of zero is legal, and it means zero', async () => {
registerCosting()
const res = await call(ctrl.saveAction, {
user: ADMIN,
- body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': 0 } },
+ body: { actionId: 'test.spawn', enabled: true, caps: { 'test.creatures': 0 } },
})
assert.equal(res.statusCode, 200)
- assert.deepEqual(res.body.action.caps, { 'x.creatures': 0 })
+ assert.deepEqual(res.body.action.caps, { 'test.creatures': 0 })
})
-test('the board offers a cap box per dimension, discovered from the declared examples', async () => {
- // The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase
- // 7 — until then a param's required `example` is what tells core the names.
+test('the board offers a cap box per dimension, named by its own declaration', async () => {
+ // WHICH dimensions an action spends is still discovered by pricing its declared
+ // examples — `cost` is a function of params, so calling it is the only honest
+ // way to ask. What Phase 7 added is what they are CALLED: the label and unit
+ // come from `registerEventBudgets`, because "30" on an unlabelled box is
+ // ambiguous in exactly the case that matters.
registerCosting()
const res = await call(ctrl.actions, { user: ADMIN })
const spawn = res.body.actions.find((a) => a.id === 'test.spawn')
- assert.deepEqual(spawn.dimensions, ['x.creatures'])
+ assert.deepEqual(spawn.dimensions, [
+ { id: 'test.creatures', label: 'Creatures', unit: 'count', registered: true },
+ ])
assert.equal(spawn.enabled, false, 'a change action arrives disabled')
assert.equal(spawn.changesWorld, true)
})
diff --git a/server/test/eventsRoles.test.js b/server/test/eventsRoles.test.js
index 3840388..9abd2dc 100644
--- a/server/test/eventsRoles.test.js
+++ b/server/test/eventsRoles.test.js
@@ -134,6 +134,9 @@ const forbidden = async (method, path, role) => (await dispatch(method, path, ro
const SURFACE = [
// Reads: staff-wide, the tier gate and nothing added.
['GET', '/events/catalog', ['admin', 'editor', 'moderator']],
+ // Phase 7. Authoring data, so staff-wide like the catalog it belongs to: an
+ // editor who may write the step must be able to see which values it accepts.
+ ['GET', '/events/catalog/options/core.options.legs', ['admin', 'editor', 'moderator']],
['GET', '/events/series', ['admin', 'editor', 'moderator']],
['GET', '/events/calendar', ['admin', 'editor', 'moderator']],
['GET', '/events/runs', ['admin', 'editor', 'moderator']],