feat(engagement): let a module ship its own templates and rules (Phase 11b) #178
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/engagement-module-seeds"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Core's half of Phase 11b. Pairs with Module-uo# (the sixteen in-universe bodies) and docs# (the four decisions).
The gap this closes
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.engagement/templateSeeds.jsandengagement/coreRules.jsare core files with core arrays in them, and there is noregisterTemplatesorregisterRulesanywhere inregistries.js.So a module could say what an event's payload was and could never say what the mail should read like. Tolerable for one trigger; decisive once the bodies carry domain prose core must not contain (§5.2).
What it adds —
MODULE_API_VERSION→ 1.9.0The module supplies data only. Core keeps
seedOne'scustomizedskip, itsseed_versioncomparison and the block registry's validation — which is the whole argument for a registry over thectx.querya module already holds. A copy of any of those living outsideengagement/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
seedVersionreaches every deployment except the ones where an operator edited that row (§4.6.1 property 3).Three prohibitions, each a shipped mistake that would only surface as mail
enabled = 0(Q3). Ignored rather than refused, so a typo cannot take a module offline at boot.protected— that flag would take an operator's delete button away.template_keysonly its own seeds or core's. Template keys are namespaced becauseengagement_templates.keyis UNIQUE and an unprefixednotify.eventwould win or lose on boot order.Where it runs, which is forced rather than chosen
server.jscallsseedDefaults()before it requiresapp.js, and requiringapp.jsis what scans the volume and runs the loader — at the moment core seeds, no module has registered anything. So module seeds run frommodules/lifecycle.jsboot(), after theinstalled_modulesreconcile (a disabled or failed module is skipped) and before theonBootdispatch (a module warming a cache may assume its rules exist).safe()-wrapped like every other step there.Second commit — a defect found building the module half
A trigger-bound template could not reference its own unsubscribe link.
emailChannel.delivercomputes the token per recipient and merges it last, so{{unsubscribeUrl}}always rendered — butvariablesFortakes a trigger-bound template's variables from the trigger's declaration, and a trigger has no business declaring a fact about how the mail was delivered. The save-time undeclared-variable check would have refused the first operator who tried to edit one of the sixteen bodies. Rendering right and then refusing the edit is the worst of both.Nothing had ever taken this path: core's
notify.eventdeclares the variable in its own seed and is bound to no trigger. AddsDELIVERY_VARIABLESbesideAMBIENT_VARIABLES— separate because they apply to a different set: ambient facts are about the deployment and reach every body, delivery facts are about the send and reach the trigger-bound ones.Verification
check:modulesclean — core names no module identifier.engagement-triggers.jsonregenerated; the only change is the version line.AI-assisted: written with Claude Code.
🤖 Generated with Claude Code
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>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 <noreply@anthropic.com>