feat(engagement): the admin ceiling and core's news.post emitter (Phase 11a) #177

Merged
whitlocktech merged 1 commits from feature/engagement-uo-triggers into edge 2026-09-01 05:05:58 +00:00
Member

Core's half of ENGAGEMENT.md Phase 11a — the two of the six decisions that land here rather than in the module. Pairs with Module-uo#22 (the 24 trigger declarations) and docs#194 (the decisions themselves).

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 they appear, 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 whole 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. permits('staff','admin') is now the single true pair below authenticated; permits('admin','staff') is false, as are both directions against owner, members and subscribers.

MODULE_API_VERSION → 1.8.0 on both halves. Additive: every declaration valid under 1.7.0 is valid now, no stored value changes, and nothing anywhere stores an admin audience yet — which is what made this cheap in this window and expensive after cutover.

The one non-obvious consequence, and why ROLE_CEILINGS is a table

notificationChannelPrefs' visibleTo asked item.ceiling !== 'staff'. That spelling 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) by name, to every player's preferences screen. Exactly the disclosure the ceiling exists to prevent, introduced by adding the ceiling.

It now reads a table via ceilings.reachableBy, so a role-gated ceiling added without an entry there fails closed rather than leaking. An editor is the viewer that tells the two rules apart — inside staff, outside admin — and the new tests use one:

  • an admin-ceilinged trigger is hidden from a player and from an editor, shown to an admin, and an editor who knows the id still cannot store a preference for it (it is a gate, not a display rule)
  • a staff-ceilinged trigger is still offered to an editor, so the generalisation did not quietly hide more than it should

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 — on a real deployment the only mail or inbox item a rule could produce came from Teams. server/src/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 — an edit or a re-publish still must not re-fire).

The other two things a publish fires are untouched, and the header comment says why: the announce legs are a one-shot delivery to a channel of the deployment with retry, and the post hooks are idempotent state mirroring that also runs on delete. Neither is a per-person notification. This adds a third distinction of the same kind rather than replacing either.

⚠ News push stops on upgrade

Publishing a news post used to tickle every subscriber directly. It now rides a rule, and core seeds that rule enabled = 0 — so a deployment that ships this and does nothing else tickles nobody until an operator opens Admin → Engagement → Rules.

This 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, and the analogous carve-out Phase 6 already refused for Teams. The mitigations are obligations, not nice-to-haves: the Rules screen gains a second migration notice naming news (this PR), and Phase 13's release note carries it as an upgrade step (11b).

The seed needed its own one-shot key — 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 news push, never. They would have lost the capability with no rule to switch on and no way to tell why.

One key per seed group is now the rule. seedGroup(key, rules, note) is the shared implementation; seedCoreRules() is what boot calls. The news rule carries all three channels (email, inapp, push) rather than the Team rules' email alone — push is on it because push is what the raw tickle did, and leaving it off would mean an operator who enabled the rule to restore news push got mail instead.

One defect found on the way

news.post's postUrl example named /news/five-on-friday-yew-invasion — a path App.jsx does not mount. It was invisible while the trigger had no caller. An example is what the template editor previews and test-sends with (§4.3 property 3), so a wrong one is a preview that looks right and a mail that is not. It is now /site/news, the list, which is what announceJobs.logic.js has always linked from the Discord and town-crier announcements for the same reason. engagement-triggers.json regenerated.

Verification

  • 1550 server tests, 0 failures (16 new: 6 on the lattice and the role table, 10 on the emitter and the seed)
  • 327 client tests, 0 failures; client builds clean
  • npm run check:modules clean — core still names no module identifier, which is a materially stronger claim now that the module it is checked against registers 24 UO-named triggers
  • engagement:manifest --check green after regeneration

AI disclosure

  • This contribution was AI-assisted (Claude Code / Claude Opus 5). Commits carry a Co-Authored-By: Claude trailer.

🤖 Generated with Claude Code

