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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user