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:
232
server/src/engagement/segments.js
Normal file
232
server/src/engagement/segments.js
Normal 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 }
|
||||
Reference in New Issue
Block a user