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

@@ -20,16 +20,29 @@
// ├── subscribers logged-in users who opted into this id
// ├── members a module-declared list (a Team, the governors)
// ├── staff admin / editor / moderator
// │ └── admin admins only
// └── owner the one user the event is about
//
// The four leaves are mutually INCOMPARABLE, deliberately. `owner` is not a
// The four branches are mutually INCOMPARABLE, deliberately. `owner` is not a
// subset of `subscribers` (an owner need not have subscribed), `staff` is not a
// subset of `members`, and no pair of them has a common descendant. That is what
// makes `meet()` below return null rather than guessing, and a null meet is a
// refused save (§5.1a rule 3) rather than a silent widening.
//
// **`admin` was added in Phase 11 and it is the one genuine refinement in the
// tree.** Phase 2 shipped six values, and §8.6 then turned out to describe three
// triggers as admin-audience — `uo.audit.staff_action`, `uo.economy.milestone`,
// `uo.world.saved` — for which the narrowest available value was `staff`, i.e.
// admin / editor / moderator. Ceilinging them there would have permitted a rule
// that mails the staff audit digest to every editor, which is the same class of
// mistake the whole file exists to prevent. It is a CHILD rather than a seventh
// leaf because every admin is staff — the containment the other pairs lack — and
// that is why `permits`, `meet` and `meetAll` needed no change at all beyond the
// new `PARENT` entry. It is a module-contract change (a module may now declare
// `ceiling: 'admin'`) and took MODULE_API_VERSION to 1.8.0.
//
// Nothing here reaches the database, the network or a user record. It is
// arithmetic over six constants, so it is safe to require anywhere.
// arithmetic over seven constants, so it is safe to require anywhere.
// child → parent. A tree, which is what makes `permits` a walk to the root and
// `meet` a comparison rather than a search: two nodes in a tree have a greatest
@@ -40,6 +53,7 @@ const PARENT = {
subscribers: 'authenticated',
members: 'authenticated',
staff: 'authenticated',
admin: 'staff',
owner: 'authenticated',
}
@@ -51,6 +65,7 @@ const LABELS = {
subscribers: 'Signed-in users subscribed to this event',
members: 'Members of a module-declared list',
staff: 'Staff only',
admin: 'Administrators only',
owner: 'Only the user the event is about',
}
@@ -66,9 +81,50 @@ const LABELS = {
// TOLD, which is the tier gate's population.
const STAFF_CEILING_ROLES = ['admin', 'editor', 'moderator']
// And which the `admin` ceiling names. One role, and it is written as a list for
// the same reason `STAFF_CEILING_ROLES` is: `recipients.staff(roles)` takes a
// list, so the two ceilings resolve through one query rather than through two
// that could drift.
const ADMIN_CEILING_ROLES = ['admin']
/** Does this user fall inside the `staff` ceiling? */
const isStaffRole = (role) => STAFF_CEILING_ROLES.includes(role)
/** Does this user fall inside the `admin` ceiling? */
const isAdminRole = (role) => ADMIN_CEILING_ROLES.includes(role)
// The ceilings that name a ROLE, and the test for each. Two consumers read this
// rather than asking about `staff` by name: the audience resolver, and the
// preferences catalog's `visibleTo`.
//
// **`visibleTo` is why this is a table and not two `if`s.** Its rule is that an
// id nobody outside a role can ever be reached by must not appear by name in a
// player's preferences screen — `uo.cheat.detected` is the case the lattice was
// written for. That rule was expressed as `ceiling !== 'staff'` when `staff` was
// the only role-gated value; the day `admin` was added, that spelling would have
// silently published every admin-ceilinged id to every player. A table cannot
// drift the same way: adding a role-gated ceiling without adding it here is a
// registration that fails its own test, not a leak.
const ROLE_CEILINGS = {
staff: { roles: STAFF_CEILING_ROLES, test: isStaffRole },
admin: { roles: ADMIN_CEILING_ROLES, test: isAdminRole },
}
/** Is this ceiling one that only certain roles can ever be reached by? */
const isRoleCeiling = (ceiling) => Object.prototype.hasOwnProperty.call(ROLE_CEILINGS, ceiling)
/**
* Could a viewer with this role EVER be reached by an id ceilinged here?
*
* The question a catalog asks before offering a toggle, and it fails closed: a
* role-gated ceiling with no role, or an unknown role, is a no.
*/
function reachableBy(ceiling, role) {
const gate = ROLE_CEILINGS[ceiling]
if (!gate) return true
return gate.test(role)
}
const CEILINGS = Object.keys(PARENT)
/** Is this one of the six? The gate every registration and every rule save runs. */
@@ -119,4 +175,18 @@ function meetAll(list) {
return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0])
}
module.exports = { CEILINGS, LABELS, STAFF_CEILING_ROLES, isStaffRole, isCeiling, permits, meet, meetAll }
module.exports = {
CEILINGS,
LABELS,
STAFF_CEILING_ROLES,
ADMIN_CEILING_ROLES,
ROLE_CEILINGS,
isStaffRole,
isAdminRole,
isRoleCeiling,
reachableBy,
isCeiling,
permits,
meet,
meetAll,
}

View File

@@ -9,6 +9,26 @@
// 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.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
// `registerEventTriggers` and `registerAudiences` accept grew. Additions only,
// so minor: every declaration valid before is valid now, no stored value
// changes, and module-uo's `coreApi: "^1.3.0"` still resolves.
//
// It exists because Phase 11's operator-facing triggers - `uo.audit.staff_action`,
// `uo.economy.milestone`, `uo.world.saved` - are described everywhere as
// admin-audience, and the narrowest value the lattice had was `staff`, which
// means admin / editor / moderator. Ceilinging them there would have permitted a
// rule that mails the staff audit digest to every editor.
//
// **What a module has to know about it beyond the new name.** `admin` is the one
// pair in the tree with real containment - every admin is staff - so it is the
// only place `permits` is true between two non-`authenticated` values:
// `permits('staff', 'admin')` holds and nothing else of that shape does. A
// trigger ceilinged `staff` therefore accepts an `admin` audience, which is the
// intended narrowing, and the reverse is refused as it should be.
// 1.7.0 — the engagement contract (docs/website/ENGAGEMENT.md Phase 2).
// Additions only, so minor: `api.registerEventTriggers([...])`,
// `api.registerAudiences([...])`, `ctx.events.emit(triggerId, envelope)` and
@@ -81,6 +101,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.7.0'
const MODULE_API_VERSION = '1.8.0'
module.exports = { MODULE_API_VERSION }