From cfd1cb3c3cdda7d9d0604012f16611c78209a187 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 1 Sep 2026 00:46:15 -0500 Subject: [PATCH 1/3] feat(engagement): let a module ship its own templates and rules (Phase 11b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/engagement-triggers.json | 2 +- server/src/engagement/moduleSeeds.js | 179 +++++++++++++ server/src/modules/lifecycle.js | 26 ++ server/src/modules/loader.js | 14 ++ server/src/modules/registries.js | 240 ++++++++++++++++++ server/src/modules/version.js | 35 ++- server/test/moduleEngagementSeeds.test.js | 294 ++++++++++++++++++++++ 7 files changed, 788 insertions(+), 2 deletions(-) create mode 100644 server/src/engagement/moduleSeeds.js create mode 100644 server/test/moduleEngagementSeeds.test.js diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index f873359..6bc05a8 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -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.", - "moduleApiVersion": "1.8.0", + "moduleApiVersion": "1.9.0", "triggers": [ { "id": "news.post", diff --git a/server/src/engagement/moduleSeeds.js b/server/src/engagement/moduleSeeds.js new file mode 100644 index 0000000..06e62b6 --- /dev/null +++ b/server/src/engagement/moduleSeeds.js @@ -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, +} diff --git a/server/src/modules/lifecycle.js b/server/src/modules/lifecycle.js index 3a9eea3..ba78702 100644 --- a/server/src/modules/lifecycle.js +++ b/server/src/modules/lifecycle.js @@ -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 diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index d53b698..51bec59 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -356,6 +356,20 @@ function buildApi(record) { once('registerAudiences') 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 // 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 diff --git a/server/src/modules/registries.js b/server/src/modules/registries.js index b35fc0b..ab9dbba 100644 --- a/server/src/modules/registries.js +++ b/server/src/modules/registries.js @@ -38,6 +38,19 @@ // 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. // +// 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 // §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 @@ -111,6 +124,15 @@ const triggers = new Map() // trigger of the same name would be a collision between two unrelated things. 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 // 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 // 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 @@ -757,6 +979,7 @@ function stage(owner) { slashCommands: [], triggers: [], audiences: [], + engagementSeeds: [], } return { staged, @@ -788,6 +1011,9 @@ function stage(owner) { if (!Array.isArray(entries)) throw new Error('registerAudiences: expected an array') 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 = [], triggers: newTriggers = [], audiences: newAudiences = [], + engagementSeeds: newSeeds = [], }) { // ── validate ── const seenStreams = new Set() @@ -886,6 +1113,14 @@ function apply({ 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 && postHooks.has(owner)) { 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 t of newTriggers) triggers.set(t.id, { owner, ...t }) for (const a of newAudiences) audiences.set(a.id, { owner, ...a }) + for (const seeds of newSeeds) engagementSeeds.set(owner, seeds) } // ── Core's own registrations ─────────────────────────────────────────────── @@ -996,6 +1232,7 @@ function _reset() { slashCommands.clear() triggers.clear() audiences.clear() + engagementSeeds.clear() coreRegistered = false } @@ -1023,6 +1260,9 @@ module.exports = { allAudiences, audience, resolveAudience, + allEngagementSeeds, + engagementSeedsFor, + SEEDABLE_CHANNELS, VARIABLE_TYPES, TRIGGER_KINDS, stage, diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 157b28e..2aa6743 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,39 @@ // 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. +// 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 // `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 @@ -101,6 +134,6 @@ // 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 // 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 } diff --git a/server/test/moduleEngagementSeeds.test.js b/server/test/moduleEngagementSeeds.test.js new file mode 100644 index 0000000..5ac2674 --- /dev/null +++ b/server/test/moduleEngagementSeeds.test.js @@ -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) +}) From 0a9149a04f2861e9171f179f51256842b415b20b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 1 Sep 2026 01:07:16 -0500 Subject: [PATCH 2/3] fix(engagement): a trigger-bound template may reference the unsubscribe link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found building module-uo's sixteen in-universe bodies, which are the first trigger-bound templates in the system to carry an unsubscribe line of their own. `emailChannel.deliver` computes an unsubscribe token per recipient and merges it LAST over the projection, so `{{unsubscribeUrl}}` has always RENDERED correctly. But `variablesFor` takes a trigger-bound template's variable list from the trigger's declaration, and a trigger has no business declaring a fact about how the mail was delivered — so the token was undeclared, and the save-time undeclared-variable check would have refused the first operator who tried to EDIT one of those bodies. Rendering right and then refusing the edit is the worst of both. Nothing had ever taken this path: core's generic `notify.event` declares `unsubscribeUrl` in its own seed and is bound to no trigger, so `seedByKey` supplied it there. Adds DELIVERY_VARIABLES beside AMBIENT_VARIABLES — declared separately because they apply to a different set of templates. Ambient facts are about the deployment and reach every body; delivery facts are about the send and reach the trigger-bound ones, which is exactly the set that is engagement mail. Co-Authored-By: Claude --- server/src/engagement/templateSeeds.js | 23 ++++++++++++++++++++++- server/src/engagement/templates.js | 7 ++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/server/src/engagement/templateSeeds.js b/server/src/engagement/templateSeeds.js index ea44d64..d77e0f0 100644 --- a/server/src/engagement/templateSeeds.js +++ b/server/src/engagement/templateSeeds.js @@ -44,6 +44,27 @@ const AMBIENT_VARIABLES = Object.freeze([ { name: 'year', type: 'string', required: true, example: '2026' }, ]) + +// The per-DELIVERY additions, which are a different thing from the ambient set +// above and are declared separately because they apply to a different set of +// templates. +// +// `emailChannel.deliver` computes an unsubscribe token per recipient and merges +// it LAST over the projection, so a body may always reference it — but a template +// bound to a TRIGGER takes its variable list from that trigger's declaration +// (`variablesFor`), and a trigger has no business declaring a fact about how the +// mail was delivered. Without these, `{{unsubscribeUrl}}` renders correctly and +// then the save-time undeclared-variable check refuses the first operator who +// tries to EDIT the body around it. +// +// Found in Phase 11b, where module-uo's sixteen in-universe bodies are the first +// trigger-bound templates in the system to carry an unsubscribe line of their +// own: core's generic `notify.event` declares it in its own seed and is bound to +// no trigger, so nothing had ever taken this path. +const DELIVERY_VARIABLES = Object.freeze([ + { name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' }, +]) + // A tiny helper so the block arrays below read as content rather than as JSON. const text = (id, body, opts = {}) => ({ id, @@ -296,4 +317,4 @@ function seedByKey(key) { return SEEDS.find((s) => s.key === key) || null } -module.exports = { SEEDS, AMBIENT_VARIABLES, seedByKey } +module.exports = { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey } diff --git a/server/src/engagement/templates.js b/server/src/engagement/templates.js index 1b01205..4964be3 100644 --- a/server/src/engagement/templates.js +++ b/server/src/engagement/templates.js @@ -18,7 +18,7 @@ const templatesDb = require('../model/engagement/engagementTemplates.db') const settings = require('../model/settings/settings.model') const brand = require('../config/brand') const emailBlocks = require('../emailBlocks') -const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('./templateSeeds') +const { SEEDS, AMBIENT_VARIABLES, DELIVERY_VARIABLES, seedByKey } = require('./templateSeeds') // The trigger registry lives with the module registries, not here — a trigger is // something a MODULE declares (see engagement/index.js's header). const { eventTrigger } = require('../modules/registries') @@ -84,6 +84,11 @@ function variablesFor(template) { if (template && template.trigger_id) { const declared = eventTrigger(template.trigger_id) if (declared && Array.isArray(declared.variables)) own.push(...declared.variables) + // A trigger-bound body is engagement mail, and engagement mail always carries + // an unsubscribe the channel computes per recipient. A trigger declares what + // HAPPENED and has no business declaring how the mail was sent, so the + // delivery facts are added here rather than to every declaration. + own.push(...DELIVERY_VARIABLES) } else if (template && template.seed_key) { const seed = seedByKey(template.seed_key) if (seed) own.push(...seed.variables) From 40ab1ce8d238bd466dac667ed58a8d5d6e2c7702 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 1 Sep 2026 01:22:00 -0500 Subject: [PATCH 3/3] fix(modules): bump the CLIENT half of MODULE_API_VERSION to 1.9.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two halves version ONE contract and a test asserts they agree (client/test/moduleRegistry.test.js). I bumped server/src/modules/version.js and not client/src/modules/version.js, so client-build went red — the job runs the client suite before it builds. Nothing on the client half changed: a seed is server-side data and core's seeders write it on the boot path. It bumps for the reason its own header gives — a module declares one `coreApi` range against both halves, and a client claiming 1.8.0 while the server answers 1.9.0 is two answers to one question. 327 client tests green; client build clean. Co-Authored-By: Claude --- client/src/modules/version.js | 9 ++++++++- server/test/emailTemplates.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/client/src/modules/version.js b/client/src/modules/version.js index e23e393..bd6f97b 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,13 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.9.0 - a module may ship its own message bodies and rules: +// `api.registerEngagementSeeds({ templates, ruleGroups })` (ENGAGEMENT.md Phase +// 11b, decision 7). Nothing on this half changed - a seed is server-side data +// and core's seeders write it on the boot path - but the bodies it ships are +// edited through the template editor this half already renders, and an operator +// meets them there. This file bumps for the reason at the top: the two halves +// state ONE version, and a module declares one `coreApi` range against both. // 1.8.0 - the ceiling lattice gains `admin` (ENGAGEMENT.md Phase 11). Nothing on // this half changed: a ceiling is declared on the server's `api` and enforced // there, and the admin screens that render one read the vocabulary from @@ -58,4 +65,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.8.0' +export const MODULE_API_VERSION = '1.9.0' diff --git a/server/test/emailTemplates.test.js b/server/test/emailTemplates.test.js index 9ef94af..1c4cfe8 100644 --- a/server/test/emailTemplates.test.js +++ b/server/test/emailTemplates.test.js @@ -19,6 +19,7 @@ const { SEEDS, AMBIENT_VARIABLES, seedByKey } = require('../src/engagement/templ const templatesDb = require('../src/model/engagement/engagementTemplates.db') const settings = require('../src/model/settings/settings.model') const templates = require('../src/engagement/templates') +const registries = require('../src/modules/registries') const SITE = 'Runic Gateway' const BASE = 'https://shard.example.com' @@ -400,6 +401,30 @@ test('variablesFor answers from the seed for a template with no trigger, plus th assert.deepEqual(templates.variablesFor({}).map((v) => v.name), ['siteName', 'siteUrl', 'logoUrl', 'year']) }) +test('a TRIGGER-bound template also gets the per-delivery variables', () => { + // Phase 11b. `emailChannel.deliver` computes an unsubscribe token per recipient + // and merges it last, so `{{unsubscribeUrl}}` has always RENDERED — but a + // trigger-bound template takes its variable list from the trigger, and a + // trigger has no business declaring a fact about how the mail was sent. Without + // this, a body carrying an unsubscribe line rendered correctly and then the + // save-time undeclared-variable check refused the first operator who edited it. + // + // Nothing had taken this path before: core's `notify.event` declares the + // variable in its own seed and is bound to no trigger. + registries.registerCore() + const names = templates.variablesFor({ trigger_id: 'news.post' }).map((v) => v.name) + assert.ok(names.includes('unsubscribeUrl'), 'a trigger-bound body may reference it') + assert.ok(names.includes('title'), 'and still gets the trigger\'s own') + assert.ok(names.includes('siteName'), 'and the ambient set') + + // A SEEDLESS, triggerless template gets neither — there is no delivery to + // describe, and an unsubscribe link on a password reset is meaningless. + assert.equal( + templates.variablesFor({}).map((v) => v.name).includes('unsubscribeUrl'), + false, + ) +}) + // ── The seeder and the render entrypoint ──────────────────────────────────── test('the shipped default is used when the row is missing, and when it is unusable', async () => {