Three defects the acceptance walk found in shipped code. **The public calendar showed neither what is live nor what is recent.** §I says `GET /public/events` is "the calendar: upcoming, **live** and **recent**". Built, it was upcoming only: `listInWindow` filtered on `scheduled_for >= from` alone and the shipped page asks for no window at all, so it took the default of now → +31d. A run that began five minutes ago and has three hours to go was absent; so was one that ended an hour ago. The site contradicted itself — `live: true` on `/site/events/<slug>` while `/site/events` served `entries: []`. A run is an interval, not an instant. `listInWindow` now matches a run whose occupied interval OVERLAPS the window, which fixes the admin calendar's identical hole (a run that started last Sunday and is still going was missing from "this week"), and the public default reaches `DEFAULT_RECENT_DAYS` back so "recent" has somewhere to live. Forecasts are still computed from `now`, never from the tail: a projection into the past would advertise an occurrence that did not happen. **A resource left `reverting` by a crash was never reclaimed.** `claimRevert`'s comment said `reverting` is not claimable "exactly as a step with a live claim is" — but a step's claim carries `claim_expires_at` and is reclaimed when the lease lapses, and a resource in `reverting` had no expiry and nothing released it. A process killed mid-teardown stranded the row for good: the sweep skipped it every 15s for ever, `cleanup_status` never left `pending`, and `POST …/cleanup` — the recourse §I names — answered 200 and did nothing, because it claims through the same function. On the rig it stranded a lease, which then BLOCKED the next run of the same event from taking that value until the shard's own deadline lapsed. The stale test is `updated_at`, which for a `reverting` row is exactly when the claim was taken, so no column is added. `updated_at` is re-stamped explicitly and that is load-bearing rather than tidy: this connector sends `CLIENT_FOUND_ROWS`, so without the write a second claimer would still match the row. `revert_attempts` is untouched — a stale claim is a process that died, not an attempt that failed. **Three facts every event announcement computed and none could use.** `announce.js` `baseFor()` puts `summary`, `seriesName` and `timezone` on all seven `event.*` payloads, but four triggers declared none of them and a fifth declared one, so `validatePayload` dropped them, they were absent from the variable list an author picks from, and every emit logged `emit carried undeclared variables` at DEBUG. They are now one shared `EVENT_AMBIENT` declaration spread into all seven, with the per-trigger copies removed so the seven cannot drift. Verified against a real ServUO + sidecar + website rig: the public page now shows a live run as "Happening now" beside recent finished ones (it showed nothing at all before), and a lease stranded by a real mid-teardown crash was reclaimed within one sweep, taking `cleanup_status` from `pending` to `complete`. The three `claimRevert` tests live in `eventRunnerSql.test.js` against a real MariaDB, because every part of the answer is the server's — `NOW() - INTERVAL`, `ON UPDATE`, and above all what `affectedRows` counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
441 lines
23 KiB
JavaScript
441 lines
23 KiB
JavaScript
// ── Core's own engagement triggers ─────────────────────────────────────────
|
||
//
|
||
// ENGAGEMENT.md §4.3 and Phase 2. The twin of config/coreStreams.js, and
|
||
// deliberately the SAME FIVE IDS — that is the org lead's §7.2 decision, taken at
|
||
// the start of this phase: **one namespace.** A trigger is not a second thing
|
||
// standing next to a stream; it is a payload contract attached to an id that may
|
||
// also carry a subscription toggle. `news.post` names one event, whether the
|
||
// question being asked of it is "may I push this?" or "what may a template
|
||
// interpolate?".
|
||
//
|
||
// What that buys, concretely: `notification_channel_prefs.stream_id` (§4.5) stays
|
||
// single-keyed. Under two namespaces it would have needed a `kind` discriminator
|
||
// in its primary key, and `news.post` would have named two different things
|
||
// forever.
|
||
//
|
||
// What it costs is the rule enforced in registries.js: an id has ONE owner across
|
||
// both facets, so a module cannot attach a payload contract to another module's
|
||
// stream, and core cannot attach one to a module's. Core's five ids below are
|
||
// already core's five streams, so all five are the same-owner upgrade case.
|
||
//
|
||
// **These declare; nothing here emits yet.** Phase 2 is the contract only — the
|
||
// Team pipeline keeps its own hardcoded mail until Phase 6 migrates it onto the
|
||
// engine, and this file is what it migrates ONTO. Registering the declarations a
|
||
// phase early is the same decision registerCore() has always taken: a registry
|
||
// whose first real exercise is a module is a registry that has already drifted.
|
||
//
|
||
// Every variable carries an `example`, and that is required rather than
|
||
// decorative (§4.3 property 3). It is what lets the template editor preview and
|
||
// test-send without a live game event, which is the reason template systems go
|
||
// untested.
|
||
|
||
/**
|
||
* The three facts `events/announce.js` puts on EVERY `event.*` payload, declared
|
||
* once because they are spread into all seven.
|
||
*
|
||
* `baseFor()` has always computed them and nothing declared them, so
|
||
* `engagementEmit.validatePayload` dropped all three before a template could see
|
||
* one — they were absent from the variable list an author picks from, and every
|
||
* single event emit logged `emit carried undeclared variables`. Found by the
|
||
* Phase 16 acceptance walk, in the DEBUG line it had been writing all along.
|
||
*
|
||
* All three are optional, and each for its own reason rather than by default: an
|
||
* event need not carry a summary, most events belong to no series, and a run
|
||
* whose definition has been deleted resolves no zone.
|
||
*/
|
||
const EVENT_AMBIENT = [
|
||
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
|
||
description: 'The event’s one-line summary, when it has one.' },
|
||
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
|
||
description: 'The arc this event belongs to, when it belongs to one.' },
|
||
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
|
||
description: 'The zone the run was computed in — what a time in the body should be read as.' },
|
||
]
|
||
|
||
const TRIGGERS = [
|
||
{
|
||
id: 'news.post',
|
||
label: 'News post published',
|
||
description: 'A news / Five-on-Friday / newsletter post was published.',
|
||
kind: 'event',
|
||
// No subjectKey. The subject of a cooldown here is the USER, not the post —
|
||
// "do not mail me about news more than once an hour" is the useful rule, and
|
||
// keying it per post would make every cooldown a no-op. Compare the four
|
||
// Team triggers below, where the Team genuinely is the subject.
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: 1,
|
||
variables: [
|
||
{ name: 'title', type: 'string', required: true, example: 'Five on Friday — the Yew invasion',
|
||
description: 'The post title.' },
|
||
{ name: 'excerpt', type: 'string', required: false, example: 'Four new champion spawns, and the fate of the Yew moongate…',
|
||
description: 'A plain-text summary, already stripped of markup.' },
|
||
{ name: 'category', type: 'string', required: false, example: 'Five on Friday',
|
||
description: 'The post category, when it has one.' },
|
||
// **`/site/news`, the LIST, and not a per-post path.** The example said
|
||
// `/news/<slug>` when this was declared with no caller; Phase 11 gave it
|
||
// one and the path turned out not to exist — `App.jsx` mounts `/site/news`
|
||
// and nothing under it, which is why `announceJobs.logic.js` links the list
|
||
// from the Discord and town-crier announcements too. An `example` is what
|
||
// the template editor previews and test-sends with (§4.3 property 3), so an
|
||
// example naming a 404 is a preview that looks right and a mail that is not.
|
||
{ name: 'postUrl', type: 'url', required: true, example: '/site/news',
|
||
description: 'Site-relative path to the post. The news list today — the site has no per-post route.' },
|
||
],
|
||
},
|
||
|
||
// ── Teams (TEAMS.md Part 6) ─────────────────────────────────────────────
|
||
//
|
||
// All four ceiling at `members` and not one of them higher. Who may be told
|
||
// about a Team event is the access resolver's answer and always has been
|
||
// (coreStreams.js says the same thing about the push catalog); the ceiling is
|
||
// that rule written where a RULE EDITOR has to obey it too. Without it an
|
||
// operator could point a rule at `authenticated` and mail a private Team's
|
||
// forum excerpt to the whole site.
|
||
{
|
||
id: 'team.member.joined',
|
||
label: 'Team — new member',
|
||
description: 'Someone joined a Team.',
|
||
kind: 'event',
|
||
subjectKey: 'teamName',
|
||
audience: 'members',
|
||
ceiling: 'members',
|
||
version: 1,
|
||
variables: [
|
||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||
{ name: 'memberName', type: 'string', required: true, example: 'Darrow',
|
||
description: 'Display name of the member who joined.' },
|
||
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
|
||
description: 'Site-relative path to the Team page. Absent when no module supplies a pageUrlTemplate.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'team.leadership.changed',
|
||
label: 'Team — leadership change',
|
||
description: 'Leadership changed in a Team.',
|
||
kind: 'event',
|
||
subjectKey: 'teamName',
|
||
audience: 'members',
|
||
ceiling: 'members',
|
||
version: 1,
|
||
variables: [
|
||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||
{ name: 'leaderName', type: 'string', required: true, example: 'Marisol',
|
||
description: 'Display name of the new leader.' },
|
||
{ name: 'teamUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil',
|
||
description: 'Site-relative path to the Team page.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'team.forum.post',
|
||
label: 'Team — new forum post',
|
||
description: 'A new thread or reply in a Team forum.',
|
||
kind: 'event',
|
||
subjectKey: 'teamName',
|
||
audience: 'members',
|
||
ceiling: 'members',
|
||
version: 1,
|
||
variables: [
|
||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||
{ name: 'authorName', type: 'string', required: true, example: 'Darrow',
|
||
description: 'Display name of the poster.' },
|
||
{ name: 'threadTitle', type: 'string', required: true, example: 'Tuesday champ rotation',
|
||
description: 'Title of the thread the post belongs to.' },
|
||
{ name: 'excerpt', type: 'string', required: false, example: 'Moving the Tuesday run an hour later…',
|
||
description: 'Plain-text excerpt of the post body, already stripped of markup.' },
|
||
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/412',
|
||
description: 'Site-relative path to the post.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'team.announcement',
|
||
label: 'Team — announcement',
|
||
description: 'A leader posted an announcement in a Team.',
|
||
kind: 'event',
|
||
subjectKey: 'teamName',
|
||
audience: 'members',
|
||
ceiling: 'members',
|
||
version: 1,
|
||
variables: [
|
||
{ name: 'teamName', type: 'string', required: true, example: 'The Silver Anvil',
|
||
description: 'The Team the event is about. Also the cooldown subject.' },
|
||
{ name: 'authorName', type: 'string', required: true, example: 'Marisol',
|
||
description: 'Display name of the leader who posted.' },
|
||
{ name: 'title', type: 'string', required: true, example: 'Siege practice moved to Sunday',
|
||
description: 'The announcement title.' },
|
||
{ name: 'excerpt', type: 'string', required: false, example: 'We are moving practice to Sunday 8pm…',
|
||
description: 'Plain-text excerpt of the announcement body.' },
|
||
{ name: 'postUrl', type: 'url', required: false, example: '/guilds/the-silver-anvil/forum/419',
|
||
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.
|
||
//
|
||
// **All six public ones now declare `eventUrl`, and Phase 14a is what made
|
||
// that legal.** Until it there was no public event page at all — `App.jsx`
|
||
// mounted nothing under `/site/events` — and `news.post` had already paid for
|
||
// that mistake once: its `postUrl` example named `/news/<slug>`, a path that
|
||
// did not exist, so the template editor previewed a link that was dead in
|
||
// every mail it sent. The variable arrived with the page it points at, which
|
||
// is what makes this a version bump (1 -> 2) rather than a correction.
|
||
//
|
||
// **It carries `?run=`, and the query string is the whole reason it is a run
|
||
// url and not an event url.** The page lives at the DEFINITION's slug, so a
|
||
// weekly event has one stable address — but every one of these triggers is
|
||
// about one OCCURRENCE, and a mail about last Friday's invasion whose link
|
||
// opened next Friday's would answer a different question from the one the
|
||
// reader clicked. `run.failed` keeps its own `runUrl` into the admin console
|
||
// and gains nothing here: an admin reading about broken machinery wants the
|
||
// console, not the storyline.
|
||
{
|
||
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: 2,
|
||
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.' },
|
||
...EVENT_AMBIENT,
|
||
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
|
||
description: 'When the occurrence is due to start, UTC.' },
|
||
// **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.' },
|
||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||
// base, and an absolute one baked in here would be wrong on every
|
||
// deployment but the first.
|
||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||
description: 'The public page for this occurrence.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'event.run.started',
|
||
label: 'Event — starting now',
|
||
description: 'A scheduled event has begun.',
|
||
kind: 'event',
|
||
subjectKey: 'runId',
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: 2,
|
||
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.' },
|
||
...EVENT_AMBIENT,
|
||
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
|
||
description: 'When it actually started, UTC.' },
|
||
// **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.' },
|
||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||
// base, and an absolute one baked in here would be wrong on every
|
||
// deployment but the first.
|
||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||
description: 'The public page for this occurrence.' },
|
||
],
|
||
},
|
||
{
|
||
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: 2,
|
||
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.' },
|
||
...EVENT_AMBIENT,
|
||
{ 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.' },
|
||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||
// base, and an absolute one baked in here would be wrong on every
|
||
// deployment but the first.
|
||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||
description: 'The public page for this occurrence.' },
|
||
],
|
||
},
|
||
{
|
||
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: 2,
|
||
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.' },
|
||
...EVENT_AMBIENT,
|
||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||
// base, and an absolute one baked in here would be wrong on every
|
||
// deployment but the first.
|
||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||
description: 'The public page for this occurrence.' },
|
||
],
|
||
},
|
||
{
|
||
id: 'event.run.completed',
|
||
label: 'Event — finished',
|
||
description: 'An event has finished.',
|
||
kind: 'event',
|
||
subjectKey: 'runId',
|
||
audience: 'subscribers',
|
||
ceiling: 'authenticated',
|
||
version: 2,
|
||
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.' },
|
||
...EVENT_AMBIENT,
|
||
// 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.' },
|
||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||
// base, and an absolute one baked in here would be wrong on every
|
||
// deployment but the first.
|
||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||
description: 'The public page for this occurrence.' },
|
||
],
|
||
},
|
||
{
|
||
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: 2,
|
||
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.' },
|
||
...EVENT_AMBIENT,
|
||
// **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.' },
|
||
// The public page for THIS occurrence (Phase 14a). Relative, like
|
||
// `postUrl` and `runUrl`: the seam resolves it against the site's own
|
||
// base, and an absolute one baked in here would be wrong on every
|
||
// deployment but the first.
|
||
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
|
||
description: 'The public page for this occurrence.' },
|
||
],
|
||
},
|
||
{
|
||
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.' },
|
||
...EVENT_AMBIENT,
|
||
{ 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 admin console, not the public page — and this trigger gains no
|
||
// `eventUrl` at all. An admin reading that the machinery broke wants the
|
||
// steps and the errors, not the storyline. 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 }
|