// ── Event definitions — the save path ────────────────────────────────────── // // EVENTS.md §D and "Versioning, and editing a live event". A definition is // operator-authored data, and this file is the boundary that decides whether a // version of it may exist. The authoring UI (Phase 3, then Phase 13) will // re-check some of this for the sake of a good inline error; that second copy is // expected to drift, so this one is the one that decides, and a definition // arriving by any other route gets the same answer. // // **The three rules with teeth, and each is a rule about time rather than about // shape:** // // 1. Publishing SNAPSHOTS. It copies the working spec into an immutable // `event_versions` row and points `current_version_id` at it. Editing // afterwards is free and does not touch the row a live run pinned. // 2. A dormant step blocks a PUBLISH and never a SAVE. An uninstalled module // must not make an author's work uneditable, and it must not let a version be // published that names a verb nobody can perform. // 3. Archiving is what "delete" means here. `event_runs.version_id` RESTRICTs, so // a hard delete of a definition that has ever run is refused by the database // anyway — and the row's history is the thing an audit reads. const db = require('./eventDefinitions.db') const versionsDb = require('./eventVersions.db') const runsDb = require('./eventRuns.db') const logDb = require('./eventRunLog.db') const seriesDb = require('./eventSeries.db') const spec = require('../../events/spec') const authorize = require('../../events/authorize') const verifier = require('../../events/verify') const registries = require('../../modules/registries') const { slugify, uniqueSlug } = require('../teams/teamSlug') const { cleanBody } = require('../../utils/sanitizeHtml') const MAX_TITLE = 200 const MAX_SUMMARY = 500 const MAX_URL = 500 const MAX_CONCURRENCY_KEY = 190 // A day either side of §D's default. Below a minute the grace window cannot // survive a single slow boot; above a day a "missed" occurrence would start // silently the following afternoon, which is the exact behaviour §E forbids. const MIN_GRACE_SECONDS = 60 const MAX_GRACE_SECONDS = 86_400 /** * IANA zone names, checked against the platform's own database rather than a * list. `Intl.DateTimeFormat` throws `RangeError` on an unknown zone, and Node * ships the full tzdata — so this is the same check Phase 4's occurrence * arithmetic will make, asked one screen earlier where an operator can fix it. */ function isTimezone(tz) { try { Intl.DateTimeFormat(undefined, { timeZone: tz }) return true } catch { return false } } const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) const trimOrNull = (v, max) => { if (v === undefined || v === null) return null const s = String(v).trim() return s === '' ? null : s.slice(0, max) } /** * Validate an incoming definition against the registries and the schema. * * `{ ok: true, definition }` with a normalised row ready for insert/update, or * `{ ok: false, errors }` listing every problem rather than the first. * * `existing` is the row being edited, or null on a create. It is what lets a * dormant step survive: the ids already in the saved spec widen what the spec * validator will accept, so an uninstall is never destructive after the fact. */ async function validate(input, { existing = null } = {}) { const errors = [] const body = isPlainObject(input) ? input : {} const title = trimOrNull(body.title, MAX_TITLE) if (!title) errors.push('title is required') const summary = trimOrNull(body.summary, MAX_SUMMARY) const imageUrl = trimOrNull(body.imageUrl, MAX_URL) const concurrencyKey = trimOrNull(body.concurrencyKey, MAX_CONCURRENCY_KEY) // The storyline. Sanitized on write, exactly as a wiki page and a forum post // are: it is author-supplied HTML that ends up on a public page. const storyline = body.body === undefined || body.body === null ? null : cleanBody(String(body.body)) const timezone = trimOrNull(body.timezone, 64) || existing?.timezone || 'UTC' if (!isTimezone(timezone)) errors.push(`timezone: "${timezone}" is not an IANA zone name`) const graceRaw = body.graceSeconds === undefined ? (existing?.grace_seconds ?? 900) : body.graceSeconds const graceSeconds = Number(graceRaw) if ( !Number.isInteger(graceSeconds) || graceSeconds < MIN_GRACE_SECONDS || graceSeconds > MAX_GRACE_SECONDS ) { errors.push(`graceSeconds must be an integer ${MIN_GRACE_SECONDS}..${MAX_GRACE_SECONDS}`) } let seriesId = null if (body.seriesId !== undefined && body.seriesId !== null && body.seriesId !== '') { seriesId = Number(body.seriesId) if (!Number.isInteger(seriesId) || seriesId < 1) { errors.push('seriesId must be an integer') seriesId = null } else if (!(await seriesDb.exists(seriesId))) { // Checked here as well as by the foreign key, because a 1452 reaching a // controller is a 500 and this is a 400 an author can act on. errors.push(`seriesId ${seriesId} does not exist`) seriesId = null } } else if (existing) { seriesId = existing.series_id } const seriesOrderRaw = body.seriesOrder === undefined ? (existing?.series_order ?? 0) : body.seriesOrder const seriesOrder = Number(seriesOrderRaw) if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer') // Whether this event is announced on the public calendar (Phase 14a). It is // NOT whether it may run: `state` answers that, and the two are separate // precisely because publishing is what makes a definition runnable — an // unlisted event still schedules, still runs and is still on the admin // calendar. A missing key means "leave it as it was", and a NEW definition // defaults to listed, which is the column's own default and the ordinary // case; unlisting is the deliberate act. const listed = body.listed === undefined ? (existing ? Boolean(existing.listed) : true) : Boolean(body.listed) // ── the spec ── const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec const known = existing?.spec ? spec.actionIdsIn(existing.spec) : [] const checked = spec.validate(rawSpec, { knownActionIds: known }) if (!checked.ok) errors.push(...checked.errors) // ── the slug ── // // Derived from the title on create and FROZEN afterwards, like a Team's: the // public event page lives at it, and a retitle must not break a link somebody // posted in Discord. An author who genuinely needs a different address makes a // new definition. let slug = existing?.slug || null if (!slug) { const stem = slugify(title || '') || 'event' const taken = (await db.list()).map((d) => d.slug) slug = uniqueSlug(stem, taken, { fallback: 'event' }) } if (errors.length) return { ok: false, errors } return { ok: true, definition: { title, slug, summary, body: storyline, image_url: imageUrl, owner_module: existing?.owner_module ?? null, series_id: seriesId, series_order: seriesOrder, concurrency_key: concurrencyKey, grace_seconds: graceSeconds, timezone, listed, spec: checked.spec, }, } } /** Create a draft. */ async function create(input, userId, { role = null } = {}) { const result = await validate(input) if (!result.ok) return result const floor = checkRoleFloor(result.definition.spec, role) if (floor) return floor const id = await db.insert({ ...result.definition, created_by: userId }) return { ok: true, id, definition: await db.getById(id) } } /** * Edit a definition. * * An `archived` definition is not editable. That is the one state check here, * and it is a real one rather than a formality: archiving is how a definition is * retired, and a retired definition that can still be edited is a definition * somebody will edit and then wonder why it never runs. */ async function save(id, input, userId, { role = null } = {}) { const existing = await db.getById(id) if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } if (existing.state === 'archived') { return { ok: false, status: 409, errors: ['an archived definition cannot be edited'] } } const result = await validate(input, { existing }) if (!result.ok) return result const floor = checkRoleFloor(result.definition.spec, role) if (floor) return floor await db.update(id, { ...result.definition, updated_by: userId }) return { ok: true, id, definition: await db.getById(id) } } /** * Publish: snapshot the working spec into an immutable version and go `ready`. * * The spec is re-validated here against the registries as they stand RIGHT NOW, * not trusted from the save that wrote it. A module uninstalled between the two * is the whole reason: the save was legitimate, and publishing a version whose * steps name a verb nobody can perform would be a run that fails at dispatch * with the world half-changed. */ async function publish(id, userId) { const existing = await db.getById(id) if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } if (existing.state === 'archived') { return { ok: false, status: 409, errors: ['an archived definition cannot be published'] } } const checked = spec.validate(existing.spec, { knownActionIds: spec.actionIdsIn(existing.spec), }) if (!checked.ok) return { ok: false, status: 400, errors: checked.errors } const publishable = spec.publishable(checked.spec) if (!publishable.ok) { return { ok: false, status: 409, errors: [ `cannot publish: no module registers ${publishable.dormant.join(', ')}`, ], } } if (!checked.spec.phases.some((p) => p.steps.length)) { // An empty event publishes cleanly and then does nothing, which looks like a // broken run rather than an empty one. Refusing costs an author one click and // saves an operator a diagnosis. return { ok: false, status: 400, errors: ['cannot publish: no phase has any steps'] } } const version = await versionsDb.nextVersion(id) const versionId = await versionsDb.insert(id, version, checked.spec, userId) await db.markReady(id, versionId, userId) // Occurrences already materialised ahead of their instant move to the new // version; ones that have begun do not (org lead, 2026-09-02). Logged per run // rather than only counted, because "which version did this run actually use" // is the first question an audit asks and the pin is no longer immutable while // a run is still `scheduled`. const pending = await runsDb.listScheduledFor(id) const stale = pending.filter((r) => Number(r.version_id) !== Number(versionId)) const repinned = stale.length ? await runsDb.repinScheduled(id, versionId) : 0 for (const run of stale) { await logDb.write({ runId: run.id, kind: 'run.status', detail: { to: 'scheduled', repinned: true, fromVersionId: run.version_id, toVersionId: versionId, toVersion: version, by: userId, }, }) } return { ok: true, versionId, version, repinned, definition: await db.getById(id) } } /** * Archive. * * Refused while a run of this definition is still in flight — not because the * database would object (it would not; archiving is an UPDATE), but because the * screen the run is on reads its title and state from here, and retiring a * definition mid-run makes the console describe something that is no longer * supposed to exist. Cancel the run, then archive. */ async function archive(id, userId) { const existing = await db.getById(id) if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } if (existing.state === 'archived') return { ok: true, definition: existing } const active = await runsDb.countActiveForDefinition(id) if (active > 0) { return { ok: false, status: 409, errors: [`cannot archive: ${active} run(s) of this definition are still in flight`], } } await db.archive(id, userId) return { ok: true, definition: await db.getById(id) } } /** * The role floor on a spec's steps (§K, Phase 6). * * §K's table reads "any step whose action is above `notify` — `admin` only", and * the line falls between `inspect` and `change` for the reason the default-off * rule does (org lead, 2026-09-03): an `inspect` action reads state and writes * nothing, and an editor who cannot author a step that WAITS has an authoring * role that cannot author. * * Checked at SAVE rather than only at publish, which is the difference between * telling an editor now and telling them after they have written twelve steps. * Publish re-checks anyway — it re-checks everything, against the registries as * they stand at that moment — because an action's risk class is a module's * declaration and a module can be upgraded between the two. */ function worldChangingSteps(specValue) { const out = [] for (const phase of specValue?.phases || []) { for (const step of phase.steps || []) { const action = registries.eventAction(step.actionId) if (action && authorize.changesWorld(action)) out.push(action) } } return out } function checkRoleFloor(specValue, role) { if (!role || role === 'admin') return null const blocked = worldChangingSteps(specValue) if (!blocked.length) return null const names = [...new Set(blocked.map((a) => `"${a.label}"`))] // Agreement, because this sentence is read by the person it refuses. The list // is almost always one long -- an editor adds one world-changing step and is // stopped -- so `"Spawn creatures" change the world` is the case that shows, // and it reads as a bug in the sentence rather than a rule about the step. const one = names.length === 1 return { ok: false, status: 403, errors: [ `${names.join(', ')} ${one ? 'changes' : 'change'} the world, so only an administrator may author a step that uses ${one ? 'it' : 'them'}`, ], } } /** * Dry-run a definition, and record the pass when there is a version to record it * on (Phase 6). * * The target follows the definition's state: a `ready` definition is verified * against the version that would actually run, a draft against the working spec * the author is still holding. See `events/verify.js` for why that is one act at * two moments rather than two rules. */ async function verify(id, user) { const existing = await db.getById(id) if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } if (existing.state === 'archived') { return { ok: false, status: 409, errors: ['an archived definition cannot be verified'] } } const version = existing.state === 'ready' && existing.current_version_id ? await versionsDb.getById(existing.current_version_id) : null const target = version?.spec || existing.spec if (!target?.phases?.length) { return { ok: false, status: 409, errors: ['this definition has no phases to verify'] } } const report = await verifier.verifySpec(target, { user }) if (version && report.ok) { await versionsDb.markVerified(version.id, user?.id || null) // Written to every run already pinned to this version, because that is where // an operator asks the question: a scheduled occurrence that was being held // is now going to start, and the line saying why belongs on it. for (const run of await runsDb.listScheduledFor(id)) { if (Number(run.version_id) !== Number(version.id)) continue await logDb.write({ runId: run.id, kind: 'version.verified', detail: { versionId: version.id, version: version.version, by: user?.id || null }, }) } } return { ok: true, report, // Which spec was verified, said plainly, because the two answer different // questions and a report that did not say would be read as the other one. target: version ? 'version' : 'draft', versionId: version?.id || null, version: version?.version || null, recorded: Boolean(version && report.ok), } } module.exports = { validate, create, save, publish, archive, verify, checkRoleFloor, isTimezone, MIN_GRACE_SECONDS, MAX_GRACE_SECONDS, }