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

@@ -69,6 +69,7 @@ const gates = require('../events/gates')
const spec = require('../events/spec')
const registries = require('../modules/registries')
const { dispatchStep } = require('../events/dispatch')
const authorize = require('../events/authorize')
const log = require('./logger')('event-runner')
const POLL_MS = Number(process.env.EVENT_POLL_MS) || 15_000
@@ -141,14 +142,30 @@ function leaseFor(step, now) {
// `skipped` is reserved for a step a human skipped from the run console (Phase
// 3) — a status that meant both "nobody ran this" and "this failed and we moved
// on" would make the run console's summary line unreadable.
async function applyFailure(run, step, error) {
await stepsDb.finish(step.id, 'failed', error)
async function applyFailure(run, step, error, { status = 'failed', kind = 'step.status', extra = null } = {}) {
// **`refused` shares this whole function with `failed`, and that is decision 3
// of Phase 6** (org lead, 2026-09-03): a cap breach or a disabled action takes
// the same disposition a terminal failure takes, so a `change` step's default
// `pause` stops the run where it stands and an operator raises the cap, edits,
// and resumes. What differs is the two words on the record — the STATUS the
// step ends in and the KIND the log line carries — because "nothing here is
// broken, this deployment does not permit that" is a different sentence from
// "the shard did not answer", and an operator reading a stopped run at 2am
// needs to tell them apart at a glance.
await stepsDb.finish(step.id, status, error)
await logDb.write({
runId: run.id,
stepId: step.id,
kind: 'step.status',
kind,
phase: step.phase,
detail: { to: 'failed', action: step.action_id, attempts: step.attempts + 1, onFailure: step.on_failure, error },
detail: {
to: status,
action: step.action_id,
attempts: step.attempts + 1,
onFailure: step.on_failure,
error,
...(extra || {}),
},
})
// A run that lost a step is degraded whatever happens next. Health is not
@@ -200,6 +217,50 @@ async function applyFailure(run, step, error) {
async function drainStep(run, step, now, carry = {}) {
if (!(await stepsDb.claim(step.id, OWNER, leaseFor(step, now), now))) return 'taken'
// ── The permission check, and it is the LAST thing before the dispatch ──
//
// §K's four layers behind one function (Phase 6). It sits after the claim, not
// before it: the cap is held by a conditional UPDATE and two ticks that both
// priced a step before either claimed it would both spend. It sits before the
// dispatch because a refusal means the action does not happen — nothing is
// sent, nothing is created, and the step never reaches the module at all.
//
// **`user` is null here, and that is the design rather than an omission.** The
// role was checked when a human published the version and again when a human
// or the scheduler started the run; a run in flight is deliberately not
// re-gated against its starter's current role, because demoting an admin at
// midnight should not silently strand every event they started. Cancel is the
// control for a run that should stop.
//
// **A retry does not pay twice.** The spend happens on the first attempt only.
// A retry re-dispatches the same idempotent operation against the same key, and
// charging a cap for a flaky socket would exhaust a deployment's allowance
// through unreliability rather than through effect. The corollary is that a
// step which spent and then failed for good keeps its spend: the attempt may
// have half-run, and a refund would be core asserting that it did not.
const action = registries.eventAction(step.action_id)
if (action) {
const verdict = await authorize.mayInvoke({
action,
params: step.params || {},
run,
spend: step.attempts === 0,
})
if (!verdict.ok) {
return applyFailure(run, step, verdict.reason, {
status: 'refused',
kind: 'step.refused',
extra: {
code: verdict.code,
dimension: verdict.dimension,
requested: verdict.requested,
cap: verdict.cap,
consumed: verdict.consumed,
},
})
}
}
const result = await dispatchStep(step, { run })
if (result.actionVersionDrift) {
@@ -572,6 +633,7 @@ async function processRun(run, now = new Date()) {
async function expandSchedules(now) {
const definitions = await definitionsDb.findSchedulable()
let created = 0
const heldUnverified = new Set()
for (const definition of definitions) {
const schedule = definition.version_spec?.schedule
@@ -610,7 +672,27 @@ async function expandSchedules(now) {
{ scope: '', scheduledFor: occurrence.at, source: 'schedule' },
null,
)
if (!result.ok || !result.created) continue
if (!result.ok) {
if (result.code === 'unverified') {
// §K's gate, and it must not be silent. There is no run row to hang
// a diagnostic line on — that is the point, nothing was created — so
// it is said once per definition per tick rather than once per
// occurrence, and the admin surface says it where the author is
// looking: a `ready` definition carries `versionVerified: false` and
// the editor shows the one button that clears it.
if (!heldUnverified.has(definition.id)) {
heldUnverified.add(definition.id)
log.warn('scheduled occurrences held: the published version has never been verified', {
definition: definition.id,
title: definition.title,
version: definition.current_version_id,
})
}
break
}
continue
}
if (!result.created) continue
created += 1
if (occurrence.adjusted) {
// Why the clock reads oddly, recorded where an operator will look for