// ── 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] } } /** * Which audience ids in this expression nobody registers right now? * * The static half of the dormancy answer `resolve` gives at send time, and it * lives here so the two cannot disagree. Two callers need it and neither may * require the other: the segment list annotates itself with it, and the RULE * list needs it to say that a rule pointing at a dormant segment is itself * dormant — which is §5.1a rule 4, and which the first version of the rule * annotation missed by asking only whether the segment ROW still existed. * * The difference is the whole point. A deleted segment and a segment whose * module is gone both leave the rule reaching nobody; only one of them leaves a * row behind. A screen that reports the first and not the second shows an * enabled, healthy-looking rule that cannot fire. */ function missingAudiences(expression) { const missing = [] const walk = (node) => { if (!node || typeof node !== 'object') return if (node.op) (node.nodes || []).forEach(walk) else if (!registries.audience(node.audienceId)) missing.push(node.audienceId) } walk(expression) return [...new Set(missing)] } module.exports = { validate, resolve, missingAudiences, MAX_DEPTH, MAX_NODES }