Files
docs/link/v5.md
wtclaude 5f5ca41d9e docs(engagement): Phase 13 as built, and Phase 14 -- retention
Phase 13 was the only phase in the workstream with no as-built section, which
6.0b makes a phase's own deliverable. It has one now, and it has something to
record beyond "it merged":

  * the window missed steps 5 and 7 (Integration-kit, runicgateway.com); they
    landed afterwards, with a third PR clearing a leftover from step 4;
  * Gitea's `raw` API route is CDN-cached for six hours, and a stale read fails
    BOTH ways -- it failed a correct site AND reported an already-republished
    bundle as current. All three cross-repo checks read `contents` now;
  * the Integration-kit equality check never goes red on its own. It clones the
    ref the kit itself pins, so the kit sat three minor versions behind, green,
    for the whole workstream. Step 5 is the only thing that makes anyone look;
  * `registerEngagementSeeds` does not validate the body it seeds, and the kit's
    own example was malformed. Only a run through core's REAL registry found it,
    which the kit's CI does not do and cannot;
  * every `edge` was deleted rather than fast-forwarded. Recorded as the
    convention, because a branch that does not exist cannot be branched from
    stale -- which was 6.0a's actual failure.

Phase 12's second finding becomes **Phase 14**, scoped and not started: four
tables with no retention sweep, and they are not one problem with one horizon.
`engagementCooldowns.db.prune()` already exists with no caller; the send log has
two live readers (the hourly cap's index and the operator screen) that bound how
short its horizon may be; and suppressions probably should NOT expire at all,
because ageing out a bounce re-mails an address that already hard-bounced. The
phase owes `/privacy` and the Play answers, which today say "nothing here expires
on its own" -- true now, false the moment it lands.

Also: ticks on the phase headings that were done and unmarked (4, 9, 10, 11, 12,
13), a status header that still said everything from Phase 10 on was design, and
three status lines that outlived the cutover -- `link/v5.md` and this README's
index row both said protocol 5 was built but unreleased, and INTEGRATION.md named
the v2.0.0/v1.0.0 pairing as the shipped one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 13:17:08 -05:00

280 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Protocol 5 — Three enrichments, one bump
**Status:** Released. Cut over to `main` on 2026-09-01 and shipped in sidecar **v2.1.0** + overlay **v1.1.0**, paired as bundle **2026.09.01**.
**Date:** 2026-08-31
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`v3.md`](v3.md) (3.0 shard content + the visibility framework), [`v4.md`](v4.md) (4.0 guild membership), [`INTEGRATION.md`](INTEGRATION.md) (website API).
**Driven by** [`../website/ENGAGEMENT.md`](../website/ENGAGEMENT.md) Phase 10.
---
## 1. Why three things at once
Every previous bump did one thing. This one does three, and the reason is a property of the bump
itself rather than of the features:
**A protocol bump costs a sidecar release, a republished bundle, and an operator update on every
shard.** A field left out does not cost a follow-up commit — it costs a *second* bump with the same
three-part lead time, and an operator population split across two protocol versions until the second
one finishes propagating. So everything the engagement workstream is known to need from the wire was
decided before any of it was written, and all of it rides v5.
The three were not picked from a wish list. Each is a field that a **specific Phase 11 trigger cannot
be built without**, and in two of the three cases that was discovered by reading the emitters rather
than by reading the plan, which had recorded both as already possible.
| # | Enrichment | The trigger that needs it | What was actually there |
|---|---|---|---|
| a | `house.decay` gains `ownerName` + a `schedule` | `uo.house.idoc_warning` — the flagship | The frame could say a house had *become* IDOC and nothing about when it ends |
| b | `vendor.listing` gains `ownerAcct` + `fees` | `uo.vendor.expiring` | `ownerSerial` and `ownerName` only, and **no fee data anywhere on the wire** — the plan had this as "needs a mapper" |
| c | `account.login.result`, a new kind | `uo.account.login_failed` | `account.login.attempt` fires **before the auth decision**, so it fires on successful logins too |
### 1.1 What was deliberately left out
**`vendor.sale` stays in the opt-in patch tier.** It is real, and it already carries `ownerAcct`
but it lives in `servuo-plugins/patches/`, verified only against ServUO 57.4, because it needs a
`PlayerVendorSale` EventSink that the patches *add* to core. Moving it into `overlay/` was considered
and declined: the emitter cannot exist without the core event, so "moving" it means shipping a core
patch as though it were an overlay file. The consequence is a documentation obligation, not a
protocol one — `uo.vendor.sale` is a trigger that is **silently dormant on a shard that declined the
tier**, and the rule that names it has to say so.
---
## 2. The shard side
### 2.1 `house.decay` — a schedule, and only where it is knowable
`BridgeSweeps.WriteDecay` gains `ownerName` and one nested object:
```json
{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC",
"name":"Millrace","ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002",
"schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z",
"decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"},
"map":"Felucca","x":1480,"y":1600,"z":0,"lastRefreshed":"2026-08-25T17:21:14.5725317Z"}
```
**`estimatedCollapse` is present only when it is exact**, and that is the whole design of this field.
ServUO has two decay implementations and they differ in how knowable the future is:
- **Dynamic decay** (`DynamicDecay.Enabled`, i.e. `Core.ML`) draws each stage's duration at *random*
when the stage is entered — `BaseHouse.SetDynamicDecay` calls `DynamicDecay.GetRandomDuration`. So
`NextDecayStage` is exact for the **next** transition and nothing beyond it is known at all.
Collapse becomes exact only once the house is already at IDOC, because then the next transition
*is* the collapse.
- **Static decay** (`GetOldDecayLevel`) is a pure function of `LastRefreshed` and `DecayPeriod`, so
collapse is exact at **every** stage — there is no randomness to wait out.
Emitting a dynamic-decay house's collapse time at, say, `Fairly` would publish a guess as a fact, and
on the website's side that becomes a dated promise in a player's mail. So it is **omitted rather than
approximated**. An absent field is honest where a wrong date is not, and the consumer's
`required: false` declaration already permits the absence.
`dynamicDecay` is emitted unconditionally so a reader can tell the two regimes apart, rather than
having to infer them from which fields arrived.
**The nesting is load-bearing, not cosmetic.** The website's visibility projection matches literal
JSON keys, so a nested group is one admin rule that hides the whole schedule where four sibling keys
would be four rules that drift apart — the same argument that made `vendor.listing`'s `location`
nested in v3.
### 2.2 `vendor.listing` — an owner who can be found, and a deadline
Two additions, and the first matters more than it looks:
**`ownerAcct`.** The frame has carried `ownerName` since v3, but a character name joins to nothing:
the website's `shard_account_links` is keyed by the game **account**. Until now a vendor row named an
owner the site could not resolve to a person, which is why a per-owner rule was impossible however
much fee data existed.
**`fees`**, a nested object describing `PlayerVendor.PayTimer`'s dismissal rule. That rule is
`if (pay > totalGold) Destroy()` at each tick — and *both halves of the comparison differ between
ServUO's two vendor systems*:
| | charge | funds | interval |
|---|---|---|---|
| `NewVendorSystem` | `ChargePerRealWorldDay` | `HoldGold` | 1 real day |
| old system | `ChargePerDay` | `BankAccount + HoldGold` | 1 UO day (`Clock.MinutesPerUODay`, ≈2 real hours) |
Both are resolved on the shard rather than left for the sidecar or the website to reconstruct,
because reconstructing them anywhere else is a second implementation of a rule that lives in
`PlayerVendor`.
```json
"fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548,"funds":82504,
"holdGold":82504,"bankAccount":0,"payIntervalSec":86400,
"nextPayAt":"2026-09-01T21:01:21.6883320Z","periodsRemaining":7,
"dismissalAt":"2026-09-08T21:01:21.6883320Z"}
```
Two naming decisions worth recording, because the obvious spellings are both wrong:
- **There is no `daysRemaining`.** On an old-system shard a pay period is a UO day, so a field with
"days" in its name would be off by a factor of twelve on exactly the shards least likely to notice.
The wire carries `periodsRemaining` plus the interval that gives it meaning, and resolves the
arithmetic into `dismissalAt` — an *instant*, which needs no units at all.
- **`exempt` is not "a very long time left".** A commission vendor (`IsCommission`) has no `PayTimer`
and is never dismissed for fees. It reports `{"exempt": true}` and no schedule, so a surface
rendering "never" can tell it apart from one rendering "in 400 days".
`dismissalAt` assumes no further sales or deposits, exactly as a bank-balance projection does. Unlike
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
`PacketHandlers.AccountLogin` invokes **before** it branches on `e.Accepted`. The emitter's own
comment has said so since it was written: *"Fires before the auth decision, so this is an attempt,
not a result."*
The verdict is set by the handlers themselves — `Server.Misc.AccountHandler` is the one that checks
the password and sets `Accepted`/`RejectReason` — so inside our handler the verdict does not exist
yet. `AccountLoginEventArgs` constructs with `Accepted = true`, which means a naive read there
reports **every** login as accepted, including the ones about to be rejected.
That is why "someone tried to log into your game account" could not be built on the attempt: a rule
on it would have mailed a security alert every time the player logged in successfully — the exact
inversion that makes people stop trusting security mail.
```json
{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":false,"reason":"BadPass"}
{"kind":"account.login.result","acct":"seed_000","ip":"127.0.0.1","accepted":true}
```
**How it reads the verdict.** `Timer.DelayCall(TimeSpan.Zero, …)` over the args object: one Core
slice later, `InvokeAccountLogin` has returned and the verdict is final. This needs no core patch,
and — the reason it is preferred over simply subscribing late — it does not depend on handler
**subscription order**, which ServUO does not define and which a shard's own scripts can change.
Three details that are deliberate:
- **`reason` is omitted on an accept.** `ALRReason`'s zero value is `Invalid`, so emitting it
unconditionally would put a plausible-looking failure reason on every successful login.
- **The IP is resolved inside the handler**, not in the deferred call: `AccountLogin_ReplyRej`
disposes the `NetState` before the deferred read runs.
- **The password is never read, never logged, never emitted.** The args object carries it, so it is
held for one extra Core slice and no longer, and exactly two properties are read off it.
---
## 3. The sidecar side
`PROTOCOL_VERSION: u32 = 4``5` in `sidecar/src/main.rs`, and **nothing else**.
There is **no store migration this time**, unlike v4. Every frame is persisted whole and the board
tables index only the columns they already had, so the new fields ride inside the stored JSON and the
new kind lands in `events` like any other. There is no kind allowlist to extend, either.
That is the dumb-forwarder property doing its job (v3 §3): the
sidecar defines no schema for a frame's contents, so it needs no change when they grow. A bump that
touches one constant is the *expected* cost of an additive protocol version here; v4 needing a
migration was the exception, because it added a column to a board table.
---
## 4. Visibility
Three classifications, made on the website in `module-uo`'s `shardVisibility.js`, never in the
sidecar. Each had to be chosen: a v5 field nobody classified would either leak, or be silently
invisible with nobody noticing.
| Field / kind | Audience | Why |
|---|---|---|
| `house.decay``schedule` | **`anonymous`**, configurable | The countdown *is* the public IDOC page's content, and a house at IDOC is already announced in game. Listed rather than left unconfigurable so a shard that considers a precise collapse time an unfair advantage can raise it |
| `vendor.listing``fees` | **`admin`**, configurable | The only default in the `market` feature that does not reproduce prior behaviour, because there is no prior behaviour to reproduce |
| `vendor.listing``ownerAcct` | **admin, locked** | Rule 1, automatically: the key ends in `acct` |
| `account.login.result` | **admin, by omission** | Rule 2 — a kind absent from `KIND_FEATURE` reaches nobody below admin |
**Why `fees` breaks the `market` pattern.** A shop's name, owner and location are already visible to
any player through the stock in-game Vendor Search gump, which is the entire argument for publishing
them. A vendor's held gold, daily charge and dismissal date are not: in game they are visible to the
**owner**, on that vendor's own gump. Publishing them anonymously would be a genuinely new disclosure
*and* a targeting aid — it says which shops are about to be abandoned and how much coin is sitting in
each.
**Why `account.login.result` is admin-only by omission rather than by mapping.** `KIND_FEATURE` is
the map of kinds that *may* leave the admin channel, and an admin can widen anything on it. There is
no rung below admin that a frame carrying an IP address and an auth verdict belongs on, so mapping it
at all would create a door that should not exist. The omission is the decision, and there is a test
that says so by name.
**The REST reads are unchanged.** `shard_vendors`' new columns are not in any read model's column
list: they exist for Phase 11's server-side trigger and reach no client at all. The house schedule
*is* on the read model, re-nested under `schedule` on the way out so the stored shape and the live
wire frame spell it identically — otherwise one admin rule would cover only one of the two paths,
which is the §3.6.1 failure mode v3 already recorded once.
---
## 5. Cross-repo obligations
| Repo | Change |
|---|---|
| `servuo-plugins` | `BridgeSweeps.AppendDecaySchedule`, `BridgeMarket.AppendFees`, `BridgeEvents.EmitLoginResult` · **`overlay.toml` `protocol = 5`, in the same PR as the emitters** |
| `link` | `PROTOCOL_VERSION` → 5 |
| `module-uo` | ingest + schema (`shard_houses` ×4, `shard_vendors` ×8) · `shardVisibility.js` ×3 · the `uo_link_config` protocol pin, both declaration sites |
| `docs` | this file · `INTEGRATION.md` §Housing, §Market, §Account · `PLAN.md` §5/§7 |
| `runicgateway.com` | `platform.json.protocol` → 5 — **deferred to ENGAGEMENT.md Phase 12**, because `checkFacts.mjs` fetches from `main` and setting it during the `edge` period turns that repo red immediately |
**The pin has three declaration sites and they are checked against each other, not against a
literal.** `module-uo`'s schema test used to assert `DEFAULT 4` at each site, which is how protocol 4
shipped with the emitters moved and one declaration left behind: every site agreed with itself. It
now reads `DEFAULT_PROTOCOL` from the model, so the assertion is "the declarations **agree**" and a
bump that misses one fails in CI instead of on an operator's install.
---
## 6. Verification
Unit tests: 470 in `module-uo/server` (16 new), 39 in the sidecar, all passing; the C# compiles
against the real ServUO 57.4 reference assemblies.
Everything below was proved on the local rig — a real ServUO with a seeded world (43k mobiles,
209k items), the **release** Rust sidecar, and `tools/scaffolding/BridgeProtocol5Probe.cs`.
| Claim | Evidence |
|---|---|
| `estimatedCollapse` appears **only** at IDOC | One house walked Fairly → Greatly → IDOC. The `Fairly` and `Greatly` frames carry `nextStage` and no `estimatedCollapse`; the `IDOC` frame carries both, equal |
| `house.decay` carries the owner's character name | `"ownerName":"Zara Crowe"` on all three frames |
| the fee block is right | `funds/chargePerPeriod` floors to `periodsRemaining` on every observed vendor (82504/10548→7, 63252/6834→9, 115531/10035→11, 43/60→0) |
| a vendor with one tick left says so | `periodsRemaining: 0` with `dismissalAt == nextPayAt` — dismissed at the very next tick, which is the case `uo.vendor.expiring` exists for |
| the login verdict is the FINAL one | A real socket login with a wrong password → `accepted:false reason:BadPass`; with the right one → `accepted:true`, no `reason`. **Both saying `true` is the bug this kind exists to prevent** |
| the sidecar needed no change beyond the constant | `GET /health``"protocol":5`; every v5 field arrived through the generic forward path |
| a mismatched pair is refused, not mis-parsed | Unchanged and already tested in `installer`: `bundle.rs::a_protocol_disagreement_inside_one_bundle_is_refused` and `doctor.rs::a_protocol_mismatch_fails_the_row`. The check is version-agnostic, so v5 needs no installer change |
### 6.1 Two rig traps, both of which fake a broken emitter
Recorded because each cost a rebuild and each produces *silence* rather than an error.
- **An in-process login probe can never produce `accepted:true`.** `AccountHandler` calls
`acct.HasAccess(e.State)` *before* it checks the password, and a null `NetState` fails that — so
firing `EventSink.InvokeAccountLogin` directly logs `Access denied` even for a correct password.
Only a real socket proves the accepted half (and it is the better test anyway: it also produces the
real `ip`).
- **Forcing a decay stage on a house that cannot decay emits nothing at all.** Only `Condemned` and
`ManualRefresh` houses decay; an `AutoRefresh` one — and *the owner's newest house is always
`AutoRefresh`* — has a `DecayLevel` getter that calls `ResetDynamicDecay()` and reports `Ageless`,
wiping the forced stage on the very next read. The sweep then sees no change. On the seeded world
exactly **one** house qualified, and it was already at IDOC, so the walk had to put it back down
first.