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

@@ -0,0 +1,86 @@
// ── event_action_settings — SQL only ───────────────────────────────────────
//
// EVENTS.md §D/§K, and Phase 6 of EVENTS_PLAN.md. The deployment's switchboard:
// one row per action an admin has an opinion about, and **nothing else in the
// permission model beyond the role**.
//
// **A missing row is not "disabled".** It is "the default for this action's risk
// class", and that default is computed in `eventActionSettings.model.js` from the
// registry rather than stored here. The reason is structural: the registry is
// assembled by `registerCore()` and by module `register()`, both of which run
// under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API.md
// §2.2), so a boot-time seed of one row per registered action would be precisely
// the database write those two forbid. A deployment that never opens the
// switchboard has no rows at all and behaves correctly.
//
// **Rows outlive their actions on purpose.** Uninstalling a module leaves its
// settings standing, so re-installing restores the caps the operator chose
// instead of silently resetting them to the default. The switchboard lists what
// is registered *now*, so a stranded row is invisible until its action returns.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const hydrate = (row) => row && { ...row, caps: parseJson(row.caps, {}) || {} }
/** Every stored row, action id first. Stranded rows included — the caller filters. */
async function all() {
const rows = await query(
`SELECT s.action_id, s.enabled, s.caps, s.updated_by, s.updated_at, u.username AS updated_by_username
FROM event_action_settings s
LEFT JOIN users u ON u.id = s.updated_by
ORDER BY s.action_id`,
)
return rows.map(hydrate)
}
/** One row, or null when the deployment has never had an opinion about this action. */
async function get(actionId) {
const rows = await query(
`SELECT action_id, enabled, caps, updated_by, updated_at
FROM event_action_settings WHERE action_id = ?`,
[actionId],
)
return rows.length ? hydrate(rows[0]) : null
}
/**
* The rows for a set of action ids, as a Map keyed by id.
*
* The shape the authorisation path wants: `mayInvoke` is asked about one action
* at a time but the run start prices a whole version at once, and one query per
* step of a twelve-step definition is twelve round trips to answer a question
* about a table with one row per action in the process.
*/
async function byIds(actionIds) {
const ids = [...new Set(actionIds || [])].filter(Boolean)
if (!ids.length) return new Map()
const rows = await query(
`SELECT action_id, enabled, caps, updated_by, updated_at
FROM event_action_settings
WHERE action_id IN (${ids.map(() => '?').join(',')})`,
ids,
)
return new Map(rows.map((r) => [r.action_id, hydrate(r)]))
}
/**
* Write one action's switch and caps.
*
* An upsert rather than a read-then-write, for the ordinary reason: two admins on
* the switchboard at once should leave one of their two opinions standing, not an
* error and not a row that never appeared. There is no compare-and-set here
* because there is nothing to race — this is configuration, and the value that
* matters is the last one a human chose.
*/
async function put(actionId, { enabled, caps }, userId = null) {
await query(
`INSERT INTO event_action_settings (action_id, enabled, caps, updated_by)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), caps = VALUES(caps), updated_by = VALUES(updated_by)`,
[actionId, enabled ? 1 : 0, JSON.stringify(caps || {}), userId],
)
return get(actionId)
}
module.exports = { all, get, byIds, put }

View File

