Skip to content

04 — Social Library & Service (lattice-social)

Part of the Lattice networking suite design set. Read 00-overview.md and 01-high-level-design.md first for vocabulary and the data-plane / control-plane split. This document specifies the social layer: friends, presence, parties, invites, and the optional chat/activity features — and, critically, how it ships both as a standalone library and as a deeply integrated part of the suite.

Cross-references: 02-netcode-lld.md (connect/session flow), 03-auth-service.md (identity & tokens), 05-engine-integration.md (binding strategy), 06-implementation-roadmap.md (delivery phasing).


1. Goals & Value Propositions

lattice-social is a standalone-first social SDK + backend. It is designed so a team can drop friends/presence/parties into any product — even one that uses none of the rest of Lattice — and also so that, when the full suite is present, social state lights up the netcode (join-a-friend's-game, party-to-matchmaking, rich presence with joinability).

1.1 Standalone mode

A product brings its own identity (BYO JWT verified against a configured JWKS, or a simple API-key + user-id model) and gets:

  • Drop-in social graph: friends, requests, blocking/mute, search-by-handle.
  • Realtime presence (online/offline/away/in-game) with a free-form rich-presence payload.
  • Parties/groups with leadership, invites, and membership lifecycle.
  • Invites (to friend / to party / to an arbitrary opaque "session" token the host product defines).
  • Optional pluggable text chat and activity feed.
  • Engine-agnostic SDK exposed via the same binding strategy as the netcode core (Unity, Unreal, Godot, web) — one mental model across the whole suite.

Value prop (standalone): ship a production-grade friends system in days, not quarters, with no opinion about your identity provider, your game engine, or whether you even have multiplayer.

1.2 Integrated mode (full Lattice suite present)

When deployed alongside the rest of the suite, additional capabilities switch on automatically:

  • Zero-config identity via lattice-auth tokens (no JWKS to wire up).
  • Rich presence carries joinability: a friend's presence advertises a resolvable session.
  • "Join friend's game": presence/party → resolve a session id via lattice-director → hand off to the netcode connect flow in lattice-core (see 02-netcode-lld.md).
  • Party → matchmaking: a party leader queues the whole party through lattice-director.

Value prop (integrated): the social graph becomes the front door to multiplayer — friends, parties, and "join game" all resolve to real netcode sessions with no glue code.

1.3 Non-negotiable constraint

No hard dependency on the rest of the suite. lattice-social must compile, deploy, and run with only PostgreSQL + Redis (NATS optional). All suite integrations are capability-gated behind configuration: if lattice-auth / lattice-director are not configured, the corresponding features are simply absent — they never become a runtime requirement.

1.4 Non-goals

  • Not a general chat platform (chat is intentionally minimal/pluggable; bridge to a dedicated provider for heavy use).
  • Not a moderation/trust-and-safety product (we expose hooks, not a review console).
  • Not an identity provider (that is lattice-auth; standalone mode delegates to BYO identity).

2. Feature Set

Domain Capabilities
Friends list List friends, paginated; relationship status; mutual-friend hints (optional).
Friend requests send, accept, decline, cancel; idempotent; reciprocal-request auto-accept.
Blocking / mute Block (hard: hides presence both ways, removes friendship, blocks invites/requests); mute (soft: suppresses chat/notifications, keeps friendship).
Presence States online / away / busy / dnd / in-game / offline; multi-device sessions (explicit busy/dnd override the auto aggregate); free-form rich presence payload (game id, map, party, joinability, session handle).
Parties / groups create, invite, join, leave, kick, transfer-leader, set-privacy; max-size policy; auto-disband on empty. Lobby gating: invite/join blocked (403) when host is busy/dnd or lobby is Private. Lobby presence override: members mirror the host's status to their friends while in the lobby.
User notes Private, author-only note about another user (set / get / delete); surfaced inline in the author's friend list; never visible to anyone else.
Invites To friend (friend request), to party, to game session (opaque token in standalone; director-resolvable in integrated mode). TTL + accept/decline/expire.
Activity feed (optional) Append-only per-user feed (achievements, "started playing X", "joined party"); fan-out to friends.
Text chat (optional/pluggable) 1:1 DM and party channels via WSS; pluggable backend (built-in store, or bridge adapter to an external chat provider).
Search / find Find user by exact handle/discriminator; prefix search (rate-limited, privacy-gated).

Optional features are build/deploy flags — they add tables and WS events only when enabled, so a minimal deployment stays lean.


3. Architecture

flowchart TB
  subgraph Clients["Game clients (engine-agnostic)"]
    SDKU["lattice-social SDK (Unity)"]
    SDKE["lattice-social SDK (Unreal)"]
    SDKG["lattice-social SDK (Godot)"]
    SDKW["lattice-social SDK (web)"]
  end

  LB["Load balancer / TLS term."]

  subgraph SocialTier["lattice-social (stateless nodes, 'act as one')"]
    N1["Node A : REST + WSS"]
    N2["Node B : REST + WSS"]
    N3["Node C : REST + WSS"]
  end

  subgraph Backbone["Fanout backbone"]
    REDIS["Redis (presence TTL, Pub/Sub)"]
    NATS["NATS (optional, durable fanout)"]
  end

  PG[("PostgreSQL (social graph, parties)")]

  subgraph SuiteOptional["Optional suite integrations (capability-gated)"]
    AUTH["lattice-auth (8443) : token + JWKS"]
    DIR["lattice-director (8444) : session resolve / matchmaking"]
  end

  SDKU & SDKE & SDKG & SDKW -->|"HTTPS (CRUD) + WSS 9443 (realtime)"| LB
  LB --> N1 & N2 & N3
  N1 & N2 & N3 --> PG
  N1 & N2 & N3 -->|"presence set/TTL + publish"| REDIS
  N1 & N2 & N3 -.->|"durable streams (opt.)"| NATS
  REDIS -->|"subscribe : presence/notify"| N1 & N2 & N3
  N1 & N2 & N3 -.->|"verify token / JWKS (opt.)"| AUTH
  N1 & N2 & N3 -.->|"resolve session / queue party (opt.)"| DIR

3.1 Components

  • Client SDK — thin, engine-agnostic. A small C/C++ portable core (HTTP client + WS client + state cache + reconnection/backoff) wrapped with idiomatic per-engine bindings using the same strategy as the netcode core (see 05-engine-integration.md): P/Invoke for Unity, a UE module for Unreal, GDExtension for Godot, and a WASM/JS build for web. The SDK exposes async calls (friends, parties, invites) and an event stream (presence, notifications, chat). It holds no authority — it is a cache + transport over the backend.
  • lattice-social backend — .NET 8 / C# (Go is the noted suite alternative). Stateless nodes behind a load balancer. Each node serves both the REST/HTTPS API (CRUD: friends, parties, profile, search) and a WebSocket endpoint on WSS 9443 (realtime: presence, notifications, chat). Nodes share nothing locally; all shared state lives in Redis/Postgres/NATS.
  • PostgreSQL — durable system of record: users/profiles, relationships, parties, party members, invites, (optional) chat history and activity feed.
  • Redis — presence sessions (with TTL), the Pub/Sub fanout bus, and ephemeral hot caches (friend-id sets, online sets). Redis is the default fanout backbone.
  • NATS (optional) — drop-in replacement/upgrade for the fanout bus when durable delivery, larger fanout, or cross-region streaming is required.

3.2 "Act as one" — the stateless-node pattern

lattice-social mirrors the authentication service's horizontal model (see 03-auth-service.md): any node can serve any request, including any WS connection. A client's WS may land on Node A while its friend's WS lands on Node C. Cross-node delivery is solved by the Pub/Sub backbone, not by sticky sessions or node-to-node RPC: when Node A updates a user's presence, it publishes to per-recipient channels; each node subscribes for the users currently connected to it and pushes down the relevant sockets. This keeps nodes stateless and independently scalable — add nodes to add capacity; lose a node and clients simply reconnect (the LB routes them to a survivor).


4. Data Model

classDiagram
  class User {
    +UUID userId        "INTERNAL only — never exposed"
    +string publicId    "opaque public id (exposed)"
    +string externalId  "BYO identity subject"
    +string handle
    +int discriminator
    +datetime createdAt
  }

  class UserNote {
    +UUID ownerId    "author (only viewer)"
    +UUID subjectId  "user the note is about"
    +string text
    +datetime updatedAt
  }

  class Profile {
    +UUID userId
    +string displayName
    +string avatarUrl
    +string status  "user-set status text"
    +PresenceVisibility visibility
    +json settings
  }

  class Relationship {
    +UUID id
    +UUID userId      "owner / actor"
    +UUID otherUserId
    +RelationshipState state  "pending | accepted | blocked"
    +RelationshipDir direction "outgoing | incoming"
    +bool muted
    +datetime updatedAt
  }

  class Party {
    +UUID partyId
    +UUID leaderUserId
    +int maxSize
    +PartyVisibility visibility
    +string sessionHandle  "set when in a game (opt.)"
    +datetime createdAt
  }

  class PartyMember {
    +UUID partyId
    +UUID userId
    +PartyRole role  "leader | member"
    +datetime joinedAt
  }

  class Invite {
    +UUID inviteId
    +InviteKind kind  "friend | party | game"
    +UUID fromUserId
    +UUID toUserId
    +UUID targetId    "partyId or null"
    +string targetHandle "session/game token (opt.)"
    +InviteState state "pending | accepted | declined | expired | cancelled"
    +datetime expiresAt
  }

  class PresenceSession {
    +string sessionId  "per-device connection"
    +UUID userId
    +PresenceState state "online|away|busy|dnd|in-game|offline"
    +bool isExplicit   "user-set (busy/dnd) vs auto"
    +string nodeId     "WS node currently serving"
    +datetime lastHeartbeat
    +int ttlSeconds
  }

  class RichPresence {
    +UUID userId
    +string gameId
    +string activity  "e.g. 'In Match — Dust'"
    +UUID partyId
    +bool joinable
    +string sessionHandle "director-resolvable (opt.)"
    +json extra
  }

  class ChatMessage {
    +UUID messageId
    +ChatChannelKind channel "dm | party"
    +UUID channelId  "otherUserId or partyId"
    +UUID fromUserId
    +string body
    +datetime sentAt
  }

  User "1" --> "1" Profile : has
  User "1" --> "0..*" Relationship : owns
  Relationship "1" --> "1" User : otherUser
  Party "1" --> "1..*" PartyMember : contains
  User "1" --> "0..*" PartyMember : memberships
  User "1" --> "0..*" Invite : sent
  User "1" --> "0..*" PresenceSession : sessions
  User "1" --> "0..1" RichPresence : advertises
  Party "1" --> "0..*" Invite : targetOf
  User "1" --> "0..*" ChatMessage : authored
  User "1" --> "0..*" UserNote : authors

4.1 Storage mapping & notes

  • Durable (PostgreSQL): User, Profile, Relationship, Party, PartyMember, Invite, and (optional) ChatMessage + activity feed rows.
  • Ephemeral (Redis): PresenceSession (key presence:{userId}:{sessionId} with TTL) and RichPresence (key rp:{userId}, refreshed on update). A derived online-friends set is cached per user (onlinefriends:{userId}) and rebuilt lazily.
  • Relationship is directional but a single accepted friendship is stored as a matched pair (one row per direction) so each side's view/mute/visibility is independent. state = blocked always takes precedence over pending/accepted on the blocker's side.
  • externalId carries the BYO identity subject (or the lattice-auth subject). The (provider, externalId) pair is unique; userId is Lattice-internal and stable across handle changes.
  • sessionHandle in RichPresence/Party is opaque in standalone mode (the host product defines it) and director-resolvable in integrated mode.

5. Identity Integration

lattice-social authenticates every REST call and WS upgrade with a bearer token, but it is deliberately identity-provider-agnostic. Three modes are supported, selected by configuration:

Mode How it works Use when
lattice-auth (integrated) Verify the suite's Ed25519-signed PASETO/JWT against lattice-auth's published keys (JWKS); subexternalId. The full suite is deployed — zero extra config.
BYO JWT (standalone) Verify a customer-supplied JWT against a configured JWKS URL (issuer + audience checks); a claim mapping yields externalId + handle. The product already has its own auth (Firebase, Auth0, custom).
API-key + user-id (standalone) Server-to-server: the product's backend presents a shared API key and asserts the acting user-id. The SDK never holds the key; the product mints a short-lived social session token. Simple products, server-brokered trust, prototypes.

Key points:

  • First contact provisions a user. On first authenticated request, an unknown (provider, externalId) is auto-provisioned into a User/Profile (handle chosen by the product or claimed by the user). No pre-sync step is required.
  • No hard dependency. Verification is fully local (cached JWKS) — lattice-social never calls lattice-auth on the hot path and runs perfectly with lattice-auth absent.
  • Token scope. Social tokens are independent of netcode session tokens; "join game" still resolves a separate session token via lattice-director (see §8), keeping concerns decoupled.
flowchart LR
  subgraph IdentityModes
    A["lattice-auth token"]
    B["BYO customer JWT"]
    C["API-key + user-id"]
  end
  V["lattice-social : token verifier (local JWKS cache / key check)"]
  A -->|"JWKS from lattice-auth"| V
  B -->|"configured JWKS URL"| V
  C -->|"shared key + asserted user-id"| V
  V --> U["resolve/provision User by (provider, externalId)"]

6. Realtime Presence

6.1 Connection model & heartbeat

  • A client opens a WSS connection to port 9443 after authenticating. The connection is bound to a PresenceSession keyed by (userId, sessionId)multi-device is first-class (a user may be online on phone + PC simultaneously; the aggregate presence is the "most active" state).
  • On connect the node writes presence:{userId}:{sessionId} to Redis with a TTL (default 30s) and records nodeId. The client sends a heartbeat/ping every ~10s; each heartbeat refreshes the TTL. WS-level pings double as liveness.
  • Disconnect / crash: if heartbeats stop, the Redis key expires, and a keyspace-expiry handler (or a lazy read) transitions the device to offline. When the last device session expires, the user is offline and a presence-down event is fanned out.
  • Reconnect: on reconnect (possibly to a different node), the SDK resumes with backoff and re-asserts presence + rich presence; the new nodeId is recorded.

6.2 Fanout across backend nodes

Presence changes must reach a user's friends, who may be connected to any node. We use Redis Pub/Sub (or NATS) as the bus:

  1. Node updates presence:* / rp:* and computes the friend recipient set (cached friends:{userId} id set).
  2. The node publishes the presence delta. Each node subscribes for the users currently holding a socket on it and pushes the event down those sockets.

6.3 Scaling to large friend graphs — fanout-on-write vs read

The core tension is fan-out cost. Two strategies, chosen per user by a degree threshold:

Strategy Mechanism Best for Cost
Fan-out-on-write (push) On a presence change, publish to each online friend's channel immediately. Typical users (small/medium friend lists). O(online-friends) writes per change; instant.
Fan-out-on-read (pull) Don't push to everyone; friends poll/aggregate presence on demand (e.g., when they open the friends list) by reading presence:* for their friend set. Hot users (streamers, very high-degree accounts) where push would storm the bus. O(friends) reads per viewer, only when viewing; no broadcast storm.

Hot-user handling: accounts above a configurable degree threshold (e.g. > 2,000 friends) are flagged hot. Their presence changes are not broadcast; instead viewers fold in the hot user's presence on read (with a short Redis cache, e.g. 5s, to coalesce). This caps worst-case fan-out and protects the bus from celebrity-account thundering herds. Coalescing/debounce: rapid presence flaps (away↔online) are debounced (~2s) before publishing to avoid event storms; rich-presence updates are rate-limited per user.

6.4 Presence update fanout — sequence

sequenceDiagram
  actor U as "User (device)"
  participant NA as "social Node A (U's WS)"
  participant R as "Redis (TTL + Pub/Sub)"
  participant NC as "social Node C (friend's WS)"
  actor F as "Friend (device)"

  U->>NA: "WS: presenceUpdate(state=in-game, rich=...)"
  NA->>R: "SET presence:U:sess (TTL=30s) + SET rp:U"
  NA->>R: "PUBLISH per-friend channels (fan-out-on-write)"
  Note over NA,R: "if U is 'hot' -> skip publish; friends read on demand"
  R-->>NC: "deliver presence delta for friend F"
  NC->>NC: "find F's socket(s) on this node"
  NC-->>F: "WS: friendPresenceChanged(U, in-game, rich)"
  Note over U,NA: "heartbeat every ~10s refreshes TTL; silence -> key expires -> offline"

7. Flows

7.1 Send + accept a friend request

sequenceDiagram
  actor A as "User A"
  participant SA as "SDK A"
  participant SVC as "lattice-social (any node)"
  participant DB as "PostgreSQL"
  participant BUS as "Redis Pub/Sub"
  participant SB as "SDK B (B online)"
  actor B as "User B"

  A->>SA: "addFriend(handle#1234)"
  SA->>SVC: "POST /v1/friends/requests {toHandle}"
  SVC->>DB: "resolve B; check block/limits; upsert Relationship(pending)"
  Note over SVC,DB: "if reciprocal pending exists -> auto-accept both sides"
  SVC->>BUS: "PUBLISH notify:B {friendRequest from A}"
  SVC-->>SA: "201 {requestId, state:pending}"
  BUS-->>SB: "friendRequestReceived(A)"
  SB-->>B: "incoming request UI"
  B->>SB: "accept(requestId)"
  SB->>SVC: "POST /v1/friends/requests/{id}/accept"
  SVC->>DB: "set both Relationship rows -> accepted"
  SVC->>BUS: "PUBLISH notify:A {accepted}  + presence exchange A<->B"
  SVC-->>SB: "200 {state:accepted}"
  BUS-->>SA: "friendRequestAccepted(B) + B's presence"
  SA-->>A: "B now in friends list (with presence)"

7.2 Party create → invite friend → friend joins

sequenceDiagram
  actor L as "Leader"
  participant SL as "SDK L"
  participant SVC as "lattice-social"
  participant DB as "PostgreSQL"
  participant BUS as "Redis Pub/Sub"
  participant SF as "SDK Friend"
  actor F as "Friend"

  L->>SL: "createParty(maxSize=4)"
  SL->>SVC: "POST /v1/parties"
  SVC->>DB: "INSERT Party + PartyMember(L, leader)"
  SVC-->>SL: "201 {partyId}"
  L->>SL: "inviteToParty(partyId, friendUserId)"
  SL->>SVC: "POST /v1/parties/{id}/invites {toUserId}"
  SVC->>DB: "INSERT Invite(kind=party, ttl)"
  SVC->>BUS: "PUBLISH notify:F {partyInvite}"
  BUS-->>SF: "partyInviteReceived(partyId, from=L)"
  SF-->>F: "invite UI"
  F->>SF: "joinParty(inviteId)"
  SF->>SVC: "POST /v1/parties/{id}/join {inviteId}"
  SVC->>DB: "validate invite + capacity; INSERT PartyMember(F)"
  SVC->>BUS: "PUBLISH party:{id} {memberJoined F}  + rich presence update"
  SVC-->>SF: "200 {party roster}"
  BUS-->>SL: "partyMemberJoined(F)"
  SL-->>L: "roster updated"

7.3 "Join friend's game" — integrated path (+ standalone subset)

sequenceDiagram
  actor U as "User"
  participant SU as "SDK U (social + netcode)"
  participant SVC as "lattice-social"
  participant DIR as "lattice-director (8444)"
  participant GS as "lattice-gameserver / host"
  actor F as "Friend (in-game, joinable)"

  Note over F: "Friend's rich presence: joinable=true, sessionHandle=H"
  U->>SU: "joinFriendsGame(friendUserId)"
  SU->>SVC: "GET /v1/presence/{friendUserId}"
  SVC-->>SU: "RichPresence {joinable:true, sessionHandle:H, partyId?}"

  alt Integrated mode (suite present)
    SU->>DIR: "POST /resolve {sessionHandle:H}  (+ social token)"
    DIR->>DIR: "validate joinability / capacity / ACL"
    DIR-->>SU: "{endpoint, sessionToken}  (or relay info)"
    Note over SU,GS: "Hand off to netcode connect flow — see 01 & 02-netcode-lld"
    SU->>GS: "lattice-core connect(endpoint, sessionToken)"
    GS-->>SU: "joined session (prediction/replication begin)"
  else Standalone mode (no director)
    Note over SU: "sessionHandle is opaque to lattice-social"
    SU-->>SU: "return RichPresence to host app"
    Note over SU,GS: "Host product resolves H with ITS OWN matchmaking/connect"
  end

Standalone subset: §7.1 and §7.2 work fully standalone. In §7.3, lattice-social only ever carries the opaque sessionHandle in rich presence/invites and hands it to the host app; the director resolve + netcode connect legs exist only in integrated mode. The social layer never needs to understand session semantics to deliver the standalone value.


8. Integration with Netcode & Director

These integrations are capability-gated; absent the suite, they no-op gracefully.

  • Rich presence carries joinability. RichPresence advertises joinable, partyId, and an opaque sessionHandle. This is the contract that makes "join game" possible without coupling social state to netcode internals.
  • "Join game" → session resolve → connect. The SDK reads a friend's rich presence, posts the sessionHandle to lattice-director (8444) to resolve an endpoint + a netcode session token (with joinability/capacity/ACL checks), then hands off to the lattice-core connect flow (dedicated server, listen-server host, or relay) — see 01-high-level-design.md and 02-netcode-lld.md.
  • Party → matchmaking handoff. A party leader queues the whole party through lattice-director: lattice-social provides the authoritative roster (member ids + skill hints from extra) and the director matches/places the group, returning a sessionHandle that lattice-social then writes into the party's RichPresence so members can join. The director, not social, owns matchmaking and fleet placement.
  • Decoupling guarantee. Social tokens and netcode session tokens are distinct; lattice-social holds no game-session authority and stores only the opaque handle. This is what lets the same social backend serve both Lattice games and entirely external products.

9. API Surface

Base: https://<host>/v1 (REST) and wss://<host>:9443/v1/ws (realtime). All require a bearer token (§5). Responses are JSON; list endpoints are cursor-paginated; mutating endpoints are idempotent where noted.

9.1 REST (CRUD)

Method & Path Purpose
GET /me Current user + profile + settings (returns publicId + handle, never the account GUID).
PATCH /me/profile Update avatar, status, presence visibility (+ displayName → name change).
POST /me/name Change display name {displayName} (policy-gated; free by default — see §13.7).
GET /users/search?handle= Find by handle / prefix (rate-limited, privacy-gated).
GET /friends List friends (with presence summary + the caller's private note, §13.4).
POST /friends/requests Send request {toHandle|toPublicId}.
GET /friends/requests?dir=incoming|outgoing List pending requests.
POST /friends/requests/{publicId}/accept Accept.
POST /friends/requests/{publicId}/decline Decline.
DELETE /friends/requests/{publicId} Cancel (sender).
DELETE /friends/{publicId} Remove friend.
POST /blocks/{publicId} / DELETE /blocks/{publicId} Block / unblock.
POST /mutes/{publicId} / DELETE /mutes/{publicId} Mute / unmute.
GET/PUT/DELETE /notes/{publicId} Private note about a user (author-only, §13.4).
POST /parties Create party {maxSize, visibility}.
GET /parties/{id} Party + roster (members by publicId/handle).
POST /parties/{id}/invites Invite {toHandle|toPublicId} (gated, §13.2).
POST /parties/{id}/join Join {inviteId}JoinResultDto{party,join}; gated at admit (§13.2/§13.9).
POST /parties/{id}/leave Leave.
POST /parties/{id}/kick Kick {handle|publicId} (leader only).
POST /parties/{id}/leader Transfer leader {toHandle|toPublicId} (leader only).
POST /parties/{id}/visibility Set lobby privacy {visibility} (leader only, §13.2).
POST /parties/{id}/join-metadata Set lobby join metadata {lobbyId, bag} (leader only, §13.9).
POST /invites/game Send game invite {toHandle|toPublicId, sessionHandle?, join?} (§13.9).
POST /invites/game/{id}/accept Accept a game invite → {join} join metadata (§13.9).
GET /presence/{publicId} Read a friend's presence + rich presence (visibility-gated; lobby-overridden, §13.3).
POST /reports Abuse report hook {targetHandle|targetPublicId, reason, context}.
GET /me/export / DELETE /me Data export / deletion (privacy, §10).
GET /chat/{kind}/{channelId}/messages (opt.) Chat history (DM or party).
GET /me/feed (opt.) Activity feed.

Identity in payloads (§13.5): path/body identifiers for users are the full handle (Name#1234) or the opaque publicId — never the internal account GUID. Resource ids (partyId, inviteId, requestId, messageId) remain GUIDs; they are not the account id.

9.2 WebSocket events (WSS 9443)

Direction Event Payload
C→S presenceUpdate {state, richPresence}stateonline/away/busy/dnd/in-game/offline
C→S heartbeat {} (refreshes presence TTL)
C→S chatSend (opt.) {channel, channelId, body}
S→C friendPresenceChanged {publicId, state, richPresence} (state is lobby-overridden, §13.3)
S→C friendProfileChanged {publicId, handle, displayName} — rename/avatar; key on publicId, update displayed name (§13.8)
S→C friendRequestReceived / friendRequestAccepted {publicId, requestId}
S→C partyInviteReceived {partyId, fromPublicId, inviteId}
S→C partyMemberJoined / partyMemberLeft / partyLeaderChanged {partyId, publicId}
S→C gameInviteReceived {fromPublicId, sessionHandle, join:{lobbyId,bag}, inviteId} (§13.9)
S→C chatMessage (opt.) {messageId, channel, channelId, fromPublicId, body, sentAt}
S→C error {code, message}

All user references in WS payloads are the opaque publicId/fromPublicId — never the account GUID (§13.5).


10. Privacy & Safety

  • Blocking semantics. A block is bidirectional in effect: it removes any friendship, cancels pending requests, prevents new requests/invites in either direction, and hides presence both ways. Mute is one-way and soft: it suppresses chat/notifications from the muted user while keeping the friendship intact.
  • Presence visibility settings. Per-user PresenceVisibility (everyone / friends / nobody) and an invisible/appear-offline mode (online to the suite for joinability but shown as offline to friends). All presence reads (REST and fanout) are gated by the target's visibility and the viewer's block/mute state. Rich presence has its own coarser gate so a user can be shown online without leaking what they are playing.
  • Report / abuse hook. POST /reports records a structured report (target, reason, captured context such as a chat window or party id) and emits it to a configurable sink (NATS subject, webhook, or table) for the host product's moderation pipeline. lattice-social provides the hook, not the review tooling.
  • Data deletion & export. GET /me/export returns the user's social data; DELETE /me performs a GDPR-style erasure: removes profile/relationships/party memberships, tombstones the user id so dangling references resolve to a redacted placeholder, purges Redis presence, and optionally hard-deletes or anonymizes chat. Deletion is propagated to friends as a friend-removed event.

11. Scaling & High Availability

  • Stateless horizontal scale. Add lattice-social nodes behind the LB to add REST + WS capacity; nodes share nothing locally (§3.2). Each node maintains many WS connections (target tens of thousands per node) and subscribes to the bus only for its connected users.
  • Pub/Sub backbone. Redis Pub/Sub is the default fanout bus; NATS is the drop-in upgrade for durable delivery, larger fan-out, and multi-region streaming. Hot-user fan-out-on-read (§6.3) caps worst-case broadcast cost; debounce/coalescing protects the bus from flap storms.
  • Store HA. PostgreSQL runs with a primary + streaming replicas (reads can be served from replicas; writes go to the primary) and automated failover. Redis runs clustered/replicated (Sentinel or Cluster) so presence/pub-sub survives a node loss; presence is ephemeral by design — a Redis blip degrades to "everyone reconnects and re-asserts presence," never data loss of the durable graph.
  • Graceful node loss / rollout. On node drain, WS clients are disconnected and reconnect through the LB to a survivor; presence keys expire/refresh naturally. Stateless nodes make rolling deploys and autoscaling trivial — the same "act as one" property the auth service relies on (see 03-auth-service.md).
  • Regionalization. Social is latency-tolerant (it is not the netcode hot path), so a single region with read replicas suffices for most products; NATS enables cross-region fanout if a global friends graph is required. See 06-implementation-roadmap.md for phasing (standalone library first, suite integrations and chat/feed as later phases).

13. Extended Social Rules (presence states, lobby gating/override, notes, identity)

These rules extend §2/§4/§6/§9 with the precise semantics implemented in lattice-social.

13.1 Presence states & precedence (DnD / Busy)

PresenceState is Offline < Away < Online < Busy < DoNotDisturb < InGame; the numeric order is the auto "most active" rank used to pick a default across devices. Busy and DoNotDisturb (DnD) are explicit, user-set states (a device session carries isExplicit). The multi-device aggregate:

  1. If any live device carries an explicit Busy/DnD, that is the user's intended displayed state — it overrides the auto rank, including auto-Away (idle) and even InGame on another device.
  2. Among explicit states, DnD outranks Busy.
  3. Otherwise the auto "most active" wins (InGame > Online > Away > Offline).

Rationale: an explicit "do not disturb" is a deliberate signal and must not be silently upgraded to InGame/Online just because another device is more active.

13.2 Lobby/party privacy + invite/join gating

A party (lobby) has a privacy setting — Public / FriendsOnly / Private — set by the leader (POST /v1/parties/{id}/visibility), and a host (the leader) with a presence status. Entry into a lobby fails with HTTP 403 when the host's status is DnD/Busy (reason lobby_host_unavailable) or the lobby privacy is Private (reason lobby_private). The reason code is returned in the response error field; a pre-existing block is checked first and reports blocked.

Confirmed rule — the host is the exception:

  • Host invite = always allowed. The host (leader) can send invites regardless of their own DnD/Busy status or the lobby privacy. ("Only the host should be able to invite if set to DnD or Busy.")
  • Member invite = gated. A non-host member's invite is rejected (403 + reason) when the host is DnD/Busy OR the lobby is Private; it is allowed only when the host is Online/Away/InGame and the lobby is not Private.
  • Join (admit time) = always gated. POST /v1/parties/{id}/join re-checks the gate at admission, so nobody enters a DnD/Busy/Private lobby. Joining requires a valid invite addressed to the joiner; an uninvited join is rejected outright. Net effect: under DnD/Busy/Private you can only get in via an explicit invite — which only the host can send.

Why the host is exempt at invite time: a host populating their own private/DnD lobby is legitimate, and the joiner-facing guarantee — you cannot get into a gated lobby — is preserved unconditionally by the Join gate. The invite is only the announcement; admission decides entry. Tests cover host-exception (Invite_Host_*_Allowed), the member gate (Invite_Member_*_Blocked / Invite_Member_WhenHostAvailable_AndNotPrivate_Allowed), and the join gate (Join_GatedWhenHostBecomesDnd_AfterInvite, Join_GatedWhenLobbyPrivate_EvenViaHostInvite, UninvitedJoin_WithoutInvite_IsRejected).

13.3 In-lobby presence override

Default rule (implemented): while a user is in a lobby, the presence shown to their friends is overridden to mirror the host's status (host = DnD ⇒ every member appears DnD to their friends).

  • Reverts to the member's own presence when they leave / are kicked / the lobby disbands / leadership transfers (the new host's status then applies).
  • An offline member stays offline — the override never resurrects presence.
  • A user viewing themselves always sees their own un-overridden state.
  • It is propagated via the existing presence fanout (per-recipient channels), so a friend subscribed over WS sees the overridden value; it is computed live in one place (GetDisplayedState) and is therefore easy to change.

Note the two are independent: the override applies whenever in a lobby (members mirror the host); the invite/join block additionally triggers on host DnD/Busy or Private privacy.

13.4 Private user notes

UserNote {ownerId, subjectId, text, updatedAt} is a private, author-only note about another user. GET/PUT/DELETE /v1/notes/{publicId} manage it (empty text clears it); it appears inline in the author's GET /v1/friends response (note field) and is never visible to anyone else and never fanned out.

13.5 Three-tier identity (hidden GUID, stable public id, mutable handle)

Identity uses three distinct identifiers so that references survive a rename:

  1. Internal account GUID (User.userId, the "sub") — backend-only storage key: the unique (provider, externalId) mapping target and the key under which relationships, notes and party memberships are stored. It never appears in any public API response or WS payload.
  2. Stable public id (User.publicId) — a separate, immutable, opaque id generated per account (base64url of a fresh random GUID, with no relation to the account GUID, so exposing it never leaks the account GUID). This is the canonical id all public references use — friend-list entries, presence/join-routing, party invites, blocks, and UserNote.subjectId. It is immutable and does not change on rename.
  3. Handle Name#1234 (display name + discriminator) — display + search/discovery only, mutable.

Rule of thumb: references/keys in payloads = the stable public id; human display = handle; friend search / friend-request-by-name = handle; internal storage key = the hidden GUID. Do not use the account GUID for references, and do not key references on the mutable handle. All friend/party/presence/notes/invite responses and all WS payloads carry publicId (+ handle); lookups accept a handle or publicId. Resource ids (partyId, inviteId, requestId, messageId) remain GUIDs — they identify resources, not the account.

Because every reference keys on the immutable publicId (and internally on the immutable account GUID), a display-name change cannot break a friendship, pending invite, block, or note — only the displayed name changes. See §13.8.

13.6 Public handle (Name#1234)

Every user is provisioned with displayName + '#' + 4 random digits (a discriminator, zero-padded to 4 digits in the address form). The discriminator is best-effort-unique per displayName: the 4-digit roll retries on collision within the same displayName. The full handle is exposed publicly and supports lookup / friend-request by handle. The handle is display + discovery only — never a reference key (§13.5).

13.7 Display-name change + policy seam

POST /v1/me/name changes the display name. It is gated by an INameChangePolicy seam whose default implementation always allows the change for free. A future build can require a purchase/entitlement by registering a different policy in DI — with no rework to the endpoint or service. Discriminator rule: keep the current discriminator if NewName#disc is still free, otherwise re-roll a fresh one for the new name (a no-op rename keeps it). The publicId and account GUID are stable across name changes, so all existing references continue to resolve (§13.8).

13.8 Realtime profile/rename updates

When a user changes their display name (or avatar/profile), the service fans out a friendProfileChanged WS event to their friends (RelationshipService.FanOutProfileChangedAsync, driven by POST /v1/me/name and PATCH /v1/me/profile). The payload is {publicId, handle, displayName} — the unchanged stable public id plus the new handle/displayName — so clients update the displayed name live while continuing to key the friend (and every friendship / invite / note / block) on the immutable publicId. Block-gated like other friend fanout.

13.9 Join metadata (lobbyId + opaque bag)

RichPresence and the party/lobby carry game-defined join metadata so a joining game knows how to actually connect (JoinMetadata): a first-class lobbyId plus an extensible, fully opaque bag (Dictionary<string,string>) the game fills with whatever its networking tech needs.

Netcode-agnostic by design. Because lattice-social is shippable standalone (§1.3), the join payload hard-depends on no Lattice/director field. The service never interprets the bag, requires no field, never calls lattice-director, and never validates bag contents — it only stores, transports, and returns it verbatim, size-limited (MaxEntries = 32, MaxTotalChars = 8 KiB; overflow → 400). lobbyId is kept distinct/first-class; everything else lives in the generic bag.

  • Set it: join on POST /v1/parties (create), POST /v1/parties/{id}/join-metadata (leader), join on POST /v1/presence and the presenceUpdate WS frame, and join on POST /v1/invites/game.
  • Returned on join — gated: a successful POST /v1/parties/{id}/join returns JoinResultDto { party, join }; POST /v1/invites/game/{id}/accept returns { join }. The lobby gate (§13.2) is enforced at admit time before any metadata is returned, so a gated join fails with 403 and yields no metadata — the bag never leaks to someone who cannot enter. Game invites carry the same metadata, so accepting an invite alone is sufficient to join.

Two connect paths (both supported; neither is required):

  1. Lattice default (blessed convention). A suite game puts a director-resolvable handle under the optional well-known key JoinMetadata.LatticeSessionHandleKey = "lattice.sessionHandle". The joiner reads it → lattice-director POST /resolve → endpoint + session token → netcode connect (§7.3/§8). The legacy sessionHandle argument on POST /v1/invites/game is mirrored into the bag under this same key for back-compat. The service still never reads or requires it.
  2. Bring-your-own-netcode (standalone, no Lattice). A game on any stack — Steam lobbies, Epic EOS, Photon, Mirror/FishNet, a raw host:port, or anything custom — puts its own connect info in the bag under its own keys (e.g. steam.lobbyId, endpoint, photon.room, password). It round-trips verbatim through set-presence/invite/join with zero Lattice involvement; no Lattice key is present or needed.

Tests cover both: ByoNetcode_SteamLobby_RoundTripsThroughPresence_NoLatticeField and ByoNetcode_RawHostPort_RoundTripsThroughInviteAndAccept (no Lattice field anywhere) alongside LatticeConvention_WellKnownKey_RoundTrips.

14. Multi-tenancy & cross-game visibility (P2)

lattice-social runs on the shared Lattice hosted platform: many games (tenants), registered in lattice-console, share one social service. P2 makes the friend graph global while letting each game control how far its players' social graph reaches across the other games.

14.1 Global friend graph

There is one account per person (keyed by the immutable account sub / publicId, §13.5) and a single global friend graph: a friendship is not per-game. Sending/accepting one friend request makes two accounts friends regardless of which game either was playing — friend requests cross games freely. Presence, parties, blocks and notes are unchanged; tenancy is layered on top.

14.2 Per-game registration awareness & the originating gameId

A social request is made in the context of a game — the caller's game, resolved from its console-issued credentials presented as X-Lattice-Game-Key / X-Lattice-Game-Secret. The TenancyMiddleware resolves them to a gameId (via ILatticeConsoleClient), records that the authenticated user is registered with that game (the per-user set of gameIds), and stashes a GameContext { GameId, Visibility } on the request. Presence (and rich presence) carry the originating gameId (PresenceSession.OriginGameId / RichPresence.OriginGameId) so a client can tell same-game from different-game friends; the friends list flags each entry with sameGame. With no game key the request is untenanted (single-tenant / standalone) and visibility is unrestricted — so the service still ships standalone.

14.3 Cross-game visibility enforcement

Each game has a CrossGameVisibility setting obtained from lattice-console:

  • All — see friends active/registered in any game.
  • None — see only same-game friends (isolated tenant).
  • Specific {gameIds} — same-game friends plus friends registered in the allow-listed games.

Same-game friends are always visible (a friend co-registered in the caller's own game). The filter is enforced in both directions:

  • Friends list (GET /v1/friends) — each global friend is listed only if the caller game's setting admits the game(s) that friend is registered in (TenancyService.IsFriendVisible).
  • Presence fanout — when a user's presence changes, it is delivered to a friend only if that recipient's game(s) setting admits the sender's games (TenancyService.IsPresenceVisibleToAsync).

The safe default for an unknown/misconfigured game is None (never over-share across tenants).

14.4 Console integration (consumer only)

lattice-social is a consumer of lattice-console; it never modifies it. The seam is ILatticeConsoleClient with two operations: ResolveGameKeyAsync(apiKey, apiSecret) → gameId? and GetCrossGameVisibilityAsync(gameId) → CrossGameVisibility. Two implementations:

  • StubLatticeConsoleClient — in-process model of the console game registry; the dev/test default and the standalone path.
  • HttpLatticeConsoleClient — calls the real console over HTTP (POST /auth/validate-key for key resolution; GET /games/{id} for the visibility setting). Selected by setting Social:Console:BaseUrl (one line in Program.cs); console stays untouched.

Tests: TenancyTests (service-level: global graph; originating gameId on presence; All/None/Specific filtering; fanout All delivers cross-game, None suppresses other-game but keeps same-game) and TenancyHttpTests (full HTTP pipeline: cross-game friend request; None hides / All shows; same-game always visible + sameGame flag; Specific = same-game + allow-listed; presence carries originGameId; untenanted is unfiltered).