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

@@ -16,10 +16,10 @@ const assert = require('node:assert/strict')
const ceilings = require('../src/modules/ceilings')
test('the six ceilings are the vocabulary, and nothing else is', () => {
test('the seven ceilings are the vocabulary, and nothing else is', () => {
assert.deepEqual(
[...ceilings.CEILINGS].sort(),
['authenticated', 'everyone', 'members', 'owner', 'staff', 'subscribers'],
['admin', 'authenticated', 'everyone', 'members', 'owner', 'staff', 'subscribers'],
)
for (const id of ceilings.CEILINGS) assert.ok(ceilings.LABELS[id], `${id} has an operator label`)
assert.equal(ceilings.isCeiling('nobody'), false)
@@ -33,9 +33,9 @@ test('everyone permits every ceiling; every ceiling permits itself', () => {
}
})
test('authenticated permits the four leaves but not everyone', () => {
for (const leaf of ['subscribers', 'members', 'staff', 'owner']) {
assert.equal(ceilings.permits('authenticated', leaf), true)
test('authenticated permits every branch and admin beneath staff, but not everyone', () => {
for (const below of ['subscribers', 'members', 'staff', 'owner', 'admin']) {
assert.equal(ceilings.permits('authenticated', below), true)
}
assert.equal(ceilings.permits('authenticated', 'everyone'), false)
})
@@ -55,6 +55,70 @@ test('a staff ceiling does NOT permit owner — fewer people is not less exposur
}
})
// ── `admin`, added in Phase 11 ─────────────────────────────────────────────
//
// The one genuine refinement in the tree: every admin is staff, which is the
// containment no other pair has. These assert that it is a NARROWING and not a
// second way to widen — the failure this file exists to keep out, in its newest
// possible costume.
test('staff permits admin and admin does not permit staff — the one true refinement', () => {
assert.equal(ceilings.permits('staff', 'admin'), true)
assert.equal(ceilings.permits('admin', 'staff'), false)
assert.equal(ceilings.meet('staff', 'admin'), 'admin')
assert.equal(ceilings.meet('admin', 'staff'), 'admin')
})
test('admin is incomparable with every branch that is not staff', () => {
for (const other of ['subscribers', 'members', 'owner']) {
assert.equal(ceilings.permits('admin', other), false, `admin must not permit ${other}`)
assert.equal(ceilings.permits(other, 'admin'), false, `${other} must not permit admin`)
assert.equal(ceilings.meet('admin', other), null, `admin ∧ ${other} has no bound`)
}
})
test('an admin-ceilinged trigger refuses a staff audience', () => {
// The acceptance criterion in as many words: a rule cannot give an
// admin-ceiling trigger a `staff` audience. `permits` is what both the save
// check and the send-time re-check call.
assert.equal(ceilings.permits('admin', 'staff'), false)
// …and the narrowing direction is allowed, which is what makes the node useful
// rather than merely restrictive.
assert.equal(ceilings.permits('staff', 'admin'), true)
})
test('the role ceilings are a table, so a new one cannot be forgotten', () => {
// `visibleTo` used to ask `ceiling !== 'staff'`. That spelling was correct
// while `staff` was the only role-gated value and would have silently published
// every admin-ceilinged id to every player's preferences screen the day `admin`
// arrived. The table is what makes that impossible to get wrong quietly.
assert.deepEqual(Object.keys(ceilings.ROLE_CEILINGS).sort(), ['admin', 'staff'])
for (const id of Object.keys(ceilings.ROLE_CEILINGS)) {
assert.ok(ceilings.isRoleCeiling(id), `${id} is a role ceiling`)
assert.ok(ceilings.ROLE_CEILINGS[id].roles.length, `${id} names at least one role`)
}
assert.equal(ceilings.isRoleCeiling('subscribers'), false)
})
test('reachableBy gates the role ceilings and lets everything else through', () => {
assert.equal(ceilings.reachableBy('admin', 'admin'), true)
assert.equal(ceilings.reachableBy('admin', 'editor'), false)
assert.equal(ceilings.reachableBy('admin', 'moderator'), false)
assert.equal(ceilings.reachableBy('admin', 'user'), false)
assert.equal(ceilings.reachableBy('staff', 'editor'), true)
assert.equal(ceilings.reachableBy('staff', 'user'), false)
// Fails closed on a missing viewer, which is how a signed-out catalog read
// reaches it.
assert.equal(ceilings.reachableBy('staff', undefined), false)
assert.equal(ceilings.reachableBy('admin', undefined), false)
// Everything that is not role-gated is visible to anyone, including the `null`
// a stream-only catalog item carries.
for (const open of ['everyone', 'authenticated', 'subscribers', 'members', 'owner']) {
assert.equal(ceilings.reachableBy(open, 'user'), true, `${open} is not role-gated`)
}
assert.equal(ceilings.reachableBy(null, 'user'), true)
})
test('an unknown ceiling is permitted by nothing, on either side', () => {
assert.equal(ceilings.permits('everyone', 'god'), false)
assert.equal(ceilings.permits('god', 'owner'), false)
@@ -70,6 +134,7 @@ test('A OR B takes the NARROWER of the two ceilings, not the wider', () => {
test('incomparable ceilings have no meet — the save is refused, not guessed', () => {
assert.equal(ceilings.meet('staff', 'members'), null)
assert.equal(ceilings.meet('admin', 'owner'), null)
assert.equal(ceilings.meet('owner', 'subscribers'), null)
assert.equal(ceilings.meet('staff', 'nonsense'), null)
})