Skip to content

refactor(gateway): call each reducer from its Store in one hop - #645

Merged
Mathih13 merged 39 commits into
mainfrom
refactor/coordinator-one-hop
Oct 10, 2026
Merged

Mathih13 merged 39 commits into
mainfrom
refactor/coordinator-one-hop

Conversation

@Mathih13

Copy link
Copy Markdown
Contributor

A Gateway handler reached a reducer through three forwarding layers: the family trait, the WorldStore adapter block, and an inherent wrapper in stdb/reducers.rs. 75 wrappers took an account id they never read, 76 copied a zero-guid guard, and five handlers told a Refusal from a lost connection by matching the text "reducer transport disconnected". That text appears only when the pipe was already down. A connection lost during the call, a 10 s timeout or a send failure counted as a silent Refusal, and the World Session went on.

This is phase 4 of #635.

Shape

  • Each family's Coordinator impl in gateway/src/stdb/store/<family>.rs calls its reducer directly. stdb/reducers.rs went from about 4,950 lines to 113 and keeps only the calls no Store owns. stdb/world_store.rs is gone; RealmDb lives in stdb/realm_db.rs.
  • Actor(NonZeroU64) replaces (account_id, actor_guid) on every Store method that acts for a Character. The handler resolves it once, and the zero-guid guard exists only in Actor::new. Calls that mean the session owner on purpose use owner_actor().
  • classify(&error) returns Refusal { reason } or TransportLoss. Every handler uses it, and Fakes return typed ReducerCallError::refused or transport_lost.

Behaviour change (approved: fail closed)

  • Only a Module Refusal (ReducerCallError::Rejected) is a Refusal. A timeout, a lost or failed send, or a failed cache read is a Transport Loss and ends the World Session, and the client relogs from durable state. Before, vendor, quest, guild, chat, channel, cast, melee, mail, bank, trainer, meeting stone and others swallowed these as a silent Refusal.
  • Every client answer to a Refusal is unchanged. A user error that the Gateway finds itself, such as an unknown Account name in a GM command, still reaches the GM as a system line.
  • Taxi: a Module Refusal on status, open or activate now answers and keeps the session, where before it ended it.
  • Bank slot purchase: the old code parsed the reducer's display text, which never matched, so every refusal showed "not a banker". It now decodes the real reason.

Tests

  • Each family has a test where a Transport Loss ends the session and a Refusal answers the client. Families that decode in the adapter test the pure decoder with typed errors.
  • Proof: no "transport disconnected" outside stdb/connection.rs, no _account_id in gateway/src/stdb, no actor_guid == 0.

Glossary: adds Transport Loss; Actor now names the type.

Main's #643 Gateway changes (bind home, talent wipe, at-war, item split and destroy) are merged and expressed in this shape.

Local gates: fmt, clippy with -D warnings, the five crate test commands and the wasm check pass.

🤖 Generated with Claude Code (Claude Opus 5.5 lead, with Opus 5.5 and Sonnet 5.5 subagents)

Mathih13 and others added 30 commits October 10, 2026 17:46
Add `DurableFailure` and `classify`: only a reducer the Module rejected
is a Refusal, and every other error is a Transport Loss. Fakes build
both through `ReducerCallError::refused` and `transport_lost`.

Add `Actor(NonZeroU64)`. `session_actor` takes an `Actor`, the
deliberate guid-0 fallbacks call `owner_actor`, and the remaining raw
guid call sites go through `actor_or_owner` until their families take
an `Actor`.

`request_gm_command` now uses `call_reducer!`. The GM handler renders
the Refusal reason verbatim and ends the World Session on a Transport
Loss.

Add Transport Loss to the glossary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… store file

Code motion only. Each `impl <Family> for Coordinator` that still sat in a
world handler file moves to `stdb/store/<family>.rs`, together with the
inherent reducer calls and Durable Reads only that family calls and the
family's Refusal decoder. Inherent methods with other callers (relays,
logon, realm-core routing, Transfer intents) stay in `reducers.rs` and
`reads/`.

Bodies are unchanged. Paths, imports and the visibility of the few
helpers two families share are adjusted so the moved code resolves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Covers the loot window dispatcher, the loot roll and death arms of
handle_loot, and HandleLootFake, which serves all three families.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…failures

Inline the melee Coordinator wrappers into the trait methods, drop the unused
account id and the zero-guid guards, and classify start and stop failures with
classify. A Transport Loss now ends the World Session.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…failures

Inline the quest Coordinator wrappers into the trait methods, drop the unused
account id and the zero-guid guards, and replace the transport-text classifier
with classify. A failed interaction-gate read is a Transport Loss.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Inline the single-caller Coordinator wrappers into the VendorActionStore
methods, drop the unused account id, take Actor, and classify failures
with the typed Refusal and Transport Loss split.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Inline the single-caller Coordinator methods into the AuctionActionStore
methods, take Actor in the requests, resolve Hold actors once, and decode
Refusals with classify.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Inline the chat inherent wrappers into the Coordinator impl, take Actor on
realm_chat, realm_whisper and set_away, and replace the message-text
classifier with classify. Add decoder tests for chat_outcome.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… Loss

