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
135 lines
5.6 KiB
JavaScript
135 lines
5.6 KiB
JavaScript
// ── event_run_budget — SQL only ────────────────────────────────────────────
|
|
//
|
|
// EVENTS.md §D/§E, and Phase 6 of EVENTS_PLAN.md. What one run has spent of one
|
|
// dimension, and the most it may.
|
|
//
|
|
// **The whole file exists for one statement.** `spend()` is the conditional
|
|
// increment §E names as the answer to "two steps spending one cap":
|
|
//
|
|
// UPDATE … SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap
|
|
//
|
|
// A read-then-write would let two steps drawing on `uo.creatures` in the same
|
|
// tick each see 28 of 30 and each spend 5. The cap in the WHERE means the second
|
|
// one changes no rows, and `affectedRows === 0` *is* the refusal — no transaction,
|
|
// no lock, and no second opinion about the arithmetic. Same shape as the outbox
|
|
// claim, `runsDb.transition` and Phase 5's gate increment, and the same argument.
|
|
//
|
|
// **The SET list here has one assignment for the reason Phase 5's had three.**
|
|
// MariaDB evaluates an UPDATE's SET assignments left to right, each seeing what
|
|
// the ones before it assigned — which is how Phase 5's gate closed a firing early
|
|
// — so nothing in this statement may read `consumed` after it has been written.
|
|
// The guard is in the WHERE, where it reads the pre-update row, and it must stay
|
|
// there.
|
|
|
|
const { query } = require('../../utils/db')
|
|
|
|
/**
|
|
* Seed a run's budget rows.
|
|
*
|
|
* **INSERT IGNORE against `uq_evbud_dim`**, so a tick that overruns into the next
|
|
* one cannot double-seed and cannot reset a cap a run has already spent against —
|
|
* the idempotence `materialisePhase` and `gates.open` both have, for the same
|
|
* reason.
|
|
*
|
|
* The caps are COPIED here rather than read live at dispatch. A run pins its
|
|
* version and is reproducible in every other respect; a cap read live would be
|
|
* the one input to a run's behaviour an admin could change underneath it while
|
|
* nobody was watching, and the console's meter would answer "what is allowed now"
|
|
* when the question afterwards is "what was this run allowed".
|
|
*/
|
|
async function seed(runId, dimensions) {
|
|
const rows = Object.entries(dimensions || {})
|
|
if (!rows.length) return 0
|
|
const values = rows.map(() => '(?, ?, 0, ?, ?)').join(', ')
|
|
const params = rows.flatMap(([dimension, d]) => [runId, dimension, d.cap, d.from || null])
|
|
const result = await query(
|
|
`INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from)
|
|
VALUES ${values}`,
|
|
params,
|
|
)
|
|
return result.affectedRows || 0
|
|
}
|
|
|
|
/**
|
|
* Spend `amount` of one dimension, or refuse.
|
|
*
|
|
* Answers `true` when the row moved and `false` when it did not — and `false` has
|
|
* exactly two causes, both of which mean the same thing to the caller: the spend
|
|
* would breach the cap, or there is no row for this dimension at all. The second
|
|
* is not a silent pass: a run whose version names an action that costs a
|
|
* dimension 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, and refusing that is the fail-closed direction.
|
|
*
|
|
* A zero or negative amount is not a spend and never touches the database. An
|
|
* action whose `cost()` answers `0` for its params is telling core it consumes
|
|
* nothing, and pricing that as a query would put one round trip per step behind a
|
|
* fact the caller already has.
|
|
*/
|
|
async function spend(runId, dimension, amount) {
|
|
if (!(amount > 0)) return true
|
|
const result = await query(
|
|
`UPDATE event_run_budget
|
|
SET consumed = consumed + ?
|
|
WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)`,
|
|
[amount, runId, dimension, amount],
|
|
)
|
|
return (result.affectedRows || 0) > 0
|
|
}
|
|
|
|
/**
|
|
* Give `amount` back.
|
|
*
|
|
* Called on exactly one path: a step that spent several dimensions and was then
|
|
* refused on a later one. The spends are separate statements — they must be, the
|
|
* atomicity that matters is per dimension — so a step costing 5 creatures and 2
|
|
* bosses can take the creatures and be refused the bosses, and a step that did
|
|
* not run must not have spent anything. `GREATEST(consumed - ?, 0)` because the
|
|
* floor is worth more than a refund that is exactly right: a negative `consumed`
|
|
* would make the cap arithmetic lie in the permissive direction forever after.
|
|
*/
|
|
async function refund(runId, dimension, amount) {
|
|
if (!(amount > 0)) return
|
|
await query(
|
|
`UPDATE event_run_budget
|
|
SET consumed = GREATEST(consumed - ?, 0)
|
|
WHERE run_id = ? AND dimension = ?`,
|
|
[amount, runId, dimension],
|
|
)
|
|
}
|
|
|
|
/** Every dimension of one run, for the console's meter. */
|
|
async function forRun(runId) {
|
|
return query(
|
|
`SELECT dimension, consumed, cap, effective_from
|
|
FROM event_run_budget WHERE run_id = ? ORDER BY dimension`,
|
|
[runId],
|
|
)
|
|
}
|
|
|
|
/** The dimensions of several runs at once, keyed by run id — the run LIST's read. */
|
|
async function forRuns(runIds) {
|
|
const ids = [...new Set(runIds || [])].filter(Boolean)
|
|
if (!ids.length) return new Map()
|
|
const rows = await query(
|
|
`SELECT run_id, dimension, consumed, cap, effective_from
|
|
FROM event_run_budget
|
|
WHERE run_id IN (${ids.map(() => '?').join(',')})
|
|
ORDER BY run_id, dimension`,
|
|
ids,
|
|
)
|
|
const out = new Map()
|
|
for (const r of rows) {
|
|
if (!out.has(r.run_id)) out.set(r.run_id, [])
|
|
out.get(r.run_id).push({
|
|
dimension: r.dimension,
|
|
consumed: r.consumed,
|
|
cap: r.cap,
|
|
effective_from: r.effective_from,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
module.exports = { seed, spend, refund, forRun, forRuns }
|