feat(events): enablement, per-run caps and mayInvoke (Phase 6) #188
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/events-phase-6"
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 6 (
EVENTS_PLAN.md). Docs half: RunicGateway/docs#214.Two new tables —
event_action_settingsandevent_run_budget— plusverified_at/verified_byonevent_versions. Three routes, none moved.Four decisions, settled 2026-09-03, all as recommended
inspectandchange, not betweennotifyandinspect. §K's sentence read literally shippedcore.wait— which isrisk: 'inspect'— disabled, so every published event that waits would break on a fresh deployment until an admin found the switch. Aninspectaction 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:changeandirreversibleare the steps only an admin may author.event_action_settings.capsis per action whileevent_run_budgetis one row per dimension, so two actions spendinguo.creatureshave 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.on_failure, and takes health todegraded. A cap breach or a disabled action is not an outage — so it isrefusedrather thanfailed, with its own log kind — but it takes the same disposition a failure takes. Achangestep's defaultpausestops the run where it stands and an operator raises the cap, edits, and resumes. Skipping and carrying on would leave acompletedrun with a hole in it, and nobody reads a log for a run that says completed.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
UPDATEand 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. Andspendis a parameter ofmayInvokerather 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.NULLcap 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
registerEventActionsis not on the module-facing API yet — that seam is Phase 7's — and core's own three actions declare nocost. 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 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 1810/1746/63/1; the +10 skipped are the new SQL cases skipping without a database.eventAuthorize.test.js(27) ismayInvoke'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 ineventRunner.test.js, 19 ineventsAdmin.test.js, and 10 ineventRunnerSql.test.jsagainst 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:manifestandnpm run swagger— three routes added, none moved.npm run check:modulesgreen. 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.
core.wait(inspect) enabled,core.announceandcore.cueenabled, the rig'schangeaction disabled, every rowconfigured: false— a deployment that has never opened the screen, behaving correctly with no rows at all.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 missingenabled.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."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.smoke_editorwas refused a world-changing step and allowed an announcing one;navadminsaved the same body.refused: asks for 15 of "core.creatures"; 30 of 30 is already spent this run; healthdegraded, runcompletedunderon_failure: skip. Withpause, a disabled action stopped the run at step 0 and left the step after itpending.GETandPUT /actionsanswered 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:
runs.detail()returned it and the client read it, butgetRunbuilds 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."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.createandruns.detailgained legs into two new tables andversionsDbgainedmarkVerified; four test files stub that layer and none knew. Same symptom as Phases 4 and 5 — a ten-secondECONNREFUSEDthat says nothing about the thing under test.eventsAdmin.test.jsalso had to mirror the newSELECT_LISTjoin 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 swaggerpicks it up here, which is why the spec diff has two deletions that are not this phase's.