From 77c615bc69093ce53bf4ff42ede7d9b7ea9e510a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 31 Aug 2026 19:47:05 -0500 Subject: [PATCH] =?UTF-8?q?docs(engagement):=20scope=20Phase=2011=20?= =?UTF-8?q?=E2=80=94=20six=20decisions,=20three=20carve-outs,=20the=2011a/?= =?UTF-8?q?11b=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 11 was widened on 2026-08-31 to ship every ✅ row of §8.6 rather than a single rule, and the phase text asked for its 11a/11b split to be confirmed with the org lead before any code. Confirming it turned up five further questions the plan had not asked, three of which change what ships — so this records all six before implementation starts, on Phase 2's precedent. The three carve-outs all came from checking §8.6's rows against what the WEBSITE can address rather than against what the wire carries: • uo.guild.joined duplicates core's team.member.joined — a UO guild IS a Team and teamSync already emits on every roster reconcile, so an operator who enables the obvious two rules gets two mails for one join. `left` and `disbanded` still ship; core has no equivalent for either. • uo.link.requested has no addressable recipient by construction (the account is not yet linked) and a ~5-minute TTL no channel can beat. • uo.points.rank_changed ships `subscribers` only: points.board's top[] names a mobile serial and shard_account_links is keyed by account, so the personal half would reach some players and silently not others. The other three: • The lattice gains `admin`, as a child of `staff` — the operator-facing family is described as admin-audience everywhere and `staff` is admin/editor/ moderator, so ceilinging it there would mail the staff audit digest to every editor. It is the only genuine refinement in the tree, so permits/meet/ meetAll are untouched. MODULE_API_VERSION → 1.8.0. • §7.1 Q9 (news.post continuity): the emit REPLACES the raw tickle and core seeds the rule disabled — the Phase 6 precedent. News push stops on upgrade; the admin banner and the release note are the mitigations, and both are named as phase deliverables. • The split is confirmed and specified: 11a is declarations/mappings/ceilings, server only, no rule or template seeded; 11b is the seeded set and the live walk. Also records the threshold-trigger mechanism uo.vendor.expiring and uo.economy.milestone need — rule conditions compare against literals only, so "within 24 hours of dismissal" is not expressible as a condition and the crossing is tracked module-side — and names module-uo's first three registered audiences. Co-Authored-By: Claude --- website/ENGAGEMENT.md | 212 +++++++++++++++++++++++++++++++++++------- 1 file changed, 180 insertions(+), 32 deletions(-) diff --git a/website/ENGAGEMENT.md b/website/ENGAGEMENT.md index c0c9bb2..f54bc3a 100644 --- a/website/ENGAGEMENT.md +++ b/website/ENGAGEMENT.md @@ -10,13 +10,16 @@ half, Android-app#42 + docs#190; Phase 9: website#176 + docs#191); everything fr still design. **PHASE 10 IS BUILT (2026-08-31)** — the protocol bump; see its as-built below and [`../link/v5.md`](../link/v5.md). **Phases 10 and 11 were both widened on 2026-08-31, by the org lead, before any code:** the protocol bump carries three wire enrichments rather than one, and Phase 11 ships **every ✅ row of -§8.6** rather than a single rule. The scope decisions -below are settled; **eight of the nine questions in §7.1 are answered** - Q1, Q3, Q5 and Q7 on +§8.6** rather than a single rule. **Phase 11 was then scoped on the same day** — six decisions, three +carve-outs, a seventh value in the ceiling lattice and an 11a/11b split; see its own decision block. +The scope decisions +below are settled; **all nine questions in §7.1 are answered** - Q1, Q3, Q5 and Q7 on 2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's namespace question), **Q2 and Q4 on 2026-08-29 at the start of Phase 4**, and **Q8 on 2026-08-31 at the start of Phase 8**. Q1's answer added a whole phase (**Phase 1b**, unique email addresses); Q4's answer and the -phase's size split **Phase 4 into 4a and 4b**; **Q9** (core's own `news.post` emitter, added -2026-08-31 in docs#189 and recommended for Phase 11) is the only one still open. Per CLAUDE.md § Conventions, no +phase's size split **Phase 4 into 4a and 4b**; **Q9** (core's own `news.post` emitter) was answered +**2026-08-31 at the start of Phase 11**, along with five further decisions that changed what that +phase ships — see its own decision block. Per CLAUDE.md § Conventions, no implementation starts without the org lead's approval of the phase it belongs to. **Branching:** every phase lands on **`edge`** in its repo; `main` is touched once, by the cutover @@ -1184,17 +1187,33 @@ everyone anyone at all, signed in or not ├── subscribers signed-in users who opted into this id ├── members a module-declared list (a Team, the governors) ├── staff admin / editor / moderator + │ └── admin admins only ← added in Phase 11 └── owner the one user the event is about ``` +**`admin` is the one node that was added after Phase 2 shipped the lattice, and the reason is worth +recording** (org lead, 2026-08-31, at the start of Phase 11). Phase 11's §8.6 table describes three +triggers as admin-audience — `uo.audit.staff_action`, `uo.economy.milestone`, `uo.world.saved` — and +`account.login.result` is classified by [`../link/v5.md`](../link/v5.md) §4 as *admin, by omission*, +"there is no rung below admin that a frame carrying an IP address and an auth verdict belongs on". +The lattice had no such rung: its narrowest staff-ish value was `staff`, which `ceilings.js` defines +as **admin / editor / moderator**, so ceilinging the operator-facing family there would have let a +rule mail the staff audit digest to every editor. It is the only true refinement in the tree — an +admin *is* staff, unlike any other pair of leaves — so it is a child rather than a seventh leaf, and +`permits`/`meet`/`meetAll` need no change at all beyond the new `PARENT` entry. It is a **contract +change** (a module may now declare `ceiling: 'admin'`) and takes `MODULE_API_VERSION` to **1.8.0**. +Cheap in Phase 11 because no row anywhere stores an `admin` audience yet; expensive after cutover. + **It is containment, not size.** The tempting model is a flat total order — `self < owner < staff < members < authenticated < everyone`, compared with `<=` — and under it a trigger ceilinged at `staff` also permits `owner`, so a rule could mail `uo.cheat.detected` to the player it detected. Fewer people is not less exposure; the question is always *which* people. -So the four leaves are **mutually incomparable**, deliberately: `owner` is not a subset of +So 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. Three consequences: +of them has a common descendant. `admin` is the single exception and it proves the rule — it is +narrower than `staff` because every admin is staff, which is exactly the containment the other pairs +lack. Three consequences: - `permits(ceiling, candidate)` is "walk `candidate` up the tree and see whether you reach `ceiling`", and it **fails closed** on anything it does not recognise. @@ -1366,7 +1385,8 @@ change is not complete until `docs/` reflects it" — is the floor; this table i | **8** In-app (Android) ✅ | `android/PLAN.md` §7 (the Room exception) + §11 (the inbox as built) · `website/ENGAGEMENT.md` this phase as built | `android-app/README.md`. Landed with the phase | | **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 | | **10** Protocol bump | `link/INTEGRATION.md` §Housing **and §Market** (tables + examples) + the new `account.login.result` row · `link/PLAN.md` §5/§7 · **a `link/v5.md`** — the bump now carries three enrichments across three subsystems, which is what earned v3 and v4 their own design docs | `servuo-plugins/overlay.toml` · `servuo-plugins/patches/README.md` — `vendor.sale` stays in the tier and the docs must say what that costs · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` | -| **11** module-uo triggers | `modules/uo/API.md` — **the full trigger catalogue, its audiences and its ceilings**, not one entry · `modules/uo/README.md` · `website/ENGAGEMENT.md` §8.6 kept true as rows ship | `module-uo/README.md` · **`runicgateway.com`**: `capabilities.mjs` and the notifications page — "one rule" and "the whole catalogue" are different marketing claims | +| **11a** module-uo triggers | `modules/uo/API.md` — **the full trigger catalogue, its audiences and its ceilings**, not one entry · `modules/uo/README.md` · `website/ENGAGEMENT.md` §8.6 kept true as rows ship · `website/MODULE_API.md` §1.1 (**1.8.0**) and the ceiling vocabulary wherever it is enumerated · `BACKEND_DESIGN.md` — the `news.post` publish path now runs through the engine | `module-uo/README.md` | +| **11b** Seeded rules + templates | `website/ENGAGEMENT.md` this phase as built · a release note naming **the seeded-disabled `news.post` rule as an upgrade step** (decision 5) — without it a deployment loses news push silently | **`runicgateway.com`**: `capabilities.mjs` and the notifications page — "one rule" and "the whole catalogue" are different marketing claims | | **12** Public site | — | **`runicgateway.com`**, in full — see the phase | | **13** Cutover | `README.md` index rows · every doc's status line | `.profile/README.md` if this is a headline capability | @@ -1683,6 +1703,8 @@ mails `uo.cheat.detected` to the player it detected. Fewer people is not less ex **What landed:** - `server/src/modules/ceilings.js` — the six-value lattice, `permits`, `meet`, `meetAll` + *(Phase 11 adds a seventh, `admin`, as a child of `staff`. See §5.1a for why it was not there from + the start and why the arithmetic is unchanged.)* - `registerEventTriggers` / `registerAudiences` on the existing `stage()` + `apply()` discipline, with the cross-facet collision checks and the shared legacy allowlist - `ctx.events.emit` (`utils/engagementEmit.js`) — validate, log, **stop**; throws in dev, drops and @@ -3129,9 +3151,11 @@ once. **Scope widened 2026-08-31, by the org lead, before any code.** This phase used to say `module-uo` registers `uo.house.idoc_warning` "(and siblings)", and Part 8's preamble used to say "Phase 11 ships -exactly one rule". Both are now wrong on purpose: **Phase 11 ships every ✅ row in §8.6**, with one -carve-out named below. §8.6 was written as "a catalogue, not a commitment" — it is now the commitment, -and the burden of proof has inverted: a row that does *not* ship needs a reason recorded here. +exactly one rule". Both are now wrong on purpose: **Phase 11 ships every ✅ row in §8.6**, with the +carve-outs named below. §8.6 was written as "a catalogue, not a commitment" — it is now the commitment, +and the burden of proof has inverted: a row that does *not* ship needs a reason recorded here. **Four +rows do not ship, and all four reasons are recorded** — one in this section (`uo.market.item_listed`) +and three in the decision block below. **What that is worth, and it is not "more mail".** One trigger proves that a module can register a trigger. Twenty-odd triggers spanning five audience kinds are the first real test of the things Parts @@ -3146,6 +3170,41 @@ is a *saved search*, not a trigger. Its audience is "users whose stored query ma and no per-user query store exists anywhere in the tree. It is its own workstream on top of this one. `vendor.listing` remains ingested; nothing is lost by waiting. +#### Six decisions settled by the org lead before any code (2026-08-31) + +Phase 2's precedent, and for the same reason: each of these is cheap now and expensive to retrofit, +and two of them are security boundaries. Three came out of checking §8.6's rows against what the +*website* can address rather than against what the wire carries — a different question, and the one +nobody had asked. + +| | Question | Decision | +|---|---|---| +| 1 | the operator-facing family ceilings at `admin`, and there is no `admin` in the lattice | **add it**, as a child of `staff`; `MODULE_API_VERSION` → **1.8.0** (§5.1a) | +| 2 | `uo.guild.joined`/`left` overlap core's `team.member.joined` | **ship `left` + `disbanded` only**; joins stay core's (§8.6) | +| 3 | `uo.points.rank_changed` cannot address an owner | **`subscribers` only**; the personal half carved out (§8.6) | +| 4 | `uo.link.requested` has no recipient and a 5-minute TTL | **carved out** (§8.6) | +| 5 | §7.1 Q9 — `news.post` continuity | **replace the raw tickle, seed the rule DISABLED**; news push stops on upgrade, deliberately (§7.1 Q9) | +| 6 | the 11a / 11b split this phase's last paragraph asks to confirm | **confirmed**, and split as below | + +**Decision 1 is the one with a version number attached, so state what it costs.** `ceilings.js` gains +one `PARENT` entry, one label, and a recipients query; `permits`, `meet` and `meetAll` are untouched, +because `admin` is the only pair in the whole tree that is a genuine refinement — every admin is +staff. The bump to 1.8.0 is real and both halves take it. **Decision 5 is the one with a migration +note attached**: `EngagementRules.jsx`'s existing "Team email resumes when you switch a rule on" +banner must name news too, and Phase 13's release note must carry it as an upgrade step, or a +deployment loses news push silently. + +**Two mechanisms this phase needs that no earlier phase built, neither of which is a decision so much +as a consequence.** `uo.vendor.expiring` and `uo.economy.milestone` are *threshold* events, and rule +conditions compare a declared variable against a **literal** only (`conditions.js` — no arithmetic, no +relative time), so "within 24 hours of dismissal" is not expressible as a condition. Nor can the +trigger simply fire per frame: `vendor.listing` is a sweep frame re-emitted on any price change, and +`economy.supply` arrives every five minutes. So **the threshold crossing is tracked module-side**, in +the in-process tracker `shardStreams.js` already uses for `champActive` and `cityGovernor`, and the +trigger declares `periodsRemaining` / `hoursRemaining` as ints so an operator can still narrow with +`lte`. The seeded rule carries a `subjectKey` cooldown keyed per vendor. Same shape for the economy +milestone, whose thresholds are the module's. + **Two rows ship only because Phase 10 does.** `uo.house.idoc_warning`'s enrichment and `uo.vendor.expiring` both need v5 fields; `uo.account.login_attempt` needs v5's new `account.login.result` emitter and is renamed **`uo.account.login_failed`** to say what it actually @@ -3161,13 +3220,13 @@ this way, since the audience kind is the thing being tested: | --- | --- | --- | --- | | **Owned asset at risk** | `uo.house.idoc_warning`, `uo.house.collapsed`, `uo.vendor.expiring` | `owner` (linked account) | The flagship family. All three resolve through `ownerAcct` → `shard_links` | | **Passive income** | `uo.vendor.sale` | `owner` | **Patch-tier only** — see §8.6's corrected row; dormant on a shard that declined the patches, and the seeded rule's description must say so | -| **Personal security** | `uo.account.login_failed`, `uo.account.unlinked`, `uo.link.requested` | `owner`, ceiling `owner` | `uo.link.requested` is also the linking funnel: a player ran `[link` in game, finish it on the site | +| **Personal security** | `uo.account.login_failed`, `uo.account.unlinked` | `owner`, ceiling `owner` | `uo.link.requested` was a third here and is carved out (decision 4) | | **Personal milestone** | `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered` | `owner`, opt-in | The two death triggers are a killfeed some players want and most do not — both ship `enabled = 0` and default `off` per channel | -| **Social / civic** | `uo.guild.joined`, `uo.guild.left`, `uo.guild.disbanded`, `uo.governor.elected`, `uo.election.opened` | `members`, `subscribers` | `uo.election.opened` carries `autoPickAt` — a real deadline, so it is the first trigger whose template has a genuine call to action with an expiry | +| **Social / civic** | `uo.guild.left`, `uo.guild.disbanded`, `uo.governor.elected`, `uo.election.opened` | `members`, `subscribers` | `uo.guild.joined` is core's `team.member.joined` (decision 2). `uo.election.opened` carries `autoPickAt` — a real deadline, so it is the first trigger whose template has a genuine call to action with an expiry | | **Come online now** | `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down` | `subscribers` | **`uo.server.up`/`down` is the cooldown table's stress test** — a flapping shard emits both repeatedly. Hard per-rule cooldown, not a per-send one | -| **Leaderboard** | `uo.points.rank_changed` | `subscribers`, `owner` | Fires both ways (you entered a top N; you were pushed out) | -| **Staff-facing** | `uo.page.new`, `uo.cheat.detected`, `uo.audit.staff_action` | ceiling `staff` / `admin` | These are why the ceiling exists. Phase 3 already filters a `staff`-ceiling trigger out of a player's catalogue *and* gates it on write, so this family is the production proof of that work rather than new mechanism | -| **Operator-facing** | `uo.economy.milestone`, `uo.world.saved` | ceiling `admin` | Digest-shaped by nature; neither should ever be instant | +| **Leaderboard** | `uo.points.rank_changed` | `subscribers` | The board's top N changed. The `owner` half is carved out — `top[]` names a serial, and links are keyed by account (decision 3) | +| **Staff-facing** | `uo.page.new`, `uo.cheat.detected` | ceiling `staff` | These are why the ceiling exists. Phase 3 already filters a `staff`-ceiling trigger out of a player's catalogue *and* gates it on write, so this family is the production proof of that work rather than new mechanism | +| **Operator-facing** | `uo.audit.staff_action`, `uo.economy.milestone`, `uo.world.saved` | ceiling `admin` (new — decision 1) | Digest-shaped by nature; neither of the last two should ever be instant. `uo.audit.staff_action` moved down from `staff`: a digest of what moderators did is not for moderators | **Also core's own `news.post` emitter, which this phase's title has always understated** (§7.1 Q9). `news.post` is declared with no caller, so a rule naming it can never fire; wiring it is one @@ -3175,13 +3234,45 @@ this way, since the audience kind is the thing being tested: the push already uses. **The announce legs and the post hooks are untouched** — a module's town-crier leg is a delivery to a channel of the deployment and its news-gump hook is state mirroring, neither of which is a per-person notification. What the emit replaces is the raw `pushDispatch.publish` beside -them, and Q9's continuity question has to be answered before it does. +them — **and it does replace it** (decision 5), with core seeding a `news.post` rule `enabled = 0` +beside the four Team ones. News push therefore stops on upgrade until an operator switches that rule +on. That is the deliberate answer rather than the comfortable one; §7.1 Q9 records why, and the two +obligations it creates — the admin banner and the release note — are 11a's and 11b's respectively. -**On the size of this phase.** It is now the largest in the workstream: ~23 trigger declarations, their -audiences and ceilings, their `shardIngest` mappings, and a seeded template each — against Phase 5a's -nine seeded bodies in total. It will likely want to land as **11a (declarations, mappings, ceilings — -server only) / 11b (the seeded templates and the live walk)**, on the 4a/4b and 5a/5b precedent, and -that split should be confirmed with the org lead at the start of the phase rather than assumed here. +**Also `module-uo`'s first registered audiences** (§5.1a: "`module-uo`'s first real audiences come in +Phase 11"). These are the *operator-composable* lists — named sets with constant parameters that an +admin points a rule or a segment at — and they are a different mechanism from the `members` audience +the guild triggers use. A guild event resolves to "the members of *this* guild", which is different +for every firing and therefore travels on the envelope as `recipientUserIds`, exactly as the Team +fan-out does (Phase 6, decision 2); a registered audience answers a question with the same answer +every time it is asked. Three ship: **`uo.guild.members`** (param `guildId`, ceiling `members`), +**`uo.governors`** (no params, ceiling `members`) and **`uo.linked.accounts`** (no params, ceiling +`members`) — every website user with a linked game account, which is the set an operator reaches for +first and the one a `not` composes against. + +**On the size of this phase, and the split — confirmed by the org lead (decision 6).** It is the +largest in the workstream: 24 trigger declarations after the three carve-outs, their audiences and +ceilings, their `shardIngest` mappings, three registered audiences, and a seeded rule and template +each — against Phase 5a's nine seeded bodies in total. It lands as two PRs per repo, on the 4a/4b +and 5a/5b precedent: + +- **11a — declarations, mappings, ceilings. Server only, and no template or rule is seeded.** + `module-uo`: `server/config/shardTriggers.js` (the 24 declarations, every variable carrying its + required `example`), `server/utils/shardEngagement.js` (the wire-kind → `ctx.events.emit` mapper and + its transition/threshold trackers), hung off `shardIngest.ingest` beside the existing broadcast and + push fan-outs and fire-and-forget like both; owner resolution through + `shardLinks.getByAccount` → `ownerUserId` on the envelope, the `shardPush.js` precedent unchanged; + `server/core.js` gains `events`; `registerEventTriggers` + `registerAudiences` in `server/index.js`. + `website`: the `admin` ceiling (decision 1), the `news.post` emitter and its seeded-disabled rule + (decision 5), the banner text, `MODULE_API_VERSION` → 1.8.0, and `engagement-triggers.json` + regenerated. The acceptance that belongs here is the catalogue-shaped half: **no staff- or + admin-ceiling trigger appears by name in a player's preferences catalogue**, and `check:modules` + proves core gained no UO identifier. +- **11b — the seeded templates, the seeded rules, and the live walk.** 24 rules, all `enabled = 0`; + bespoke template bodies for the families that earn one and `notify.event` + the structural + projection for the rest (§4.6.1 property 1 is precisely the claim that most of them need no + authoring, and 24 triggers is the first test of it that means anything). Everything in the + acceptance list below that needs a running shard is 11b's. **Acceptance:** the five-rung shard visibility walk still shows no leak, and **no staff- or admin-ceiling trigger appears by name in a player's preferences catalogue**; a house transitioning to @@ -3191,9 +3282,13 @@ cancels the pending mail; a player vendor drawn down to its last day produces on to its owner; a failed game login produces one `uo.account.login_failed` and a **successful** one produces none; a shard restarted three times in a minute produces **one** downtime mail, not six; and **a news post published on the rig reaches a rule, with the town-crier leg and every registered post -hook still firing exactly as they did.** +hook still firing exactly as they did** — and, decision 5's other half, **with the seeded `news.post` +rule switched on, a publish tickles the same subscribers it tickled before this phase; with it off, +it tickles nobody and the admin screen says why.** Two more the new ceiling owes: **a rule cannot be +saved giving an `admin`-ceiling trigger a `staff` audience**, and **an editor is not a recipient of +`uo.audit.staff_action`** while an admin is. **Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point — -which is a materially stronger claim now that the module registers twenty-odd UO-named triggers. +which is a materially stronger claim now that the module registers two dozen UO-named triggers. --- @@ -3294,7 +3389,7 @@ Stage A 1 ── 1b ── 2 Stage B └─ 3 ── 4 ── 5a ── 5b ── 6 Stage C └─ 7 ── 8 (8 = app-store cadence) Stage D └─ 9 -Stage E 10 ──────────────────────────────────── 11 (10 parallel from day one; 11 needs 6 + 10) +Stage E 10 ──────────────────────────────────── 11a ── 11b (10 parallel from day one; 11 needs 6 + 10) Stage F 12 ── 13 (12 written before 13, merged in its window) ── all of the above onto `edge` ── @@ -3325,7 +3420,7 @@ day it ships. ## Part 7 — Open questions and forward-compat notes -### 7.1 Questions for the org lead — seven answered, two still open +### 7.1 Questions for the org lead — all nine answered 1. ✅ **ANSWERED — may unverified addresses receive engagement mail?** *"Emails need to be unique and verification blocking sending is an admin setting."* Combined with the opt-in answer, this settles @@ -3396,7 +3491,26 @@ day it ships. all nine M12 phase PRs. `pull_request.branches` is now `[main, edge]`. **`sonarqube.yml` was deliberately left alone**: it is a push-on-`main` analysis rather than a PR gate, so no phase PR was ever expected to run it. -9. **Core's own `news.post` emitter — and the three other things a publish already fires.** +9. ✅ **ANSWERED (2026-08-31, at the start of Phase 11) — core's own `news.post` emitter, and the + three other things a publish already fires.** The three sub-questions below are all settled: + **the emit REPLACES `pushDispatch.publish`, and core seeds a `news.post` rule DISABLED** — the + Phase 6 Team precedent, applied unchanged. **It gates on `enqueueIfNeeded`'s job id**, the existing + transition signal, re-read nowhere. **Phase 11 owns it**, in the 11a half. + + **State the consequence plainly, because it is the cost of the answer and it is deliberate: news + push stops on upgrade.** A deployment that ships this and does nothing else tickles nobody on the + next publish, until an operator opens Admin → Engagement → Rules and switches the seeded rule on. + The alternative — keeping the raw tickle beside the emit for one release — was considered and + refused for the same reason Phase 6 refused seeding enabled: an exception carved for the one + pipeline that predates the engine is an exception that has to be re-argued every time somebody + reads the invariant, and "the tickle stays for one release" is an exception with a deadline nobody + owns. The mitigations are the ones Phase 6 already built and they are obligations of Phase 11, not + nice-to-haves: **`EngagementRules.jsx`'s banner must name news alongside Teams**, and **Phase 13's + release note must name it as an upgrade step**. + + The original question and its analysis follow, because the table below is still the map of what + must *not* be disturbed. + `config/coreTriggers.js` declares core's five triggers and says in as many words that **nothing here emits yet**; Phase 6 migrated only the four `team.*` ones onto the engine. So `news.post` is a declared payload contract with **no caller**, and on a real deployment the only in-app or email items @@ -3428,7 +3542,7 @@ day it ships. exactly one owner across both facets. A module that wants a person-facing notification of its own declares its own trigger through `registerEventTriggers`. That is the whole of "modules can use it". - **Three things to settle before anyone writes the line:** + **Three things to settle before anyone writes the line — all three settled above:** - **Continuity, and it is the same shape as G22 and Phase 6's decision 3.** Today publishing news tickles every `news.post` subscriber directly. If the emit *replaces* that call, push stops the moment this lands and stays stopped until an operator enables a rule — silently, because `enabled` @@ -3620,12 +3734,14 @@ which is the one legitimately campaign-shaped use and the one most likely to be ### 8.6 Module events — `module-uo`, grounded in what the shard actually emits Every kind below is real (`docs/link/INTEGRATION.md`). The trigger id is what `module-uo` registers; -core stays ignorant of every word in it. **Every ✅ row here is Phase 11 scope** except -`uo.market.item_listed`, carved out there with its reason. +core stays ignorant of every word in it. **Every ✅ row here is Phase 11 scope** except the four +struck through or marked carved out, each of which carries its reason. **Three rows were corrected on 2026-08-31**, when the set was scheduled and each claim was checked against the emitters rather than against this table. Two of the corrections are what widened Phase 10 from one wire enrichment to three; the third is a caveat that has to reach the operator, not a defect. +**Three more were carved out or narrowed on the same day**, at the start of Phase 11, when the same +check was run against the *website* rather than against the wire — see "Three carve-outs" below. | Trigger | Wire source | Data | Why anyone cares | | --- | --- | --- | --- | @@ -3637,7 +3753,8 @@ from one wire enrichment to three; the third is a caveat that has to reach the o | `uo.character.death` / `murdered` | `player.death`, `player.murdered` | ✅ | Opt-in; a killfeed some players want and most do not | | `uo.skill.capped` | `skill.gain` where `base == cap` | ✅ | You hit the cap in a skill — a genuine milestone | | `uo.quest.complete` | `quest.complete` | ✅ | Milestone / achievement mail | -| `uo.guild.joined` / `left` | `guild.join`, `guild.leave`, `guild.roster` | ✅ | Roster changes to guild leadership | +| ~~`uo.guild.joined`~~ | `guild.join` | **carved out — Phase 11** | Core's `team.member.joined` already fires for this; see below | +| `uo.guild.left` | `guild.leave`, `guild.roster` | ✅ | A member left. Core has no `team.member.left`, so this one has no overlap | | `uo.guild.disbanded` | `guild.remove` | ✅ | Consequential and easy to miss | | `uo.champ.started` | `champ.update` → active | ✅ (transition tracker exists) | A champion spawn is up — the classic "come online now" driver | | `uo.champ.boss_up` | `champ.update` `bossUp` | ✅ | Higher-value version of the same | @@ -3647,10 +3764,10 @@ from one wire enrichment to three; the third is a caveat that has to reach the o | `uo.page.new` | `page.new` | ✅ | A player opened a help page → **staff** audience | | `uo.cheat.detected` | `cheat.fastwalk` | ✅ | Staff audience only — this kind is *not* on the public allowlist | | `uo.audit.staff_action` | `audit.set`, `audit.command`, `admin.audit` | ✅ | Admin-audience digest of staff activity | -| `uo.link.requested` | `link.request` | ✅ | A player ran `[link` in game — confirm on the site | +| ~~`uo.link.requested`~~ | `link.request` | **carved out — Phase 11** | No addressable recipient, and a TTL no channel can beat; see below | | `uo.account.unlinked` | `account.unlinked` | ✅ | Someone severed the tie from in-game | | `uo.market.item_listed` | `vendor.listing` | ✅ (protocol v3) | Saved-search hit: "a Vanquishing kryss appeared under 50k" | -| `uo.points.rank_changed` | `points.board` | ✅ (protocol v3) | You entered/left a leaderboard top N | +| `uo.points.rank_changed` | `points.board` | ⚠ **`subscribers` only** (protocol v3) | The board's top N changed. The personal "you were pushed out" half is carved out — see below | | `uo.economy.milestone` | `economy.supply` | ✅ | Operator-facing; economy health thresholds | | `uo.world.saved` | `world.save.after` | ✅ | Operator-facing only; world-size trend | @@ -3687,6 +3804,37 @@ from one wire enrichment to three; the third is a caveat that has to reach the o `audience` it is *permitted* to have, and a rule cannot widen it. Without that, the module system's security boundary has a second door. **This belongs in Phase 2's trigger declaration, not Phase 4.** +**Three carve-outs and one narrowing, settled by the org lead 2026-08-31 at the start of Phase 11.** +Phase 11 inverted the burden of proof — a ✅ row that does *not* ship needs a reason recorded — so +here they are. All three were found by checking each row against what the *website* can address, +which is a different question from whether the wire carries the event. In every case the event keeps +being ingested and nothing else changes. + +- **`uo.guild.joined` — duplicates core's `team.member.joined`.** A UO guild *is* a Team and + `module-uo` is this deployment's Team provider, so `teamSync.notifyRoster` already emits + `team.member.joined` and `team.leadership.changed` on every roster reconcile, and core already seeds + (disabled) rules for both. Shipping a UO trigger for the same fact means an operator who enables the + obvious two rules gets two mails for one join and has to work out why. **`uo.guild.left` and + `uo.guild.disbanded` still ship** — core has no `team.member.left` and no disband event at all, so + they are the half of the family with no overlap. The trade given up is latency: `guild.join` is + real-time and the Team sweep is ~15 minutes. That is the right thing to give up, because a duplicate + mail is a defect an operator discovers in production and fifteen minutes is not. +- **`uo.link.requested` — nobody to address, and a deadline no channel can meet.** By construction the + game account is *not yet* linked, which is the entire point of the event, so an `owner` audience + resolves to nobody: `shard_account_links` has no row for it, and there is no other tie between that + account and a website user. Even if there were, `link.request` carries `ttlSec` — a default of five + minutes — and every channel this system has is slower than that in the worst case and *allowed* to + be. The player is already looking at the code on their screen when the event fires. +- **`uo.points.rank_changed` — ships `subscribers` only; the personal half is carved out.** + `points.board`'s `top[]` entries are `{rank, serial, name, points}` — a **mobile serial**, and + `shard_account_links` is keyed by game **account**. No general serial→account map exists on the + website: `shard_online` has one only for characters logged in right now, and `shard_guild_members` + only for guilded ones. So "you were pushed out of the top ten" would be addressable for some players + and silently not for others, which is worse than not offering it — a leaderboard mail that reaches + half the board reads as favouritism. The board-changed feed at `subscribers` needs no owner and + ships. Making the personal half real means a serial→account map, which nothing else in the tree + needs, so it waits for something that does. + ### 8.7 Module events — the game-agnostic shape What a *different* game's module would register, to prove nothing above is UO-specific: -- 2.49.1