feat(engagement): the rules engine, cooldowns and outbox (engagement Phase 4a)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m37s

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:
2026-08-29 08:07:27 -05:00
parent 447c9113d3
commit 2079aaf667
18 changed files with 3322 additions and 11 deletions

View 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 }

View File

@@ -0,0 +1,251 @@
// ── Rule conditions — a predicate over a trigger's DECLARED variables ───────
//
// ENGAGEMENT.md §4.5, Phase 4a. `engagement_rules.conditions` is the half of a
// rule that decides *whether* this particular firing is interesting: "only when
// decayStatus is IDOC", "only for threads in this Team". Without it every rule is
// all-or-nothing per trigger, and an operator's only way to narrow is to ask a
// module author for a second trigger.
//
// **It is validated against the declaration, not against a payload.** A condition
// naming a variable the trigger does not declare is refused at SAVE, with the
// variable named, for the same reason §4.3 gives the template editor: a predicate
// that silently reads `undefined` is a rule that silently never fires (or always
// does), and the day you find out is the day the mail did not go.
//
// **The grammar is small and closed on purpose.** No arbitrary expressions, no
// arithmetic, no regex. An operator composes and/or/not over comparisons of one
// declared variable against a literal, and every operator here is one a rule
// editor can render as a dropdown. Anything that needs more than this is asking
// for a condition the module should have declared as a variable.
//
// Nothing in this file reaches the database or the network.
const registries = require('../modules/registries')
// Comparison operators, grouped by what they may be applied to. The grouping is
// the whole of the type check: `gt` on a boolean and `startsWith` on an int are
// both refused at save rather than quietly answering false forever.
const OPERATORS = {
eq: { label: 'is', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 },
ne: { label: 'is not', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 },
in: { label: 'is one of', types: ['string', 'int', 'float', 'url'], arity: 'list' },
nin: { label: 'is none of', types: ['string', 'int', 'float', 'url'], arity: 'list' },
gt: { label: 'is greater than', types: ['int', 'float', 'datetime'], arity: 1 },
gte: { label: 'is at least', types: ['int', 'float', 'datetime'], arity: 1 },
lt: { label: 'is less than', types: ['int', 'float', 'datetime'], arity: 1 },
lte: { label: 'is at most', types: ['int', 'float', 'datetime'], arity: 1 },
contains: { label: 'contains', types: ['string', 'url'], arity: 1 },
startsWith: { label: 'starts with', types: ['string', 'url'], arity: 1 },
// The one operator that takes no value: "the emit carried this variable at
// all". It is the honest way to write a rule about an OPTIONAL variable, and
// without it `ne` would have to double as a presence test and get it wrong
// (an absent variable is not "not equal to X"; it is absent).
present: { label: 'is present', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 },
absent: { label: 'is absent', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 },
}
const BOOLEAN_OPS = ['and', 'or', 'not']
// A list literal an operator may type. Bounded because it is stored in a JSON
// column an admin can write, and an unbounded IN list is an unbounded predicate
// evaluated on every event.
const MAX_LIST = 50
// Depth of the and/or/not tree. Three levels is more nesting than any rule
// editor should offer; the bound is here so a hand-written JSON body cannot
// recurse this evaluator into a stack overflow on the emit path.
const MAX_DEPTH = 5
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
/**
* Check one literal against the declared type of the variable it is compared to.
*
* `datetime` accepts anything `Date` parses and is normalised to an ISO string,
* which is what `engagementEmit.coerce` does to the payload side — so both sides
* of every comparison are the same representation of a moment, and a lexical
* `<` on two ISO strings is a chronological one.
*/
function checkLiteral(type, raw) {
switch (type) {
case 'string':
case 'url':
return typeof raw === 'string' ? { value: raw } : { error: 'expected a string' }
case 'int':
return Number.isInteger(raw) ? { value: raw } : { error: 'expected an integer' }
case 'float':
return typeof raw === 'number' && Number.isFinite(raw)
? { value: raw }
: { error: 'expected a finite number' }
case 'boolean':
return typeof raw === 'boolean' ? { value: raw } : { error: 'expected a boolean' }
case 'datetime': {
const d = raw instanceof Date ? raw : new Date(raw)
if (Number.isNaN(d.getTime())) return { error: 'expected a date' }
return { value: d.toISOString() }
}
default:
return { error: `unsupported type "${type}"` }
}
}
/**
* Validate a condition tree against a trigger declaration.
*
* Returns `{ ok: true, conditions }` with a NEW normalised tree — literals
* coerced, unknown keys dropped — or `{ ok: false, errors }` listing every
* problem rather than the first, the posture `validatePayload` takes and for the
* same reason: an operator fixing one clause at a time is an operator making six
* round trips through a form.
*
* `null` and `undefined` are valid and mean "no conditions" — a rule that fires
* on every occurrence of its trigger, which is the common case.
*/
function validate(declaration, raw) {
const errors = []
const variables = new Map((declaration?.variables || []).map((v) => [v.name, v]))
function walk(node, depth, path) {
if (depth > MAX_DEPTH) {
errors.push(`${path}: nested deeper than ${MAX_DEPTH}`)
return null
}
if (!isPlainObject(node)) {
errors.push(`${path}: expected an object`)
return null
}
if (BOOLEAN_OPS.includes(node.op)) {
// `not` takes exactly one node; `and`/`or` take a list. Both are written
// as `nodes` so a client walks one shape.
const raws = Array.isArray(node.nodes) ? node.nodes : []
if (!raws.length) {
errors.push(`${path}: "${node.op}" has no nodes`)
return null
}
if (node.op === 'not' && raws.length !== 1) {
errors.push(`${path}: "not" takes exactly one node`)
return null
}
const nodes = raws.map((child, i) => walk(child, depth + 1, `${path}.nodes[${i}]`)).filter(Boolean)
return nodes.length === raws.length ? { op: node.op, nodes } : null
}
if (node.op !== undefined) {
errors.push(`${path}: unknown operator "${node.op}"`)
return null
}
// A leaf: { variable, cmp, value }.
const variable = variables.get(node.variable)
if (!variable) {
errors.push(`${path}: "${node.variable}" is not a variable of "${declaration?.id}"`)
return null
}
const operator = OPERATORS[node.cmp]
if (!operator) {
errors.push(`${path}: unknown comparison "${node.cmp}"`)
return null
}
if (!operator.types.includes(variable.type)) {
errors.push(`${path}: "${node.cmp}" cannot be applied to a ${variable.type}`)
return null
}
if (operator.arity === 0) return { variable: variable.name, cmp: node.cmp }
if (operator.arity === 'list') {
if (!Array.isArray(node.value) || !node.value.length) {
errors.push(`${path}: "${node.cmp}" needs a non-empty list`)
return null
}
if (node.value.length > MAX_LIST) {
errors.push(`${path}: "${node.cmp}" list is longer than ${MAX_LIST}`)
return null
}
const value = []
let bad = false
node.value.forEach((item, i) => {
const checked = checkLiteral(variable.type, item)
if (checked.error) {
errors.push(`${path}.value[${i}]: ${checked.error}`)
bad = true
} else value.push(checked.value)
})
return bad ? null : { variable: variable.name, cmp: node.cmp, value }
}
const checked = checkLiteral(variable.type, node.value)
if (checked.error) {
errors.push(`${path}: ${checked.error}`)
return null
}
return { variable: variable.name, cmp: node.cmp, value: checked.value }
}
if (raw === null || raw === undefined) return { ok: true, conditions: null }
const conditions = walk(raw, 0, 'conditions')
return errors.length ? { ok: false, errors } : { ok: true, conditions }
}
/** Compare one already-normalised leaf against a payload. */
function evaluateLeaf(leaf, data) {
const present = Object.prototype.hasOwnProperty.call(data, leaf.variable)
const actual = data[leaf.variable]
if (leaf.cmp === 'present') return present
if (leaf.cmp === 'absent') return !present
// Every other comparison against an absent variable is FALSE, never true.
// `ne` is the one that tempts otherwise — "not equal to X" reads as satisfied
// by nothing at all — and treating it as true would make an optional variable's
// absence fire the rule.
if (!present) return false
switch (leaf.cmp) {
case 'eq': return actual === leaf.value
case 'ne': return actual !== leaf.value
case 'in': return leaf.value.includes(actual)
case 'nin': return !leaf.value.includes(actual)
case 'gt': return actual > leaf.value
case 'gte': return actual >= leaf.value
case 'lt': return actual < leaf.value
case 'lte': return actual <= leaf.value
case 'contains': return typeof actual === 'string' && actual.includes(leaf.value)
case 'startsWith': return typeof actual === 'string' && actual.startsWith(leaf.value)
default: return false
}
}
/**
* Does this event's payload satisfy the rule's conditions?
*
* `null` conditions are satisfied — a rule with no conditions fires on every
* occurrence. A tree this evaluator does not recognise answers **false**, which
* is the fail-closed direction: a stored condition that no longer parses (a rule
* saved against an older trigger version, say) must stop the mail rather than
* become "no conditions" and mail everyone.
*/
function evaluate(conditions, data = {}) {
if (conditions === null || conditions === undefined) return true
if (!isPlainObject(conditions)) return false
if (conditions.op === 'and') return (conditions.nodes || []).every((n) => evaluate(n, data))
if (conditions.op === 'or') return (conditions.nodes || []).some((n) => evaluate(n, data))
if (conditions.op === 'not') return !evaluate((conditions.nodes || [])[0], data)
if (conditions.op !== undefined) return false
return evaluateLeaf(conditions, data)
}
/**
* The operator vocabulary a rule editor renders, with the variable types each
* one applies to. Served with the rule surface in Phase 4b rather than hardcoded
* in the client, on the same argument the ceiling vocabulary is served with the
* trigger catalog: a second copy of a rule is a copy that drifts.
*/
const vocabulary = () =>
Object.entries(OPERATORS).map(([cmp, o]) => ({ cmp, label: o.label, types: o.types, arity: o.arity }))
/** Convenience for a caller holding only a trigger id. */
const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw)
module.exports = { validate, validateFor, evaluate, vocabulary, OPERATORS, MAX_LIST, MAX_DEPTH }

