// ── Audience ceilings ────────────────────────────────────────────────────── // // G24, and the one piece of ENGAGEMENT.md that was named everywhere and defined // nowhere: §5.1a says a composed segment takes "the narrowest ceiling it // contains" and §4.3 says a trigger declares "the widest audience a rule may // ever give it", but neither says what narrower MEANS. This file is that // answer, settled by the org lead at the start of Phase 2. // // **It is a subset lattice, not a size ordering.** The tempting model is a flat // total order — self < owner < staff < members < authenticated < everyone, // compared with `<=` — and it is wrong in a way that matters. Under a total // order a trigger ceilinged at `staff` also permits `owner`, so a rule could // mail `uo.cheat.detected` to the player who was detected. "Fewer people" is not // "less exposure"; the question is always WHICH people. // // So the order is containment, and it is a TREE: // // everyone anyone at all, signed in or not // └── authenticated any logged-in user // ├── subscribers logged-in users who opted into this id // ├── members a module-declared list (a Team, the governors) // ├── staff admin / editor / moderator // └── owner the one user the event is about // // The four leaves 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. // // Nothing here reaches the database, the network or a user record. It is // arithmetic over six 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 // lower bound only when one of them IS the bound. const PARENT = { everyone: null, authenticated: 'everyone', subscribers: 'authenticated', members: 'authenticated', staff: 'authenticated', owner: 'authenticated', } // Operator-facing text. Lives beside the lattice rather than in the admin client // so the rule editor and the trigger catalog describe a ceiling the same way. const LABELS = { everyone: 'Everyone, including signed-out visitors', authenticated: 'Any signed-in user', subscribers: 'Signed-in users subscribed to this event', members: 'Members of a module-declared list', staff: 'Staff only', owner: 'Only the user the event is about', } const CEILINGS = Object.keys(PARENT) /** Is this one of the six? The gate every registration and every rule save runs. */ const isCeiling = (value) => Object.prototype.hasOwnProperty.call(PARENT, value) /** * May `ceiling` reach as widely as `candidate`? * * True when `candidate` is `ceiling` itself or sits below it — i.e. walking * `candidate` up the tree reaches `ceiling`. Everything else is false, including * every incomparable pair, so this FAILS CLOSED on an id it does not know. */ function permits(ceiling, candidate) { if (!isCeiling(ceiling) || !isCeiling(candidate)) return false for (let at = candidate; at; at = PARENT[at]) { if (at === ceiling) return true } return false } /** * The narrower of two ceilings, or `null` when they are incomparable. * * This is the greatest lower bound, and in a tree it exists only when one node * is an ancestor of the other — so `meet('authenticated', 'staff')` is `staff` * and `meet('staff', 'owner')` is `null`. Returning null is the point: * §5.1a rule 3 says composition must never widen, and the intuitive * union-widens implementation is the wrong one. A caller that cannot name a * bound must refuse the save, not pick a side. */ function meet(a, b) { if (!isCeiling(a) || !isCeiling(b)) return null if (permits(a, b)) return b if (permits(b, a)) return a return null } /** * Fold `meet` across a whole expression's ceilings. * * `A OR B` takes the tighter of the two, and so does `A AND B` — the direction * of the boolean operator is irrelevant, because the ceiling is a statement * about what the operator is ALLOWED to reach, not about what it will resolve * to. An empty list has no bound to state and is null, not `everyone`. */ function meetAll(list) { if (!Array.isArray(list) || !list.length) return null return list.reduce((acc, next) => (acc === null ? null : meet(acc, next)), list[0]) } module.exports = { CEILINGS, LABELS, isCeiling, permits, meet, meetAll }