Skip to content

RPC and Protobuf

Kurt edited this page Jul 19, 2026 · 8 revisions

RPC and Protobuf

Audience: Engine Developer Status: ✅ Ready

The services talk to each other over gRPC with Protocol Buffers, generated by buf. Four proto packages define the whole inter-service surface. A guiding principle runs through them: the wire types enumerate no game concept — stats, resources, and items are maps of content-defined keys, so the protocol never needs to change when a pack invents a new attribute.

Related: Edge & Protocol (the Play stream), Accounts & Auth Internals (the Account service), Cross-Shard Handoff (the Handoff service).

The four proto packages

api/proto/telosmud/{common,play,account,handoff}/v1/:

  • common/v1 — shared value types, no game concept baked in: CoreStats{map<string,sint64> attributes}, Vitals{map<string,Resource>}, Resource{cur, max}, Item{proto_ref, delta_json, persist_id, contents[]} (the flyweight + copy-on-write delta on the wire), Equipped{slot, item}, Affect{id, duration_pulses, magnitude, stacks, source}, SkillState{id, proficiency}.
  • play/v1 — the Play service and its frames (Edge & Protocol).
  • account/v1 — the Account service.
  • handoff/v1 — the Handoff service (imports common).

The Account service

telos-account is the only service that touches OAuth and credentials; the gate calls it, and the world never does on the hot path (it trusts the signed session assertion). RPCs:

RPC Purpose
ListCharacters the character-select menu
ReserveName unique (CITEXT) name check/reserve
CreateCharacter create from bundle refs (race/class/background)
IssueSessionAssertion mint the Ed25519 {account, character, session, exp} token the gate carries in Attach (empty when signing is unconfigured); the response also returns a manage_tiers visibility bit the gate uses to hide promote/demote from non-staff (resolved even without a signing key, fail-safe to false)
StartDeviceAuth begin the brokered OAuth device flow → {device_code, verification_uri, expires_in, interval}
PollDeviceAuth {status, account_id, characters[]}, status ∈ pending/authed/expired
GetChargenFlow the content-driven chargen flow the gate walks as prompts
CreateChargenCharacter validate a prompt-driven submission (the point-buy cost curve stays server-side)
SetAccountTier promote/demote — authorization is enforced here (the actor's tier is read from the authoritative store; admin-only; audited; empty new_tier is the demote-to-baseline sentinel)
GetAccountPrefs / SetAccountPrefs cross-session edge prefs (color); each field is proto3 optional for an absent/false/true tri-state; self-scoped, non-privileged only — a future privilege-bearing pref must not ride this message

See Accounts & Auth Internals and Trust Tier Model.

The Handoff service

Cross-shard migration, two-phase (Prepare/Commit) plus an ownership epoch to preserve the single-writer invariant under failure:

RPC Purpose
Prepare rehydrate the player on the destination in a pending state; returns {handoff_token, target_shard_addr, pending_ttl_ms}
Commit activate after the gate re-attaches. Signed + destination-bound on a keyed shard (below)
Abort roll back on failure/timeout. Signed + destination-bound on a keyed shard (below)
AdoptZone graceful-drain helper: build-only host + start renewal so the caller can then flip the lease and hand players off; idempotent. Signed on a keyed shard (below)

AdoptZone is authenticated and fenced. A keyed shard once verified the signature on Prepare while adopting a zone on a wholly unauthenticated AdoptZone — so anyone with network reach to a world's gRPC port could force it to build and run an arbitrary zone, then race the lease. The request is now signed with the shared cluster keypair over a digest binding zone_id, the destination shard, and lease_gen — the zone lease's monotonic generation, as the source observed it while still holding the lease. The destination checks that value against the directory's current generation, and the source's own lease flip increments it. So a captured request is neither transferable to another shard nor usable once the handover it authorized has landed: a replay is unrepresentable, not merely time-bounded. (issued_at_unix_ms with a 60s skew window was the earlier, weaker design; the field is deprecated and unread.) The destination verifies the signature before reading the directory, so unauthenticated traffic can never amplify into load on the shared Redis. from_shard_id rides the digest as an audit subject, not an authorization input — the generation match already proves the owner has not changed. As a correctness (not security) precondition, the destination also requires from_shard_id to be the zone's live owner and refuses before building the zone, so a misnamed source can't leave an orphan; that check is free because it reads the owner in the same directory round-trip the fence already makes. The keyless path keeps its refusal, and TELOS_ALLOW_INSECURE cannot loosen a keyed shard. This matters because the gRPC mesh is plaintext — its trust is entirely app-layer.

Commit/Abort are signed and destination-bound too. The two phase-2 messages were once authenticated only by the handoff_token — but the token is deterministic (sha256(character/epoch)) over public inputs (a name via who, the epoch a small int), so anyone with network reach to a world's gRPC port could recompute it and Abort a player mid-handoff; the private port was the only barrier, the same trusted-network assumption AdoptZone above removed. Each phase-2 message now carries an Ed25519 signature over a digest binding the token and the destination shard id under a per-operation domain separator, checked against the receiver's own id. Binding the destination is load-bearing: on the plaintext mesh a signed Abort captured in flight could otherwise be replayed against a second destination in a split-brain same-epoch handoff — both derive the identical token — discarding the winner's live pending. The same-destination replay is a pre-existing token-idempotency interaction (tracked separately). A keyless shard keeps the same refusal as Prepare/AdoptZone, so there is no unsigned phase-2 path.

The Prepare request carries a PlayerSnapshot — the authoritative in-memory state so the destination resumes with zero DB round-trips — plus epoch, from_shard_id, and snapshot_sig (Ed25519 over a canonical digest binding character/epoch/target/state, verified when the destination has a key). The snapshot includes state_version (the optimistic-concurrency base), applied_seq (the freeze-point input high-water that seeds the destination's dedup watermark), persist_id (so the destination CASes the same durable row), the comms subtree, the remaining state JSON, and tier — the account trust tier, which is the secure replacement for carrying reserved capability flags across the wire (it's trusted only because snapshot_sig binds it). The optional claims (tier, account) are folded into the digest unconditionally rather than appended-when-non-empty: with two such fields, an append-if-non-empty encoding made digest(tier="", account=X) and digest(tier=X, account="") byte-identical, a field-confusion collision an on-path attacker could exploit on the plaintext mesh. See Cross-Shard Handoff and Trust Tier Model.

The buf toolchain

  • buf.yaml (v2): one module at api/proto; lint STANDARD with a few documented exceptions for the single-bidi-stream Play service and the intentional non-*Service service names; breaking-change check FILE.
  • buf.gen.yaml (v2): managed mode on, go_package_prefix = github.com/double-nibble/telosmud/api/gen; plugins protocolbuffers/go and grpc/go, both emitting to api/gen with paths=source_relative.
  • Generated *.pb.go is gitignored. Run buf generate (via make proto) on a fresh checkout before building; never stage api/gen. Generated code lands at api/gen/telosmud/<pkg>/v1/.

Clone this wiki locally