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

@@ -127,12 +127,14 @@ function priceOf(action, params) {
* The dimensions an action can spend, discovered by pricing its declared
* examples.
*
* **This is a Phase 6 stand-in with a Phase 7 replacement already named.** §F's
* `registerEventBudgets` is what will declare a dimension's id, label and unit,
* and it arrives with the module contract. Until then the switchboard still has
* to render a cap editor, and it cannot offer a box for a dimension it cannot
* name — so core prices each action's own `example` values, which is a use every
* param already has a required `example` for.
* **Phase 7 replaced half of this and deliberately kept the other half.** §F's
* `registerEventBudgets` now declares a dimension's id, label and unit, so the
* switchboard no longer has to invent a name for a box — see `budgetsOf` below,
* which is what the screen reads. What a registry cannot answer is *which*
* dimensions THIS action spends, because `cost` is a function of params (§F) and
* the only honest way to ask it is to call it. So the discovery stays: core
* prices each action's own `example` values, which is a use every param already
* has a required `example` for.
*
* It is honest about its limits: a `cost()` that returns different dimension KEYS
* for different params under-reports here. That costs an operator a cap box on
@@ -148,6 +150,45 @@ function dimensionsOf(action) {
return priced ? Object.keys(priced).sort() : []
}
/**
* The same dimensions, dressed with what the registry says they are called.
*
* The switchboard's read (Phase 7). `registered: false` is the case worth having
* a field for: an action that prices a dimension no module declares is a
* DECLARATION ERROR — `mayInvoke` refuses it, the dry run fails on it, and the
* save refuses it — so the screen has to be able to show the operator the reason
* their action will not run, rather than silently listing one fewer cap box than
* the action has dimensions. Hiding it would make a broken module look like a
* cheap one.
*/
function budgetsOf(action) {
return dimensionsOf(action).map((id) => {
const declared = registries.eventBudget(id)
return declared
? { id, label: declared.label, unit: declared.unit, registered: true }
: { id, label: id, unit: '', registered: false }
})
}
/**
* Which of these dimensions does nobody declare? (§F, org lead 2026-09-03.)
*
* Fail closed. §F's *"a module cannot spend a budget it did not declare"* is a
* rule only if something asks, and this is what asks — from `mayInvoke` at
* dispatch and at the dry run, and from the spec validator at save. Three places
* because they answer at three different moments and only the first of them is
* cheap: catching it at save costs an editor a red line, catching it at dispatch
* costs a run a refused step at two in the morning.
*
* Not folded into `priceOf`, which answers *what does this cost* and should keep
* answering only that: a cost of 12 creatures is a true statement about the
* action whether or not anyone declared the dimension, and the two facts have
* different fixes.
*/
function undeclaredDimensions(cost) {
return Object.keys(cost || {}).filter((d) => !registries.isEventBudget(d))
}
/**
* The effective per-run cap for each dimension a set of steps will spend.
*
@@ -245,6 +286,24 @@ async function mayInvoke({
const dimensions = Object.keys(cost)
if (!dimensions.length) return { ok: true, cost }
// Before any cap arithmetic, because a dimension nobody declared has no cap to
// be under and no meter to draw on — asking "is 12 within the limit" about a
// resource core has never been told the name of would be answering a question
// that has not been asked yet. It is also the honest reading of the refusal:
// this is a module whose declaration is incomplete, not a deployment whose
// allowance is spent, and an operator who is told the second will go and raise
// a cap that changes nothing.
const undeclared = undeclaredDimensions(cost)
if (undeclared.length) {
return {
ok: false,
code: 'undeclared',
reason: `spends "${undeclared[0]}", which no module declares as a budget`,
dimension: undeclared[0],
requested: cost[undeclared[0]],
}
}
if (!run) {
// No run, so nothing to draw on: the question is whether the cost could EVER
// fit, which is what the dry run and the editor are asking. A cost larger
@@ -333,6 +392,8 @@ module.exports = {
isEnabled,
priceOf,
dimensionsOf,
budgetsOf,
undeclaredDimensions,
effectiveCaps,
changesWorld,
WORLD_CHANGING,