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
340 lines
14 KiB
JavaScript
340 lines
14 KiB
JavaScript
// ── 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,
|
|
}
|