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

@@ -180,6 +180,21 @@ exports.catalog = (_req, res) => {
})),
operators: conditionGrammar.vocabulary(),
advanceKinds: spec.ADVANCE_KINDS,
// **The other three registrations of the module contract** (Phase 7). Served
// beside the actions rather than on three routes of their own, because the
// step editor needs all four to render one step: the action says what params
// it takes, a param's `source` names an option source, and a cap the editor
// shows is a budget's label and unit. Four requests to draw one form would
// be four chances for the screen to render half of it.
//
// Each is already stripped of its callables by the registry (`resolve`,
// `read`, `apply`, `restore`) — the same rule that keeps `perform` off an
// action here. A source's VALUES are not in this payload either: they are a
// request of their own (`/options/:sourceId`), because a source can be slow,
// can fail, and would otherwise take the whole catalog down with it.
budgets: registries.allEventBudgets(),
leases: registries.allEventLeases(),
optionSources: registries.allEventOptionSources(),
limits: {
maxPhases: spec.MAX_PHASES,
maxStepsPerPhase: spec.MAX_STEPS_PER_PHASE,
@@ -463,10 +478,16 @@ exports.actions = async (_req, res) => {
configured: Boolean(row),
changesWorld: authorize.changesWorld(full),
// The dimensions this action can spend, so the screen can offer a cap
// box per dimension. Discovered by pricing the action's own declared
// examples until §F's `registerEventBudgets` lands in Phase 7 — see
// `authorize.dimensionsOf`.
dimensions: authorize.dimensionsOf(full),
// box per dimension — each now carrying the label and unit its
// `registerEventBudgets` declaration gives it (Phase 7), because "30" on
// a box is ambiguous in exactly the case that matters: 30 of what?
//
// `registered: false` says a dimension nobody declares, and it is shown
// rather than filtered out: an action that prices an undeclared
// dimension is REFUSED at save and at dispatch, so the screen must be
// able to show the operator why their action will not run instead of
// quietly listing one fewer box than the action has dimensions.
dimensions: authorize.budgetsOf(full),
caps: row?.caps || {},
updatedAt: row?.updated_at || null,
updatedBy: row?.updated_by_username || null,
@@ -478,6 +499,30 @@ exports.actions = async (_req, res) => {
})
}
/**
* GET /api/v1/admin/events/catalog/options/:sourceId — the values behind a param's `source`.
*
* §F's *Param option sources* (Phase 7). Without it the step editor is a JSON
* editor with better fonts: a landmark, a creature and an item are all "a string
* the operator has to spell right", and an unattended world write scheduled with
* a typo in it is the failure this whole feature exists to make unlikely.
*
* **A refusal is a 200 with `ok: false`, not a 4xx or a 5xx.** §F: a source that
* cannot answer degrades its field to free text with a visible warning rather
* than blocking the form. A 502 would be true about the module and wrong about
* the screen — the operator very often knows the value they want to type, and an
* authoring form that a sidecar outage can make unusable is a worse failure than
* the typo the dropdown prevents. The client renders the `reason` beside the box.
*
* `admin, editor`, like the catalog and for the same argument: this is authoring
* data, and an editor who can write the step must be able to see the values it
* accepts. The registry answers it, so it names no game noun here.
*/
exports.options = async (req, res) => {
const result = await registries.resolveOptionSource(String(req.params.sourceId || ''))
return res.json(result)
}
/**
* PUT /api/v1/admin/events/actions — set one action's switch and caps.
*

View File

@@ -47,13 +47,36 @@ eventsRouter.get(
'/catalog',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question.'
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step — the action says what params it takes, a param source names a dropdown, and a cap box is a budget label and unit. A source resolves its VALUES on a request of its own (/options/:sourceId), because a source can be slow or down and must not take the catalog with it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.catalog,
)
// ── Param option sources (Phase 7) ────────────────────────────────────────
//
// Nested UNDER `/catalog`, which is where the § API surface table has always put
// it, and the nesting is the right shape rather than a formality: a source's
// values are catalog data fetched on their own request, because a source can be
// slow or down and must not take the catalog with it. It also puts the route
// permanently out of `/:id`'s way — `/:id/anything` is one route away from being
// added, and a source id read as an event id would 404 with the wrong noun.
//
// Staff, not `adminOnly`: this is authoring data, and §N2's narrow gate is about
// committing the deployment to a run, not about seeing which landmarks exist.
eventsRouter.get(
'/catalog/options/:sourceId',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Resolve the values behind a param option source'
// #swagger.description = 'EVENTS.md F, Param option sources (Phase 7). A param may declare a `source`, and this is what answers it: the module that registered the source resolves the list, so an authoring field is a dropdown of real landmarks or creatures rather than a text box an operator can typo. A refusal comes back as a 200 with `ok: false` and a `reason` -- deliberately, because a source that cannot answer degrades its field to free text with a visible warning rather than blocking the form, and an authoring screen a sidecar outage can make unusable is a worse failure than the typo the dropdown prevents. Values are resolved per request rather than cached in the catalog, because a source can be slow or down and must not take the whole catalog with it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.options,
)
// ── The switchboard (Phase 6) ──────────────────────────────────────────────
//
// A literal path, so it is declared up here with `/catalog` rather than beside