feat(engagement): declare 24 shard triggers and 3 audiences (Phase 11a) #22

Merged
whitlocktech merged 1 commits from feature/engagement-triggers into edge 2026-09-01 05:06:29 +00:00
Member

module-uo's half of ENGAGEMENT.md Phase 11a. Pairs with website (the admin ceiling, MODULE_API_VERSION → 1.8.0, core's news.post emitter) and docs#194 (the six decisions this was built to).

Nothing here sends anybody anything. A trigger is a payload contract — what a rule may fire on, what a template may interpolate, and the widest audience an operator may ever give it. No rule and no template is seeded; that is 11b.

What landed

server/config/shardTriggers.js the 24 declarations, grouped by the audience kind each family exercises
server/config/shardAudiences.js the 3 registered audiencesuo.guild.members, uo.governors, uo.linked.accounts
server/utils/shardEngagement.js the wire-kind → ctx.events.emit mapper and its transition/threshold trackers
server/utils/shardIngest.js the fan-out, beside the SSE broadcast and the push tickle
server/core.js ctx.events / ctx.inbox reach the module
module.json coreApi^1.8.0, version → 0.4.0

Ceilings: 10 owner, 2 members, 7 authenticated, 2 staff, 3 admin (the value core adds in the same window). uo.cheat.detected at staff is the declaration the whole lattice exists for — under a flat "fewer people is narrower" ordering a staff ceiling would also permit owner, and the rule an operator could then save mails the cheat report to the player who was detected.

The ordering that is load-bearing

The engagement fan-out runs BEFORE applyStateChange, and it is awaited. This is the one place ingest waits on a notification path, and it has to:

  • account.unlinked drops the shard_account_links row — the row that names the one person who needs to be told their account was unlinked. Resolving afterwards finds nobody, every time.
  • house.remove drops the house, whose stored ownerAcct is the only place the owner of a collapsed house appears (the frame carries a serial alone).
  • guild.leave / guild.remove need the roster and board mirrors to name who left and which guild it was.

A fire-and-forget promise would race the DELETE it is trying to precede. fromShardEvent never throws and opens no socket — it resolves ids and hands the engine an envelope, and emit is deliberately not awaited inside that.

Three things a rule cannot express, so they live in the mapper

conditions.js compares a declared variable against a literal — no arithmetic, no relative time, no previous value.

  • Transitions. champ.update and city.update are full-state upserts re-emitted on any change, so without a per-process tracker a sidecar reconnect reads as twenty champion spawns starting at once. A first sighting is never a transition — the same rule shardStreams.js already holds for push.
  • Thresholds. vendor.listing is a sweep frame re-emitted whenever anyone reprices an item, so uo.vendor.expiring fires on the crossing into a 48-hour window; a deposit that leaves the window re-arms it. It declares hoursRemaining as an int so an operator can still narrow with is at most 24. uo.economy.milestone crosses a gold/account line in either direction, never on first sight.
  • The members audience. "The members of this guild" is a different answer every firing, which a saved segment's constant params cannot express, so the access-checked set travels on the envelope as recipientUserIds (Phase 6, decision 2 — the mechanism the Team fan-out was built on). Resolved through shard_account_links, not through the roster's mirrored web_id: the mirror is a copy of what the wire said, and the links table is the answer.

Two protocol facts the mapper obeys

  • account.login.result, never account.login.attempt. The attempt fires from a sink that runs before the auth decision and whose args default Accepted = true, so a rule on it would have mailed a security alert on every successful login. That inversion is why v5 added the kind and why the trigger is uo.account.login_failed. The attempt kind is deliberately left unmapped, with a test that says so.
  • An absent estimatedCollapse means "not knowable", never "not yet read". Under dynamic decay ServUO draws each stage's duration at random when the stage is entered, so the mapper passes the absence through rather than computing a fallback — which would republish exactly the guess the shard refused to make.

Four rows that deliberately do not ship

