docs(link): protocol 5 — the decay schedule, vendor fee state and account.login.result #193

Merged
whitlocktech merged 2 commits from docs/protocol-v5 into edge 2026-09-01 00:27:29 +00:00
Showing only changes of commit efce1d88aa - Show all commits

View File

@@ -7,7 +7,9 @@ docs#182; Phase 4a: website#170 + docs#183; Phase 4b: website#171 + docs#184; Ph
docs#185; Phase 5b: website#173 + docs#186 + runicgateway.com#22; Phase 6: website#174 + docs#187 +
runicgateway.com#23; Phase 7: website#175 + docs#188 + runicgateway.com#24; Phase 8: the Android
half, Android-app#42 + docs#190; Phase 9: website#176 + docs#191); everything from Phase 10 on is
still design. The scope decisions
still design. **Phases 10 and 11 were both widened on 2026-08-31, by the org lead, before any code:**
the protocol bump carries three wire enrichments rather than one, and Phase 11 ships **every ✅ row of
§8.6** rather than a single rule. The scope decisions
below are settled; **eight of the nine questions in §7.1 are answered** - Q1, Q3, Q5 and Q7 on
2026-08-28, Q6 on 2026-08-29 at the start of Phase 2 (which also settled §7.2's namespace question),
**Q2 and Q4 on 2026-08-29 at the start of Phase 4**, and **Q8 on 2026-08-31 at the start of Phase
@@ -24,7 +26,10 @@ none.
1. **The in-app channel is in scope.** It does not exist today and has to be built, not adapted.
2. **The Teams notification pipeline is generalized and migrated onto the new system**, not built beside it.
3. **The `house.decay` protocol enrichment is in scope**, as a coordinated four-repo `PROTOCOL_VERSION` bump.
3. **The `house.decay` protocol enrichment is in scope**, as a coordinated four-repo `PROTOCOL_VERSION`
bump. **Widened 2026-08-31:** that single bump now carries **three** enrichments (house decay,
player-vendor fee state, and a post-decision login result), because a second bump would cost another
release + bundle + operator update. See Phase 10.
4. **Gmail OAuth2 is removed, not retained as a transport.** SMTP is the baseline; the OAuth2 consent
flow, its two routes, its borrowed Google client and its stored refresh token all go. See §1.2a for
what that deletes and §6/Phase 1 for the operator cutover it forces.
@@ -1359,8 +1364,8 @@ change is not complete until `docs/` reflects it" — is the floor; this table i
| **7** In-app channel (core+web) ✅ | `website/BACKEND_DESIGN.md` routes + tables (the four inbox routes, `user_notifications`) · `website/ENGAGEMENT.md` this phase as built | **`runicgateway.com`**: `notifications-and-email.mdx` gains the in-app channel. Landed with the phase |
| **8** In-app (Android) ✅ | `android/PLAN.md` §7 (the Room exception) + §11 (the inbox as built) · `website/ENGAGEMENT.md` this phase as built | `android-app/README.md`. Landed with the phase |
| **9** Deliverability | `website/BACKEND_DESIGN.md` §7 · a suppression/bounce operator section (the verification flow is Phase 1b's) | **`runicgateway.com`**: `troubleshooting.mdx` gains bounce/suppression · **`PLAY_DATA_SAFETY.md` + `/privacy`** — see Phase 12 |
| **10** Protocol bump | `link/INTEGRATION.md` §Housing (table + example) · `link/PLAN.md` §5/§7 · a `link/v5.md` if the bump earns its own design doc, as v3 and v4 did | `servuo-plugins/overlay.toml` · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
| **11** module-uo triggers | `modules/uo/API.md` · `modules/uo/README.md` | `module-uo/README.md` |
| **10** Protocol bump | `link/INTEGRATION.md` §Housing **and §Market** (tables + examples) + the new `account.login.result` row · `link/PLAN.md` §5/§7 · **a `link/v5.md`** the bump now carries three enrichments across three subsystems, which is what earned v3 and v4 their own design docs | `servuo-plugins/overlay.toml` · `servuo-plugins/patches/README.md``vendor.sale` stays in the tier and the docs must say what that costs · **`runicgateway.com`**: `platform.json.protocol` → 5, `bundle.*`, `architecture/protocol-versions.mdx` |
| **11** module-uo triggers | `modules/uo/API.md` **the full trigger catalogue, its audiences and its ceilings**, not one entry · `modules/uo/README.md` · `website/ENGAGEMENT.md` §8.6 kept true as rows ship | `module-uo/README.md` · **`runicgateway.com`**: `capabilities.mjs` and the notifications page — "one rule" and "the whole catalogue" are different marketing claims |
| **12** Public site | — | **`runicgateway.com`**, in full — see the phase |
| **13** Cutover | `README.md` index rows · every doc's status line | `.profile/README.md` if this is a headline capability |
@@ -3000,47 +3005,133 @@ a bounce handler today.
---
### Phase 10 — Protocol bump: `house.decay` enrichment *(parallel from day one)*
### Phase 10 — Protocol bump: three wire enrichments *(parallel from day one)*
Four repos plus the overlay declaration, per CLAUDE.md's "The bridge is a contract":
**Scope widened 2026-08-31, by the org lead, before any code.** The phase was written as the
`house.decay` enrichment alone. It now carries **three** enrichments, for one reason that is a property
of this phase and of no other: **a protocol bump costs a sidecar release, a republished bundle and an
operator update on every shard.** A field left out here 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 in the meantime. So everything the engagement workstream is known to need from the wire is
decided **now** and rides the single v4 → v5 bump.
The two additions come from Phase 11's expanded trigger set (below). Each was verified against the
emitters rather than assumed, and each closes a gap that §8.6 did not know it had:
- **Player-vendor fee state.** `uo.vendor.expiring` is the same shape as the flagship — an owned asset
at risk, with a real deadline — and is the second-strongest driver in the catalogue. But
`vendor.listing` carries `ownerSerial` and `ownerName` and **no `ownerAcct` and no fee state at
all**, so the trigger can today neither be addressed to a person nor know that anything is expiring.
- **A login *outcome*.** `EventSink.AccountLogin` is a pre-decision veto hook — the emitter's own
comment says so ("Fires before the auth decision, so this is an attempt, not a result"). It therefore
fires on every **successful** login too, and `uo.account.login_attempt` as §8.6 describes it
("someone tried to log into your game account") cannot be built on it: it would mail a security alert
every time the player themselves logged in. This is the one addition that is a new emitter rather
than new fields on an existing one.
**Explicitly NOT in this bump:** moving `vendor.sale` out of the opt-in patch tier. It stays where it
is (see §8.6's corrected row), which leaves `uo.vendor.sale` dormant on a shard that declined the
patches — a documentation obligation, not a protocol one.
| Repo | Change |
| --- | --- |
| `servuo-plugins/` | `BridgeSweeps.WriteDecay` gains `ownerName` (`house.Owner.Name`), `nextStage` (`BaseHouse.NextDecayStage`), `decayPeriod`, and `estimatedCollapse` **only when `to == "IDOC"`** (§0.3) |
| `servuo-plugins/overlay.toml` | `protocol = 4` → `5`, **in the same PR as the emitter** |
| `link/` | `PROTOCOL_VERSION: u32 = 4` → `5` (`sidecar/src/main.rs:55`); houses board carries the new fields |
| `website/` (`module-uo`) | ingest maps the new fields; `uoLinkConfig` protocol version |
| `docs/` | `docs/link/INTEGRATION.md` §Housing table + example, `docs/link/PLAN.md` §5/§7 |
| `servuo-plugins/` | **(a)** `BridgeSweeps.WriteDecay` gains `ownerName` (`house.Owner.Name`), `nextStage` (`BaseHouse.NextDecayStage`), `decayPeriod`, and `estimatedCollapse` **only when `to == "IDOC"`** (§0.3) · **(b)** `BridgeMarket`'s `vendor.listing` gains `ownerAcct`, `holdGold`, `chargePerDay`, `daysRemaining` · **(c)** a new post-decision `account.login.result` (`acct`, `accepted`, `rejectReason`, `ip`) beside the existing pre-decision attempt |
| `servuo-plugins/overlay.toml` | `protocol = 4` → `5`, **in the same PR as the emitters** |
| `link/` | `PROTOCOL_VERSION: u32 = 4` → `5` (`sidecar/src/main.rs:55`); the houses board carries the new decay fields; `account.login.result` is a forwarded kind like any other — the sidecar stays a dumb forwarder |
| `website/` (`module-uo`) | ingest maps the new fields; `uoLinkConfig` protocol version; **`shardVisibility.js`'s `KIND_FEATURE` gains `account.login.result`** — rule 2 fails an unmapped kind closed to admin-only, which is the right answer for a frame carrying an IP, but it must be *chosen* here rather than inherited by accident |
| `docs/` | `docs/link/INTEGRATION.md` §Housing and §Market tables + examples, `docs/link/PLAN.md` §5/§7 |
**Acceptance:** a live run on the local rig (`C:\Users\colby\Desktop\ServUO` + the Rust sidecar, not the
PowerShell stub) shows a real transition carrying the new fields; a v4 overlay paired with a v5 sidecar
is **refused by the installer**, not mis-parsed; CI publishes a bundle whose manifest pairs v5 with v5.
**Two things the emitter work must not get wrong**, both following from §0.3's finding about how
ServUO actually decays:
- `estimatedCollapse` is exact **only** at IDOC, because dynamic decay draws each stage's duration at
random when the stage is entered. Emitting it at an earlier stage would publish a guess as a fact.
- `daysRemaining` for a vendor **is** exact (`HoldGold / ChargePerDay`), unlike the house. The two
fields must not be documented as though they carried the same confidence — a template that says
"your house collapses on the 4th" and one that says "your vendor is dismissed in 2 days" are making
very different promises.
**Acceptance:** a live run on the local rig (`C:\Users\colby\Desktop\ServUO` + the Rust sidecar, not
the PowerShell stub) shows **(a)** a real decay transition carrying the new fields, **(b)** a seeded
player vendor whose listing carries a fee state that falls as its held gold is drawn down, and **(c)** a
*failed* game login producing `account.login.result accepted:false` where a successful one produces
`accepted:true`; a v4 overlay paired with a v5 sidecar is **refused by the installer**, not mis-parsed;
CI publishes a bundle whose manifest pairs v5 with v5; and the five-rung shard visibility walk still
shows no leak, with `account.login.result` reaching **admin only**.
**Note:** this phase's lead time is a release plus a bundle plus an operator update, which is why it
starts early and lands independently.
---
### Phase 11 — module-uo's triggers and the first real rule
### Phase 11 — module-uo's triggers: the full catalogue, and core's `news.post`
`module-uo` registers `uo.house.idoc_warning` (and siblings), emits from `shardIngest`, and ships the
"greatly damaged" mapping. **The `Greatly` transition mapping needs no protocol change** and can ship
with Phase 6 if Phase 10 is still in flight — the trigger simply omits `nextStage`/`estimatedCollapse`
until the v5 overlay is deployed, which the `required: false` declaration already permits.
**Scope widened 2026-08-31, by the org lead, before any code.** This phase used to say `module-uo`
registers `uo.house.idoc_warning` "(and siblings)", and Part 8's preamble used to say "Phase 11 ships
exactly one rule". Both are now wrong on purpose: **Phase 11 ships every ✅ row in §8.6**, with one
carve-out named below. §8.6 was written as "a catalogue, not a commitment" — it is now the commitment,
and the burden of proof has inverted: a row that does *not* ship needs a reason recorded here.
**Also core's own `news.post` emitter, which this phase's title understates** (§7.1 Q9). `news.post` is
declared with no caller, so a rule naming it can never fire; wiring it is one `ctx.events.emit`-shaped
call in `announceIfNewlyPublished`, gated on the same job-id transition signal the push already uses.
**The announce legs and the post hooks are untouched** — a module's town-crier leg is a delivery to a
channel of the deployment and its news-gump hook is state mirroring, neither of which is a per-person
notification. What the emit replaces is the raw `pushDispatch.publish` beside them, and Q9's continuity
question has to be answered before it does.
**What that is worth, and it is not "more mail".** One trigger proves that a module can register a
trigger. Twenty-odd triggers spanning five audience kinds are the first real test of the things Parts
35 asserted and nothing has yet stressed: that the ceiling lattice holds when most triggers are *not*
public, that `check:modules` still finds no UO identifier in core when the module is this large, and
that a player's preferences screen stays legible when the catalogue is real rather than a demo. Every
rule ships `enabled = 0` (Q3), so this is a catalogue an operator turns on, not a switch that floods
anyone.
**Acceptance:** the five-rung shard visibility walk still shows no leak; a house transitioning to
**The carve-out: `uo.market.item_listed` does not ship here**, for the reason §8.6 already gives — it
is a *saved search*, not a trigger. Its audience is "users whose stored query matches this listing",
and no per-user query store exists anywhere in the tree. It is its own workstream on top of this one.
`vendor.listing` remains ingested; nothing is lost by waiting.
**Two rows ship only because Phase 10 does.** `uo.house.idoc_warning`'s enrichment and
`uo.vendor.expiring` both need v5 fields; `uo.account.login_attempt` needs v5's new
`account.login.result` emitter and is renamed **`uo.account.login_failed`** to say what it actually
is. Everything else is mapping-only and can ship whether or not the bump has landed — the flagship's
`Greatly` mapping included, which **needs no protocol change** and can ship with Phase 6 if Phase 10
is still in flight, the trigger simply omitting `nextStage`/`estimatedCollapse` until the v5 overlay is
deployed, exactly as the `required: false` declaration already permits.
**The set, grouped by the audience kind each family exercises** — which is the point of grouping them
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` |
| **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`, `uo.link.requested` | `owner`, ceiling `owner` | `uo.link.requested` is also the linking funnel: a player ran `[link` in game, finish it on the site |
| **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 |
| **Social / civic** | `uo.guild.joined`, `uo.guild.left`, `uo.guild.disbanded`, `uo.governor.elected`, `uo.election.opened` | `members`, `subscribers` | `uo.election.opened` carries `autoPickAt` — a real deadline, so it is the first trigger whose template has a genuine call to action with an expiry |
| **Come online now** | `uo.champ.started`, `uo.champ.boss_up`, `uo.server.up`, `uo.server.down` | `subscribers` | **`uo.server.up`/`down` is the cooldown table's stress test** — a flapping shard emits both repeatedly. Hard per-rule cooldown, not a per-send one |
| **Leaderboard** | `uo.points.rank_changed` | `subscribers`, `owner` | Fires both ways (you entered a top N; you were pushed out) |
| **Staff-facing** | `uo.page.new`, `uo.cheat.detected`, `uo.audit.staff_action` | ceiling `staff` / `admin` | These are why the ceiling exists. Phase 3 already filters a `staff`-ceiling trigger out of a player's catalogue *and* gates it on write, so this family is the production proof of that work rather than new mechanism |
| **Operator-facing** | `uo.economy.milestone`, `uo.world.saved` | ceiling `admin` | Digest-shaped by nature; neither should ever be instant |
**Also core's own `news.post` emitter, which this phase's title has always understated** (§7.1 Q9).
`news.post` is declared with no caller, so a rule naming it can never fire; wiring it is one
`ctx.events.emit`-shaped call in `announceIfNewlyPublished`, gated on the same job-id transition signal
the push already uses. **The announce legs and the post hooks are untouched** — a module's town-crier
leg is a delivery to a channel of the deployment and its news-gump hook is state mirroring, neither of
which is a per-person notification. What the emit replaces is the raw `pushDispatch.publish` beside
them, and Q9's continuity question has to be answered before it does.
**On the size of this phase.** It is now the largest in the workstream: ~23 trigger declarations, their
audiences and ceilings, their `shardIngest` mappings, and a seeded template each — against Phase 5a's
nine seeded bodies in total. It will likely want to land as **11a (declarations, mappings, ceilings —
server only) / 11b (the seeded templates and the live walk)**, on the 4a/4b and 5a/5b precedent, and
that split should be confirmed with the org lead at the start of the phase rather than assumed here.
**Acceptance:** the five-rung shard visibility walk still shows no leak, and **no staff- or
admin-ceiling trigger appears by name in a player's preferences catalogue**; a house transitioning to
`Greatly` on the live rig produces one email to the linked owner and nothing to anyone else; a second
transition inside the cooldown produces nothing; a refresh back to `LikeNew` inside the delay window
cancels the pending mail; **a news post published on the rig reaches a rule, and the town-crier leg and
every registered post hook still fire exactly as they did.**
**Guardrails:** `check:modules` proves core gained no UO identifier across every phase to this point.
cancels the pending mail; a player vendor drawn down to its last day produces one `uo.vendor.expiring`
to its owner; a failed game login produces one `uo.account.login_failed` and a **successful** one
produces none; a shard restarted three times in a minute produces **one** downtime mail, not six; and
**a news post published on the rig reaches a rule, with the town-crier leg and every registered post
hook still firing exactly as they did.**
**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 twenty-odd UO-named triggers.
---
@@ -3370,9 +3461,11 @@ Nothing here is "documentation to do at the end" — a phase is not done until i
## Part 8 — What the system could be used for
A catalogue, not a commitment. Its purpose is to check that the design in Parts 35 is general enough,
and to show what an operator gets for the phases they pay for. **Nothing here is scheduled**; Phase 11
ships exactly one rule.
Written as a catalogue, not a commitment to check that the design in Parts 35 is general enough, and
to show what an operator gets for the phases they pay for. **§8.6 is no longer that.** On 2026-08-31 the
org lead scheduled **every ✅ row of §8.6 into Phase 11**, so for the `module-uo` section the burden of
proof has inverted: a row that does not ship needs a reason recorded in the phase. The rest of Part 8 —
§8.18.5 and §8.7 — remains unscheduled and stays a catalogue.
Read the availability column as: **✅** the data exists today and a rule could be written the day the
engine lands · **⚠** the data exists but needs a mapper or a resolver · **❌** needs new plumbing
@@ -3464,16 +3557,21 @@ which is the one legitimately campaign-shaped use and the one most likely to be
### 8.6 Module events — `module-uo`, grounded in what the shard actually emits
Every kind below is real (`docs/link/INTEGRATION.md`). The trigger id is what `module-uo` would register;
core stays ignorant of every word in it.
Every kind below is real (`docs/link/INTEGRATION.md`). The trigger id is what `module-uo` registers;
core stays ignorant of every word in it. **Every ✅ row here is Phase 11 scope** except
`uo.market.item_listed`, carved out there with its reason.
**Three rows were corrected on 2026-08-31**, when the set was scheduled and each claim was checked
against the emitters rather than against this table. Two of the corrections are what widened Phase 10
from one wire enrichment to three; the third is a caveat that has to reach the operator, not a defect.
| Trigger | Wire source | Data | Why anyone cares |
| --- | --- | --- | --- |
| `uo.house.idoc_warning` | `house.decay` → `Greatly` | ⚠ mapping only (the enrichment is Phase 10) | **The flagship.** Your house is decaying; log in or lose it and its contents |
| `uo.house.collapsed` | `house.decay` → `Collapsed`, `house.remove` | ✅ | The bad news, so it is not a surprise |
| `uo.vendor.sale` | `vendor.sale` | ✅ (`ownerAcct` → linked user) | Your player vendor sold something, for how much |
| `uo.vendor.expiring` | `vendor.listing`, vendor fees | ⚠ | Your vendor is about to be dismissed for unpaid fees |
| `uo.account.login_attempt` | `account.login.attempt` | ✅ (already a personal stream) | Someone tried to log into your game account, from where |
| `uo.vendor.sale` | `vendor.sale` | ✅ (`ownerAcct` → linked user) — **but patch-tier only**, see below | Your player vendor sold something, for how much |
| `uo.vendor.expiring` | `vendor.listing` + **v5 fee fields** | ⚠ → ✅ **after Phase 10**; `vendor.listing` carries no `ownerAcct` and no fee state today | Your vendor is about to be dismissed for unpaid fees |
| `uo.account.login_failed` | **v5 `account.login.result`** | ⚠ → ✅ **after Phase 10**; the existing `account.login.attempt` is pre-decision, see below | Someone tried to log into your game account, and failed, and from where |
| `uo.character.death` / `murdered` | `player.death`, `player.murdered` | ✅ | Opt-in; a killfeed some players want and most do not |
| `uo.skill.capped` | `skill.gain` where `base == cap` | ✅ | You hit the cap in a skill — a genuine milestone |
| `uo.quest.complete` | `quest.complete` | ✅ | Milestone / achievement mail |
@@ -3494,7 +3592,25 @@ core stays ignorant of every word in it.
| `uo.economy.milestone` | `economy.supply` | ✅ | Operator-facing; economy health thresholds |
| `uo.world.saved` | `world.save.after` | ✅ | Operator-facing only; world-size trend |
**Three of these are worth calling out as design pressure on Parts 35:**
**Three rows as originally written were wrong, and finding out cost one sweep of the emitters:**
- **`uo.vendor.sale` is real and does carry `ownerAcct` — but it lives in `servuo-plugins/patches/`,
the opt-in patch tier**, verified only against ServUO 57.4. It is a `PlayerVendorSale` EventSink the
patches *add* to core plus a subscriber that reads it, which is why it is absent from `overlay/`. A
shard that declined the tier, or runs another ServUO version, emits this kind never — so the rule is
silently dormant there rather than broken. Phase 10 deliberately does **not** try to move it into the
overlay; the obligation is that the seeded rule and the operator docs say which tier it needs.
- **`uo.vendor.expiring` had no data at all, not merely no mapper.** `vendor.listing` carries
`ownerSerial` and `ownerName` — no `ownerAcct`, so it cannot even be addressed to a website user —
and nothing anywhere on the wire carries a vendor's held gold or daily charge. Phase 10 adds all four.
- **`uo.account.login_attempt` could not have been built as described.** `EventSink.AccountLogin` is a
pre-decision veto hook; the emitter's own comment says "Fires before the auth decision, so this is an
attempt, not a result". A rule on it would have mailed "someone tried to log into your account" every
time the player logged in successfully — the exact inversion that makes people distrust security
mail. Phase 10 adds a post-decision `account.login.result`, and the trigger is renamed
**`uo.account.login_failed`** so its id cannot be misread again.
**Three others are worth calling out as design pressure on Parts 35:**
- **`uo.market.item_listed` is a *saved search*, not a plain trigger.** The audience is "users whose
stored query matches this listing", which `audience: 'computed'` covers but a per-user query store