feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a)
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 13m3s

Core's half of ENGAGEMENT.md Phase 11a: the two decisions the org lead settled
before any code that land in core rather than in module-uo. Pairs with
Module-uo#22 and docs#194.

## Decision 1 -- a seventh ceiling, `admin`, as a child of `staff`

Phase 11's operator-facing triggers (uo.audit.staff_action, uo.economy.milestone,
uo.world.saved) are described as admin-audience everywhere, and the narrowest
value the lattice had was `staff` -- which ceilings.js defines as admin, editor
AND moderator. Ceilinging them there would have let an operator save a rule that
mails the staff audit digest to every moderator in it.

`admin` is the ONLY genuine refinement in the tree -- every admin is staff, which
is exactly the containment every other pair of branches lacks -- so it is a child
rather than a seventh leaf, and permits/meet/meetAll needed no change beyond the
new PARENT entry.

**The one non-obvious consequence, and the reason for ROLE_CEILINGS.**
notificationChannelPrefs' `visibleTo` asked `item.ceiling !== 'staff'`. That was
correct while `staff` was the only role-gated value, and the day `admin` arrived
it would have silently published every admin-ceilinged id -- the staff audit
digest, the economy thresholds -- to every player's preferences screen by name.
It now reads a TABLE (`ceilings.reachableBy`), so a ceiling added without an entry
fails closed instead. An EDITOR is the viewer that tells the two rules apart, and
the new tests use one.

MODULE_API_VERSION -> 1.8.0 on both halves. Additive: every declaration valid
under 1.7.0 is valid now and no stored value changes.

## Decision 5 -- 7.1 Q9: news.post gets an emitter, and it REPLACES the tickle

`news.post` has been a declared payload contract with no caller since Phase 2, so
a rule naming it could never fire. utils/newsNotify.js is the caller;
announceIfNewlyPublished now calls it instead of pushDispatch.publish, gated on
the same enqueueIfNeeded job id -- the single "newly published news" transition
signal, not re-derived.

**News push therefore stops on upgrade** until an operator enables the seeded
rule. That is the org lead's decision, taken over keeping the raw call beside the
emit "for one release": an exception with a deadline nobody owns, which Phase 6
already refused for Teams. The Rules screen gains a second migration notice
naming news, and Phase 13's release note carries it as an upgrade step.

**The seed needed its own one-shot key, and this is the trap worth recording.**
`engagement_team_rules_seeded` is already stamped on every deployment that has
booted since Phase 6, and the guard reads its presence -- so appending news to
RULES would have seeded it on fresh installs only, and on exactly the upgrades
that lose their raw push, never. One key per seed GROUP is now the rule;
seedGroup() is the shared implementation and seedCoreRules() is what boot calls.

Also fixes news.post's `postUrl` example, which named `/news/<slug>` -- a path
App.jsx does not mount. An example is what the template editor previews and
test-sends with, so a wrong one is a preview that looks right and a mail that is
not. It is `/site/news`, the list, which is what the Discord and town-crier
announcements have always linked.

1550 tests pass (16 new), 327 client tests pass, client builds, check:modules
clean -- core still names no module identifier with module-uo now registering 24
UO-named triggers.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-31 20:33:02 -05:00
parent 49a61fdafa
commit 1d4cd4adae
17 changed files with 669 additions and 56 deletions

View File

