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,225 @@
// ── 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 conditions = require('../../engagement/conditions')
// 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
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.
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 || {})) {
if (!wanted.includes(channel)) {
errors.push(`templateKeys names "${channel}", which is not one of this rule's channels`)
continue
}
if (typeof key !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.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) }
}
/**
* 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 = new Map((await segmentsDb.list()).map((s) => [s.id, s]))
return rows.map((rule) => {
const reasons = []
if (!registries.eventTrigger(rule.trigger_id)) reasons.push(`trigger "${rule.trigger_id}" is not registered`)
if (rule.audience_segment_id && !segments.has(rule.audience_segment_id)) {
reasons.push('its audience segment no longer exists')
}
for (const c of rule.channels || []) if (!channels.has(c)) reasons.push(`channel "${c}" is not registered`)
return { ...rule, dormant: reasons.length > 0, dormantReasons: reasons }
})
}
module.exports = {
validate,
create,
update,
listAnnotated,
MAX_COOLDOWN_SECONDS,
MAX_DELAY_SECONDS,
MAX_SENDS_PER_HOUR,
}