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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user