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,339 @@
// ── The whole authorisation decision, behind one function ──────────────────
//
// EVENTS.md §K, and Phase 6 of EVENTS_PLAN.md. Four layers stand between an
// action being *declared* and an action being *carried out* — role, enablement,
// cap, and the shard's own switch — and §K asks for them in one place rather
// than spread across route middleware:
//
// > **Keep the check in one function.** Not for tidiness: it is what makes an
// > EM-style delegation model a *later* option rather than a redesign. If a
// > deployment ever wants named coordinators with their own budgets, that is
// > one function learning to consult a second table, and nothing else in this
// > document changes.
//
// So `mayInvoke()` below is the only thing in this codebase that answers "may
// this happen". The router still calls `requireRole` — that gate is about
// reaching the ROUTE — but whether a particular verb may be aimed at the world is
// decided here, once, on every path that can cause it: authoring a step,
// publishing, the dry run, starting a run, and the runner's own unattended
// dispatch.
//
// ## Four layers, and where each one is actually enforced
//
// 1. **Declaration** — a module says a verb exists. Not a permission, and not
// checked here: `dispatch.js` already answers a step whose action nobody
// registers, and it answers it `dormant` rather than `refused`, because an
// uninstalled module is a different fact from a forbidden one.
// 2. **Role** — `change` and `irreversible` are `admin` only. See below.
// 3. **Enablement and caps** — this file, against `event_action_settings` and
// `event_run_budget`.
// 4. **The shard's own switches** — `AdminWriteEnabled` and `AdminAccessFloor`
// live on the shard host, outside the website's reach entirely, and core
// deliberately does not duplicate them. A module honours them when it
// translates an action into a sidecar command (P9); a second copy of that
// decision in core would be a copy that could disagree with the shard about
// whether the shard is accepting writes. It is named as a layer because
// leaving it unnamed is how it comes to be re-implemented.
//
// ## The role line, and why it is drawn at `change`
//
// §K's table says "any step whose action is above `notify`, and the action
// switchboard — `admin` only". Read literally that is the same sentence that made
// `core.wait` — `risk: 'inspect'` — ship disabled by default, and the org lead
// settled that on 2026-09-03: the line falls between `inspect` and `change`, not
// between `notify` and `inspect`. An `inspect` action reads state and writes
// nothing, so neither the default nor the role floor gains a deployment anything
// by excluding it, and an editor who cannot author a step that WAITS has an
// authoring role that cannot author.
//
// ## Why `user` may be null, and what that means
//
// The runner dispatches with nobody logged in. It is not "the system escalating":
// the role was checked when a human published the version and again when a human
// or the scheduler started the run, and **a run already in flight is not re-gated
// against its starter's current role**. Re-checking would mean that demoting an
// admin at midnight silently strands every event they started — an event stopping
// halfway through because of an unrelated personnel change. §K's "a demoted user
// loses access at once" is about reaching a route, and it still holds exactly
// there. Cancel is the control for a run that should stop.
//
// ## Why the cap check can spend
//
// `mayInvoke` reads on every path but ONE, and on that one it must also write.
// The cap is held by a conditional `UPDATE` whose WHERE carries the guard (§E), so
// checking and then spending would be two statements with a race between them —
// the exact race the conditional increment exists to remove. `spend: true` is
// therefore a parameter rather than a separate function: one decision procedure,
// one set of layers, and the authoritative check is the one that also commits.
const settingsDb = require('../model/events/eventActionSettings.db')
const budgetDb = require('../model/events/eventRunBudget.db')
const registries = require('../modules/registries')
const log = require('../utils/logger')('events')
// The risk classes that change the world, and the two things that follow from
// being on this list: the action arrives DISABLED on a fresh deployment, and only
// an admin may author a step that names it. Both were one sentence in §K and both
// were settled together (org lead, 2026-09-03).
const WORLD_CHANGING = ['change', 'irreversible']
/** Does this action alter the world, in the sense the switchboard and the role floor mean? */
const changesWorld = (action) => WORLD_CHANGING.includes(action?.risk)
/**
* Whether an action is enabled, given the deployment's stored opinion — or, when
* it has none, its risk class.
*
* Exported because the switchboard renders the same answer, and a screen that
* computed the default itself would be a second copy of the posture.
*/
function isEnabled(action, settingsRow) {
if (settingsRow) return Boolean(settingsRow.enabled)
return !changesWorld(action)
}
/**
* What one invocation of `action` costs, as `{dimension: amount}`.
*
* **A module's `cost()` is called here and nowhere else.** It is declared as a
* function of params (§F) and it is called with the params a step actually
* carries, so the number core enforces is the number the module said. A `cost`
* that throws, or that answers something other than a flat object of
* non-negative finite numbers, is treated as an unpriceable action rather than a
* free one: `null` comes back, and every caller reads `null` as a refusal. That
* is the fail-closed direction, and it is the only honest one — an action whose
* own accounting is broken is not an action whose consumption is zero.
*/
function priceOf(action, params) {
if (typeof action?.cost !== 'function') return {}
let raw
try {
raw = action.cost(params || {})
} catch (err) {
log.warn('event action cost() threw', { action: action.id, message: err.message })
return null
}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
const out = {}
for (const [dimension, amount] of Object.entries(raw)) {
const n = Number(amount)
if (!Number.isFinite(n) || n < 0) return null
if (n > 0) out[dimension] = n
}
return out
}
/**
* The dimensions an action can spend, discovered by pricing its declared
* examples.
*
* **This is a Phase 6 stand-in with a Phase 7 replacement already named.** §F's
* `registerEventBudgets` is what will declare a dimension's id, label and unit,
* and it arrives with the module contract. Until then the switchboard still has
* to render a cap editor, and it cannot offer a box for a dimension it cannot
* name — so core prices each action's own `example` values, which is a use every
* param already has a required `example` for.
*
* It is honest about its limits: a `cost()` that returns different dimension KEYS
* for different params under-reports here. That costs an operator a cap box on
* the switchboard, and it costs a run nothing at all — a run's budget is seeded
* from the params its steps were actually authored with, never from examples.
*/
function dimensionsOf(action) {
const params = {}
for (const p of action?.params || []) {
if (p.example !== undefined && p.example !== null) params[p.name] = p.example
}
const priced = priceOf(action, params)
return priced ? Object.keys(priced).sort() : []
}
/**
* The effective per-run cap for each dimension a set of steps will spend.
*
* **The tightest cap wins** (org lead, 2026-09-03). `event_action_settings.caps`
* is per action while `event_run_budget` is one row per dimension, so two actions
* both spending `uo.creatures` have to agree on one number, and the number a
* safety limit should settle on is the smaller. It is what keeps a dimension a
* bound on the RUN's total effect rather than a per-verb allowance that two verbs
* can each draw in full.
*
* A dimension no action caps comes back `{ cap: null }` — uncapped, and still
* seeded, so the meter counts it and a missing row keeps its one meaning.
*
* `steps` are `{ actionId, params }`; the answer is `{dimension: {cap, from}}`.
*/
function effectiveCaps(steps, settingsByAction) {
const out = {}
for (const step of steps || []) {
const action = registries.eventAction(step.actionId)
if (!action) continue
const priced = priceOf(action, step.params)
if (!priced) continue
const declared = (settingsByAction.get(action.id) || {}).caps || {}
for (const dimension of Object.keys(priced)) {
const raw = declared[dimension]
const cap = Number.isFinite(Number(raw)) && Number(raw) >= 0 ? Number(raw) : null
if (!(dimension in out)) {
out[dimension] = { cap, from: cap === null ? null : action.id }
continue
}
const held = out[dimension]
// `null` is uncapped, so it never wins a minimum — an action that declines
// to cap a dimension must not raise the ceiling another action set.
if (cap !== null && (held.cap === null || cap < held.cap)) {
out[dimension] = { cap, from: action.id }
}
}
}
return out
}
/**
* May this action be carried out, and — when asked — spend its cost.
*
* Answers an envelope, never throws, and never answers a bare boolean: every
* refusal carries a `code` a caller can branch on and a `reason` a human reads.
* The reason is written here rather than at the four call sites for the same
* argument Phase 5 made about the diagnosis panel — one place the words are
* written, so the dry run, the editor, the run console and the log all say the
* same sentence about the same fact.
*
* `{ user }` null means the unattended runner; see the header. `{ run }` null
* means there is no budget to draw on yet — authoring and the dry run — and the
* cap layer then compares the cost against the effective cap instead of against
* what is left of it.
*/
async function mayInvoke({
user = null,
action,
params = {},
run = null,
settings = undefined,
spend = false,
} = {}) {
if (!action) return { ok: false, code: 'unregistered', reason: 'no module registers this action' }
// ── Layer 2: the role ──
if (user && changesWorld(action) && user.role !== 'admin') {
return {
ok: false,
code: 'role',
reason: `"${action.label}" changes the world, so only an administrator may use it`,
}
}
// ── Layer 3a: enablement ──
const row = settings === undefined ? await settingsDb.get(action.id) : settings
if (!isEnabled(action, row)) {
return {
ok: false,
code: 'disabled',
reason: `"${action.label}" is not enabled on this deployment`,
}
}
// ── Layer 3b: the cap ──
const cost = priceOf(action, params)
if (cost === null) {
return {
ok: false,
code: 'unpriceable',
reason: `"${action.label}" could not report what it costs`,
}
}
const dimensions = Object.keys(cost)
if (!dimensions.length) return { ok: true, cost }
if (!run) {
// No run, so nothing to draw on: the question is whether the cost could EVER
// fit, which is what the dry run and the editor are asking. A cost larger
// than the cap is an authoring error and it is answerable before anything is
// scheduled — which is the entire value of catching it here.
const caps = effectiveCaps([{ actionId: action.id, params }], new Map([[action.id, row || {}]]))
for (const dimension of dimensions) {
const { cap } = caps[dimension] || { cap: null }
if (cap !== null && cost[dimension] > cap) {
return {
ok: false,
code: 'cap',
reason: `asks for ${cost[dimension]} of "${dimension}" and this deployment allows ${cap} per run`,
dimension,
requested: cost[dimension],
cap,
}
}
}
return { ok: true, cost }
}
if (!spend) {
// A read of the meter rather than a draw on it. Deliberately advisory: this
// answer is stale the moment another step in the same tick spends, which is
// exactly why the authoritative check is the one that commits.
const rows = await budgetDb.forRun(run.id)
const byDimension = new Map(rows.map((r) => [r.dimension, r]))
for (const dimension of dimensions) {
const held = byDimension.get(dimension)
if (!held) return refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
if (held.cap !== null && held.consumed + cost[dimension] > held.cap) {
return refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
}
}
return { ok: true, cost }
}
// ── The committing path ──
//
// One statement per dimension, because the atomicity that matters is per
// dimension: a cap is a bound on one thing, and a transaction spanning three of
// them would serialise three unrelated counters to buy nothing. What it does
// create is a partial spend — creatures taken, bosses refused — and a step that
// did not run must not have spent anything, so the taken ones are given back.
const taken = []
for (const dimension of dimensions) {
if (await budgetDb.spend(run.id, dimension, cost[dimension])) {
taken.push(dimension)
continue
}
for (const back of taken) await budgetDb.refund(run.id, back, cost[back])
const rows = await budgetDb.forRun(run.id)
const held = rows.find((r) => r.dimension === dimension)
return held
? refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
: refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
}
return { ok: true, cost, spent: true }
}
/** The two cap refusals, written once so they cannot drift apart. */
function refusal(dimension, requested, cap, consumed, code) {
if (code === 'unbudgeted') {
return {
ok: false,
code: 'unbudgeted',
reason: `spends "${dimension}", which this run has no budget for`,
dimension,
requested,
}
}
return {
ok: false,
code: 'cap',
reason: `asks for ${requested} of "${dimension}"; ${consumed} of ${cap} is already spent this run`,
dimension,
requested,
cap,
consumed,
}
}
module.exports = {
mayInvoke,
isEnabled,
priceOf,
dimensionsOf,
effectiveCaps,
changesWorld,
WORLD_CHANGING,
}