// ── 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 }