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
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user