docs(engagement): scope Phase 11 — six decisions, three carve-outs, the 11a/11b split #194

Merged
whitlocktech merged 1 commits from docs/engagement-phase-11-decisions into edge 2026-09-01 05:05:41 +00:00

View File

@@ -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: