Files
website/server/src/events/authorize.js
wtclaude fd9fb50351
Some checks failed
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Failing after 8m41s
feat(events): open the event contract to modules (Phase 7)
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
2026-09-03 14:15:04 -05:00

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