Files
website/server/test/eventVerify.test.js
wtclaude fd9fb50351
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
feat(events): open the event contract to modules (Phase 7)
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
2026-09-03 14:15:04 -05:00

265 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 callers 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 modules own refusal is a finding in the modules 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 authors 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: [] })
})