feat(engagement): let a module ship its own templates and rules (Phase 11b) #178

Merged
whitlocktech merged 3 commits from feature/engagement-module-seeds into edge 2026-09-01 06:35:36 +00:00
Member

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.js and engagement/coreRules.js are core files with core arrays in them, and there is 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. 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.0

api.registerEngagementSeeds({ templates, ruleGroups })

The module supplies data only. 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 (§4.6.1 property 3).
  • 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; one that must reach stamped deployments takes a new group key.

Three prohibitions, each a shipped mistake that would only surface as mail

  1. A seeded rule is always enabled = 0 (Q3). Ignored rather than refused, so a typo cannot take a module offline at boot.
  2. A module may not mark a template protected — that flag would take an operator's delete button away.
  3. A rule may only name its own trigger ids, and its template_keys only its own seeds or core's. Template keys are namespaced because engagement_templates.key is UNIQUE and an unprefixed notify.event would win or lose on boot order.

Where it runs, which is forced rather than chosen

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. So module seeds run from modules/lifecycle.js boot(), after the installed_modules reconcile (a disabled or failed module is skipped) and before the onBoot dispatch (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.deliver computes the token per recipient and merges it last, so {{unsubscribeUrl}} always rendered — but variablesFor takes 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.event declares the variable in its own seed and is bound to no trigger. Adds DELIVERY_VARIABLES beside AMBIENT_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

  • 1550 core tests green, 0 failing (17 new).
  • check:modules clean — core names no module identifier.
  • engagement-triggers.json regenerated; the only change is the version line.
  • The registry was exercised against module-uo's real 32 templates and 25 rules before either PR was opened: all validate, all rules disabled.

AI-assisted: written with Claude Code.

🤖 Generated with Claude Code

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.js` and `engagement/coreRules.js` are core files with core arrays in them, and there is 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. 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.0 ```js api.registerEngagementSeeds({ templates, ruleGroups }) ``` The module supplies **data only**. 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 (§4.6.1 property 3). - **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; one that must reach stamped deployments takes a new group key. ### Three prohibitions, each a shipped mistake that would only surface as mail 1. **A seeded rule is always `enabled = 0`** (Q3). Ignored rather than refused, so a typo cannot take a module offline at boot. 2. **A module may not mark a template `protected`** — that flag would take an operator's delete button away. 3. **A rule may only name its own trigger ids**, and its `template_keys` only its own seeds or core's. Template keys are namespaced because `engagement_templates.key` is UNIQUE and an unprefixed `notify.event` would win or lose on boot order. ## Where it runs, which is forced rather than chosen `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. So module seeds run from `modules/lifecycle.js` `boot()`, **after** the `installed_modules` reconcile (a disabled or failed module is skipped) and **before** the `onBoot` dispatch (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.deliver` computes the token per recipient and merges it last, so `{{unsubscribeUrl}}` always *rendered* — but `variablesFor` takes 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.event` declares the variable in its own seed and is bound to no trigger. Adds `DELIVERY_VARIABLES` beside `AMBIENT_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 - **1550 core tests green, 0 failing** (17 new). - `check:modules` clean — core names no module identifier. - `engagement-triggers.json` regenerated; the only change is the version line. - The registry was exercised against **module-uo's real 32 templates and 25 rules** before either PR was opened: all validate, all rules disabled. --- AI-assisted: written with Claude Code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 2 commits 2026-09-01 06:15:32 +00:00
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>
fix(engagement): a trigger-bound template may reference the unsubscribe link
Some checks failed
PR Checks / client-build (pull_request) Failing after 23s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m6s
0a9149a04f
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>
wtclaude added 1 commit 2026-09-01 06:22:13 +00:00
fix(modules): bump the CLIENT half of MODULE_API_VERSION to 1.9.0
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 13m0s
40ab1ce8d2
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 <noreply@anthropic.com>
whitlocktech merged commit c3783f56f1 into edge 2026-09-01 06:35:36 +00:00
whitlocktech deleted branch feature/engagement-module-seeds 2026-09-01 06:35:37 +00:00
Sign in to join this conversation.
No description provided.