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