View File

@@ -0,0 +1,241 @@
// ── The engagement engine ──────────────────────────────────────────────────
//
// ENGAGEMENT.md Phase 4a. `ctx.events.emit` validated a payload against a
// declaration and stopped (Phase 2); this is what it now hands the validated
// event to. The engine's whole job is to answer, for one event, **who gets told,
// on what, and not too often** - and then to write that down as outbox rows.
// It never delivers: `engagementWorker` drains the outbox, and what actually
// carries a message arrives with the channels' `deliver` in Phases 6 and 7.
//
// **The order of the gates is the design, and each one is here because skipping
// it is a way to mail the wrong people or too many of them:**
//
// 1. enabled rules for this trigger - nothing is seeded, nothing is on by default
// 2. conditions - is this particular firing interesting
// 3. audience -> user ids - core's tables, or a composed segment
// 4. ceiling re-check (G24) - re-run at SEND time, not only at save
// 5. per-channel preference - a user's own opt-in, effective mode
// 6. per-rule hourly ceiling (§7.1 Q3) - the hard stop that makes rules-as-data safe
// 7. cooldown, per (rule, user, subject) - one statement, so two emits cannot race
// 8. enqueue, deduped - a replayed event is one row, not two
//
// Steps 6 and 7 are in that order deliberately. The hourly ceiling is about the
// RULE and is the thing that stops a mail storm; the cooldown is about one
// recipient and one subject. Checking the cheap global bound before consuming a
// per-recipient cooldown slot means a rule that has hit its ceiling does not also
// silently burn everybody's cooldowns on sends that never happen.
//
// **Nothing here throws at its caller.** It is invoked from inside a game-event
// handler by way of `ctx.events.emit`, and a database problem of core's must not
// become a module's control flow (the same posture the emit validator takes).
const rulesDb = require('../model/engagement/engagementRules.db')
const outboxDb = require('../model/engagement/engagementOutbox.db')
const cooldownsDb = require('../model/engagement/engagementCooldowns.db')
const sendsDb = require('../model/engagement/engagementSends.db')
const recipients = require('../model/engagement/engagementRecipients.db')
const conditions = require('./conditions')
const audiences = require('./audiences')
const channels = require('./channels')
const log = require('../utils/logger')('engagement')
const HOUR_MS = 60 * 60 * 1000
/**
* Which of a rule's channels are actually deliverable right now?
*
* A rule stores channel ids as data (`channels JSON`), so it can name one whose
* module has been removed since. An unregistered channel is dropped rather than
* failing the rule: the other channels of that rule are still correct, and a
* dropped one is visible in the log line below.
*/
const liveChannels = (rule) => (rule.channels || []).filter((c) => channels.has(c))
/**
* Narrow a candidate set to the users whose EFFECTIVE mode for (id, channel) is
* not 'off'.
*
* Effective, not stored: a row exists only where a user has expressed something,
* and absence means the channel's `defaultMode` (§3.1). Reading the stored rows
* and applying the default here keeps that answer in the registry, which is the
* invariant Phase 3 established.
*
* A 'digest' preference is kept, not dropped. Digest delivery is Phase 6's, and
* an outbox row for it is still the right record of "this person should be told";
* what changes in Phase 6 is who drains it.
*/
async function subscribedTo(userIds, streamId, channel) {
if (!userIds.length) return []
const stored = await recipients.storedModes(userIds, streamId, channel)
const fallback = channels.defaultMode(channel)
return userIds.filter((id) => (stored.get(id) ?? fallback) !== 'off')
}
/**
* Run one rule against one event. Returns a small summary, for the log line and
* for tests; it is not read by the caller for control flow.
*/
async function applyRule(rule, event, now) {
const summary = { ruleId: rule.id, enqueued: 0, deduped: 0, cooled: 0, capped: 0, skipped: null }
if (!conditions.evaluate(rule.conditions, event.data)) {
summary.skipped = 'conditions'
return summary
}
const resolved = await audiences.resolveForRule(rule, event)
if (resolved.dormant) {
summary.skipped = resolved.reason || 'dormant'
return summary
}
if (!resolved.userIds.length) {
summary.skipped = resolved.reason || 'empty audience'
return summary
}
// 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
// ran the same check.
if (!audiences.permitted(event.triggerId, resolved.ceiling)) {
log.warn('rule audience exceeds its trigger ceiling - refusing', {
rule: rule.id,
trigger: event.triggerId,
audience: resolved.ceiling,
})
summary.skipped = 'ceiling'
return summary
}
const live = liveChannels(rule)
if (!live.length) {
summary.skipped = 'no registered channel'
return summary
}
// The per-rule hourly ceiling (§7.1 Q3). Counted once for the whole event
// rather than per channel: an operator setting "100 an hour" means a hundred
// messages, not a hundred per channel per event.
const sentThisHour = await sendsDb.countSentSince(rule.id, new Date(now.getTime() - HOUR_MS))
let budget = Math.max(0, rule.max_sends_per_hour - sentThisHour)
if (budget === 0) {
log.warn('rule is at its hourly send ceiling', {
rule: rule.id,
trigger: event.triggerId,
ceiling: rule.max_sends_per_hour,
})
summary.skipped = 'hourly ceiling'
return summary
}
const subjectKey = (event.subject ?? '').toString().slice(0, 190)
const dueAt = new Date(now.getTime() + Math.max(0, rule.delay_seconds) * 1000)
for (const channel of live) {
const eligible = await subscribedTo(resolved.userIds, event.triggerId, channel)
for (const userId of eligible) {
if (budget <= 0) {
summary.capped += 1
continue
}
// One statement, guarded on the interval, so two concurrent emits cannot
// both pass a read-then-write check (§4.1).
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
if (!allowed) {
summary.cooled += 1
continue
}
const id = await outboxDb.enqueue({
rule_id: rule.id,
trigger_id: event.triggerId,
user_id: userId,
channel,
subject_key: subjectKey,
payload: event.data,
// Scoped per (rule, user, channel) by the unique index, so one event
// fanned out to fifty people is fifty rows carrying the same key.
dedupe_key: event.dedupeKey,
due_at: dueAt,
})
if (id === null) summary.deduped += 1
else {
summary.enqueued += 1
budget -= 1
}
}
}
return summary
}
/**
* Cancel pending rows that this event resolves (§4.2a).
*
* This is the actual point of `delay_seconds`: without cancellation a delay is
* just a late mail. A house repaired back to LikeNew fires a trigger that some
* rule names in its `cancel_on`, and every still-scheduled row for that
* (rule, subject) stops.
*
* When the resolving event names an owner, only that user's rows are cancelled;
* when it does not, every user queued about that subject is - which is the
* house-repaired case, where the event is about the house and not about any one
* of the people who were going to be told.
*/
async function applyCancellations(event, summary) {
const rules = await rulesDb.enabledCancelledBy(event.triggerId)
if (!rules.length) return
const subjectKey = (event.subject ?? '').toString().slice(0, 190)
for (const rule of rules) {
const n = await outboxDb.cancel(rule.id, subjectKey, event.ownerUserId || null)
if (n) {
summary.cancelled += n
log.info('cancelled scheduled sends', {
rule: rule.id,
by: event.triggerId,
subject: subjectKey,
rows: n,
})
}
}
}
/**
* Dispatch one validated event. Called by `engagementEmit.emit` after the payload
* has been checked against the declaration.
*
* @param {object} event the envelope `engagementEmit` built
* @returns {Promise<{ rules: number, enqueued: number, cancelled: number }>}
*/
async function dispatch(event, now = new Date()) {
const summary = { rules: 0, enqueued: 0, deduped: 0, cooled: 0, capped: 0, cancelled: 0 }
try {
const rules = await rulesDb.enabledForTrigger(event.triggerId)
summary.rules = rules.length
for (const rule of rules) {
const result = await applyRule(rule, event, now)
summary.enqueued += result.enqueued
summary.deduped += result.deduped
summary.cooled += result.cooled
summary.capped += result.capped
}
await applyCancellations(event, summary)
// Keys and counts, never values - the same rule the emit log line follows.
// A payload carries player names, house locations and forum excerpts, and a
// log that reproduces them is a second copy of exactly the content
// `engagement_sends` is careful to keep out of the database.
if (summary.rules || summary.cancelled) {
log.info('event dispatched', { trigger: event.triggerId, ...summary })
}
} catch (err) {
// A database problem of core's must not become the module's control flow at
// three in the morning. The emit already succeeded as a contract; what failed
// is delivery, and it is logged as core's failure.
log.error('dispatch failed', { trigger: event.triggerId, message: err.message })
}
return summary
}
module.exports = { dispatch, applyRule, applyCancellations, subscribedTo, liveChannels, HOUR_MS }

