docs(events): a trigger can start a run (Rust §14.3 stage 9, D370)

- EVENTS.md §E "Starting a run from a trigger": `starts`, the third
  emit subscriber, the four rules (verified, one at a time, cooldown,
  no event.*), the run's origin, and what is deliberately not in it.
  §D: event_runs.origin and a twelfth table, event_trigger_starts.
  API surface: triggerStarts on GET /admin/events/:id, origin on the
  run console.
- BACKEND_DESIGN.md: event_runs.origin and event_trigger_starts.
- PLAN_REDESIGNS §14.3.21: stage 9 as built, eight settled-in-code
  points, tests and the Module-uo proof; pointers in §9, §14.3.11 and
  §14.3.12. This ends the §14.3 build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-16 01:52:16 -05:00
parent 5f769c6e05
commit b846c0c6b4
3 changed files with 133 additions and 7 deletions

View File

@@ -413,8 +413,9 @@ completion, cancellation and abort alike.
## D — Data model
Eleven core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core
tables carry no module prefix. (The count said "nine" over a list of ten from the revision that added
Twelve core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core
tables carry no module prefix. (The twelfth, `event_trigger_starts`, came with starting a run from a
trigger in 2026-10, [§E](#starting-a-run-from-a-trigger).) (The count said "nine" over a list of ten from the revision that added
`event_series` until Phase 5 counted them.)
| Table | Holds | Why a table |
@@ -422,7 +423,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th
| `event_definitions` | id, title, slug, summary, storyline body, image, `owner_module` **nullable**, `state` `ENUM('draft','ready','archived')`, `current_version_id`, `series_id`, `series_order`, `spec` (the working copy — see below), `concurrency_key`, `grace_seconds`, timezone, `listed` (Phase 14a), created/updated by. | The thing that is listed, searched, scheduled and audited. Three states, not five: an admin publishes their own work, so there is nobody to submit it to. **`listed` is announcement, not permission** — see below. |
| `event_series` | id, name, slug, description, ordering. Definitions optionally belong to one, at their own `series_order` within it. | **The arc.** "Royal Spy Mission → Risky Partner → Message From the Void" is continuity that exists nowhere in the tooling this replaces. One small table buys it. |
| `event_versions` | `definition_id`, `version`, `spec` JSON — phases, steps, schedule, conditions, announcements — `published_at`, `published_by`, and (Phase 6) `verified_at` / `verified_by`. Immutable. | A run pins one. This is what makes a run reproducible and an audit answerable after an edit. The two `verified_*` columns are the exception that proves the immutability: they describe something that happened **to** the version afterwards — a review — rather than changing the plan reviewed, which is also why a pass recorded against one stays true for ever ([§K](#k--security-model)). |
| `event_runs` | `definition_id`, `version_id`, `scope` (module-opaque), `status`, `health`, `current_phase`, `scheduled_for`, `timezone`, `started_at`, `ended_at`, `cleanup_status`, `claimed_by`, `claim_expires_at`, `started_by`. **`UNIQUE (definition_id, scope, scheduled_for)`** | The unique index — not the claim — is what makes "one run per occurrence per scope" true under two instances. `scope` is in the key so a worldwide event fans out to many servers without colliding with itself. |
| `event_runs` | `definition_id`, `version_id`, `scope` (module-opaque), `status`, `health`, `current_phase`, `scheduled_for`, `timezone`, `started_at`, `ended_at`, `cleanup_status`, `claimed_by`, `claim_expires_at`, `started_by`, `origin` JSON (the firing that started it, when a trigger did). **`UNIQUE (definition_id, scope, scheduled_for)`** | The unique index — not the claim — is what makes "one run per occurrence per scope" true under two instances. `scope` is in the key so a worldwide event fans out to many servers without colliding with itself. |
| `event_run_steps` | `run_id`, `phase`, `seq`, `action_id`, `params` JSON, `action_version`, `status`, `due_at`, `attempts`, `on_failure`, `idempotency_key`, `claimed_by`, `claim_expires_at`, `last_error`. `INDEX (status, due_at)` | The work queue, claimed with the outbox's compare-and-set. |
| `event_action_settings` | `action_id` (the primary key), `enabled`, `caps` JSON (`{dimension: perRunCap}`), `updated_by`, `updated_at`. | **The deployment's switchboard, and the whole of the permission model beyond the role.** One row per action an admin has an opinion about; **a missing row is not "disabled", it is the default for the action's risk class** — see [§K](#k--security-model). Not a grant table — nobody is named, because the role check already answered who. Rows outlive their actions, so uninstalling a module and re-installing it restores the caps the operator chose. |
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap` **nullable**, `effective_from`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND (cap IS NULL OR consumed + ? <= cap)` — so the cap holds under concurrent steps without a transaction. **A NULL cap is uncapped and still a row**, so the console's meter counts what nothing bounds, and a *missing* row keeps its one meaning: a step spending a dimension its own run's version never priced, which is refused. `effective_from` names the action whose cap won, so a number on the meter traces back to a switch. |
@@ -430,6 +431,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque **and NOT NULL**, `score` `DECIMAL(18,4)`, `rank_at`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. The unique key is what makes a retried collect step an upsert rather than a doubled leaderboard, and `rank_at` carries the suffix because `rank` is a reserved word from MariaDB 10.2 — one forgotten pair of backticks away from a syntax error in a query nothing runs until a run completes at four in the morning. **Written only from an action's success envelope** (Phase 10): core stores what a module tells it and sources nothing, because a `member_key` → account mapping is one game's. |
| `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. |
| `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. |
| `event_trigger_starts` | `definition_id` (the primary key, `CASCADE`), `claimed_at`, `last_started_at`, `last_run_id`, `ignored_running`, `ignored_cooldown`, `ignored_unverified`, `last_ignored_at`, `last_ignored_reason`. | **One row per definition a trigger has tried to start** ([§E](#starting-a-run-from-a-trigger)). The claim that makes "one run at a time" true is a conditional `UPDATE` of it, and the counts are what the editor's *"2 firings ignored while running"* reads. Not columns on `event_definitions`: they change on every firing, and that row's `updated_at` orders the admin list. |
> **`listed` is Phase 14a's one addition to this table**, and it is deliberately not a second answer
> to what `state` answers. `state` says whether an event is SCHEDULABLE; `listed` says whether it is
@@ -639,6 +641,64 @@ publish, because a version runs are pinned to must not wait on a trigger that ca
predicate is carried through unvalidated rather than dropped — there is no declaration to check it
against, and deleting it would silently discard what the author wrote.
### Starting a run from a trigger
Built 2026-10-16, the core half of Rust [PLAN_REDESIGNS](../modules/rust/PLAN_REDESIGNS.md) §14.3.12
(D370). *"When anyone destroys Bradley on Main, start Launch Site Lockdown"*; *"when a champion's boss
appears, start Champion's Call"*. Until then a trigger could only advance a phase of a run already
going, or send a notice. **No module contract changed**: a definition starts on the triggers modules
already register, and no MODULE_API version was bumped.
**A spec's third top-level key, `starts`**, beside `schedule` (a definition may have either, or
both):
```js
starts: [{ on: 'rust.world.bradley_destroyed', where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '30m' }]
```
`on` and `where` are a phase gate's own — the same catalogue, the same grammar, validated by the same
function, so a variable the trigger does not declare is refused by name at save and a trigger nothing
registers makes the start **dormant** (it saves, and it refuses to publish). `cooldown` is the `after`
grammar with zero allowed, `0m` to `7d`, **30 minutes when left out**, normalised as `after` is. At
most ten starts. The key is omitted when the list is empty, for the reason an absent `advance` is.
**The emit path's third subscriber**, `events/starts.js`, beside `engine.dispatch` and the gate
observer and in their posture: never awaited, never allowed to reject. A firing looks up the `ready`
definitions whose **published version** starts on it — one query over the versions' JSON; a
deployment has tens of definitions and a JSON column cannot carry an index, and if that ever changes
a side table written at publish is the fix — and for each one whose condition matches:
1. **The version must have passed its dry run.** A trigger start is unattended, as a scheduled one
is, so §K's gate falls on it: an unverified version starts nothing, and the firing is counted.
2. **One run at a time, and a cooldown.** A firing while a run of the definition is going (started
and not ended, or `scheduled` and already due), or within the matching start's `cooldown` of the
last run a trigger started, starts nothing and is counted. Both rules, and a guard against a claim
younger than a minute, are **one conditional `UPDATE`** on `event_trigger_starts`, so two firings
arriving together cannot both start: the `event_run_budget` argument once more, proved against a
real server in `eventRunnerSql.test.js`.
3. **The run is created now**, through `runs.create()` as every run is, with `source: 'trigger'`,
`scheduled_for` = the firing's time in whole seconds (never in the future), scope `''` (as a
scheduled occurrence's), and `started_by` NULL. The occurrence key then makes **one firing
reported twice one run**, and a repeat of the firing that started the last run is not counted.
4. **Its `origin` is stored on the run** and in the `run.created` audit line: the trigger, its label,
when it fired, its subject, the start's condition in the builder's words, and the trigger's
**declared** variables (nothing else a module sent; past 4 KB only the variables the condition
named). The console reads *"Started because “A champion boss is up” fired (matching spawnName is
"Abyss"): spawnName Abyss · location Felucca 5187, 570"*. A column rather than only a log line,
because the log is pruned and the run is not.
A firing older than the definition's grace window starts nothing (the missed sweep would take the run
on its next tick) and is logged. **Core's own `event.*` triggers cannot start a run**, refused at save
and skipped on the emit path, so a run starting can never start another; a run whose own steps cause
its start trigger is held by one-at-a-time and the cooldown. **Everything else is a run like any
other**: caps and budgets, concurrency key, announcements, cleanup. The counts are since the last
trigger-started run, which zeroes them, and the editor shows them: *"Last started run #12; since then
2 firings ignored while a run was going, 1 firing ignored in the cooldown."*
**Not in it, deliberately:** a step cannot read the firing's variables (Bradley's grid is on the run,
not in `perform()`'s envelope). That is a module-contract change, a MODULE_API minor, and nothing has
asked for it yet.
### Enablement and caps, in front of every dispatch
Phase 6. The runner gained one thing: before it asks a module to do anything, it asks
@@ -1915,7 +1975,7 @@ no URL moved.
| Route | Gate | |
| --- | --- | --- |
| `GET /admin/events` | staff | definitions, state, next occurrence, health |
| `GET /admin/events/:id` | staff | one definition, working spec included — what the editor reads. With **`draftAhead`** (MODULE_API 1.13.0): whether the working spec differs from the published version, so the start dialog can ask which to run. `PUT` and `publish` answer with it too |
| `GET /admin/events/:id` | staff | one definition, working spec included — what the editor reads. With **`draftAhead`** (MODULE_API 1.13.0): whether the working spec differs from the published version, so the start dialog can ask which to run. `PUT` and `publish` answer with it too. With **`triggerStarts`** (2026-10, [§E](#starting-a-run-from-a-trigger)): the last run a trigger started and the firings ignored since, by why — null until a trigger first tries |
| `POST /admin/events` | admin, editor | create a draft |
| `PUT /admin/events/:id` | admin, editor | edit the draft spec |
| `POST /admin/events/:id/publish` | admin | snapshot a version and go `ready` |
@@ -1925,7 +1985,7 @@ no URL moved.
| `POST /admin/events/price` | admin, editor | **the live cap meter** (Phase 13) — price the spec IN THE BODY against the per-run caps. Dispatches nothing and records nothing, which is what separates it from the dry run and what makes it safe to call on a debounce while an author types; the spec is in the body because the plan being priced is unsaved between keystrokes. It therefore knows nothing a module knows — whether the landmark exists, whether the shard is up — and the screen says so under the numbers. A step it cannot price is reported in `unpriced` rather than counted as free; an **undeclared dimension is still counted**, because the action really will try to spend it and it is the enforcement rather than the amount that is missing. Over the caps is a `200`, for `verify`'s reason |
| `POST /admin/events/:id/runs` | admin | start now; optional `rehearsal: true`, optional `scope` — and optional `params`, which **Phase 13 was the first thing to send**: the concurrency key is a `{placeholder}` template rendered from the run's own params, so a UI posting an empty body rendered the same key for every manual run and had the second refused as an overlap with the first |
| `GET /admin/events/runs` | staff | run history across definitions |
| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup |
| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup — and the run's `origin` when a game firing started it ([§E](#starting-a-run-from-a-trigger)) |
| `GET /admin/events/runs/:runId/log` | staff | the diagnostic log |
| `POST /admin/events/runs/:runId/pause\|resume\|cancel` | admin, moderator | live control of a run in flight (Phase 3); `cancel` takes `{ reason }` and, since Phase 8, `{ cleanup }` — **defaulting to true**, so §L's *"cancelling WITHOUT cleanup is a separate, logged, admin-only action"* is the flag that has to be asked for. `cleanup: false` is refused to a moderator in the MODEL rather than in middleware, because which of the two roles you have to be depends on what is in the body. Either way the request answers at once: the teardown is the runner's cleanup leg, not this call |
| `POST /admin/events/runs/:runId/advance` | admin, moderator | force the current phase past its advance condition (Phase 5). Legal only while the phase is genuinely waiting on its gate; `409` naming what it is waiting on otherwise. Satisfies the gate and stops — the next tick performs the boundary |