Friends — Domain Spec
A friendship is a single undirected row between two users with a
status. This page is the rules for request flow, mutual-graph
invariants, blocks, and how the graph gates location sharing.
Mental model
Three shapes:
- One row per pair. Friendship is modelled as a single row in
friendships with user_id_1 (requester) and user_id_2
(recipient). Ordering matters only for "pending sent" vs "pending
received" UI.
- Mutual, not asymmetric. A request needs the recipient's accept
before the edge counts. There is no asymmetric follow.
- The graph gates everything. Live location, friends-only
visibility on moments / events, mutual-friend counts on profiles
all read from the same
friendships table.
Friendship statuses
| Status |
Meaning |
Set by |
pending |
Request sent, not yet accepted |
Sender via POST /friends/request |
accepted |
Both parties are friends |
Recipient via POST /friends/accept |
blocked |
Recipient blocked the sender (one-way restriction) |
Block action (TBD UI surface) |
Request flow
| From state |
Action |
To state |
Caller |
| (none) |
POST /friends/request |
pending (requester = user_id_1) |
Sender |
pending |
POST /friends/accept |
accepted |
Recipient |
pending |
POST /friends/reject |
(row deleted) |
Recipient or sender (cancel) |
accepted |
DELETE /friends/:id |
(row deleted) |
Either side |
accepted |
Block |
blocked |
Either side |
POST /friends/accept takes the other user's UUID, not the
friendship row ID, for historical reasons. The service resolves the
row via FindBetweenUsers(userID, friendID) and confirms
UserID2 == userID before flipping status. POST /friends/reject
and DELETE /friends/:id follow the same pattern.
Invariants
| Invariant |
Why |
| One row per pair |
No duplicate friendship rows |
accepted is symmetric |
Both sides see the edge; both sides can unfriend |
blocked is one-way at the data layer but enforced symmetrically at the read layer |
Blocked users are invisible to the blocker on every read site |
| Friend list is cached in Redis |
user:friends:{userID}, 10-min TTL, invalidated on accept / unfriend |
| Mutual-friends count is computed in Discovery |
Not in FriendService — the friend service exposes only the raw graph |
Location-share rules
| Setting |
Owner |
Controls |
users.is_location_shared |
The friend |
Whether others see this user on the map |
Heartbeat (presence:{userID}, TTL 3 min) |
The friend's client |
Whether the user appears online |
| Active-location session |
The friend |
Whether the user has opted in to broadcast coordinates for a finite window (see Presence) |
Privacy on the read site is one-way: the caller's own
is_location_shared flag controls whether others see the caller. It
does not restrict what the caller can see — GetFriendLocations
returns visible friend locations regardless of the caller's own
sharing setting. Only friends whose own is_location_shared is true
appear in the result.
Block semantics
| Surface |
Behaviour when blocked |
| Map / radar |
Blocked user does not appear |
| Search results |
Blocked user does not appear |
| Profile view |
Blocked user's profile is not loadable |
| Mutual-friend counts |
Blocked edges do not contribute |
| Chat |
Existing DMs become inaccessible; new DM cannot be created |
| Events |
Blocked user cannot see friends-visibility events of the blocker |
Lifecycle / state machine (prose)
Friendship lifecycle: (none) → pending → accepted → (deleted) with
blocked as an alternative branch from accepted. A pending row
goes either to accepted (recipient accepts) or to (deleted)
(reject or cancel). An accepted row goes either to (deleted)
(unfriend) or blocked (block). A blocked row stays blocked until
explicitly unblocked, which deletes the row.
No location data is persisted in Postgres. Live location lives
entirely in Redis at user:location:{userID} with a 24-hour TTL.
Cross-domain interactions
| Other domain |
Interaction |
| Presence |
GetFriendLocations reads user:location:, presence:, active_location: for each friend in a single MGET |
| Discovery |
Reads the graph to compute mutual-friend counts, friend overlay markers, radar friend results |
| Events |
friends-visibility events check accepted friendship with host |
| Moments |
friends_only visibility checks accepted friendship with poster |
| Chat |
DM creation typically requires accepted friendship (TBD policy) |
| Identity |
On user hard delete, friendship rows are removed |
Privacy / visibility
| Field on profile |
Public |
Friend |
| Display name + username + avatar |
Yes |
Yes |
| Bio / profile fields |
Yes |
Yes |
| Friend list count |
Yes |
Yes |
| Mutual-friends count |
Yes (auth-gated) |
Yes |
| Live location |
No |
Yes (if friend's is_location_shared is on) |
| Friends-only moments |
No |
Yes |
| Friends-visibility events |
No |
Yes (if host is friend) |
See also