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>
Phase 11a declared 24 triggers and stopped where the plan said it would. Standing
11b up found that the next sentence — "24 rules, all enabled = 0; bespoke template
bodies" — described work with no mechanism to land in: templateSeeds.js and
coreRules.js are core files with core arrays in them, and there was no
registerTemplates or registerRules anywhere in registries.js.
So a module could say what an event's payload was and could never say what the
mail should read like. That is tolerable for one trigger and not for a catalogue,
and it is decisive once the bodies carry domain prose core must not contain (§5.2).
Adds api.registerEngagementSeeds({ templates, ruleGroups }) — MODULE_API 1.9.0.
The module supplies data; core keeps seedOne's customized skip, its seed_version
comparison and the block registry's validation, which is the whole argument for a
registry over the ctx.query a module already holds: a copy of any of those living
outside engagement/ would drift the first time core improved the original, and the
drift would surface as a mail somebody already received.
The two halves behave differently, deliberately:
- Templates re-ensure on every boot, so a bumped seedVersion reaches every
deployment except the ones where an operator edited that row.
- Rule groups are ONE-SHOT, each under its own settings guard — re-ensuring
would resurrect a rule an operator deleted and reset one they enabled. This is
11a's seed-key finding stated as an API rather than as a warning: a rule
appended to an existing group reaches fresh installs only, and one that must
reach stamped deployments takes a new group key.
Three prohibitions, each a shipped mistake that would only surface as mail: a
seeded rule is always enabled = 0 (Q3's invariant, ignored rather than refused so
a typo cannot take a module offline at boot); a module may not mark a template
protected; and a rule may only name its own trigger ids and its own or core's
template keys, with template keys namespaced because the key column is UNIQUE.
Runs from modules/lifecycle.js boot() rather than seedDefaults(), and that is
forced rather than chosen: server.js seeds before it requires app.js, and
requiring app.js is what runs the loader — at the moment core seeds, no module has
registered anything. Placed after the installed_modules reconcile (so a disabled
or failed module is skipped) and before the onBoot dispatch (so a module warming a
cache may assume its rules exist).
16 new tests; 1549 core tests green; check:modules clean.
Refs docs#/ENGAGEMENT.md Phase 11b decision 7.
Co-Authored-By: Claude <noreply@anthropic.com>