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

@@ -122,6 +122,24 @@ Two naming decisions worth recording, because the obvious spellings are both wro
a dynamic-decay house, though, there is **no randomness in it**: given the current funds it is the
exact tick the vendor is destroyed on.
**The market sweep has to DIFF the fee state, and originally it did not.** `BridgeMarket.Signature()`
compares shop name, owner, map, coordinates and the item/price list — the things a *listing* is made
of — so adding `fees` to the frame did not by itself make a frame arrive when the fees changed. A
vendor quietly running down its gold altered nothing the sweep compared, emitted nothing, and the
consumer that exists for exactly that event could fire only by coincidence: when somebody happened to
reprice an item on a shop that was already broke. Found on the engagement Phase 11b live walk and
fixed there (ENGAGEMENT.md, decision 13).
The signature carries the **derived** values — `exempt` and `periodsRemaining` — not the raw ones. An
integer division moves only when the shard's own answer to "is this vendor in danger" moves, while
`holdGold` changes on every sale and `nextPayAt` on every pay tick; keying on either would re-emit a
fat listing frame for a shop whose listings had not changed. This is emit CADENCE, not frame shape:
no field was added and `PROTOCOL_VERSION` is untouched.
**The general form is worth carrying to the next enrichment.** A sweep-based kind has a change
detector, and a field added to the frame that is not added to the detector is a field that ships
correct and arrives never.
### 2.3 `account.login.result` — a new kind, because the old one cannot be fixed
`account.login.attempt` (protocol 1) is emitted from `EventSink.AccountLogin`, which

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

View File

@@ -545,21 +545,35 @@ The ceiling is a **stored column rather than a runtime computation** so an audit
was allowed to reach without re-resolving it, and so a module that later widens its own audience's
ceiling cannot retroactively widen a segment saved under the old one.
### engagement_cooldowns — one fire per (rule, user, subject) (engagement phase 4a)
### engagement_cooldowns — one fire per (rule, user, subject, channel) (engagement phase 4a; `channel` added 11b)
| col | type | notes |
|---|---|---|
| rule_id | INT NOT NULL FK→engagement_rules(id) ON DELETE CASCADE | |
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
| subject_key | VARCHAR(190) NOT NULL DEFAULT '' | opaque to core: a house serial, a vendor id. `''` = this rule cools per user, not per subject |
| channel | VARCHAR(32) NOT NULL DEFAULT '' | the delivery channel. VARCHAR like `engagement_outbox.channel`, and for the same reason: the channel set is data a module can extend |
| last_fired_at | DATETIME NOT NULL | |
| fire_count | INT NOT NULL DEFAULT 1 | |
`PRIMARY KEY(rule_id, user_id, subject_key)`, `INDEX(last_fired_at)` for a prune.
`PRIMARY KEY(rule_id, user_id, subject_key, channel)`, `INDEX(last_fired_at)` for a prune.
**`subject_key` is why this is not a per-user counter.** "One IDOC mail per player per day" is the
wrong rule: a player with four houses decaying should hear about all four, once each, and cooling on
(rule, user) alone silently drops three of them.
**`channel` is why a two-channel rule delivers on both, and it was added after a live walk found that
it did not** (ENGAGEMENT.md Phase 11b, decision 12). The engine claims INSIDE its per-channel loop, so
without the channel in the key the first channel of a rule claimed the cooldown and every later one
was refused as still cooling — and `inapp` is ranked first deliberately, so a rule naming email and
in-app delivered the inbox item and silently never the mail. A cooldown is per delivery, not per
occasion: an operator who says "one a day about this house" means one mail and one inbox item.
Migrated in place behind a guarded `DROP PRIMARY KEY`, because **MariaDB has no conditional form of a
key change** — replaying `schema.sql` on every boot would fail after the first run without the
`information_schema` guard that reads whether the key already carries the column. Rows written before
the migration keep `channel = ''` and expire on their own interval; dropping the table instead would
let a storm through the window.
**The claim is two statements, not the one §4.1 originally described** — a guarded `UPDATE` (the
interval in a WHERE clause) falling back to `INSERT IGNORE` for a first fire. The single
`INSERT … ON DUPLICATE KEY UPDATE` form reads its answer out of `affectedRows`, and the mariadb

View File

