feat(engagement): let a module ship its own templates and rules (Phase 11b)
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>
This commit is contained in:
@@ -174,6 +174,32 @@ async function boot({ modules, model } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// What a module SHIPS as engagement content — its message bodies and its
|
||||
// seeded rules (ENGAGEMENT.md Phase 11b, decision 7).
|
||||
//
|
||||
// **Here rather than in `seedDefaults()`, and that is forced.** `server.js`
|
||||
// seeds before it requires `app.js`, and requiring `app.js` is what scans the
|
||||
// volume and runs the loader — so at the moment core seeds its own templates,
|
||||
// no module has registered anything.
|
||||
//
|
||||
// **After the reconcile and before `onBoot`**, both deliberately: `disabled`
|
||||
// is now known, so a module the operator switched off is skipped rather than
|
||||
// having its rules quietly written; and a module that warms a cache in
|
||||
// `onBoot` may assume its rules and bodies exist by then.
|
||||
//
|
||||
// Failed modules are skipped for the stronger reason. A module whose require
|
||||
// or schema replay failed has registered nothing anyway — but one whose ROW
|
||||
// says `startup_failed` may have registered before failing later, and seeding
|
||||
// content for a module that is about to answer 503 puts rows in the operator's
|
||||
// Rules screen for a thing that is not running.
|
||||
const skip = new Set([
|
||||
...disabled,
|
||||
...scanned.filter((m) => m.state === 'startup_failed').map((m) => m.id),
|
||||
])
|
||||
await safe('seeding module engagement content', () =>
|
||||
// eslint-disable-next-line global-require
|
||||
require('../engagement/moduleSeeds').seedModuleEngagement({ skip }))
|
||||
|
||||
for (const { id, hook, ctx } of loader.bootable()) {
|
||||
try {
|
||||
// Awaited without a timeout, deliberately (§2.5): a slow onBoot delays the
|
||||
|
||||
Reference in New Issue
Block a user