fix(engagement): claim the seed guard atomically, and let a rule name a digest body
Both defects came out of Phase 13's acceptance walk, and neither was visible to any test. **1. The one-shot rule-group guard was not a guard.** `seedRuleGroup` and `coreRules.seedGroup` both read their settings stamp, inserted the whole group, and wrote the stamp AFTER the loop. Two instances booting in the same moment both read "absent" and both insert -- the walk ended up with 52 module rules where the module ships 26. `docker compose up --scale app=2` and a rolling restart both start two instances on purpose, so this is ordinary rather than exotic. `settings.db` gains `claim(key, value)`: the same `INSERT IGNORE` as `seedDefault`, reporting its own `affectedRows`, so exactly one caller can win a key. The atomicity is the PRIMARY KEY's -- no transaction, no lock, the same bargain `engagementWorker`'s row claim already makes. Both seeders claim before inserting. The trade the code already documented is unchanged, only its order: a process that dies mid-loop leaves the group stamped and partly seeded, and the missing rules are an operator's visit to the "new rule" form. A duplicate rule is two mails per event, for every rule in the group, which both functions' own comments already call the worse outcome. **Why no test caught it:** the stubs supplied `get` and `set` over a Map, which cannot race, so they agreed with the bug -- Phase 4a's `foundRows` finding in a different costume. The fake is now `claim`-shaped and decides without yielding, exactly as the table does, and both suites gained a test that runs two seeders with `Promise.all` and asserts one insert each. Verified by reverting the fix: the module test then reports every rule inserted twice. **2. A rule that names a `digest` body could not be saved.** `registries.js` `checkSeedRule` permits `digest` in as many words -- it is the body `teamDigestWorker` renders for a rule whose email channel an individual set to digest mode, so it never appears in `channels` and never could -- and MODULE_API.md 2.4 tells modules they may point `template_keys` at `notify.digest`. `engagementRules.model.js` then rejected any key that was not one of the rule's channels. So every rule shipping a digest body answered an operator who opened it and pressed Save with a 400 naming a key they had never typed: core's own Team and news rules, and sixteen of module-uo's. The only way to save was to delete the digest body, silently dropping digest support from that rule. The two validators now agree; anything that is neither a channel nor `digest` is still refused, with a test for each direction. No MODULE_API bump: no member is added, removed or changed, and the documented contract is what the code now honours rather than something new. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -627,6 +627,44 @@ test('the hourly ceiling counts sends, not attempts', async () => {
|
||||
assert.equal(result.enqueued, 1)
|
||||
})
|
||||
|
||||
// ── templateKeys: what a channel is, and what `digest` is ──────────────────
|
||||
|
||||
test('a rule may name a `digest` body, which is a template slot rather than a channel', async () => {
|
||||
// The defect Phase 13's acceptance walk found. `registries.js` `checkSeedRule`
|
||||
// permits `digest` in as many words — it is the body `teamDigestWorker`
|
||||
// renders for a rule whose email channel an individual set to digest mode, so
|
||||
// it never appears in `channels` and never could — and core's own Team and
|
||||
// news rules ship one, as do sixteen of module-uo's. This validator rejected
|
||||
// it, so every one of those rules answered an operator who opened it and
|
||||
// pressed Save with a 400 naming a key they had never typed, and the only way
|
||||
// to save was to delete the digest body.
|
||||
const checked = await rules.validate({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
name: 'IDOC warning',
|
||||
channels: ['email'],
|
||||
audience: 'owner',
|
||||
templateKeys: { email: 'notify.event', digest: 'notify.digest' },
|
||||
})
|
||||
|
||||
assert.equal(checked.ok, true, checked.errors && checked.errors.join(' '))
|
||||
assert.equal(checked.rule.template_keys.digest, 'notify.digest')
|
||||
})
|
||||
|
||||
test('a templateKeys entry that is neither a channel nor `digest` is still refused', async () => {
|
||||
// The rule that was right all along, kept: `digest` is one named exception
|
||||
// with a renderer behind it, not a hole that admits any word.
|
||||
const checked = await rules.validate({
|
||||
triggerId: 'uo.house.idoc_warning',
|
||||
name: 'IDOC warning',
|
||||
channels: ['email'],
|
||||
audience: 'owner',
|
||||
templateKeys: { email: 'notify.event', carrierpigeon: 'notify.event' },
|
||||
})
|
||||
|
||||
assert.equal(checked.ok, false)
|
||||
assert.match(checked.errors.join(' '), /carrierpigeon/)
|
||||
})
|
||||
|
||||
// ── Ceilings: the security boundary, both halves ───────────────────────────
|
||||
|
||||
test('a rule may not be SAVED with an audience wider than its trigger permits', async () => {
|
||||
|
||||
Reference in New Issue
Block a user