@@ -16,9 +16,16 @@ const hydrate = (row) =>
// `current_version` is joined rather than stored: the list screen shows "v3" and
// the column that would hold it is a denormalisation of a row this query already
// has to reach for the publish date anyway.
//
// `current_version_verified_at` rides along for the same reason and answers the
// same kind of question (Phase 6). A `ready` definition whose version has never
// been dry-run will not start on its schedule (§K), and the one place that fact
// is worth saying is the screen its author is already looking at -- the
// alternative is finding out on the Friday it did not run.
const SELECT_LIST = `
SELECT d.*, s.name AS series_name, s.slug AS series_slug,
v.version AS current_version
v.version AS current_version,
v.verified_at AS current_version_verified_at
FROM event_definitions d
LEFT JOIN event_series s ON s.id = d.series_id
LEFT JOIN event_versions v ON v.id = d.current_version_id

View File

@@ -26,6 +26,9 @@ const runsDb = require('./eventRuns.db')
const logDb = require('./eventRunLog.db')
const seriesDb = require('./eventSeries.db')
const spec = require('../../events/spec')
const authorize = require('../../events/authorize')
const verifier = require('../../events/verify')
const registries = require('../../modules/registries')
const { slugify, uniqueSlug } = require('../teams/teamSlug')
const { cleanBody } = require('../../utils/sanitizeHtml')
@@ -162,9 +165,11 @@ async function validate(input, { existing = null } = {}) {
}
/** Create a draft. */
async function create(input, userId) {
async function create(input, userId, { role = null } = {}) {
const result = await validate(input)
if (!result.ok) return result
const floor = checkRoleFloor(result.definition.spec, role)
if (floor) return floor
const id = await db.insert({ ...result.definition, created_by: userId })
return { ok: true, id, definition: await db.getById(id) }
}
@@ -177,7 +182,7 @@ async function create(input, userId) {
* retired, and a retired definition that can still be edited is a definition
* somebody will edit and then wonder why it never runs.
*/
async function save(id, input, userId) {
async function save(id, input, userId, { role = null } = {}) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
if (existing.state === 'archived') {
@@ -185,6 +190,8 @@ async function save(id, input, userId) {
}
const result = await validate(input, { existing })
if (!result.ok) return result
const floor = checkRoleFloor(result.definition.spec, role)
if (floor) return floor
await db.update(id, { ...result.definition, updated_by: userId })
return { ok: true, id, definition: await db.getById(id) }
}
@@ -283,12 +290,113 @@ async function archive(id, userId) {
return { ok: true, definition: await db.getById(id) }
}
/**
* The role floor on a spec's steps (§K, Phase 6).
*
* §K's table reads "any step whose action is above `notify` — `admin` only", and
* the line falls between `inspect` and `change` for the reason the default-off
* rule does (org lead, 2026-09-03): an `inspect` action reads state and writes
* nothing, and an editor who cannot author a step that WAITS has an authoring
* role that cannot author.
*
* Checked at SAVE rather than only at publish, which is the difference between
* telling an editor now and telling them after they have written twelve steps.
* Publish re-checks anyway — it re-checks everything, against the registries as
* they stand at that moment — because an action's risk class is a module's
* declaration and a module can be upgraded between the two.
*/
function worldChangingSteps(specValue) {
const out = []
for (const phase of specValue?.phases || []) {
for (const step of phase.steps || []) {
const action = registries.eventAction(step.actionId)
if (action && authorize.changesWorld(action)) out.push(action)
}
}
return out
}
function checkRoleFloor(specValue, role) {
if (!role || role === 'admin') return null
const blocked = worldChangingSteps(specValue)
if (!blocked.length) return null
const names = [...new Set(blocked.map((a) => `"${a.label}"`))]
// Agreement, because this sentence is read by the person it refuses. The list
// is almost always one long -- an editor adds one world-changing step and is
// stopped -- so `"Spawn creatures" change the world` is the case that shows,
// and it reads as a bug in the sentence rather than a rule about the step.
const one = names.length === 1
return {
ok: false,
status: 403,
errors: [
`${names.join(', ')} ${one ? 'changes' : 'change'} the world, so only an administrator may author a step that uses ${one ? 'it' : 'them'}`,
],
}
}
/**
* Dry-run a definition, and record the pass when there is a version to record it
* on (Phase 6).
*
* The target follows the definition's state: a `ready` definition is verified
* against the version that would actually run, a draft against the working spec
* the author is still holding. See `events/verify.js` for why that is one act at
* two moments rather than two rules.
*/
async function verify(id, user) {
const existing = await db.getById(id)
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
if (existing.state === 'archived') {
return { ok: false, status: 409, errors: ['an archived definition cannot be verified'] }
}
const version =
existing.state === 'ready' && existing.current_version_id
? await versionsDb.getById(existing.current_version_id)
: null
const target = version?.spec || existing.spec
if (!target?.phases?.length) {
return { ok: false, status: 409, errors: ['this definition has no phases to verify'] }
}
const report = await verifier.verifySpec(target, { user })
if (version && report.ok) {
await versionsDb.markVerified(version.id, user?.id || null)
// Written to every run already pinned to this version, because that is where
// an operator asks the question: a scheduled occurrence that was being held
// is now going to start, and the line saying why belongs on it.
for (const run of await runsDb.listScheduledFor(id)) {
if (Number(run.version_id) !== Number(version.id)) continue
await logDb.write({
runId: run.id,
kind: 'version.verified',
detail: { versionId: version.id, version: version.version, by: user?.id || null },
})
}
}
return {
ok: true,
report,
// Which spec was verified, said plainly, because the two answer different
// questions and a report that did not say would be read as the other one.
target: version ? 'version' : 'draft',
versionId: version?.id || null,
version: version?.version || null,
recorded: Boolean(version && report.ok),
}
}
module.exports = {
validate,
create,
save,
publish,
archive,
verify,
checkRoleFloor,
isTimezone,
MIN_GRACE_SECONDS,
MAX_GRACE_SECONDS,

View File

@@ -0,0 +1,134 @@
// ── 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 }

View File

@@ -40,6 +40,13 @@ const KINDS = [
'phase.gate', // a phase opened an advance gate, with what it waits for
'condition.evaluated', // a firing was tested against a gate, matched or not
'phase.advanced', // a gate opened: on a firing, on its deadline, or forced
// Phase 6's three. `step.refused` is the one worth naming separately from
// `step.status`: a refusal is not a failure, and an operator reading a run that
// stopped needs to see at a glance that nothing is broken -- the deployment
// simply does not permit what the author asked for.
'run.budget', // the caps this run was seeded with, and which switch set each
'step.refused', // a step was not permitted: disabled, or over a cap
'version.verified', // a dry run passed against a version, unlocking scheduled starts
]
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }

View File

@@ -25,6 +25,9 @@ const gatesDb = require('./eventPhaseGates.db')
const gates = require('../../events/gates')
const definitionsDb = require('./eventDefinitions.db')
const versionsDb = require('./eventVersions.db')
const settingsDb = require('./eventActionSettings.db')
const budgetDb = require('./eventRunBudget.db')
const authorize = require('../../events/authorize')
const MAX_SCOPE = 190
@@ -85,6 +88,22 @@ async function create(
return { ok: false, status: 409, errors: ['the published version has no phases'] }
}
// §K's last bound, enforced for SCHEDULED starts only (org lead, 2026-09-03):
// *"a scheduled definition that has never been verified is the case worth
// refusing to start"*. An admin pressing start is watching, and that human IS
// the review the gate exists to require — so the gate falls on the path where
// nobody is. A version is immutable, so a dry run that passed against it stays
// true, which is what makes the pass a property of the version rather than
// something re-earned every occurrence.
if (source === 'schedule' && !version.verified_at) {
return {
ok: false,
status: 409,
code: 'unverified',
errors: ['this version has not been verified, so it will not start unattended'],
}
}
const scopeValue = String(scope || '').slice(0, MAX_SCOPE)
const when = scheduledFor ? new Date(scheduledFor) : new Date()
if (Number.isNaN(when.getTime())) {
@@ -130,6 +149,32 @@ async function create(
},
})
// The run's budget, seeded from EVERY phase's steps rather than from the first
// one's (Phase 6). The version is pinned and immutable, so all of its steps are
// knowable now — and a budget that grew as phases were entered would let a
// phase-1 step spend a cap that a phase-3 step was going to need, which is the
// opposite of a per-run bound. The caps are copied here, so an admin moving a
// switch tomorrow does not change what a run already in flight is allowed.
const allSteps = version.spec.phases.flatMap((p) =>
(p.steps || []).map((s) => ({ actionId: s.actionId, params: s.params || {} })),
)
const settingsByAction = await settingsDb.byIds(allSteps.map((s) => s.actionId))
const budget = authorize.effectiveCaps(allSteps, settingsByAction)
if (Object.keys(budget).length) {
await budgetDb.seed(runId, budget)
await logDb.write({
runId,
kind: 'run.budget',
detail: {
dimensions: Object.entries(budget).map(([dimension, d]) => ({
dimension,
cap: d.cap,
from: d.from,
})),
},
})
}
// The first phase's steps, materialised at creation rather than at start.
// Phase 2 materialises each LATER phase as the run enters it; doing the first
// one here is what makes a Phase 1 run row inspectable — an operator can see
@@ -159,13 +204,29 @@ async function create(
async function detail(runId) {
const run = await db.getById(runId)
if (!run) return null
const [steps, counts, gateRows] = await Promise.all([
const [steps, counts, gateRows, budget] = await Promise.all([
stepsDb.listForRun(runId),
stepsDb.statusCounts(runId),
gatesDb.listForRun(runId),
budgetDb.forRun(runId),
])
const now = new Date()
return { run, steps, counts, gates: gateRows.map((g) => gates.describe(g, now)) }
return {
run,
steps,
counts,
gates: gateRows.map((g) => gates.describe(g, now)),
// The meter, as rows rather than as a sentence: a cap is two numbers and a
// name, and unlike a gate it needs no grammar rendered to be read. `cap:
// null` is uncapped and the client says so — a dimension the run counts but
// nothing bounds.
budget: budget.map((b) => ({
dimension: b.dimension,
consumed: b.consumed,
cap: b.cap,
from: b.effective_from,
})),
}
}
module.exports = { create, detail, renderConcurrencyKey }

View File

@@ -51,4 +51,24 @@ const insert = async (definitionId, version, spec, userId) => {
return result.insertId
}
module.exports = { listForDefinition, getById, nextVersion, insert }
/**
* Record that a dry run passed against this version (Phase 6).
*
* A version is immutable in every respect that describes the EVENT — its spec,
* its number, who published it. These two columns describe something that
* happened to it afterwards, which is why they can be written at all: a pass is
* a fact about a review, not a change to the plan reviewed.
*
* Deliberately not idempotent-checked: verifying twice stamps the second one, and
* the later reviewer is the more useful answer to "who last looked at this
* before it ran unattended".
*/
const markVerified = async (id, userId, at = new Date()) => {
const result = await query(
'UPDATE event_versions SET verified_at = ?, verified_by = ? WHERE id = ?',
[at, userId, id],
)
return (result.affectedRows || 0) > 0
}
module.exports = { listForDefinition, getById, nextVersion, insert, markVerified }