docs(engagement): Phase 11b's live walk — three decisions and six defects

The walk is the last piece of 11b and it was not a formality. It found six
defects, four of them in code shipped by earlier phases, and none of the six was
visible in a unit test: each is a disagreement between two things that agree with
each other in a fixture.

Three of the fixes were decisions rather than repairs, all settled by the org
lead before any code:

  11  `uo.house.idoc_warning` ships delay_seconds: 900 and nothing could cancel
      it  ->  add `uo.house.refreshed`, a 26th trigger with a body and a rule
  12  a rule with a cooldown delivered on exactly ONE of its channels
          ->  `channel` joins the cooldown key; a cooldown is per DELIVERY
  13  `uo.vendor.expiring` could not fire, because the market sweep does not
      diff fees  ->  widen BridgeMarket.Signature() with exempt +
      periodsRemaining

Files:

  website/ENGAGEMENT.md   the three decisions, the four repairs, and what the
                          walk proved rung by rung; the 11b bullet and the §8.6
                          family table now read 26 triggers / 34 bodies
  website/BACKEND_DESIGN.md  engagement_cooldowns gains `channel` in its PRIMARY
                          KEY, with the migration's information_schema guard and
                          why MariaDB forces one
  link/v5.md              the sweep has to DIFF the fees or the frame never
                          comes -- stated as the general rule for the next
                          enrichment, since it is emit cadence and not shape
  modules/uo/API.md       §5.7a the cancel-shaped trigger and the Ageless-vs-
                          LikeNew ServUO fact; §5.7b every link comes from
                          config/clientPaths.js, and the two mistakes that made
                          every call-to-action a dead link

Pairs with website#<core>, Module-uo#<uo> and servuo-plugins#<plugin>.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 07:13:03 -05:00
parent c9873eff7d
commit cd0d22a225
4 changed files with 203 additions and 11 deletions

View File

