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
265 lines
11 KiB
JavaScript
265 lines
11 KiB
JavaScript
// ── The dry run (EVENTS_PLAN.md Phase 6, EVENTS.md §I) ─────────────────────
|
||
//
|
||
// *"Materialise the steps, dispatch each with `verify: true`, report what would
|
||
// happen and what it would cost against the caps."*
|
||
//
|
||
// **The finding worth the most is the one no other path can make.** Every
|
||
// per-step check here is also made at save or at dispatch; the TOTAL is not.
|
||
// Three steps each spawning 15 under a cap of 30 pass every individual check and
|
||
// breach the cap on the third — at two in the morning, unattended, with the world
|
||
// half-changed. Adding the costs up across the whole version is the thing only a
|
||
// look at the plan as a whole can do, and it is why a dry run is worth more than
|
||
// the sum of its step checks.
|
||
//
|
||
// The second property this file holds is the one §I states outright: **`verify:
|
||
// true` must change nothing and must answer honestly.** So the actions here
|
||
// record what they were asked and assert that the flag arrived — a dry run that
|
||
// silently dispatched for real is the single worst bug this feature could ship,
|
||
// and it would look exactly like a passing test otherwise.
|
||
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
|
||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const registries = require('../src/modules/registries')
|
||
const { verifySpec } = require('../src/events/verify')
|
||
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
||
const db = require('../src/utils/db')
|
||
|
||
after(() => db.close())
|
||
|
||
const originals = { ...settingsDb }
|
||
let store
|
||
let seen
|
||
|
||
beforeEach(() => {
|
||
registries._reset()
|
||
store = new Map()
|
||
seen = []
|
||
settingsDb.byIds = async (ids) =>
|
||
new Map([...new Set(ids || [])].filter((i) => store.has(i)).map((i) => [i, store.get(i)]))
|
||
settingsDb.get = async (id) => store.get(id) || null
|
||
})
|
||
|
||
afterEach(() => {
|
||
Object.assign(settingsDb, originals)
|
||
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)
|
||
}
|
||
|
||
const setSetting = (id, enabled, caps = {}) =>
|
||
store.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps })
|
||
|
||
/** An action that records every envelope it is handed and then answers `answer`. */
|
||
const recorder = (id, over = {}) => ({
|
||
id,
|
||
label: over.label || id,
|
||
risk: over.risk || 'notify',
|
||
reversible: 'none',
|
||
version: over.version || 1,
|
||
params: over.params || [],
|
||
...(over.cost ? { cost: over.cost } : {}),
|
||
async perform(envelope) {
|
||
seen.push({ id, ...envelope })
|
||
if (over.throws) throw new Error(over.throws)
|
||
return over.answer || { ok: true }
|
||
},
|
||
})
|
||
|
||
const spec = (steps) => ({ phases: [{ key: 'one', steps }] })
|
||
const step = (actionId, params = {}, extra = {}) => ({ actionId, params, ...extra })
|
||
|
||
const ADMIN = { id: 1, role: 'admin' }
|
||
const EDITOR = { id: 2, role: 'editor' }
|
||
|
||
test('every step is dispatched with verify true, and nothing is asked to act', async () => {
|
||
// §I's promise, held as an assertion about the envelope each action received.
|
||
register([recorder('test.tell'), recorder('test.wait', { risk: 'inspect' })])
|
||
const report = await verifySpec(spec([step('test.tell', { body: 'x' }), step('test.wait')]), { user: ADMIN })
|
||
|
||
assert.equal(report.ok, true)
|
||
assert.equal(report.steps, 2)
|
||
assert.deepEqual(report.findings, [])
|
||
assert.equal(seen.length, 2)
|
||
for (const envelope of seen) assert.equal(envelope.verify, true)
|
||
})
|
||
|
||
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) => ({ 'test.creatures': p.count }) })])
|
||
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||
|
||
const report = await verifySpec(
|
||
spec([
|
||
step('test.spawn', { count: 15 }),
|
||
step('test.spawn', { count: 15 }),
|
||
step('test.spawn', { count: 15 }),
|
||
]),
|
||
{ user: ADMIN },
|
||
)
|
||
|
||
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 "test.creatures" across all its steps, and this deployment allows 30/)
|
||
assert.deepEqual(report.cost, [
|
||
{ 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) => ({ '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: '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: () => ({ '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: '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 () => {
|
||
// Reported rather than thrown, so an author sees EVERY problem in one pass. A
|
||
// verification that stopped at the first finding would make fixing a
|
||
// twelve-step definition twelve round trips.
|
||
register([recorder('test.tell')])
|
||
const report = await verifySpec(spec([step('gone.away'), step('test.tell')]), { user: ADMIN })
|
||
|
||
assert.equal(report.ok, false)
|
||
assert.equal(report.findings.length, 1)
|
||
assert.equal(report.findings[0].code, 'dormant')
|
||
assert.equal(report.findings[0].actionId, 'gone.away')
|
||
assert.equal(seen.length, 1, 'the registered step is still dispatched')
|
||
})
|
||
|
||
test('the caller’s own role is what the report answers against', async () => {
|
||
// The value of doing it here: an editor is told a step needs an administrator
|
||
// at the moment they can still do something about it, rather than at the moment
|
||
// it does not run.
|
||
register([recorder('test.change', { risk: 'change', label: 'Change things' })])
|
||
setSetting('test.change', true)
|
||
|
||
const asEditor = await verifySpec(spec([step('test.change')]), { user: EDITOR })
|
||
assert.equal(asEditor.ok, false)
|
||
assert.equal(asEditor.findings[0].code, 'role')
|
||
|
||
const asAdmin = await verifySpec(spec([step('test.change')]), { user: ADMIN })
|
||
assert.equal(asAdmin.ok, true)
|
||
})
|
||
|
||
test('a disabled action is a finding, and its step is never dispatched', async () => {
|
||
// The order matters: dispatching a disabled action under `verify: true` would
|
||
// change nothing, but it would call code the deployment has switched off, and
|
||
// "we only ran it to ask whether we could run it" is not a defence anyone wants
|
||
// to make.
|
||
register([recorder('test.change', { risk: 'change' })])
|
||
const report = await verifySpec(spec([step('test.change')]), { user: ADMIN })
|
||
assert.equal(report.findings[0].code, 'disabled')
|
||
assert.equal(seen.length, 0)
|
||
})
|
||
|
||
test('the module’s own refusal is a finding in the module’s words', async () => {
|
||
// The half core cannot compute: whether the landmark exists, whether the
|
||
// creature is on the allowlist, whether the shard is reachable at all.
|
||
register([recorder('test.spawn', { answer: { ok: false, retry: false, error: 'no such landmark "Bratain"' } })])
|
||
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
|
||
assert.equal(report.ok, false)
|
||
assert.equal(report.findings[0].code, 'refused')
|
||
assert.match(report.findings[0].message, /no such landmark/)
|
||
})
|
||
|
||
test('an action that throws under verify is a finding, not a 500 on the author’s screen', async () => {
|
||
register([recorder('test.spawn', { throws: 'exploded' })])
|
||
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
|
||
assert.equal(report.ok, false)
|
||
assert.equal(report.findings[0].code, 'refused')
|
||
assert.match(report.findings[0].message, /exploded/)
|
||
})
|
||
|
||
test('a step authored against an older action version is a WARNING, not an error', async () => {
|
||
// §F: a bump makes the editor render a warning rather than refusing to run. The
|
||
// level is the whole point — a warning does not hold a scheduled start, and an
|
||
// error does.
|
||
register([recorder('test.tell', { version: 3 })])
|
||
const report = await verifySpec(spec([step('test.tell', {}, { actionVersion: 1 })]), { user: ADMIN })
|
||
assert.equal(report.ok, true, 'a drift warning must not fail the dry run')
|
||
assert.equal(report.findings[0].level, 'warning')
|
||
assert.equal(report.findings[0].code, 'version-drift')
|
||
assert.match(report.findings[0].message, /authored against version 1/)
|
||
})
|
||
|
||
test('a finding says which phase and which step it is about', async () => {
|
||
// A twelve-step definition needs the finding anchored, or the author is left
|
||
// reading the message and counting rows.
|
||
register([recorder('test.tell')])
|
||
const twoPhases = {
|
||
phases: [
|
||
{ key: 'open', steps: [step('test.tell')] },
|
||
{ key: 'close', steps: [step('test.tell'), step('gone.away')] },
|
||
],
|
||
}
|
||
const report = await verifySpec(twoPhases, { user: ADMIN })
|
||
assert.equal(report.steps, 3)
|
||
assert.deepEqual(
|
||
report.findings.map((f) => ({ phase: f.phase, seq: f.seq })),
|
||
[{ phase: 'close', seq: 1 }],
|
||
)
|
||
})
|
||
|
||
test('an empty spec verifies clean and costs nothing', async () => {
|
||
const report = await verifySpec({ phases: [] }, { user: ADMIN })
|
||
assert.deepEqual(report, { ok: true, steps: 0, findings: [], cost: [] })
|
||
})
|