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:
@@ -483,9 +483,43 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
/** Register actions the way a module does, through the real staging area. */
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
@@ -1161,22 +1195,22 @@ test('a step over its cap is refused with the dimension and the numbers on the l
|
||||
// "You asked for 40 and this deployment allows 30" is an authoring error, and
|
||||
// it has to arrive as those words rather than as a stack trace.
|
||||
register([
|
||||
scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) }),
|
||||
scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) }),
|
||||
])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
setSwitch('test.spawn', true, { 'test.creatures': 30 })
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', { count: 12 }, 'skip')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 28, cap: 30 })
|
||||
seedBudget(id, 'test.creatures', { consumed: 28, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'refused')
|
||||
const line = store.log.find((l) => l.runId === id && l.kind === 'step.refused')
|
||||
assert.equal(line.detail.code, 'cap')
|
||||
assert.equal(line.detail.dimension, 'x.creatures')
|
||||
assert.equal(line.detail.dimension, 'test.creatures')
|
||||
assert.equal(line.detail.requested, 12)
|
||||
assert.equal(line.detail.cap, 30)
|
||||
assert.equal(line.detail.consumed, 28)
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 28, 'a refused step spends nothing')
|
||||
assert.equal(budgetOf(id, 'test.creatures').consumed, 28, 'a refused step spends nothing')
|
||||
})
|
||||
|
||||
test('two steps drawing on one cap spend it once each, and the second is refused when it will not fit', async () => {
|
||||
@@ -1184,8 +1218,8 @@ test('two steps drawing on one cap spend it once each, and the second is refused
|
||||
// spends arriving genuinely at once — is in `eventRunnerSql.test.js`, because
|
||||
// the guard lives in a WHERE and a stub reproduces the reading rather than the
|
||||
// server.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })])
|
||||
setSwitch('test.spawn', true, { 'test.creatures': 30 })
|
||||
const id = seedRun([
|
||||
{
|
||||
key: 'main',
|
||||
@@ -1196,7 +1230,7 @@ test('two steps drawing on one cap spend it once each, and the second is refused
|
||||
],
|
||||
},
|
||||
])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
@@ -1204,7 +1238,7 @@ test('two steps drawing on one cap spend it once each, and the second is refused
|
||||
assert.equal(s[0].status, 'done')
|
||||
assert.equal(s[1].status, 'refused', 'the second 20 does not fit under 30')
|
||||
assert.equal(s[2].status, 'done', 'and a later step that DOES fit still runs')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 30)
|
||||
assert.equal(budgetOf(id, 'test.creatures').consumed, 30)
|
||||
})
|
||||
|
||||
test('a retry does not pay the cap twice', async () => {
|
||||
@@ -1212,36 +1246,36 @@ test('a retry does not pay the cap twice', async () => {
|
||||
// idempotent operation against the same key, and charging a cap for a flaky
|
||||
// socket would exhaust a deployment's allowance through unreliability rather
|
||||
// than through effect.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
|
||||
setSwitch('test.spawn', true, { 'test.creatures': 30 })
|
||||
scripted['test.spawn'] = { calls: [], answers: [{ ok: false, retry: true, error: 'shard busy' }] }
|
||||
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
assert.equal(stepsOf(id)[0].status, 'pending')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the first attempt spends')
|
||||
assert.equal(budgetOf(id, 'test.creatures').consumed, 5, 'the first attempt spends')
|
||||
|
||||
// Past the retry backoff: the second attempt succeeds and must not spend again.
|
||||
await runner.tick(later(61_000))
|
||||
assert.equal(stepsOf(id)[0].status, 'done')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the retry must not pay twice')
|
||||
assert.equal(budgetOf(id, 'test.creatures').consumed, 5, 'the retry must not pay twice')
|
||||
})
|
||||
|
||||
test('a step that spent and then failed for good keeps its spend', async () => {
|
||||
// The corollary, and it is deliberate: the attempt may have half-run, and a
|
||||
// refund would be core asserting that it did not.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })])
|
||||
setSwitch('test.spawn', true, { 'test.creatures': 30 })
|
||||
scripted['test.spawn'] = { calls: [], answer: { ok: false, retry: false, error: 'no' } }
|
||||
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
seedBudget(id, 'test.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
assert.equal(stepsOf(id)[0].status, 'failed')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 5)
|
||||
assert.equal(budgetOf(id, 'test.creatures').consumed, 5)
|
||||
})
|
||||
|
||||
test('a step spending a dimension its run has no budget row for is refused', async () => {
|
||||
@@ -1249,7 +1283,7 @@ test('a step spending a dimension its run has no budget row for is refused', asy
|
||||
// dimension seeded — uncapped ones included, as a row with a NULL cap — so a
|
||||
// missing row means the step is spending something its own version never
|
||||
// declared.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })])
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.ghosts': 1 }) })])
|
||||
setSwitch('test.spawn', true)
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
|
||||
|
||||
@@ -1261,15 +1295,15 @@ test('a step spending a dimension its run has no budget row for is refused', asy
|
||||
})
|
||||
|
||||
test('an uncapped dimension counts without ever refusing', async () => {
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })])
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 99 }) })])
|
||||
setSwitch('test.spawn', true)
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn'), step('test.spawn')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: null })
|
||||
seedBudget(id, 'test.creatures', { consumed: 0, cap: null })
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.deepEqual(stepsOf(id).map((s) => s.status), ['done', 'done'])
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 198)
|
||||
assert.equal(budgetOf(id, 'test.creatures').consumed, 198)
|
||||
})
|
||||
|
||||
test('the runner never re-checks the role of whoever started the run', async () => {
|
||||
|
||||
Reference in New Issue
Block a user