@@ -3218,7 +3218,7 @@ this way, since the audience kind is the thing being tested:
| Family | Triggers | Audience kind | Notes |
| --- | --- | --- | --- |
| **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.refreshed`, `uo.house.collapsed`, `uo.vendor.expiring` | `owner` (linked account) | The flagship family. All four resolve through `ownerAcct` → `shard_links`. `uo.house.refreshed` was added by decision 11 during the live walk: it is what CANCELS the warning's delayed row, and without it a fifteen-minute delay was just a late mail |
| **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` | `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 |
@@ -3268,8 +3268,9 @@ and 5a/5b precedent:
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.** **25** rules, all
`enabled = 0` — the twenty-fourth is `uo.governor.appointed`, added by decision 10 below. Sixteen
- **11b — the seeded templates, the seeded rules, and the live walk.** **26** rules, all
`enabled = 0` — the twenty-fifth is `uo.governor.appointed` (decision 10 below) and the
twenty-sixth is `uo.house.refreshed`, which the walk itself added (decision 11). Seventeen
in-universe bodies on two channels each (decisions 8 and 9), and core's `notify.event` +
`inapp.event` + the structural projection for the nine that stay plain — §4.6.1 property 1 is
precisely the claim that a trigger needs no authoring, and the nine are the test of it that
@@ -3392,7 +3393,7 @@ are not the same text. Two triggers is also what lets an operator run the announ
letter off, or the reverse.
**This takes the phase to 25 triggers**, and the count in §8.6 and in 11a's summary should be read
that way from here. It does not take another `MODULE_API` bump: a trigger declaration is module data,
that way from here — **26 after decision 11 below added `uo.house.refreshed`**. It does not take another `MODULE_API` bump: a trigger declaration is module data,
and 1.9.0 is decision 7's.
#### What building it found
@@ -3478,6 +3479,115 @@ decision-8 boundary stated as a check rather than as a promise.
**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 two dozen UO-named triggers.
#### Three decisions taken during the live walk (2026-09-01), and what it found
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 three of the fixes were decisions rather than repairs. None of
the six was visible in a unit test, and the reason is the same every time — each is a disagreement
between two things that agree with each other in a fixture.
| | Question the walk raised | Decision |
|---|---|---|
| 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 seeded rule of its own |
| 12 | a rule with a cooldown delivered on exactly ONE of its channels | **`channel` joins the cooldown key** — a cooldown is per delivery, not per occasion |
| 13 | `uo.vendor.expiring` could not fire, because the market sweep does not diff fees | **widen `BridgeMarket.Signature()`** with `exempt` + `periodsRemaining` |
**Decision 11 — the delay had no cancel path for the GOOD outcome.** §4.2a is explicit that a delay
without a cancellation is just a late mail, and the fifteen minutes on the warning exist so a player
who repairs the house is never told it is in peril. The rule's `cancel_on` named only
`uo.house.collapsed` — the outcome where the warning is pointless — and nothing named the outcome
where it is *wrong*, because the mapper deliberately returned early on every transition that was not
a late stage, so a refresh reached the engine as silence. The wire had carried it all along. It ships
with a body as well as the cancellation (the org lead's call): an office that only ever writes when
something is wrong teaches its reader to dread the seal, and a notice that the ledger is set right is
the cheapest possible way not to. The cancellation is read off the WARNING's rule, so an operator who
wants it without the reassurance simply leaves the new rule off — which, every seeded rule shipping
disabled, is the default.
> **`Ageless`, not only `LikeNew`, and this is a ServUO fact worth not re-deriving.** A condemned
> house cannot be refreshed at all — `BaseHouse.RefreshDecay()` returns false for
> `DecayType.Condemned` — so the rescue is the owner logging back in. Their NEWEST house then becomes
> `AutoRefresh` and reads **`Ageless`**; an older one becomes `ManualRefresh` and reads **`LikeNew`**.
> Both mean "out of danger", and reading only the second misses the common case. The live rig produced
> `Ageless`.
**Decision 12 is the one with the widest blast radius, and it had been latent since Phase 4a.**
`cooldownsDb.claim` is called INSIDE the engine's per-channel loop and its key was
`(rule, user, subject)`, so the first channel of a rule claimed the cooldown and every later one was
reported as `cooled`. `inapp` is ranked first on purpose (so `push` can reference the inbox row it
writes), which means a rule naming email + in-app delivered the inbox item and **silently never the
mail** — including core's own `news.post` rule, which names three channels and carries an hour's
cooldown. Decision 8 requires the letter and the inbox item to fire together, so every in-universe
body this phase wrote was unreachable behind its own in-app twin.
Claiming once per user *before* the channel loop was the alternative and was refused: it makes the
cooldown a per-occasion limit, and an operator who sets "one a day about this house" means one mail
and one inbox item, not one of the two. So `channel` joins the PRIMARY KEY, migrated in place behind
a guarded `DROP PRIMARY KEY` — MariaDB has no conditional form of a key change, so replaying the
schema file would fail on every boot after the first without the `information_schema` guard. Existing
rows keep `channel = ''` and expire on their own interval: one stale cooldown per (rule, user,
subject) is a better trade than dropping the table, which would let a storm through.
**Decision 13 is a defect in the shard plugin, and Phase 10 is where it was introduced by omission.**
`BridgeMarket.Signature()` diffs shop name, owner, map, coordinates and the item/price list — and v5
added a `fees` block that it never learned about. So a vendor quietly running out of gold changes
nothing the sweep compares, emits no frame, and the trigger whose entire subject is a vendor running
out of gold can fire only by coincidence: when somebody happens to reprice an item on a shop that is
already broke. Proved on the rig by setting a vendor's held gold to zero and watching no frame
follow. The fix carries the DERIVED values (`exempt`, `periodsRemaining`) rather than the raw ones —
an integer division moves only when the shard's own answer to "is this vendor in danger" moves, while
`holdGold` changes on every sale and `nextPayAt` on every tick, either of which would re-emit a fat
listing frame for a shop whose listings did not change. No new wire field and **no `PROTOCOL_VERSION`
bump**: it changes when an existing frame is sent, not its shape.
**Four more defects, each fixed without a decision because the right answer was a fact rather than a
choice:**
- **Every module-seeded rule failed to insert.** `checkSeedRule` validated `max_sends_per_hour` and
then dropped it from the normalized rule; the column is NOT NULL, so all 25 rules failed at boot —
loudly, on a real database, and on no fixture. The registry test asserted the REJECTION of a bad
ceiling and never that a good one survives. It now asserts the normalized rule against the insert's
own column list, so the next field added is covered the day it is added.
- **Every `owner`-audienced trigger resolved to nobody.** `resolveTarget` read `link.user_id` where
the model's `toSafe` returns `userId`, so the flagship family — houses, vendors, logins, unlinks,
deaths, the governor's letter — reached no one, and looked exactly like the ordinary
unlinked-account case, which the code treats as normal and deliberately does not log. **The test
fake returned `user_id` and therefore agreed with the bug**, while `shardStreams.test.js`'s fake
next door — same model, and the path `shardEngagement.js` says it copies — returned `userId`. The
fake is now built by running the real `toSafe` over a stubbed row.
- **Every call-to-action in every in-universe body was a dead link**, from two independent mistakes
with one symptom. The client router prefixes a module's routes with its **ID** (`/uo/houses`), not
with `module.json`'s `mounts` (`/shard/…`), so every declared `example` and every hard-coded href
was a 404 — and not one of the eight `url` variables was ever populated by the mapper, so the
buttons rendered with an empty href and vanished from the text part altogether. Both now read
`config/clientPaths.js`, and two tests close it: every `url` example must name a route the module
mounts, and every declared `url` variable must actually be supplied on the path that emits its
trigger.
- **A raw wire timestamp was signing off the Merchants' Guild's letter** —
`2026-09-02T04:06:43.8397548Z`, mid-sentence. Core has no interpolation filters by design, so the
readable form is assembled in the mapper and arrives as its own variable; the machine value stays
exactly as it was, because an operator writes `is at most` conditions against it.
**What the walk proved, rung by rung, against real ServUO + the release sidecar + a live SMTP
catcher.** A `Greatly` transition produced **one** notification to the linked owner on both channels
and nothing to a second linked player who was subscribed; the next transition inside the day was
cooled; the owner logging back in **cancelled both pending rows** and sent the reassurance letter; a
vendor drawn to its last period warned its owner once and was cooled on the second crossing; a failed
game login over a real socket produced one notice and a **successful** one produced none; three
genuine shutdown/boot cycles produced **one** downtime fan-out and two fully-cooled ones; a governor
seated on a live `city.update` produced **both** the town's bulletin (six subscribers) and the crown's
letter (the winner alone); a real staff action driven through the sidecar reached the admin and
**neither the editor nor the moderator**, both of whom were subscribed; and the rule editor refused
`staff`, `authenticated` and `everyone` on an `admin`-ceiling trigger while accepting `admin`.
> The three cycles took about three minutes rather than the acceptance's "one minute", because a real
> ServUO boot is roughly fifty seconds. The property is unchanged: the rule's cooldown is an hour.
The walk is repeatable. `servuo-plugins/tools/scaffolding/BridgeRigDriver.cs` drives the shard from
outside the game over a polled command file, which is what a walk needs and what no existing probe
gave — every other one runs a fixed script at boot or from an in-game client, and ServUO's console
takes a fixed verb set, so `[p5probe` cannot be typed at a headless shard at all.
---
### Phase 12 — `runicgateway.com`: the public site and the docs journey