Phase 11 inverted the burden of proof, so each carries a reason (recorded in docs#194 §8.6 and in docs/modules/uo/API.md §5.2):

Not shipped Reason
uo.market.item_listed a saved search; no per-user query store exists
uo.guild.joined core's team.member.joined already fires for it — a UO guild is a Team and this module is the provider, so the roster reconcile emits on every join. uo.guild.left and uo.guild.disbanded ship: core has neither
uo.link.requested no addressable recipient by construction (the account is not yet linked, which is the point) and a ~5-minute TTL
uo.points.rank_changed's personal half top[] names a mobile serial, shard_account_links is keyed by account

A test asserts all three carved-out ids are absent, so adding one back has to edit a test that says why.

coreApi^1.8.0

Not cosmetic. The module now calls api.registerEventTriggers and declares ceiling: 'admin': a 1.7.0 core refuses the ceiling and a 1.6.0 core does not have the method at all (which is exactly how the stale test fake failed before it was updated). ^1.3.0 would have let it load into a core that crashes it at registration.

Verification

  • 509/509 server tests, 39 of them new — the mapper half needs no database and is tested pure; the resolution half is tested with stubbed models.
  • check:imports clean · check:bundle clean (60 reachable files, all shipped) · check:swagger clean.
  • All 24 declarations were registered against core's real registries.js alongside the 7 grandfathered stream ids, to prove the one-namespace rule holds and every ceiling/audience pair is accepted.

Note for the reviewer

swagger-fragment.json reports STALE when check:swagger runs on Windows. It is a line-ending artifact — regenerating produces a byte-identical file modulo CRLF, and git diff shows no content change — so nothing is committed for it. It is pre-existing on edge and passes on CI's Linux checkout.


AI disclosure

  • This contribution was AI-assisted (Claude Code / Claude Opus 5). Commits carry a Co-Authored-By: Claude trailer.

🤖 Generated with Claude Code

`module-uo`'s half of **ENGAGEMENT.md Phase 11a**. Pairs with website (the `admin` ceiling, `MODULE_API_VERSION` → 1.8.0, core's `news.post` emitter) and docs#194 (the six decisions this was built to). **Nothing here sends anybody anything.** A trigger is a payload *contract* — what a rule may fire on, what a template may interpolate, and the widest audience an operator may ever give it. No rule and no template is seeded; that is 11b. ## What landed | | | | --- | --- | | `server/config/shardTriggers.js` | the **24 declarations**, grouped by the audience kind each family exercises | | `server/config/shardAudiences.js` | the **3 registered audiences** — `uo.guild.members`, `uo.governors`, `uo.linked.accounts` | | `server/utils/shardEngagement.js` | the wire-kind → `ctx.events.emit` mapper and its transition/threshold trackers | | `server/utils/shardIngest.js` | the fan-out, beside the SSE broadcast and the push tickle | | `server/core.js` | `ctx.events` / `ctx.inbox` reach the module | | `module.json` | `coreApi` → `^1.8.0`, version → 0.4.0 | Ceilings: **10 `owner`**, **2 `members`**, **7 `authenticated`**, **2 `staff`**, **3 `admin`** (the value core adds in the same window). `uo.cheat.detected` at `staff` is the declaration the whole lattice exists for — under a flat "fewer people is narrower" ordering a `staff` ceiling would also permit `owner`, and the rule an operator could then save mails the cheat report to the player who was detected. ## The ordering that is load-bearing **The engagement fan-out runs BEFORE `applyStateChange`, and it is awaited.** This is the one place `ingest` waits on a notification path, and it has to: - `account.unlinked` drops the `shard_account_links` row — the row that names **the one person who needs to be told their account was unlinked**. Resolving afterwards finds nobody, every time. - `house.remove` drops the house, whose stored `ownerAcct` is the only place the owner of a collapsed house appears (the frame carries a serial alone). - `guild.leave` / `guild.remove` need the roster and board mirrors to name who left and which guild it was. A fire-and-forget promise would race the DELETE it is trying to precede. `fromShardEvent` never throws and opens no socket — it resolves ids and hands the engine an envelope, and `emit` is deliberately not awaited inside that. ## Three things a rule cannot express, so they live in the mapper `conditions.js` compares a declared variable against a **literal** — no arithmetic, no relative time, no previous value. - **Transitions.** `champ.update` and `city.update` are full-state upserts re-emitted on any change, so without a per-process tracker a sidecar reconnect reads as twenty champion spawns starting at once. A **first** sighting is never a transition — the same rule `shardStreams.js` already holds for push. - **Thresholds.** `vendor.listing` is a sweep frame re-emitted whenever anyone reprices an item, so `uo.vendor.expiring` fires on the **crossing** into a 48-hour window; a deposit that leaves the window re-arms it. It declares `hoursRemaining` as an int so an operator can still narrow with `is at most 24`. `uo.economy.milestone` crosses a gold/account line in either direction, never on first sight. - **The `members` audience.** "The members of *this* guild" is a different answer every firing, which a saved segment's constant params cannot express, so the access-checked set travels on the envelope as `recipientUserIds` (Phase 6, decision 2 — the mechanism the Team fan-out was built on). Resolved through `shard_account_links`, **not** through the roster's mirrored `web_id`: the mirror is a copy of what the wire said, and the links table is the answer. ## Two protocol facts the mapper obeys - **`account.login.result`, never `account.login.attempt`.** The attempt fires from a sink that runs *before* the auth decision and whose args default `Accepted = true`, so a rule on it would have mailed a security alert on **every successful login**. That inversion is why v5 added the kind and why the trigger is `uo.account.login_failed`. The attempt kind is deliberately left unmapped, with a test that says so. - **An absent `estimatedCollapse` means "not knowable", never "not yet read".** Under dynamic decay ServUO draws each stage's duration at random when the stage is entered, so the mapper passes the absence through rather than computing a fallback — which would republish exactly the guess the shard refused to make. ## Four rows that deliberately do not ship Phase 11 inverted the burden of proof, so each carries a reason (recorded in docs#194 §8.6 and in `docs/modules/uo/API.md` §5.2): | Not shipped | Reason | | --- | --- | | `uo.market.item_listed` | a saved **search**; no per-user query store exists | | `uo.guild.joined` | core's `team.member.joined` already fires for it — a UO guild **is** a Team and this module is the provider, so the roster reconcile emits on every join. `uo.guild.left` and `uo.guild.disbanded` ship: core has neither | | `uo.link.requested` | no addressable recipient **by construction** (the account is not yet linked, which is the point) and a ~5-minute TTL | | `uo.points.rank_changed`'s personal half | `top[]` names a mobile **serial**, `shard_account_links` is keyed by **account** | A test asserts all three carved-out ids are absent, so adding one back has to edit a test that says why. ## `coreApi` → `^1.8.0` Not cosmetic. The module now calls `api.registerEventTriggers` and declares `ceiling: 'admin'`: a **1.7.0** core refuses the ceiling and a **1.6.0** core does not have the method at all (which is exactly how the stale test fake failed before it was updated). `^1.3.0` would have let it load into a core that crashes it at registration. ## Verification - **509/509 server tests**, 39 of them new — the mapper half needs no database and is tested pure; the resolution half is tested with stubbed models. - `check:imports` clean · `check:bundle` clean (60 reachable files, all shipped) · `check:swagger` clean. - All 24 declarations were registered against **core's real `registries.js`** alongside the 7 grandfathered stream ids, to prove the one-namespace rule holds and every ceiling/audience pair is accepted. ### Note for the reviewer `swagger-fragment.json` reports **STALE** when `check:swagger` runs on Windows. It is a line-ending artifact — regenerating produces a byte-identical file modulo CRLF, and `git diff` shows no content change — so nothing is committed for it. It is pre-existing on `edge` and passes on CI's Linux checkout. --- ### AI disclosure - [x] This contribution was AI-assisted (Claude Code / Claude Opus 5). Commits carry a `Co-Authored-By: Claude` trailer. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-01 01:29:09 +00:00
feat(engagement): declare 24 shard triggers and 3 audiences (Phase 11a)
Some checks failed
PR Checks / client-build (pull_request) Successful in 22s
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / frozen-manifest (pull_request) Failing after 41s
419dee3e49
module-uo's half of ENGAGEMENT.md Phase 11: every trigger DECLARATION, the
wire-kind mapping that fires them, and the three registered audiences. No rule
and no template is seeded here -- that is 11b -- so nothing this adds sends
anybody anything until an operator writes a rule.

