The admin surface over the Phase 4a engine: two screens, twelve routes and the
reach preview. Nothing in the engine changed; what changed is that an operator
can now reach it.
Four decisions settled by the org lead before any code:
- segments get their OWN nav entry, "Audiences", not a tab of the rules screen
- the on/off switch is its own PATCH route, not a full PUT
- the reach preview is a count only, on demand
- a rule can be hard-deleted; the send log survives it
The switch is the one with real content in it. A PUT re-validates against the
registries as they are NOW, so the rules a re-validating toggle cannot switch
off are exactly the three an operator most wants stopped: a rule whose module
was uninstalled, one naming a channel that is gone, and one whose trigger has
since narrowed its ceiling under a saved audience. PATCH .../enabled writes one
column and always works. Switching ON unvalidated is safe because the engine
re-checks the ceiling at send time.
The preview calls the engine's own resolver rather than a second query that
agrees with it today, and answers a count and nothing else - the resolver's
output for a module-declared segment is a set of players derived from game data.
It reports `capped` at the 5000-row bound (the count is a floor, not a total),
`reason` for an `owner` audience (which resolves per event and has no advance
answer), and `permitted` so the editor cannot show a healthy number beside a
save the server will refuse.
Two defects found by walking it against a live server, both in Phase 4a's code:
1. A rule pointing at a DORMANT segment read as healthy. listAnnotated asked
only whether the segment ROW existed. The other shape of the same failure
is a segment sitting exactly where it was whose every audience belongs to
an uninstalled module: same outcome, nothing deleted. Uninstalling a module
under an enabled rule produced a rule the screen showed as on and firing.
The expression walk now lives in engagement/segments.js as
`missingAudiences` and both lists ask it.
2. "1 rule still use this segment" - the delete refusal pluralised the noun
and not the verb, in the sentence an operator reads when told no.
Also: a rule's trigger is now a stated rule rather than an omission in the
UPDATE statement (its cooldowns, queued sends and history are all about one
trigger id); a condition tree the editor cannot render is shown read-only rather
than flattened, because flattening changes which events fire the rule; and
literals are coerced client-side to the type the trigger declared, with anything
that does not parse passed through unchanged so the server's refusal names the
variable.
Tests: 21 new server tests (test/engagementAdmin.test.js) and 25 client ones
(client/test/engagementRules.test.js), all green. The single failure in the
server suite (`the committed manifest matches the declarations in the tree`) is
the known Windows CRLF artifact and fails identically on clean edge.
Companion docs PR: docs#184.
- [x] AI-assisted: written with Claude Code (Opus)
Co-Authored-By: Claude <noreply@anthropic.com>
259 lines
10 KiB
JavaScript
259 lines
10 KiB
JavaScript
// ── 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 }
|