// ── 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 `