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:
@@ -68,9 +68,43 @@ afterEach(() => {
|
||||
registries._reset()
|
||||
})
|
||||
|
||||
const register = (entries, owner = 'test') => {
|
||||
/**
|
||||
* 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', budgets = true } = {}) => {
|
||||
const api = registries.stage(owner)
|
||||
api.registerEventActions(entries)
|
||||
if (budgets) api.registerEventBudgets(declaredBudgets(entries, owner))
|
||||
registries.apply(api.staged)
|
||||
}
|
||||
|
||||
@@ -192,10 +226,10 @@ test('the refusal names the action by its LABEL, not its id', async () => {
|
||||
// ── Pricing ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('cost() is called with the step params and its answer is what core enforces', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
||||
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
|
||||
setSetting('test.spawn', true)
|
||||
const priced = authorize.priceOf(registries.eventAction('test.spawn'), { count: 12 })
|
||||
assert.deepEqual(priced, { 'x.creatures': 12 })
|
||||
assert.deepEqual(priced, { 'test.creatures': 12 })
|
||||
})
|
||||
|
||||
test('a cost() that throws makes the action unpriceable, never free', async () => {
|
||||
@@ -252,10 +286,10 @@ test('dimensions are discovered by pricing the declared examples', async () => {
|
||||
action('test.spawn', {
|
||||
risk: 'change',
|
||||
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
|
||||
cost: (p) => ({ 'x.creatures': p.count, 'x.bosses': 1 }),
|
||||
cost: (p) => ({ 'test.creatures': p.count, 'test.bosses': 1 }),
|
||||
}),
|
||||
])
|
||||
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['x.bosses', 'x.creatures'])
|
||||
assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['test.bosses', 'test.creatures'])
|
||||
})
|
||||
|
||||
test('an action whose cost() cannot survive its own examples reports no dimensions', async () => {
|
||||
@@ -275,44 +309,44 @@ test('an action whose cost() cannot survive its own examples reports no dimensio
|
||||
|
||||
test('two actions spending one dimension resolve to the tightest cap, and it says whose', async () => {
|
||||
register([
|
||||
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
||||
action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
||||
action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
|
||||
action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
|
||||
])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
setSetting('test.horde', true, { 'x.creatures': 10 })
|
||||
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||
setSetting('test.horde', true, { 'test.creatures': 10 })
|
||||
|
||||
const caps = authorize.effectiveCaps(
|
||||
[{ actionId: 'test.spawn', params: {} }, { actionId: 'test.horde', params: {} }],
|
||||
await settingsDb.byIds(['test.spawn', 'test.horde']),
|
||||
)
|
||||
assert.deepEqual(caps, { 'x.creatures': { cap: 10, from: 'test.horde' } })
|
||||
assert.deepEqual(caps, { 'test.creatures': { cap: 10, from: 'test.horde' } })
|
||||
})
|
||||
|
||||
test('an action that declines to cap a dimension does not raise a ceiling another one set', async () => {
|
||||
// `null` is uncapped and must never win a minimum. Otherwise adding a second
|
||||
// verb to an event would silently remove the bound on the first.
|
||||
register([
|
||||
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
||||
action('test.horde', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) }),
|
||||
action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
|
||||
action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }),
|
||||
])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||
setSetting('test.horde', true, {})
|
||||
|
||||
const caps = authorize.effectiveCaps(
|
||||
[{ actionId: 'test.horde', params: {} }, { actionId: 'test.spawn', params: {} }],
|
||||
await settingsDb.byIds(['test.spawn', 'test.horde']),
|
||||
)
|
||||
assert.deepEqual(caps, { 'x.creatures': { cap: 30, from: 'test.spawn' } })
|
||||
assert.deepEqual(caps, { 'test.creatures': { cap: 30, from: 'test.spawn' } })
|
||||
})
|
||||
|
||||
test('a dimension nobody caps is still a row, uncapped', async () => {
|
||||
// Seeded so the meter counts it. The distinction it preserves is that a MISSING
|
||||
// row means something else entirely: a step spending a dimension its own run's
|
||||
// version never priced.
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 1 }) })])
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) })])
|
||||
setSetting('test.spawn', true, {})
|
||||
const caps = authorize.effectiveCaps([{ actionId: 'test.spawn', params: {} }], await settingsDb.byIds(['test.spawn']))
|
||||
assert.deepEqual(caps, { 'x.creatures': { cap: null, from: null } })
|
||||
assert.deepEqual(caps, { 'test.creatures': { cap: null, from: null } })
|
||||
})
|
||||
|
||||
test('a step naming an unregistered action contributes no dimension', async () => {
|
||||
@@ -325,13 +359,13 @@ test('with no run, the question is whether the cost could EVER fit', async () =>
|
||||
// The dry run's and the editor's question. A step asking for 40 under a cap of
|
||||
// 30 is an authoring error answerable before anything is scheduled, which is
|
||||
// the entire value of catching it here rather than at 2am.
|
||||
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
|
||||
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||
|
||||
const bad = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 40 } })
|
||||
assert.equal(bad.ok, false)
|
||||
assert.equal(bad.code, 'cap')
|
||||
assert.equal(bad.dimension, 'x.creatures')
|
||||
assert.equal(bad.dimension, 'test.creatures')
|
||||
assert.equal(bad.requested, 40)
|
||||
assert.equal(bad.cap, 30)
|
||||
|
||||
@@ -340,9 +374,9 @@ test('with no run, the question is whether the cost could EVER fit', async () =>
|
||||
})
|
||||
|
||||
test('with a run and spend, the check IS the spend', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
|
||||
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||
setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
const v = await authorize.mayInvoke({
|
||||
action: registries.eventAction('test.spawn'),
|
||||
@@ -352,24 +386,24 @@ test('with a run and spend, the check IS the spend', async () => {
|
||||
})
|
||||
assert.equal(v.ok, true)
|
||||
assert.equal(v.spent, true)
|
||||
assert.equal(store.budget.get('1:x.creatures').consumed, 12)
|
||||
assert.equal(store.budget.get('1:test.creatures').consumed, 12)
|
||||
})
|
||||
|
||||
test('without spend the cap check is advisory and moves nothing', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
|
||||
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||
setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN })
|
||||
assert.equal(v.ok, true)
|
||||
assert.equal(v.spent, undefined)
|
||||
assert.equal(store.budget.get('1:x.creatures').consumed, 0)
|
||||
assert.equal(store.budget.get('1:test.creatures').consumed, 0)
|
||||
})
|
||||
|
||||
test('a refusal names the numbers an operator needs, not just "refused"', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
||||
setSetting('test.spawn', true, { 'x.creatures': 30 })
|
||||
setBudget(RUN.id, 'x.creatures', { consumed: 28, cap: 30 })
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
|
||||
setSetting('test.spawn', true, { 'test.creatures': 30 })
|
||||
setBudget(RUN.id, 'test.creatures', { consumed: 28, cap: 30 })
|
||||
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||
assert.equal(v.ok, false)
|
||||
@@ -386,24 +420,24 @@ test('a partial spend across dimensions is given back when a later one is refuse
|
||||
// costing creatures AND bosses can take the creatures and be refused the
|
||||
// bosses, and a step that did not run must not have spent anything.
|
||||
register([
|
||||
action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5, 'x.bosses': 2 }) }),
|
||||
action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5, 'test.bosses': 2 }) }),
|
||||
])
|
||||
setSetting('test.spawn', true)
|
||||
setBudget(RUN.id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
setBudget(RUN.id, 'x.bosses', { consumed: 1, cap: 2 })
|
||||
setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 })
|
||||
setBudget(RUN.id, 'test.bosses', { consumed: 1, cap: 2 })
|
||||
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||
assert.equal(v.ok, false)
|
||||
assert.equal(v.dimension, 'x.bosses')
|
||||
assert.equal(store.budget.get('1:x.creatures').consumed, 0, 'the creatures must have been given back')
|
||||
assert.equal(store.budget.get('1:x.bosses').consumed, 1)
|
||||
assert.equal(v.dimension, 'test.bosses')
|
||||
assert.equal(store.budget.get('1:test.creatures').consumed, 0, 'the creatures must have been given back')
|
||||
assert.equal(store.budget.get('1:test.bosses').consumed, 1)
|
||||
})
|
||||
|
||||
test('spending a dimension the run has no row for is refused, and says so in its own words', async () => {
|
||||
// Fail-closed, and a distinct code: "this run has no budget for that" is a
|
||||
// different diagnosis from "the cap is spent", and an operator who raises the
|
||||
// cap in answer to the wrong one has not fixed anything.
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })])
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.ghosts': 1 }) })])
|
||||
setSetting('test.spawn', true)
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||
assert.equal(v.ok, false)
|
||||
@@ -411,13 +445,36 @@ test('spending a dimension the run has no row for is refused, and says so in its
|
||||
assert.match(v.reason, /no budget for/)
|
||||
})
|
||||
|
||||
test('an uncapped budget row never refuses', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 999 }) })])
|
||||
test('a dimension no module declares is refused before any cap arithmetic', async () => {
|
||||
// §F, fail closed (org lead, 2026-09-03). The refusal has to be its OWN code:
|
||||
// told "the cap is spent", an operator goes and raises a cap, and nothing
|
||||
// changes, because the problem is a module whose declaration is incomplete.
|
||||
register([action('test.spawn', { risk: 'notify', cost: () => ({ 'test.wisps': 3 }) })], {
|
||||
budgets: false,
|
||||
})
|
||||
setSetting('test.spawn', true)
|
||||
setBudget(RUN.id, 'x.creatures', { consumed: 5, cap: null })
|
||||
setBudget(1, 'test.wisps', { cap: 100 })
|
||||
|
||||
const verdict = await authorize.mayInvoke({
|
||||
action: registries.eventAction('test.spawn'),
|
||||
params: {},
|
||||
run: RUN,
|
||||
spend: true,
|
||||
})
|
||||
assert.equal(verdict.ok, false)
|
||||
assert.equal(verdict.code, 'undeclared')
|
||||
assert.match(verdict.reason, /no module declares/)
|
||||
// And it did not spend: a step refused for this reason never ran.
|
||||
assert.equal(store.budget.get('1:test.wisps').consumed, 0)
|
||||
})
|
||||
|
||||
test('an uncapped budget row never refuses', async () => {
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 999 }) })])
|
||||
setSetting('test.spawn', true)
|
||||
setBudget(RUN.id, 'test.creatures', { consumed: 5, cap: null })
|
||||
const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true })
|
||||
assert.equal(v.ok, true)
|
||||
assert.equal(store.budget.get('1:x.creatures').consumed, 1004)
|
||||
assert.equal(store.budget.get('1:test.creatures').consumed, 1004)
|
||||
})
|
||||
|
||||
// ── The layers are ordered ─────────────────────────────────────────────────
|
||||
@@ -426,7 +483,7 @@ test('the layers answer in order: role before enablement before cap', async () =
|
||||
// Not cosmetic. An editor told "that action is disabled" would go and ask an
|
||||
// admin to enable it, and still not be allowed to author the step — the honest
|
||||
// first answer is the role.
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })])
|
||||
register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 99 }) })])
|
||||
// Disabled AND over cap AND world-changing, all at once.
|
||||
const v = await authorize.mayInvoke({
|
||||
user: EDITOR,
|
||||
@@ -435,7 +492,7 @@ test('the layers answer in order: role before enablement before cap', async () =
|
||||
})
|
||||
assert.equal(v.code, 'role')
|
||||
|
||||
setSetting('test.spawn', false, { 'x.creatures': 1 })
|
||||
setSetting('test.spawn', false, { 'test.creatures': 1 })
|
||||
const asAdmin = await authorize.mayInvoke({
|
||||
user: ADMIN,
|
||||
action: registries.eventAction('test.spawn'),
|
||||
|
||||
Reference in New Issue
Block a user