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
|
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:**
|
[`../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
|
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
|
§8.6** rather than a single rule. **Phase 11 was then scoped on the same day** — six decisions, three
|
||||||
below are settled; **eight of the nine questions in §7.1 are answered** - Q1, Q3, Q5 and Q7 on
|
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),
|
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
|
**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
|
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
|
phase's size split **Phase 4 into 4a and 4b**; **Q9** (core's own `news.post` emitter) was answered
|
||||||
2026-08-31 in docs#189 and recommended for Phase 11) is the only one still open. Per CLAUDE.md § Conventions, no
|
**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.
|
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
|
**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
|
├── subscribers signed-in users who opted into this id
|
||||||
├── members a module-declared list (a Team, the governors)
|
├── members a module-declared list (a Team, the governors)
|
||||||
├── staff admin / editor / moderator
|
├── staff admin / editor / moderator
|
||||||
|
│ └── admin admins only ← added in Phase 11
|
||||||
└── owner the one user the event is about
|
└── 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 <
|
**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`
|
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
|
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.
|
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
|
`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`",
|
- `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.
|
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 |
|
| **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 |
|
| **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` |
|
| **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 |
|
| **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 |
|
| **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:**
|
**What landed:**
|
||||||
|
|
||||||
- `server/src/modules/ceilings.js` — the six-value lattice, `permits`, `meet`, `meetAll`
|
- `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
|
- `registerEventTriggers` / `registerAudiences` on the existing `stage()` + `apply()` discipline, with
|
||||||
the cross-facet collision checks and the shared legacy allowlist
|
the cross-facet collision checks and the shared legacy allowlist
|
||||||
- `ctx.events.emit` (`utils/engagementEmit.js`) — validate, log, **stop**; throws in dev, drops and
|
- `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`
|
**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
|
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
|
exactly one rule". Both are now wrong on purpose: **Phase 11 ships every ✅ row in §8.6**, with the
|
||||||
carve-out named below. §8.6 was written as "a catalogue, not a commitment" — it is now the commitment,
|
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.
|
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
|
**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
|
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.
|
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.
|
`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
|
**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
|
`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
|
`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` |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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) |
|
| **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`, `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 |
|
| **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.economy.milestone`, `uo.world.saved` | ceiling `admin` | Digest-shaped by nature; neither should ever be instant |
|
| **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).
|
**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
|
`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
|
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
|
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
|
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
|
**Also `module-uo`'s first registered audiences** (§5.1a: "`module-uo`'s first real audiences come in
|
||||||
audiences and ceilings, their `shardIngest` mappings, and a seeded template each — against Phase 5a's
|
Phase 11"). These are the *operator-composable* lists — named sets with constant parameters that an
|
||||||
nine seeded bodies in total. It will likely want to land as **11a (declarations, mappings, ceilings —
|
admin points a rule or a segment at — and they are a different mechanism from the `members` audience
|
||||||
server only) / 11b (the seeded templates and the live walk)**, on the 4a/4b and 5a/5b precedent, and
|
the guild triggers use. A guild event resolves to "the members of *this* guild", which is different
|
||||||
that split should be confirmed with the org lead at the start of the phase rather than assumed here.
|
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
|
**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
|
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
|
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
|
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
|
**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 —
|
**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 B └─ 3 ── 4 ── 5a ── 5b ── 6
|
||||||
Stage C └─ 7 ── 8 (8 = app-store cadence)
|
Stage C └─ 7 ── 8 (8 = app-store cadence)
|
||||||
Stage D └─ 9
|
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)
|
Stage F 12 ── 13 (12 written before 13, merged in its window)
|
||||||
|
|
||||||
── all of the above onto `edge` ──
|
── all of the above onto `edge` ──
|
||||||
@@ -3325,7 +3420,7 @@ day it ships.
|
|||||||
|
|
||||||
## Part 7 — Open questions and forward-compat notes
|
## 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
|
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
|
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
|
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
|
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.
|
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
|
`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
|
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
|
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
|
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".
|
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
|
- **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
|
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`
|
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
|
### 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;
|
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
|
core stays ignorant of every word in it. **Every ✅ row here is Phase 11 scope** except the four
|
||||||
`uo.market.item_listed`, carved out there with its reason.
|
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
|
**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
|
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.
|
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 |
|
| 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.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.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.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.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.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 |
|
| `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.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.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.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.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.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.economy.milestone` | `economy.supply` | ✅ | Operator-facing; economy health thresholds |
|
||||||
| `uo.world.saved` | `world.save.after` | ✅ | Operator-facing only; world-size trend |
|
| `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
|
`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.**
|
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
|
### 8.7 Module events — the game-agnostic shape
|
||||||
|
|
||||||
What a *different* game's module would register, to prove nothing above is UO-specific:
|
What a *different* game's module would register, to prove nothing above is UO-specific:
|
||||||
|
|||||||
Reference in New Issue
Block a user