feat(events): open the event contract to modules (Phase 7) #189
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/events-phase-7"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Event System Phase 7 (
EVENTS_PLAN.md). Docs half: RunicGateway/docs#215.MODULE_API_VERSION → 1.10.0, in both halves. One route added, none moved. No schema change.
Four decisions, settled 2026-09-03, all as recommended
registerEventOptionSources([{ id, label, resolve }]), modelled onregisterAudiences. Not a field on the action that names one: a catalog has more than one consumer —uo.options.itemsis the allowlist for granting an item and for taking one back — and two actions declaring it separately would be two allowlists that can disagree.cost()naming a dimension no module declared is REFUSED — at save, at the dry run and at dispatch. Fail closed, so that §F's "a module cannot spend a budget it did not declare" is a rule rather than a sentence. Deliberately not at registration time:costis a function of params, so core could only enforce it against the declared examples, which is a rule about examples rather than about what runs.core.options.legs.core.announce'slegparam was a free-text box whose typo was caught at dispatch, mid-run — which is precisely the defect Phase 6's own walk hit, an announce leg"site"no module registers. The legs are already a registry with labels in them, so the dropdown costs nothing new, and it means the seam's first exercise is not a module's.Three things the build settled
undeclared) and runs BEFORE any cap arithmetic. A dimension nobody declared has no cap to be under and no meter to draw on. The separate code is not tidiness: an operator told "the cap is spent" goes and raises a cap, and nothing changes, because the fix is a module's declaration.unitis required and its vocabulary is open. Required because a bare number on a cap box is ambiguous in exactly the case that matters — 30 of what? — and open because core never interprets it. Closing the set would make "kilometres" a MODULE_API bump for a noun core does not read.registerEventBudgetssupplies the label and unit, while which dimensions an action spends is still discovered by pricing its declared examples — a registry cannot answer that, becausecostis a function of params.Proved with a throwaway module, as the plan asks
eventModuleContract.test.js(17 tests) writes a real module to a real directory, pointsMODULES_DIRat it and lets the real loader scan, validate,register()and commit it. Nothing in it stubs the loader or callsregistries.stage()by hand ��� a test that staged directly would have passed just as happily before this phase, whenbuildApiforwarded none of these four names.It covers all five failure shapes dispatched from a module (rejected promise, throw,
budgetMstimeout, non-object, missingok),retry: false,await: 'human'andholdFor, the whole envelope including the idempotency key,verify: truewriting nothing, the four id spaces,onceon a second call, the un-namespaced refusal, a module that registers nothing at all, every option-source failure mode, and an action going dormant when its module is uninstalled.Verify
npm test— 1950 tests, 1876 pass, 73 skipped, 1 fail. The one failure isengagementManifest.test.js, pre-existing and environmental (CRLF undercore.autocrlf=true) — confirmed by stashing this branch and watching it fail unchanged.edgebefore this branch is 1921/1847/73/1.eventModuleContract.test.js; 11 ineventActionRegistry.test.js(the three new shape checks and the id spaces); 1 ineventsRoles.test.js, the new route joining the 403 walk.eventsAdmin,eventAuthorize,eventVerifyandeventRunnerall gained the budget declarations their cap tests now need — without that, every cap test would still pass and for the wrong reason: refused by the layer above the one under test. Their test dimensions were renamedx.*→test.*so they carry the registering owner's prefix, exactly as their action ids already do.npm run routes:manifestandnpm run swagger— one route added, none moved.check:modulesandcheck:hostsgreen. Client: 362 pass, and it builds.The live walk, on the local review stack
Driven by a throwaway
rigmodule inwebsite/modules/— a real module directory, scanned by the real loader, deleted before commit.coreApi: "^1.10.0"resolved andmodule-uo's"^1.9.0"still did, on the same boot. That is the additive claim proved rather than asserted.200,ok: false, "could not be read"); a source nobody registers; andcore.options.legs, which came back withdiscordand module-uo'stowncrier— the per-request resolve, proved.Wisps summoned … counton the cap box, and the undeclared one rendered disabled with "No module declares this as a budget, so a step using this action is refused."8 of "rig.wisps". At dispatch the first step spent 3 and the second came backrefused: asks for 3 of "rig.wisps"; 3 of 5 is already spent this run, healthdegraded."place": "britain-hall"into the JSON; one request served two steps on the same action; breaking the JSON greyed that step's picker to "Fix the params JSON to pick a value" while the other stayed live; and pointing the param at the failing source rendered "… could not be read — type the value by hand" in red beside a field that stayed editable.The defect only the browser could find, and it is a React one. The option-source loader wrote its "have I already asked for this?" guard inside a
setSourcesstate updater and read the flag on the next line.setSourcesqueues its updater rather than running it, so the flag was alwaysfalse, the function always returned early, and the request was never made — the field sat on "Reading the list…" for ever. Every server test passed; the route answered correctly undercurl; nothing but opening the page could say so. State is the wrong tool for a question that must be answered synchronously, at the call — it is auseRefnow.And one thing the docs caught the code doing.
EVENTS.md's § API surface table has always named this routeGET /admin/events/catalog/options/:sourceId; it was built one segment shallower at/admin/events/options/:sourceId, and the divergence surfaced only when the docs half was written. Moved to match the spec — which is also the better shape, because a source's values are catalog data, fetched separately so a slow source cannot take the catalog with it.