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

@@ -37,6 +37,11 @@ const versionsDb = require('../src/model/events/eventVersions.db')
const runsDb = require('../src/model/events/eventRuns.db')
const stepsDb = require('../src/model/events/eventRunSteps.db')
const logDb = require('../src/model/events/eventRunLog.db')
// Phase 6: the run console reads the budget meter and the switchboard route
// reads the settings table. Same rule as Phases 4 and 5 -- a new leg under a
// model needs a stub in every file that stubs that layer.
const settingsDb = require('../src/model/events/eventActionSettings.db')
const budgetDb = require('../src/model/events/eventRunBudget.db')
const seriesDb = require('../src/model/events/eventSeries.db')
const gatesDb = require('../src/model/events/eventPhaseGates.db')
const activity = require('../src/model/activity/activity.model')
@@ -56,6 +61,8 @@ for (const [name, mod] of [
['logDb', logDb],
['seriesDb', seriesDb],
['gatesDb', gatesDb],
['settingsDb', settingsDb],
['budgetDb', budgetDb],
['activity', activity],
]) {
originals[name] = { mod, fns: { ...mod } }
@@ -79,6 +86,8 @@ function installStubs() {
log: [],
series: new Map(),
gates: [],
settings: new Map(),
budget: new Map(),
occurrences: new Set(),
nextDefinition: 1,
nextVersion: 1,
@@ -91,6 +100,10 @@ function installStubs() {
series_name: store.series.get(d.series_id)?.name ?? null,
series_slug: store.series.get(d.series_id)?.slug ?? null,
current_version: store.versions.get(d.current_version_id)?.version ?? null,
// Phase 6: joined in `SELECT_LIST` alongside `current_version`, and it has to
// be joined HERE too or the stub answers a shape the real query never
// returns — which is a test agreeing with itself rather than with the server.
current_version_verified_at: store.versions.get(d.current_version_id)?.verified_at ?? null,
})
definitionsDb.list = async ({ state = null } = {}) =>
@@ -147,9 +160,19 @@ function installStubs() {
spec: JSON.parse(JSON.stringify(spec)),
published_at: new Date(),
published_by: userId,
// Phase 6. A version is unverified the moment it is cut, which is what
// makes §K's gate mean anything: publishing is not the review.
verified_at: null,
verified_by: null,
})
return id
}
versionsDb.markVerified = async (id, userId, at = new Date()) => {
const v = store.versions.get(id)
if (!v) return false
Object.assign(v, { verified_at: at, verified_by: userId })
return true
}
const shapeRun = (r) => ({
...r,
@@ -278,6 +301,46 @@ function installStubs() {
store.log.push({ audit: true, ...entry })
return true
}
// ── Phase 6's two tables ──
//
// `store.settings` is a real store here rather than an empty stand-in, because
// this file tests the switchboard ROUTES: the GET has to be able to tell a
// stored opinion from a risk-class default, and the PUT has to be readable
// back.
settingsDb.all = async () => [...store.settings.values()]
settingsDb.get = async (actionId) => store.settings.get(actionId) || null
settingsDb.byIds = async (ids) =>
new Map(
[...new Set(ids || [])]
.filter((id) => store.settings.has(id))
.map((id) => [id, store.settings.get(id)]),
)
settingsDb.put = async (actionId, { enabled, caps }, userId = null) => {
const row = {
action_id: actionId,
enabled: enabled ? 1 : 0,
caps: caps || {},
updated_by: userId,
updated_at: new Date(),
}
store.settings.set(actionId, row)
return row
}
budgetDb.seed = async (runId, dimensions) => {
for (const [dimension, d] of Object.entries(dimensions || {})) {
const key = `${runId}:${dimension}`
if (!store.budget.has(key)) {
store.budget.set(key, { run_id: runId, dimension, consumed: 0, cap: d.cap, effective_from: d.from || null })
}
}
return Object.keys(dimensions || {}).length
}
budgetDb.forRun = async (runId) =>
[...store.budget.values()]
.filter((b) => Number(b.run_id) === Number(runId))
.sort((a, b) => a.dimension.localeCompare(b.dimension))
}
// ── Fixtures ───────────────────────────────────────────────────────────────
@@ -675,3 +738,295 @@ test('re-publishing with nothing scheduled ahead re-pins nothing', async () => {
const again = await call(ctrl.publish, { params: { id: String(id) } })
assert.equal(again.body.repinned, 0)
})
// ── Phase 6: the switchboard, the dry run, and the role floor on a step ────
//
// `eventAuthorize.test.js` holds `mayInvoke`'s layers and `eventVerify.test.js`
// holds the dry run's report. What is genuinely new HERE is what the surface
// decides on top of them: what the board serves when nobody has ever touched it,
// what a cap is allowed to name, which spec a dry run is run against, and the one
// gate that cannot live in route middleware because it depends on the BODY.
const ADMIN = { id: 1, role: 'admin' }
const EDITOR = { id: 2, role: 'editor' }
/**
* A module whose action declares a COST, which `demo.world.change` does not.
*
* Separate rather than folded into `registerDemoModule`, because the two answer
* different questions: that one is "a world-changing verb exists", this one is "a
* verb that spends something exists", and the switchboard's cap editor only has
* anything to render for the second.
*/
function registerCosting() {
// The owner must match the id's namespace: the registry refuses an action id
// that is not prefixed with the module registering it, which is what keeps an
// action's id space its own (§F).
const api = registries.stage('test')
api.registerEventActions([
{
id: 'test.spawn',
label: 'Spawn creatures',
risk: 'change',
reversible: 'none',
params: [{ name: 'count', type: 'int', required: true, example: 4 }],
cost: (p) => ({ 'x.creatures': p.count }),
perform: async () => ({ ok: true }),
},
])
registries.apply(api.staged)
}
// ── The switchboard ────────────────────────────────────────────────────────
test('the board serves every registered action with its risk-class default, and says nothing is configured', async () => {
// A fresh deployment has no rows at all — nothing is seeded at boot, because
// registration runs against a dead pool (MODULE_API §2.2) — so the board's
// first render is entirely computed. `configured: false` is how the screen
// tells "an admin turned this on" from "this has always been on".
const res = await call(ctrl.actions, { user: ADMIN })
assert.equal(res.statusCode, 200)
const byId = Object.fromEntries(res.body.actions.map((a) => [a.id, a]))
assert.deepEqual(Object.keys(byId).sort(), ['core.announce', 'core.cue', 'core.wait'])
// core.wait is `inspect`, and it arrives ENABLED. Read §K's sentence literally
// and it would not, and every published event that waits would break on a fresh
// deployment (org lead, 2026-09-03).
assert.equal(byId['core.wait'].enabled, true)
assert.equal(byId['core.wait'].changesWorld, false)
assert.equal(byId['core.announce'].enabled, true)
for (const a of res.body.actions) assert.equal(a.configured, false)
assert.deepEqual(res.body.worldChangingRisks, ['change', 'irreversible'])
})
test('the board never serves a callable', async () => {
// `allEventActions()` strips `perform`, `revert` and `cost`. This board adds
// fields to that object, and adding them back by spreading the full
// registration would be how a module's function comes to leave the process.
const res = await call(ctrl.actions, { user: ADMIN })
for (const a of res.body.actions) {
assert.equal(a.perform, undefined)
assert.equal(a.revert, undefined)
assert.equal(a.cost, undefined)
}
})
test('a stored switch is served back, marked configured, with who set it', async () => {
await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: false } })
const res = await call(ctrl.actions, { user: ADMIN })
const announce = res.body.actions.find((a) => a.id === 'core.announce')
assert.equal(announce.enabled, false)
assert.equal(announce.configured, true)
assert.ok(announce.updatedAt)
})
test('the switch works in both directions, because an operator must be able to turn things OFF', async () => {
const off = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.cue', enabled: false } })
assert.equal(off.statusCode, 200)
assert.equal(off.body.action.enabled, false)
const on = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.cue', enabled: true } })
assert.equal(on.body.action.enabled, true)
})
test('a switch for an action nobody registers is a 404, not a stored row', async () => {
// The board is rendered from the registry, so a write against something not in
// it is a client out of date — and storing it would put a row on the screen
// that no action can ever claim.
const res = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'gone.away', enabled: true } })
assert.equal(res.statusCode, 404)
})
test('enabled must be stated, because there is no safe value to guess', async () => {
const res = await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce' } })
assert.equal(res.statusCode, 400)
assert.match(res.body.error, /enabled must be true or false/)
})
test('a cap must name a dimension the action actually spends', async () => {
// Not pedantry. A cap on a dimension an action never names is a number an
// operator believes is protecting them, rendered back to them for ever,
// bounding nothing. None of core's three actions declares a cost at all, so
// every cap is refused here — which is itself the honest state of a deployment
// with no module installed.
const res = await call(ctrl.saveAction, {
user: ADMIN,
body: { actionId: 'core.announce', enabled: true, caps: { 'uo.creatures': 30 } },
})
assert.equal(res.statusCode, 400)
assert.match(res.body.error, /does not spend "uo.creatures"/)
})
test('a cap that is not a whole number of 0 or more is refused', async () => {
registerCosting()
for (const bad of [-1, 2.5, 'lots']) {
const res = await call(ctrl.saveAction, {
user: ADMIN,
body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': bad } },
})
assert.equal(res.statusCode, 400, String(bad))
}
})
test('a cap of zero is legal, and it means zero', async () => {
// "This deployment permits this verb, and permits none of it" is a coherent
// thing to say, and refusing 0 would make an operator disable the action
// instead — which is a different fact with a different audit trail.
registerCosting()
const res = await call(ctrl.saveAction, {
user: ADMIN,
body: { actionId: 'test.spawn', enabled: true, caps: { 'x.creatures': 0 } },
})
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body.action.caps, { 'x.creatures': 0 })
})
test('the board offers a cap box per dimension, discovered from the declared examples', async () => {
// The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase
// 7 — until then a param's required `example` is what tells core the names.
registerCosting()
const res = await call(ctrl.actions, { user: ADMIN })
const spawn = res.body.actions.find((a) => a.id === 'test.spawn')
assert.deepEqual(spawn.dimensions, ['x.creatures'])
assert.equal(spawn.enabled, false, 'a change action arrives disabled')
assert.equal(spawn.changesWorld, true)
})
// ── The dry run ────────────────────────────────────────────────────────────
test('a draft is verified against its working spec, and the pass is not recorded', async () => {
// There is no version to record it on, and a pass on a draft would be a claim
// about a spec that changes under the author's hands.
const { body } = await createDraft()
const res = await call(ctrl.verify, { user: EDITOR, params: { id: String(body.event.id) } })
assert.equal(res.statusCode, 200)
assert.equal(res.body.target, 'draft')
assert.equal(res.body.versionId, null)
assert.equal(res.body.recorded, false)
assert.equal(res.body.report.ok, true)
assert.equal(res.body.report.steps, 1)
})
test('a ready definition is verified against the version that would actually run, and the pass IS recorded', async () => {
// §K's last bound. A version is immutable, so a dry run that passed against one
// stays true — which is what makes the pass a property of the version.
const { body } = await createDraft()
const id = body.event.id
await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(id) } })
assert.equal(res.body.target, 'version')
assert.equal(res.body.recorded, true)
assert.equal(res.body.version, 1)
// And the definition now says so, on the screen its author is already looking
// at rather than on the Friday it did not run.
const after = await call(ctrl.get, { user: ADMIN, params: { id: String(id) } })
assert.ok(after.body.event.currentVersionVerifiedAt)
})
test('a definition that has never been verified says so', async () => {
const { body } = await createDraft()
await call(ctrl.publish, { user: ADMIN, params: { id: String(body.event.id) } })
const res = await call(ctrl.get, { user: ADMIN, params: { id: String(body.event.id) } })
assert.equal(res.body.event.currentVersionVerifiedAt, null)
})
test('a report with findings is a 200, and it does not record a pass', async () => {
// The request succeeded; the plan has problems. A 4xx would make "this event
// asks for 45 and you allow 30" indistinguishable from "you sent a bad id",
// and rendering the findings is the whole value of the screen.
const { body } = await createDraft({
spec: {
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [announceStep()] }],
},
})
const id = body.event.id
await call(ctrl.publish, { user: ADMIN, params: { id: String(id) } })
// Switch the action off underneath the published version: the plan is now one
// this deployment will not carry out.
await call(ctrl.saveAction, { user: ADMIN, body: { actionId: 'core.announce', enabled: false } })
const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(id) } })
assert.equal(res.statusCode, 200)
assert.equal(res.body.report.ok, false)
assert.equal(res.body.recorded, false, 'a failing dry run must not unlock a scheduled start')
assert.equal(res.body.report.findings[0].code, 'disabled')
})
test('an archived definition cannot be verified', async () => {
const { body } = await createDraft()
await call(ctrl.archive, { user: ADMIN, params: { id: String(body.event.id) } })
const res = await call(ctrl.verify, { user: ADMIN, params: { id: String(body.event.id) } })
assert.equal(res.statusCode, 409)
})
test('verifying a definition that does not exist is a 404', async () => {
const res = await call(ctrl.verify, { user: ADMIN, params: { id: '9999' } })
assert.equal(res.statusCode, 404)
})
// ── The role floor, which cannot live in route middleware ──────────────────
test('an editor cannot save a step whose action changes the world', async () => {
// §K's "any step whose action is above notify — admin only", with the line
// drawn between `inspect` and `change` (org lead, 2026-09-03). It is checked in
// the model rather than on the route because it depends on the BODY: the route
// is `admin, editor` and stays that way, and which of the two you have to be
// depends on what you put in the spec.
registerCosting()
const res = await call(ctrl.create, {
user: EDITOR,
body: draftBody({
spec: {
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }],
},
}),
})
assert.equal(res.statusCode, 403)
assert.match(res.body.errors[0], /only an administrator may author a step/)
})
test('an editor may still save a step that only announces or waits', async () => {
// The other half, and the one the literal reading of §K would have broken: an
// editor who cannot author a step that waits has an authoring role that cannot
// author.
const res = await call(ctrl.create, { user: EDITOR, body: draftBody() })
assert.equal(res.statusCode, 201)
})
test('an admin may save the same world-changing step', async () => {
registerCosting()
const res = await call(ctrl.create, {
user: ADMIN,
body: draftBody({
spec: {
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }],
},
}),
})
assert.equal(res.statusCode, 201)
})
test('the floor is checked on EDIT as well as on create', async () => {
// Otherwise an editor writes a legal draft and then edits a world-changing step
// into it, which is the same escalation with one more click.
registerCosting()
const { body } = await call(ctrl.create, { user: EDITOR, body: draftBody() })
const res = await call(ctrl.update, {
user: EDITOR,
params: { id: String(body.event.id) },
body: draftBody({
spec: {
schedule: { kind: 'manual' },
phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'test.spawn', params: { count: 1 } }] }],
},
}),
})
assert.equal(res.statusCode, 403)
})