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:
@@ -196,6 +196,25 @@ CREATE TABLE event_run_phase_gates (
|
||||
UNIQUE KEY uq_evgate_phase (run_id, phase),
|
||||
INDEX idx_evgate_open (trigger_id, satisfied_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE event_run_budget (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
dimension VARCHAR(96) NOT NULL,
|
||||
consumed INT NOT NULL DEFAULT 0,
|
||||
cap INT NULL,
|
||||
effective_from VARCHAR(96) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_evbud_dim (run_id, dimension)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE event_action_settings (
|
||||
action_id VARCHAR(96) NOT NULL PRIMARY KEY,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
caps JSON NULL,
|
||||
updated_by INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`
|
||||
|
||||
// The statements under test, verbatim from `eventRuns.db.js` and
|
||||
@@ -1097,3 +1116,190 @@ test('a gate goes with its run', async (t) => {
|
||||
await pool.query('DELETE FROM event_runs WHERE id = ?', [runId])
|
||||
assert.equal((await pool.query('SELECT * FROM event_run_phase_gates WHERE id = ?', [gateId])).length, 0)
|
||||
})
|
||||
|
||||
// ── Phase 6: the cap's conditional increment ───────────────────────────────
|
||||
//
|
||||
// **The plan's own acceptance criterion**: *"two concurrent steps against one cap
|
||||
// proving neither over-spends"*. It belongs here rather than beside the stub for
|
||||
// the reason the gate's increment does — the guard lives in a WHERE that the
|
||||
// server evaluates against the pre-update row, and a stub reproduces the reading
|
||||
// rather than the server. Engagement's cooldown claim was green against its stub
|
||||
// and always allowed the send, because the connector defaults `foundRows: true`
|
||||
// and a no-op UPDATE reports 1: exactly the mistake this shape of statement
|
||||
// invites, and the reason `spend()` reads `affectedRows` at all.
|
||||
//
|
||||
// The SET list has ONE assignment, and that is Phase 5's lesson applied rather
|
||||
// than rediscovered: MariaDB evaluates SET assignments left to right with the
|
||||
// values already assigned, so nothing in this statement may read `consumed`
|
||||
// after writing it. The guard stays in the WHERE.
|
||||
|
||||
// **Requiring a shipping model into THIS file starts a second pool**, and it is
|
||||
// the only file in the suite where that matters. Everywhere else the environment
|
||||
// points at a dead port and `utils/db`'s pool never connects; here it points at a
|
||||
// live server, so the pool holds open connections and the process never exits —
|
||||
// 49 green tests and a file that hangs until the harness kills it. Every other
|
||||
// event test file already closes it in an `after`; this one now has a reason to.
|
||||
const budgetDb = require('../src/model/events/eventRunBudget.db')
|
||||
const appDb = require('../src/utils/db')
|
||||
after(() => appDb.close())
|
||||
|
||||
const seedBudget = async (over = {}) => {
|
||||
const { runId } = await seedRun({ status: 'running' })
|
||||
await pool.query(
|
||||
'INSERT INTO event_run_budget (run_id, dimension, consumed, cap, effective_from) VALUES (?, ?, ?, ?, ?)',
|
||||
// `'cap' in over`, not `over.cap ?? 30` -- `null` is a MEANINGFUL cap here
|
||||
// (uncapped) and `??` coalesces it straight back to 30, which made the
|
||||
// uncapped test seed a capped row and fail against the correct statement.
|
||||
[
|
||||
runId,
|
||||
over.dimension ?? 'uo.creatures',
|
||||
over.consumed ?? 0,
|
||||
'cap' in over ? over.cap : 30,
|
||||
over.from ?? 'uo.creature.spawn',
|
||||
],
|
||||
)
|
||||
return runId
|
||||
}
|
||||
|
||||
const consumedOf = async (runId, dimension = 'uo.creatures') =>
|
||||
Number(
|
||||
(
|
||||
await pool.query('SELECT consumed FROM event_run_budget WHERE run_id = ? AND dimension = ?', [
|
||||
runId,
|
||||
dimension,
|
||||
])
|
||||
)[0].consumed,
|
||||
)
|
||||
|
||||
// The statement, lifted verbatim from `eventRunBudget.db.js`. Run through this
|
||||
// file's pool rather than through the module, because the module builds its own
|
||||
// pool from the environment while this pool points at the throwaway database.
|
||||
const SPEND = `
|
||||
UPDATE event_run_budget
|
||||
SET consumed = consumed + ?
|
||||
WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)`
|
||||
|
||||
const spend = async (runId, amount, dimension = 'uo.creatures') =>
|
||||
Number((await pool.query(SPEND, [amount, runId, dimension, amount])).affectedRows) > 0
|
||||
|
||||
test('two steps spending one cap at once: the second is refused, not queued behind the first', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// 28 of 30 spent, and two steps each wanting 5 arrive together. A
|
||||
// read-then-write would let both see 28 and both spend, ending at 38 of 30 —
|
||||
// the exact over-spend the conditional increment exists to make impossible.
|
||||
const runId = await seedBudget({ consumed: 28, cap: 30 })
|
||||
const [a, b] = await Promise.all([spend(runId, 5), spend(runId, 5)])
|
||||
assert.equal(a, false)
|
||||
assert.equal(b, false)
|
||||
assert.equal(await consumedOf(runId), 28)
|
||||
})
|
||||
|
||||
test('two steps that BOTH fit both spend, and the total is exact', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The other half, and the one a too-strict guard would break: a cap is not a
|
||||
// lock. Three steps of 5 against 30 must all succeed and land on 15, or the
|
||||
// statement is refusing legal work.
|
||||
const runId = await seedBudget({ consumed: 0, cap: 30 })
|
||||
const results = await Promise.all([spend(runId, 5), spend(runId, 5), spend(runId, 5)])
|
||||
assert.deepEqual(results, [true, true, true])
|
||||
assert.equal(await consumedOf(runId), 15)
|
||||
})
|
||||
|
||||
test('a spend that exactly reaches the cap is allowed; one over it is not', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// `<=`, not `<`. A cap of 30 means thirty creatures are permitted, and an
|
||||
// off-by-one here is a deployment that can never use the last unit of anything
|
||||
// it configured.
|
||||
const runId = await seedBudget({ consumed: 25, cap: 30 })
|
||||
assert.equal(await spend(runId, 5), true)
|
||||
assert.equal(await consumedOf(runId), 30)
|
||||
assert.equal(await spend(runId, 1), false)
|
||||
assert.equal(await consumedOf(runId), 30)
|
||||
})
|
||||
|
||||
test('a NULL cap is uncapped, and still counts', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The row exists so the meter has something to show; nothing bounds it. The
|
||||
// distinction matters because a MISSING row is a refusal — a step spending a
|
||||
// dimension its own run's version never priced — and the two must not collapse
|
||||
// into one behaviour.
|
||||
const runId = await seedBudget({ consumed: 0, cap: null })
|
||||
assert.equal(await spend(runId, 1000000), true)
|
||||
assert.equal(await consumedOf(runId), 1000000)
|
||||
})
|
||||
|
||||
test('spending a dimension with no row is refused', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const runId = await seedBudget()
|
||||
assert.equal(await spend(runId, 1, 'uo.bosses'), false)
|
||||
})
|
||||
|
||||
test('seeding is INSERT IGNORE: a tick that overruns cannot reset a spent cap', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The idempotence `materialisePhase` and `gates.open` both have. Without it a
|
||||
// second seed would either error on the unique key or — worse, written as an
|
||||
// upsert — hand a run that has spent 28 of 30 a fresh 0.
|
||||
const { runId } = await seedRun({ status: 'running' })
|
||||
const SEED =
|
||||
'INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from) VALUES (?, ?, 0, ?, ?)'
|
||||
await pool.query(SEED, [runId, 'uo.creatures', 30, 'uo.creature.spawn'])
|
||||
await spend(runId, 28)
|
||||
await pool.query(SEED, [runId, 'uo.creatures', 30, 'uo.creature.spawn'])
|
||||
assert.equal(await consumedOf(runId), 28)
|
||||
assert.equal((await pool.query('SELECT * FROM event_run_budget WHERE run_id = ?', [runId])).length, 1)
|
||||
})
|
||||
|
||||
test('a refund floors at zero rather than going negative', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// `GREATEST(consumed - ?, 0)`. A negative `consumed` would make every later cap
|
||||
// check lie in the permissive direction, permanently — a worse outcome than a
|
||||
// refund that is slightly too small.
|
||||
const runId = await seedBudget({ consumed: 3, cap: 30 })
|
||||
await pool.query(
|
||||
'UPDATE event_run_budget SET consumed = GREATEST(consumed - ?, 0) WHERE run_id = ? AND dimension = ?',
|
||||
[10, runId, 'uo.creatures'],
|
||||
)
|
||||
assert.equal(await consumedOf(runId), 0)
|
||||
})
|
||||
|
||||
test('a budget goes with its run', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const runId = await seedBudget()
|
||||
await pool.query('DELETE FROM event_runs WHERE id = ?', [runId])
|
||||
assert.equal((await pool.query('SELECT * FROM event_run_budget WHERE run_id = ?', [runId])).length, 0)
|
||||
})
|
||||
|
||||
test('a version records that a dry run passed against it', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// §K's gate is two columns on an immutable table, and the ALTER that adds them
|
||||
// has to actually apply — `verified_at` is what `runsModel.create` reads to
|
||||
// decide whether a scheduled occurrence may start at all, so a column that
|
||||
// silently did not exist would hold every schedule on the deployment.
|
||||
await pool.query('ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL')
|
||||
await pool.query('ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL')
|
||||
const v = await pool.query('INSERT INTO event_versions (definition_id, spec) VALUES (1, ?)', ['{}'])
|
||||
const before = (await pool.query('SELECT verified_at FROM event_versions WHERE id = ?', [v.insertId]))[0]
|
||||
assert.equal(before.verified_at, null)
|
||||
const moved = await pool.query('UPDATE event_versions SET verified_at = ?, verified_by = ? WHERE id = ?', [
|
||||
T0,
|
||||
1,
|
||||
v.insertId,
|
||||
])
|
||||
assert.equal(Number(moved.affectedRows), 1)
|
||||
const after = (
|
||||
await pool.query('SELECT verified_at, verified_by FROM event_versions WHERE id = ?', [v.insertId])
|
||||
)[0]
|
||||
assert.ok(after.verified_at instanceof Date)
|
||||
assert.equal(Number(after.verified_by), 1)
|
||||
})
|
||||
|
||||
test('a non-positive spend never reaches the database', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// Through the shipping module rather than a copy of its statement. An action
|
||||
// whose `cost()` answers 0 for its params is telling core it consumes nothing,
|
||||
// and pricing that as a query would be one round trip per step behind a fact
|
||||
// the caller already has.
|
||||
const runId = await seedBudget({ consumed: 0, cap: 10 })
|
||||
assert.equal(await budgetDb.spend(runId, 'uo.creatures', 0), true)
|
||||
assert.equal(await consumedOf(runId), 0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user