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

@@ -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 }