feat(events): enablement, per-run caps and mayInvoke (Phase 6) #188

Merged
whitlocktech merged 1 commits from feature/events-phase-6 into edge 2026-09-03 14:36:44 +00:00
Member

Event System Phase 6 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#214.

A module declaring a verb is code the operator installed. It is not a permission. Enablement is the grant, the cap is how much of it, and after this phase both live behind one function rather than being spread across route middleware. Walked end to end on the local review stack, which found two defects (details at the bottom).

Two new tablesevent_action_settings and event_run_budget — plus verified_at / verified_by on event_versions. Three routes, none moved.

  1. The default-off line falls between inspect and change, not between notify and inspect. §K's sentence read literally shipped core.wait — which is risk: 'inspect' — disabled, so every published event that waits would break on a fresh deployment until an admin found the switch. An inspect action reads state and writes nothing, so a deployment gains no risk by having it on. §K is amended, and the same sentence is the role floor, so it moved with it: change and irreversible are the steps only an admin may author.
  2. The tightest cap wins. event_action_settings.caps is per action while event_run_budget is one row per dimension, so two actions spending uo.creatures have to agree on one number — and the number a safety limit settles on is the smaller. It is what keeps a dimension a bound on the RUN's total effect rather than a per-verb allowance two verbs can each draw in full. Pinned into the run at creation with the action it came from, so the console can say whose switch set it.
  3. A refusal follows the step's on_failure, and takes health to degraded. A cap breach or a disabled action is not an outage — so it is refused rather than failed, with its own log kind — but it takes the same disposition a failure takes. A change step's default pause stops the run where it stands and an operator raises the cap, edits, and resumes. Skipping and carrying on would leave a completed run with a hole in it, and nobody reads a log for a run that says completed.
  4. The verify gate is enforced for SCHEDULED starts only. A version must have passed its dry run before the scheduler will materialise an occurrence of it unattended. A human pressing Start now is not gated, because that human is the review the gate exists to require.

Two things derived rather than decided, and both worth a look. A dry run fails rather than warns when a version names a disabled action or over-spends a cap — that is exactly what the scheduled-start gate is for; and the unattended path does not re-check the starter's role, because demoting an admin at midnight must not silently strand every event they started. Cancel is the control for a run that should stop.

Three things the build settled

  • The check is after the claim, before the dispatch, and it can write. After, because the cap is held by a conditional UPDATE and two ticks that both priced a step before either claimed it would both spend. Before, because a refusal means the action does not happen: nothing sent, nothing created, the module never reached. And spend is a parameter of mayInvoke rather than a second function, because checking and then spending would be two statements with a race between them — the exact race the conditional increment exists to remove.
  • A retry does not pay the cap twice, and a failure gets no refund. The spend happens on the first attempt only: charging a cap for a flaky socket would exhaust a deployment's allowance through unreliability rather than through effect. The corollary is deliberate — a step that spent and then failed for good keeps its spend, because the attempt may have half-run and a refund would be core asserting that it did not. A partial spend across dimensions is unwound: a step refused on its second dimension gives the first back, because a step that did not run must not have spent anything.
  • NULL cap is uncapped and still a row; a MISSING row is a refusal. Keeping those distinct is what lets the meter count a dimension nothing bounds, while a step spending something its own run's version never priced still fails closed.

The one thing this phase deliberately cannot demonstrate with a module

registerEventActions is not on the module-facing API yet — that seam is Phase 7's — and core's own three actions declare no cost. So the cap machinery ships with no live consumer, exactly as the plan intends ("ships: a system that cannot be made to do an unbounded amount of anything"). It is proved instead by unit tests, by a real-MariaDB concurrency test, and on the walk by a rig action added to core and reverted before commit. Worth knowing before reading the diff and wondering what spends anything.

Verify

  • npm test1921 tests, 1847 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 1810/1746/63/1; the +10 skipped are the new SQL cases skipping without a database.
  • +111 tests. eventAuthorize.test.js (27) is mayInvoke's four layers and the cap arithmetic; eventVerify.test.js (12) the dry run including the whole-plan total; eventsRoles.test.js (31) is the 403 walk — it drives the real router with every handler replaced by a marker, so it measures gates rather than gates-plus-handlers, and asserts each route was reached rather than merely "not 403" (a mistyped path would otherwise 404 for every role and protect nothing). Plus 12 in eventRunner.test.js, 19 in eventsAdmin.test.js, and 10 in eventRunnerSql.test.js against a real MariaDB (49/49 with a database, skipped without) — among them the plan's own criterion, two concurrent spends against one cap.
  • npm run routes:manifest and npm run swaggerthree routes added, none moved. npm run check:modules green. Client: 362 pass, and it builds.

