// ── Engagement retention policy ──────────────────────────────────────────── // // ENGAGEMENT.md Phase 14. Three horizons, one place to read and write them, so // the nightly worker and Admin → Engagement → Retention cannot disagree about // what this deployment keeps — and so `/privacy` has one thing to describe. // // **The fourth engagement table is deliberately absent from this file.** // `engagement_suppressions` does not expire (org lead, 2026-09-01): a // suppression is a standing decision, and ageing out a hard bounce re-mails an // address that already bounced, which is how a sender loses a domain's // reputation. The one way out of that table stays what Phase 9 built — a // deliberate act by an admin, which Phase 14 only made reachable per row. // // Settings rows rather than env, for `teamActivityPrune`'s reason: an operator // tightening a busy shard should not need a deploy. Unlike the two workers this // copies, these three get a screen — the send log's horizon changes what an // operator-facing page can show, so it cannot be an invisible key. const settings = require('../settings/settings.model') const rulesDb = require('./engagementRules.db') const log = require('../../utils/logger')('engagement') /** * One entry per horizon. `min` is not a UI nicety — each is the point below * which the sweep breaks something that is not retention: * * - **cooldowns**: a pruned row makes the next fire a FIRST fire, i.e. a * duplicate send. `MAX_COOLDOWN_SECONDS` is a validated 86 400 (one day), so * 2 days is the smallest provably-safe value against any rule that can be * saved. `checkCooldownHorizon` re-checks it against the rules that exist. * - **outbox**: `MAX_DELAY_SECONDS` is also 86 400, so no `scheduled` row is * ever more than a day out; terminal rows younger than that are still the * most recent thing an operator would look at. * - **sends**: the per-rule hourly ceiling (§7.1 Q3) counts this table, so a * horizon under an hour would silently disable it. The floor is set far * above that, at the point the Send Log stops being worth opening. */ const HORIZONS = { sends: { key: 'engagement_sends_retain_days', default: 180, min: 7, max: 3650, label: 'Send log', }, cooldowns: { key: 'engagement_cooldowns_retain_days', default: 30, min: 2, max: 3650, label: 'Cooldowns', }, outbox: { key: 'engagement_outbox_retain_days', default: 30, min: 2, max: 3650, label: 'Outbox', }, } const NAMES = Object.keys(HORIZONS) /** * The current policy, as `{ sends, cooldowns, outbox }` in days. * * Wrapped in a try like `teamActivity.retentionConfig` and for its reason: the * nightly worker calls this with nobody watching, so a settings table that is * briefly unavailable must yield defaults rather than an exception that kills * the job. An unreadable, absent, non-numeric or out-of-range value all mean * the same thing — use the default — because none of them is a horizon. */ async function get() { const out = {} for (const name of NAMES) { const spec = HORIZONS[name] let days = spec.default try { const raw = await settings.get(spec.key) const n = Number(raw) if (Number.isFinite(n) && n >= spec.min && n <= spec.max) days = Math.floor(n) } catch (err) { log.debug('retention setting unreadable; using the default', { key: spec.key, message: err.message, }) } out[name] = days } return out } /** * Write one or more horizons. Unknown names are ignored rather than rejected, so * a client sending the whole object back is not coupled to this list; an * out-of-range value IS rejected, because silently clamping a number an operator * typed would leave the screen showing something the deployment is not doing. * * @returns {Promise} the policy as it now stands */ async function set(patch = {}, updatedBy = null) { for (const name of NAMES) { if (!(name in patch) || patch[name] === undefined || patch[name] === null) continue const spec = HORIZONS[name] const n = Number(patch[name]) if (!Number.isFinite(n) || Math.floor(n) !== n) { throw Object.assign(new Error(`${spec.label} retention must be a whole number of days`), { status: 400, }) } if (n < spec.min || n > spec.max) { throw Object.assign( new Error(`${spec.label} retention must be between ${spec.min} and ${spec.max} days`), { status: 400 }, ) } await settings.set(spec.key, String(n), updatedBy) } return get() } /** * The longest cooldown any ENABLED rule configures, in seconds — see * `engagementRules.db.maxEnabledCooldownSeconds` for why enabled only. * * Swallows its error rather than propagating: this is consulted by a worker * running on a timer, and a database hiccup must degrade the WARNING, never the * sweep. Zero reads as "no enabled rule has a cooldown", which produces no * warning — the same answer as an unreadable table, and the safe one, because * the alternative is a nightly alarm nobody can act on. */ async function longestEnabledCooldownSeconds() { try { return await rulesDb.maxEnabledCooldownSeconds() } catch (err) { log.debug('could not read the longest enabled cooldown', { message: err.message }) return 0 } } /** * Phase 14's acceptance line: the cooldown horizon is CHECKED against the * longest enabled rule's cooldown rather than picked. * * It returns a warning rather than throwing, and the worker sweeps anyway. The * alternative — refusing to prune — trades a bounded, describable fault (one * rule may re-fire early once) for the unbounded one this phase exists to end. * The screen surfaces the same warning, which is where an operator can act on it. * * @returns {Promise<{ ok: boolean, longestCooldownSeconds: number, message: string|null }>} */ async function checkCooldownHorizon(days) { const longest = await longestEnabledCooldownSeconds() const horizonSeconds = days * 24 * 60 * 60 if (longest > 0 && horizonSeconds <= longest) { return { ok: false, longestCooldownSeconds: longest, message: `Cooldown retention is ${days} day(s), but an enabled rule has a cooldown of ` + `${longest} second(s). Pruning a cooldown row that is still in force makes the next ` + 'fire count as a first fire, so that rule can send twice. Raise the horizon.', } } return { ok: true, longestCooldownSeconds: longest, message: null } } module.exports = { HORIZONS, NAMES, get, set, longestEnabledCooldownSeconds, checkCooldownHorizon, }