diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js
index c7b34d0..58ad825 100644
--- a/client/src/lib/eventAuthoring.js
+++ b/client/src/lib/eventAuthoring.js
@@ -221,6 +221,89 @@ export function whereFormFrom(where) {
}
}
+// ── Starting from a trigger (Rust PLAN_REDESIGNS §14.3.12, D370) ───────────
+//
+// A start is a phase gate's trigger and condition plus a cooldown, so the form
+// holds it in the gate's own fields — and the same `WhereBuilder` draws it. The
+// server (`events/spec.js`) decides; this only carries the fields.
+
+export const DEFAULT_COOLDOWN = '30m'
+
+// Core's own lifecycle triggers cannot start a run; the editor does not offer
+// them, and the server refuses them if posted anyway.
+export const isStartableTrigger = (trigger) => !String(trigger?.id || '').startsWith('event.')
+
+export function blankStart() {
+ return { on: '', cooldown: DEFAULT_COOLDOWN, dormant: false, ...blankWhere() }
+}
+
+/** The stored `starts`, as the form's rows. */
+export function startsFormFrom(starts) {
+ return (starts || []).map((s) => ({
+ ...blankStart(),
+ on: s.on || '',
+ cooldown: s.cooldown ?? DEFAULT_COOLDOWN,
+ dormant: Boolean(s.dormant),
+ ...whereFormFrom(s.where),
+ }))
+}
+
+/**
+ * The form's starts as the spec's list. A row with no trigger chosen is dropped
+ * rather than posted — it is a half-added row, not a start the author wrote.
+ */
+export function startsPayload(rows, errors, triggersById = new Map()) {
+ const out = []
+ ;(rows || []).forEach((row, i) => {
+ if (!row.on) return
+ const gate = advancePayload(
+ { ...row, kind: 'on' },
+ `Start ${i + 1}`,
+ errors,
+ triggersById.get(row.on)?.variables || [],
+ )
+ const entry = { on: row.on, cooldown: String(row.cooldown ?? '').trim() || DEFAULT_COOLDOWN }
+ if (gate?.where) entry.where = gate.where
+ out.push(entry)
+ })
+ return out
+}
+
+/**
+ * Why a run exists, when a game firing started it — the console's sentence.
+ * Null for a run started by hand or by the schedule.
+ */
+export function describeOrigin(origin) {
+ if (!origin || origin.kind !== 'trigger') return null
+ const vars = Object.entries(origin.variables || {})
+ .filter(([k]) => k !== '_truncated')
+ .map(([k, v]) => `${k} ${typeof v === 'string' ? v : JSON.stringify(v)}`)
+ const label = origin.label || origin.triggerId
+ return [
+ `Started because “${label}” fired`,
+ origin.matched ? ` (matching ${origin.matched})` : '',
+ vars.length ? `: ${vars.join(' · ')}` : '',
+ ].join('')
+}
+
+/**
+ * The editor's line about what its trigger starts have done — or null when a
+ * trigger has never tried. "2 firings ignored while running, 1 in its cooldown".
+ */
+export function describeTriggerStarts(state) {
+ if (!state) return null
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
+ const ignored = [
+ state.ignoredRunning ? `${plural(state.ignoredRunning, 'firing')} ignored while a run was going` : null,
+ state.ignoredCooldown ? `${plural(state.ignoredCooldown, 'firing')} ignored in the cooldown` : null,
+ state.ignoredUnverified
+ ? `${plural(state.ignoredUnverified, 'firing')} ignored because the published version is not verified`
+ : null,
+ ].filter(Boolean)
+ const last = state.lastRunId ? `Last started run #${state.lastRunId}` : 'No run started by a trigger yet'
+ return ignored.length ? `${last}; since then ${ignored.join(', ')}.` : `${last}.`
+}
+
/** The editor's working state, from what `GET /admin/events/:id` returned. */
export function formFromDefinition(event) {
const spec = event?.spec || {}
@@ -243,6 +326,7 @@ export function formFromDefinition(event) {
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
...scheduleFormFrom(spec.schedule),
+ starts: startsFormFrom(spec.starts),
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
label: p.label || '',
@@ -347,6 +431,8 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
}
})
+ const starts = startsPayload(form.starts, errors, triggersById)
+
if (errors.length) return { ok: false, errors }
return {
@@ -363,7 +449,10 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
timezone: form.timezone,
listed: Boolean(form.listed),
listed: Boolean(form.listed),
- spec: { schedule: scheduleFromForm(form), phases },
+ // `starts` only when there is one, as the server stores it (D370).
+ spec: starts.length
+ ? { schedule: scheduleFromForm(form), starts, phases }
+ : { schedule: scheduleFromForm(form), phases },
},
}
}
@@ -1012,7 +1101,9 @@ export function describeLogLine(line) {
? `${d.action} → ${d.to}${by} — ${d.control}${d.note || d.reason ? `: ${d.note || d.reason}` : ''}`
: `${d.action} → ${d.to}${d.error ? `: ${d.error}` : ''}`
case 'run.created':
- return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}`
+ return `Occurrence created from version ${d.version}${d.rehearsal ? ' (rehearsal)' : ''}${
+ d.source === 'trigger' && d.origin ? ` — ${describeOrigin(d.origin)}` : ''
+ }`
case 'phase.gate':
return d.kind === 'after'
? `${line.phase} advances ${d.after} after it started`
diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx
index d418fa7..3a2b2b9 100644
--- a/client/src/routes/admin/views/EventEditor.jsx
+++ b/client/src/routes/admin/views/EventEditor.jsx
@@ -35,6 +35,9 @@ import {
missingParams,
guardedAdvance,
shortfallSentence,
+ blankStart,
+ isStartableTrigger,
+ describeTriggerStarts,
} from '../../../lib/eventAuthoring.js'
import { operatorsForType, coerceLiteral } from '../../../lib/engagementRules.js'
@@ -841,7 +844,7 @@ function TemplatePicker({ templates, sources, loadSource, onDraft }) {
* into `A and B and C` changes which firings release the phase, and an author
* would have no way to know the save had done it.
*/
-function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
+function WhereBuilder({ advance, trigger, operators, disabled, onChange, emptyText }) {
const variables = trigger?.variables || []
const rows = advance.whereRows || []
@@ -873,8 +876,7 @@ function WhereBuilder({ advance, trigger, operators, disabled, onChange }) {
Only when
{rows.length === 0 && (
- Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for
- firings that match.
+ {emptyText || 'Every firing of this trigger counts. Add a clause to narrow it — the phase then waits for firings that match.'}
)}
{rows.length > 1 && (
@@ -1897,6 +1899,85 @@ export default function EventEditor() {
+ {/* ── Started by the game (Rust PLAN_REDESIGNS §14.3.12, D370) ──
+ A start is a phase gate's trigger and condition plus a cooldown, so it
+ is drawn with the gate's own builder. Beside the schedule rather than
+ inside it: a definition may have either, or both. */}
+
diff --git a/client/test/eventStarts.test.js b/client/test/eventStarts.test.js
new file mode 100644
index 0000000..cca093b
--- /dev/null
+++ b/client/test/eventStarts.test.js
@@ -0,0 +1,110 @@
+// A definition's trigger starts in the editor, and a run's origin on the
+// console (Rust PLAN_REDESIGNS §14.3.12, D370). The server decides; these prove
+// the form carries what it is given and the sentences read right.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import {
+ formFromDefinition,
+ payloadFromForm,
+ blankStart,
+ startsFormFrom,
+ startsPayload,
+ isStartableTrigger,
+ describeOrigin,
+ describeTriggerStarts,
+ describeLogLine,
+} from '../src/lib/eventAuthoring.js'
+
+const TRIGGER = {
+ id: 'rust.world.bradley_destroyed',
+ label: 'Bradley destroyed',
+ variables: [
+ { name: 'server', type: 'string' },
+ { name: 'kills', type: 'int' },
+ ],
+}
+const triggersById = new Map([[TRIGGER.id, TRIGGER]])
+
+const definition = (starts) => ({
+ title: 'Launch Site Lockdown',
+ spec: {
+ schedule: { kind: 'manual' },
+ ...(starts ? { starts } : {}),
+ phases: [{ key: 'main', label: 'Main', steps: [] }],
+ },
+})
+
+test('a stored start round-trips through the form unchanged', () => {
+ const starts = [{ on: TRIGGER.id, where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '2h' }]
+ const form = formFromDefinition(definition(starts))
+ assert.equal(form.starts.length, 1)
+ assert.equal(form.starts[0].whereRows.length, 1)
+ const built = payloadFromForm(form, { triggersById })
+ assert.equal(built.ok, true)
+ assert.deepEqual(built.payload.spec.starts, starts)
+})
+
+test('no starts is no key, as the server stores it', () => {
+ const built = payloadFromForm(formFromDefinition(definition()), { triggersById })
+ assert.equal('starts' in built.payload.spec, false)
+ assert.deepEqual(formFromDefinition(definition()).starts, [])
+})
+
+test('a half-added row is dropped, a blank cooldown is the default, a literal takes the declared type', () => {
+ const rows = [
+ blankStart(),
+ { ...blankStart(), on: TRIGGER.id, cooldown: ' ', whereRows: [{ variable: 'kills', cmp: 'gte', value: '2' }] },
+ ]
+ const errors = []
+ const out = startsPayload(rows, errors, triggersById)
+ assert.deepEqual(errors, [])
+ assert.deepEqual(out, [{ on: TRIGGER.id, cooldown: '30m', where: { variable: 'kills', cmp: 'gte', value: 2 } }])
+})
+
+test('a nested condition the builder cannot draw is posted back verbatim', () => {
+ const where = { op: 'and', nodes: [{ variable: 'server', cmp: 'eq', value: 'main' }, { op: 'or', nodes: [{ variable: 'kills', cmp: 'gt', value: 1 }, { variable: 'kills', cmp: 'lt', value: 0 }] }] }
+ const [row] = startsFormFrom([{ on: TRIGGER.id, where, cooldown: '0m' }])
+ assert.equal(row.whereEditable, false)
+ assert.deepEqual(startsPayload([row], [], triggersById), [{ on: TRIGGER.id, cooldown: '0m', where }])
+})
+
+test('core\'s own lifecycle triggers are not offered', () => {
+ assert.equal(isStartableTrigger({ id: 'event.run.started' }), false)
+ assert.equal(isStartableTrigger(TRIGGER), true)
+})
+
+test('the origin sentence', () => {
+ assert.equal(describeOrigin(null), null)
+ assert.equal(
+ describeOrigin({
+ kind: 'trigger',
+ triggerId: TRIGGER.id,
+ label: 'Bradley destroyed',
+ matched: 'server is "main"',
+ variables: { server: 'main', grid: 'G8', _truncated: true },
+ }),
+ 'Started because “Bradley destroyed” fired (matching server is "main"): server main · grid G8',
+ )
+ assert.equal(describeOrigin({ kind: 'trigger', triggerId: 'x.y', variables: {} }), 'Started because “x.y” fired')
+})
+
+test('the editor\'s ignored-firings line', () => {
+ assert.equal(describeTriggerStarts(null), null)
+ assert.equal(
+ describeTriggerStarts({ lastRunId: 12, ignoredRunning: 2, ignoredCooldown: 1, ignoredUnverified: 0 }),
+ 'Last started run #12; since then 2 firings ignored while a run was going, 1 firing ignored in the cooldown.',
+ )
+ assert.equal(
+ describeTriggerStarts({ lastRunId: null, ignoredRunning: 0, ignoredCooldown: 0, ignoredUnverified: 1 }),
+ 'No run started by a trigger yet; since then 1 firing ignored because the published version is not verified.',
+ )
+ assert.equal(describeTriggerStarts({ lastRunId: 3 }), 'Last started run #3.')
+})
+
+test('the audit line names a trigger start', () => {
+ const line = describeLogLine({
+ kind: 'run.created',
+ detail: { version: 2, source: 'trigger', origin: { kind: 'trigger', label: 'Bradley destroyed', variables: { grid: 'G8' } } },
+ })
+ assert.equal(line, 'Occurrence created from version 2 — Started because “Bradley destroyed” fired: grid G8')
+})
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 560aab2..2253c61 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -2704,3 +2704,43 @@ ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NU
-- that no longer resolves answers that honestly.
ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL;
ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);
+
+-- ── A trigger can start a run (Rust PLAN_REDESIGNS §14.3.12, D370) ─────────
+
+-- Why a run exists, when a game firing started it: the trigger, when it fired,
+-- its subject and its variables, so the console can say "started because a
+-- champion boss is up at Abyss" and the audit can say what. NULL for a run an
+-- admin or the schedule started, which `run.created`'s `source` already says.
+--
+-- A column rather than only a log line because the run log is pruned and the
+-- run is not, and "why did this run happen" outlives "what did each step do".
+ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS origin JSON NULL;
+
+-- One row per definition that a trigger has tried to start: the cooldown's
+-- clock, the claim that makes "one run at a time" true, and the count of
+-- firings it ignored, which the editor shows ("2 firings ignored while
+-- running").
+--
+-- **The claim is a conditional UPDATE on this row**, the event_run_budget
+-- argument once more: two firings arriving together each try it, and the
+-- cooldown, the one-at-a-time check and a stale-claim guard are all in its
+-- WHERE, so exactly one of them owns the start. `claimed_at` is held only
+-- between the claim and the run row existing — a minute is a crashed process,
+-- not a slow insert.
+--
+-- The counts are since the LAST trigger-started run, and a new start zeroes
+-- them: what an operator asks is "is it ignoring things right now", and a
+-- lifetime total cannot answer that.
+CREATE TABLE IF NOT EXISTS event_trigger_starts (
+ definition_id INT NOT NULL PRIMARY KEY,
+ claimed_at DATETIME NULL,
+ last_started_at DATETIME NULL, -- the firing's time, which is the run's scheduled_for
+ last_run_id BIGINT NULL,
+ ignored_running INT NOT NULL DEFAULT 0,
+ ignored_cooldown INT NOT NULL DEFAULT 0,
+ ignored_unverified INT NOT NULL DEFAULT 0,
+ last_ignored_at DATETIME NULL,
+ last_ignored_reason VARCHAR(32) NULL,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_evtrig_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/server/src/events/spec.js b/server/src/events/spec.js
index a9f3401..d39b4bd 100644
--- a/server/src/events/spec.js
+++ b/server/src/events/spec.js
@@ -12,13 +12,14 @@
// 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
+// **What this file knows, and what it deliberately refuses.** Three top-level
+// keys exist today: `schedule`, `starts` 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 added a phase's
-// `advance`, Phase 10 adds `announcements`.
+// `advance`, Phase 10 adds `announcements`, and Rust PLAN_REDESIGNS §14.3.12
+// added `starts`, the triggers that begin a run (D370).
//
// **Phase 4 widened `schedule` from one shape to four** — `manual`, `once`,
// `weekly`, `monthly` — and every check on them is a check on SHAPE. The
@@ -76,6 +77,24 @@ const MAX_AFTER_SECONDS = 30 * 86_400
// never advances written as one that eventually does.
const MAX_ADVANCE_COUNT = 1000
+// A definition's `starts` (Rust PLAN_REDESIGNS §14.3.12, D370): the triggers that
+// begin a run of it. Bounded for the reason MAX_PHASES is — a paste guard, not a
+// policy, and ten is more than any event here has wanted.
+const MAX_STARTS = 10
+
+// How long after one trigger-started run another may start. Thirty minutes by
+// default, zero (one run at a time is then the only bound) up to a week. It is
+// the same one-integer-one-unit grammar as `after`, for the same proofreading
+// reason, and normalised the same way.
+const DEFAULT_COOLDOWN = '30m'
+const MAX_COOLDOWN_SECONDS = 7 * 86_400
+
+// Core's own lifecycle triggers cannot start a run (D370). `event.run.started`
+// starting a run would be a run starting another, and the one-at-a-time rule is
+// per definition, so it would not hold a chain of two definitions starting each
+// other. Refused by prefix because every one of them is core's and named so.
+const CORE_EVENT_PREFIX = 'event.'
+
// 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.
@@ -323,6 +342,74 @@ function validateAdvance(raw, path, errors) {
return { on: triggerId, where: checked.conditions, count, dormant: false }
}
+/**
+ * Parse a cooldown into seconds, or answer null. `'0m'` (or a bare `0`) is
+ * allowed here and nowhere else in the duration grammar: no cooldown at all is a
+ * real choice, and then one run at a time is the only bound.
+ */
+function parseCooldown(raw) {
+ if (raw === 0 || raw === '0') return 0
+ const m = AFTER_RE.exec(String(raw ?? ''))
+ if (!m) return null
+ const seconds = Number(m[1]) * AFTER_UNIT_SECONDS[m[2]]
+ return seconds <= MAX_COOLDOWN_SECONDS ? seconds : null
+}
+
+const formatCooldown = (seconds) => (seconds === 0 ? '0m' : formatAfter(seconds))
+
+/**
+ * Check a definition's `starts` and answer the normalised list (D370).
+ *
+ * Each entry is a phase gate's `on` and `where` — the same catalogue, the same
+ * grammar, the same dormant rule — plus a `cooldown`. A trigger nobody registers
+ * makes the entry dormant: it saves, and it refuses to publish.
+ */
+function validateStarts(raw, errors) {
+ if (raw === undefined || raw === null) return []
+ if (!Array.isArray(raw)) {
+ errors.push('spec.starts: expected an array')
+ return []
+ }
+ if (raw.length > MAX_STARTS) {
+ errors.push(`spec.starts: at most ${MAX_STARTS} triggers`)
+ return []
+ }
+
+ const out = []
+ raw.forEach((entry, i) => {
+ const path = `spec.starts[${i}]`
+ if (!isPlainObject(entry)) {
+ errors.push(`${path}: expected an object`)
+ return
+ }
+ const extra = Object.keys(entry).filter((k) => !['on', 'where', 'cooldown', 'dormant'].includes(k))
+ if (extra.length) {
+ errors.push(`${path}: unknown key(s) ${extra.join(', ')}`)
+ return
+ }
+ if (typeof entry.on === 'string' && entry.on.startsWith(CORE_EVENT_PREFIX)) {
+ errors.push(`${path}.on: "${entry.on}" is one of the event system's own triggers, and a run cannot start another`)
+ return
+ }
+
+ const cooldownSeconds = parseCooldown(entry.cooldown ?? DEFAULT_COOLDOWN)
+ if (cooldownSeconds === null) {
+ errors.push(
+ `${path}.cooldown: expected a duration like "30m" — a whole number of s, m, h or d, ` +
+ `from 0 to ${formatAfter(MAX_COOLDOWN_SECONDS)}`,
+ )
+ return
+ }
+
+ // The trigger and its predicate are a gate's, so a gate's validator answers
+ // for them: one grammar, one dormant rule, one set of messages.
+ const gate = validateAdvance({ on: entry.on, where: entry.where ?? null }, path, errors)
+ if (!gate) return
+ out.push({ on: gate.on, where: gate.where, cooldown: formatCooldown(cooldownSeconds), dormant: gate.dormant })
+ })
+ return out
+}
+
/**
* A `multiple` param's value as the list it is (MODULE_API 1.14.0, Rust
* PLAN_REDESIGNS §12.3).
@@ -427,7 +514,7 @@ function validate(raw, { knownActionIds = [] } = {}) {
if (!isPlainObject(raw)) return { ok: false, errors: ['spec: expected an object'] }
- const allowed = new Set(['schedule', 'phases'])
+ const allowed = new Set(['schedule', 'phases', 'starts'])
for (const key of Object.keys(raw)) {
if (!allowed.has(key)) {
errors.push(`spec: unknown key "${key}" (Phase 1 understands ${[...allowed].join(', ')})`)
@@ -452,6 +539,9 @@ function validate(raw, { knownActionIds = [] } = {}) {
schedule = validateSchedule(kind, rawSchedule, errors)
}
+ // ── starts (D370) ──
+ const starts = validateStarts(raw.starts, errors)
+
// ── phases ──
const rawPhases = raw.phases
const phases = []
@@ -605,7 +695,10 @@ function validate(raw, { knownActionIds = [] } = {}) {
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 } }
+ // `starts` is omitted when empty, for the reason a phase's `advance` is: a spec
+ // carrying `"starts": []` everywhere is a version diff that says every
+ // definition changed the first time one of them gained a trigger.
+ return { ok: true, spec: starts.length ? { schedule, starts, phases } : { schedule, phases } }
}
/** Every action id a spec names, dormant ones included. */
@@ -630,6 +723,9 @@ function publishable(spec) {
const dormant = [
...phases.flatMap((p) => (p.steps || []).filter((s) => s.dormant).map((s) => s.actionId)),
...phases.filter((p) => p.advance?.dormant).map((p) => p.advance.on),
+ // A start on a trigger nothing registers, on the same rule (D370): a version
+ // that starts on something nobody can emit is one that never starts.
+ ...(spec?.starts || []).filter((s) => s.dormant).map((s) => s.on),
]
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
}
@@ -651,7 +747,12 @@ module.exports = {
defaultOnFailure,
parseAfter,
formatAfter,
+ parseCooldown,
PHASE_KEY,
+ MAX_STARTS,
+ DEFAULT_COOLDOWN,
+ MAX_COOLDOWN_SECONDS,
+ CORE_EVENT_PREFIX,
SCHEDULE_KINDS,
ADVANCE_KINDS,
MAX_ADVANCE_COUNT,
diff --git a/server/src/events/starts.js b/server/src/events/starts.js
new file mode 100644
index 0000000..92a149f
--- /dev/null
+++ b/server/src/events/starts.js
@@ -0,0 +1,210 @@
+// ── Starting a run from a trigger: the emit path's third subscriber ────────
+//
+// Rust PLAN_REDESIGNS §14.3.12 (D370), and EVENTS.md §E "Starting a run from a
+// trigger". *"When anyone destroys Bradley on Main, start Launch Site
+// Lockdown"*; *"when a champion's boss appears, start Champion's Call"*. Before
+// this a trigger could only advance a phase of a run already going (`gates.js`)
+// or send a notice (`engine.dispatch`); now a definition's published version may
+// carry `starts`, and a firing that matches one creates a run of it, now.
+//
+// **Same seam and posture as the other two subscribers**: called from
+// `engagementEmit.emit`, never awaited, never allowed to reject. Whether some
+// event wanted to start is core's business, and a failure of core's must not
+// become the module's control flow.
+//
+// **Four rules decide whether a matching firing starts anything:**
+//
+// 1. The version must have passed its dry run. A trigger start is unattended,
+// as a scheduled one is, and §K's gate falls on the path where nobody is.
+// 2. One run at a time per definition. A firing while a run of it is going
+// starts nothing and is counted.
+// 3. A cooldown after the last trigger-started run (default 30 minutes, 0 to
+// 7 days). A firing inside it starts nothing and is counted. Rules 2 and 3
+// are what stop twelve road Bradleys dying in a minute starting twelve runs.
+// 4. Core's own `event.*` triggers never start a run (refused at save, and
+// skipped here as well), so a run starting can never start another.
+//
+// Rules 2 and 3 are ONE conditional UPDATE (`eventTriggerStarts.db.claim`); the
+// occurrence key `UNIQUE (definition_id, scope, scheduled_for)` with
+// `scheduled_for` = the firing's time is what makes one firing reported twice
+// (two instances ingesting one feed) create one run.
+
+const definitionsDb = require('../model/events/eventDefinitions.db')
+const startsDb = require('../model/events/eventTriggerStarts.db')
+const runsModel = require('../model/events/eventRuns.model')
+const conditions = require('../engagement/conditions')
+const registries = require('../modules/registries')
+const spec = require('./spec')
+const gates = require('./gates')
+const log = require('../utils/logger')('event-starts')
+
+// What of a firing's data is kept on the run. The run row is admin-only, and it
+// is the answer to "why did this run happen", so the trigger's DECLARED
+// variables are kept and nothing else a module happened to send. Bounded, so
+// one oversized payload cannot make the insert the thing that fails.
+const MAX_ORIGIN_BYTES = 4096
+
+/** The firing's time, in whole seconds (the column's precision), never in the future. */
+function firingTime(occurredAt, now) {
+ const parsed = occurredAt ? new Date(occurredAt) : now
+ const at = Number.isNaN(parsed.getTime()) || parsed > now ? now : parsed
+ return new Date(Math.floor(at.getTime() / 1000) * 1000)
+}
+
+const sameSecond = (a, b) =>
+ a && b && Math.floor(new Date(a).getTime() / 1000) === Math.floor(new Date(b).getTime() / 1000)
+
+/** The firing as the run will remember it. */
+function originOf(event, declaration, start, at) {
+ const declared = (declaration?.variables || []).map((v) => v.name)
+ const data = event.data || {}
+ let variables = Object.fromEntries(declared.filter((n) => n in data).map((n) => [n, data[n]]))
+ if (Buffer.byteLength(JSON.stringify(variables), 'utf8') > MAX_ORIGIN_BYTES) {
+ // Too big to keep whole: keep the ones the start's condition named, which
+ // are the reason it matched, and say the rest were dropped.
+ const named = gates.variablesIn(start.where)
+ variables = Object.fromEntries(named.filter((n) => n in data).map((n) => [n, data[n]]))
+ variables._truncated = true
+ }
+ return {
+ kind: 'trigger',
+ triggerId: event.triggerId,
+ label: declaration?.label || event.triggerId,
+ occurredAt: at.toISOString(),
+ subject: event.subject ?? null,
+ // The start's condition in the builder's own words, so the console can say
+ // what it matched on without re-reading the version.
+ matched: gates.phrase(start.where),
+ variables,
+ }
+}
+
+/**
+ * Why a firing that matched did not get the claim. A duplicate of the firing
+ * that started the last run is not counted: it is the same thing happening once,
+ * reported twice.
+ */
+async function whyNot(definitionId, at, now) {
+ const state = await startsDb.forDefinition(definitionId)
+ if (state && sameSecond(state.last_started_at, at)) return 'duplicate'
+ if (await startsDb.isGoing(definitionId, now)) return 'running'
+ // A live claim is another firing starting it this instant: the run is going in
+ // every sense but the row.
+ if (state?.claimed_at && now - new Date(state.claimed_at) < startsDb.CLAIM_STALE_MS) return 'running'
+ return 'cooldown'
+}
+
+/** One definition, one firing. Answers what happened, for the summary and the tests. */
+async function consider(definition, event, at, now) {
+ const starts = (definition.version_spec?.starts || []).filter((s) => s.on === event.triggerId && !s.dormant)
+ const start = starts.find((s) => conditions.evaluate(s.where, event.data || {}))
+ if (!start) return 'unmatched'
+
+ if (now - at > Number(definition.grace_seconds || 0) * 1000) {
+ // A firing older than the grace window would make a run the missed sweep
+ // takes on its next tick. Said, and not started.
+ log.warn('a firing too old to start a run', { definition: definition.id, trigger: event.triggerId, at })
+ return 'stale'
+ }
+
+ if (!definition.version_verified_at) {
+ await startsDb.ignore(definition.id, 'unverified', now)
+ log.warn('trigger start held: the published version has never been verified', {
+ definition: definition.id,
+ title: definition.title,
+ trigger: event.triggerId,
+ })
+ return 'unverified'
+ }
+
+ const cooldownSeconds = spec.parseCooldown(start.cooldown ?? spec.DEFAULT_COOLDOWN) ?? 0
+ if (!(await startsDb.claim(definition.id, { at, cooldownSeconds, now }))) {
+ const reason = await whyNot(definition.id, at, now)
+ if (reason !== 'duplicate') await startsDb.ignore(definition.id, reason, now)
+ return reason
+ }
+
+ const declaration = registries.eventTrigger(event.triggerId)
+ let result
+ try {
+ result = await runsModel.create(
+ definition.id,
+ // Scope is empty, as a scheduled occurrence's is (org lead, 2026-09-02): a
+ // scope registry does not exist, and the condition is what narrows a start
+ // to one server.
+ { scope: '', scheduledFor: at, source: 'trigger', origin: originOf(event, declaration, start, at) },
+ null,
+ )
+ } catch (err) {
+ await startsDb.release(definition.id)
+ throw err
+ }
+
+ if (!result.ok) {
+ await startsDb.release(definition.id)
+ if (result.code === 'unverified') await startsDb.ignore(definition.id, 'unverified', now)
+ log.warn('trigger start refused', { definition: definition.id, errors: result.errors })
+ return 'refused'
+ }
+
+ if (!result.created) {
+ // The occurrence already existed: this firing was reported twice, and the
+ // claimant that made the run has recorded it. Nothing to count.
+ await startsDb.release(definition.id)
+ return 'duplicate'
+ }
+ await startsDb.started(definition.id, { at, runId: result.run.id })
+ log.info('run started by a trigger', { definition: definition.id, run: result.run.id, trigger: event.triggerId })
+ return 'started'
+}
+
+/**
+ * The trigger stream's third subscriber.
+ *
+ * The common answer is one indexed-by-nothing query over the ready definitions
+ * returning no rows: a deployment has tens of definitions, not thousands, and a
+ * JSON column cannot carry an index. If that ever changes, a side table of
+ * (trigger, definition) written at publish is the fix.
+ */
+async function observe(event, { now = new Date() } = {}) {
+ const summary = { definitions: 0, started: 0, ignored: 0 }
+ try {
+ if (!event?.triggerId || event.triggerId.startsWith(spec.CORE_EVENT_PREFIX)) return summary
+ const candidates = await definitionsDb.findStartingOn(event.triggerId)
+ summary.definitions = candidates.length
+ if (!candidates.length) return summary
+
+ const at = firingTime(event.occurredAt, now)
+ for (const definition of candidates) {
+ try {
+ const outcome = await consider(definition, event, at, now)
+ if (outcome === 'started') summary.started += 1
+ if (['running', 'cooldown', 'unverified'].includes(outcome)) summary.ignored += 1
+ } catch (err) {
+ log.error('trigger start failed', { definition: definition.id, trigger: event.triggerId, message: err.message })
+ }
+ }
+ } catch (err) {
+ log.error('trigger start observation failed', { trigger: event?.triggerId, message: err.message })
+ }
+ return summary
+}
+
+/**
+ * The editor's line about a definition's trigger starts, from the state row.
+ * Null when no trigger has ever tried to start it.
+ */
+function describeState(row) {
+ if (!row) return null
+ return {
+ lastStartedAt: row.last_started_at || null,
+ lastRunId: row.last_run_id || null,
+ ignoredRunning: Number(row.ignored_running || 0),
+ ignoredCooldown: Number(row.ignored_cooldown || 0),
+ ignoredUnverified: Number(row.ignored_unverified || 0),
+ lastIgnoredAt: row.last_ignored_at || null,
+ lastIgnoredReason: row.last_ignored_reason || null,
+ }
+}
+
+module.exports = { observe, consider, describeState, firingTime, originOf, MAX_ORIGIN_BYTES }
diff --git a/server/src/model/events/eventDefinitions.db.js b/server/src/model/events/eventDefinitions.db.js
index 8342bed..b906f0e 100644
--- a/server/src/model/events/eventDefinitions.db.js
+++ b/server/src/model/events/eventDefinitions.db.js
@@ -159,6 +159,30 @@ const findSchedulable = async ({ listedOnly = false } = {}) => {
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
}
+/**
+ * Every `ready` definition whose PUBLISHED version starts on this trigger
+ * (Rust PLAN_REDESIGNS §14.3.12, D370). The emit path's question, asked on every
+ * firing, so it answers in one query and the common answer is no rows.
+ *
+ * Read from the version, never the working copy, for the reason the schedule
+ * expansion is: a start an author is midway through typing must not fire.
+ * `JSON_EXTRACT` over `$.starts[*].on` is an array, or NULL for a version with
+ * no starts, and `JSON_CONTAINS` of NULL is not true.
+ */
+const findStartingOn = async (triggerId) => {
+ const rows = await query(
+ `SELECT d.id, d.title, d.grace_seconds, d.current_version_id,
+ v.spec AS version_spec, v.verified_at AS version_verified_at
+ FROM event_definitions d
+ JOIN event_versions v ON v.id = d.current_version_id
+ WHERE d.state = 'ready'
+ AND JSON_CONTAINS(JSON_EXTRACT(v.spec, '$.starts[*].on'), JSON_QUOTE(?))
+ ORDER BY d.id`,
+ [triggerId],
+ )
+ return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
+}
+
/**
* One definition by slug, for the PUBLIC event page (Phase 14a).
*
@@ -206,6 +230,7 @@ module.exports = {
listPublicBySeries,
slugTaken,
findSchedulable,
+ findStartingOn,
insert,
update,
markReady,
diff --git a/server/src/model/events/eventRuns.db.js b/server/src/model/events/eventRuns.db.js
index e333dfb..a790070 100644
--- a/server/src/model/events/eventRuns.db.js
+++ b/server/src/model/events/eventRuns.db.js
@@ -15,7 +15,13 @@
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
-const hydrate = (row) => row && { ...row, params: parseJson(row.params, null), rehearsal: Boolean(row.rehearsal) }
+const hydrate = (row) =>
+ row && {
+ ...row,
+ params: parseJson(row.params, null),
+ origin: parseJson(row.origin, null),
+ rehearsal: Boolean(row.rehearsal),
+ }
// The statuses a run never leaves. A transition INTO one of these stamps
// `ended_at` and drops the claim, and only rows in one of them are eligible for
@@ -90,8 +96,8 @@ const materialise = async (run) => {
const result = await query(
`INSERT IGNORE INTO event_runs
(definition_id, version_id, scope, scheduled_for, timezone, concurrency_key,
- params, rehearsal, started_by)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ params, rehearsal, started_by, origin)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
run.definition_id,
run.version_id,
@@ -102,6 +108,8 @@ const materialise = async (run) => {
run.params === null || run.params === undefined ? null : JSON.stringify(run.params),
run.rehearsal ? 1 : 0,
run.started_by,
+ // Why the run exists when a trigger started it (D370); NULL otherwise.
+ run.origin ? JSON.stringify(run.origin) : null,
],
)
return Number(result?.affectedRows || 0) === 1 ? result.insertId : null
diff --git a/server/src/model/events/eventRuns.model.js b/server/src/model/events/eventRuns.model.js
index 9a11371..1af3751 100644
--- a/server/src/model/events/eventRuns.model.js
+++ b/server/src/model/events/eventRuns.model.js
@@ -69,7 +69,7 @@ function renderConcurrencyKey(template, params) {
*/
async function create(
definitionId,
- { scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {},
+ { scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual', origin = null } = {},
userId,
) {
const definition = await definitionsDb.getById(definitionId)
@@ -97,7 +97,10 @@ async function create(
// 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) {
+ //
+ // **A trigger start is unattended too** (D370): a boss dying at four in the
+ // morning starts the run with nobody watching, exactly as a schedule does.
+ if ((source === 'schedule' || source === 'trigger') && !version.verified_at) {
return {
ok: false,
status: 409,
@@ -124,6 +127,7 @@ async function create(
params,
rehearsal,
started_by: userId,
+ origin,
})
if (runId === null) {
@@ -146,8 +150,11 @@ async function create(
// 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.
+ // 'trigger' is a game firing (D370), and it is the one source that also
+ // says WHAT: the origin goes into the audit line as well as on the row.
source,
by: userId,
+ ...(origin ? { origin } : {}),
},
})
diff --git a/server/src/model/events/eventTriggerStarts.db.js b/server/src/model/events/eventTriggerStarts.db.js
new file mode 100644
index 0000000..09fb114
--- /dev/null
+++ b/server/src/model/events/eventTriggerStarts.db.js
@@ -0,0 +1,106 @@
+// ── event_trigger_starts — SQL only ────────────────────────────────────────
+//
+// Rust PLAN_REDESIGNS §14.3.12 (D370), and EVENTS.md §E "Starting a run from a
+// trigger". One row per definition a trigger has tried to start. `events/starts.js`
+// decides; this file holds the claim and the counts.
+//
+// **The claim is one statement with every rule inside it.** A firing may start a
+// run only when no claim is live, the last trigger-started run is out of its
+// cooldown, and no run of the definition is going. Read-then-write would let two
+// firings arriving together each see a quiet definition and both start, which is
+// exactly the twelve-Bradleys case the rule exists for.
+
+const { query } = require('../../utils/db')
+
+// A claim older than this belongs to a process that died between the claim and
+// the run row existing. A minute is far longer than an insert takes.
+const CLAIM_STALE_MS = 60_000
+
+// What "a run is going" means for the one-at-a-time rule: started and not ended,
+// or scheduled and already due — a trigger-started run is `scheduled` for up to
+// one tick before the runner claims it, and it is going from the moment it exists.
+const GOING_SQL = `
+ SELECT 1 FROM event_runs r
+ WHERE r.definition_id = t.definition_id
+ AND (r.status IN ('starting','running','paused','ending')
+ OR (r.status = 'scheduled' AND r.scheduled_for <= ?))`
+
+const REASONS = ['running', 'cooldown', 'unverified']
+
+/** The row, or null when no trigger has ever tried to start this definition. */
+const forDefinition = async (definitionId) => {
+ const [row] = await query('SELECT * FROM event_trigger_starts WHERE definition_id = ?', [definitionId])
+ return row || null
+}
+
+/**
+ * Try to own the start. `true` when this caller may create the run.
+ *
+ * `at` is the firing's time (whole seconds), `cooldownSeconds` the matching
+ * start's own. The row is made first with `INSERT IGNORE`, so the UPDATE always
+ * has something to guard.
+ */
+async function claim(definitionId, { at, cooldownSeconds, now }) {
+ await query('INSERT IGNORE INTO event_trigger_starts (definition_id) VALUES (?)', [definitionId])
+ const result = await query(
+ `UPDATE event_trigger_starts t
+ SET t.claimed_at = ?
+ WHERE t.definition_id = ?
+ AND (t.claimed_at IS NULL OR t.claimed_at < ?)
+ AND (t.last_started_at IS NULL OR t.last_started_at <= ?)
+ AND NOT EXISTS (${GOING_SQL})`,
+ [
+ now,
+ definitionId,
+ new Date(now.getTime() - CLAIM_STALE_MS),
+ new Date(at.getTime() - cooldownSeconds * 1000),
+ now,
+ ],
+ )
+ return Number(result?.affectedRows || 0) === 1
+}
+
+/** The run exists: record it, drop the claim, and start the counts again. */
+const started = (definitionId, { at, runId }) =>
+ query(
+ `UPDATE event_trigger_starts
+ SET claimed_at = NULL, last_started_at = ?, last_run_id = ?,
+ ignored_running = 0, ignored_cooldown = 0, ignored_unverified = 0,
+ last_ignored_at = NULL, last_ignored_reason = NULL
+ WHERE definition_id = ?`,
+ [at, runId, definitionId],
+ )
+
+/** The claim came to nothing (the create refused): give it back. */
+const release = (definitionId) =>
+ query('UPDATE event_trigger_starts SET claimed_at = NULL WHERE definition_id = ?', [definitionId])
+
+/**
+ * Count a firing that started nothing, by why. `reason` is one of REASONS and
+ * names its own column, so it is checked against the list before it reaches SQL.
+ */
+async function ignore(definitionId, reason, now) {
+ if (!REASONS.includes(reason)) throw new Error(`unknown ignore reason "${reason}"`)
+ await query('INSERT IGNORE INTO event_trigger_starts (definition_id) VALUES (?)', [definitionId])
+ await query(
+ `UPDATE event_trigger_starts
+ SET ignored_${reason} = ignored_${reason} + 1, last_ignored_at = ?, last_ignored_reason = ?
+ WHERE definition_id = ?`,
+ [now, reason, definitionId],
+ )
+}
+
+/** Is a run of this definition going right now? The ignore path's "why". */
+const isGoing = async (definitionId, now) => {
+ const rows = await query(
+ `SELECT 1 FROM event_runs r
+ WHERE r.definition_id = ?
+ AND (r.status IN ('starting','running','paused','ending')
+ OR (r.status = 'scheduled' AND r.scheduled_for <= ?))
+ LIMIT 1`,
+ [definitionId, now],
+ )
+ return rows.length > 0
+}
+
+module.exports = { forDefinition, claim, started, release, ignore, isGoing, REASONS, CLAIM_STALE_MS }
diff --git a/server/src/router/v1/admin/events.controller.js b/server/src/router/v1/admin/events.controller.js
index 872a36a..232fdae 100644
--- a/server/src/router/v1/admin/events.controller.js
+++ b/server/src/router/v1/admin/events.controller.js
@@ -34,6 +34,8 @@ const activity = require('../../../model/activity/activity.model')
const settingsDb = require('../../../model/events/eventActionSettings.db')
const authorize = require('../../../events/authorize')
const price = require('../../../events/price')
+const starts = require('../../../events/starts')
+const triggerStartsDb = require('../../../model/events/eventTriggerStarts.db')
const asId = (raw) => {
const n = Number(raw)
@@ -86,6 +88,9 @@ const shapeDefinition = (d) => ({
const shapeEditable = async (row) => ({
...shapeDefinition(row),
draftAhead: await definitions.draftAhead(row),
+ // What its trigger starts have done lately (D370): the last run one started
+ // and the firings ignored since, by why. Null until a trigger first tries.
+ triggerStarts: starts.describeState(await triggerStartsDb.forDefinition(row.id)),
})
const shapeRun = (r) => ({
@@ -105,6 +110,10 @@ const shapeRun = (r) => ({
concurrencyKey: r.concurrency_key,
params: r.params,
rehearsal: r.rehearsal,
+ // What started it, when a game firing did (D370): the trigger, when, its
+ // subject, the condition it matched and its declared variables. Null for a
+ // run started by hand or by the schedule.
+ origin: r.origin || null,
startedAt: r.started_at,
endedAt: r.ended_at,
// When the results table was ranked and published (Phase 10). On the LIST as
@@ -223,6 +232,9 @@ exports.catalog = (_req, res) => {
defaultBudgetMs: registries.DEFAULT_BUDGET_MS,
maxAdvanceCount: spec.MAX_ADVANCE_COUNT,
maxAfterSeconds: spec.MAX_AFTER_SECONDS,
+ // A definition's `starts` (D370): how many, and the cooldown's ceiling.
+ maxStarts: spec.MAX_STARTS,
+ maxCooldownSeconds: spec.MAX_COOLDOWN_SECONDS,
},
})
}
diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js
index e84bd5d..e1320e8 100644
--- a/server/src/router/v1/admin/events.router.js
+++ b/server/src/router/v1/admin/events.router.js
@@ -261,7 +261,7 @@ eventsRouter.get(
'/runs/:runId',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
- // #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
+ // #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published, and `origin` when a game firing started it (Rust PLAN_REDESIGNS 14.3.12): the trigger, its label, when it fired, its subject, the start condition it matched in the builder words, and its declared variables. Null for a run started by hand or by the schedule.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -439,7 +439,7 @@ eventsRouter.get(
'/:id',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'One event definition, including its working spec'
- // #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.'
+ // #swagger.description = 'The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included. The spec may carry `starts` (Rust PLAN_REDESIGNS 14.3.12): the triggers that begin a run of the published version, each a phase gate `on` and `where` plus a `cooldown` (0m to 7d, 30m when left out); core own event.* triggers are refused. `triggerStarts` is what they have done lately: the last run a trigger started and the firings ignored since, by why (a run was going, inside the cooldown, or the published version is not verified). Null until a trigger first tries.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The definition', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true } } } } } } */
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
diff --git a/server/src/utils/engagementEmit.js b/server/src/utils/engagementEmit.js
index c716981..a3253ae 100644
--- a/server/src/utils/engagementEmit.js
+++ b/server/src/utils/engagementEmit.js
@@ -24,6 +24,7 @@ const registries = require('../modules/registries')
const ceilings = require('../modules/ceilings')
const engine = require('../engagement/engine')
const eventGates = require('../events/gates')
+const eventStarts = require('../events/starts')
const scopedPrefs = require('../engagement/scopedPrefs')
const createLogger = require('./logger')
@@ -320,6 +321,15 @@ function emit(owner, triggerId, envelope = {}) {
.observe(event)
.catch((err) => log.error('gate observation rejected', { trigger: triggerId, message: err.message }))
+ // **The third subscriber** (Rust PLAN_REDESIGNS §14.3.12, D370): a definition
+ // whose published version starts on this trigger gets a run, now. Separate
+ // from the gate observer for the same reason that one is separate from
+ // `dispatch` — starting a run and advancing one decide different things, and
+ // neither may fail the other.
+ eventStarts
+ .observe(event)
+ .catch((err) => log.error('trigger start rejected', { trigger: triggerId, message: err.message }))
+
return { ok: true, event }
}
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index ce9ae0a..9befb3e 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -4422,7 +4422,7 @@
"Admin · Events"
],
"summary": "One run: its status, health, cleanup state and every step with its params and idempotency key",
- "description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.",
+ "description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published, and `origin` when a game firing started it (Rust PLAN_REDESIGNS 14.3.12): the trigger, its label, when it fired, its subject, the start condition it matched in the builder words, and its declared variables. Null for a run started by hand or by the schedule.",
"parameters": [
{
"name": "runId",
@@ -5703,7 +5703,7 @@
"Admin · Events"
],
"summary": "One event definition, including its working spec",
- "description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.",
+ "description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included. The spec may carry `starts` (Rust PLAN_REDESIGNS 14.3.12): the triggers that begin a run of the published version, each a phase gate `on` and `where` plus a `cooldown` (0m to 7d, 30m when left out); core own event.* triggers are refused. `triggerStarts` is what they have done lately: the last run a trigger started and the firings ignored since, by why (a run was going, inside the cooldown, or the published version is not verified). Null until a trigger first tries.",
"parameters": [
{
"name": "id",
diff --git a/server/test/eventRunnerSql.test.js b/server/test/eventRunnerSql.test.js
index 9cb4cfa..c2685e4 100644
--- a/server/test/eventRunnerSql.test.js
+++ b/server/test/eventRunnerSql.test.js
@@ -136,7 +136,8 @@ CREATE TABLE event_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
definition_id INT NOT NULL,
version INT NOT NULL DEFAULT 1,
- spec JSON NOT NULL
+ spec JSON NOT NULL,
+ verified_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE event_runs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -149,11 +150,16 @@ CREATE TABLE event_runs (
health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok',
current_phase VARCHAR(64) NULL,
scheduled_for DATETIME NOT NULL,
+ timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
concurrency_key VARCHAR(190) NULL,
+ params JSON NULL,
+ rehearsal TINYINT(1) NOT NULL DEFAULT 0,
started_at DATETIME NULL,
ended_at DATETIME NULL,
claimed_by VARCHAR(64) NULL,
claim_expires_at DATETIME NULL,
+ started_by INT NULL,
+ origin JSON NULL,
last_error VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -250,6 +256,18 @@ CREATE TABLE event_action_settings (
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+CREATE TABLE event_trigger_starts (
+ definition_id INT NOT NULL PRIMARY KEY,
+ claimed_at DATETIME NULL,
+ last_started_at DATETIME NULL,
+ last_run_id BIGINT NULL,
+ ignored_running INT NOT NULL DEFAULT 0,
+ ignored_cooldown INT NOT NULL DEFAULT 0,
+ ignored_unverified INT NOT NULL DEFAULT 0,
+ last_ignored_at DATETIME NULL,
+ last_ignored_reason VARCHAR(32) NULL,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`
// The statements under test, verbatim from `eventRuns.db.js` and
@@ -1745,3 +1763,131 @@ test('setCleanupStatus is guarded, which is what stops a late row re-opening a s
assert.equal(await runsDb.setCleanupStatus(a.runId, 'pending', ['not_required']), false)
assert.equal(await runsDb.setCleanupStatus(a.runId, 'pending'), true)
})
+
+// ── A trigger can start a run (Rust PLAN_REDESIGNS §14.3.12, D370) ─────────
+//
+// `eventStarts.test.js` proves what `starts.observe()` DECIDES against a stand-in
+// whose claim keeps the rules. These prove the rules are what the server does:
+// the JSON lookup over the published version, and the claim — one winner, the
+// cooldown and the one-at-a-time check all inside one WHERE.
+
+const definitionsDb = require('../src/model/events/eventDefinitions.db')
+const triggerStartsDb = require('../src/model/events/eventTriggerStarts.db')
+
+const startsOn = (on, extra = {}) => ({
+ schedule: { kind: 'manual' },
+ starts: [{ on, where: null, cooldown: '30m', dormant: false, ...extra }],
+ phases: [{ key: 'main', label: 'Main', steps: [] }],
+})
+
+test('findStartingOn reads the PUBLISHED version of ready definitions, and nothing else', async (t) => {
+ if (needDb(t)) return
+ const hit = await seedDefinition({ spec: startsOn('demo.bradley.destroyed') })
+ await seedDefinition({ state: 'draft', spec: startsOn('demo.bradley.destroyed') })
+ await seedDefinition({ state: 'archived', spec: startsOn('demo.bradley.destroyed') })
+ await seedDefinition({ spec: startsOn('demo.heli.downed') })
+ await seedDefinition({ spec: { schedule: { kind: 'manual' } } })
+ // Two starts, the trigger second: `$.starts[*].on` is every entry, not the first.
+ const second = await seedDefinition({
+ spec: { ...startsOn('demo.heli.downed'), starts: [{ on: 'demo.heli.downed' }, { on: 'demo.bradley.destroyed' }] },
+ })
+ await pool.query('UPDATE event_versions SET verified_at = ? WHERE id = ?', [T0, hit.versionId])
+
+ const found = await definitionsDb.findStartingOn('demo.bradley.destroyed')
+ assert.deepEqual(found.map((d) => Number(d.id)), [Number(hit.definitionId), Number(second.definitionId)])
+ assert.equal(found[0].version_spec.starts[0].on, 'demo.bradley.destroyed', 'the spec comes back parsed')
+ assert.ok(found[0].version_verified_at, 'and whether it may start unattended')
+ assert.equal(found[1].version_verified_at, null)
+ // A substring is not a trigger.
+ assert.equal((await definitionsDb.findStartingOn('demo.bradley')).length, 0)
+})
+
+test('two firings claiming at once: exactly one owns the start', async (t) => {
+ if (needDb(t)) return
+ const def = await seedDefinition({ spec: startsOn('demo.x', { cooldown: '0m' }) })
+ const results = await Promise.all(
+ Array.from({ length: 5 }, (_, i) =>
+ triggerStartsDb.claim(def.definitionId, { at: later(i * 1000), cooldownSeconds: 0, now: later(10_000) }),
+ ),
+ )
+ assert.equal(results.filter(Boolean).length, 1)
+})
+
+test('the cooldown is measured from the last trigger-started run', async (t) => {
+ if (needDb(t)) return
+ const def = await seedDefinition({ spec: startsOn('demo.x') })
+ const id = def.definitionId
+ assert.equal(await triggerStartsDb.claim(id, { at: T0, cooldownSeconds: 1800, now: T0 }), true)
+ await triggerStartsDb.started(id, { at: T0, runId: 99 })
+
+ assert.equal(await triggerStartsDb.claim(id, { at: later(10 * 60_000), cooldownSeconds: 1800, now: later(10 * 60_000) }), false)
+ // Exactly at the boundary the cooldown has passed.
+ assert.equal(await triggerStartsDb.claim(id, { at: later(30 * 60_000), cooldownSeconds: 1800, now: later(30 * 60_000) }), true)
+})
+
+test('a going run blocks the claim; a future occurrence does not', async (t) => {
+ if (needDb(t)) return
+ const def = await seedDefinition({ spec: startsOn('demo.x', { cooldown: '0m' }) })
+ const id = def.definitionId
+ const run = await pool.query(
+ 'INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for) VALUES (?, ?, ?, ?, ?)',
+ [id, def.versionId, '', 'scheduled', later(24 * 60 * 60_000)],
+ )
+ assert.equal(await triggerStartsDb.isGoing(id, T0), false, 'tomorrow\'s occurrence is not going')
+ assert.equal(await triggerStartsDb.claim(id, { at: T0, cooldownSeconds: 0, now: T0 }), true)
+ await triggerStartsDb.release(id)
+
+ for (const [status, when] of [['running', T0], ['paused', T0], ['scheduled', later(-1000)]]) {
+ await pool.query('UPDATE event_runs SET status = ?, scheduled_for = ? WHERE id = ?', [status, when, run.insertId])
+ assert.equal(await triggerStartsDb.isGoing(id, T0), true, status)
+ assert.equal(await triggerStartsDb.claim(id, { at: T0, cooldownSeconds: 0, now: T0 }), false, status)
+ }
+ await pool.query("UPDATE event_runs SET status = 'completed' WHERE id = ?", [run.insertId])
+ assert.equal(await triggerStartsDb.claim(id, { at: T0, cooldownSeconds: 0, now: T0 }), true)
+})
+
+test('a claim held past a minute belongs to a dead process and can be taken', async (t) => {
+ if (needDb(t)) return
+ const def = await seedDefinition({ spec: startsOn('demo.x', { cooldown: '0m' }) })
+ const id = def.definitionId
+ assert.equal(await triggerStartsDb.claim(id, { at: T0, cooldownSeconds: 0, now: T0 }), true)
+ assert.equal(await triggerStartsDb.claim(id, { at: later(30_000), cooldownSeconds: 0, now: later(30_000) }), false)
+ assert.equal(await triggerStartsDb.claim(id, { at: later(61_000), cooldownSeconds: 0, now: later(61_000) }), true)
+})
+
+test('ignore counts by reason, and a start zeroes the counts', async (t) => {
+ if (needDb(t)) return
+ const def = await seedDefinition({ spec: startsOn('demo.x') })
+ const id = def.definitionId
+ await triggerStartsDb.ignore(id, 'running', T0)
+ await triggerStartsDb.ignore(id, 'running', T0)
+ await triggerStartsDb.ignore(id, 'cooldown', later(1000))
+ let row = await triggerStartsDb.forDefinition(id)
+ assert.equal(row.ignored_running, 2)
+ assert.equal(row.ignored_cooldown, 1)
+ assert.equal(row.last_ignored_reason, 'cooldown')
+ await assert.rejects(() => triggerStartsDb.ignore(id, 'bogus', T0), /unknown ignore reason/)
+
+ await triggerStartsDb.started(id, { at: later(2000), runId: 5 })
+ row = await triggerStartsDb.forDefinition(id)
+ assert.equal(row.ignored_running, 0)
+ assert.equal(Number(row.last_run_id), 5)
+ assert.equal(row.claimed_at, null)
+})
+
+test('a run carries its origin through the insert and back', async (t) => {
+ if (needDb(t)) return
+ const def = await seedDefinition()
+ const origin = { kind: 'trigger', triggerId: 'demo.x', variables: { grid: 'G8' } }
+ const runId = await runsDb.materialise({
+ definition_id: def.definitionId,
+ version_id: def.versionId,
+ scope: '',
+ scheduled_for: T0,
+ concurrency_key: null,
+ params: null,
+ started_by: null,
+ origin,
+ })
+ assert.deepEqual((await runsDb.getById(runId)).origin, origin)
+})
diff --git a/server/test/eventStarts.test.js b/server/test/eventStarts.test.js
new file mode 100644
index 0000000..a92b353
--- /dev/null
+++ b/server/test/eventStarts.test.js
@@ -0,0 +1,371 @@
+// ── Starting a run from a trigger (Rust PLAN_REDESIGNS §14.3.12, D370) ─────
+//
+// Two halves. The SPEC half: a definition's `starts` is validated the way a
+// phase gate is (one grammar, one dormant rule), with a cooldown, and core's own
+// `event.*` triggers refused. The EMIT half: `events/starts.observe()` decides
+// whether a firing starts a run, and this file proves what it decides against an
+// in-memory stand-in whose claim keeps the SQL's rules. The SQL itself — one
+// winner, the cooldown and the one-at-a-time check in one WHERE — is proved
+// against a real server in `eventRunnerSql.test.js`.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const { test, beforeEach, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+const registries = require('../src/modules/registries')
+const spec = require('../src/events/spec')
+const starts = require('../src/events/starts')
+const definitionsDb = require('../src/model/events/eventDefinitions.db')
+const startsDb = require('../src/model/events/eventTriggerStarts.db')
+const runsModel = require('../src/model/events/eventRuns.model')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const TRIGGER = 'demo.bradley.destroyed'
+
+beforeEach(() => {
+ registries._reset()
+ registries.registerCore()
+ const api = registries.stage('demo')
+ api.registerEventTriggers([
+ {
+ id: TRIGGER,
+ label: 'Bradley destroyed',
+ ceiling: 'everyone',
+ variables: [
+ { name: 'server', type: 'string', required: true, example: 'main', description: 'The server.' },
+ { name: 'grid', type: 'string', required: false, example: 'G8', description: 'Where.' },
+ { name: 'kills', type: 'int', required: false, example: 1, description: 'How many.' },
+ ],
+ },
+ ])
+ registries.apply(api.staged)
+})
+afterEach(() => registries._reset())
+
+const withStarts = (startsValue) => ({
+ schedule: { kind: 'manual' },
+ starts: startsValue,
+ phases: [{ key: 'main', label: 'Main', steps: [] }],
+})
+
+// ── The spec half ──────────────────────────────────────────────────────────
+
+test('a start is a gate\'s trigger and condition plus a cooldown, normalised', () => {
+ const result = spec.validate(
+ withStarts([{ on: TRIGGER, where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '120m' }]),
+ )
+ assert.equal(result.ok, true, JSON.stringify(result.errors))
+ assert.deepEqual(result.spec.starts, [
+ { on: TRIGGER, where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '2h', dormant: false },
+ ])
+})
+
+test('the cooldown defaults to thirty minutes, and zero is a real choice', () => {
+ const a = spec.validate(withStarts([{ on: TRIGGER }]))
+ assert.equal(a.spec.starts[0].cooldown, '30m')
+ assert.equal(a.spec.starts[0].where, null)
+ const b = spec.validate(withStarts([{ on: TRIGGER, cooldown: 0 }]))
+ assert.equal(b.spec.starts[0].cooldown, '0m')
+ assert.equal(spec.parseCooldown('0m'), 0)
+})
+
+test('a cooldown past seven days, or not a duration, is refused by name', () => {
+ for (const cooldown of ['8d', '1h30m', 'soon', -5]) {
+ const result = spec.validate(withStarts([{ on: TRIGGER, cooldown }]))
+ assert.equal(result.ok, false, String(cooldown))
+ assert.match(result.errors[0], /^spec\.starts\[0\]\.cooldown:/)
+ }
+ assert.equal(spec.validate(withStarts([{ on: TRIGGER, cooldown: '7d' }])).ok, true)
+})
+
+test('core\'s own event.* triggers cannot start a run', () => {
+ const result = spec.validate(withStarts([{ on: 'event.run.started' }]))
+ assert.equal(result.ok, false)
+ assert.match(result.errors[0], /event system's own triggers/)
+})
+
+test('the condition is checked against the trigger\'s declaration, path and all', () => {
+ const result = spec.validate(withStarts([{ on: TRIGGER, where: { variable: 'sever', cmp: 'eq', value: 'main' } }]))
+ assert.equal(result.ok, false)
+ assert.ok(result.errors.some((e) => e.startsWith('spec.starts[0].where') && e.includes('sever')), result.errors.join('\n'))
+})
+
+test('a start on a trigger nobody registers saves dormant and refuses to publish', () => {
+ const result = spec.validate(withStarts([{ on: 'gone.trigger', where: { variable: 'x', cmp: 'eq', value: 1 } }]))
+ assert.equal(result.ok, true)
+ assert.equal(result.spec.starts[0].dormant, true)
+ assert.deepEqual(result.spec.starts[0].where, { variable: 'x', cmp: 'eq', value: 1 }, 'kept, not dropped')
+ assert.deepEqual(spec.publishable(result.spec), { ok: false, dormant: ['gone.trigger'] })
+})
+
+test('validate accepts its own output, and an empty list is not written', () => {
+ const first = spec.validate(withStarts([{ on: TRIGGER, cooldown: '45m' }]))
+ const second = spec.validate(first.spec)
+ assert.deepEqual(second.spec, first.spec)
+ assert.equal('starts' in spec.validate(withStarts([])).spec, false)
+})
+
+test('bad shapes: not a list, too many, unknown keys', () => {
+ assert.match(spec.validate(withStarts({ on: TRIGGER })).errors[0], /spec\.starts: expected an array/)
+ const many = Array.from({ length: spec.MAX_STARTS + 1 }, () => ({ on: TRIGGER }))
+ assert.match(spec.validate(withStarts(many)).errors[0], /at most/)
+ assert.match(spec.validate(withStarts([{ on: TRIGGER, count: 2 }])).errors[0], /unknown key\(s\) count/)
+})
+
+// ── The emit half ──────────────────────────────────────────────────────────
+
+const T0 = new Date('2026-10-16T20:00:00Z')
+
+let store
+const originals = {
+ definitionsDb: { ...definitionsDb },
+ startsDb: { ...startsDb },
+ runsModel: { ...runsModel },
+}
+
+// A run counts as going while it is not terminal — the SQL's own reading, with
+// "scheduled and due" folded in because a trigger-started run is due at once.
+const GOING = ['scheduled', 'starting', 'running', 'paused', 'ending']
+
+function installStubs({ definitions }) {
+ store = { definitions, runs: [], state: new Map(), creates: [], nextRun: 1 }
+ definitionsDb.findStartingOn = async (triggerId) =>
+ store.definitions.filter((d) => (d.version_spec.starts || []).some((s) => s.on === triggerId))
+ const stateOf = (id) => {
+ if (!store.state.has(id)) {
+ store.state.set(id, {
+ claimed_at: null,
+ last_started_at: null,
+ last_run_id: null,
+ ignored_running: 0,
+ ignored_cooldown: 0,
+ ignored_unverified: 0,
+ last_ignored_reason: null,
+ })
+ }
+ return store.state.get(id)
+ }
+ const going = (id) => store.runs.some((r) => r.definition_id === id && GOING.includes(r.status))
+ startsDb.forDefinition = async (id) => store.state.get(id) || null
+ startsDb.isGoing = async (id) => going(id)
+ startsDb.claim = async (id, { at, cooldownSeconds, now }) => {
+ const s = stateOf(id)
+ if (s.claimed_at && now - s.claimed_at < startsDb.CLAIM_STALE_MS) return false
+ if (s.last_started_at && s.last_started_at > new Date(at.getTime() - cooldownSeconds * 1000)) return false
+ if (going(id)) return false
+ s.claimed_at = now
+ return true
+ }
+ startsDb.started = async (id, { at, runId }) => {
+ Object.assign(stateOf(id), {
+ claimed_at: null,
+ last_started_at: at,
+ last_run_id: runId,
+ ignored_running: 0,
+ ignored_cooldown: 0,
+ ignored_unverified: 0,
+ })
+ }
+ startsDb.release = async (id) => {
+ stateOf(id).claimed_at = null
+ }
+ startsDb.ignore = async (id, reason) => {
+ const s = stateOf(id)
+ s[`ignored_${reason}`] += 1
+ s.last_ignored_reason = reason
+ }
+ runsModel.create = async (definitionId, opts, userId) => {
+ store.creates.push({ definitionId, opts, userId })
+ const def = store.definitions.find((d) => d.id === definitionId)
+ if (!def.version_verified_at) return { ok: false, code: 'unverified', errors: ['unverified'] }
+ const existing = store.runs.find(
+ (r) => r.definition_id === definitionId && r.scheduled_for.getTime() === opts.scheduledFor.getTime(),
+ )
+ if (existing) return { ok: true, created: false, run: existing }
+ const run = { id: store.nextRun++, definition_id: definitionId, status: 'scheduled', scheduled_for: opts.scheduledFor, origin: opts.origin }
+ store.runs.push(run)
+ return { ok: true, created: true, run }
+ }
+}
+
+afterEach(() => {
+ Object.assign(definitionsDb, originals.definitionsDb)
+ Object.assign(startsDb, originals.startsDb)
+ Object.assign(runsModel, originals.runsModel)
+})
+
+const definition = (over = {}) => ({
+ id: 7,
+ title: 'Launch Site Lockdown',
+ grace_seconds: 900,
+ version_verified_at: new Date('2026-10-01T00:00:00Z'),
+ version_spec: {
+ schedule: { kind: 'manual' },
+ starts: [{ on: TRIGGER, where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '30m', dormant: false }],
+ phases: [{ key: 'main', label: 'Main', steps: [] }],
+ },
+ ...over,
+})
+
+const firing = (at, data = { server: 'main', grid: 'G8' }, extra = {}) => ({
+ triggerId: TRIGGER,
+ occurredAt: at.toISOString(),
+ subject: 'bradley-1',
+ data,
+ ...extra,
+})
+
+const plus = (ms) => new Date(T0.getTime() + ms)
+
+test('a matching firing starts one run, now, with its origin', async () => {
+ installStubs({ definitions: [definition()] })
+ const summary = await starts.observe(firing(T0, { server: 'main', grid: 'G8', stray: 'x' }), { now: plus(2_000) })
+ assert.deepEqual(summary, { definitions: 1, started: 1, ignored: 0 })
+ assert.equal(store.runs.length, 1)
+ const [{ opts, userId }] = store.creates
+ assert.equal(userId, null, 'no user started it')
+ assert.equal(opts.source, 'trigger')
+ assert.equal(opts.scheduledFor.toISOString(), T0.toISOString(), 'the firing\'s time, not the observer\'s')
+ assert.deepEqual(opts.origin, {
+ kind: 'trigger',
+ triggerId: TRIGGER,
+ label: 'Bradley destroyed',
+ occurredAt: T0.toISOString(),
+ subject: 'bradley-1',
+ matched: 'server is "main"',
+ // Declared variables only: `stray` is not something the trigger declares.
+ variables: { server: 'main', grid: 'G8' },
+ })
+ assert.equal(store.state.get(7).last_run_id, 1)
+})
+
+test('a firing that does not match the condition starts nothing and counts nothing', async () => {
+ installStubs({ definitions: [definition()] })
+ const summary = await starts.observe(firing(T0, { server: 'staging' }), { now: plus(1_000) })
+ assert.deepEqual(summary, { definitions: 1, started: 0, ignored: 0 })
+ assert.equal(store.creates.length, 0)
+ assert.equal(store.state.has(7), false)
+})
+
+test('a second firing while the run is going starts nothing, and is counted as running', async () => {
+ installStubs({ definitions: [definition({ version_spec: { ...definition().version_spec, starts: [{ ...definition().version_spec.starts[0], cooldown: '0m' }] } })] })
+ await starts.observe(firing(T0), { now: plus(1_000) })
+ const summary = await starts.observe(firing(plus(30_000)), { now: plus(31_000) })
+ assert.deepEqual(summary, { definitions: 1, started: 0, ignored: 1 })
+ assert.equal(store.runs.length, 1)
+ assert.equal(store.state.get(7).ignored_running, 1)
+})
+
+test('inside the cooldown, a firing starts nothing and is counted; after it, a second run', async () => {
+ installStubs({ definitions: [definition()] })
+ await starts.observe(firing(T0), { now: plus(1_000) })
+ store.runs[0].status = 'completed'
+
+ await starts.observe(firing(plus(10 * 60_000)), { now: plus(10 * 60_000) })
+ assert.equal(store.runs.length, 1)
+ assert.equal(store.state.get(7).ignored_cooldown, 1)
+
+ const summary = await starts.observe(firing(plus(31 * 60_000)), { now: plus(31 * 60_000) })
+ assert.equal(summary.started, 1)
+ assert.equal(store.runs.length, 2)
+ assert.equal(store.state.get(7).ignored_cooldown, 0, 'a new start zeroes the counts')
+})
+
+test('one firing reported twice is one run, and the repeat is not counted', async () => {
+ installStubs({ definitions: [definition()] })
+ await starts.observe(firing(T0), { now: plus(1_000) })
+ const summary = await starts.observe(firing(T0), { now: plus(1_500) })
+ assert.equal(summary.started, 0)
+ assert.equal(summary.ignored, 0)
+ assert.equal(store.runs.length, 1)
+ assert.equal(store.state.get(7).ignored_running, 0)
+})
+
+test('an unverified version is not started by a trigger, and is counted', async () => {
+ installStubs({ definitions: [definition({ version_verified_at: null })] })
+ const summary = await starts.observe(firing(T0), { now: plus(1_000) })
+ assert.deepEqual(summary, { definitions: 1, started: 0, ignored: 1 })
+ assert.equal(store.creates.length, 0, 'not even tried')
+ assert.equal(store.state.get(7).ignored_unverified, 1)
+})
+
+test('a firing older than the grace window starts nothing', async () => {
+ installStubs({ definitions: [definition({ grace_seconds: 60 })] })
+ const summary = await starts.observe(firing(T0), { now: plus(5 * 60_000) })
+ assert.equal(summary.started, 0)
+ assert.equal(store.creates.length, 0)
+})
+
+test('a firing stamped in the future starts the run now, not later', async () => {
+ installStubs({ definitions: [definition()] })
+ await starts.observe(firing(plus(60 * 60_000)), { now: plus(1_000) })
+ assert.equal(store.creates[0].opts.scheduledFor.toISOString(), plus(1_000).toISOString())
+})
+
+test('core\'s event.* triggers are never looked up, and a dormant start never fires', async () => {
+ installStubs({ definitions: [] })
+ let asked = 0
+ definitionsDb.findStartingOn = async () => {
+ asked += 1
+ return []
+ }
+ await starts.observe({ triggerId: 'event.run.started', data: {} })
+ assert.equal(asked, 0)
+
+ installStubs({
+ definitions: [definition({ version_spec: { ...definition().version_spec, starts: [{ on: TRIGGER, where: null, cooldown: '30m', dormant: true }] } })],
+ })
+ const summary = await starts.observe(firing(T0), { now: plus(1_000) })
+ assert.equal(summary.started, 0)
+})
+
+test('two definitions on one trigger each decide for themselves', async () => {
+ const other = definition({ id: 8, title: 'Other', version_spec: { ...definition().version_spec, starts: [{ on: TRIGGER, where: null, cooldown: '30m', dormant: false }] } })
+ installStubs({ definitions: [definition(), other] })
+ const summary = await starts.observe(firing(T0, { server: 'staging' }), { now: plus(1_000) })
+ assert.deepEqual(summary, { definitions: 2, started: 1, ignored: 0 })
+ assert.equal(store.runs[0].definition_id, 8)
+})
+
+test('a create that throws gives the claim back and is not fatal to the others', async () => {
+ const other = definition({ id: 8 })
+ installStubs({ definitions: [definition(), other] })
+ const create = runsModel.create
+ runsModel.create = async (id, ...rest) => {
+ if (id === 7) throw new Error('boom')
+ return create(id, ...rest)
+ }
+ const summary = await starts.observe(firing(T0), { now: plus(1_000) })
+ assert.equal(summary.started, 1)
+ assert.equal(store.state.get(7).claimed_at, null)
+})
+
+test('an oversized payload keeps only the variables the condition named', () => {
+ const big = 'x'.repeat(starts.MAX_ORIGIN_BYTES)
+ const origin = starts.originOf(
+ { triggerId: TRIGGER, data: { server: 'main', grid: big } },
+ registries.eventTrigger(TRIGGER),
+ { where: { variable: 'server', cmp: 'eq', value: 'main' } },
+ T0,
+ )
+ assert.deepEqual(origin.variables, { server: 'main', _truncated: true })
+})
+
+test('the editor\'s state line, and its absence', () => {
+ assert.equal(starts.describeState(null), null)
+ assert.deepEqual(
+ starts.describeState({ last_started_at: T0, last_run_id: 4, ignored_running: 2, ignored_cooldown: 1, ignored_unverified: 0 }),
+ {
+ lastStartedAt: T0,
+ lastRunId: 4,
+ ignoredRunning: 2,
+ ignoredCooldown: 1,
+ ignoredUnverified: 0,
+ lastIgnoredAt: null,
+ lastIgnoredReason: null,
+ },
+ )
+})
diff --git a/server/test/eventsAdmin.test.js b/server/test/eventsAdmin.test.js
index e5706bf..4b46902 100644
--- a/server/test/eventsAdmin.test.js
+++ b/server/test/eventsAdmin.test.js
@@ -52,6 +52,9 @@ const resourcesDb = require('../src/model/events/eventRunResources.db')
const participantsDb = require('../src/model/events/eventRunParticipants.db')
const seriesDb = require('../src/model/events/eventSeries.db')
const gatesDb = require('../src/model/events/eventPhaseGates.db')
+// Rust PLAN_REDESIGNS §14.3.12: the editor's shape reads the trigger-start state.
+// The same rule as every leg above, a fifth time.
+const triggerStartsDb = require('../src/model/events/eventTriggerStarts.db')
const activity = require('../src/model/activity/activity.model')
const db = require('../src/utils/db')
@@ -69,6 +72,7 @@ for (const [name, mod] of [
['logDb', logDb],
['seriesDb', seriesDb],
['gatesDb', gatesDb],
+ ['triggerStartsDb', triggerStartsDb],
['settingsDb', settingsDb],
['budgetDb', budgetDb],
['resourcesDb', resourcesDb],
@@ -96,6 +100,7 @@ function installStubs() {
log: [],
series: new Map(),
gates: [],
+ triggerStarts: new Map(),
settings: new Map(),
budget: new Map(),
resources: [],
@@ -295,6 +300,7 @@ function installStubs() {
// it merely made the file slow. Worth the comment: when the runner or a model
// gains a leg, every file that stubs the layer under it needs the stub.
gatesDb.listForRun = async (runId) => store.gates.filter((g) => g.run_id === runId)
+ triggerStartsDb.forDefinition = async (id) => store.triggerStarts.get(id) || null
logDb.listForRun = async (runId) => store.log.filter((l) => l.run_id === runId).reverse()
logDb.write = async ({ runId, stepId = null, kind, phase = null, detail = null }) => {