// ── 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, }