// ── Engagement rules — the save path ─────────────────────────────────────── // // ENGAGEMENT.md §4.5 / §7.1 Q3, Phase 4a. A rule is **operator-editable data**, // not code, and that was a deliberate choice with a condition attached: it is // safe to choose only because `enabled` defaults to 0 and every rule carries a // hard per-hour send ceiling. Both of those live in this file's validation, not // in the screen that calls it - Phase 4b builds a form over this, and a rule that // arrives by any other route (a restore, a fixture, a future import) gets the // same answer. // // **Every check here is a boundary, not a convenience.** The rule editor will // re-implement some of them for the sake of a good error message, and that // second copy is expected to drift - so this one is the one that decides. // // The check with teeth is the ceiling (G24): an operator may narrow a rule's // audience as much as they like and may never widen it past what the trigger // declared. `ceilings.permits` is that arithmetic, `segments.validate` derives // it for a composed audience, and the engine re-runs the same check at SEND // time in case a module upgrade narrowed the declaration underneath a saved rule. const db = require('./engagementRules.db') const segmentsDb = require('./engagementSegments.db') const registries = require('../../modules/registries') const ceilings = require('../../modules/ceilings') const channels = require('../../engagement/channels') const segmentExpressions = require('../../engagement/segments') const conditions = require('../../engagement/conditions') const templates = require('../../engagement/templates') // A day. Longer than this and "cooldown" is really "send once", which a rule // expresses by being disabled rather than by a decade-long interval. const MAX_COOLDOWN_SECONDS = 86_400 // The grace window (§4.2a). A delay longer than a day outlives the thing it is // about - and, more practically, a queue row that sits for a week is a row whose // payload no longer describes the world. const MAX_DELAY_SECONDS = 86_400 // The upper bound on the operator-set hourly ceiling. It is not "unlimited by // another name": the number exists so that a misconfiguration is a bad hour // rather than an unbounded one, and a ceiling nobody can raise past a bound is // what makes rules-as-data safe (§7.1 Q3). const MAX_SENDS_PER_HOUR = 10_000 // The one key in `template_keys` that is not a delivery channel. `teamDigestWorker` // renders it for a rule whose email channel an individual has set to digest mode, // so it belongs to a MODE rather than to the rule's channel list and can never // appear there. const DIGEST_SLOT = 'digest' const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) /** * Validate a rule against the registries and the lattice. * * Returns `{ ok: true, rule }` with a normalised row ready for insert/update, or * `{ ok: false, errors }` listing every problem. * * `triggerId` may name a trigger nobody currently registers ONLY on an update of * an existing rule - a dormant rule must stay editable (its module can come * back), and refusing to save it would make an uninstall destructive after the * fact. A NEW rule must name a live trigger, because there is nothing to * preserve and a typo should be caught now. */ async function validate(input, { existing = null } = {}) { const errors = [] const raw = isPlainObject(input) ? input : {} const triggerId = typeof raw.triggerId === 'string' ? raw.triggerId : existing?.trigger_id const declaration = triggerId ? registries.eventTrigger(triggerId) : null if (!triggerId) errors.push('triggerId is required') else if (!declaration && !existing) errors.push(`no trigger "${triggerId}" is registered`) const name = typeof raw.name === 'string' ? raw.name.trim() : '' if (!name) errors.push('name is required') else if (name.length > 160) errors.push('name is longer than 160 characters') // Channels are stored as data and checked against the registry, so a rule // cannot name a sink that does not exist. Phase 4b's form offers the registered // set; this is what makes that an affordance rather than the rule. const wanted = Array.isArray(raw.channels) ? [...new Set(raw.channels)] : [] if (!wanted.length) errors.push('at least one channel is required') for (const c of wanted) if (!channels.has(c)) errors.push(`no channel "${c}" is registered`) // `template_keys` is { channel: templateKey }. Phase 5 owns templates, so the // KEYS are checked for shape and not for existence - a rule may legitimately // name a template that has not been authored yet, and Phase 5's editor is where // that becomes resolvable. // // **The shape check was wrong until Phase 5b, and wrong in the way that matters:** // it required `/^[a-z0-9][a-z0-9-]{0,63}$/`, which has no dot, while every // template key that exists is dotted (`notify.event`, `auth.password-reset`). // Written before templates existed, it could not match one, so no rule could name // any real template - which is precisely the workflow S4.6.2's duplicate action // exists to serve. It now uses the templates model's own pattern, so the two // cannot disagree about what a key is. const templateKeys = {} if (raw.templateKeys !== undefined && !isPlainObject(raw.templateKeys)) { errors.push('templateKeys must be an object of { channel: templateKey }') } else { for (const [channel, key] of Object.entries(raw.templateKeys || {})) { // **`digest` is a template SLOT, not a channel**, and it is legal here for // exactly the reason `registries.js` `checkSeedRule` says it is: it names // the body `teamDigestWorker` renders for a rule whose email channel an // individual has set to digest mode, so it never appears in `channels` and // never could. Rejecting it made every rule that ships one unsaveable from // the Rules screen — core's own team and news rules included, and sixteen // of module-uo's — with a 400 naming a key the operator never typed, whose // only remedy was deleting the digest body and silently dropping digest // support. Found by Phase 13's acceptance walk; the two validators now // agree about what `digest` is. if (channel !== DIGEST_SLOT && !wanted.includes(channel)) { errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`) continue } if (typeof key !== 'string' || key.length > templates.MAX_KEY || !templates.KEY_RE.test(key)) { errors.push(`templateKeys.${channel} is not a valid template key`) continue } templateKeys[channel] = key } } const numbers = [ ['cooldownSeconds', 'cooldown_seconds', MAX_COOLDOWN_SECONDS, 0], ['delaySeconds', 'delay_seconds', MAX_DELAY_SECONDS, 0], ['maxSendsPerHour', 'max_sends_per_hour', MAX_SENDS_PER_HOUR, 1], ] const scalars = {} for (const [key, column, max, min] of numbers) { const supplied = raw[key] const fallback = existing ? existing[column] : column === 'max_sends_per_hour' ? 100 : 0 const value = supplied === undefined || supplied === null ? fallback : Number(supplied) if (!Number.isInteger(value) || value < min || value > max) { errors.push(`${key} must be an integer between ${min} and ${max}`) } else scalars[column] = value } // `cancel_on` names trigger ids, and they are NOT checked for registration for // the dormancy reason (§7.3): a resolving event whose module is temporarily // absent should stop cancelling, not make the rule unsaveable. const cancelOn = Array.isArray(raw.cancelOn) ? [...new Set(raw.cancelOn.filter((t) => typeof t === 'string'))] : [] if (cancelOn.length && !scalars.delay_seconds) { // Not an error - it is a rule that will never cancel anything, because there // is no window in which to do it. Worth saying out loud rather than silently // accepting a setting that cannot take effect. errors.push('cancelOn has no effect without a delaySeconds grace window') } const checked = conditions.validate(declaration, raw.conditions === undefined ? existing?.conditions : raw.conditions) if (!checked.ok) errors.push(...checked.errors) // ── The audience, and the one check that is a security boundary ────────── let audience = typeof raw.audience === 'string' ? raw.audience : existing?.audience || declaration?.audience let segmentId = raw.audienceSegmentId === undefined ? existing?.audience_segment_id ?? null : raw.audienceSegmentId segmentId = segmentId === null || segmentId === '' ? null : Number(segmentId) let effectiveCeiling = null if (segmentId !== null) { if (!Number.isInteger(segmentId)) errors.push('audienceSegmentId must be an integer') else { const segment = await segmentsDb.getById(segmentId) if (!segment) errors.push(`no audience segment ${segmentId} exists`) else { // The segment's STORED ceiling, derived when it was saved by // `segments.validate` from the narrowest audience it contains. A rule // pointing at a segment takes that as its reach; the `audience` column // is retained for display and is not what the engine resolves. effectiveCeiling = segment.ceiling audience = segment.ceiling } } } else if (!ceilings.isCeiling(audience)) { errors.push(`audience must be one of ${ceilings.CEILINGS.join(', ')}`) } else { effectiveCeiling = audience } if (declaration && effectiveCeiling && !ceilings.permits(declaration.ceiling, effectiveCeiling)) { errors.push( `audience "${effectiveCeiling}" is wider than trigger "${triggerId}" permits (ceiling "${declaration.ceiling}")`, ) } if (errors.length) return { ok: false, errors } return { ok: true, rule: { trigger_id: triggerId, name, enabled: raw.enabled === undefined ? Boolean(existing?.enabled) : Boolean(raw.enabled), audience, audience_segment_id: segmentId, max_sends_per_hour: scalars.max_sends_per_hour, channels: wanted, template_keys: templateKeys, conditions: checked.conditions, cooldown_seconds: scalars.cooldown_seconds, delay_seconds: scalars.delay_seconds, cancel_on: cancelOn, updated_by: Number.isInteger(raw.updatedBy) ? raw.updatedBy : null, }, } } async function create(input) { const checked = await validate(input) if (!checked.ok) return checked const id = await db.insert(checked.rule) return { ok: true, rule: await db.getById(id) } } async function update(id, input) { const existing = await db.getById(id) if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true } const checked = await validate(input, { existing }) if (!checked.ok) return checked await db.update(id, checked.rule) return { ok: true, rule: await db.getById(id) } } /** * Why this rule cannot currently fire, as a list of sentences. Empty = it can. * * **Three ways, not two.** A rule can be dormant because its trigger is gone, * because a channel it names is gone, or because its AUDIENCE is gone - and the * audience case has two shapes that a screen must not collapse into one: * * • the segment row was deleted out from under it (§7.3), or * • the segment still exists and every audience in it belongs to a module that * has been uninstalled (§5.1a rule 4). * * Both leave the rule reaching nobody. Only the first leaves nothing behind, and * a check that asks only "does the row exist" reports the first and misses the * second - which shows an enabled, healthy-looking rule that cannot fire. Found * by uninstalling a module under a live rule while building Phase 4b's screen. * * @param {Map} segments every segment, by id */ function dormancyReasons(rule, segments) { const reasons = [] if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`) if (rule.audience_segment_id) { const segment = segments.get(rule.audience_segment_id) if (!segment) reasons.push('its audience segment no longer exists') else { const missing = segmentExpressions.missingAudiences(segment.expression) if (missing.length) { reasons.push(`its audience "${segment.name}" uses ${missing.join(', ')}, which nothing registers`) } } } for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`) return reasons } const annotate = (rule, segments) => { const reasons = dormancyReasons(rule, segments) return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons } } const segmentsById = async () => new Map((await segmentsDb.list()).map((s) => [s.id, s])) /** * List every rule, each annotated with whether it can currently fire. * * Dormancy is computed rather than stored (§7.3): a rule whose trigger or * segment is not registered right now is listed, flagged, and left alone. The * alternative - deleting or disabling it on uninstall - destroys an operator's * configuration on the strength of a module being temporarily absent. */ async function listAnnotated() { const rows = await db.list() const segments = await segmentsById() return rows.map((rule) => annotate(rule, segments)) } /** One rule with the same dormancy annotation the list carries, or null. */ async function getAnnotated(id) { const rule = await db.getById(id) if (!rule) return null return annotate(rule, await segmentsById()) } /** * Turn one rule on or off, writing that column and no other (Phase 4b). * * This is the one write path that does NOT go through `validate`, and the * asymmetry is deliberate. Switching a rule OFF must always be possible - a rule * whose module has been uninstalled, or whose trigger has since narrowed its * ceiling under a saved audience, is exactly the rule an operator most urgently * wants stopped, and it is exactly the rule `validate` would now refuse. The * full editor still re-validates on save, and the engine re-checks the ceiling at * send time, so nothing is loosened by having a switch that is only a switch. */ async function setEnabled(id, enabled, updatedBy = null) { const existing = await db.getById(id) if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true } await db.setEnabled(id, enabled, updatedBy) return { ok: true, rule: await getAnnotated(id) } } /** * Delete a rule. * * Its cooldown rows and any still-pending outbox rows go with it (both carry an * ON DELETE CASCADE), and that is the right blast radius: neither means anything * without the rule. `engagement_sends` deliberately does NOT — its `rule_id` * carries no foreign key — so the send log outlives the rule and the record of * what was actually mailed survives an operator tidying up. */ async function remove(id) { const existing = await db.getById(id) if (!existing) return { ok: false, errors: [`no rule ${id} exists`], notFound: true } await db.remove(id) return { ok: true } } module.exports = { validate, create, update, setEnabled, remove, listAnnotated, getAnnotated, MAX_COOLDOWN_SECONDS, MAX_DELAY_SECONDS, MAX_SENDS_PER_HOUR, }