The live walk, on the local review stack

The gate announced itself before anything was authored: a leftover Phase 4 definition started logging "scheduled occurrences held: the published version has never been verified" on the first tick after boot.

  • The board arrived with the right posture. core.wait (inspect) enabled, core.announce and core.cue enabled, the rig's change action disabled, every row configured: false — a deployment that has never opened the screen, behaving correctly with no rows at all.
  • The dimension was discovered by pricing the declared example, live, and the cap editor offered exactly it. A cap naming a dimension the action does not spend was refused by name; so were an unregistered action and a missing enabled.
  • The dry run caught the whole-plan total. Three steps of 15 under a cap of 30 — each legal alone — came back this event asks for 45 of "core.creatures" across all its steps, and this deployment allows 30 per run, before anything was scheduled. That check exists nowhere else: every per-step check is also made at save or at dispatch, and the total is not.
  • The dry run found a real defect in a leftover definition — an announce leg "site" no module registers — and, being a failure, recorded nothing, so the schedule stayed held. Fixing and re-verifying lifted the hold on the same tick and the occurrence materialised.
  • §K's role floor held: smoke_editor was refused a world-changing step and allowed an announcing one; navadmin saved the same body.
  • The cap bit at dispatch. Two 15s spent 30 of 30 and the third came back refused: asks for 15 of "core.creatures"; 30 of 30 is already spent this run; health degraded, run completed under on_failure: skip. With pause, a disabled action stopped the run at step 0 and left the step after it pending.
  • The 403 walk held live as well as in the harness: GET and PUT /actions answered 403 to the editor and the moderator alike, 200 to the admin.

Two defects the walk found, both fixed here — and neither could have been caught anywhere else:

  1. The run console route dropped the budget. runs.detail() returned it and the client read it, but getRun builds its response field by field and never listed it — so the meter would have rendered empty for ever, with the model test and the client both correct in isolation. A hand-built response shape is a place a new field silently does not arrive, and only a request through the whole stack says so.
  2. The role refusal did not agree with itself. "Spawn creatures" change the world — the sentence always used the plural verb over a list that is almost always one long, so the case an editor actually sees read as a broken sentence rather than as a rule.

And one in the harness, for the fourth time. runs.create and runs.detail gained legs into two new tables and versionsDb gained markVerified; four test files stub that layer and none knew. Same symptom as Phases 4 and 5 — a ten-second ECONNREFUSED that says nothing about the thing under test. eventsAdmin.test.js also had to mirror the new SELECT_LIST join column, or the stub answers a shape the real query never returns, which is a test agreeing with itself.

One thing this PR fixes in passing: the committed OpenAPI spec still carried the pre-Phase-5 catalog description — that annotation was edited without a regenerate. npm run swagger picks it up here, which is why the spec diff has two deletions that are not this phase's.


  • AI-assisted: authored with Claude Code (Claude Opus).
