// ── 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. 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/` 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. // // **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/`, 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 }