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

@@ -32,6 +32,8 @@ const runs = require('../../../model/events/eventRuns.model')
const controls = require('../../../model/events/eventRunControls.model')
const logDb = require('../../../model/events/eventRunLog.db')
const activity = require('../../../model/activity/activity.model')
const settingsDb = require('../../../model/events/eventActionSettings.db')
const authorize = require('../../../events/authorize')
const asId = (raw) => {
const n = Number(raw)
@@ -57,6 +59,10 @@ const shapeDefinition = (d) => ({
state: d.state,
currentVersionId: d.current_version_id,
currentVersion: d.current_version,
// §K's gate, rendered where it can still be acted on. `null` on a draft --
// there is no version to have verified -- and a date once a dry run has passed
// against the published one.
currentVersionVerifiedAt: d.current_version_verified_at || null,
seriesId: d.series_id,
seriesName: d.series_name,
seriesOrder: d.series_order,
@@ -289,6 +295,11 @@ exports.getRun = async (req, res) => {
// `eventRuns.model.detail` for why the sentence is built here and not in
// the browser.
gates: found.gates,
// The caps this run was given and what it has spent (Phase 6). Copied into
// the run when it was created, so it answers "what was THIS run allowed"
// rather than "what is allowed now" — which is the question that survives
// an admin moving a switch tomorrow.
budget: found.budget,
})
}
@@ -342,7 +353,7 @@ exports.listVersions = async (req, res) => {
/** POST /api/v1/admin/events */
exports.create = async (req, res) => {
const result = await definitions.create(req.body, req.user.id)
const result = await definitions.create(req.body, req.user.id, { role: req.user.role })
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
await activity.log({
req,
@@ -356,7 +367,7 @@ exports.create = async (req, res) => {
exports.update = async (req, res) => {
const id = asId(req.params.id)
if (!id) return res.status(400).json({ error: 'bad event id' })
const result = await definitions.save(id, req.body, req.user.id)
const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role })
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
await activity.log({
req,
@@ -388,6 +399,139 @@ exports.publish = async (req, res) => {
})
}
/**
* POST /api/v1/admin/events/:id/verify — the dry run.
*
* `admin, editor` rather than `admin` (§ API surface): a dry run dispatches
* nothing and changes nothing, and the author who wrote the definition is
* exactly who should be able to price it against the caps before asking an
* admin to publish it.
*
* **A report with findings is a 200, not a 400.** The request succeeded; the
* plan has problems. Answering 4xx would make "this event asks for 45 creatures
* and you allow 30" indistinguishable to the client from "you sent a bad event
* id", and the whole value of the screen is rendering the findings.
*/
exports.verify = async (req, res) => {
const id = asId(req.params.id)
if (!id) return res.status(400).json({ error: 'bad event id' })
const result = await definitions.verify(id, req.user)
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
await activity.log({
req,
action: 'event.definition.verified',
detail: {
id,
target: result.target,
versionId: result.versionId,
passed: result.report.ok,
findings: result.report.findings.length,
},
})
return res.json({
target: result.target,
versionId: result.versionId,
version: result.version,
recorded: result.recorded,
report: result.report,
})
}
/**
* GET /api/v1/admin/events/actions — the deployment's switchboard.
*
* Every registered action, each with the deployment's stored opinion of it or,
* where there is none, **the default its risk class implies**. The default is
* computed by `authorize.isEnabled` rather than here, because a screen that
* worked out the posture for itself would be a second copy of the posture, and
* the copy that drifts is always the one on the screen.
*
* `configured` says whether a row exists, which the client needs to tell "an
* admin turned this on" from "this has always been on" — the same fact, arrived
* at two ways, and only one of them is a decision somebody made.
*/
exports.actions = async (_req, res) => {
const all = registries.allEventActions()
const stored = await settingsDb.byIds(all.map((a) => a.id))
return res.json({
actions: all.map((a) => {
const row = stored.get(a.id) || null
const full = registries.eventAction(a.id)
return {
...a,
enabled: authorize.isEnabled(full, row),
configured: Boolean(row),
changesWorld: authorize.changesWorld(full),
// The dimensions this action can spend, so the screen can offer a cap
// box per dimension. Discovered by pricing the action's own declared
// examples until §F's `registerEventBudgets` lands in Phase 7 — see
// `authorize.dimensionsOf`.
dimensions: authorize.dimensionsOf(full),
caps: row?.caps || {},
updatedAt: row?.updated_at || null,
updatedBy: row?.updated_by_username || null,
}
}),
// The rule the screen explains to the operator, served rather than written
// into the client twice.
worldChangingRisks: authorize.WORLD_CHANGING,
})
}
/**
* PUT /api/v1/admin/events/actions — set one action's switch and caps.
*
* One action per request rather than the whole board: the board is rendered from
* the registry and a whole-board PUT would have to say what an action MISSING
* from the body means. On a screen listing what is registered right now, that is
* "a module booted between the GET and the PUT", and answering it by writing a
* default over an admin's stored choice is the kind of quiet data loss a sparse
* write does not have.
*/
exports.saveAction = async (req, res) => {
const actionId = String(req.body?.actionId || '')
const action = registries.eventAction(actionId)
if (!action) return res.status(404).json({ error: 'no module registers that action' })
if (typeof req.body?.enabled !== 'boolean') {
return res.status(400).json({ error: 'enabled must be true or false' })
}
// Caps are validated against the dimensions this action can actually spend.
// A cap on a dimension it never names is not a harmless extra row — it is a
// number an operator believes is protecting them, on a screen that would
// render it back to them forever, bounding nothing.
const known = new Set(authorize.dimensionsOf(action))
const caps = {}
for (const [dimension, raw] of Object.entries(req.body?.caps || {})) {
if (raw === null || raw === '') continue
if (!known.has(dimension)) {
return res.status(400).json({ error: `"${action.id}" does not spend "${dimension}"` })
}
const n = Number(raw)
if (!Number.isInteger(n) || n < 0) {
return res.status(400).json({ error: `the cap for "${dimension}" must be a whole number of 0 or more` })
}
caps[dimension] = n
}
const row = await settingsDb.put(actionId, { enabled: req.body.enabled, caps }, req.user.id)
await activity.log({
req,
action: 'event.action.configured',
detail: { actionId, enabled: Boolean(req.body.enabled), caps },
})
return res.json({
action: {
id: actionId,
enabled: Boolean(row.enabled),
configured: true,
caps: row.caps,
updatedAt: row.updated_at,
},
})
}
/** DELETE /api/v1/admin/events/:id — archive, never a hard delete */
exports.archive = async (req, res) => {
const id = asId(req.params.id)

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']