@@ -1,7 +1,7 @@
// ── Resolving a rule's audience to recipients ──────────────────────────────
//
// ENGAGEMENT.md §5.1a / §4.5, Phase 4a. A rule names an audience two ways and
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`,
// only ever one at a time: a **plain ceiling name** (`owner`, `staff`, `admin`,
// `subscribers`, `authenticated`, `everyone`) resolved from core's own tables, or
// an **`audience_segment_id`** pointing at an operator-composed tree of
// module-declared audiences (segments.js). This file turns either into user ids.
@@ -88,9 +88,14 @@ async function resolveForRule(rule, event) {
}
}
case 'staff':
case 'admin':
// Both role-gated, and resolved through the ONE query rather than two.
// `ceilings.ROLE_CEILINGS` holds which roles each names, so the day a
// third is added the resolver does not need a third case — and, more to
// the point, cannot get one of them wrong while the others stay right.
return {
userIds: await recipients.staff(ceilings.STAFF_CEILING_ROLES),
ceiling: 'staff',
userIds: await recipients.staff(ceilings.ROLE_CEILINGS[rule.audience].roles),
ceiling: rule.audience,
dormant: false,
reason: null,
}

View File

@@ -1,4 +1,4 @@
// ── The four Team rules core ships, all of them OFF ────────────────────────
// ── The five rules core ships, all of them OFF ─────────────────────────────
//
// ENGAGEMENT.md Phase 6, decision 3. Before this phase the Team pipeline mailed
// people with no operator configuration at all: the code decided who was mailed
@@ -24,6 +24,17 @@
// that has seen this seed never sees it again — deleted rules stay deleted, and
// an enabled rule stays enabled rather than being reset to off.
// **Phase 11 added a fifth, for `news.post`, and it needed its OWN one-shot key
// rather than an entry in the list above.** The Team key is already stamped on
// every deployment that has booted since Phase 6, and the guard reads its
// presence — so appending to `RULES` would have seeded the news rule on fresh
// installs only, and on exactly the upgrades that need it, never. Those are the
// deployments where `pushDispatch.publish('news.post', …)` used to run and no
// longer does (§7.1 Q9): they would have lost news push with no rule to switch
// on and no way to tell why. One key per seed GROUP is the rule this establishes;
// a sixth rule for a new trigger takes a sixth key, and a rule added to an
// existing group is a rule that only fresh installs will ever see.
const rulesDb = require('../model/engagement/engagementRules.db')
const settingsDb = require('../model/settings/settings.db')
const log = require('../utils/logger')('engagement')
@@ -32,6 +43,9 @@ const log = require('../utils/logger')('engagement')
// the settings table can tell when it ran; only its presence is read.
const SEEDED_KEY = 'engagement_team_rules_seeded'
// Phase 11's, and separate for the reason above. Same shape, same semantics.
const NEWS_SEEDED_KEY = 'engagement_news_rule_seeded'
const RULES = [
{
trigger_id: 'team.forum.post',
@@ -98,20 +112,57 @@ const RULES = [
},
]
// Phase 11's one rule, in its own list so it can carry its own one-shot key.
const NEWS_RULES = [
{
trigger_id: 'news.post',
name: 'News posts',
// `subscribers`, which is the trigger's declared default and the population
// `pushDispatch.publish('news.post', …)` used to reach directly: users who
// opted into this id on at least one channel. Not `authenticated`, even
// though the trigger's ceiling permits it — a news post is worth telling
// people who asked to be told, and mailing the whole user table on every
// publish is how a notification feature earns a spam complaint.
audience: 'subscribers',
// **All three channels, unlike the Team rules' `email` alone**, and that is
// the continuity half of §7.1 Q9's answer. Push is on this rule because push
// is what the raw tickle did; leaving it off would mean an operator who
// enabled the rule to restore news push got mail instead. In-app rides along
// because the inbox is the surface a tickle deep-links into (Phase 7).
channels: ['email', 'inapp', 'push'],
// The generic body plus the structural projection (§4.6.1 property 1):
// `news.post` declares its own `title` and `postUrl`, which the projection
// leaves exactly as emitted, so an unauthored mail already names the post and
// links it. `inapp.event` is the in-app renderer's; push carries no content
// by construction and needs no template.
template_keys: { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' },
// An hour, per USER — `news.post` declares no `subjectKey`, so the cooldown
// subject is the recipient. "Do not tell me about news more than once an
// hour" is the useful rule; keying it per post would make it a no-op, since
// every post is a new subject.
cooldown_seconds: 3600,
max_sends_per_hour: 1000,
},
]
/**
* Seed the four rules, once. Returns a small summary for the boot log.
* Seed one group of rules, once, under its own guard key.
*
* Never throws: it is on the boot path beside `seedTemplates`, and a rule that
* failed to seed costs an operator one visit to the "new rule" form, not a
* deployment.
*
* @param {string} key the one-shot settings guard for THIS group
* @param {object[]} rules
* @param {string} note what the boot log should say when it inserts
*/
async function seedTeamRules() {
async function seedGroup(key, rules, note) {
const summary = { inserted: 0, skipped: 0 }
try {
const seen = await settingsDb.get(SEEDED_KEY)
if (seen) return { ...summary, skipped: RULES.length }
const seen = await settingsDb.get(key)
if (seen) return { ...summary, skipped: rules.length }
for (const rule of RULES) {
for (const rule of rules) {
try {
await rulesDb.insert({
audience_segment_id: null,
@@ -133,17 +184,46 @@ async function seedTeamRules() {
// Stamped even on a partial run. Re-running would duplicate the rules that
// did insert, and a duplicate rule is two mails per event — a worse outcome
// than the one missing rule an operator can add from the screen.
await settingsDb.set(SEEDED_KEY, new Date().toISOString())
await settingsDb.set(key, new Date().toISOString())
if (summary.inserted) {
log.info('seeded Team engagement rules, all disabled', {
rules: summary.inserted,
note: 'Team email stays off until an operator enables one',
})
log.info('seeded engagement rules, all disabled', { rules: summary.inserted, note })
}
} catch (err) {
log.error('team rule seeding failed', { message: err.message })
log.error('rule seeding failed', { key, message: err.message })
}
return summary
}
module.exports = { seedTeamRules, RULES, SEEDED_KEY }
/** The four Team rules (Phase 6). */
const seedTeamRules = () =>
seedGroup(SEEDED_KEY, RULES, 'Team email stays off until an operator enables one')
/** The one news rule (Phase 11). */
const seedNewsRule = () =>
seedGroup(NEWS_SEEDED_KEY, NEWS_RULES, 'News notifications stay off until an operator enables this rule')
/**
* Both groups, which is what the boot path calls.
*
* Sequential rather than concurrent, and not for correctness — each group has its
* own guard key and its own rows — but so the boot log reads in a fixed order and
* a failure names one group rather than an interleaving of two.
*/
async function seedCoreRules() {
const team = await seedTeamRules()
const news = await seedNewsRule()
return {
inserted: team.inserted + news.inserted,
skipped: team.skipped + news.skipped,
}
}
module.exports = {
seedCoreRules,
seedTeamRules,
seedNewsRule,
RULES,
NEWS_RULES,
SEEDED_KEY,
NEWS_SEEDED_KEY,
}