`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)
320 lines
15 KiB
JavaScript
320 lines
15 KiB
JavaScript
// ── 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
|
|
// and about what, and the only knobs were per-user. Phase 6 moves that decision
|
|
// onto rules — which default `enabled = 0`, and of which core seeds none.
|
|
//
|
|
// **So a straight migration would have stopped Team email on every existing
|
|
// deployment, silently.** The org lead's decision was to honour the invariant
|
|
// rather than carve an exception into it: the rules are seeded, and they are
|
|
// seeded OFF. Team email resumes when an operator opens Admin → Engagement →
|
|
// Rules and switches one on, and until then the admin screen says so in as many
|
|
// words (`EngagementRules.jsx`). The release note names it.
|
|
//
|
|
// The alternative — seeding them enabled so nothing changes for anybody — was
|
|
// considered and refused. "Nothing is seeded, nothing is on by default" is what
|
|
// makes a rules table safe to restore, import or replicate, and an exception
|
|
// carved for the one pipeline that predates the engine is an exception that has
|
|
// to be re-argued every time somebody reads the invariant.
|
|
//
|
|
// **Seeded once, not ensured on every boot**, and the difference matters: an
|
|
// operator who deletes a rule must not find it back after a restart. The guard is
|
|
// a settings key, the same mechanism a one-shot migration uses, so a deployment
|
|
// that has seen this seed never sees it again — deleted rules stay deleted, and
|
|
// an enabled rule stays enabled rather than being reset to off.
|
|
|
|
// **Phase 11 added a fifth, for `news.post`, and it needed its OWN one-shot key
|
|
// rather than an entry in the list above.** The Team key is already stamped on
|
|
// every deployment that has booted since Phase 6, and the guard reads its
|
|
// presence — so appending to `RULES` would have seeded the news rule on fresh
|
|
// installs only, and on exactly the upgrades that need it, never. Those are the
|
|
// deployments where `pushDispatch.publish('news.post', …)` used to run and no
|
|
// longer does (§7.1 Q9): they would have lost news push with no rule to switch
|
|
// 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')
|
|
const log = require('../utils/logger')('engagement')
|
|
|
|
// The one-shot guard. Its VALUE is the timestamp, purely so an operator reading
|
|
// the settings table can tell when it ran; only its presence is read.
|
|
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',
|
|
name: 'Team forum posts',
|
|
// `members`, which resolves to the recipient set the event carries — the
|
|
// access-checked list `teamNotify` has always computed. Not `authenticated`,
|
|
// and the trigger's own ceiling would refuse that anyway: a private Team's
|
|
// forum excerpt reaching the whole site is the failure G24 exists for.
|
|
audience: 'members',
|
|
channels: ['email'],
|
|
// `email` is the instant body; `digest` is what the digest worker renders.
|
|
// Two keys because they are two different messages — a template written for
|
|
// one post renders a day of them as a single missing variable.
|
|
template_keys: { email: 'notify.team-post', digest: 'notify.digest' },
|
|
// No cooldown. A busy thread is exactly what the per-user `email_mode` and
|
|
// the digest option are for, and a cooldown here would silently drop the
|
|
// second reply of a conversation rather than batching it.
|
|
cooldown_seconds: 0,
|
|
max_sends_per_hour: 500,
|
|
},
|
|
{
|
|
trigger_id: 'team.announcement',
|
|
name: 'Team announcements',
|
|
audience: 'members',
|
|
channels: ['email'],
|
|
// **The generic body, not `notify.team-post`, and the reason is a naming
|
|
// inconsistency in the Phase 2 declarations rather than a design choice
|
|
// here.** The two triggers describe the same underlying thing — a thread in a
|
|
// Team forum — but `team.forum.post` declares its title as `threadTitle` and
|
|
// `team.announcement` declares it as `title`. A template can only name one of
|
|
// them, so `notify.team-post`'s `{{threadTitle}}` renders empty for an
|
|
// announcement. `notify.event` + the structural projection gets it right
|
|
// (`title` is in the payload, `actionUrl` falls back to `postUrl`), and
|
|
// reconciling the two declarations is a version bump this phase did not take
|
|
// on its own authority.
|
|
template_keys: { email: 'notify.event', digest: 'notify.digest' },
|
|
cooldown_seconds: 0,
|
|
max_sends_per_hour: 500,
|
|
},
|
|
{
|
|
trigger_id: 'team.member.joined',
|
|
name: 'Team — new member',
|
|
audience: 'members',
|
|
channels: ['email'],
|
|
// The generic body: `notify.event` plus the structural projection renders it
|
|
// with no authoring (§4.6.1 property 1). A deployment that wants a better one
|
|
// duplicates the template and points this rule at the copy.
|
|
template_keys: { email: 'notify.event' },
|
|
// An hour, per user per Team. This is the rule §6.4 argued should not exist
|
|
// as a sink at all — a fifteen-minute sweep, already on the activity feed —
|
|
// and the cooldown is what makes it survivable for the operator who wants it
|
|
// anyway: a guild recruiting ten people in an afternoon sends one mail.
|
|
cooldown_seconds: 3600,
|
|
max_sends_per_hour: 200,
|
|
},
|
|
{
|
|
trigger_id: 'team.leadership.changed',
|
|
name: 'Team — leadership change',
|
|
audience: 'members',
|
|
channels: ['email'],
|
|
template_keys: { email: 'notify.event' },
|
|
cooldown_seconds: 3600,
|
|
max_sends_per_hour: 200,
|
|
},
|
|
]
|
|
|
|
// Phase 11's one rule, in its own list so it can carry its own one-shot key.
|
|
const NEWS_RULES = [
|
|
{
|
|
trigger_id: 'news.post',
|
|
name: 'News posts',
|
|
// `subscribers`, which is the trigger's declared default and the population
|
|
// `pushDispatch.publish('news.post', …)` used to reach directly: users who
|
|
// opted into this id on at least one channel. Not `authenticated`, even
|
|
// though the trigger's ceiling permits it — a news post is worth telling
|
|
// people who asked to be told, and mailing the whole user table on every
|
|
// publish is how a notification feature earns a spam complaint.
|
|
audience: 'subscribers',
|
|
// **All three channels, unlike the Team rules' `email` alone**, and that is
|
|
// the continuity half of §7.1 Q9's answer. Push is on this rule because push
|
|
// is what the raw tickle did; leaving it off would mean an operator who
|
|
// enabled the rule to restore news push got mail instead. In-app rides along
|
|
// because the inbox is the surface a tickle deep-links into (Phase 7).
|
|
channels: ['email', 'inapp', 'push'],
|
|
// The generic body plus the structural projection (§4.6.1 property 1):
|
|
// `news.post` declares its own `title` and `postUrl`, which the projection
|
|
// leaves exactly as emitted, so an unauthored mail already names the post and
|
|
// links it. `inapp.event` is the in-app renderer's; push carries no content
|
|
// by construction and needs no template.
|
|
template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' },
|
|
// An hour, per USER — `news.post` declares no `subjectKey`, so the cooldown
|
|
// subject is the recipient. "Do not tell me about news more than once an
|
|
// hour" is the useful rule; keying it per post would make it a no-op, since
|
|
// every post is a new subject.
|
|
cooldown_seconds: 3600,
|
|
max_sends_per_hour: 1000,
|
|
},
|
|
]
|
|
|
|
// 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.
|
|
*
|
|
* Never throws: it is on the boot path beside `seedTemplates`, and a rule that
|
|
* failed to seed costs an operator one visit to the "new rule" form, not a
|
|
* deployment.
|
|
*
|
|
* @param {string} key the one-shot settings guard for THIS group
|
|
* @param {object[]} rules
|
|
* @param {string} note what the boot log should say when it inserts
|
|
*/
|
|
async function seedGroup(key, rules, note) {
|
|
const summary = { inserted: 0, skipped: 0 }
|
|
try {
|
|
// **Claimed BEFORE the loop, atomically**, and the stamp is the claim. A
|
|
// `get()` here with a `set()` after the inserts is not a guard when two
|
|
// instances boot together — both read "absent", both seed — and a duplicate
|
|
// rule is two mails per event. `claim()` is an `INSERT IGNORE` reporting its
|
|
// own `affectedRows`, so exactly one caller proceeds. See the note below on
|
|
// what a partial run costs: that trade is unchanged, only its ordering.
|
|
if (!(await settingsDb.claim(key, new Date().toISOString()))) {
|
|
return { ...summary, skipped: rules.length }
|
|
}
|
|
|
|
for (const rule of rules) {
|
|
try {
|
|
await rulesDb.insert({
|
|
audience_segment_id: null,
|
|
conditions: null,
|
|
// No delay and nothing cancels these. `delay_seconds` is the grace
|
|
// window a cancelling event needs, and nothing cancels "someone
|
|
// posted" — the post happened.
|
|
delay_seconds: 0,
|
|
cancel_on: [],
|
|
...rule,
|
|
enabled: 0,
|
|
updated_by: null,
|
|
})
|
|
summary.inserted += 1
|
|
} catch (err) {
|
|
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
|
|
// would duplicate the rules that did insert, and a duplicate rule is two
|
|
// mails per event, a worse outcome than the one missing rule an operator can
|
|
// add from the screen.
|
|
if (summary.inserted) {
|
|
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
|
|
}
|
|
} catch (err) {
|
|
log.error('rule seeding failed', { key, message: err.message })
|
|
}
|
|
return summary
|
|
}
|
|
|
|
/** The four Team rules (Phase 6). */
|
|
const seedTeamRules = () =>
|
|
seedGroup(SEEDED_KEY, RULES, 'Team email stays off until an operator enables one')
|
|
|
|
/** The one news rule (Phase 11). */
|
|
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.
|
|
*
|
|
* Sequential rather than concurrent, and not for correctness — each group has its
|
|
* own guard key and its own rows — but so the boot log reads in a fixed order and
|
|
* a failure names one group rather than an interleaving of two.
|
|
*/
|
|
async function seedCoreRules() {
|
|
const team = await seedTeamRules()
|
|
const news = await seedNewsRule()
|
|
const events = await seedEventRules()
|
|
return {
|
|
inserted: team.inserted + news.inserted + events.inserted,
|
|
skipped: team.skipped + news.skipped + events.skipped,
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
seedCoreRules,
|
|
seedTeamRules,
|
|
seedNewsRule,
|
|
seedEventRules,
|
|
RULES,
|
|
NEWS_RULES,
|
|
EVENT_RULES,
|
|
SEEDED_KEY,
|
|
NEWS_SEEDED_KEY,
|
|
EVENT_SEEDED_KEY,
|
|
}
|