feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
Some checks failed
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Failing after 5m47s
PR Checks / bot-tests (pull_request) Successful in 8m27s

`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:
2026-09-04 13:06:46 -05:00
parent d4516739b4
commit 7d3d6d5abd
36 changed files with 2960 additions and 39 deletions

View File

@@ -156,11 +156,27 @@ async function resolveForRule(rule, event) {
* same id more tightly. That is precisely the case where a stale rule would
* otherwise mail a population the current declaration forbids, which is what
* makes this the security boundary rather than a duplicate check.
*
* **`emitted` is the second thing this gate now weighs** (Phase 10). A firing may
* carry a ceiling of its own — a rehearsal's `staff` (EVENTS.md §I) — and the
* effective bound is the MEET of the two, so a firing can only ever narrow what
* the declaration allows. Two incomparable ceilings meet to null and the gate
* refuses: `owner` and `staff` have no common descendant, and picking one would
* be the guess §5.1a rule 3 exists to refuse. That is also why an unknown value
* cannot get here — `emit` validates it against the same lattice — but the null
* is handled anyway, because this is the boundary and a boundary that trusts its
* caller is not one.
*
* @param {string} triggerId
* @param {string} ceiling the audience the rule resolved to
* @param {string|null} [emitted] a narrowing ceiling this firing carries
*/
function permitted(triggerId, ceiling) {
function permitted(triggerId, ceiling, emitted = null) {
const declaration = registries.eventTrigger(triggerId)
if (!declaration) return false
return ceilings.permits(declaration.ceiling, ceiling)
const bound = emitted ? ceilings.meet(declaration.ceiling, emitted) : declaration.ceiling
if (!bound) return false
return ceilings.permits(bound, ceiling)
}
module.exports = { resolveForRule, permitted, defaultOnChannels }

View File

@@ -1,4 +1,4 @@
// ── The five rules core ships, all of them OFF ────────────────────────────
// ── The seven rules core ships, all of them OFF ────────────────────────────
//
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
// people with no operator configuration at all: the code decided who was mailed
@@ -34,6 +34,11 @@
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
// existing group is a rule that only fresh installs will ever see.
//
// **EVENTS.md Phase 10 added two more, and a third key**, for the event
// lifecycle: `event.run.started` and `event.run.failed`. Same argument, third
// application — a deployment that has already stamped the news key must still
// see these.
const rulesDb = require('../model/engagement/engagementRules.db')
const settingsDb = require('../model/settings/settings.db')
@@ -46,6 +51,9 @@ const SEEDED_KEY = 'engagement_team_rules_seeded'
// Phase 11's, and separate for the reason above. Same shape, same semantics.
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
// EVENTS.md Phase 10's, third group, third key.
const EVENT_SEEDED_KEY = 'engagement_event_rules_seeded'
const RULES = [
{
trigger_id: 'team.forum.post',
@@ -145,6 +153,73 @@ const NEWS_RULES = [
},
]
// Phase 10's two, in their own list under their own one-shot key — the rule
// Phase 11 established, applied for the second time. Appending to `NEWS_RULES`
// would seed these on fresh installs only and on exactly the upgrades that want
// them, never.
//
// **Two rules for seven triggers, and that is the whole decision** (org lead,
// 2026-09-04). Every one of the seven is declared, so an operator can write a
// rule against any of them from the rules screen; what is SEEDED is the pair
// somebody would otherwise have to build from scratch on the first day — the
// player-facing "it is starting" and the staff-facing "it broke". Seeding all
// seven would grow Admin → Engagement → Rules by seven disabled rows nobody
// asked for, and `event.phase.changed` is the one most likely to be switched on
// by accident and then mail a player four times in one evening.
const EVENT_RULES = [
{
trigger_id: 'event.run.started',
name: 'Events — starting now',
// `subscribers`, the trigger's own default: people who opted into this id on
// at least one channel. Not `authenticated`, even though the ceiling permits
// it — an event is worth telling people who asked to be told about events,
// and mailing the whole user table every Saturday night is how a feature
// earns a spam complaint. An operator who wants the whole site can widen it;
// the ceiling is what stops them widening it past that.
audience: 'subscribers',
// All three, like the news rule and for the same reason: push is the channel
// that gets somebody to log in *now*, which is the entire point of a
// "come back for this" notice (ENGAGEMENT.md §8.5), and the in-app inbox is
// the surface a content-free tickle deep-links into.
channels: ['email', 'inapp', 'push'],
// The one bespoke body this phase seeds; see `templateSeeds.js` for why it
// is one and not seven. `inapp.event` is the in-app renderer's generic, and
// push carries no content by construction and needs no template.
template_keys: { email: 'notify.event-started', inapp: 'inapp.event', digest: 'notify.digest' },
// An hour, per user PER RUN — `event.run.started` declares `subjectKey:
// 'runId'`, so the cooldown subject is the run and not the recipient. It is
// near-redundant on a trigger that fires once per run, which is the point:
// it costs nothing and it is the guard if a run is ever restarted.
cooldown_seconds: 3600,
max_sends_per_hour: 1000,
},
{
trigger_id: 'event.run.failed',
name: 'Events — a run failed',
// `admin`, which is both the trigger's default and its ceiling. A failed run
// names the deployment's own broken machinery — a sidecar that did not
// answer, a step that ran out of attempts — and there is no widening of this
// that is not a disclosure.
audience: 'admin',
// No push. An admin's phone buzzing at four in the morning for a step that
// will still be failed at breakfast is a notification people switch off
// wholesale, and switching it off wholesale is how the one that mattered is
// missed. Mail and the inbox both wait.
channels: ['email', 'inapp'],
// The generic body plus the structural projection: `event.run.failed`
// declares its own `title` and a `runUrl`, so an unauthored mail is already
// headed with the event's name and buttoned through to the run console —
// §4.6.1 property 1, working exactly as it promises.
template_keys: { email: 'notify.event', inapp: 'inapp.event' },
// **No cooldown, and this is the one rule in the file that must not have
// one.** The subject is the run, so a cooldown would only ever suppress a
// second failure of the SAME run — which is precisely the run an
// administrator most needs the second line about.
cooldown_seconds: 0,
max_sends_per_hour: 200,
},
]
/**
* Seed one group of rules, once, under its own guard key.
*
@@ -185,7 +260,7 @@ async function seedGroup(key, rules, note) {
})
summary.inserted += 1
} catch (err) {
log.error('team rule seed failed', { trigger: rule.trigger_id, message: err.message })
log.error('rule seed failed', { key, trigger: rule.trigger_id, message: err.message })
}
}
// Stamped even on a partial run — the claim above is the stamp. Re-running
@@ -209,6 +284,10 @@ const seedTeamRules = () =>
const seedNewsRule = () =>
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
/** The two event-lifecycle rules (EVENTS.md Phase 10). */
const seedEventRules = () =>
seedGroup(EVENT_SEEDED_KEY, EVENT_RULES, 'Event notifications stay off until an operator enables one')
/**
* Both groups, which is what the boot path calls.
*
@@ -219,9 +298,10 @@ const seedNewsRule = () =>
async function seedCoreRules() {
const team = await seedTeamRules()
const news = await seedNewsRule()
const events = await seedEventRules()
return {
inserted: team.inserted + news.inserted,
skipped: team.skipped + news.skipped,
inserted: team.inserted + news.inserted + events.inserted,
skipped: team.skipped + news.skipped + events.skipped,
}
}
@@ -229,8 +309,11 @@ module.exports = {
seedCoreRules,
seedTeamRules,
seedNewsRule,
seedEventRules,
RULES,
NEWS_RULES,
EVENT_RULES,
SEEDED_KEY,
NEWS_SEEDED_KEY,
EVENT_SEEDED_KEY,
}

View File

@@ -150,13 +150,21 @@ async function applyRule(rule, event, now) {
// G24, re-run at send time. A rule saved when its trigger permitted a wider
// audience must not keep reaching it after a module upgrade narrowed the
// declaration - and that is the only way this can fail, since the save path
// declaration - and that was the only way this could fail, since the save path
// ran the same check.
if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
//
// **Phase 10 gave it a second way, and it is the one that fires in practice:**
// the event may carry a narrowing ceiling of its own. A rehearsal emits
// `event.run.started` with `ceiling: 'staff'`, and every rule an operator wrote
// for the real thing is then refused here rather than mailing subscribers about
// an event that is not happening. Nothing about the rule changed; the occasion
// did. See `audiences.permitted`.
if (!audiences.permitted(event.triggerId, resolved.ceiling, event.ceiling)) {
log.warn('rule audience exceeds its trigger ceiling - refusing', {
rule: rule.id,
trigger: event.triggerId,
audience: resolved.ceiling,
emitted: event.ceiling || null,
})
summary.skipped = 'ceiling'
return summary

View File

@@ -310,6 +310,56 @@ const SEEDS = [
button('cta', 'Open', '{{actionUrl}}'),
],
},
// ── The event system (EVENTS.md §J — Phase 10) ─────────────────────────
//
// **One body, not seven.** Six of the seven `event.` triggers render through
// `notify.event` and the structural projection with no authoring at all
// (§4.6.1 property 1) — they declare their own `title`, so an unauthored mail
// is already headed with the event's name — and seeding a bespoke body per
// trigger would be seven templates an operator has to maintain to change one
// sentence.
//
// `event.run.started` gets one because it is the flagship: the mail that
// answers §8.5's *"Come back for X — a scheduled event is starting"*, the one
// an operator will actually enable, and the one where the generic body reads
// visibly worse — `notify.event` renders the title over the TRIGGER's
// description, while this reads the payload's own names and says what is
// starting, when, and what arc it belongs to. Same argument `notify.team-post`
// makes beside the generic body, one feature along.
//
// **Every optional line is one token on its own**, which is this template
// language's whole conditional (see `email.text`: a block whose content is a
// single absent variable renders nothing, in both parts). A standalone event
// has no `seriesName` and its line disappears rather than reading "Part of .".
//
// **No `{{actionUrl}}` and no button, deliberately.** There is no public event
// page until Phase 14, so the six public triggers declare no `url` variable at
// all (see `coreTriggers.js`), and a button here would render as an inert grey
// label in every mail — worse than none, because it advertises a link the
// reader cannot follow. Phase 14 adds the variable and the block together.
{
key: 'notify.event-started',
name: 'Event starting',
channel: 'email',
protected: false,
seedVersion: 1,
subject: '{{title}} is starting',
variables: [
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
{ name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
heading('h', '{{title}}'),
text('summary', '{{summary}}'),
text('when', '{{startsAtLabel}}', { muted: true }),
text('series', '{{seriesName}}', { muted: true }),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
],
},
]
/** @returns {object|null} the seed definition for `key`. */