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

@@ -0,0 +1,230 @@
// ── The dry run (EVENTS_PLAN.md Phase 6, EVENTS.md §I) ─────────────────────
//
// *"Materialise the steps, dispatch each with `verify: true`, report what would
// happen and what it would cost against the caps."*
//
// **The finding worth the most is the one no other path can make.** Every
// per-step check here is also made at save or at dispatch; the TOTAL is not.
// Three steps each spawning 15 under a cap of 30 pass every individual check and
// breach the cap on the third — at two in the morning, unattended, with the world
// half-changed. Adding the costs up across the whole version is the thing only a
// look at the plan as a whole can do, and it is why a dry run is worth more than
// the sum of its step checks.
//
// The second property this file holds is the one §I states outright: **`verify:
// true` must change nothing and must answer honestly.** So the actions here
// record what they were asked and assert that the flag arrived — a dry run that
// silently dispatched for real is the single worst bug this feature could ship,
// and it would look exactly like a passing test otherwise.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const { verifySpec } = require('../src/events/verify')
const settingsDb = require('../src/model/events/eventActionSettings.db')
const db = require('../src/utils/db')
after(() => db.close())
const originals = { ...settingsDb }
let store
let seen
beforeEach(() => {
registries._reset()
store = new Map()
seen = []
settingsDb.byIds = async (ids) =>
new Map([...new Set(ids || [])].filter((i) => store.has(i)).map((i) => [i, store.get(i)]))
settingsDb.get = async (id) => store.get(id) || null
})
afterEach(() => {
Object.assign(settingsDb, originals)
registries._reset()
})
const register = (entries, owner = 'test') => {
const api = registries.stage(owner)
api.registerEventActions(entries)
registries.apply(api.staged)
}
const setSetting = (id, enabled, caps = {}) =>
store.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps })
/** An action that records every envelope it is handed and then answers `answer`. */
const recorder = (id, over = {}) => ({
id,
label: over.label || id,
risk: over.risk || 'notify',
reversible: 'none',
version: over.version || 1,
params: over.params || [],
...(over.cost ? { cost: over.cost } : {}),
async perform(envelope) {
seen.push({ id, ...envelope })
if (over.throws) throw new Error(over.throws)
return over.answer || { ok: true }
},
})
const spec = (steps) => ({ phases: [{ key: 'one', steps }] })
const step = (actionId, params = {}, extra = {}) => ({ actionId, params, ...extra })
const ADMIN = { id: 1, role: 'admin' }
const EDITOR = { id: 2, role: 'editor' }
test('every step is dispatched with verify true, and nothing is asked to act', async () => {
// §I's promise, held as an assertion about the envelope each action received.
register([recorder('test.tell'), recorder('test.wait', { risk: 'inspect' })])
const report = await verifySpec(spec([step('test.tell', { body: 'x' }), step('test.wait')]), { user: ADMIN })
assert.equal(report.ok, true)
assert.equal(report.steps, 2)
assert.deepEqual(report.findings, [])
assert.equal(seen.length, 2)
for (const envelope of seen) assert.equal(envelope.verify, true)
})
test('the cost of the whole plan is added up across steps, and a total over the cap is a finding', async () => {
// The one check that only exists here. Each of the three steps fits under 30 on
// its own; together they do not.
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
setSetting('test.spawn', true, { 'x.creatures': 30 })
const report = await verifySpec(
spec([
step('test.spawn', { count: 15 }),
step('test.spawn', { count: 15 }),
step('test.spawn', { count: 15 }),
]),
{ user: ADMIN },
)
assert.equal(report.ok, false)
const total = report.findings.find((f) => f.code === 'cap-total')
assert.ok(total, 'the whole-plan total must be its own finding')
assert.match(total.message, /asks for 45 of "x.creatures" across all its steps, and this deployment allows 30/)
assert.deepEqual(report.cost, [
{ dimension: 'x.creatures', total: 45, cap: 30, from: 'test.spawn', over: true },
])
})
test('a plan that fits reports its cost without a finding', async () => {
register([recorder('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
setSetting('test.spawn', true, { 'x.creatures': 30 })
const report = await verifySpec(spec([step('test.spawn', { count: 12 }), step('test.spawn', { count: 8 })]), {
user: ADMIN,
})
assert.equal(report.ok, true)
assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 20, cap: 30, from: 'test.spawn', over: false }])
})
test('an uncapped dimension is reported with its total and no cap', async () => {
// Worth showing rather than hiding: "this event will spawn 40 creatures and
// nothing bounds that" is exactly what an operator opening the switchboard
// wants to have seen first.
register([recorder('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 40 }) })])
setSetting('test.spawn', true, {})
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, true)
assert.deepEqual(report.cost, [{ dimension: 'x.creatures', total: 40, cap: null, from: null, over: false }])
})
test('a step naming an action nobody registers is a dormant finding, and the rest are still checked', async () => {
// Reported rather than thrown, so an author sees EVERY problem in one pass. A
// verification that stopped at the first finding would make fixing a
// twelve-step definition twelve round trips.
register([recorder('test.tell')])
const report = await verifySpec(spec([step('gone.away'), step('test.tell')]), { user: ADMIN })
assert.equal(report.ok, false)
assert.equal(report.findings.length, 1)
assert.equal(report.findings[0].code, 'dormant')
assert.equal(report.findings[0].actionId, 'gone.away')
assert.equal(seen.length, 1, 'the registered step is still dispatched')
})
test('the callers own role is what the report answers against', async () => {
// The value of doing it here: an editor is told a step needs an administrator
// at the moment they can still do something about it, rather than at the moment
// it does not run.
register([recorder('test.change', { risk: 'change', label: 'Change things' })])
setSetting('test.change', true)
const asEditor = await verifySpec(spec([step('test.change')]), { user: EDITOR })
assert.equal(asEditor.ok, false)
assert.equal(asEditor.findings[0].code, 'role')
const asAdmin = await verifySpec(spec([step('test.change')]), { user: ADMIN })
assert.equal(asAdmin.ok, true)
})
test('a disabled action is a finding, and its step is never dispatched', async () => {
// The order matters: dispatching a disabled action under `verify: true` would
// change nothing, but it would call code the deployment has switched off, and
// "we only ran it to ask whether we could run it" is not a defence anyone wants
// to make.
register([recorder('test.change', { risk: 'change' })])
const report = await verifySpec(spec([step('test.change')]), { user: ADMIN })
assert.equal(report.findings[0].code, 'disabled')
assert.equal(seen.length, 0)
})
test('the modules own refusal is a finding in the modules words', async () => {
// The half core cannot compute: whether the landmark exists, whether the
// creature is on the allowlist, whether the shard is reachable at all.
register([recorder('test.spawn', { answer: { ok: false, retry: false, error: 'no such landmark "Bratain"' } })])
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, false)
assert.equal(report.findings[0].code, 'refused')
assert.match(report.findings[0].message, /no such landmark/)
})
test('an action that throws under verify is a finding, not a 500 on the authors screen', async () => {
register([recorder('test.spawn', { throws: 'exploded' })])
const report = await verifySpec(spec([step('test.spawn')]), { user: ADMIN })
assert.equal(report.ok, false)
assert.equal(report.findings[0].code, 'refused')
assert.match(report.findings[0].message, /exploded/)
})
test('a step authored against an older action version is a WARNING, not an error', async () => {
// §F: a bump makes the editor render a warning rather than refusing to run. The
// level is the whole point — a warning does not hold a scheduled start, and an
// error does.
register([recorder('test.tell', { version: 3 })])
const report = await verifySpec(spec([step('test.tell', {}, { actionVersion: 1 })]), { user: ADMIN })
assert.equal(report.ok, true, 'a drift warning must not fail the dry run')
assert.equal(report.findings[0].level, 'warning')
assert.equal(report.findings[0].code, 'version-drift')
assert.match(report.findings[0].message, /authored against version 1/)
})
test('a finding says which phase and which step it is about', async () => {
// A twelve-step definition needs the finding anchored, or the author is left
// reading the message and counting rows.
register([recorder('test.tell')])
const twoPhases = {
phases: [
{ key: 'open', steps: [step('test.tell')] },
{ key: 'close', steps: [step('test.tell'), step('gone.away')] },
],
}
const report = await verifySpec(twoPhases, { user: ADMIN })
assert.equal(report.steps, 3)
assert.deepEqual(
report.findings.map((f) => ({ phase: f.phase, seq: f.seq })),
[{ phase: 'close', seq: 1 }],
)
})
test('an empty spec verifies clean and costs nothing', async () => {
const report = await verifySpec({ phases: [] }, { user: ADMIN })
assert.deepEqual(report, { ok: true, steps: 0, findings: [], cost: [] })
})