server/config/shardTriggers.js declares the 24, grouped by the audience kind
each family exercises, and every variable carries the `example` the template
editor previews and test-sends with. Ceilings: 10 `owner`, 2 `members`, 7
`authenticated`, 2 `staff`, 3 `admin` (the value core adds in the same window).
`uo.cheat.detected` at `staff` is the declaration the lattice exists for.

server/utils/shardEngagement.js maps the wire to those ids, hung off
shardIngest.ingest beside the SSE broadcast and the push tickle, and reads like
shardPush.js on purpose -- owner resolution is why neither can be a pure mapper.
Three things live here because a rule cannot express them:

  * Transitions. champ.update and city.update are full-state upserts, so without
    a per-process tracker a sidecar reconnect reads as twenty spawns starting.
    A FIRST sighting is never a transition.
  * Thresholds. conditions.js compares a declared variable against a LITERAL, so
    "within 24 hours of dismissal" is not expressible; and vendor.listing is a
    sweep frame re-emitted on any price change, so per-frame would flood. The
    crossing is tracked here and `hoursRemaining` is declared so an operator can
    still narrow with `is at most`.
  * The members audience. "The members of THIS guild" differs every firing, so
    it travels on the envelope as recipientUserIds (Phase 6 decision 2).

**The fan-out runs BEFORE the state write, and that ordering is load-bearing.**
account.unlinked drops the shard_account_links row that names the one person who
needs to be told; house.remove drops the house whose stored ownerAcct is the only
place a collapsed house's owner appears; guild.leave/remove need the roster and
board mirrors to name who left. Resolving afterwards finds nobody, every time.

Four rows of 8.6 deliberately do not ship, each with its reason recorded in
docs (docs#194): uo.market.item_listed (a saved search, no per-user query store),
uo.guild.joined (core's team.member.joined already fires for it -- a UO guild IS
a Team and this module is the provider), uo.link.requested (no addressable
recipient by construction, ~5-minute TTL), and uo.points.rank_changed's personal
half (top[] names a serial, links are keyed by account).

coreApi -> ^1.8.0: the module now calls registerEventTriggers and declares
`ceiling: 'admin'`, so a 1.7.0 core would refuse the ceiling and a 1.6.0 one
would not have the method at all.

39 new tests; 509/509 pass. check:imports, check:bundle and check:swagger clean.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 1a866112e4 into edge 2026-09-01 05:06:29 +00:00
whitlocktech deleted branch feature/engagement-triggers 2026-09-01 05:06:30 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-uo#22
No description provided.