feat(engagement): the rules engine, cooldowns and outbox (engagement Phase 4a)
Phase 4 of docs/website/ENGAGEMENT.md, split 4a/4b at the org lead's direction. This is 4a: the engine, server only, with no HTTP surface at all. A fired trigger now produces outbox rows and send-log entries; Admin - Engagement - Rules and the segment composition UI are 4b. Five tables (rules, audience segments, cooldowns, outbox, sends), the sweep worker, audience resolution, condition evaluation, the grace window and its cancellation, and the save-path validation 4b's form will call. engagementEmit's Phase 2 log line becomes the engine call. Two settled questions this phase was blocked on: Q2 (multi-instance) - neither SKIP LOCKED nor documented single-instance: the outbox claims each row with a compare-and-set into the 'sending' state the ENUM already carried. It makes the outbox safe for two instances, not the deployment. Q4 (admin surface) - its own top-level nav group, built in 4b. Two defects in the plan's own section 4, both found by building it: The global UNIQUE(dedupe_key) was data loss. A dedupe key names the EVENT, and one event is one row per (rule, user, channel) - so a fifty-person audience would have had one row admitted and forty-nine silently ignored. Scoped. Section 4.1's single INSERT ... ON DUPLICATE KEY UPDATE cooldown claim always passes against this codebase's pool: the mariadb connector defaults foundRows:true, so a no-op update reports affectedRows 1 rather than 0. It is two statements now, with the interval guard in a WHERE clause. The second defect is why there is a second test file. The stubbed suite was green against the broken claim, because a stub can only agree with whoever wrote it; engagementEngineSql.test.js runs the raw statements against a real MariaDB and skips when there is none. Verification: 43 new tests green in engagementEngine.test.js, 12 more against MariaDB 11.8, and the whole path exercised end to end against a live database - per-subject cooldowns, conditions, the CAS claim, the send log's honest failure detail, and dormancy on uninstall. The three pre-existing Windows-only CRLF failures in the generated-artifact tests are unchanged from clean edge. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
138
server/src/engagement/audiences.js
Normal file
138
server/src/engagement/audiences.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// ── 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`,
|
||||
// `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` as a PLAIN audience resolves to nobody. It is the ceiling for
|
||||
// "a module-declared list", and without a segment there is no list - core
|
||||
// knows no game vocabulary and cannot guess which members were meant. A rule
|
||||
// saved that way 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':
|
||||
return {
|
||||
userIds: await recipients.staff(ceilings.STAFF_CEILING_ROLES),
|
||||
ceiling: 'staff',
|
||||
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':
|
||||
return {
|
||||
userIds: [],
|
||||
ceiling: 'members',
|
||||
dormant: false,
|
||||
reason: 'a "members" audience needs a segment naming which list',
|
||||
}
|
||||
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.
|
||||
*/
|
||||
function permitted(triggerId, ceiling) {
|
||||
const declaration = registries.eventTrigger(triggerId)
|
||||
if (!declaration) return false
|
||||
return ceilings.permits(declaration.ceiling, ceiling)
|
||||
}
|
||||
|
||||
module.exports = { resolveForRule, permitted, defaultOnChannels }
|
||||
Reference in New Issue
Block a user