Event System **Phase 6** (`EVENTS_PLAN.md`). Docs half: **RunicGateway/docs#214**. > **A module declaring a verb is code the operator installed. It is not a permission.** Enablement is the grant, the cap is how much of it, and after this phase both live behind one function rather than being spread across route middleware. Walked end to end on the local review stack, which found two defects (details at the bottom). **Two new tables** — `event_action_settings` and `event_run_budget` — plus `verified_at` / `verified_by` on `event_versions`. **Three routes, none moved.** ## Four decisions, settled 2026-09-03, all as recommended 1. **The default-off line falls between `inspect` and `change`, not between `notify` and `inspect`.** §K's sentence read literally shipped `core.wait` — which is `risk: 'inspect'` — disabled, so every published event that waits would break on a fresh deployment until an admin found the switch. An `inspect` action reads state and writes nothing, so a deployment gains no risk by having it on. §K is amended, and **the same sentence is the role floor**, so it moved with it: `change` and `irreversible` are the steps only an admin may author. 2. **The tightest cap wins.** `event_action_settings.caps` is per action while `event_run_budget` is one row per dimension, so two actions spending `uo.creatures` have to agree on one number — and the number a safety limit settles on is the smaller. It is what keeps a dimension a bound on the RUN's total effect rather than a per-verb allowance two verbs can each draw in full. Pinned into the run at creation with the action it came from, so the console can say whose switch set it. 3. **A refusal follows the step's `on_failure`, and takes health to `degraded`.** A cap breach or a disabled action is not an outage — so it is `refused` rather than `failed`, with its own log kind — but it takes the same *disposition* a failure takes. A `change` step's default `pause` stops the run where it stands and an operator raises the cap, edits, and resumes. Skipping and carrying on would leave a `completed` run with a hole in it, and nobody reads a log for a run that says completed. 4. **The verify gate is enforced for SCHEDULED starts only.** A version must have passed its dry run before the scheduler will materialise an occurrence of it unattended. A human pressing *Start now* is not gated, because that human **is** the review the gate exists to require. **Two things derived rather than decided, and both worth a look.** A dry run **fails** rather than warns when a version names a disabled action or over-spends a cap — that is exactly what the scheduled-start gate is for; and the unattended path **does not re-check the starter's role**, because demoting an admin at midnight must not silently strand every event they started. Cancel is the control for a run that should stop. ## Three things the build settled - **The check is after the claim, before the dispatch, and it can write.** After, because the cap is held by a conditional `UPDATE` and two ticks that both priced a step before either claimed it would both spend. Before, because a refusal means the action does not happen: nothing sent, nothing created, the module never reached. And `spend` is a **parameter** of `mayInvoke` rather than a second function, because checking and then spending would be two statements with a race between them — the exact race the conditional increment exists to remove. - **A retry does not pay the cap twice, and a failure gets no refund.** The spend happens on the first attempt only: charging a cap for a flaky socket would exhaust a deployment's allowance through unreliability rather than through effect. The corollary is deliberate — a step that spent and then failed for good keeps its spend, because the attempt may have half-run and a refund would be core asserting that it did not. A *partial* spend across dimensions **is** unwound: a step refused on its second dimension gives the first back, because a step that did not run must not have spent anything. - **`NULL` cap is uncapped and still a row; a MISSING row is a refusal.** Keeping those distinct is what lets the meter count a dimension nothing bounds, while a step spending something its own run's version never priced still fails closed. ## The one thing this phase deliberately cannot demonstrate with a module `registerEventActions` is **not on the module-facing API yet** — that seam is Phase 7's — and core's own three actions declare no `cost`. So the cap machinery ships with no live consumer, exactly as the plan intends (*"ships: a system that cannot be made to do an unbounded amount of anything"*). It is proved instead by unit tests, by a real-MariaDB concurrency test, and on the walk by a **rig action added to core and reverted before commit**. Worth knowing before reading the diff and wondering what spends anything. ## Verify - `npm test` — **1921 tests, 1847 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 1810/1746/63/1; the +10 skipped are the new SQL cases skipping without a database. - **+111 tests.** `eventAuthorize.test.js` (27) is `mayInvoke`'s four layers and the cap arithmetic; `eventVerify.test.js` (12) the dry run including the whole-plan total; **`eventsRoles.test.js` (31) is the 403 walk** — it drives the real router with every handler replaced by a marker, so it measures gates rather than gates-plus-handlers, and asserts each route was *reached* rather than merely "not 403" (a mistyped path would otherwise 404 for every role and protect nothing). Plus 12 in `eventRunner.test.js`, 19 in `eventsAdmin.test.js`, and **10 in `eventRunnerSql.test.js` against a real MariaDB** (49/49 with a database, skipped without) — among them the plan's own criterion, two concurrent spends against one cap. - `npm run routes:manifest` and `npm run swagger` — **three routes added, none moved.** `npm run check:modules` green. Client: **362 pass**, and it builds. ### The live walk, on the local review stack The gate announced itself before anything was authored: a leftover Phase 4 definition started logging *"scheduled occurrences held: the published version has never been verified"* on the first tick after boot. - **The board arrived with the right posture.** `core.wait` (`inspect`) enabled, `core.announce` and `core.cue` enabled, the rig's `change` action disabled, every row `configured: false` — a deployment that has never opened the screen, behaving correctly with no rows at all. - **The dimension was discovered by pricing the declared `example`**, live, and the cap editor offered exactly it. A cap naming a dimension the action does not spend was refused by name; so were an unregistered action and a missing `enabled`. - **The dry run caught the whole-plan total.** Three steps of 15 under a cap of 30 — each legal alone — came back `this event asks for 45 of "core.creatures" across all its steps, and this deployment allows 30 per run`, before anything was scheduled. That check exists nowhere else: every per-step check is also made at save or at dispatch, and the total is not. - **The dry run found a real defect in a leftover definition** — an announce leg `"site"` no module registers — and, being a failure, **recorded nothing**, so the schedule stayed held. Fixing and re-verifying lifted the hold on the same tick and the occurrence materialised. - **§K's role floor held**: `smoke_editor` was refused a world-changing step and allowed an announcing one; `navadmin` saved the same body. - **The cap bit at dispatch.** Two 15s spent 30 of 30 and the third came back `refused: asks for 15 of "core.creatures"; 30 of 30 is already spent this run`; health `degraded`, run `completed` under `on_failure: skip`. With `pause`, a **disabled** action stopped the run at step 0 and left the step after it `pending`. - **The 403 walk held live** as well as in the harness: `GET` and `PUT /actions` answered 403 to the editor and the moderator alike, 200 to the admin. **Two defects the walk found, both fixed here — and neither could have been caught anywhere else:** 1. **The run console route dropped the budget.** `runs.detail()` returned it and the client read it, but `getRun` builds its response field by field and never listed it — so the meter would have rendered empty for ever, with the model test and the client both correct in isolation. **A hand-built response shape is a place a new field silently does not arrive**, and only a request through the whole stack says so. 2. **The role refusal did not agree with itself.** `"Spawn creatures" change the world` — the sentence always used the plural verb over a list that is almost always one long, so the case an editor actually sees read as a broken sentence rather than as a rule. **And one in the harness, for the fourth time.** `runs.create` and `runs.detail` gained legs into two new tables and `versionsDb` gained `markVerified`; four test files stub that layer and none knew. Same symptom as Phases 4 and 5 — a ten-second `ECONNREFUSED` that says nothing about the thing under test. `eventsAdmin.test.js` also had to mirror the new `SELECT_LIST` join column, or the stub answers a shape the real query never returns, which is a test agreeing with itself. **One thing this PR fixes in passing:** the committed OpenAPI spec still carried the pre-Phase-5 catalog description — that annotation was edited without a regenerate. `npm run swagger` picks it up here, which is why the spec diff has two deletions that are not this phase's. --- - [x] AI-assisted: authored with Claude Code (Claude Opus).
wtclaude added 1 commit 2026-09-03 10:52:45 +00:00
feat(events): enablement, per-run caps and mayInvoke (Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 13m33s
4077c4e79e
Two new tables — event_action_settings (the deployment switchboard) and
event_run_budget (what a run has spent and the most it may) — plus verified_at
and verified_by on event_versions. The whole authorisation decision moves behind
one function, events/authorize.js: role, enablement, cap, and the shard's own
switch named as the layer core deliberately does not duplicate.

Three routes, none moved: GET/PUT /admin/events/actions (admin in both
directions) and POST /admin/events/:id/verify (admin, editor — a dry run
dispatches nothing).

Four decisions, settled by the org lead 2026-09-03:

- The default-off line falls between inspect and change, not between notify and
  inspect. Read literally, §K shipped core.wait disabled. The same line is the
  role floor.
- The tightest cap wins where two actions spend one dimension, pinned into the
  run at creation with the action it came from.
- A refusal follows the step's on_failure and takes health to degraded — its own
  status and its own log kind, because a refusal is not an outage.
- The verify gate is enforced for scheduled starts only: a human pressing Start
  now is the review the gate exists to require.

Derived and flagged for review: a dry run fails rather than warns on a disabled
action or an over-cap plan, and the unattended path does not re-check the
starter's role.

+111 tests (1921/1847/73/1 — the one failure pre-existing and environmental),
including a 403 walk over the real router and two concurrent spends against one
cap on a real MariaDB. The live walk found two defects, both fixed here: the run
console route dropped the budget it was handed, and the role refusal used a
plural verb over a one-item list.

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 429e657239 into edge 2026-09-03 14:36:44 +00:00
whitlocktech deleted branch feature/events-phase-6 2026-09-03 14:36:45 +00:00
Sign in to join this conversation.
No description provided.