feat(events): open the event contract to modules (Phase 7) #189

Merged
whitlocktech merged 1 commits from feature/events-phase-7 into edge 2026-09-03 19:37:36 +00:00
Member

Event System Phase 7 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#215.

A module declaring a verb is code the operator installed — but until this phase no module could declare one at all. registerEventActions has existed since Phase 1 and has staged core's three actions on every boot since; 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 this phase forwards four names, adds the three declarations beside the action registry, and makes one rule real. Walked end to end on the local review stack, which found one defect nothing else could (details at the bottom).

MODULE_API_VERSION → 1.10.0, in both halves. One route added, none moved. No schema change.

  1. Option sources are their own registrationregisterEventOptionSources([{ id, label, resolve }]), modelled on registerAudiences. Not a field on the action that names one: a catalog has more than one consumer — uo.options.items is the allowlist for granting an item and for taking one back — and two actions declaring it separately would be two allowlists that can disagree.
  2. A 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: cost is 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.
  3. A lease is DECLARED here and acquired by nothing. Core owns a lease's duration and its conflict check; both live in the resource ledger, which is Phase 8. It is in 1.10.0 rather than 1.11.0 so the module contract is one version an author reads once.
  4. Core registers an option source of its own, core.options.legs. core.announce's leg param 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

  • The undeclared refusal gets its own code (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.
  • A budget's unit is 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.
  • A dimension nobody declares is SHOWN on the switchboard, not filtered out. The action is refused wherever it is used, so the screen has to be able to say which module is incomplete; hiding the row would make a broken module look like a cheap one. registerEventBudgets supplies the label and unit, while which dimensions an action spends is still discovered by pricing its declared examples — a registry cannot answer that, because cost is a function of params.

Proved with a throwaway module, as the plan asks

"Prove it with a throwaway module, not with module-uo. A contract validated only against the module it was carved out of has not been validated."

eventModuleContract.test.js (17 tests) writes a real module to a real directory, points MODULES_DIR at it and lets the real loader scan, validate, register() and commit it. Nothing in it stubs the loader or calls registries.stage() by hand ��� a test that staged directly would have passed just as happily before this phase, when buildApi forwarded none of these four names.

It covers all five failure shapes dispatched from a module (rejected promise, throw, budgetMs timeout, non-object, missing ok), retry: false, await: 'human' and holdFor, the whole envelope including the idempotency key, verify: true writing nothing, the four id spaces, once on 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 test1950 tests, 1876 pass, 73 skipped, 1 fail. The one failure is engagementManifest.test.js, pre-existing and environmental (CRLF under core.autocrlf=true) — confirmed by stashing this branch and watching it fail unchanged. edge before this branch is 1921/1847/73/1.
  • +29 tests. 17 in eventModuleContract.test.js; 11 in eventActionRegistry.test.js (the three new shape checks and the id spaces); 1 in eventsRoles.test.js, the new route joining the 403 walk.
  • eventsAdmin, eventAuthorize, eventVerify and eventRunner all 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 renamed x.*test.* so they carry the registering owner's prefix, exactly as their action ids already do.
  • npm run routes:manifest and npm run swaggerone route added, none moved. check:modules and check:hosts green. Client: 362 pass, and it builds.

The live walk, on the local review stack

Driven by a throwaway rig module in website/modules/ — a real module directory, scanned by the real loader, deleted before commit.

  • coreApi: "^1.10.0" resolved and module-uo's "^1.9.0" still did, on the same boot. That is the additive claim proved rather than asserted.
  • The catalog served all four registrations with every callable stripped — two budgets with their labels and units, one lease, three option sources including core's own.
  • The option route answered four ways: a module's list; a source that throws (200, ok: false, "could not be read"); a source nobody registers; and core.options.legs, which came back with discord and module-uo's towncrier — the per-request resolve, proved.
  • The switchboard named the dimension. Wisps summoned … count on 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."
  • The save refused the undeclared dimension by name and phase-step path, before anything was scheduled; the role floor refused an editor the same world-changing step an admin saved.
  • The dry run priced a MODULE's action — 3 against a cap of 5 — and refused the two-step version at 8 of "rig.wisps". At dispatch the first step spent 3 and the second came back refused: asks for 3 of "rig.wisps"; 3 of 5 is already spent this run, health degraded.
  • The dropdown wrote the exact spelling into the params box. Picking Britain Hall put "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 setSources state updater and read the flag on the next line. setSources queues its updater rather than running it, so the flag was always false, 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 under curl; 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 a useRef now.

And one thing the docs caught the code doing. EVENTS.md's § API surface table has always named this route GET /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.


This PR turns the integration kit red, and that is the mechanism rather than a bug. checkCoreApi.js asserts equality against the MODULE_API_VERSION of the main sha ci/core-ref.json pins, so a bump here is meant to make that repo fail until someone re-reads the chapters. It stays red until the cutover re-pins it (Phase 16). Please do not "fix" it there.


  • AI-assisted: authored with Claude Code (Claude Opus).
Event System **Phase 7** (`EVENTS_PLAN.md`). Docs half: **RunicGateway/docs#215**. > **A module declaring a verb is code the operator installed — but until this phase no module could declare one at all.** `registerEventActions` has existed since Phase 1 and has staged core's three actions on every boot since; 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 this phase forwards four names, adds the three declarations beside the action registry, and makes one rule real. Walked end to end on the local review stack, which found one defect nothing else could (details at the bottom). **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 1. **Option sources are their own registration** — `registerEventOptionSources([{ id, label, resolve }])`, modelled on `registerAudiences`. Not a field on the action that names one: a catalog has more than one consumer — `uo.options.items` is the allowlist for granting an item *and* for taking one back — and two actions declaring it separately would be two allowlists that can disagree. 2. **A `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: `cost` is 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. 3. **A lease is DECLARED here and acquired by nothing.** Core owns a lease's duration and its conflict check; both live in the resource ledger, which is Phase 8. It is in 1.10.0 rather than 1.11.0 so the module contract is one version an author reads once. 4. **Core registers an option source of its own**, `core.options.legs`. `core.announce`'s `leg` param 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 - **The undeclared refusal gets its own code (`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. - **A budget's `unit` is 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. - **A dimension nobody declares is SHOWN on the switchboard, not filtered out.** The action is refused wherever it is used, so the screen has to be able to say which module is incomplete; hiding the row would make a broken module look like a cheap one. `registerEventBudgets` supplies the label and unit, while *which* dimensions an action spends is still discovered by pricing its declared examples — a registry cannot answer that, because `cost` is a function of params. ## Proved with a throwaway module, as the plan asks > *"Prove it with a throwaway module, not with module-uo. A contract validated only against the module it was carved out of has not been validated."* `eventModuleContract.test.js` (17 tests) writes a real module to a real directory, points `MODULES_DIR` at it and lets the **real loader** scan, validate, `register()` and commit it. Nothing in it stubs the loader or calls `registries.stage()` by hand ��� a test that staged directly would have passed just as happily *before* this phase, when `buildApi` forwarded none of these four names. It covers all five failure shapes **dispatched from a module** (rejected promise, throw, `budgetMs` timeout, non-object, missing `ok`), `retry: false`, `await: 'human'` and `holdFor`, the whole envelope including the idempotency key, `verify: true` writing nothing, the four id spaces, `once` on 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 is `engagementManifest.test.js`, pre-existing and environmental (CRLF under `core.autocrlf=true`) — **confirmed by stashing this branch and watching it fail unchanged**. `edge` before this branch is 1921/1847/73/1. - **+29 tests.** 17 in `eventModuleContract.test.js`; 11 in `eventActionRegistry.test.js` (the three new shape checks and the id spaces); 1 in `eventsRoles.test.js`, the new route joining the 403 walk. - `eventsAdmin`, `eventAuthorize`, `eventVerify` and `eventRunner` all 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 renamed `x.*` → `test.*` so they carry the registering owner's prefix, exactly as their action ids already do. - `npm run routes:manifest` and `npm run swagger` — **one route added, none moved.** `check:modules` and `check:hosts` green. Client: **362 pass**, and it builds. ### The live walk, on the local review stack Driven by a throwaway `rig` module in `website/modules/` — a real module directory, scanned by the real loader, **deleted before commit**. - **`coreApi: "^1.10.0"` resolved and `module-uo`'s `"^1.9.0"` still did**, on the same boot. That is the additive claim proved rather than asserted. - **The catalog served all four registrations** with every callable stripped — two budgets with their labels and units, one lease, three option sources including core's own. - **The option route answered four ways**: a module's list; a source that throws (`200`, `ok: false`, *"could not be read"*); a source nobody registers; and `core.options.legs`, which came back with `discord` **and** module-uo's `towncrier` — the per-request resolve, proved. - **The switchboard named the dimension.** `Wisps summoned … count` on 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."* - **The save refused the undeclared dimension** by name and phase-step path, before anything was scheduled; the role floor refused an editor the same world-changing step an admin saved. - **The dry run priced a MODULE's action** — 3 against a cap of 5 — and refused the two-step version at `8 of "rig.wisps"`. At dispatch the first step spent 3 and the second came back `refused: asks for 3 of "rig.wisps"; 3 of 5 is already spent this run`, health `degraded`. - **The dropdown wrote the exact spelling into the params box.** Picking *Britain Hall* put `"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 `setSources` state updater** and read the flag on the next line. `setSources` **queues** its updater rather than running it, so the flag was always `false`, 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 under `curl`; 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 a `useRef` now. **And one thing the docs caught the code doing.** `EVENTS.md`'s § API surface table has always named this route `GET /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. --- > **This PR turns the integration kit red, and that is the mechanism rather than a bug.** `checkCoreApi.js` asserts *equality* against the `MODULE_API_VERSION` of the `main` sha `ci/core-ref.json` pins, so a bump here is meant to make that repo fail until someone re-reads the chapters. It stays red until the cutover re-pins it (Phase 16). Please do not "fix" it there. --- - [x] AI-assisted: authored with Claude Code (Claude Opus).
wtclaude added 1 commit 2026-09-03 19:15:58 +00:00
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
fd9fb50351
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
whitlocktech merged commit 57d183e921 into edge 2026-09-03 19:37:36 +00:00
whitlocktech deleted branch feature/events-phase-7 2026-09-03 19:37:37 +00:00
Sign in to join this conversation.
No description provided.