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

@@ -29,6 +29,11 @@
// computes there.
const registries = require('../modules/registries')
// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's
// fail-closed budget rule (Phase 7). Nothing here reaches the database:
// `authorize` requires two `.db.js` modules and requiring one opens no
// connection, which is the same rule this file already lives under.
const authorize = require('./authorize')
const recurrence = require('./recurrence')
const conditionGrammar = require('../engagement/conditions')
const { checkLiteral } = conditionGrammar
@@ -509,6 +514,29 @@ function validate(raw, { knownActionIds = [] } = {}) {
const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath)
errors.push(...paramErrors)
// §F, fail closed (org lead, 2026-09-03): a step may not spend a dimension
// no module declares as a budget. Refused HERE as well as at dispatch
// because this is the cheap moment — the editor is open, the author is
// looking at the step, and the alternative is a run that refuses at two in
// the morning for a reason that was decidable when it was written.
//
// Only when the params validated. Pricing a step whose params were just
// refused would run a module's `cost()` over values core has already said
// are wrong, and report its answer as a second, confusing error about the
// same mistake.
if (!paramErrors.length) {
const priced = authorize.priceOf(declaration, params)
if (priced === null) {
errors.push(`${spath}: "${declaration.label}" could not report what it costs`)
} else {
for (const dimension of authorize.undeclaredDimensions(priced)) {
errors.push(
`${spath}: "${declaration.label}" spends "${dimension}", which no module declares as a budget`,
)
}
}
}
if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) {
errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`)
}