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

@@ -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