// ── 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 }