feat(events): enablement, per-run caps and mayInvoke (Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 13m33s

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:
2026-09-03 05:50:58 -05:00
parent 4ac917c3a3
commit 4077c4e79e
31 changed files with 3890 additions and 24 deletions

View File

@@ -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)
})