// ── mayInvoke: the whole authorisation decision (EVENTS_PLAN.md Phase 6) ─── // // §K asks for four layers behind ONE function — role, enablement, cap, and the // shard's own switch — and says why: *"it is what makes an EM-style delegation // model a later option rather than a redesign"*. This file is that function's // contract, and it is worth stating what each test is actually protecting, // because three of them protect a decision rather than a mechanism. // // • **The default-off line falls between `inspect` and `change`** (org lead, // 2026-09-03). §K's sentence read literally would have shipped `core.wait` // disabled — it is `risk: 'inspect'` — so every published event that waits // would break on a fresh deployment. The same line is the role floor. // • **The tightest cap wins.** `event_action_settings.caps` is per action while // `event_run_budget` is one row per dimension, so two actions spending // `uo.creatures` must agree on one number. // • **An unpriceable action is refused, not free.** A `cost()` that throws is // an action whose own accounting is broken, and reading that as zero would // make the broken one the only one nothing bounds. // // The registry is the REAL one, staged and applied the way a module does it, for // `eventRunner.test.js`'s reason: an action that would not register is not one // this function has to survive. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, afterEach, after } = require('node:test') const assert = require('node:assert/strict') const registries = require('../src/modules/registries') const authorize = require('../src/events/authorize') const settingsDb = require('../src/model/events/eventActionSettings.db') const budgetDb = require('../src/model/events/eventRunBudget.db') const db = require('../src/utils/db') after(() => db.close()) const originals = { settings: { ...settingsDb }, budget: { ...budgetDb } } let store beforeEach(() => { registries._reset() store = { settings: new Map(), budget: new Map() } settingsDb.get = async (id) => store.settings.get(id) || null settingsDb.byIds = async (ids) => new Map([...new Set(ids || [])].filter((i) => store.settings.has(i)).map((i) => [i, store.settings.get(i)])) budgetDb.forRun = async (runId) => [...store.budget.values()].filter((b) => Number(b.run_id) === Number(runId)) budgetDb.spend = async (runId, dimension, amount) => { if (!(amount > 0)) return true const row = store.budget.get(`${runId}:${dimension}`) if (!row) return false if (row.cap !== null && row.consumed + amount > row.cap) return false row.consumed += amount return true } budgetDb.refund = async (runId, dimension, amount) => { const row = store.budget.get(`${runId}:${dimension}`) if (row && amount > 0) row.consumed = Math.max(row.consumed - amount, 0) } }) afterEach(() => { Object.assign(settingsDb, originals.settings) Object.assign(budgetDb, originals.budget) registries._reset() }) /** * Declare the budget dimensions a batch of actions prices. * * §F, fail closed (Phase 7): a `cost()` naming a dimension no module registered * is refused BEFORE any cap arithmetic runs. Without this, every cap test below * would pass for the WRONG reason — refused by the layer above the one under * test. Discovered the way the switchboard discovers them, by pricing the * action's own declared examples, so a test never has to keep a second list of * its dimensions in step with its `cost()`. * * Written out here rather than borrowed from `authorize.dimensionsOf` so this * file keeps stubbing exactly what it means to stub. The undeclared case is not * an omission; it has its own test. */ const declaredBudgets = (entries, owner) => { const dimensions = new Set() for (const e of entries) { if (typeof e.cost !== 'function') continue const params = {} for (const p of e.params || []) if (p.example !== undefined) params[p.name] = p.example let priced try { priced = e.cost(params) } catch { continue } for (const d of Object.keys(priced || {})) dimensions.add(d) } return [...dimensions] .filter((id) => id.startsWith(`${owner}.`)) .map((id) => ({ id, label: id, unit: 'count' })) } const register = (entries, { owner = 'test', budgets = true } = {}) => { const api = registries.stage(owner) api.registerEventActions(entries) if (budgets) api.registerEventBudgets(declaredBudgets(entries, owner)) registries.apply(api.staged) } const action = (id, over = {}) => ({ id, label: over.label || id, risk: over.risk || 'notify', reversible: over.reversible || 'none', params: over.params || [], ...(over.cost ? { cost: over.cost } : {}), async perform() { return { ok: true } }, }) const setSetting = (id, enabled, caps = {}) => store.settings.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps }) const setBudget = (runId, dimension, { consumed = 0, cap = null } = {}) => store.budget.set(`${runId}:${dimension}`, { run_id: runId, dimension, consumed, cap }) const RUN = { id: 1, scope: '' } const ADMIN = { id: 1, role: 'admin' } const EDITOR = { id: 2, role: 'editor' } const MODERATOR = { id: 3, role: 'moderator' } // ── Layer 2: the role floor ──────────────────────────────────────────────── test('the role floor falls between inspect and change, not between notify and inspect', async () => { // The decision, held as a test rather than as a comment. Read §K's sentence // literally and an editor cannot author a step that WAITS, which is an // authoring role that cannot author. register([ action('test.tell', { risk: 'notify' }), action('test.look', { risk: 'inspect' }), action('test.change', { risk: 'change' }), action('test.burn', { risk: 'irreversible' }), ]) for (const id of ['test.tell', 'test.look']) { const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction(id) }) assert.equal(v.ok, true, `${id} should be open to an editor`) } for (const id of ['test.change', 'test.burn']) { const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction(id) }) assert.equal(v.ok, false) assert.equal(v.code, 'role') assert.match(v.reason, /only an administrator/) } }) test('a moderator is no more able to invoke a world-changing action than an editor', async () => { // The moderator's entire power over this feature is the run console (§K). The // floor is `admin`, not "not a player". register([action('test.change', { risk: 'change' })]) setSetting('test.change', true) const v = await authorize.mayInvoke({ user: MODERATOR, action: registries.eventAction('test.change') }) assert.equal(v.code, 'role') }) test('an admin passes the role layer for every risk class', async () => { register([action('test.burn', { risk: 'irreversible' })]) setSetting('test.burn', true) const v = await authorize.mayInvoke({ user: ADMIN, action: registries.eventAction('test.burn') }) assert.equal(v.ok, true) }) test('a null user skips the role layer, because the runner is not a person', async () => { // The unattended path. The role was checked when a human published the version // and again when a human or the scheduler started the run; re-checking here // would mean demoting an admin at midnight silently strands every event they // started. register([action('test.burn', { risk: 'irreversible' })]) setSetting('test.burn', true) const v = await authorize.mayInvoke({ action: registries.eventAction('test.burn') }) assert.equal(v.ok, true) }) // ── Layer 3a: enablement ─────────────────────────────────────────────────── test('nothing that changes the world is enabled by default, and everything else is', async () => { register([ action('test.tell', { risk: 'notify' }), action('test.look', { risk: 'inspect' }), action('test.change', { risk: 'change' }), action('test.burn', { risk: 'irreversible' }), ]) const enabled = (id) => authorize.isEnabled(registries.eventAction(id), null) assert.equal(enabled('test.tell'), true) assert.equal(enabled('test.look'), true) assert.equal(enabled('test.change'), false) assert.equal(enabled('test.burn'), false) }) test('a stored row beats the risk-class default in both directions', async () => { // The switch is the operator's, and it has to be able to turn a `notify` action // OFF as well as a `change` action on. An "enable only" switchboard would leave // a deployment unable to stop an announcement it did not want. register([action('test.tell', { risk: 'notify' }), action('test.change', { risk: 'change' })]) setSetting('test.tell', false) setSetting('test.change', true) const off = await authorize.mayInvoke({ action: registries.eventAction('test.tell') }) assert.equal(off.ok, false) assert.equal(off.code, 'disabled') const on = await authorize.mayInvoke({ action: registries.eventAction('test.change') }) assert.equal(on.ok, true) }) test('the refusal names the action by its LABEL, not its id', async () => { // The reason is rendered to an operator on the run console. `"Spawn creatures" // is not enabled on this deployment` is a sentence; the id is a slug. register([action('test.change', { risk: 'change', label: 'Spawn creatures' })]) const v = await authorize.mayInvoke({ action: registries.eventAction('test.change') }) assert.match(v.reason, /"Spawn creatures" is not enabled/) }) // ── Pricing ──────────────────────────────────────────────────────────────── test('cost() is called with the step params and its answer is what core enforces', async () => { register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })]) setSetting('test.spawn', true) const priced = authorize.priceOf(registries.eventAction('test.spawn'), { count: 12 }) assert.deepEqual(priced, { 'test.creatures': 12 }) }) test('a cost() that throws makes the action unpriceable, never free', async () => { register([ action('test.spawn', { risk: 'change', cost: () => { throw new Error('nope') }, }), ]) setSetting('test.spawn', true) assert.equal(authorize.priceOf(registries.eventAction('test.spawn'), {}), null) const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN }) assert.equal(v.ok, false) assert.equal(v.code, 'unpriceable') }) test('a cost() answering a non-object, a negative or a NaN is unpriceable too', async () => { register([ action('test.a', { risk: 'change', cost: () => 5 }), action('test.b', { risk: 'change', cost: () => ({ d: -1 }) }), action('test.c', { risk: 'change', cost: () => ({ d: 'lots' }) }), action('test.d', { risk: 'change', cost: () => [1, 2] }), ]) for (const id of ['test.a', 'test.b', 'test.c', 'test.d']) { assert.equal(authorize.priceOf(registries.eventAction(id), {}), null, id) } }) test('a zero cost is dropped rather than becoming a dimension with no spend', async () => { // A dimension present at 0 would be seeded as a budget row nothing ever draws // on, and it would appear on the console's meter as "0 of 30" for a verb this // event never uses. register([action('test.spawn', { risk: 'change', cost: () => ({ a: 0, b: 3 }) })]) assert.deepEqual(authorize.priceOf(registries.eventAction('test.spawn'), {}), { b: 3 }) }) test('an action with no cost() costs nothing and never touches the budget', async () => { register([action('test.tell')]) const v = await authorize.mayInvoke({ action: registries.eventAction('test.tell'), run: RUN, spend: true }) assert.equal(v.ok, true) assert.deepEqual(v.cost, {}) // No budget row exists for this run at all, and that is not a refusal: an // action that spends nothing has nothing to be refused over. assert.equal(store.budget.size, 0) }) test('dimensions are discovered by pricing the declared examples', async () => { // The Phase 6 stand-in for §F's `registerEventBudgets`, which arrives in Phase // 7. Every param carries a required `example` precisely so a form has something // to show, and pricing them is what lets the switchboard offer a cap box. register([ action('test.spawn', { risk: 'change', params: [{ name: 'count', type: 'int', required: true, example: 4 }], cost: (p) => ({ 'test.creatures': p.count, 'test.bosses': 1 }), }), ]) assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), ['test.bosses', 'test.creatures']) }) test('an action whose cost() cannot survive its own examples reports no dimensions', async () => { // Honest rather than clever: the operator loses a cap box, and the RUN loses // nothing, because a run's budget is seeded from the params its steps were // actually authored with. register([ action('test.spawn', { risk: 'change', cost: (p) => ({ d: p.missing.count }), }), ]) assert.deepEqual(authorize.dimensionsOf(registries.eventAction('test.spawn')), []) }) // ── effectiveCaps: the tightest cap wins ─────────────────────────────────── test('two actions spending one dimension resolve to the tightest cap, and it says whose', async () => { register([ action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }), action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }), ]) setSetting('test.spawn', true, { 'test.creatures': 30 }) setSetting('test.horde', true, { 'test.creatures': 10 }) const caps = authorize.effectiveCaps( [{ actionId: 'test.spawn', params: {} }, { actionId: 'test.horde', params: {} }], await settingsDb.byIds(['test.spawn', 'test.horde']), ) assert.deepEqual(caps, { 'test.creatures': { cap: 10, from: 'test.horde' } }) }) test('an action that declines to cap a dimension does not raise a ceiling another one set', async () => { // `null` is uncapped and must never win a minimum. Otherwise adding a second // verb to an event would silently remove the bound on the first. register([ action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }), action('test.horde', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) }), ]) setSetting('test.spawn', true, { 'test.creatures': 30 }) setSetting('test.horde', true, {}) const caps = authorize.effectiveCaps( [{ actionId: 'test.horde', params: {} }, { actionId: 'test.spawn', params: {} }], await settingsDb.byIds(['test.spawn', 'test.horde']), ) assert.deepEqual(caps, { 'test.creatures': { cap: 30, from: 'test.spawn' } }) }) test('a dimension nobody caps is still a row, uncapped', async () => { // Seeded so the meter counts it. The distinction it preserves is that a MISSING // row means something else entirely: a step spending a dimension its own run's // version never priced. register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 1 }) })]) setSetting('test.spawn', true, {}) const caps = authorize.effectiveCaps([{ actionId: 'test.spawn', params: {} }], await settingsDb.byIds(['test.spawn'])) assert.deepEqual(caps, { 'test.creatures': { cap: null, from: null } }) }) test('a step naming an unregistered action contributes no dimension', async () => { assert.deepEqual(authorize.effectiveCaps([{ actionId: 'nobody.registers', params: {} }], new Map()), {}) }) // ── Layer 3b: the cap ────────────────────────────────────────────────────── test('with no run, the question is whether the cost could EVER fit', async () => { // The dry run's and the editor's question. A step asking for 40 under a cap of // 30 is an authoring error answerable before anything is scheduled, which is // the entire value of catching it here rather than at 2am. register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })]) setSetting('test.spawn', true, { 'test.creatures': 30 }) const bad = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 40 } }) assert.equal(bad.ok, false) assert.equal(bad.code, 'cap') assert.equal(bad.dimension, 'test.creatures') assert.equal(bad.requested, 40) assert.equal(bad.cap, 30) const fine = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 30 } }) assert.equal(fine.ok, true) }) test('with a run and spend, the check IS the spend', async () => { register([action('test.spawn', { risk: 'change', cost: (p) => ({ 'test.creatures': p.count }) })]) setSetting('test.spawn', true, { 'test.creatures': 30 }) setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 }) const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: { count: 12 }, run: RUN, spend: true, }) assert.equal(v.ok, true) assert.equal(v.spent, true) assert.equal(store.budget.get('1:test.creatures').consumed, 12) }) test('without spend the cap check is advisory and moves nothing', async () => { register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })]) setSetting('test.spawn', true, { 'test.creatures': 30 }) setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 }) const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN }) assert.equal(v.ok, true) assert.equal(v.spent, undefined) assert.equal(store.budget.get('1:test.creatures').consumed, 0) }) test('a refusal names the numbers an operator needs, not just "refused"', async () => { register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5 }) })]) setSetting('test.spawn', true, { 'test.creatures': 30 }) setBudget(RUN.id, 'test.creatures', { consumed: 28, cap: 30 }) const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true }) assert.equal(v.ok, false) assert.equal(v.code, 'cap') assert.equal(v.consumed, 28) assert.equal(v.cap, 30) assert.equal(v.requested, 5) assert.match(v.reason, /28 of 30 is already spent this run/) }) test('a partial spend across dimensions is given back when a later one is refused', async () => { // The property the whole multi-dimension path turns on. The spends must be // separate statements — the atomicity that matters is per dimension — so a step // costing creatures AND bosses can take the creatures and be refused the // bosses, and a step that did not run must not have spent anything. register([ action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 5, 'test.bosses': 2 }) }), ]) setSetting('test.spawn', true) setBudget(RUN.id, 'test.creatures', { consumed: 0, cap: 30 }) setBudget(RUN.id, 'test.bosses', { consumed: 1, cap: 2 }) const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true }) assert.equal(v.ok, false) assert.equal(v.dimension, 'test.bosses') assert.equal(store.budget.get('1:test.creatures').consumed, 0, 'the creatures must have been given back') assert.equal(store.budget.get('1:test.bosses').consumed, 1) }) test('spending a dimension the run has no row for is refused, and says so in its own words', async () => { // Fail-closed, and a distinct code: "this run has no budget for that" is a // different diagnosis from "the cap is spent", and an operator who raises the // cap in answer to the wrong one has not fixed anything. register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.ghosts': 1 }) })]) setSetting('test.spawn', true) const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true }) assert.equal(v.ok, false) assert.equal(v.code, 'unbudgeted') assert.match(v.reason, /no budget for/) }) test('a dimension no module declares is refused before any cap arithmetic', async () => { // §F, fail closed (org lead, 2026-09-03). The refusal has to be its OWN code: // told "the cap is spent", an operator goes and raises a cap, and nothing // changes, because the problem is a module whose declaration is incomplete. register([action('test.spawn', { risk: 'notify', cost: () => ({ 'test.wisps': 3 }) })], { budgets: false, }) setSetting('test.spawn', true) setBudget(1, 'test.wisps', { cap: 100 }) const verdict = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), params: {}, run: RUN, spend: true, }) assert.equal(verdict.ok, false) assert.equal(verdict.code, 'undeclared') assert.match(verdict.reason, /no module declares/) // And it did not spend: a step refused for this reason never ran. assert.equal(store.budget.get('1:test.wisps').consumed, 0) }) test('an uncapped budget row never refuses', async () => { register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 999 }) })]) setSetting('test.spawn', true) setBudget(RUN.id, 'test.creatures', { consumed: 5, cap: null }) const v = await authorize.mayInvoke({ action: registries.eventAction('test.spawn'), run: RUN, spend: true }) assert.equal(v.ok, true) assert.equal(store.budget.get('1:test.creatures').consumed, 1004) }) // ── The layers are ordered ───────────────────────────────────────────────── test('the layers answer in order: role before enablement before cap', async () => { // Not cosmetic. An editor told "that action is disabled" would go and ask an // admin to enable it, and still not be allowed to author the step — the honest // first answer is the role. register([action('test.spawn', { risk: 'change', cost: () => ({ 'test.creatures': 99 }) })]) // Disabled AND over cap AND world-changing, all at once. const v = await authorize.mayInvoke({ user: EDITOR, action: registries.eventAction('test.spawn'), run: RUN, }) assert.equal(v.code, 'role') setSetting('test.spawn', false, { 'test.creatures': 1 }) const asAdmin = await authorize.mayInvoke({ user: ADMIN, action: registries.eventAction('test.spawn'), run: RUN, }) assert.equal(asAdmin.code, 'disabled') }) test('an action nobody registers is refused rather than thrown at', async () => { const v = await authorize.mayInvoke({ action: null }) assert.equal(v.ok, false) assert.equal(v.code, 'unregistered') })