// ── The event spec, and the one place it is validated ────────────────────── // // EVENTS.md §C ("phases and actions are configuration inside a version snapshot, // not tables") and §D. A spec is the authored tree a definition carries and a // version freezes: phases, and the steps inside them. It is stored as JSON in // `event_definitions`' working copy and in `event_versions.spec`, and this file // is the only thing that decides whether one is well formed. // // **Every check here is a boundary, not a convenience.** The authoring UI (Phase // 3, then Phase 13) will re-implement some of them for the sake of a good // inline error, and that second copy is expected to drift — so this one is the // one that decides. A spec arriving by any other route (a restore, a fixture, a // module shipping a definition as content) gets the same answer. // // **What this file knows, and what it deliberately refuses.** Two top-level keys // exist today: `schedule` and `phases`. Unknown top-level keys are REFUSED rather // than preserved: a spec that silently carries `announcements` today is a spec // whose author believes announcements work, and the later phase that gives the // key meaning would inherit a corpus of unvalidated ones. The refusal list is the // changelog — Phase 4 added the recurrence shapes, Phase 5 adds a phase's // `advance`, Phase 10 adds `announcements`. // // **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`, // `weekly`, `monthly` — and every check on them is a check on SHAPE. The // arithmetic they describe lives in `events/recurrence.js`, and the zone they are // computed in is `event_definitions.timezone`, a sibling column this file cannot // see and does not need to: a well-formed wall clock resolves in every zone (a // DST gap shifts it, it is never rejected), so a schedule that validates here // computes there. const registries = require('../modules/registries') const recurrence = require('./recurrence') const { checkLiteral } = require('../engagement/conditions') // A phase key is a slug: it is stored in `event_run_steps.phase`, it is what the // run console groups by, and it is what an operator reads in "phase 3 has not // started". Same grammar as a template key's segment. const PHASE_KEY = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/ const MAX_PHASE_KEY = 64 // Bounds, not guesses. They exist so that a paste of the wrong JSON is a refusal // with a number in it rather than a run that materialises fifty thousand step // rows — the same argument `MAX_SENDS_PER_HOUR` makes on the engagement side. const MAX_PHASES = 40 const MAX_STEPS_PER_PHASE = 100 const MAX_STEPS = 500 // The four closed shapes of §E. `manual` is first because it is the default and // what an unscheduled draft carries; the other three are recurrences the runner // expands into occurrences ahead of time. const SCHEDULE_KINDS = ['manual', 'once', 'weekly', 'monthly'] // The keys each shape may carry, and the ONLY ones. A `weekly` that also names // an `at` is an author who believes something about it that is not true — the // same argument the top-level refusal makes, one level down. const SCHEDULE_KEYS = { manual: [], once: ['at'], weekly: ['days', 'time'], monthly: ['nth', 'weekday', 'time'], } // What a step does when its attempts are exhausted (§L). The disposition only — // retry is not one of the values, it is what happens BEFORE one of them. Each // risk class has a default, which is the whole reason `risk` is required at // registration: a `change` action that fell back to `skip` would leave a run // advancing over a half-changed world. const ON_FAILURE = ['skip', 'pause', 'abort_run'] const ON_FAILURE_BY_RISK = { notify: 'skip', inspect: 'skip', change: 'pause', irreversible: 'abort_run', } const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) /** The default disposition for an action whose risk class core knows. */ const defaultOnFailure = (risk) => ON_FAILURE_BY_RISK[risk] || 'pause' /** * Check one schedule shape and answer the normalised form of it. * * Always answers a valid schedule — `{ kind: 'manual' }` when the input was not * one — because `validate` collects every error and carries on, and a caller * reading `spec.schedule.days` of a refused spec should find an empty recurrence * rather than a half-built one. * * **`days` is normalised into week order**, not into the order they were typed. * The spec is compared, described and diffed, and `['friday','monday']` and * `['monday','friday']` naming the same schedule while differing as JSON is a * version history that reports edits nobody made. */ function validateSchedule(kind, raw, errors) { const at = (key) => `spec.schedule.${key}` if (kind === 'once') { const m = recurrence.AT_RE.exec(String(raw.at ?? '')) if (!m) { errors.push(`${at('at')}: expected a local date and time as YYYY-MM-DDTHH:MM`) return { kind: 'manual' } } const [, y, mo, d, h, mi] = m.map(Number) // The regex admits `2026-02-30`, which is a string and not a day. if (!recurrence.isRealDate(y, mo, d)) { errors.push(`${at('at')}: "${raw.at}" is not a real date`) return { kind: 'manual' } } // Stored as the operator wrote it — a wall clock in the definition's own // zone, never a UTC instant. §E: the schedule belongs to the event, and the // instant is derived at materialisation. const pad = (n) => String(n).padStart(2, '0') return { kind: 'once', at: `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${pad(mi)}` } } if (kind === 'weekly' || kind === 'monthly') { const time = recurrence.TIME_RE.test(String(raw.time ?? '')) ? String(raw.time) : null if (!time) errors.push(`${at('time')}: expected a 24-hour time as HH:MM`) if (kind === 'weekly') { const rawDays = Array.isArray(raw.days) ? raw.days : null if (!rawDays || rawDays.length === 0) { errors.push(`${at('days')}: expected a non-empty array of weekday names`) return { kind: 'manual' } } const unknown = rawDays.filter((d) => !recurrence.WEEKDAYS.includes(String(d).toLowerCase())) if (unknown.length) { errors.push( `${at('days')}: unknown weekday(s) ${unknown.join(', ')} — expected ${recurrence.WEEKDAYS.join(', ')}`, ) } const days = recurrence.WEEKDAYS.filter((name) => rawDays.some((d) => String(d).toLowerCase() === name), ) if (!time || !days.length) return { kind: 'manual' } return { kind: 'weekly', days, time } } const weekday = String(raw.weekday ?? '').toLowerCase() if (!recurrence.WEEKDAYS.includes(weekday)) { errors.push( `${at('weekday')}: expected one of ${recurrence.WEEKDAYS.join(', ')}`, ) } const nth = Number(raw.nth) if (!recurrence.MONTHLY_NTH.includes(nth)) { // -1 is "last", which a month with five Fridays makes different from 4. // There is no 5: every month has a first through fourth of every weekday, // so the closed set has no absent case (org lead, 2026-09-02). errors.push(`${at('nth')}: expected 1, 2, 3, 4 or -1 (last)`) } if (!time || !recurrence.WEEKDAYS.includes(weekday) || !recurrence.MONTHLY_NTH.includes(nth)) { return { kind: 'manual' } } return { kind: 'monthly', nth, weekday, time } } return { kind: 'manual' } } /** * Check one authored param object against an action's declared params. * * Returns `{ params, errors }`. Unknown params are an ERROR rather than a silent * drop: an author who typed `creatures` where the action declares `creature` has * written a step that would dispatch with the count missing, and dropping the key * makes that look like it saved cleanly. */ function checkParams(declaration, raw, path) { const errors = [] const params = {} const given = isPlainObject(raw) ? raw : {} if (raw !== undefined && raw !== null && !isPlainObject(raw)) { return { params, errors: [`${path}.params: expected an object`] } } const declared = new Map((declaration.params || []).map((p) => [p.name, p])) for (const name of Object.keys(given)) { if (!declared.has(name)) { errors.push(`${path}.params: "${name}" is not a param of ${declaration.id}`) } } for (const p of declaration.params || []) { const value = given[p.name] if (value === undefined || value === null || value === '') { if (p.required) errors.push(`${path}.params: "${p.name}" is required`) continue } const checked = checkLiteral(p.type, value) if (checked.error) { errors.push(`${path}.params: "${p.name}" ${checked.error}`) continue } params[p.name] = checked.value } return { params, errors } } /** * Validate and normalise a whole spec. * * `{ ok: true, spec }` with a new normalised tree, or `{ ok: false, errors }` * listing EVERY problem rather than the first — the posture `conditions.validate` * takes, and for the same reason: an author fixing one step at a time is an * author making six round trips through a form. * * `knownActionIds` widens what may be named beyond what is registered right now, * and it is how a definition survives its module being uninstalled. The rule, * lifted verbatim from `engagementRules.model`'s treatment of a dormant trigger: * **a step already in the saved spec may keep an unregistered action; a new step * may not add one.** Refusing to save the whole definition would make an * uninstall destructive after the fact, and silently dropping the step would * delete authored work to make a form submit. A kept step is marked * `dormant: true` and its params pass through unvalidated, because the only * thing that could validate them left with the module. */ function validate(raw, { knownActionIds = [] } = {}) { const errors = [] const known = new Set(knownActionIds) if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] } const allowed = new Set(['schedule', 'phases']) for (const key of Object.keys(raw)) { if (!allowed.has(key)) { errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`) } } // ── schedule ── const rawSchedule = raw.schedule === undefined || raw.schedule === null ? { kind: 'manual' } : raw.schedule let schedule = { kind: 'manual' } if (!isPlainObject(rawSchedule)) { errors.push('spec.schedule: expected an object') } else if (!SCHEDULE_KINDS.includes(rawSchedule.kind)) { errors.push(`spec.schedule: kind must be one of ${SCHEDULE_KINDS.join(', ')}`) } else { const kind = rawSchedule.kind const allowedKeys = new Set(['kind', ...SCHEDULE_KEYS[kind]]) const extra = Object.keys(rawSchedule).filter((k) => !allowedKeys.has(k)) if (extra.length) { errors.push(`spec.schedule: unknown key(s) ${extra.join(', ')} for kind "${kind}"`) } schedule = validateSchedule(kind, rawSchedule, errors) } // ── phases ── const rawPhases = raw.phases const phases = [] if (!Array.isArray(rawPhases) || rawPhases.length === 0) { errors.push('spec.phases: expected a non-empty array') return { ok: false, errors } } if (rawPhases.length > MAX_PHASES) { errors.push(`spec.phases: at most ${MAX_PHASES} phases`) return { ok: false, errors } } const seenKeys = new Set() let totalSteps = 0 rawPhases.forEach((rawPhase, pi) => { const path = `spec.phases[${pi}]` if (!isPlainObject(rawPhase)) { errors.push(`${path}: expected an object`) return } const extra = Object.keys(rawPhase).filter((k) => !['key', 'label', 'steps'].includes(k)) if (extra.length) { errors.push(`${path}: unknown key(s) ${extra.join(', ')} (a phase gains "advance" in Phase 5)`) } const key = rawPhase.key if (typeof key !== 'string' || !PHASE_KEY.test(key) || key.length > MAX_PHASE_KEY) { errors.push(`${path}.key: bad phase key "${key}"`) } else if (seenKeys.has(key)) { // Not cosmetic: `event_run_steps` is UNIQUE on (run_id, phase, seq), so two // phases sharing a key would silently collapse into one at materialisation // and half the authored steps would never exist. errors.push(`${path}.key: "${key}" is used by more than one phase`) } else { seenKeys.add(key) } if (!rawPhase.label) errors.push(`${path}.label: a phase needs a label`) const rawSteps = rawPhase.steps if (!Array.isArray(rawSteps)) { errors.push(`${path}.steps: expected an array`) return } if (rawSteps.length > MAX_STEPS_PER_PHASE) { errors.push(`${path}.steps: at most ${MAX_STEPS_PER_PHASE} steps in one phase`) return } totalSteps += rawSteps.length const steps = [] rawSteps.forEach((rawStep, si) => { const spath = `${path}.steps[${si}]` if (!isPlainObject(rawStep)) { errors.push(`${spath}: expected an object`) return } // `actionVersion` and `dormant` are in this list because **validate must // accept its own output**. A saved spec is re-validated on every later // save and again at publish, so a normalised field that the validator // itself added and then refused would make the second save of any // definition impossible. They are accepted and then RECOMPUTED below // rather than trusted: the version comes from the declaration, and // dormancy from whether anyone registers the action right now. const stepExtra = Object.keys(rawStep).filter( (k) => !['actionId', 'params', 'onFailure', 'label', 'actionVersion', 'dormant'].includes(k), ) if (stepExtra.length) errors.push(`${spath}: unknown key(s) ${stepExtra.join(', ')}`) const actionId = rawStep.actionId const declaration = typeof actionId === 'string' ? registries.eventAction(actionId) : null if (typeof actionId !== 'string' || !actionId) { errors.push(`${spath}.actionId: a step needs an action`) return } if (!declaration && !known.has(actionId)) { errors.push(`${spath}.actionId: no module registers "${actionId}"`) return } if (!declaration) { // Dormant: kept verbatim, params untouched, and flagged so the editor and // the run console can both say WHY rather than showing an empty step. steps.push({ actionId, label: rawStep.label || actionId, params: isPlainObject(rawStep.params) ? rawStep.params : {}, actionVersion: Number.isInteger(rawStep.actionVersion) ? rawStep.actionVersion : 1, onFailure: ON_FAILURE.includes(rawStep.onFailure) ? rawStep.onFailure : 'pause', dormant: true, }) return } const { params, errors: paramErrors } = checkParams(declaration, rawStep.params, spath) errors.push(...paramErrors) if (rawStep.onFailure !== undefined && !ON_FAILURE.includes(rawStep.onFailure)) { errors.push(`${spath}.onFailure: must be one of ${ON_FAILURE.join(', ')}`) } steps.push({ actionId, label: rawStep.label || declaration.label, params, // Captured at SAVE time, from the declaration this step was authored // against (§F). It is what lets a later bump render a warning in the // editor instead of dispatching a mistyped parameter. actionVersion: declaration.version, onFailure: ON_FAILURE.includes(rawStep.onFailure) ? rawStep.onFailure : defaultOnFailure(declaration.risk), dormant: false, }) }) phases.push({ key, label: rawPhase.label, steps }) }) if (totalSteps > MAX_STEPS) errors.push(`spec: at most ${MAX_STEPS} steps in one definition`) if (errors.length) return { ok: false, errors } return { ok: true, spec: { schedule, phases } } } /** Every action id a spec names, dormant ones included. */ const actionIdsIn = (spec) => (spec?.phases || []).flatMap((p) => (p.steps || []).map((s) => s.actionId)).filter(Boolean) /** * A spec is publishable when nothing in it is dormant. * * Separate from `validate` on purpose: a dormant step must not stop an author * SAVING (that is what makes an uninstall non-destructive), and it must stop * them PUBLISHING, because publishing is what makes a version a thing runs are * pinned to and a run cannot dispatch a verb nobody registers. */ function publishable(spec) { const dormant = (spec?.phases || []) .flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)) return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] } } /** An empty, valid spec — what a newly created draft carries. */ const emptySpec = () => ({ schedule: { kind: 'manual' }, phases: [{ key: 'main', label: 'Main', steps: [] }], }) module.exports = { validate, publishable, actionIdsIn, emptySpec, defaultOnFailure, PHASE_KEY, SCHEDULE_KINDS, ON_FAILURE, ON_FAILURE_BY_RISK, MAX_PHASES, MAX_STEPS_PER_PHASE, MAX_STEPS, }