Inline the channel inherent wrappers, take Actor on channel_op, and drop the
recoverable read path: a failed target or roster read ends the World Session.
Add decoder tests for channel_outcome.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Inline the hold, decide and finish wrappers, drop CharterOwner, and type the
test realm's failures.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Inline the guild inherent wrappers, take Actor on guild_op and
guild_destroy_charter, extract guild_outcome, and replace is_fatal with
classify.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Inline the item Coordinator wrappers into the trait methods and drop the
unused account id. The handler answers an internal-error Refusal when the
session has no Actor, as the Store did before. The Fake models a Transport
Loss with a typed error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ailures

Inline the cast Coordinator wrappers into the trait methods, drop the unused
account id and the zero-guid guards, and replace is_transport_failure with
classify. Requests the Gateway rejects before the Module (no item target, no
Actor) answer the client as a Refusal. A Transport Loss while stopping a ranged
loop now ends the World Session.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… an Actor

Each family's Coordinator methods now carry the reducer call inside the trait method, the
unused account ids are gone, and a Character acts as `Actor`. Handlers resolve the session's
Actor once; outside the world the contact ops and the addon bridge drop silently, and a
Character delete for guid 0 answers Failed. Decoders classify with `classify`: a Refusal
answers the client as before, a Transport Loss ends the World Session. Character create and
delete and the addon bridge now end the session on a Transport Loss instead of answering
Failed or dropping it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`begin_transfer` takes the Character it freezes as an `Actor`, resolved once from the plan in
`run_transfer`, so the owner fallback is gone. The forwarding wrappers whose only caller was
the trait method now live inside it; the escrow steps the account-session test drives keep
their inherent methods.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ansport Loss

Every mail Store step that acts for a Character takes an `Actor`; the trait methods carry the
reducer calls, and the owner fallback is gone. Mail ops answer `MailFailure`: a Refusal from a
Gateway Gate or the Module answers the client as before, and a Transport Loss ends the World
Session. A drive the transport lost mid-escrow, a cache read that failed and an exhausted
escrow id range used to answer an internal error and keep the session; they now end it, and
the next mailbox visit or login re-drives the fence. The mail Fake answers typed Refusals.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both families are read-only. The trait methods now hold the cache reads
that only they called. Neither family has a Durable Request, so neither has
a Refusal or Transport Loss answer: a failed read still shows fine weather
or skips one Member Stats tick.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Inline the two bank reducer wrappers into the trait, drop the unused account
id, and resolve the Character once in the handler. A Refusal answers as
before. A Transport Loss now ends the World Session. The buy-slot answer
reads the Module's reason, not the reducer error text, so the bracketed code
decodes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…failures

Inline the four Meeting Stone wrappers into the trait and replace the text
match on the transport message with classify. Add a test for the adapter
decoder with typed errors.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Inline the taxi wrappers into the trait and remove the zero-guid and owner
fallbacks. A Refusal on a query or an activation answers with no packet or
the server-error reply. A Transport Loss, or any failure to arm a committed
flight, ends the World Session.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mathih13 and others added 7 commits October 10, 2026 18:56
Inline the trainer wrappers into the trait and drop the unused account id.
Per-action requests (action bar, at-war, talents) log a Refusal and end the
World Session on a Transport Loss.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Inline the NPC wrappers into the trait and drop the unused account id. The
query handler resolves the Character once. Gossip, bind home, inspect, text
emote and area trigger log a Refusal and end the World Session on a
Transport Loss. The handler's speech arms and their tests ride in this
commit because they share the file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Inline the speech wrappers into the trait. The GM dot-command keeps two
sides: an unknown Account, which the Gateway finds itself, is a Refusal the
GM reads as a system line. Realm-core being unreachable is a Transport Loss
and ends the World Session.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…inator-one-hop

# Conflicts:
#	gateway/src/world/handle_loot_fake.rs
…mily conversions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Drop the temporary owner fallback: the Realm index reducers act for the
Character they move, and the GM command takes the Actor its handler
resolved. RealmDb for Coordinator moves to stdb/realm_db.rs and calls its
Realm-only reducers directly; world_store.rs is gone and reducers.rs keeps
only the calls no Store trait owns. item_slot_by_guid loses its unused
account id. Session tests assert classify gives TransportLoss instead of
matching the transport text. The opcode recipe, architecture doc and
Actor glossary entry describe the one-hop shape.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in the Benilla protocol handling and the weak-test removal. Main's new
Store methods take the one-hop shape: split_item and destroy_item live in the
item family's Coordinator impl, bind_home, reset_talents, set_faction_at_war
and talent_reset_cost in the npc and trainer impls. They take an Actor, and
InteractionOutcome decodes Refusals through classify.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor Author

Code review: Standards and Spec

Reviewed main...ab718dcb (37 commits, 152 files) on two separate axes. The spec is #635, phase 4. Line numbers are for files at the head commit.

