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:
2026-09-01 00:46:15 -05:00
parent 81e0338a69
commit cfd1cb3c3c
7 changed files with 788 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
{ {
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.8.0", "moduleApiVersion": "1.9.0",
"triggers": [ "triggers": [
{ {
"id": "news.post", "id": "news.post",

View File

@@ -0,0 +1,179 @@
// ── Seeding what a module ships (ENGAGEMENT.md Phase 11b, decision 7) ──────
//
// Core's own bodies and rules are seeded from `seedDefaults()`, and a module's
// cannot be: `server.js` calls `seedDefaults()` BEFORE it requires `app.js`, and
// requiring `app.js` is what scans the volume and runs the loader. At the moment
// core seeds, no module has registered anything at all.
//
// So this runs from `modules/lifecycle.js` `boot()` instead — after the
// `installed_modules` reconcile, so a module the operator disabled or one that
// failed to load is skipped rather than seeded, and BEFORE the `onBoot`
// dispatch, so a module that warms a cache in `onBoot` may assume its rules
// exist.
//
// **It reuses core's two seeders rather than reimplementing them**, which is the
// whole argument for the registry existing (decision 7): `seedOne` owns the
// `customized` skip and the `seed_version` comparison, `validateEmailBlocks`
// owns what a renderable body is, and a module supplies data. A copy of either
// living outside this directory would drift the first time core improved the
// original — and the drift would surface as a mail somebody already received.
//
// ── The asymmetry, once more, because it is the thing to get right ─────────
//
// **Templates are re-ensured every boot.** A row carries `seed_key`,
// `seed_version` and `customized`, so re-ensuring is how a better default
// reaches a deployment without stealing an operator's edit (§4.6.1 property 3),
// and a template added in a later module version reaches every deployment rather
// than only fresh ones.
//
// **Rule groups are one-shot, each under its own settings guard.** Re-ensuring a
// rule would resurrect one an operator deleted and reset one they enabled. This
// is 11a's seed-key finding as a mechanism: a rule appended to an existing group
// reaches fresh installs only, and a rule that must reach already-stamped
// deployments takes a new group key. The module chooses; this file honours it.
//
// **Never throws.** It is on the boot path beside every other `safe()`-wrapped
// step in `lifecycle.boot()`, and a body that would not seed costs the shipped
// default — `renderByKey`'s fallback stays in charge — not the deployment.
const templatesDb = require('../model/engagement/engagementTemplates.db')
const rulesDb = require('../model/engagement/engagementRules.db')
const settingsDb = require('../model/settings/settings.db')
const emailBlocks = require('../emailBlocks')
const log = require('../utils/logger')('engagement')
/**
* The one-shot guard for one module's rule group.
*
* Namespaced by owner AND by group so two modules may use the same group name,
* and so a module can add a second group later without touching the first. Its
* VALUE is the timestamp — purely so an operator reading the settings table can
* tell when it ran; only its presence is read.
*/
const guardKey = (owner, group) => `engagement_module_rules_seeded:${owner}:${group}`
/**
* Ensure one module's templates, and bring un-customized rows up to the current
* seed. Idempotent.
*/
async function seedModuleTemplates(owner, templates, deps = {}) {
const templates_ = deps.templatesDb || templatesDb
const counts = { inserted: 0, updated: 0, skipped: 0, invalid: 0 }
for (const seed of templates) {
// Validated against the block registry before it is stored, exactly as core's
// own seeds are and for the same reason: a shipped block array no renderer
// understands sitting in the table reads to an operator as their deployment
// being broken. Refusing to write it leaves the fallback in charge and puts
// the reason in the boot log, with the module named.
const { valid, errors } = emailBlocks.validateEmailBlocks(seed.blocks)
if (!valid) {
log.error('a module template is invalid and was not seeded', { owner, key: seed.key, errors })
counts.invalid += 1
continue
}
try {
counts[await templates_.seedOne(seed)] += 1
} catch (err) {
log.error('module template seed failed', { owner, key: seed.key, message: err.message })
}
}
// The third arm of §4.6.1 property 3: a customized row is never touched, and
// the fact that a better default now exists is surfaced instead of applied.
let stale = []
try {
stale = await templates_.staleCustomized(
templates.map((t) => ({ key: t.key, seedVersion: t.seedVersion })),
)
} catch {
stale = []
}
if (stale.length) {
log.info('customized module templates have a newer shipped default', {
owner,
keys: stale.map((t) => t.key),
})
}
return { ...counts, stale: stale.map((t) => t.key) }
}
/**
* Seed one named rule group, once, under its own guard.
*
* Mirrors `coreRules.seedGroup` deliberately, including the stamp-on-partial
* behaviour: re-running would duplicate the rules that DID insert, and a
* duplicate rule is two mails per event — worse than the one missing rule an
* operator can add from the Rules screen.
*/
async function seedRuleGroup(owner, group, deps = {}) {
const rules_ = deps.rulesDb || rulesDb
const settings_ = deps.settingsDb || settingsDb
const summary = { inserted: 0, skipped: 0 }
const key = guardKey(owner, group.key)
try {
const seen = await settings_.get(key)
if (seen) return { ...summary, skipped: group.rules.length }
for (const rule of group.rules) {
try {
await rules_.insert(rule)
summary.inserted += 1
} catch (err) {
log.error('module rule seed failed', {
owner,
group: group.key,
trigger: rule.trigger_id,
message: err.message,
})
}
}
await settings_.set(key, new Date().toISOString())
if (summary.inserted) {
log.info('seeded module engagement rules, all disabled', {
owner,
group: group.key,
rules: summary.inserted,
note: group.note || undefined,
})
}
} catch (err) {
log.error('module rule group seeding failed', { owner, group: group.key, message: err.message })
}
return summary
}
/**
* Seed every registered module's engagement content.
*
* @param {object} [deps]
* @param {Function} [deps.seeds] () => [{ owner, templates, ruleGroups }]
* @param {Set} [deps.skip] owners not to seed (disabled or failed)
* @param {object} [deps.templatesDb] / [deps.rulesDb] / [deps.settingsDb] — test seams
*/
async function seedModuleEngagement({ seeds, skip = new Set(), ...dbs } = {}) {
// eslint-disable-next-line global-require
const read = seeds || require('../modules/registries').allEngagementSeeds
const totals = { templates: 0, rules: 0 }
for (const entry of read()) {
if (skip.has(entry.owner)) {
log.info('skipping engagement seeds for a module that is not booting', { owner: entry.owner })
continue
}
const t = await seedModuleTemplates(entry.owner, entry.templates || [], dbs)
totals.templates += t.inserted + t.updated
for (const group of entry.ruleGroups || []) {
const r = await seedRuleGroup(entry.owner, group, dbs)
totals.rules += r.inserted
}
log.info('module engagement seeds ensured', { owner: entry.owner, ...t })
}
return totals
}
module.exports = {
seedModuleEngagement,
seedModuleTemplates,
seedRuleGroup,
guardKey,
}

View File

@@ -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()) { for (const { id, hook, ctx } of loader.bootable()) {
try { try {
// Awaited without a timeout, deliberately (§2.5): a slow onBoot delays the // Awaited without a timeout, deliberately (§2.5): a slow onBoot delays the

View File

@@ -356,6 +356,20 @@ function buildApi(record) {
once('registerAudiences') once('registerAudiences')
record.staged.registerAudiences(audiences) record.staged.registerAudiences(audiences)
}, },
// What the module SHIPS behind those two — its message bodies and its
// seeded rules (API 1.9.0, ENGAGEMENT.md Phase 11b decision 7). `once` for
// the same reason again, and here it is load-bearing rather than tidy: a
// rule belongs to exactly one named group, and merging two calls would make
// "which group is this rule in" — the question the one-shot guard answers —
// unanswerable.
//
// Data only. Nothing on the object is a function and nothing on it reaches a
// recipient: seeding writes rows that are `enabled = 0`, and a module still
// cannot send mail (§1.2).
registerEngagementSeeds(seeds) {
once('registerEngagementSeeds')
record.staged.registerEngagementSeeds(seeds)
},
// The two lifecycle hooks (§2.5). Registered here, dispatched from // The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one. // lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply // Both are optional: a module with no warm-up and nothing to close simply

View File

@@ -38,6 +38,19 @@
// 5. `registerAudiences(audiences)` — §5.1a. Named sets of user ids a // 5. `registerAudiences(audiences)` — §5.1a. Named sets of user ids a
// module can resolve over its own data, for an operator to point a rule at. // module can resolve over its own data, for an operator to point a rule at.
// //
// And a sixth, in Phase 11b (decision 7):
//
// 6. `registerEngagementSeeds({ templates, ruleGroups })` — the message BODIES
// and the shipped rules behind 4 and 5. A module declaring a trigger could
// say what its payload was and never say what it should read like, so a
// module's mail was core's generic body or nothing.
//
// **6 stores data and nothing else — no function, no handle.** A template is
// blocks and a rule is columns, both validated here and both written by core's
// own seeders (`engagement/moduleSeeds.js`), which is what keeps `seed_version`,
// `customized` and the block registry in the one file that owns them. It is
// emphatically not a send path: a module still cannot mail anyone (§1.2).
//
// **Triggers and notification streams share ONE id namespace** (the org lead's // **Triggers and notification streams share ONE id namespace** (the org lead's
// §7.2 decision). A stream entry is a subscription toggle and a trigger is a // §7.2 decision). A stream entry is a subscription toggle and a trigger is a
// payload contract, so they stay two REGISTRATIONS with two shapes — but an id // payload contract, so they stay two REGISTRATIONS with two shapes — but an id
@@ -111,6 +124,15 @@ const triggers = new Map()
// trigger of the same name would be a collision between two unrelated things. // trigger of the same name would be a collision between two unrelated things.
const audiences = new Map() const audiences = new Map()
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
// decision 7). What a module ships as CONTENT rather than as contract: the
// bodies its triggers render through, and the rules an operator switches on.
//
// Keyed by owner and not by template key, because the seeder runs per module —
// a module the operator disabled is skipped whole, and a module that failed to
// load never gets here at all.
const engagementSeeds = new Map()
let coreRegistered = false let coreRegistered = false
// Stream ids that predate the module system and may not carry their owner's // Stream ids that predate the module system and may not carry their owner's
@@ -723,6 +745,206 @@ function checkAudienceShape(entry) {
} }
} }
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
//
// **Two mechanisms, and the asymmetry between them is the whole design.**
//
// A TEMPLATE is re-ensured on every boot. Its row carries `seed_key`,
// `seed_version` and `customized`, so re-ensuring is how a better default
// reaches a deployment without stealing an operator's edit (§4.6.1 property 3),
// and a template added in a later module version reaches every deployment rather
// than only fresh ones.
//
// A RULE is the opposite. Re-ensuring one would resurrect a rule an operator
// deleted and reset one they enabled — so rules arrive in named GROUPS, each
// with its own one-shot settings guard. That is 11a's seed-key finding stated as
// an API instead of as a warning: appending a rule to an existing group reaches
// fresh installs only, and a rule that must reach deployments already stamped
// takes a NEW group. The module names its groups, so the module makes that
// choice knowingly.
//
// Everything below is a shape check. Nothing here writes: `engagement/
// moduleSeeds.js` does, through the same `seedOne` and the same block validator
// core's own seeds go through.
// A module template key must be namespaced to its owner, for the same reason a
// trigger id must: `engagement_templates.key` is UNIQUE across the table, so an
// unprefixed `notify.event` from a module would collide with core's — and win or
// lose depending on boot order, which is the worst of both.
const TEMPLATE_KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
const MAX_TEMPLATE_KEY = 96
const SEED_GROUP_KEY = /^[a-z][a-z0-9]*(?:[-.][a-z0-9]+)*$/
// The channels a seeded template may target. Deliberately a literal rather than
// a read of the channel registry: this runs at registration time, which is
// before any channel a module might add is registered, and a seed for a channel
// nothing delivers is a row an operator can never use.
const SEEDABLE_CHANNELS = ['email', 'inapp']
// Core's own seed keys, which a module's rule MAY point at — that is §4.6.1
// property 1 in force, and the nine plain bodies of decision 9 are exactly this.
// Required lazily-safe: `templateSeeds` is pure data with no requires of its own.
// eslint-disable-next-line global-require
const coreTemplateKeys = () => new Set(require('../engagement/templateSeeds').SEEDS.map((s) => s.key))
function checkSeedTemplate(owner, entry) {
const t = entry || {}
const where = `registerEngagementSeeds: template "${t.key}"`
if (!TEMPLATE_KEY.test(t.key || '') || t.key.length > MAX_TEMPLATE_KEY) {
throw new Error(`registerEngagementSeeds: bad template key "${t.key}"`)
}
if (!t.key.startsWith(`${owner}.`)) {
throw new Error(`${where} is not namespaced "${owner}."`)
}
if (!t.name) throw new Error(`${where} has no name`)
if (!SEEDABLE_CHANNELS.includes(t.channel)) {
throw new Error(`${where} has unknown channel "${t.channel}" (one of ${SEEDABLE_CHANNELS.join(', ')})`)
}
if (!Array.isArray(t.blocks) || !t.blocks.length) throw new Error(`${where} has no blocks`)
if (!Number.isInteger(t.seedVersion) || t.seedVersion < 1) {
throw new Error(`${where} needs an integer seedVersion of 1 or more`)
}
// An email body without a subject is a mail with an empty subject line, which
// no operator meant; an in-app body WITH one is a column the inbox does not
// read (`inapp.event` leaves it NULL and says why).
if (t.channel === 'email' && !t.subject) throw new Error(`${where} is an email body with no subject`)
if (t.channel !== 'email' && t.subject) {
throw new Error(`${where} is a ${t.channel} body and cannot carry a subject`)
}
// `protected` is core's alone. It means "the system breaks without this body",
// which is true of a password reset and true of nothing a module ships; a
// module marking its own template undeletable is a module taking an operator's
// delete button away.
if (t.protected) throw new Error(`${where} may not be protected — that flag is core's`)
return {
key: t.key,
name: t.name,
channel: t.channel,
subject: t.subject || null,
blocks: t.blocks,
seedVersion: t.seedVersion,
triggerId: t.triggerId || null,
triggerVersion: Number.isInteger(t.triggerVersion) ? t.triggerVersion : null,
protected: false,
status: 'published',
}
}
function checkSeedRule(owner, entry, ownTemplateKeys, coreKeys) {
const r = entry || {}
const where = `registerEngagementSeeds: rule for "${r.trigger_id}"`
if (!EVENT_ID.test(r.trigger_id || '')) {
throw new Error(`registerEngagementSeeds: bad rule trigger_id "${r.trigger_id}"`)
}
// A module seeds rules for ITS OWN triggers. Shipping one for core's — or for
// another module's — would mean uninstalling this module leaves a rule behind
// that nobody can explain, and two modules could ship two rules for the same
// event with neither aware of the other.
if (!namespaced(owner, r.trigger_id, LEGACY_STREAM_IDS)) {
throw new Error(`${where} is not namespaced "${owner}."`)
}
if (!r.name) throw new Error(`${where} has no name`)
if (!Array.isArray(r.channels) || !r.channels.length) throw new Error(`${where} has no channels`)
if (!r.audience) throw new Error(`${where} has no audience`)
if (!Number.isInteger(r.cooldown_seconds) || r.cooldown_seconds < 0) {
throw new Error(`${where} needs a cooldown_seconds of 0 or more`)
}
// Q3's hard ceiling, and the reason a seeded rule cannot omit it: it is what
// keeps a misconfiguration from becoming a mail storm, so a module may choose
// the number and may not decline to have one.
if (!Number.isInteger(r.max_sends_per_hour) || r.max_sends_per_hour < 1) {
throw new Error(`${where} needs a max_sends_per_hour of 1 or more`)
}
const keys = r.template_keys || {}
if (!keys || typeof keys !== 'object' || Array.isArray(keys)) {
throw new Error(`${where} needs a template_keys object`)
}
for (const [channel, key] of Object.entries(keys)) {
// `digest` is a template slot rather than a channel — the digest worker's
// body for a rule whose email channel is set to digest mode — so it is
// allowed here and absent from `channels`.
if (!ownTemplateKeys.has(key) && !coreKeys.has(key)) {
throw new Error(
`${where} names template "${key}" for ${channel}, which is neither one of its own seeds nor core's`,
)
}
}
return {
trigger_id: r.trigger_id,
name: r.name,
audience: r.audience,
audience_segment_id: null,
channels: [...r.channels],
template_keys: { ...keys },
conditions: r.conditions === undefined ? null : r.conditions,
cooldown_seconds: r.cooldown_seconds,
delay_seconds: Number.isInteger(r.delay_seconds) ? r.delay_seconds : 0,
cancel_on: Array.isArray(r.cancel_on) ? [...r.cancel_on] : [],
// Never negotiable and never a parameter (Q3). A module that could ship an
// enabled rule could mail a deployment's whole user table on the strength of
// an upgrade nobody read the release note for.
enabled: 0,
updated_by: null,
}
}
/**
* `registerEngagementSeeds({ templates, ruleGroups })`.
*
* Validated whole, exactly as `apply()` validates: a module that got one of
* thirty-two templates wrong ships none of them, and finds out at boot with the
* offending key named rather than at send time with a half-seeded table.
*/
function checkEngagementSeeds(owner, entry) {
const e = entry || {}
if (e.templates !== undefined && !Array.isArray(e.templates)) {
throw new Error('registerEngagementSeeds: templates must be an array')
}
if (e.ruleGroups !== undefined && !Array.isArray(e.ruleGroups)) {
throw new Error('registerEngagementSeeds: ruleGroups must be an array')
}
const templates = []
const seenKeys = new Set()
for (const t of e.templates || []) {
const checked = checkSeedTemplate(owner, t)
if (seenKeys.has(checked.key)) {
throw new Error(`registerEngagementSeeds: template "${checked.key}" declared twice`)
}
seenKeys.add(checked.key)
templates.push(checked)
}
const coreKeys = coreTemplateKeys()
const ruleGroups = []
const seenGroups = new Set()
for (const g of e.ruleGroups || []) {
const group = g || {}
if (!SEED_GROUP_KEY.test(group.key || '')) {
throw new Error(`registerEngagementSeeds: bad rule group key "${group.key}"`)
}
if (seenGroups.has(group.key)) {
throw new Error(`registerEngagementSeeds: rule group "${group.key}" declared twice`)
}
seenGroups.add(group.key)
if (!Array.isArray(group.rules) || !group.rules.length) {
throw new Error(`registerEngagementSeeds: rule group "${group.key}" has no rules`)
}
ruleGroups.push({
key: group.key,
note: group.note || '',
rules: group.rules.map((r) => checkSeedRule(owner, r, seenKeys, coreKeys)),
})
}
return { templates, ruleGroups }
}
/** Every registrant's seeds, in registration order. What the seeder walks. */
const allEngagementSeeds = () =>
[...engagementSeeds.entries()].map(([owner, seeds]) => ({ owner, ...seeds }))
/** One registrant's, or null. */
const engagementSeedsFor = (owner) => engagementSeeds.get(owner) || null
// `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's // `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's
// router reaches the app through declareSlot(), which no static parse of app.js // router reaches the app through declareSlot(), which no static parse of app.js
// can follow, so swagger-autogen would silently drop every route in it — the // can follow, so swagger-autogen would silently drop every route in it — the
@@ -757,6 +979,7 @@ function stage(owner) {
slashCommands: [], slashCommands: [],
triggers: [], triggers: [],
audiences: [], audiences: [],
engagementSeeds: [],
} }
return { return {
staged, staged,
@@ -788,6 +1011,9 @@ function stage(owner) {
if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array') if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array')
for (const e of entries) staged.audiences.push(checkAudienceShape(e)) for (const e of entries) staged.audiences.push(checkAudienceShape(e))
}, },
registerEngagementSeeds(entry) {
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
},
} }
} }
@@ -810,6 +1036,7 @@ function apply({
slashCommands: newSlashCommands = [], slashCommands: newSlashCommands = [],
triggers: newTriggers = [], triggers: newTriggers = [],
audiences: newAudiences = [], audiences: newAudiences = [],
engagementSeeds: newSeeds = [],
}) { }) {
// ── validate ── // ── validate ──
const seenStreams = new Set() const seenStreams = new Set()
@@ -886,6 +1113,14 @@ function apply({
seenSlots.add(x.slot) seenSlots.add(x.slot)
} }
// One call per registrant, like the post hook and the team provider above it.
// A second call is a module that wrote its seeds in two places, and merging
// them silently would make "which group is this rule in" unanswerable.
if (newSeeds.length > 1) throw new Error(`"${owner}" registered engagement seeds more than once`)
if (newSeeds.length && engagementSeeds.has(owner)) {
throw new Error(`"${owner}" already registered engagement seeds`)
}
if (newPostHooks.length > 1) throw new Error(`"${owner}" registered more than one post hook`) if (newPostHooks.length > 1) throw new Error(`"${owner}" registered more than one post hook`)
if (newPostHooks.length && postHooks.has(owner)) { if (newPostHooks.length && postHooks.has(owner)) {
throw new Error(`"${owner}" already registered a post hook`) throw new Error(`"${owner}" already registered a post hook`)
@@ -921,6 +1156,7 @@ function apply({
for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c }) for (const c of newSlashCommands) slashCommands.set(c.name, { owner, ...c })
for (const t of newTriggers) triggers.set(t.id, { owner, ...t }) for (const t of newTriggers) triggers.set(t.id, { owner, ...t })
for (const a of newAudiences) audiences.set(a.id, { owner, ...a }) for (const a of newAudiences) audiences.set(a.id, { owner, ...a })
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
} }
// ── Core's own registrations ─────────────────────────────────────────────── // ── Core's own registrations ───────────────────────────────────────────────
@@ -996,6 +1232,7 @@ function _reset() {
slashCommands.clear() slashCommands.clear()
triggers.clear() triggers.clear()
audiences.clear() audiences.clear()
engagementSeeds.clear()
coreRegistered = false coreRegistered = false
} }
@@ -1023,6 +1260,9 @@ module.exports = {
allAudiences, allAudiences,
audience, audience,
resolveAudience, resolveAudience,
allEngagementSeeds,
engagementSeedsFor,
SEEDABLE_CHANNELS,
VARIABLE_TYPES, VARIABLE_TYPES,
TRIGGER_KINDS, TRIGGER_KINDS,
stage, stage,

View File

@@ -9,6 +9,39 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version. // has nothing to say about a website module) and from any module's own version.
// 1.9.0 - a sixth registration call: `api.registerEngagementSeeds({ templates,
// ruleGroups })` (docs/website/ENGAGEMENT.md Phase 11b, decision 7). A module
// could declare a trigger from 1.7.0 and could never say what the mail should
// READ like: `templateSeeds.js` and `coreRules.js` are core files with core
// arrays in them, so a module's notification was core's generic body or nothing.
// Additions only, so minor: every module written against 1.8.0 keeps working and
// simply seeds nothing.
//
// **What a module has to know about it beyond the new name**, because the two
// halves behave differently on purpose:
//
// - **Templates are re-ensured on every boot**, under `seed_key` /
// `seed_version` / `customized` - so bumping a body's `seedVersion` reaches
// every deployment except the ones where an operator edited that row, and a
// template added in a later module version reaches everyone.
// - **Rules are one-shot, per named GROUP.** Re-ensuring one would resurrect a
// rule an operator deleted and reset one they enabled, so each group carries
// its own settings guard. A rule appended to an existing group therefore
// reaches FRESH INSTALLS ONLY; one that must reach deployments already
// stamped takes a new group key. That is 11a's seed-key finding as an API
// rather than as a warning, and the module makes the choice knowingly.
//
// Two things it deliberately does not permit. A seeded rule is always
// `enabled = 0` - it is not a parameter - which is Q3's invariant surviving
// contact with the largest seed set in the workstream. And a module may not mark
// a template `protected`: that flag means "the system breaks without this body",
// which is true of a password reset and of nothing a module ships, and a module
// setting it would take an operator's delete button away.
//
// It runs from `modules/lifecycle.js` `boot()` rather than `seedDefaults()`, and
// that is forced rather than chosen: core seeds before `app.js` is required, and
// requiring `app.js` is what runs the loader.
// 1.8.0 - a seventh value in the audience ceiling lattice: `admin`, a child of // 1.8.0 - a seventh value in the audience ceiling lattice: `admin`, a child of
// `staff` (docs/website/ENGAGEMENT.md Phase 11, decision 1). A module may now // `staff` (docs/website/ENGAGEMENT.md Phase 11, decision 1). A module may now
// declare `ceiling: 'admin'` on a trigger or an audience, so the set of values // declare `ceiling: 'admin'` on a trigger or an audience, so the set of values
@@ -101,6 +134,6 @@
// an admin action a module performs belongs in core's one audit log, the // an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module // extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor. // reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.8.0' const MODULE_API_VERSION = '1.9.0'
module.exports = { MODULE_API_VERSION } module.exports = { MODULE_API_VERSION }

View File

@@ -0,0 +1,294 @@
// ── registerEngagementSeeds + the module seeder ────────────────────────────
//
// ENGAGEMENT.md Phase 11b, decision 7. Two halves, tested apart because they
// fail differently: the REGISTRY refuses a bad declaration at boot with the key
// named, and the SEEDER decides what reaches the database and — much more
// importantly — what does not reach it a second time.
//
// The properties worth a test are the ones no hand run would catch:
//
// • a module cannot ship an ENABLED rule, or a `protected` template, or a body
// for someone else's trigger, or a rule pointing at a template that does not
// exist. Each of those is a shipped mistake that only shows up as mail.
// • templates are re-ensured and rules are NOT — the asymmetry the whole
// design rests on, and the one an implementer would most plausibly "tidy".
// • a disabled module is skipped, which is the operator's switch meaning what
// it says even for content that is only rows in a table.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')
const registries = require('../src/modules/registries')
const db = require('../src/utils/db')
after(() => db.close())
beforeEach(() => registries._reset())
const blocks = [{ id: 'p1', type: 'email.text', props: { text: 'Hail, {{siteName}}.' } }]
const tpl = (over = {}) => ({
key: 'demo.house.warning',
name: 'A warning',
channel: 'email',
subject: 'A warning',
seedVersion: 1,
blocks,
...over,
})
const rule = (over = {}) => ({
trigger_id: 'demo.house.warning',
name: 'House warning',
audience: 'owner',
channels: ['email'],
template_keys: { email: 'demo.house.warning' },
cooldown_seconds: 3600,
max_sends_per_hour: 200,
...over,
})
/** Register a seed batch as `owner`; returns the error message or null. */
function trySeeds(owner, seeds) {
const api = registries.stage(owner)
try {
api.registerEngagementSeeds(seeds)
registries.apply(api.staged)
return null
} catch (err) {
return err.message
}
}
// ── The registry: what a module may and may not ship ───────────────────────
test('a well-formed batch registers and reads back under its owner', () => {
assert.equal(trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', note: 'the first set', rules: [rule()] }],
}), null)
const all = registries.allEngagementSeeds()
assert.equal(all.length, 1)
assert.equal(all[0].owner, 'demo')
assert.equal(all[0].templates.length, 1)
assert.equal(all[0].ruleGroups[0].key, 'v1')
assert.deepEqual(registries.engagementSeedsFor('demo').templates[0].key, 'demo.house.warning')
assert.equal(registries.engagementSeedsFor('nobody'), null)
})
test('a seeded rule is always disabled, whatever the module said', () => {
// Q3's invariant, and the one place in the workstream where a module could
// have overridden it. `enabled: 1` is not refused — it is IGNORED — because
// refusing would let a typo take a deployment's whole module offline at boot.
assert.equal(trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ enabled: 1 })] }],
}), null)
assert.equal(registries.engagementSeedsFor('demo').ruleGroups[0].rules[0].enabled, 0)
})
test('a template key must be namespaced to its owner', () => {
// `engagement_templates.key` is UNIQUE across the table, so an unprefixed
// `notify.event` from a module would collide with core's and win or lose on
// boot order.
const err = trySeeds('demo', { templates: [tpl({ key: 'notify.event' })] })
assert.match(err, /not namespaced "demo\."/)
})
test('a module may not ship a rule for a trigger it does not own', () => {
const err = trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ trigger_id: 'news.post' })] }],
})
assert.match(err, /not namespaced "demo\."/)
})
test('a module may not mark a template protected', () => {
const err = trySeeds('demo', { templates: [tpl({ protected: true })] })
assert.match(err, /may not be protected/)
})
test('a rule must name a template that exists — its own or core\'s', () => {
const missing = trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ template_keys: { email: 'demo.nope' } })] }],
})
assert.match(missing, /neither one of its own seeds nor core's/)
// Core's generic bodies ARE permitted — that is §4.6.1 property 1 in force,
// and the nine plain bodies of decision 9 are exactly this case.
registries._reset()
assert.equal(trySeeds('demo', {
ruleGroups: [{
key: 'v1',
rules: [rule({ template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' } })],
}],
}), null)
})
test('an email body needs a subject and an in-app body may not have one', () => {
assert.match(trySeeds('demo', { templates: [tpl({ subject: null })] }), /no subject/)
registries._reset()
assert.match(
trySeeds('demo', { templates: [tpl({ channel: 'inapp' })] }),
/cannot carry a subject/,
)
registries._reset()
assert.equal(trySeeds('demo', { templates: [tpl({ channel: 'inapp', subject: null })] }), null)
})
test('a rule must carry a per-hour ceiling', () => {
// Q3: the module chooses the number and may not decline to have one.
const err = trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ max_sends_per_hour: 0 })] }],
})
assert.match(err, /max_sends_per_hour/)
})
test('registering twice is a collision, not an addition', () => {
assert.equal(trySeeds('demo', { templates: [tpl()] }), null)
assert.match(trySeeds('demo', { templates: [tpl({ key: 'demo.other' })] }), /already registered/)
})
test('a bad template leaves nothing behind — validate-then-commit', () => {
const err = trySeeds('demo', {
templates: [tpl(), tpl({ key: 'demo.bad', channel: 'sms' })],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
assert.match(err, /unknown channel "sms"/)
assert.equal(registries.engagementSeedsFor('demo'), null)
assert.deepEqual(registries.allEngagementSeeds(), [])
})
// ── The seeder ─────────────────────────────────────────────────────────────
const moduleSeeds = require('../src/engagement/moduleSeeds')
/** A registered batch, shaped the way `allEngagementSeeds()` returns it. */
function registered(owner, seeds) {
assert.equal(trySeeds(owner, seeds), null)
return () => registries.allEngagementSeeds()
}
test('guardKey names both the owner and the group', () => {
// Two modules may use the same group name, and one module may add a second
// group later without disturbing the first.
assert.equal(moduleSeeds.guardKey('uo', 'triggers-v1'), 'engagement_module_rules_seeded:uo:triggers-v1')
assert.notEqual(moduleSeeds.guardKey('uo', 'a'), moduleSeeds.guardKey('other', 'a'))
})
test('templates are re-ensured every run and rule groups are seeded once', async () => {
// The asymmetry the design rests on. A second run must re-offer every template
// (so a bumped seedVersion reaches an existing deployment) and must offer no
// rule at all (so a rule an operator deleted stays deleted).
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
const settings = new Map()
const seededTemplates = []
const insertedRules = []
const stub = {
templatesDb: {
seedOne: async (t) => { seededTemplates.push(t.key); return 'inserted' },
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) },
},
}
await moduleSeeds.seedModuleEngagement({ seeds, ...stub })
await moduleSeeds.seedModuleEngagement({ seeds, ...stub })
assert.deepEqual(seededTemplates, ['demo.house.warning', 'demo.house.warning'])
assert.deepEqual(insertedRules, ['demo.house.warning'])
assert.ok(settings.has(moduleSeeds.guardKey('demo', 'v1')))
})
test('a partial rule group is still stamped', async () => {
// Re-running would duplicate the rules that DID insert, and a duplicate rule
// is two mails per event — worse than the one missing rule an operator can add
// from the Rules screen. `coreRules.seedGroup` made the same call.
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule(), rule({ trigger_id: 'demo.house.gone', name: 'Gone' })] }],
})
const settings = new Map()
let inserts = 0
await moduleSeeds.seedModuleEngagement({
seeds,
templatesDb: { seedOne: async () => 'inserted', staleCustomized: async () => [] },
rulesDb: {
insert: async () => {
inserts += 1
if (inserts === 2) throw new Error('duplicate')
},
},
settingsDb: {
get: async (k) => settings.get(k) || null,
set: async (k, v) => { settings.set(k, v) },
},
})
assert.equal(inserts, 2)
assert.ok(settings.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', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
let touched = 0
await moduleSeeds.seedModuleEngagement({
seeds,
skip: new Set(['demo']),
templatesDb: { seedOne: async () => { touched += 1; return 'inserted' }, staleCustomized: async () => [] },
rulesDb: { insert: async () => { touched += 1 } },
settingsDb: { get: async () => null, set: async () => {} },
})
assert.equal(touched, 0)
})
test('a database failure is logged, never thrown — this is the boot path', async () => {
const seeds = registered('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule()] }],
})
await moduleSeeds.seedModuleEngagement({
seeds,
templatesDb: {
seedOne: async () => { throw new Error('table is gone') },
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 () => {} },
})
})
test('an invalid block array is refused rather than stored', async () => {
// A shipped block array no renderer understands reads to an operator as their
// deployment being broken. Refusing leaves renderByKey's fallback in charge.
const seeds = registered('demo', {
templates: [tpl({ blocks: [{ id: 'x', type: 'email.nosuchblock', props: {} }] })],
})
let stored = 0
const totals = await moduleSeeds.seedModuleEngagement({
seeds,
templatesDb: { seedOne: async () => { stored += 1; return 'inserted' }, staleCustomized: async () => [] },
rulesDb: { insert: async () => {} },
settingsDb: { get: async () => null, set: async () => {} },
})
assert.equal(stored, 0)
assert.equal(totals.templates, 0)
})