MODULE_API 1.10.0. Four names forwarded on the module-facing `api` -- registerEventActions, registerEventBudgets, registerEventLeases and registerEventOptionSources -- one new route, and one rule made real: a `cost()` naming a dimension no module declared is refused. Only one of the four is new machinery. The action registry has staged core's three actions on every boot since Phase 1; what it never had was a way in, because loader.js builds its own `api` facade and had no method that delegated to it. So the registry a module now reaches is one that has been exercised on every boot for six phases. Four decisions, settled 2026-09-03, all as recommended: - Option sources are their own registration, modelled on registerAudiences, because a catalog has more than one consumer. - An undeclared dimension is refused -- at save, at the dry run and at dispatch -- with its own code, because the fix is a module's declaration and not a deployment's cap. - A lease is declared here and acquired by nothing; the ledger is Phase 8. - Core registers core.options.legs, so an announce leg is a dropdown rather than the free-text box whose typo Phase 6's walk caught mid-run. Proved with a throwaway module through the real loader, not with module-uo: eventModuleContract.test.js writes a module to a real directory and lets the loader scan it, covering all five envelope failure shapes, verify: true, the four id spaces and dormancy on uninstall. The live walk found the one defect nothing else could: the option-source loader wrote its "already asked?" guard inside a setState updater and read it on the next line, so the request was never made and the field sat on "Reading the list..." for ever. It is a useRef now. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
401 lines
17 KiB
JavaScript
401 lines
17 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.
|
|
*
|
|
* **Phase 7 replaced half of this and deliberately kept the other half.** §F's
|
|
* `registerEventBudgets` now declares a dimension's id, label and unit, so the
|
|
* switchboard no longer has to invent a name for a box — see `budgetsOf` below,
|
|
* which is what the screen reads. What a registry cannot answer is *which*
|
|
* dimensions THIS action spends, because `cost` is a function of params (§F) and
|
|
* the only honest way to ask it is to call it. So the discovery stays: 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 same dimensions, dressed with what the registry says they are called.
|
|
*
|
|
* The switchboard's read (Phase 7). `registered: false` is the case worth having
|
|
* a field for: an action that prices a dimension no module declares is a
|
|
* DECLARATION ERROR — `mayInvoke` refuses it, the dry run fails on it, and the
|
|
* save refuses it — so the screen has to be able to show the operator the reason
|
|
* their action will not run, rather than silently listing one fewer cap box than
|
|
* the action has dimensions. Hiding it would make a broken module look like a
|
|
* cheap one.
|
|
*/
|
|
function budgetsOf(action) {
|
|
return dimensionsOf(action).map((id) => {
|
|
const declared = registries.eventBudget(id)
|
|
return declared
|
|
? { id, label: declared.label, unit: declared.unit, registered: true }
|
|
: { id, label: id, unit: '', registered: false }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Which of these dimensions does nobody declare? (§F, org lead 2026-09-03.)
|
|
*
|
|
* Fail closed. §F's *"a module cannot spend a budget it did not declare"* is a
|
|
* rule only if something asks, and this is what asks — from `mayInvoke` at
|
|
* dispatch and at the dry run, and from the spec validator at save. Three places
|
|
* because they answer at three different moments and only the first of them is
|
|
* cheap: catching it at save costs an editor a red line, catching it at dispatch
|
|
* costs a run a refused step at two in the morning.
|
|
*
|
|
* Not folded into `priceOf`, which answers *what does this cost* and should keep
|
|
* answering only that: a cost of 12 creatures is a true statement about the
|
|
* action whether or not anyone declared the dimension, and the two facts have
|
|
* different fixes.
|
|
*/
|
|
function undeclaredDimensions(cost) {
|
|
return Object.keys(cost || {}).filter((d) => !registries.isEventBudget(d))
|
|
}
|
|
|
|
/**
|
|
* 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 }
|
|
|
|
// Before any cap arithmetic, because a dimension nobody declared has no cap to
|
|
// be under and no meter to draw on — asking "is 12 within the limit" about a
|
|
// resource core has never been told the name of would be answering a question
|
|
// that has not been asked yet. It is also the honest reading of the refusal:
|
|
// this is a module whose declaration is incomplete, not a deployment whose
|
|
// allowance is spent, and an operator who is told the second will go and raise
|
|
// a cap that changes nothing.
|
|
const undeclared = undeclaredDimensions(cost)
|
|
if (undeclared.length) {
|
|
return {
|
|
ok: false,
|
|
code: 'undeclared',
|
|
reason: `spends "${undeclared[0]}", which no module declares as a budget`,
|
|
dimension: undeclared[0],
|
|
requested: cost[undeclared[0]],
|
|
}
|
|
}
|
|
|
|
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,
|
|
budgetsOf,
|
|
undeclaredDimensions,
|
|
effectiveCaps,
|
|
changesWorld,
|
|
WORLD_CHANGING,
|
|
}
|