feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who took part, publishes a results table, and announces a post through the legs the news pipeline already uses. Events owns none of the delivery: a run says what happened and an operator's rule decides who is told, so email, the in-app inbox, push tickles, Discord and the town crier all arrive without anything in `events/` growing a second delivery path. **No route was added and nothing moved.** The whole surface is two more derived fields on a run — `participants` and `resultsPublishedAt` — and a zero-line `routes.manifest.json` diff proves it. Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly where `news.post` sits, and `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot key so a deployment that has already stamped the Team and news keys still gets them. **The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a rehearsal runs for real "with announcements ceilinged to `staff`" — but a ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as the real thing, so the moment this phase gave a run something to announce, rehearsing a published event would have mailed every subscriber. The emit envelope now takes an optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse every rule rather than resolving to either. `MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so 1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third time. Three defects the live walk found, none visible to a unit test: 1. **A channel that reported success while reaching nobody.** The seeded `run.started` rule named `push`, because §8.5 and the plan both do. Push delivery joins `notification_subscriptions`, only ever written for an id the preferences screen offered push for — and it offers push only for a registered STREAM. So the tickle went nowhere every time while `pushChannel.deliver` answered "tickle published". `event.run.started` is now a stream as well as a trigger; the other six are not. 2. **A trigger's `description` reaches a recipient.** It is the structural projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing." put those words in an administrator's own inbox item. 3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have counted every idempotent retried collect as a fresh participant. And one caught before it shipped: ranking with a session variable is wrong here, because `query()` takes a pool connection per call — the variable would be set on one connection and read on another. A window function needs no session state. ## Verification - `npm test --prefix server` — **1981 pass, 1 fail**, and that one (`botScore.test.js`) passes standalone at 18/18: a file-level flake under parallel load. Run with an empty `MODULES_DIR`, as CI does. - `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green. - Zero-line `routes.manifest.json` / `routes.guards.json` diff. - A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail arrived, headed with the event's title and its start time in the shard's own zone; the rehearsal fired the same trigger and produced zero outbox rows where the real run produced three; `run.failed` reached the administrator's inbox and no player's; `core.announce.post` queued a second job without touching the news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert were run against real MariaDB 11. ## One thing for a reviewer, out of scope and not fixed **Every `#swagger.description` in this repo is truncated in the generated spec.** swagger-autogen does not honour a backslash-escaped apostrophe, so a description is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is pre-existing and repo-wide. Only the one annotation this phase edits is fixed here (a typographic apostrophe), because otherwise this phase's own addition to it would be dead text. The rest wants its own change. - [x] AI-assisted: Claude Code (Opus 5). Docs: RunicGateway/docs#TBD. Co-Authored-By: Claude <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -22,6 +22,16 @@
|
||||
// verb per module would be that bound re-implemented once per module, advisory
|
||||
// everywhere, and wrong in the first one that forgot it.
|
||||
//
|
||||
// **Phase 10 added the last two, and they are the integrations** (EVENTS.md
|
||||
// §J). `core.announce.post` sends an ARTICLE rather than a line — it links a
|
||||
// post an editor already wrote and queues it through `announce_jobs`, so the
|
||||
// town crier and Discord arrive as already-registered legs with their retry and
|
||||
// their classification rather than as a second delivery pipeline. And
|
||||
// `core.results.publish` is what makes §F's "publish results" literal: it ranks
|
||||
// the run's participants and stamps the table published. Both name a game noun
|
||||
// nowhere, which is why they are core's; six actions is now the whole of what an
|
||||
// event can do on a deployment with no game module installed at all.
|
||||
//
|
||||
// **Phase 2 gave all three real bodies**, and between them they exercise every
|
||||
// shape §F's envelope can take: `core.announce` does work and finishes,
|
||||
// `core.wait` finishes while deferring what follows it, and `core.cue` succeeds
|
||||
@@ -413,6 +423,142 @@ const ACTIONS = [
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.announce.post',
|
||||
label: 'Announce a post',
|
||||
description:
|
||||
'Send an existing news post out on every registered announce leg — Discord, the in-game town crier — as this run\'s announcement.',
|
||||
|
||||
// Nothing in the world changes and nothing is created; a message goes out.
|
||||
// Same class as `core.announce` and for the same reason.
|
||||
risk: 'notify',
|
||||
// The job is queued, the legs deliver, and none of it can be unsent. A
|
||||
// `ledger` here would put a row in the cleanup ledger that teardown could
|
||||
// never resolve.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
// **`core.announce` sends a line; this sends an ARTICLE**, and that is the
|
||||
// whole difference between them (EVENTS.md §J, "News"). Events does not
|
||||
// write posts — `ctx.posts` is read-only to modules and the CMS is core's —
|
||||
// so an event that wants prose, an image and a permanent page links a post
|
||||
// an editor already wrote. What this action adds over `core.announce` is
|
||||
// therefore not a second transport but a second SHAPE: every leg's
|
||||
// `dispatch()` takes a post, and this is the one that hands it a real one.
|
||||
params: [
|
||||
{
|
||||
name: 'postId',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 412,
|
||||
source: 'core.options.posts',
|
||||
description: 'The published post to announce. Any category.',
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Queue the post on every registered leg, as this run's announcement.
|
||||
*
|
||||
* **The refusals are all `retry: false`**, and each is a thing a human has to
|
||||
* fix: a post id that names nothing, or a draft. Neither will have changed
|
||||
* sixty seconds later, and retrying would spend two more attempts before
|
||||
* saying the same thing.
|
||||
*
|
||||
* **What it does NOT wait for is delivery.** `enqueueForRun` writes the job
|
||||
* and the legs and returns; `announceWorker` drains them on its own tick with
|
||||
* its own backoff. So this step is `done` when the announcement is queued,
|
||||
* not when Discord has it — which is honest, because a leg that fails after
|
||||
* six attempts over two hours is not something a step could usefully have
|
||||
* stayed open for, and the post admin panel is where that failure is already
|
||||
* surfaced.
|
||||
*/
|
||||
async perform({ runId, params, verify }) {
|
||||
/* eslint-disable global-require */
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const announceJobs = require('../model/announceJobs/announceJobs.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const postId = Number(params.postId)
|
||||
if (!Number.isInteger(postId) || postId < 1) {
|
||||
return { ok: false, retry: false, error: `"${params.postId}" is not a post id` }
|
||||
}
|
||||
|
||||
const post = await posts.getById(postId)
|
||||
if (!post) return { ok: false, retry: false, error: `no post with id ${postId}` }
|
||||
if (!post.published) {
|
||||
// A draft has no public page for a town-crier line to point at, and
|
||||
// announcing one would publish its title to a shard before an editor
|
||||
// meant to. Refused rather than published on the author's behalf:
|
||||
// publishing is the CMS's decision and this action is not it.
|
||||
return { ok: false, retry: false, error: `"${post.title}" is not published` }
|
||||
}
|
||||
|
||||
// The dry run has now checked everything worth checking — the post exists
|
||||
// and is published — and queues nothing. Checked BEFORE the legs are read,
|
||||
// because a deployment with no leg registered is a real state and a verify
|
||||
// that reported it as a failure would refuse a plan that is fine.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
await announceJobs.enqueueForRun(postId, runId)
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'core.results.publish',
|
||||
label: 'Publish the results',
|
||||
description:
|
||||
'Rank this run\'s participants by score and publish the results table.',
|
||||
|
||||
// Nothing in the game world changes and nobody is messaged: a table core
|
||||
// already holds becomes readable. `inspect` is the weakest class the closed
|
||||
// set has and it is the honest one — which also means this action is
|
||||
// default-ON like `core.wait`, and an author can place it without an admin
|
||||
// first visiting the switchboard.
|
||||
risk: 'inspect',
|
||||
// **`none`, and it is worth saying why a publication is not reversible.**
|
||||
// Nothing is created that core would have to come back for; un-publishing is
|
||||
// an admin decision about a table, not a teardown obligation, and a `ledger`
|
||||
// row here would make every completed event carry an outstanding resource
|
||||
// for ever.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
|
||||
// No params. What is published is this run's participants, which is the only
|
||||
// set there is — a param naming which run would be a way to publish someone
|
||||
// else's results from inside your own event.
|
||||
params: [],
|
||||
|
||||
/**
|
||||
* Rank, stamp, and say how many.
|
||||
*
|
||||
* **Idempotent by construction**, which is what makes it safe as an ordinary
|
||||
* retried step: ranking is a total order over `(score, joined_at, id)`, so
|
||||
* running it twice over an unchanged table writes the same numbers, and the
|
||||
* stamp simply moves. A late participant added by a second collect step and
|
||||
* a re-publish afterwards renumbers deliberately — that is the operator
|
||||
* asking for exactly that.
|
||||
*
|
||||
* **A run with no participants publishes an empty table rather than
|
||||
* failing.** "Nobody was recorded" is a true and renderable result, and it is
|
||||
* the state of every run until a module can source attendance at all (Phase
|
||||
* 12). Failing here would make an event whose module reports nothing look
|
||||
* broken on the console for a reason that has nothing to do with the event.
|
||||
*/
|
||||
async perform({ runId, verify }) {
|
||||
/* eslint-disable global-require */
|
||||
const participantsDb = require('../model/events/eventRunParticipants.db')
|
||||
const runsDb = require('../model/events/eventRuns.db')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
await participantsDb.rankRun(runId)
|
||||
await runsDb.markResultsPublished(runId)
|
||||
return { ok: true }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ── Core's own param option sources (§F, Phase 7) ──────────────────
|
||||
@@ -446,6 +592,28 @@ const OPTION_SOURCES = [
|
||||
.map((l) => ({ value: l.id, label: l.label, group: l.id.split('.')[0] }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'core.options.posts',
|
||||
label: 'Published posts',
|
||||
description: 'Every published post an event may announce, newest first.',
|
||||
/**
|
||||
* **The one option source in core that reaches a table**, and the reason it
|
||||
* is allowed to is the rule §F draws about WHEN: a source resolves on its own
|
||||
* request (`GET /admin/events/catalog/options/:sourceId`), which is a live
|
||||
* request on a booted server, not at `register()` time under a dead pool.
|
||||
*
|
||||
* Grouped by category so the dropdown separates news from the newsletter
|
||||
* rather than presenting one long list in which the two are indistinguishable
|
||||
* — a `group` is what the form renders as an optgroup, and it costs a column
|
||||
* that is already selected.
|
||||
*/
|
||||
async resolve() {
|
||||
// eslint-disable-next-line global-require
|
||||
const postsDb = require('../model/posts/posts.db')
|
||||
const rows = await postsDb.listPublishedForOptions(200)
|
||||
return rows.map((p) => ({ value: p.id, label: p.title, group: p.category }))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { ACTIONS, OPTION_SOURCES }
|
||||
|
||||
@@ -75,6 +75,38 @@ const STREAMS = [
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
|
||||
//
|
||||
// **One of the seven `event.` triggers is also a stream, and that is a
|
||||
// decision rather than an oversight** (org lead, 2026-09-04). A stream is a
|
||||
// PUSH toggle: `notificationChannelPrefs.catalog` offers the push channel only
|
||||
// for ids registered here, `publishToUsers` joins `notification_subscriptions`,
|
||||
// and that table is only ever written for a channel a user could switch on. So
|
||||
// a trigger that is not also a stream can be mailed and put in the inbox, and
|
||||
// its push is dead — a tickle published to nobody, which the send log
|
||||
// nonetheless records as sent. Found on the live rig; the seeded rule named
|
||||
// `push` before this line existed.
|
||||
//
|
||||
// **`run.started` alone, because push is the channel that says "now".** It is
|
||||
// the one lifecycle moment worth waking a phone for — ENGAGEMENT.md §8.5's
|
||||
// *"come back for X"* — and the other six are things a player reads when they
|
||||
// next look. Six more toggles would put a wall of switches on the preferences
|
||||
// screen for one feature, and `event.phase.changed` is the one most likely to
|
||||
// buzz a phone four times in an evening.
|
||||
//
|
||||
// Same id as the trigger, which is §7.2's one namespace and the same-owner
|
||||
// upgrade `news.post` already is: one id, one owner, two facets.
|
||||
{
|
||||
id: 'event.run.started',
|
||||
label: 'Events — starting now',
|
||||
description: 'A scheduled event is beginning.',
|
||||
// Not owner-keyed: this is a public event happening in public, not a fact
|
||||
// about one account's own property. Same as `news.post`.
|
||||
personal: false,
|
||||
// A player with no linked game account can still want to know an event is on.
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { STREAMS }
|
||||
|
||||
@@ -149,6 +149,232 @@ const TRIGGERS = [
|
||||
description: 'Site-relative path to the announcement.' },
|
||||
],
|
||||
},
|
||||
|
||||
// ── The event system (EVENTS.md §J — Phase 10) ──────────────────────────
|
||||
//
|
||||
// **Seven triggers, one per moment a run passes through that somebody outside
|
||||
// the run console might want to hear about — and Events owns none of the
|
||||
// delivery.** A run emits; an operator's rule decides who is told, on what,
|
||||
// and how often. That is the whole of §J's "clean fit" row, and it is why
|
||||
// there is no announcement machinery anywhere in `utils/eventRunner.js`
|
||||
// beyond a call to `emit`.
|
||||
//
|
||||
// **Six are ceilinged `authenticated` and one at `admin`** (§J, and the org
|
||||
// lead 2026-09-04). `run.failed` is an operational fact — a step ran out of
|
||||
// attempts, the world may be half-changed — and a rule that mailed it to
|
||||
// every subscriber would publish the deployment's incidents. The other six
|
||||
// describe a public event happening in public, so they sit exactly where
|
||||
// `news.post` sits: ceiling `authenticated`, default audience `subscribers`,
|
||||
// which is "people who asked to be told" rather than the whole user table.
|
||||
//
|
||||
// **Every `description` here is read by two audiences**, and the second one is
|
||||
// easy to forget: the rule editor's catalog, and — through
|
||||
// `projection.project`'s `intro` fallback — every recipient of an unauthored
|
||||
// render through `notify.event` or `inapp.event`. So each is prose a player
|
||||
// can read rather than a note to the operator. The live rig caught the
|
||||
// original `run.failed` line, which ended "Staff-facing." and put those words
|
||||
// in an administrator's own inbox item. Who a trigger is for is said by its
|
||||
// CEILING, which is the only place that can enforce it anyway.
|
||||
//
|
||||
// **`subjectKey: 'runId'` on every one of them**, and it is the one place
|
||||
// these differ from `news.post`. A cooldown keyed on the user would make
|
||||
// `phase.changed` mean "at most one phase of at most one event an hour",
|
||||
// silently swallowing the second wave of an invasion because the first wave's
|
||||
// mail went out forty minutes ago. Keyed on the run it means "at most one
|
||||
// line an hour ABOUT THIS RUN", which is the useful sentence — and across
|
||||
// runs of the same definition the ids differ, so a weekly event is not
|
||||
// throttled by last week's.
|
||||
//
|
||||
// **None of the six public ones declares a `url` variable, deliberately.**
|
||||
// There is no public event page until Phase 14 — `App.jsx` mounts nothing
|
||||
// under `/site/events` — and `news.post` has already paid for this mistake
|
||||
// once: its `postUrl` example named `/news/<slug>`, a path that does not
|
||||
// exist, and the template editor previewed a link that was dead in every mail
|
||||
// it sent. A variable added in Phase 14 alongside the page it points at is a
|
||||
// version bump; a variable shipped now is a 404 in an operator's first
|
||||
// announcement. `run.failed` is the exception because its destination exists
|
||||
// today: `/admin/events/runs/:runId` is a real route and an admin can read it.
|
||||
{
|
||||
id: 'event.run.scheduled',
|
||||
label: 'Event — scheduled',
|
||||
description: 'A new event has been added to the calendar.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
|
||||
description: 'The event summary, as authored.' },
|
||||
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
|
||||
description: 'The arc this event belongs to, when it belongs to one.' },
|
||||
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
|
||||
description: 'When the occurrence is due to start, UTC.' },
|
||||
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
|
||||
description: 'The shard-local zone the schedule was authored in.' },
|
||||
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
|
||||
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
|
||||
// string — correct as data and unreadable in a mail, and a template has no
|
||||
// logic with which to format it. So the formatting happens at the emitter,
|
||||
// in the shard-local zone, and arrives as a variable whose `example` shows
|
||||
// exactly what it produces. Same trade `forWhom` makes in the auth bodies.
|
||||
{ name: 'startsAtLabel', type: 'string', required: false,
|
||||
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
|
||||
description: 'The start time written out in the shard-local zone, for a mail to read.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.started',
|
||||
label: 'Event — starting now',
|
||||
description: 'A scheduled event has begun.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
|
||||
description: 'The event summary, as authored.' },
|
||||
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
|
||||
description: 'The arc this event belongs to, when it belongs to one.' },
|
||||
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
|
||||
description: 'When it actually started, UTC.' },
|
||||
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
|
||||
description: 'The shard-local zone the schedule was authored in.' },
|
||||
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
|
||||
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
|
||||
// string — correct as data and unreadable in a mail, and a template has no
|
||||
// logic with which to format it. So the formatting happens at the emitter,
|
||||
// in the shard-local zone, and arrives as a variable whose `example` shows
|
||||
// exactly what it produces. Same trade `forWhom` makes in the auth bodies.
|
||||
{ name: 'startsAtLabel', type: 'string', required: false,
|
||||
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
|
||||
description: 'The start time written out in the shard-local zone, for a mail to read.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.phase.changed',
|
||||
label: 'Event — a new phase',
|
||||
description: 'An event that is under way has moved on to its next stage.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
{ name: 'phase', type: 'string', required: true, example: 'assault',
|
||||
description: 'The phase key just entered, as authored in the spec.' },
|
||||
{ name: 'phaseLabel', type: 'string', required: false, example: 'The assault',
|
||||
description: 'The phase label, when the spec gave it one. Falls back to the key.' },
|
||||
{ name: 'phaseIndex', type: 'int', required: true, example: 2,
|
||||
description: 'Which phase this is, counting from 1.' },
|
||||
{ name: 'phaseCount', type: 'int', required: true, example: 4,
|
||||
description: 'How many phases the pinned version has in total.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.ending',
|
||||
label: 'Event — winding down',
|
||||
description: 'An event is drawing to a close.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.completed',
|
||||
label: 'Event — finished',
|
||||
description: 'An event has finished.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
|
||||
description: 'The event summary, as authored.' },
|
||||
// Counted from `event_run_participants` at emit. Zero on a run whose
|
||||
// module reported nobody, which is every run until a module collects —
|
||||
// a template that says "47 took part" needs a number that is never
|
||||
// missing, and "0" is the honest one.
|
||||
{ name: 'participantCount', type: 'int', required: true, example: 47,
|
||||
description: 'How many participants the run recorded. Zero when nothing collected any.' },
|
||||
{ name: 'durationMinutes', type: 'int', required: true, example: 95,
|
||||
description: 'How long the run took, start to end, in whole minutes.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.cancelled',
|
||||
label: 'Event — cancelled',
|
||||
description: 'A scheduled event was cancelled by a member of staff.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
// **The operator's reason, and not the run's `last_error`.** `cancel`
|
||||
// takes a `{ reason }` a human typed for other humans; a diagnostic
|
||||
// string is for the run console and would read as gibberish in a mail.
|
||||
{ name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.',
|
||||
description: 'What the staff member gave as the reason, when they gave one.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'event.run.failed',
|
||||
label: 'Event — run failed',
|
||||
description: 'An event stopped before it finished.',
|
||||
kind: 'event',
|
||||
subjectKey: 'runId',
|
||||
// **`admin`, and both halves of that.** The ceiling is the security
|
||||
// boundary (§J, G24): no rule may ever widen this past admins, because a
|
||||
// failure names the deployment's own broken machinery. The default audience
|
||||
// matches, so a rule created from this trigger starts where it must end.
|
||||
audience: 'admin',
|
||||
ceiling: 'admin',
|
||||
version: 1,
|
||||
variables: [
|
||||
{ name: 'runId', type: 'string', required: true, example: '3692',
|
||||
description: 'The run this is about. Also the cooldown subject.' },
|
||||
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
|
||||
description: 'The event title.' },
|
||||
{ name: 'phase', type: 'string', required: false, example: 'assault',
|
||||
description: 'The phase it failed in, when it had entered one.' },
|
||||
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503',
|
||||
description: 'The run’s last error, verbatim from the run row.' },
|
||||
// The one url variable in this file's Phase 10 block, and the reason is
|
||||
// that this route exists TODAY. See the note above the six.
|
||||
{ name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692',
|
||||
description: 'Site-relative path to the run console.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { TRIGGERS }
|
||||
|
||||
Reference in New Issue
Block a user