// ── 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') /** The most advice lines one step's dry run carries, and the longest one. */ const ADVICE_MAX = 10 const ADVICE_CHARS = 500 /** * A dry run's `detail.warnings`, as the lines a dry run shows: strings only, * trimmed, the empty dropped, at most ten, each cut at 500 characters. Anything * else a module put there is ignored rather than refused — advice that cannot be * read is not a reason to fail a plan that would run. */ function adviceOf(detail) { if (!detail || typeof detail !== 'object' || !Array.isArray(detail.warnings)) return [] return detail.warnings .filter((w) => typeof w === 'string') .map((w) => w.trim()) .filter(Boolean) .slice(0, ADVICE_MAX) .map((w) => (w.length > ADVICE_CHARS ? `${w.slice(0, ADVICE_CHARS - 1)}…` : w)) } /** * 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}`, }) } // MODULE_API 1.16.0: a module's ADVICE on a step that would run — the // world as it stands now, which may change before the run does (Rust // PLAN_REDESIGNS §11.4, D391). A warning, so it never fails the dry run. for (const message of adviceOf(result.detail)) { findings.push({ ...where, level: 'warning', code: 'advice', message }) } } } // ── 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, adviceOf }