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

@@ -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,