View File

@@ -0,0 +1,232 @@
// ── Audience segments — operator composition over module-declared audiences ──
//
// ENGAGEMENT.md §5.1a, Phase 4a. A module declares named audiences over its own
// data ("members of a Team", "the governors"); an operator combines them with
// and/or/not into a saved segment; a rule points at the segment. This file is the
// two halves of that: derive the segment's ceiling at save time, and resolve the
// expression to user ids at send time.
//
// **Composition must NARROW, never widen** (§5.1a rule 3), and that is the whole
// security content of this file. `A OR B` takes the TIGHTER of the two ceilings,
// not the looser - a ceiling states what an expression is *allowed* to reach, not
// what it will resolve to, so the direction of the boolean operator is
// irrelevant. Union-widens is the intuitive implementation and it is the wrong
// one; `ceilings.meetAll` is the arithmetic, settled in Phase 2, and this is its
// first consumer.
//
// The second rule that shows up in both halves is **dormancy** (§5.1a rule 4).
// An audience whose module has been uninstalled resolves to the EMPTY set and
// flags itself, never to an error and never to some other set of people. A
// segment containing one is dormant, and a rule using a dormant segment does not
// send. Resolving the rest of the tree instead would mail a DIFFERENT population
// than the one the operator composed.
const registries = require('../modules/registries')
const ceilings = require('../modules/ceilings')
const BOOLEAN_OPS = ['and', 'or', 'not']
// Same bounds and the same reason as conditions.js: this tree comes out of a JSON
// column an admin can write, and it is walked on the emit path.
const MAX_DEPTH = 5
const MAX_NODES = 50
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const isNot = (node) => isPlainObject(node) && node.op === 'not'
/** Check one audience's declared params against what the operator supplied. */
function checkParams(declaration, raw, path, errors) {
const params = {}
const supplied = isPlainObject(raw) ? raw : {}
for (const p of declaration.params || []) {
const value = supplied[p.id]
if (value === undefined || value === null || value === '') {
if (p.required) errors.push(`${path}: "${p.id}" is required`)
continue
}
if (p.type === 'int') {
const n = Number(value)
if (!Number.isInteger(n)) {
errors.push(`${path}: "${p.id}" expected an integer`)
continue
}
params[p.id] = n
} else if (p.type === 'boolean') {
if (typeof value !== 'boolean') {
errors.push(`${path}: "${p.id}" expected a boolean`)
continue
}
params[p.id] = value
} else {
if (typeof value !== 'string') {
errors.push(`${path}: "${p.id}" expected a string`)
continue
}
params[p.id] = value
}
}
return params
}
/**
* Validate an expression and derive its ceiling in one walk.
*
* Returns `{ ok: true, expression, ceiling }` with a normalised tree, or
* `{ ok: false, errors }`.
*
* **`not` is legal only as a child of `and`**, and that restriction is what makes
* a complement mean something. A complement needs a universe, and the only
* universe available here that does not widen is the set its siblings already
* produced: `A AND NOT B` is "A, less B", which is exactly what an operator
* wants and cannot be composed into a broadcast. A bare `NOT B`, or `A OR NOT B`,
* would have to mean "everyone except..." - a way to build the whole deployment
* out of one narrow audience, which is the widening rule 3 forbids. Refusing it
* at save is better than a semantics nobody can predict from the screen.
*
* Two failure modes, and they are different:
*
* - a leaf naming an audience nobody registers is refused AT SAVE, because an
* operator composing a segment out of a typo should hear about it now rather
* than discovering a permanently-empty rule later. (A segment that was VALID
* when saved and whose module has since gone is a different case - that is
* dormancy, handled in `resolve`, and it is not refused.)
* - two incomparable ceilings have NO meet, so the composition is refused rather
* than resolved to a guess. `staff AND owner` is not `owner`; it is a question
* the lattice declines to answer, and picking a side would be a widening.
*/
function validate(raw) {
const errors = []
let nodes = 0
// `underAnd` is the only context in which a `not` is legal.
function walk(node, depth, path, underAnd) {
if (++nodes > MAX_NODES) {
errors.push(`${path}: expression has more than ${MAX_NODES} nodes`)
return null
}
if (depth > MAX_DEPTH) {
errors.push(`${path}: nested deeper than ${MAX_DEPTH}`)
return null
}
if (!isPlainObject(node)) {
errors.push(`${path}: expected an object`)
return null
}
if (node.op === 'not') {
if (!underAnd) {
errors.push(`${path}: "not" is only allowed inside an "and" - a complement needs a set to take it from`)
return null
}
const children = Array.isArray(node.nodes) ? node.nodes : []
if (children.length !== 1) {
errors.push(`${path}: "not" takes exactly one node`)
return null
}
const inner = walk(children[0], depth + 1, `${path}.nodes[0]`, false)
if (!inner) return null
// A `not` contributes NO ceiling. Excluding people cannot widen who the
// expression reaches, so folding the excluded audience's ceiling into the
// meet would refuse perfectly safe segments: `members AND NOT staff` would
// hit meet('members','staff') = null and be rejected, even though it
// reaches strictly fewer people than `members` alone.
return { node: { op: 'not', nodes: [inner.node] }, ceiling: null, complement: true }
}
if (node.op === 'and' || node.op === 'or') {
const children = Array.isArray(node.nodes) ? node.nodes : []
if (!children.length) {
errors.push(`${path}: "${node.op}" has no nodes`)
return null
}
const walked = children.map((c, i) => walk(c, depth + 1, `${path}.nodes[${i}]`, node.op === 'and'))
if (walked.some((w) => w === null)) return null
const positives = walked.filter((w) => !w.complement)
if (!positives.length) {
errors.push(`${path}: "${node.op}" has nothing but complements - there is no set to exclude from`)
return null
}
return {
node: { op: node.op, nodes: walked.map((w) => w.node) },
ceiling: ceilings.meetAll(positives.map((w) => w.ceiling)),
}
}
if (node.op !== undefined) {
errors.push(`${path}: unknown operator "${node.op}"`)
return null
}
const declaration = registries.audience(node.audienceId)
if (!declaration) {
errors.push(`${path}: no audience "${node.audienceId}" is registered`)
return null
}
const params = checkParams(declaration, node.params, path, errors)
return { node: { audienceId: declaration.id, params }, ceiling: declaration.ceiling }
}
if (!isPlainObject(raw)) return { ok: false, errors: ['expression: expected an object'] }
const walked = walk(raw, 0, 'expression', false)
if (errors.length || !walked) return { ok: false, errors: errors.length ? errors : ['expression: invalid'] }
if (!walked.ceiling) {
return {
ok: false,
errors: [
'expression: the audiences combined here have no common ceiling, so there is no bound this segment could be given',
],
}
}
return { ok: true, expression: walked.node, ceiling: walked.ceiling }
}
/**
* Resolve a validated expression to a set of user ids.
*
* Returns `{ dormant, userIds }`. `dormant` is true the moment ANY leaf names an
* audience that is no longer registered, and when it is true the caller must not
* send: `userIds` is empty, because the tree it would have come from is not the
* tree the operator composed.
*
* `and` is the intersection of its positive children, less the union of its
* complements. `or` is the union of its children, which are all positive because
* `validate` refused any other shape.
*/
async function resolve(expression) {
let dormant = false
async function walk(node) {
if (!isPlainObject(node)) return new Set()
if (node.op === 'and' || node.op === 'or') {
const children = Array.isArray(node.nodes) ? node.nodes : []
const positives = children.filter((c) => !isNot(c))
const complements = children.filter(isNot)
let out = new Set()
for (let i = 0; i < positives.length; i += 1) {
const set = await walk(positives[i])
if (i === 0) out = set
else if (node.op === 'and') out = new Set([...out].filter((id) => set.has(id)))
else for (const id of set) out.add(id)
}
for (const c of complements) {
const excluded = await walk((c.nodes || [])[0])
out = new Set([...out].filter((id) => !excluded.has(id)))
}
return out
}
// A `not` reached directly (never produced by validate, but a stored row
// predates nothing and this must not throw): no universe, so no members.
if (node.op !== undefined) return new Set()
const { dormant: gone, userIds } = await registries.resolveAudience(node.audienceId, node.params || {})
if (gone) dormant = true
return new Set(userIds)
}
const set = await walk(expression)
return { dormant, userIds: dormant ? [] : [...set] }
}
module.exports = { validate, resolve, MAX_DEPTH, MAX_NODES }