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

18 KiB
Raw Blame History

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

"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.

{"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 = 45 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.decayschedule 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.listingfees admin, configurable The only default in the market feature that does not reproduce prior behaviour, because there is no prior behaviour to reproduce
vendor.listingownerAcct 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.