// ── Event runs — creating an occurrence ──────────────────────────────────── // // EVENTS.md §E. Phase 1 creates a run row and materialises the first phase's // steps. **It does not start anything**: there is no runner until Phase 2, so a // row created here sits at `scheduled` indefinitely. That is the correct // behaviour for this phase and it has to be VISIBLE as such rather than looking // broken, which is why `create()` answers with the row and the admin surface // renders the status verbatim. // // Two properties this file owns, both of which are the reason it exists before // the runner rather than with it: // // - **Materialisation is `INSERT IGNORE` against the occurrence key.** Two // attempts at one occurrence produce one row and an honest answer, not a // duplicate-key error a caller has to interpret. The unique index — not a // claim — is what makes "one run per occurrence per scope" true (§E). // - **The idempotency key is minted with the step row and never varies by // attempt.** It is a function of identity, so it can only be stable if it is // stamped where the identity is created. const db = require('./eventRuns.db') const stepsDb = require('./eventRunSteps.db') const logDb = require('./eventRunLog.db') const gatesDb = require('./eventPhaseGates.db') const gates = require('../../events/gates') const definitionsDb = require('./eventDefinitions.db') const versionsDb = require('./eventVersions.db') const settingsDb = require('./eventActionSettings.db') const budgetDb = require('./eventRunBudget.db') const resourcesDb = require('./eventRunResources.db') const participantsDb = require('./eventRunParticipants.db') const authorize = require('../../events/authorize') const MAX_SCOPE = 190 /** * Render a definition's `concurrency_key` template against a run's params. * * `invasion:{region}` with `{ region: 'Yew' }` becomes `invasion:Yew` (§E). A * placeholder with no matching param is left standing rather than replaced with * an empty string: `invasion:` would collide with every other unrendered key on * the deployment, which is the opposite of what a concurrency key is for, and a * literal `invasion:{region}` in the column is a visible mistake. */ function renderConcurrencyKey(template, params) { if (!template) return null return String(template) .replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (whole, name) => { const value = params && params[name] return value === undefined || value === null || value === '' ? whole : String(value) }) .slice(0, MAX_SCOPE) } /** * Create one occurrence of a definition and materialise its first phase. * * `scheduledFor` defaults to now — "start now" is an occurrence whose instant is * the present, not a separate concept, which is what keeps the runner's one * materialise/advance path honest now that Phase 4 has put recurrence on top. * * **Phase 4's expansion calls this, rather than a second insert path beside it.** * That is deliberate: every check here — the definition is still `ready`, the * version still has phases, the concurrency key renders, the first phase's steps * are materialised with their idempotency keys — is one a scheduled occurrence * needs at least as much as a hand-started one, because there is nobody watching * when it happens. The `INSERT IGNORE` answering `created: false` is what makes * it safe to call on every tick for every occurrence inside the horizon. */ async function create( definitionId, { scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {}, userId, ) { const definition = await definitionsDb.getById(definitionId) if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] } if (definition.state !== 'ready') { return { ok: false, status: 409, errors: [`a ${definition.state} definition has no published version to run`], } } if (!definition.current_version_id) { return { ok: false, status: 409, errors: ['this definition has no published version'] } } const version = await versionsDb.getById(definition.current_version_id) if (!version?.spec?.phases?.length) { return { ok: false, status: 409, errors: ['the published version has no phases'] } } // §K's last bound, enforced for SCHEDULED starts only (org lead, 2026-09-03): // *"a scheduled definition that has never been verified is the case worth // refusing to start"*. An admin pressing start is watching, and that human IS // the review the gate exists to require — so the gate falls on the path where // nobody is. A version is immutable, so a dry run that passed against it stays // true, which is what makes the pass a property of the version rather than // something re-earned every occurrence. if (source === 'schedule' && !version.verified_at) { return { ok: false, status: 409, code: 'unverified', errors: ['this version has not been verified, so it will not start unattended'], } } const scopeValue = String(scope || '').slice(0, MAX_SCOPE) const when = scheduledFor ? new Date(scheduledFor) : new Date() if (Number.isNaN(when.getTime())) { return { ok: false, status: 400, errors: ['scheduledFor is not a date'] } } const runId = await db.materialise({ definition_id: definitionId, version_id: version.id, scope: scopeValue, // Stored as UTC. The definition's zone is what an occurrence is COMPUTED in // (Phase 4); what is stored is the instant. scheduled_for: when, timezone: definition.timezone, concurrency_key: renderConcurrencyKey(definition.concurrency_key, params), params, rehearsal, started_by: userId, }) if (runId === null) { // The occurrence already existed. Not an error — it is what the unique index // is for — so the existing row is the answer. const existing = await db.findOccurrence(definitionId, scopeValue, when) return { ok: true, created: false, run: existing } } await logDb.write({ runId, kind: 'run.created', detail: { definitionId, versionId: version.id, version: version.version, scope: scopeValue, rehearsal: Boolean(rehearsal), // 'manual' is an admin pressing start; 'schedule' is the runner expanding // a recurrence (Phase 4). Both produce the same row, and the log is the // only place the difference is recorded — `started_by` is NULL for both a // scheduled occurrence and one started by a since-deleted account. source, by: userId, }, }) // The run's budget, seeded from EVERY phase's steps rather than from the first // one's (Phase 6). The version is pinned and immutable, so all of its steps are // knowable now — and a budget that grew as phases were entered would let a // phase-1 step spend a cap that a phase-3 step was going to need, which is the // opposite of a per-run bound. The caps are copied here, so an admin moving a // switch tomorrow does not change what a run already in flight is allowed. const allSteps = version.spec.phases.flatMap((p) => (p.steps || []).map((s) => ({ actionId: s.actionId, params: s.params || {} })), ) const settingsByAction = await settingsDb.byIds(allSteps.map((s) => s.actionId)) const budget = authorize.effectiveCaps(allSteps, settingsByAction) if (Object.keys(budget).length) { await budgetDb.seed(runId, budget) await logDb.write({ runId, kind: 'run.budget', detail: { dimensions: Object.entries(budget).map(([dimension, d]) => ({ dimension, cap: d.cap, from: d.from, })), }, }) } // The first phase's steps, materialised at creation rather than at start. // Phase 2 materialises each LATER phase as the run enters it; doing the first // one here is what makes a Phase 1 run row inspectable — an operator can see // the steps that would run, with their params and their idempotency keys, // before there is anything to run them. const first = version.spec.phases[0] await stepsDb.materialisePhase(runId, first.key, first.steps || []) await logDb.write({ runId, kind: 'phase.entered', phase: first.key, detail: { steps: (first.steps || []).length } }) return { ok: true, created: true, run: await db.getById(runId) } } /** * A run, its steps, its status counts and its phase gates — the run console. * * The gates arrive already DESCRIBED rather than as rows (Phase 5): the panel's * whole value is that it reads the way the condition builder reads, and those * words come from `engagement/conditions.js`'s own operator labels. Rendering * them in the browser would be a second implementation of a grammar the server * owns, and the first clause the two spelled differently would meet its operator * at two in the morning. * * Every gate the run has opened is returned, not only the current phase's. A * completed phase's gate answers "how long did phase 2 actually wait, and what * released it" — which is the same question as the live one, asked afterwards. */ async function detail(runId) { const run = await db.getById(runId) if (!run) return null const [steps, counts, gateRows, budget, resources, attendees] = await Promise.all([ stepsDb.listForRun(runId), stepsDb.statusCounts(runId), gatesDb.listForRun(runId), budgetDb.forRun(runId), resourcesDb.forRun(runId), participantsDb.listForRun(runId), ]) const now = new Date() return { run, steps, counts, gates: gateRows.map((g) => gates.describe(g, now)), // The meter, as rows rather than as a sentence: a cap is two numbers and a // name, and unlike a gate it needs no grammar rendered to be read. `cap: // null` is uncapped and the client says so — a dimension the run counts but // nothing bounds. budget: budget.map((b) => ({ dimension: b.dimension, consumed: b.consumed, cap: b.cap, from: b.effective_from, })), // What this run changed in the world, and what became of it (Phase 8). The // WHOLE ledger, reverted rows included, because "what did last night's // invasion actually spawn, and did all of it come back" is the question this // panel exists for and a list of only the failures cannot answer the second // half of it. // // **The `@step` placeholders are filtered out.** They are core's own // bookkeeping — a row that says "a dispatch is in flight and may have made // something" — and the console's list is of things in the world. One left in // would read as a resource nobody can name, which is exactly the confusion it // exists to prevent internally. resources: resources .filter((r) => r.kind !== resourcesDb.STEP_KIND) .map((r) => ({ id: r.id, stepId: r.step_id, module: r.owner_module, kind: r.kind, ref: r.ref, payload: r.payload, leaseUntil: r.lease_until, status: r.status, revertAttempts: r.revert_attempts, lastError: r.last_error, memberKey: r.member_key, createdAt: r.created_at, })), // How many rows are still unresolved, counted over the WHOLE ledger rather // than over the list above — a placeholder left standing by a lost // acknowledgement is exactly the case `cleanup_status` must not call clean. unresolvedResources: resources.filter((r) => resourcesDb.UNRESOLVED.includes(r.status)).length, // Who took part, best first (Phase 10). Returned on every run rather than // only on a published one: the console's question is "what did this event // record", and a run whose module has collected but whose author never // placed a publish step is exactly the case an operator needs to see. What // `results_published_at` on the run row then says is whether anyone OUTSIDE // this screen may read it — which is Phase 14's question, not this one's. // // **`rank` is `rank_at`, renamed at the boundary and not in the column.** // `rank` is a reserved word in MariaDB 10.2+ (it is the window function), // so the column carries the suffix and the API carries the name a client // wants. The alternative — backticking the column at every use — is one // forgotten pair of backticks away from a syntax error in a query nobody // runs until a run completes at four in the morning. participants: attendees.map((p) => ({ memberKey: p.member_key, userId: p.user_id, score: p.score, rank: p.rank_at, joinedAt: p.joined_at, meta: p.meta, })), } } module.exports = { create, detail, renderConcurrencyKey }