Files
website/server/src/engagement/audiences.js
wtclaude 7d3d6d5abd
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
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)
2026-09-04 13:06:46 -05:00

183 lines
8.8 KiB
JavaScript

// ── Resolving a rule's audience to recipients ──────────────────────────────
//
// ENGAGEMENT.md §5.1a / §4.5, Phase 4a. A rule names an audience two ways and
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`, `admin`,
// `subscribers`, `authenticated`, `everyone`) resolved from core's own tables, or
// an **`audience_segment_id`** pointing at an operator-composed tree of
// module-declared audiences (segments.js). This file turns either into user ids.
//
// **Three things it is careful about, all of them the same worry.** The set this
// function returns is the set that gets mailed, so:
//
// 1. Every id is checked against `users.status = 'active'` - including the ones a
// MODULE's resolver produced, which core has no reason to trust with account
// status it does not know about.
// 2. A dormant segment (its module uninstalled) resolves to EMPTY and says so.
// The caller must not send. Falling back to the rule's plain `audience`
// column would reach a different population than the one composed (§5.1a
// rule 4), which is the failure mode this whole design exists to avoid.
// 3. `members` resolves to nobody unless something NAMED the list: a segment, or
// (from Phase 6) an event carrying its own access-checked recipient set. Core
// knows no game vocabulary and cannot guess which members were meant. A rule
// with neither is inert and visible as such, rather than quietly falling back
// to something wider.
const registries = require('../modules/registries')
const channels = require('./channels')
const segments = require('./segments')
const segmentsDb = require('../model/engagement/engagementSegments.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const ceilings = require('../modules/ceilings')
const log = require('../utils/logger')('engagement')
/**
* Which registered channels default to something other than 'off'?
*
* Read once per resolution rather than hardcoded, because it is the difference
* between "opted in" meaning a stored row and meaning the absence of one
* (§3.1, G9). All three of core's channels default 'off' today, so this is empty
* and `subscribers` is the simple query - but the answer lives in the registry.
*/
const defaultOnChannels = () => channels.all().filter((c) => c.defaultMode !== 'off').map((c) => c.id)
/**
* Resolve one rule against one event.
*
* @returns {{ userIds: number[], ceiling: string|null, dormant: boolean, reason: string|null }}
* `dormant` means "this rule cannot be resolved right now"; `reason` names why
* for the log and, in Phase 4b, for the admin list's dormant badge.
*/
async function resolveForRule(rule, event) {
if (rule.audience_segment_id) {
const segment = await segmentsDb.getById(rule.audience_segment_id)
if (!segment) {
// The segment was deleted out from under the rule. `audience_segment_id`
// deliberately has no ON DELETE SET NULL (see schema.sql), because that
// would silently fall back to the rule's plain `audience` column and mail
// a different set of people.
return { userIds: [], ceiling: null, dormant: true, reason: 'audience segment no longer exists' }
}
const { dormant, userIds } = await segments.resolve(segment.expression)
if (dormant) {
return { userIds: [], ceiling: segment.ceiling, dormant: true, reason: 'audience segment is dormant' }
}
return {
userIds: await recipients.filterActive(userIds),
// The STORED ceiling, not one re-derived now: a module that has since
// widened its own audience's ceiling must not widen a segment that was
// saved under the old one.
ceiling: segment.ceiling,
dormant: false,
reason: null,
}
}
switch (rule.audience) {
case 'owner': {
if (!event.ownerUserId) {
// Not dormant: the rule is fine and this particular event simply has no
// owner to mail. A trigger that never carries one is an operator's
// mistake the rule editor should catch (Phase 4b), not a runtime error.
return { userIds: [], ceiling: 'owner', dormant: false, reason: 'event carries no ownerUserId' }
}
return {
userIds: await recipients.filterActive([event.ownerUserId]),
ceiling: 'owner',
dormant: false,
reason: null,
}
}
case 'staff':
case 'admin':
// Both role-gated, and resolved through the ONE query rather than two.
// `ceilings.ROLE_CEILINGS` holds which roles each names, so the day a
// third is added the resolver does not need a third case — and, more to
// the point, cannot get one of them wrong while the others stay right.
return {
userIds: await recipients.staff(ceilings.ROLE_CEILINGS[rule.audience].roles),
ceiling: rule.audience,
dormant: false,
reason: null,
}
case 'subscribers':
return {
userIds: await recipients.subscribers(event.triggerId, defaultOnChannels()),
ceiling: 'subscribers',
dormant: false,
reason: null,
}
case 'authenticated':
case 'everyone':
return { userIds: await recipients.active(), ceiling: rule.audience, dormant: false, reason: null }
case 'members': {
// **The event may name its own list, and Phase 6 is why that exists.**
// `members` is the ceiling for "a module-declared list", and until this
// phase the only way to name one was a segment — an operator-composed tree
// over audiences with CONSTANT params. That cannot express "the members of
// the Team this particular post was in": the list is different for every
// firing, and nothing in a saved segment reads the event.
//
// So an emitter that has already computed an access-checked recipient set
// hands it over on the envelope, and this is where it is used. It is not a
// bypass of anything: the set is still filtered through `users.status`
// below, and the ceiling returned is still `members`, so the G24 re-check
// in the engine still refuses a rule whose trigger has since narrowed.
// What it removes is core having to guess a game's membership vocabulary —
// the thing this case's original comment said it could not do.
if (Array.isArray(event.recipientUserIds) && event.recipientUserIds.length) {
return {
userIds: await recipients.filterActive(event.recipientUserIds),
ceiling: 'members',
dormant: false,
reason: null,
}
}
return {
userIds: [],
ceiling: 'members',
dormant: false,
reason: 'a "members" audience needs a segment naming which list, or an event that carries one',
}
}
default:
// Fails closed on an audience name the lattice does not know - the same
// posture `ceilings.permits` takes, and for the same reason.
log.warn('rule names an unknown audience', { rule: rule.id, audience: rule.audience })
return { userIds: [], ceiling: null, dormant: true, reason: `unknown audience "${rule.audience}"` }
}
}
/**
* The G24 gate, re-run at SEND time and not only at save time.
*
* A rule's audience was checked against its trigger's ceiling when it was saved,
* so this can only fail when something changed underneath: a module upgraded and
* narrowed its trigger's ceiling, or a module was replaced by one declaring the
* 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, emitted = null) {
const declaration = registries.eventTrigger(triggerId)
if (!declaration) return false
const bound = emitted ? ceilings.meet(declaration.ceiling, emitted) : declaration.ceiling
if (!bound) return false
return ceilings.permits(bound, ceiling)
}
module.exports = { resolveForRule, permitted, defaultOnChannels }