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
87 lines
3.7 KiB
JavaScript
87 lines
3.7 KiB
JavaScript
// ── 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 }
|