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>
18 KiB
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 (1.0 read/event plane), PROTOCOL_2.md (2.0 provisioning + world-state streams), v3.md (3.0 shard content + the visibility framework), v4.md (4.0 guild membership), INTEGRATION.md (website API).
Driven by ../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:
{"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.SetDynamicDecaycallsDynamicDecay.GetRandomDuration. SoNextDecayStageis 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 ofLastRefreshedandDecayPeriod, 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.
"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 carriesperiodsRemainingplus the interval that gives it meaning, and resolves the arithmetic intodismissalAt— an instant, which needs no units at all. exemptis not "a very long time left". A commission vendor (IsCommission) has noPayTimerand 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.
{"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:
reasonis omitted on an accept.ALRReason's zero value isInvalid, 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_ReplyRejdisposes theNetStatebefore 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.AccountHandlercallsacct.HasAccess(e.State)before it checks the password, and a nullNetStatefails that — so firingEventSink.InvokeAccountLogindirectly logsAccess deniedeven for a correct password. Only a real socket proves the accepted half (and it is the better test anyway: it also produces the realip). - Forcing a decay stage on a house that cannot decay emits nothing at all. Only
CondemnedandManualRefreshhouses decay; anAutoRefreshone — and the owner's newest house is alwaysAutoRefresh— has aDecayLevelgetter that callsResetDynamicDecay()and reportsAgeless, 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.