fix(engagement): two defects the Phase 11b live walk found in core
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Successful in 5m30s

Both are invisible to a fixture and loud on a real database, which is why the
walk is the phase's acceptance rather than a formality.

1. registerEngagementSeeds validated `max_sends_per_hour` and then dropped it
   from the normalized rule. The column is NOT NULL, so every one of the 25
   module-seeded rules failed to insert at boot. The registry test asserted the
   REJECTION of a bad ceiling and never that a good one survives; it now asserts
   the normalized rule against `engagementRules.db.insert`'s own column list, so
   the next field added is covered the day it is added.

2. The cooldown claim runs inside the engine's per-channel loop and its key was
   (rule, user, subject). So the first channel of a rule claimed the cooldown and
   every later one was reported as cooled -- and `inapp` is ranked first
   deliberately, so a rule naming email + in-app delivered the inbox item and
   silently never the mail. Core's own `news.post` rule has that shape. Phase
   11b's decision 8 requires the letter and the inbox item to fire together.

   `channel` joins the PRIMARY KEY (the org lead's decision 12: a cooldown is per
   delivery, not per occasion). Migrated in place behind an information_schema
   guard, because MariaDB has no conditional form of a key change and replaying
   schema.sql would otherwise fail on every boot after the first.

1551 core tests green; both new tests verified by reverting each fix in turn.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 07:12:09 -05:00
parent c3783f56f1
commit c8d45733b6
6 changed files with 118 additions and 17 deletions

View File

@@ -203,9 +203,18 @@ async function applyRule(rule, event, now) {
summary.capped += 1
continue
}
// One statement, guarded on the interval, so two concurrent emits cannot
// both pass a read-then-write check (§4.1).
const allowed = await cooldownsDb.claim(rule.id, userId, subjectKey, rule.cooldown_seconds, now)
// Guarded on the interval, so two concurrent emits cannot both pass a
// read-then-write check (§4.1).
//
// **Keyed on the CHANNEL as well**, which is what makes this loop correct
// rather than what makes it work. Without the channel, the first channel of
// a rule claims the cooldown and every later one is refused as cooling —
// and `inapp` is ranked first above, so a rule naming email + in-app would
// deliver the in-app item and silently never the mail. Found on Phase 11b's
// live rig; a cooldown is per delivery, not per occasion.
const allowed = await cooldownsDb.claim(
rule.id, userId, subjectKey, channel, rule.cooldown_seconds, now,
)
if (!allowed) {
summary.cooled += 1
continue

View File

@@ -1,8 +1,14 @@
const { query } = require('../../utils/db')
/**
* Claim a fire for (rule, user, subject), or refuse it because the pair is still
* cooling. ENGAGEMENT.md §4.1.
* Claim a fire for (rule, user, subject, channel), or refuse it because that
* delivery is still cooling. ENGAGEMENT.md §4.1.
*
* **`channel` is part of the key, and Phase 11b is where that was settled.** The
* engine claims inside its per-channel loop, so a key without the channel means
* the first channel of a two-channel rule claims the cooldown and every later one
* is refused as cooling - which made every in-universe email body of Phase 11b
* unreachable behind the in-app one. A cooldown is per delivery.
*
* **Two statements, each of which is its own atomic decision** - and it is worth
* saying why it is not the single `INSERT ... ON DUPLICATE KEY UPDATE` §4.1
@@ -37,28 +43,29 @@ const { query } = require('../../utils/db')
* `cooldown_seconds = 0` always passes, which is the documented meaning of a rule
* with no cooldown: the guard becomes `last_fired_at <= now`, and it is.
*/
async function claim(ruleId, userId, subjectKey, cooldownSeconds, now = new Date()) {
async function claim(ruleId, userId, subjectKey, channel, cooldownSeconds, now = new Date()) {
const moved = await query(
`UPDATE engagement_cooldowns
SET last_fired_at = ?, fire_count = fire_count + 1
WHERE rule_id = ? AND user_id = ? AND subject_key = ?
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?
AND last_fired_at <= ? - INTERVAL ? SECOND`,
[now, ruleId, userId, subjectKey, now, cooldownSeconds],
[now, ruleId, userId, subjectKey, channel, now, cooldownSeconds],
)
if (Number(moved?.affectedRows || 0) === 1) return true
const inserted = await query(
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, last_fired_at, fire_count)
VALUES (?, ?, ?, ?, 1)`,
[ruleId, userId, subjectKey, now],
`INSERT IGNORE INTO engagement_cooldowns (rule_id, user_id, subject_key, channel, last_fired_at, fire_count)
VALUES (?, ?, ?, ?, ?, 1)`,
[ruleId, userId, subjectKey, channel, now],
)
return Number(inserted?.affectedRows || 0) === 1
}
const get = async (ruleId, userId, subjectKey) => {
const get = async (ruleId, userId, subjectKey, channel) => {
const [row] = await query(
'SELECT * FROM engagement_cooldowns WHERE rule_id = ? AND user_id = ? AND subject_key = ?',
[ruleId, userId, subjectKey],
`SELECT * FROM engagement_cooldowns
WHERE rule_id = ? AND user_id = ? AND subject_key = ? AND channel = ?`,
[ruleId, userId, subjectKey, channel],
)
return row || null
}

View File

@@ -874,6 +874,10 @@ function checkSeedRule(owner, entry, ownTemplateKeys, coreKeys) {
name: r.name,
audience: r.audience,
audience_segment_id: null,
// Checked above and carried here: the column is NOT NULL, so a normalizer
// that validates the ceiling and then drops it fails every insert in the
// group at boot — loudly, but only on a real database.
max_sends_per_hour: r.max_sends_per_hour,
channels: [...r.channels],
template_keys: { ...keys },
conditions: r.conditions === undefined ? null : r.conditions,