Core's half of **ENGAGEMENT.md Phase 11a** — the two of the six decisions that land here rather than in the module. Pairs with Module-uo#22 (the 24 trigger declarations) and docs#194 (the decisions themselves). ## 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 they appear, 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 whole 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**. `permits('staff','admin')` is now the single true pair below `authenticated`; `permits('admin','staff')` is false, as are both directions against `owner`, `members` and `subscribers`. **`MODULE_API_VERSION` → 1.8.0** on both halves. Additive: every declaration valid under 1.7.0 is valid now, no stored value changes, and nothing anywhere stores an `admin` audience yet — which is what made this cheap in this window and expensive after cutover. ### The one non-obvious consequence, and why `ROLE_CEILINGS` is a table `notificationChannelPrefs`' `visibleTo` asked `item.ceiling !== 'staff'`. That spelling 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) **by name, to every player's preferences screen**. Exactly the disclosure the ceiling exists to prevent, introduced by adding the ceiling. It now reads a table via `ceilings.reachableBy`, so a role-gated ceiling added without an entry there **fails closed** rather than leaking. An **editor** is the viewer that tells the two rules apart — inside `staff`, outside `admin` — and the new tests use one: - an `admin`-ceilinged trigger is hidden from a player **and from an editor**, shown to an admin, and an editor who knows the id still cannot store a preference for it (it is a gate, not a display rule) - a `staff`-ceilinged trigger is **still** offered to an editor, so the generalisation did not quietly hide more than it should ## 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 — on a real deployment the only mail or inbox item a rule could produce came from Teams. `server/src/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 — an edit or a re-publish still must not re-fire). **The other two things a publish fires are untouched**, and the header comment says why: the announce legs are a one-shot *delivery to a channel of the deployment* with retry, and the post hooks are idempotent *state mirroring* that also runs on delete. Neither is a per-person notification. This adds a third distinction of the same kind rather than replacing either. > ### ⚠ News push stops on upgrade > > Publishing a news post used to tickle every subscriber directly. It now rides a rule, and core seeds that rule `enabled = 0` — so a deployment that ships this and does nothing else tickles nobody until an operator opens Admin → Engagement → Rules. > > This 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, and the analogous carve-out Phase 6 already refused for Teams. The mitigations are obligations, not nice-to-haves: **the Rules screen gains a second migration notice naming news** (this PR), and **Phase 13's release note carries it as an upgrade step** (11b). ### The seed needed its own one-shot key — 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 news push**, never. They would have lost the capability with no rule to switch on and no way to tell why. One key per seed **group** is now the rule. `seedGroup(key, rules, note)` is the shared implementation; `seedCoreRules()` is what boot calls. The news rule carries all three channels (`email`, `inapp`, `push`) rather than the Team rules' `email` alone — push is on it because push is what the raw tickle did, and leaving it off would mean an operator who enabled the rule to restore news push got mail instead. ## One defect found on the way `news.post`'s `postUrl` **example** named `/news/five-on-friday-yew-invasion` — a path `App.jsx` does not mount. It was invisible while the trigger had no caller. An example is what the template editor **previews and test-sends with** (§4.3 property 3), so a wrong one is a preview that looks right and a mail that is not. It is now `/site/news`, the list, which is what `announceJobs.logic.js` has always linked from the Discord and town-crier announcements for the same reason. `engagement-triggers.json` regenerated. ## Verification - **1550 server tests, 0 failures** (16 new: 6 on the lattice and the role table, 10 on the emitter and the seed) - **327 client tests, 0 failures**; client builds clean - `npm run check:modules` clean — **core still names no module identifier**, which is a materially stronger claim now that the module it is checked against registers 24 UO-named triggers - `engagement:manifest --check` green after regeneration --- ### AI disclosure - [x] This contribution was AI-assisted (Claude Code / Claude Opus 5). Commits carry a `Co-Authored-By: Claude` trailer. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-01 01:33:44 +00:00
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
1d4cd4adae
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>
whitlocktech merged commit 81e0338a69 into edge 2026-09-01 05:05:58 +00:00
Sign in to join this conversation.
No description provided.