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

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
This commit is contained in:
2026-09-03 05:50:58 -05:00
parent 4ac917c3a3
commit 4077c4e79e
31 changed files with 3890 additions and 24 deletions

View File

@@ -13,10 +13,12 @@
// gate nobody notices was missing.
//
// Reads are staff-wide. The live run controls landed in Phase 3 and are `admin`
// + `moderator`, deliberately wider than start (§N2). `verify` (admin, editor),
// `advance`, `cleanup` and the action switchboard are still absent rather than
// stubbed — there is no advance condition until Phase 5, no resource ledger
// until Phase 8 and no caps to price against until Phase 6.
// + `moderator`, deliberately wider than start (§N2). `advance` arrived in Phase
// 5; **`verify` and the action switchboard arrived in Phase 6** — `verify` at
// `admin, editor` because a dry run dispatches nothing, and both halves of
// `/actions` at `admin`, because §K puts the switchboard in the same row as the
// world-changing actions it governs. `cleanup` is still absent rather than
// stubbed: there is no resource ledger until Phase 8.
//
// **Literal paths are declared before `/:id`**, so `/catalog`, `/series`,
// `/calendar` and `/runs` are never read as an event id.
@@ -52,6 +54,42 @@ eventsRouter.get(
controller.catalog,
)
// ── The switchboard (Phase 6) ──────────────────────────────────────────────
//
// A literal path, so it is declared up here with `/catalog` rather than beside
// the definition routes -- `/:id` would otherwise read `actions` as an event id.
// Both halves are `adminOnly`: §K puts the action switchboard in the same row as
// the world-changing actions it governs, because deciding what a deployment may
// do at all is configuration that can break things, which is exactly the line
// module-uo's split already draws.
eventsRouter.get(
'/actions',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Which actions are enabled on this deployment, and their per-run caps'
// #swagger.description = 'The deployment switchboard (EVENTS.md K, Phase 6). One entry per action registered on THIS boot, each carrying the deployment stored opinion of it or, where there is none, the default its risk class implies: change and irreversible actions arrive disabled, notify and inspect arrive enabled. `configured` says whether a row exists at all, which is how the screen tells "an admin turned this on" from "this has always been on". `dimensions` is what the action can spend, so the screen can offer one cap box per dimension. Nothing is seeded at boot: a deployment that has never opened this screen has no rows and behaves correctly.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Every registered action with its switch, its caps and the dimensions it can spend', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, worldChangingRisks: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.actions,
)
eventsRouter.put(
'/actions',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Enable or disable one action, and set its per-run caps'
// #swagger.description = 'One action per request rather than the whole board, because the board is rendered from the registry and a whole-board write would have to decide what an action missing from the body means -- on a screen listing what is registered right now that is "a module booted between the read and the write", and writing a default over an admin stored choice is quiet data loss. A cap must name a dimension the action actually spends: a cap on a dimension it never names would be a number an operator believes is protecting them while it bounds nothing. Caps are copied into a run budget when the run is created, so moving a switch never changes what a run already in flight is allowed.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["actionId", "enabled"], properties: { actionId: { type: "string", example: "core.announce" }, enabled: { type: "boolean", example: true }, caps: { type: "object", additionalProperties: { type: "integer" }, example: { "uo.creatures": 30 } } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The stored setting', content: { "application/json": { schema: { type: "object", properties: { action: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[400] = { description: 'enabled is missing, or a cap names a dimension this action does not spend', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No module registers that action', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
controller.saveAction,
)
eventsRouter.get(
'/series',
// #swagger.tags = ['Admin · Events']
@@ -343,6 +381,20 @@ eventsRouter.get(
controller.listVersions,
)
eventsRouter.post(
'/:id/verify',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps'
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'The definition is archived, or has no phases to verify', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOrEditor,
controller.verify,
)
eventsRouter.post(
'/:id/publish',
// #swagger.tags = ['Admin · Events']