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

@@ -0,0 +1,86 @@
// ── event_action_settings — SQL only ───────────────────────────────────────
//
// EVENTS.md §D/§K, and Phase 6 of EVENTS_PLAN.md. The deployment's switchboard:
// one row per action an admin has an opinion about, and **nothing else in the
// permission model beyond the role**.
//
// **A missing row is not "disabled".** It is "the default for this action's risk
// class", and that default is computed in `eventActionSettings.model.js` from the
// registry rather than stored here. The reason is structural: the registry is
// assembled by `registerCore()` and by module `register()`, both of which run
// under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API.md
// §2.2), so a boot-time seed of one row per registered action would be precisely
// the database write those two forbid. A deployment that never opens the
// switchboard has no rows at all and behaves correctly.
//
// **Rows outlive their actions on purpose.** Uninstalling a module leaves its
// settings standing, so re-installing restores the caps the operator chose
// instead of silently resetting them to the default. The switchboard lists what
// is registered *now*, so a stranded row is invisible until its action returns.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const hydrate = (row) => row && { ...row, caps: parseJson(row.caps, {}) || {} }
/** Every stored row, action id first. Stranded rows included — the caller filters. */
async function all() {
const rows = await query(
`SELECT s.action_id, s.enabled, s.caps, s.updated_by, s.updated_at, u.username AS updated_by_username
FROM event_action_settings s
LEFT JOIN users u ON u.id = s.updated_by
ORDER BY s.action_id`,
)
return rows.map(hydrate)
}
/** One row, or null when the deployment has never had an opinion about this action. */
async function get(actionId) {
const rows = await query(
`SELECT action_id, enabled, caps, updated_by, updated_at
FROM event_action_settings WHERE action_id = ?`,
[actionId],
)
return rows.length ? hydrate(rows[0]) : null
}
/**
* The rows for a set of action ids, as a Map keyed by id.
*
* The shape the authorisation path wants: `mayInvoke` is asked about one action
* at a time but the run start prices a whole version at once, and one query per
* step of a twelve-step definition is twelve round trips to answer a question
* about a table with one row per action in the process.
*/
async function byIds(actionIds) {
const ids = [...new Set(actionIds || [])].filter(Boolean)
if (!ids.length) return new Map()
const rows = await query(
`SELECT action_id, enabled, caps, updated_by, updated_at
FROM event_action_settings
WHERE action_id IN (${ids.map(() => '?').join(',')})`,
ids,
)
return new Map(rows.map((r) => [r.action_id, hydrate(r)]))
}
/**
* Write one action's switch and caps.
*
* An upsert rather than a read-then-write, for the ordinary reason: two admins on
* the switchboard at once should leave one of their two opinions standing, not an
* error and not a row that never appeared. There is no compare-and-set here
* because there is nothing to race — this is configuration, and the value that
* matters is the last one a human chose.
*/
async function put(actionId, { enabled, caps }, userId = null) {
await query(
`INSERT INTO event_action_settings (action_id, enabled, caps, updated_by)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), caps = VALUES(caps), updated_by = VALUES(updated_by)`,
[actionId, enabled ? 1 : 0, JSON.stringify(caps || {}), userId],
)
return get(actionId)
}
module.exports = { all, get, byIds, put }