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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user