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

@@ -460,6 +460,11 @@ const KIND_WORDS = {
'phase.gate': 'Advance condition set',
'condition.evaluated': 'Condition evaluated',
'phase.advanced': 'Phase advanced',
// Phase 6. "Refused" reads differently from "Step" on purpose: an operator
// scanning a stopped run needs to see that nothing is broken.
'step.refused': 'Refused',
'run.budget': 'Caps',
'version.verified': 'Dry run passed',
note: 'Note',
}
@@ -514,6 +519,18 @@ export function describeLogLine(line) {
return d.because === 'forced'
? `${line.phase} advanced by hand after ${d.waitedSeconds}s${d.reason ? `: ${d.reason}` : ''}`
: `${line.phase} advanced on its ${d.because === 'elapsed' ? 'deadline' : 'condition'} after ${d.waitedSeconds}s`
// Phase 6. `step.refused` is its own kind rather than a `step.status` for a
// reason an operator feels at 2am: a refusal is not a failure, and the line
// has to say which deployment rule stopped it -- the answer to "not enabled"
// is a switch, and the answer to "over the cap" is a number.
case 'step.refused':
return `${d.action} refused: ${d.error}`
case 'run.budget':
return (d.dimensions || [])
.map((x) => `${x.dimension} capped at ${x.cap === null ? 'nothing' : x.cap}${x.from ? ` (${x.from})` : ''}`)
.join(', ') || 'no caps apply to this run'
case 'version.verified':
return `Version ${d.version} passed its dry run — scheduled occurrences may start`
default:
return logKindWord(line?.kind)
}