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

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

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

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

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

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

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

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

View File

@@ -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 contracts 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 `<select>` 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"/)
})