Most claims in the description are true: reducers.rs is 113 lines, world_store.rs is gone, and classify (stdb/connection.rs:578) downcasts to ReducerCallError. No "transport disconnected" text, _account_id or actor_guid == 0 is left. No wire, schema or Module files changed. The bank slot-purchase fix is correct.

Standards

Breaches of documented standards

  1. Medium. The Actor glossary entry has drifted. CONTEXT.md:219 ends "into a SessionActor" and CORE_TERMS.md:47 ends "into the SessionActor a reducer receives". AGENTS.md and CORE_TERMS.md require the two to match.
  2. Medium. New prose uses "database" to mean a shard, which is an Avoid word under Shard. See store/loot_roll.rs:14, the pending_local_rolls doc, the unsharded-database comments in store/channel.rs, chat.rs and guild.rs, and the heartbeat doc in reducers.rs.
  3. Low. New names use Avoid words: "reject" (under Refusal) in the a_rejected_* tests in connection.rs, in the decoder tests and in fn rejected (loot_window.rs:487), and "fatal" (under Transport Loss).
  4. Low. About 139 added comment lines have em dashes, which the unslop skill bans. Most of them moved over from reducers.rs. History narration remains in the pending_local_rolls doc: "this table is now part of the coordinator subscription list".

Smells (judgement calls)

  1. Medium, Duplicated Code. The pattern "classify, then parse_tag(reason), then Err(error)" repeats in store/item.rs:88, loot_roll.rs:205, social.rs:256, auction.rs:1072 and party.rs:548. party.rs:548 also duplicates reducer_refusal_reason (connection.rs:586). One generic helper next to classify would replace all of them.
  2. Medium, Middle Man. In realm_db.rs, about 14 trait methods only forward to the inherent method of the same name. Move the bodies into the impl.
  3. Medium, Primitive Obsession. InWorld.self_guid stays a u64, so handlers call Actor::new about 20 times (guild.rs:1659/1677/1717/1788, party.rs:1076/1363/1403/1509, mail.rs:704, taxi.rs:115, ...). Each of those callers decides what guid 0 means. If InWorld stored an Actor, the conversion would happen once.
  4. Medium, Data Clumps. The three finish_*character_shard_transfer bodies in realm_db.rs pass the same source and destination tuple.
  5. Low. store/character.rs:431,434 still decides on reason.contains("NAME_IN_USE") and "SERVER_LIMIT" instead of parsing a tag.

Spec

Wrong implementation

  1. High. A rejected taxi ack now keeps the session after the flight has committed. In store/taxi.rs, gw_ack_taxi_reply runs after gw_activate_taxi commits. If the Module refuses the ack, classify returns Refusal, and or_refused (world/handlers/taxi.rs:46) answers ACTIVATE_UNSPECIFIED_SERVER_ERROR and keeps the session. The flight stays committed but is never armed. The comment there still says a failed ack "remains session-fatal". Either map an ack failure to Transport Loss, or apply or_refused only to the activate call.

  2. Medium. "Fail closed" covers more than transport. classify treats every untyped error as a Transport Loss, so local Gateway failures now end the World Session even when nothing durable is in doubt. Examples:

    • next_escrow_id bails with "not claimed" or "exhausted" (world/mail.rs:908,913) before any durable write.
    • mail_escrows_of (store/mail.rs:436) drops an escrow row whose header fails to decode, so held_fence times out.
    • auction.rs:230 returns "not in world".

    Confirm that the approval covers these cases, or make them typed Gateway Refusals.

Missing or partial

  1. Medium. Spec: "Delete the inherent reducer wrappers that only forward." In store/transfer.rs:107-131, trait methods still forward to inherent wrappers at :327-390, and only a test in account_sessions.rs:600-623 uses them. realm_db.rs:45-52 (establish_session) also forwards to reducers.rs:97.
  2. Medium. Spec: "one test per family shows a transport loss and a Refusal produce different outcomes." The transfer family has no such test.
  3. Low. guild.rs:1699 (run_best_effort, sign-on at world entry) logs and continues and does not call classify.

Not in the spec

  1. Low. realm_core.rs:163 (unknown_account) builds a ReducerCallError::Rejected for a check the Gateway makes itself, so the reducer error type now also carries Gateway Refusals.
  2. Low. The cache reads moved from stdb/reads/* into the Store files. The move is harmless, but it makes the diff larger.

Summary: Standards has 9 findings. The worst is the Actor entry that differs between CONTEXT.md and CORE_TERMS.md, plus the Avoid-word use. Spec has 7 findings. The worst is the rejected taxi ack, which keeps the session with a committed but unarmed flight.


Generated by Claude Code

Mathih13 and others added 2 commits October 10, 2026 19:29
An activation commits before its reply is acknowledged, so answering a later Refusal with a server
error would leave a durable activation and no flight.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
presence::of now wraps a failed absence vouch in PresenceUnknown. Whisper,
channel targeted ops, guild invite, petition offer and .guild create answer
it as they did before the one-hop change. A real Transport Loss still ends
the session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Mathih13
Mathih13 merged commit 1614067 into main Oct 10, 2026
9 checks passed
@Mathih13
Mathih13 deleted the refactor/coordinator-one-hop branch October 11, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant