feat(events): enablement, per-run caps and mayInvoke (Phase 6)
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
This commit is contained in:
@@ -37,6 +37,14 @@ const logDb = require('../src/model/events/eventRunLog.db')
|
||||
const versionsDb = require('../src/model/events/eventVersions.db')
|
||||
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||
const gatesDb = require('../src/model/events/eventPhaseGates.db')
|
||||
// Phase 6 put a permission check in front of every dispatch, and it reads two
|
||||
// tables. **For the third time in this feature, a leg the stubbing file did not
|
||||
// know about is a ten-second ECONNREFUSED that says nothing about the route it
|
||||
// was testing** — Phase 4's expansion leg and Phase 5's gate read did the same.
|
||||
// The rule the three of them add up to: when the runner or a model gains a leg,
|
||||
// every file that stubs the layer under it needs the stub.
|
||||
const settingsDb = require('../src/model/events/eventActionSettings.db')
|
||||
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
||||
const gates = require('../src/events/gates')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
@@ -50,7 +58,7 @@ const later = (ms) => new Date(T0.getTime() + ms)
|
||||
let store
|
||||
const originals = {}
|
||||
|
||||
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb]]) {
|
||||
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb], ['gatesDb', gatesDb], ['settingsDb', settingsDb], ['budgetDb', budgetDb]]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
}
|
||||
|
||||
@@ -69,6 +77,8 @@ function installStubs() {
|
||||
versions: new Map(),
|
||||
definitions: new Map(),
|
||||
gates: new Map(),
|
||||
settings: new Map(),
|
||||
budget: new Map(),
|
||||
nextStepId: 1,
|
||||
nextGateId: 1,
|
||||
}
|
||||
@@ -374,6 +384,46 @@ function installStubs() {
|
||||
Object.assign(g, { satisfied_at: now, satisfied_by: by, forced_by: userId })
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Phase 6's two tables ──
|
||||
//
|
||||
// `store.settings` is empty by default, which is not a gap: an empty
|
||||
// switchboard is what a fresh deployment HAS, and `authorize.isEnabled` then
|
||||
// answers from the risk class. Every test in this file that does not set a
|
||||
// switch is therefore exercising the default posture, which is the posture
|
||||
// almost every deployment will run under.
|
||||
settingsDb.get = async (actionId) => store.settings.get(actionId) || null
|
||||
settingsDb.byIds = async (ids) =>
|
||||
new Map(
|
||||
[...new Set(ids || [])]
|
||||
.filter((id) => store.settings.has(id))
|
||||
.map((id) => [id, store.settings.get(id)]),
|
||||
)
|
||||
|
||||
budgetDb.seed = async (runId, dimensions) => {
|
||||
for (const [dimension, d] of Object.entries(dimensions || {})) {
|
||||
const key = `${runId}:${dimension}`
|
||||
if (store.budget.has(key)) continue
|
||||
store.budget.set(key, { run_id: runId, dimension, consumed: 0, cap: d.cap, effective_from: d.from || null })
|
||||
}
|
||||
return Object.keys(dimensions || {}).length
|
||||
}
|
||||
// The conditional increment, read the way the server reads it — the guard is
|
||||
// evaluated against the PRE-update value, and a NULL cap is uncapped.
|
||||
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)
|
||||
}
|
||||
budgetDb.forRun = async (runId) =>
|
||||
[...store.budget.values()].filter((b) => b.run_id === runId).sort((a, b) => a.dimension.localeCompare(b.dimension))
|
||||
}
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────────────────
|
||||
@@ -1035,3 +1085,203 @@ test('re-entering a phase does not open a second gate', async () => {
|
||||
assert.equal(store.gates.size, 1)
|
||||
assert.equal(gateOf(id, 'one').entered_at, entered, 'and the deadline it was given does not move')
|
||||
})
|
||||
|
||||
// ── Phase 6: enablement and caps, in front of the dispatch ─────────────────
|
||||
//
|
||||
// The runner gained one thing this phase: it asks `mayInvoke` before it asks a
|
||||
// module to do anything. What follows is the behaviour that produces, and the
|
||||
// three properties that are decisions rather than mechanisms.
|
||||
|
||||
const seedBudget = (runId, dimension, { consumed = 0, cap = null } = {}) =>
|
||||
store.budget.set(`${runId}:${dimension}`, { run_id: runId, dimension, consumed, cap, effective_from: null })
|
||||
|
||||
const budgetOf = (runId, dimension) => store.budget.get(`${runId}:${dimension}`)
|
||||
|
||||
const setSwitch = (id, enabled, caps = {}) =>
|
||||
store.settings.set(id, { action_id: id, enabled: enabled ? 1 : 0, caps })
|
||||
|
||||
test('a disabled action is REFUSED, not failed, and the two look different on the record', async () => {
|
||||
// Decision 3 (org lead, 2026-09-03): a refusal takes the same disposition a
|
||||
// failure takes, and says a different thing. `refused` and `step.refused` are
|
||||
// what let an operator reading a stopped run at 2am see at a glance that
|
||||
// nothing is broken — the deployment simply does not permit what was asked.
|
||||
register([scriptedAction('test.change', { risk: 'change', label: 'Change things' })])
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.change')] }])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'refused')
|
||||
assert.equal(stepsOf(id)[0].last_error, '"Change things" is not enabled on this deployment')
|
||||
assert.ok(kinds(id).includes('step.refused'))
|
||||
assert.ok(!kinds(id).includes('step.status'), 'a refusal is not a step.status line')
|
||||
assert.equal(scripted['test.change'], undefined, 'a refused action is never dispatched')
|
||||
})
|
||||
|
||||
test('a refusal follows the step’s on_failure, exactly as a failure does', async () => {
|
||||
// The whole of decision 3. `change` defaults to `pause`, so the run stops where
|
||||
// it stands and waits for a human to raise the cap or edit the plan.
|
||||
register([scriptedAction('test.change', { risk: 'change' }), scriptedAction('test.after')])
|
||||
const id = seedRun([
|
||||
{ key: 'main', steps: [step('test.change', {}, 'pause'), step('test.after')] },
|
||||
])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(run(id).status, 'paused')
|
||||
assert.equal(run(id).health, 'degraded')
|
||||
assert.equal(stepsOf(id)[1].status, 'pending', 'nothing after a pausing refusal runs')
|
||||
})
|
||||
|
||||
test('a refusal with on_failure skip lets the run carry on, degraded', async () => {
|
||||
register([scriptedAction('test.change', { risk: 'change' }), scriptedAction('test.after')])
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.change', {}, 'skip'), step('test.after')] }])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'refused')
|
||||
assert.equal(stepsOf(id)[1].status, 'done')
|
||||
assert.equal(run(id).status, 'completed')
|
||||
assert.equal(run(id).health, 'degraded')
|
||||
})
|
||||
|
||||
test('an enabled world-changing action runs, because the switch is the whole gate', async () => {
|
||||
// The other half of the default-off posture, and the one that proves the switch
|
||||
// is read rather than the risk class being a refusal on its own.
|
||||
register([scriptedAction('test.change', { risk: 'change' })])
|
||||
setSwitch('test.change', true)
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.change')] }])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'done')
|
||||
assert.equal(run(id).status, 'completed')
|
||||
})
|
||||
|
||||
test('a step over its cap is refused with the dimension and the numbers on the log line', async () => {
|
||||
// "You asked for 40 and this deployment allows 30" is an authoring error, and
|
||||
// it has to arrive as those words rather than as a stack trace.
|
||||
register([
|
||||
scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) }),
|
||||
])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', { count: 12 }, 'skip')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 28, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'refused')
|
||||
const line = store.log.find((l) => l.runId === id && l.kind === 'step.refused')
|
||||
assert.equal(line.detail.code, 'cap')
|
||||
assert.equal(line.detail.dimension, 'x.creatures')
|
||||
assert.equal(line.detail.requested, 12)
|
||||
assert.equal(line.detail.cap, 30)
|
||||
assert.equal(line.detail.consumed, 28)
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 28, 'a refused step spends nothing')
|
||||
})
|
||||
|
||||
test('two steps drawing on one cap spend it once each, and the second is refused when it will not fit', async () => {
|
||||
// The stub's half of the plan's acceptance criterion. The SERVER's half — two
|
||||
// spends arriving genuinely at once — is in `eventRunnerSql.test.js`, because
|
||||
// the guard lives in a WHERE and a stub reproduces the reading rather than the
|
||||
// server.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: (p) => ({ 'x.creatures': p.count }) })])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
const id = seedRun([
|
||||
{
|
||||
key: 'main',
|
||||
steps: [
|
||||
step('test.spawn', { count: 20 }, 'skip'),
|
||||
step('test.spawn', { count: 20 }, 'skip'),
|
||||
step('test.spawn', { count: 10 }, 'skip'),
|
||||
],
|
||||
},
|
||||
])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
const s = stepsOf(id)
|
||||
assert.equal(s[0].status, 'done')
|
||||
assert.equal(s[1].status, 'refused', 'the second 20 does not fit under 30')
|
||||
assert.equal(s[2].status, 'done', 'and a later step that DOES fit still runs')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 30)
|
||||
})
|
||||
|
||||
test('a retry does not pay the cap twice', async () => {
|
||||
// The spend happens on the first attempt only. A retry re-dispatches the same
|
||||
// idempotent operation against the same key, and charging a cap for a flaky
|
||||
// socket would exhaust a deployment's allowance through unreliability rather
|
||||
// than through effect.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
scripted['test.spawn'] = { calls: [], answers: [{ ok: false, retry: true, error: 'shard busy' }] }
|
||||
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
assert.equal(stepsOf(id)[0].status, 'pending')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the first attempt spends')
|
||||
|
||||
// Past the retry backoff: the second attempt succeeds and must not spend again.
|
||||
await runner.tick(later(61_000))
|
||||
assert.equal(stepsOf(id)[0].status, 'done')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 5, 'the retry must not pay twice')
|
||||
})
|
||||
|
||||
test('a step that spent and then failed for good keeps its spend', async () => {
|
||||
// The corollary, and it is deliberate: the attempt may have half-run, and a
|
||||
// refund would be core asserting that it did not.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 5 }) })])
|
||||
setSwitch('test.spawn', true, { 'x.creatures': 30 })
|
||||
scripted['test.spawn'] = { calls: [], answer: { ok: false, retry: false, error: 'no' } }
|
||||
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: 30 })
|
||||
|
||||
await runner.tick(T0)
|
||||
assert.equal(stepsOf(id)[0].status, 'failed')
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 5)
|
||||
})
|
||||
|
||||
test('a step spending a dimension its run has no budget row for is refused', async () => {
|
||||
// Fail-closed. A run whose version names a costing action always has that
|
||||
// dimension seeded — uncapped ones included, as a row with a NULL cap — so a
|
||||
// missing row means the step is spending something its own version never
|
||||
// declared.
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.ghosts': 1 }) })])
|
||||
setSwitch('test.spawn', true)
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn', {}, 'skip')] }])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'refused')
|
||||
const line = store.log.find((l) => l.runId === id && l.kind === 'step.refused')
|
||||
assert.equal(line.detail.code, 'unbudgeted')
|
||||
})
|
||||
|
||||
test('an uncapped dimension counts without ever refusing', async () => {
|
||||
register([scriptedAction('test.spawn', { risk: 'change', cost: () => ({ 'x.creatures': 99 }) })])
|
||||
setSwitch('test.spawn', true)
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.spawn'), step('test.spawn')] }])
|
||||
seedBudget(id, 'x.creatures', { consumed: 0, cap: null })
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.deepEqual(stepsOf(id).map((s) => s.status), ['done', 'done'])
|
||||
assert.equal(budgetOf(id, 'x.creatures').consumed, 198)
|
||||
})
|
||||
|
||||
test('the runner never re-checks the role of whoever started the run', async () => {
|
||||
// §K's "a demoted user loses access at once" is about reaching a ROUTE. A run
|
||||
// already in flight is deliberately not re-gated against its starter's current
|
||||
// role: demoting an admin at midnight must not silently strand every event they
|
||||
// started. Cancel is the control for a run that should stop.
|
||||
register([scriptedAction('test.burn', { risk: 'irreversible' })])
|
||||
setSwitch('test.burn', true)
|
||||
const id = seedRun([{ key: 'main', steps: [step('test.burn')] }])
|
||||
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'done')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user