feat(events): enablement, per-run caps and mayInvoke (Phase 6)
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:
@@ -26,6 +26,9 @@ const runsDb = require('./eventRuns.db')
|
||||
const logDb = require('./eventRunLog.db')
|
||||
const seriesDb = require('./eventSeries.db')
|
||||
const spec = require('../../events/spec')
|
||||
const authorize = require('../../events/authorize')
|
||||
const verifier = require('../../events/verify')
|
||||
const registries = require('../../modules/registries')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
const { cleanBody } = require('../../utils/sanitizeHtml')
|
||||
|
||||
@@ -162,9 +165,11 @@ async function validate(input, { existing = null } = {}) {
|
||||
}
|
||||
|
||||
/** Create a draft. */
|
||||
async function create(input, userId) {
|
||||
async function create(input, userId, { role = null } = {}) {
|
||||
const result = await validate(input)
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
const id = await db.insert({ ...result.definition, created_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
@@ -177,7 +182,7 @@ async function create(input, userId) {
|
||||
* retired, and a retired definition that can still be edited is a definition
|
||||
* somebody will edit and then wonder why it never runs.
|
||||
*/
|
||||
async function save(id, input, userId) {
|
||||
async function save(id, input, userId, { role = null } = {}) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
@@ -185,6 +190,8 @@ async function save(id, input, userId) {
|
||||
}
|
||||
const result = await validate(input, { existing })
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
await db.update(id, { ...result.definition, updated_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
@@ -283,12 +290,113 @@ async function archive(id, userId) {
|
||||
return { ok: true, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The role floor on a spec's steps (§K, Phase 6).
|
||||
*
|
||||
* §K's table reads "any step whose action is above `notify` — `admin` only", and
|
||||
* the line falls between `inspect` and `change` for the reason the default-off
|
||||
* rule does (org lead, 2026-09-03): an `inspect` action reads state and writes
|
||||
* nothing, and an editor who cannot author a step that WAITS has an authoring
|
||||
* role that cannot author.
|
||||
*
|
||||
* Checked at SAVE rather than only at publish, which is the difference between
|
||||
* telling an editor now and telling them after they have written twelve steps.
|
||||
* Publish re-checks anyway — it re-checks everything, against the registries as
|
||||
* they stand at that moment — because an action's risk class is a module's
|
||||
* declaration and a module can be upgraded between the two.
|
||||
*/
|
||||
function worldChangingSteps(specValue) {
|
||||
const out = []
|
||||
for (const phase of specValue?.phases || []) {
|
||||
for (const step of phase.steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (action && authorize.changesWorld(action)) out.push(action)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function checkRoleFloor(specValue, role) {
|
||||
if (!role || role === 'admin') return null
|
||||
const blocked = worldChangingSteps(specValue)
|
||||
if (!blocked.length) return null
|
||||
const names = [...new Set(blocked.map((a) => `"${a.label}"`))]
|
||||
// Agreement, because this sentence is read by the person it refuses. The list
|
||||
// is almost always one long -- an editor adds one world-changing step and is
|
||||
// stopped -- so `"Spawn creatures" change the world` is the case that shows,
|
||||
// and it reads as a bug in the sentence rather than a rule about the step.
|
||||
const one = names.length === 1
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
errors: [
|
||||
`${names.join(', ')} ${one ? 'changes' : 'change'} the world, so only an administrator may author a step that uses ${one ? 'it' : 'them'}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run a definition, and record the pass when there is a version to record it
|
||||
* on (Phase 6).
|
||||
*
|
||||
* The target follows the definition's state: a `ready` definition is verified
|
||||
* against the version that would actually run, a draft against the working spec
|
||||
* the author is still holding. See `events/verify.js` for why that is one act at
|
||||
* two moments rather than two rules.
|
||||
*/
|
||||
async function verify(id, user) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
return { ok: false, status: 409, errors: ['an archived definition cannot be verified'] }
|
||||
}
|
||||
|
||||
const version =
|
||||
existing.state === 'ready' && existing.current_version_id
|
||||
? await versionsDb.getById(existing.current_version_id)
|
||||
: null
|
||||
const target = version?.spec || existing.spec
|
||||
if (!target?.phases?.length) {
|
||||
return { ok: false, status: 409, errors: ['this definition has no phases to verify'] }
|
||||
}
|
||||
|
||||
const report = await verifier.verifySpec(target, { user })
|
||||
|
||||
if (version && report.ok) {
|
||||
await versionsDb.markVerified(version.id, user?.id || null)
|
||||
// Written to every run already pinned to this version, because that is where
|
||||
// an operator asks the question: a scheduled occurrence that was being held
|
||||
// is now going to start, and the line saying why belongs on it.
|
||||
for (const run of await runsDb.listScheduledFor(id)) {
|
||||
if (Number(run.version_id) !== Number(version.id)) continue
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'version.verified',
|
||||
detail: { versionId: version.id, version: version.version, by: user?.id || null },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
report,
|
||||
// Which spec was verified, said plainly, because the two answer different
|
||||
// questions and a report that did not say would be read as the other one.
|
||||
target: version ? 'version' : 'draft',
|
||||
versionId: version?.id || null,
|
||||
version: version?.version || null,
|
||||
recorded: Boolean(version && report.ok),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
create,
|
||||
save,
|
||||
publish,
|
||||
archive,
|
||||
verify,
|
||||
checkRoleFloor,
|
||||
isTimezone,
|
||||
MIN_GRACE_SECONDS,
|
||||
MAX_GRACE_SECONDS,
|
||||
|
||||
Reference in New Issue
Block a user