docs(engagement): scope Phase 11 — six decisions, three carve-outs, the 11a/11b split #194
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user