Files
website/server/src/events/verify.js
wtclaude 8c30cbbddd
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 6m8s
PR Checks / client-build (pull_request) Successful in 7m58s
feat(events): a dry run carries a module's advice (MODULE_API 1.16.0)
An action answering verify: true with { ok: true, detail: { warnings } } has
each line shown as a warning finding (code `advice`) that never fails the
dry run. Strings only, at most ten per step, each cut at 500 characters;
anything else under warnings is ignored, and a refused step carries none.

Rust PLAN_REDESIGNS §11.4, D391: a raid base's spot is checked against the
world as it stands now (a player on it, a rock beside it), which may change
before the run, so the editor shows it as advice. One optional reading of an
existing member, so minor; Module-uo's ^1.10.0 still resolves and says no
warnings. STEAM_SIGNIN.md's claim moves to 1.17.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:53 -05:00

182 lines
7.5 KiB
JavaScript

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