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:
153
server/src/events/verify.js
Normal file
153
server/src/events/verify.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// ── The dry run ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I ("four affordances worth building in from the start") and §K's
|
||||
// last bound, in Phase 6. Materialise nothing, dispatch every step with
|
||||
// `verify: true`, and report what would happen and what it would cost.
|
||||
//
|
||||
// > **Dry run before anything unattended.** A scheduled definition that has never
|
||||
// > been verified is the case worth refusing to start; verification is cheap and
|
||||
// > it is the last point a human sees the plan.
|
||||
//
|
||||
// **What it verifies depends on the definition's state, and that is not a
|
||||
// compromise.** A `ready` definition is verified against its PUBLISHED VERSION,
|
||||
// because a published version is the only thing that ever actually runs and §K's
|
||||
// gate is about letting one run unattended. A draft is verified against its
|
||||
// working spec, because §API's note is explicit that an author prices their work
|
||||
// *before* asking an admin to publish it. The two readings do not conflict — they
|
||||
// are the same act at two moments — and the answer says which one it did.
|
||||
//
|
||||
// **Only a pass against a version is recorded.** A version is immutable, so a dry
|
||||
// run that passed against one stays true; a draft changes under the author's
|
||||
// hands, so a pass on it would be a claim about a spec that no longer exists.
|
||||
//
|
||||
// ## The finding that only exists here
|
||||
//
|
||||
// Every per-step check — is the action registered, is it enabled, does this one
|
||||
// invocation fit the cap — is a check something else also makes, at save or at
|
||||
// dispatch. **The TOTAL is not.** Three steps each spawning 15 creatures under a
|
||||
// cap of 30 pass every individual check and breach the cap on the third, at two
|
||||
// in the morning, with the world half-changed. Adding the costs up across the
|
||||
// whole version is the one thing that can only be done by looking at the plan as
|
||||
// a whole, and it is the reason a dry run is worth more than the sum of its
|
||||
// step checks.
|
||||
|
||||
const { dispatchStep } = require('./dispatch')
|
||||
const authorize = require('./authorize')
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
/**
|
||||
* Dry-run a spec.
|
||||
*
|
||||
* `user` is the caller, so the role layer answers for *them* — an editor gets
|
||||
* told that a step needs an administrator, at the moment they can still do
|
||||
* something about it, rather than at the moment it does not run.
|
||||
*
|
||||
* Never throws: a `perform()` that explodes under `verify: true` is a finding
|
||||
* about that action, not a 500 on the author's screen. `dispatchStep` already
|
||||
* guarantees that, and this file adds no path around it.
|
||||
*/
|
||||
async function verifySpec(spec, { user = null, scope = '' } = {}) {
|
||||
const phases = spec?.phases || []
|
||||
const flat = []
|
||||
for (const phase of phases) {
|
||||
for (const [seq, step] of (phase.steps || []).entries()) {
|
||||
flat.push({ phase: phase.key, seq, step })
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await settingsDb.byIds(flat.map(({ step }) => step.actionId))
|
||||
const findings = []
|
||||
const totals = {}
|
||||
|
||||
for (const { phase, seq, step } of flat) {
|
||||
const where = { phase, seq, actionId: step.actionId, label: step.label || null }
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (!action) {
|
||||
// The same fact `publishable()` refuses on, said in the dry run's voice.
|
||||
// Reported rather than thrown so that an author sees EVERY problem in one
|
||||
// pass — a verification that stops at the first finding makes fixing a
|
||||
// twelve-step definition twelve round trips.
|
||||
findings.push({ ...where, level: 'error', code: 'dormant', message: `no module registers "${step.actionId}"` })
|
||||
continue
|
||||
}
|
||||
|
||||
const verdict = await authorize.mayInvoke({
|
||||
user,
|
||||
action,
|
||||
params: step.params || {},
|
||||
settings: settings.get(action.id) || null,
|
||||
})
|
||||
if (!verdict.ok) {
|
||||
findings.push({ ...where, level: 'error', code: verdict.code, message: verdict.reason })
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [dimension, amount] of Object.entries(verdict.cost || {})) {
|
||||
totals[dimension] = (totals[dimension] || 0) + amount
|
||||
}
|
||||
|
||||
// The module's own answer. This is the half core cannot compute: whether the
|
||||
// landmark exists, whether the creature is on the allowlist, whether the
|
||||
// shard is reachable at all. `verify: true` rides through the real
|
||||
// dispatcher rather than down a second path, because a dry run down a second
|
||||
// path is a dry run OF the second path.
|
||||
const result = await dispatchStep(
|
||||
{
|
||||
id: null,
|
||||
run_id: null,
|
||||
phase,
|
||||
seq,
|
||||
action_id: step.actionId,
|
||||
params: step.params || {},
|
||||
action_version: step.actionVersion || null,
|
||||
idempotency_key: null,
|
||||
attempts: 0,
|
||||
},
|
||||
{ run: { id: null, scope }, actor: user ? user.id : null, verify: true },
|
||||
)
|
||||
if (result.outcome === 'retry' || result.outcome === 'terminal') {
|
||||
findings.push({ ...where, level: 'error', code: 'refused', message: result.error })
|
||||
} else if (result.actionVersionDrift) {
|
||||
findings.push({
|
||||
...where,
|
||||
level: 'warning',
|
||||
code: 'version-drift',
|
||||
message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── The whole-plan check ──
|
||||
const caps = authorize.effectiveCaps(
|
||||
flat.map(({ step }) => ({ actionId: step.actionId, params: step.params || {} })),
|
||||
settings,
|
||||
)
|
||||
const cost = Object.entries(totals)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dimension, total]) => {
|
||||
const cap = (caps[dimension] || {}).cap ?? null
|
||||
const over = cap !== null && total > cap
|
||||
if (over) {
|
||||
findings.push({
|
||||
phase: null,
|
||||
seq: null,
|
||||
actionId: null,
|
||||
label: null,
|
||||
level: 'error',
|
||||
code: 'cap-total',
|
||||
message: `this event asks for ${total} of "${dimension}" across all its steps, and this deployment allows ${cap} per run`,
|
||||
})
|
||||
}
|
||||
return { dimension, total, cap, from: (caps[dimension] || {}).from || null, over }
|
||||
})
|
||||
|
||||
return {
|
||||
ok: !findings.some((f) => f.level === 'error'),
|
||||
steps: flat.length,
|
||||
findings,
|
||||
cost,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifySpec }
|
||||
Reference in New Issue
Block a user