feat(events): open the event contract to modules (Phase 7)
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

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:
2026-09-03 14:15:04 -05:00
parent 429e657239
commit fd9fb50351
22 changed files with 1825 additions and 117 deletions

View File

@@ -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 () => {