@@ -157,9 +157,9 @@ stored payload, whose actors carry `acct` and `webId`; `/public/shard/idoc` retu
## 5. Engagement triggers and audiences (ENGAGEMENT.md Phase 11)
Not an HTTP surface, and it is here anyway: it is the other thing this module registers with core, and
it is the one an operator interacts with by name. `module-uo` declares **25 event triggers** and
it is the one an operator interacts with by name. `module-uo` declares **26 event triggers** and
**3 audiences** through `api.registerEventTriggers` / `api.registerAudiences`, and ships
**32 message bodies and 25 rules** through `api.registerEngagementSeeds`
**34 message bodies and 26 rules** through `api.registerEngagementSeeds`
([`MODULE_API.md`](../../website/MODULE_API.md) §2.4). Core never learns a word of the vocabulary —
it holds an id, a label, a variable list, a ceiling and, for an audience, a `resolve` it may call.
@@ -181,7 +181,7 @@ ceiling does not permit `owner`, because fewer people is not less exposure.
| Ceiling | Triggers | Why that ceiling |
| --- | --- | --- |
| `owner` | `uo.house.idoc_warning`, `uo.house.collapsed`, `uo.vendor.expiring`, `uo.vendor.sale`, `uo.account.login_failed`, `uo.account.unlinked`, `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered`, `uo.governor.appointed` | Each is about one person's own property, account, character or office. All eleven resolve through an account on the frame to `shard_account_links`; an unlinked game account is nobody to notify |
| `owner` | `uo.house.idoc_warning`, `uo.house.refreshed`, `uo.house.collapsed`, `uo.vendor.expiring`, `uo.vendor.sale`, `uo.account.login_failed`, `uo.account.unlinked`, `uo.skill.capped`, `uo.quest.complete`, `uo.character.death`, `uo.character.murdered`, `uo.governor.appointed` | Each is about one person's own property, account, character or office. All twelve resolve through an account on the frame to `shard_account_links`; an unlinked game account is nobody to notify |
| `members` | `uo.guild.left`, `uo.guild.disbanded` | The guild's roster, resolved to website users through `shard_account_links` and carried on the emit as `recipientUserIds` — "the members of *this* guild" is a different answer every firing, which a saved segment cannot express |
| `authenticated` | `uo.governor.elected`, `uo.election.opened`, `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down`, `uo.points.rank_changed` | Public shard news. Each defaults to `subscribers`; the ceiling permits an operator to widen to everyone signed in, which for "the shard is back up" is a defensible thing to want |
| `staff` | `uo.page.new`, `uo.cheat.detected` | `uo.cheat.detected` is the declaration the lattice was written 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 |
@@ -265,7 +265,7 @@ Declaring a trigger says what an event IS. It says nothing about what the messag
until Phase 11b there was no way for a module to say: `templateSeeds.js` and `coreRules.js` are core
files with core arrays in them. `api.registerEngagementSeeds({ templates, ruleGroups })`
([`MODULE_API.md`](../../website/MODULE_API.md) §1.1, **1.9.0**) is the mechanism; this module is its
first caller, with **32 bodies and 25 rules** in
first caller, with **34 bodies and 26 rules** in
[`server/config/engagementSeeds.js`](https://gitea.whitlocktech.com/RunicGateway/Module-uo/src/branch/main/server/config/engagementSeeds.js).
**Sixteen families read from inside Britannia, with a per-family sender.** The org lead's decision
@@ -302,13 +302,63 @@ not fixtures.
Two mechanical notes that will bite whoever adds the twenty-sixth trigger:
- **All 25 rules are in ONE seed group, `triggers-v1`, and a group is seeded once.** A rule appended
- **All 26 rules are in ONE seed group, `triggers-v1`, and a group is seeded once.** A rule appended
to it later reaches **fresh installs only** — never a deployment already stamped. A rule that must
reach existing deployments takes a new group key.
- **A trigger id and a template key have different grammars.** `uo.champ.boss_up` is a legal trigger
id and an illegal template key (core's key pattern admits `.` and `-`, not `_`), so its body is
keyed `uo.champ.boss-up`. Registration refuses the mistake at boot.
### 5.7a `uo.house.refreshed` — the trigger that exists to cancel one
Added by the live walk (ENGAGEMENT.md Phase 11b, decision 11), and it is the only trigger in this
module whose primary job is not to say something.
`uo.house.idoc_warning`'s seeded rule carries `delay_seconds: 900` so that a player who repairs the
house inside the quarter-hour is never told it is in peril. That is only true if something CANCELS
the pending row, and until this trigger existed nothing could: `cancel_on` named `uo.house.collapsed`
— the outcome where the warning is pointless — and the mapper returned early on every transition that
was not a late decay stage, so a refresh reached the engine as silence. The wire had carried the
transition all along.
```
house.decay Greatly -> Ageless (the owner logged back in)
-> uo.house.refreshed (owner-audienced, subject = the house serial)
-> cancels every scheduled engagement_outbox row for
(the warning's rule, that house, that owner)
-> and, if the operator enabled its own rule, sends the Office of Deeds'
one non-warning letter
```
Three things about it are load-bearing:
- **Its `subjectKey` is `houseSerial`, the same as the warning's.** `outboxDb.cancel` matches on
`(rule, subject_key)`, so a refresh carrying any other subject would cancel nothing at all.
- **It fires on `Ageless` as well as `LikeNew`, and `Ageless` is the common case.** A condemned house
cannot be refreshed — `BaseHouse.RefreshDecay()` refuses `DecayType.Condemned` outright — so the
rescue is the owner logging in. Their newest house then becomes `AutoRefresh` and reads `Ageless`;
an older one becomes `ManualRefresh` and reads `LikeNew`. Reading only the second misses most
rescues.
- **The cancellation does not depend on its own rule.** `cancel_on` is read off the WARNING's rule,
so an operator who wants the cancellation without the reassurance letter simply leaves the new rule
disabled — which, every seeded rule shipping disabled, is what a fresh install already does.
### 5.7b Every link a body offers comes from `config/clientPaths.js`
A notification's call-to-action is a path into this module's own SPA routes, and there is exactly one
place that knows them. The live walk found every one of them wrong, in two independent ways:
- the declared `example`s read `/shard/…`, taken from `module.json`'s `mounts` — but
`registry.registerRoutes` prefixes a module's client routes with its **ID** and nothing else, so
the real paths are `/uo/houses`, `/uo/market`, `/player/uo/characters`. Every example was a 404,
and an example is what the template editor previews and test-sends with;
- and no `url` variable was ever populated by the mapper, so the buttons rendered with an empty href
and dropped out of the text part entirely.
`clientPaths.js` is now the single source for both the declarations and the bodies. `client/src/entry.jsx`'s
own `registerNav` is the cross-check: the hrefs it hands the sidebar are these, and if the two ever
disagree the sidebar is right.
### 5.8 The presentational fragments, and why they exist
A template has **no conditionals**, by design, and an unset optional interpolates to the **empty