fix(engagement): claim the seed guard atomically, and let a rule name a digest body
All checks were successful
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 5m29s
PR Checks / bot-tests (pull_request) Successful in 8m30s

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:
2026-09-01 14:29:21 -05:00
parent 66bb3b9a3f
commit eec7dbf785
7 changed files with 192 additions and 29 deletions

View File

@@ -371,8 +371,7 @@ test('a GET on the unsubscribe endpoint mutates nothing and lands on the page',
// ── The seeded rules (decision 3) ──────────────────────────────────────────
test('core seeds a rule for each Team trigger, and every one of them is OFF', async () => {
patch(settingsDb, 'get', async () => null)
patch(settingsDb, 'set', async (k, v) => world.settings.set(k, v))
patch(settingsDb, 'claim', async (k, v) => { world.settings.set(k, v); return true })
patch(rulesDb, 'insert', async (rule) => { world.inserted.push(rule); return world.inserted.length })
const summary = await coreRules.seedTeamRules()
@@ -400,9 +399,32 @@ test('every seeded rule names a template that actually exists', () => {
// Seeded once, not ensured: an operator who deletes a rule must not find it back
// after a restart, and one they enabled must not be reset to off.
test('a second boot seeds nothing', async () => {
patch(settingsDb, 'get', async () => '2026-08-29T00:00:00.000Z')
// The guard is a CLAIM, so "already seeded" is the claim losing rather than a
// read finding a row. It is the same one-shot promise, made atomically: two
// instances booting together used to both read "absent" and both seed, and a
// duplicate rule is two mails per event (Phase 13's acceptance walk).
patch(settingsDb, 'claim', async () => false)
patch(rulesDb, 'insert', async () => { throw new Error('must not insert') })
const summary = await coreRules.seedTeamRules()
assert.equal(summary.inserted, 0)
assert.equal(summary.skipped, 4)
})
test('two instances booting together seed the Team rules once, not twice', async () => {
// Not awaited in turn on purpose: interleaving the two calls is the test, and
// awaiting the first would pass against the read-then-write guard this
// replaced. `claim` decides and writes without yielding, exactly as the
// settings table's PRIMARY KEY does.
const claimed = new Set()
patch(settingsDb, 'claim', async (k) => {
if (claimed.has(k)) return false
claimed.add(k)
return true
})
patch(rulesDb, 'insert', async (rule) => { world.inserted.push(rule); return world.inserted.length })
const [a, b] = await Promise.all([coreRules.seedTeamRules(), coreRules.seedTeamRules()])
assert.equal(a.inserted + b.inserted, 4)
assert.equal(world.inserted.length, 4)
})

View File

@@ -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 () => {

View File

@@ -64,6 +64,26 @@ function trySeeds(owner, seeds) {
// ── The registry: what a module may and may not ship ───────────────────────
/**
* The settings table, as far as these tests need it — and specifically its
* ATOMICITY. `claim` is the real `INSERT IGNORE`: it decides and writes without
* yielding, so exactly one caller can win a key, which is the property the guard
* depends on. A fake that read with `get` and wrote later with `set` could never
* express that, and agreed with the bug Phase 13's walk found.
*/
function fakeSettings(store = new Map()) {
return {
store,
get: async (k) => store.get(k) || null,
set: async (k, v) => { store.set(k, v) },
claim: async (k, v) => {
if (store.has(k)) return false
store.set(k, v)
return true
},
}
}
test('a well-formed batch registers and reads back under its owner', () => {
assert.equal(trySeeds('demo', {
templates: [tpl()],
@@ -225,10 +245,7 @@ test('templates are re-ensured every run and rule groups are seeded once', async
staleCustomized: async () => [],
},
rulesDb: { insert: async (r) => { insertedRules.push(r.trigger_id) } },
settingsDb: {
get: async (k) => settings.get(k) || null,
set: async (k, v) => { settings.set(k, v) },
},
settingsDb: fakeSettings(settings),
}
await moduleSeeds.seedModuleEngagement({ seeds, ...stub })
@@ -259,16 +276,46 @@ test('a partial rule group is still stamped', async () => {
if (inserts === 2) throw new Error('duplicate')
},
},
settingsDb: {
get: async (k) => settings.get(k) || null,
set: async (k, v) => { settings.set(k, v) },
},
settingsDb: fakeSettings(settings),
})
assert.equal(inserts, 2)
assert.ok(settings.has(moduleSeeds.guardKey('demo', 'v1')))
})
test('two instances booting together seed the group once, not twice', async () => {
// The defect Phase 13's acceptance walk found, and the reason the guard is a
// CLAIM rather than a read followed by a write. `docker compose up
// --scale app=2` and a rolling restart both start two instances on purpose;
// under the old ordering both read "not seeded" before either stamped, and
// both inserted the whole group. The walk ended up with 52 module rules where
// the module ships 26 — and a duplicate rule is two mails per event.
//
// The two calls are deliberately NOT awaited in turn: interleaving them is the
// whole test, and awaiting the first would pass against the bug.
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule(), rule({ trigger_id: 'demo.house.gone', name: 'Gone' })] }],
})
const settings = fakeSettings()
const inserted = []
const stub = {
seeds,
templatesDb: { seedOne: async () => 'inserted', staleCustomized: async () => [] },
rulesDb: { insert: async (r) => { inserted.push(r.trigger_id) } },
settingsDb: settings,
}
await Promise.all([
moduleSeeds.seedModuleEngagement({ ...stub }),
moduleSeeds.seedModuleEngagement({ ...stub }),
])
assert.deepEqual(inserted, ['demo.house.warning', 'demo.house.gone'])
assert.ok(settings.store.has(moduleSeeds.guardKey('demo', 'v1')))
})
test('a skipped owner is seeded not at all', async () => {
// The operator's switch means what it says even for content that is only rows.
const seeds = registered('demo', {
@@ -281,7 +328,7 @@ test('a skipped owner is seeded not at all', async () => {
skip: new Set(['demo']),
templatesDb: { seedOne: async () => { touched += 1; return 'inserted' }, staleCustomized: async () => [] },
rulesDb: { insert: async () => { touched += 1 } },
settingsDb: { get: async () => null, set: async () => {} },
settingsDb: fakeSettings(),
})
assert.equal(touched, 0)
})
@@ -298,7 +345,7 @@ test('a database failure is logged, never thrown — this is the boot path', asy
staleCustomized: async () => { throw new Error('also gone') },
},
rulesDb: { insert: async () => { throw new Error('gone too') } },
settingsDb: { get: async () => { throw new Error('and gone') }, set: async () => {} },
settingsDb: { claim: async () => { throw new Error('and gone') }, set: async () => {} },
})
})
@@ -313,7 +360,7 @@ test('an invalid block array is refused rather than stored', async () => {
seeds,
templatesDb: { seedOne: async () => { stored += 1; return 'inserted' }, staleCustomized: async () => [] },
rulesDb: { insert: async () => {} },
settingsDb: { get: async () => null, set: async () => {} },
settingsDb: fakeSettings(),
})
assert.equal(stored, 0)
assert.equal(totals.templates, 0)