Presence — Domain Spec
Presence is two independent signals: online (a short-TTL heartbeat
key) and actively sharing live location (a typed, finite-duration
session). This page is the rules for both and how they compose with
the privacy toggles that gate them.
Mental model
Two shapes:
- Heartbeat → online. Client posts
POST /presence/heartbeat,
server sets presence:{userID} = "1" with a 3-minute Redis TTL.
If the heartbeat stops, the user goes offline implicitly within
three minutes.
- Typed session → live location. Client calls
POST /presence/active/start with one of four allowed intervals.
Server writes a session blob to Redis (TTL = interval) and a row
to active_location_sessions (for restart recovery).
Heartbeat semantics
| Property |
Value |
| Key |
presence:{userID} |
| Value |
"1" |
| TTL |
3 minutes |
| Endpoint |
POST /presence/heartbeat |
| Recommended cadence |
Every ≤2 minutes from an active client |
| Going offline |
Implicit, by TTL expiry. No presence/stop endpoint |
Active-location intervals
| Allowed value (minutes) |
UX label (TBD) |
| 30 |
Short |
| 60 |
One hour |
| 360 |
Six hours |
| 720 |
Twelve hours |
Anything else is rejected. The session writes a Redis blob to
active_location:{userID} with {interval_minutes, started_at,
expires_at} and a row to active_location_sessions.
Two-signal model
| Question |
Signal |
| "Is the user online right now?" |
presence:{userID} exists |
| "Is the user actively sharing live coordinates?" |
active_location:{userID} exists, OR active_location_sessions has an active row |
Both can be true. Either can be true independently. Both are checked
separately at the read sites (friend feed, radar, map overlay).
Privacy guardrails (composed with presence)
| Setting |
Owner |
Effect |
User.ChatPreferences.ActivityStatus |
The user |
If false, the user appears offline to everyone even when presence: key is fresh |
users.is_location_shared |
The user |
If false, the user does not appear on others' maps even when sharing live coordinates |
active_location_sessions.is_active |
Set by start/stop |
Restart-safe truth source |
Invariants
| Invariant |
Why |
| Heartbeat TTL is exactly 3 minutes |
Predictable offline detection |
Only 30 / 60 / 360 / 720 are valid intervals |
Anti-arbitrary-input; UX has a fixed picker |
| One active session per user |
DeactivateAll runs before each new Create |
| Redis is hot path; Postgres is restart fallback |
GetActiveLocationStatus rehydrates Redis from DB on cache miss |
ActivityStatus = false masks online indicator |
Honoured at every consumer read site |
is_location_shared = false removes from others' maps |
Honoured at every consumer read site |
| Live-location coordinates are not persisted to Postgres |
Only Redis (user:location:{userID}, TTL 24 h) |
| Stay-duration preserved within 50 m |
UpdateLocation keeps arrived_at if the new coords are within 50 m of the previous reading |
Lifecycle / state machine (prose)
Online lifecycle: (offline) → online → (offline). Online is
entered on heartbeat; offline is entered implicitly when the 3-minute
TTL on presence:{userID} expires without a refresh. There is no
explicit offline transition.
Active-location lifecycle: (inactive) → active → (expired |
stopped). Active is entered via POST /presence/active/start with a
valid interval. Expired is entered automatically when the session
window passes — both the Redis key and the row's is_active flag
are dropped (the latter on next read or by the heartbeat fallback).
Stopped is entered explicitly via POST /presence/active/stop, which
deletes the Redis key and marks the DB row inactive immediately.
Cross-domain interactions
| Other domain |
Reads |
| Friends |
GetFriendLocations reads presence:, active_location:, user:location: for each friend in one MGET |
| Discovery |
Friend overlay markers + radar use the same three keys |
| Chat |
Online indicator in the chat header reads presence:{userID} (gated by ActivityStatus) |
| Identity |
is_location_shared and activity_status are user-level settings |
Privacy / visibility
| Field |
Self |
Friend |
Stranger |
| Online status |
Always |
Yes (gated by ActivityStatus) |
No |
| Live coordinates |
Always |
Yes (gated by is_location_shared) |
No |
| Active-location session detail |
Always |
No (only the resulting coords) |
No |
| Heartbeat / endpoint surface |
Self only |
— |
— |
See also