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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user