fix(engagement): two defects the Phase 11b live walk found in core
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user