Social Graph Service
20
API Endpoints
2
Service Deps
2
Infrastructure
1
DB Schemas
API Endpoints
/internal/relationship-contextService-to-service only. Request-service supplies two participant UUIDs that it derived from an
/invitations/generateGenerate a new invitation code for current user.
/invitations/acceptAccept an invitation code during user signup.
/invitationsGet invitation history for current user.
/invitations/statsGet inviter statistics for current user (gamification metrics).
/paths/:targetUserIdGet shortest path between current user and target user.
/paths/batchGet paths for multiple target users (optimized for feed ranking).
/trust/graphAggregate ego-network across all of the calling user's communities (Sprint 67). Returns calling user + direct neighbors + edges among them, de-duplicated and summed across communities.
/trust/graph/:communityIdEgo-network for the calling user in a specific community — calling user + direct neighbors + edges among them (Sprint 67 rewrite). Returns the calling user's ego-centric neighborhood, never the full
/trust/graph/:communityId/fullFull community trust graph (Sprint 74) — up to 149 **neutrally selected** non-caller active members, UNION the calling user (always included), plus every edge between that member set. Sprint 115 (AD
/trust/neighborhood/:userIdSprint 111 (ADR-081) — privacy-scoped recursive ego-neighborhood backing the full-page `/network` explorer. Walks `trust_edges_live` outward from `:userId` and returns each reachable person with the
/trust/edgeReturn a single trust edge for a user pair in a community.
/trust/communities (Sprint 79 — inter-community depth)Inter-community depth graph for the calling user. Nodes are the caller's active
/networkGet the current user's local network graph from the materialized connections table.
Infrastructure
Service Dependencies
Subscribes To
Full Documentation
Social Graph Service Context
Quick Start:
cd services/social-graph-service && npm run devPort: 3010 | Health: http://localhost:3010/health
Purpose
Manages invitation tracking, social graph computation, and trust path visualization. Enables users to see how they're connected through invitation chains and prioritize help requests from their extended network.
Core Principles
"Trust flows through relationships" - The service makes social connections visible and uses invitation paths as a primary trust signal for feed ranking and request matching.
Database Schema
Tables Owned by This Service
-- Invitation tracking
auth.user_invitations
auth.inviter_stats
-- Precomputed paths (cache)
auth.social_distances
-- User table extensions
auth.users.invited_by
auth.users.invitation_accepted_at
auth.users.show_connection_path
auth.users.show_who_invited_me
auth.users.show_who_i_invited
-- Sprint 65: Trust graph (weighted, community-scoped edges)
social_graph.trust_edges (
id, user_id_a, user_id_b, -- normalized: user_id_a < user_id_b
community_id, -- community scope
match_completed_count, -- per-type interaction counts
endorsement_count,
karma_given_count,
event_count,
raw_weight, -- peak weight (never decayed; grows with interactions)
stability, -- Sprint 68: multiplier for effective half-life (starts 1.0, grows 20%/interaction)
last_interaction_at, -- used for Ebbinghaus decay computation
created_at, updated_at
)
-- Sprint 68: Live view — computes current_weight via Ebbinghaus decay at query time
-- Formula: raw_weight × e^(-days_since_interaction / (stability × half_life))
-- NEVER INSERT or UPDATE this view — it is read-only.
social_graph.trust_edges_live (
... all trust_edges columns ...,
current_weight -- computed column: effective weight right now
)
-- Sprint 68: Per-community decay tuning
social_graph.trust_decay_config (
id, community_id, -- NULL community_id = global default
base_half_life_days, -- default: 30 days
stability_growth_rate, -- default: 0.20 (20% per interaction)
disappearance_threshold, -- default: 0.5 (edge swept when current_weight falls below)
created_at, updated_at
)
social_graph.interaction_weights (
id, community_id, -- NULL community_id = platform default
interaction_type, -- match_completed | endorsement | karma_given | event
weight -- platform defaults: 10, 5, 3, 2
)
social_graph.community_trust_edges (
id, community_id_a, community_id_b, -- normalized pair
cross_interaction_count,
weight,
last_interaction_at
)
See migration 009_social_graph.sql and migration 20260525-trust-graph-foundation.sql for complete schema.
Tables Read by This Service
auth.users- User information for path displaycommunities.members- Community membership for RLSreputation.karma_records- Karma scores for trust path calculationrequests.matches+requests.help_requests- completed-help topology for reciprocal relationship context (Sprint 116; platform-wide under ADR-077)
Sprint 116 adds no table, column, view, or migration. relationshipContextDb.ts reads the existing
completed-match, active-membership, trust-edge-live, and decay-config sources above.
Architecture
Invitation Flow
1. User generates invitation code
↓
2. Code shared via link/email/SMS/QR
↓
3. New user accepts code during signup
↓
4. Invitation recorded in graph
↓
5. Paths recomputed (lazy, on-demand)
Path Computation Strategy
Cache Hit (< 7 days old):
- Return from auth.social_distances
- ~50ms response time
Cache Miss:
- Run BFS algorithm
- Max depth: 3 degrees
- Cache result for 7 days
- ~500ms response time (first time)
API Endpoints
POST /internal/relationship-context
Service-to-service only. Request-service supplies two participant UUIDs that it derived from an
authorized request, match, or provider offer. Requires X-Internal-Secret, fails closed with 503 when
INTERNAL_SECRET is not configured, and is mounted before member JWT auth. Returns the strict
provider-free RelationshipContextProjection (identity, platform path, bounded one-hop networks,
qualitative links, factual summary). It never accepts request/provider metadata or arbitrary public
member lookup. All deployed Nginx variants return 404 for the corresponding public
/api/social[-graph]/internal/ prefix; only direct service-network traffic can reach the secret check.
POST /invitations/generate
Generate a new invitation code for current user.
Request (authenticated):
{
// No body required - uses current user from JWT
}
Response:
{
"success": true,
"data": {
"code": "KARMYQ-MIKE-2024-A7B3",
"url": "http://localhost:3000/invite/KARMYQ-MIKE-2024-A7B3",
"created_at": "2025-12-27T10:30:00Z",
"expires_at": null
}
}
Implementation: src/routes/invitations.ts:11
Key Features:
- Invitation codes follow format:
KARMYQ-{NAME}-{YEAR}-{RANDOM} - Uses database function
auth.generate_invitation_code() - Automatically updates
inviter_stats.total_invitations_sent
POST /invitations/accept
Accept an invitation code during user signup.
Request (authenticated):
{
"invitation_code": "KARMYQ-MIKE-2024-A7B3"
}
Response:
{
"success": true,
"data": {
"inviter_id": "uuid-123",
"community_id": "uuid-456",
"accepted_at": "2025-12-27T10:35:00Z"
}
}
Implementation: src/routes/invitations.ts:91
Side Effects:
- Updates
user_invitations.invitation_accepted_at - Sets
users.invited_byfor the new user - Triggers
update_inviter_stats_on_acceptance()(incrementstotal_invitations_accepted)
GET /invitations
Get invitation history for current user.
Response:
{
"success": true,
"data": {
"sent": [
{
"id": "uuid-789",
"invitation_code": "KARMYQ-MIKE-2024-A7B3",
"invited_at": "2025-12-20T00:00:00Z",
"accepted_at": "2025-12-21T00:00:00Z",
"invitee": {
"id": "uuid-abc",
"name": "Sarah Rodriguez",
"karma": 92
}
}
],
"received": {
"id": "uuid-def",
"invitation_code": "KARMYQ-JOHN-2024-X1Y2",
"invited_at": "2025-11-15T00:00:00Z",
"accepted_at": "2025-11-15T00:00:00Z",
"inviter": {
"id": "uuid-ghi",
"name": "Mike Chen"
}
}
}
}
Implementation: src/routes/invitations.ts:170
GET /invitations/stats
Get inviter statistics for current user (gamification metrics).
Response:
{
"success": true,
"data": {
"total_invitations_sent": 8,
"total_invitations_accepted": 7,
"acceptance_rate": 87.5,
"avg_invitee_karma": 78.5,
"avg_invitee_trust_score": 82.3,
"total_invitee_exchanges": 45,
"total_network_size": 87,
"bridge_score": 3,
"inviter_tier": "gold",
"tier_updated_at": "2025-12-20T00:00:00Z"
}
}
Implementation: src/routes/invitations.ts:237
Inviter Tiers:
- Bronze: Default
- Silver: 3+ accepted, 60+ avg karma, 50%+ acceptance
- Gold: 5+ accepted, 70+ avg karma, 60%+ acceptance
- Platinum: 10+ accepted, 80+ avg karma, 70%+ acceptance
GET /paths/:targetUserId
Get shortest path between current user and target user.
Parameters:
targetUserId(URL param): UUID of target user
Response (exchange path found — identity + topology only, ADR-082; no karma, no numeric trust_score):
{
"success": true,
"data": {
"degrees_of_separation": 2,
"path": [
{ "id": "user-123", "name": "You" },
{ "id": "user-456", "name": "Mike Chen", "exchanged_at": "2024-11-15" },
{ "id": "user-789", "name": "Sarah Rodriguez" }
],
"connection_type": "exchange",
"scope": "community",
"cached": true,
"computed_at": "2025-12-27T10:00:00Z"
}
}
Response (community_member path — Sprint 119 / BUG-029: two endpoints + community_name only;
no third person is ever inserted into the path; degrees_of_separation is uniformly 2, a
proximity signal for ranking, never a claimed direct bond):
{
"success": true,
"data": {
"degrees_of_separation": 2,
"path": [
{ "id": "user-123", "name": "You" },
{ "id": "user-789", "name": "Sarah Rodriguez" }
],
"connection_type": "community_member",
"community_name": "Southeast PDX Helpers",
"scope": "community",
"cached": false
}
}
Cache hits for community_member rows are enriched with community_name from live shared
membership (cached rows don't store it; the resolved request scope is preferred when the pair
shares several communities, with a deterministic fallback). Cached community_member rows are
also revalidated on read (same pattern as S118 exchange rows): a pre-fix shape (3-node path
or 1° degrees) or a pair that no longer shares any community is deleted and recomputed instead
of served.
Response (no path):
{
"success": true,
"data": {
"degrees_of_separation": null,
"path": null,
"connection_type": null,
"cached": false,
"message": "No connection found (4+ degrees or unconnected)"
}
}
Implementation: src/routes/paths.ts:12
Algorithm: Bidirectional BFS (see src/services/pathComputation.ts)
Performance:
- Cache hit: ~50ms
- Cache miss: ~500ms (first computation)
- Cache TTL: 7 days
- Cached
exchangerows are revalidated against live graph edges before return; stale or malformed rows are deleted and recomputed.
POST /paths/batch
Get paths for multiple target users (optimized for feed ranking).
Request:
{
"target_user_ids": ["uuid-1", "uuid-2", "uuid-3", ...]
}
Response (degrees + type only, ADR-082 — no numeric trust_score; community_name present on
community_member entries, Sprint 119):
{
"success": true,
"data": [
{
"target_user_id": "uuid-1",
"degrees_of_separation": 1,
"connection_type": "exchange",
"cached": true
},
{
"target_user_id": "uuid-2",
"degrees_of_separation": 2,
"connection_type": "community_member",
"community_name": "Southeast PDX Helpers",
"cached": false
}
],
"scope": "community"
}
Implementation: src/routes/paths.ts:95
Limits:
- Max 50 users per request
- Automatically caches newly computed paths
- Revalidates cached
exchangerows against live graph edges before returning batch cache hits. - Isolates compute/cache failures per target: a failed target returns the existing no-connection
entry while the remaining targets and their
degrees_of_separationranking inputs still return.
Use Case: Feed Service calls this endpoint to get social proximity scores for all requesters in the feed, then ranks requests accordingly.
Canonical metric (Sprint 79 / ADR-063): every node
trust_scoreacross the graph endpoints is the decayed sum —SUM(current_weight)fromsocial_graph.trust_edges_live— ingetTrustGraph,getTrustGraphAggregate, andgetFullCommunityGraph. The earlier raw-weight node aggregate on the ego/aggregate endpoints was retired so node and edge values agree.
Removed (Sprint 83):
getTrustGraphAggregateForCenterand theGET /trust/graph?center=expansion path were deleted as orphaned code — Sprint 79 dropped click-to-recenter, leaving this path unreachable.GET /trust/graphnow always returns the calling user's own aggregate ego-network.
GET /trust/graph
Aggregate ego-network across all of the calling user's communities (Sprint 67). Returns calling user + direct neighbors + edges among them, de-duplicated and summed across communities.
Auth required: Bearer JWT.
Response: { nodes: TrustNode[], links: TrustLink[] } — nodes include trust_score, karma, isCurrentUser; links include effective_weight.
Primary consumer: apps/frontend/src/components/NetworkGraph.tsx (dashboard Your Network panel)
GET /trust/graph/:communityId
Ego-network for the calling user in a specific community — calling user + direct neighbors + edges among them (Sprint 67 rewrite). Returns the calling user's ego-centric neighborhood, never the full community graph.
Auth required: Bearer JWT. Caller must be an active member of the community.
Response:
{
"success": true,
"data": {
"nodes": [{ "id": "uuid", "name": "Alice", "trust_score": 42.5, "karma": 120, "isCurrentUser": false }],
"links": [{ "source": "uuid-a", "target": "uuid-b", "effective_weight": 10.0 }]
}
}
Primary consumer: apps/frontend/src/components/community/tabs/TrustGraphTab.tsx (My Network sub-tab)
GET /trust/graph/:communityId/full
Full community trust graph (Sprint 74) — up to 149 neutrally selected non-caller active members, UNION the calling user (always included), plus every edge between that member set. Sprint 115 (ADR-083): selection is by normalized name + ID (ORDER BY LOWER(BTRIM(u.name)), user_id LIMIT 149), never by trust score — so the member set carries no ranking. The response adds structural completeness metadata so an incomplete graph can say so. Unlike /trust/graph/:communityId (which returns only the caller's ego-network), this returns the whole community topology, capped at 150 nodes. Reads decay-adjusted weights from the trust_edges_live VIEW.
Implemented by getFullCommunityGraph(communityId, callingUserId) in src/database/trustEdgeDb.ts. Registered before /trust/graph/:communityId so Express doesn't match full as a communityId.
Auth required: Bearer JWT. Caller must be an active member of the community.
Response (Sprint 112 ADR-082 safe projection — no node trust_score/karma, no edge weights; Sprint 115 adds meta):
{
"success": true,
"data": {
"nodes": [{ "id": "uuid", "name": "Alice", "isCurrentUser": true }],
"links": [{ "source": "uuid-a", "target": "uuid-b", "relationship_state": "warm" }],
"meta": { "totalActiveMembers": 151, "truncated": true }
}
}
meta.totalActiveMembers is the community's full active-member count (independent of the 150 cap); truncated is totalActiveMembers > nodes.length. The frontend uses this to render "Showing N of M active members. Bond counts describe only the members shown." and to suppress whole-community structural interpretation when the view is partial.
Primary consumer: apps/frontend/src/components/community/tabs/TrustGraphTab.tsx (This Community sub-tab — the CommunityRingGraph single-ring renderer)
GET /trust/neighborhood/:userId
Sprint 111 (ADR-081) — privacy-scoped recursive ego-neighborhood backing the full-page /network explorer. Walks trust_edges_live outward from :userId and returns each reachable person with their shortest BFS depth.
Query params: depth = 1|2|3 (default 1); optional communityId (UUID).
Auth required: Bearer JWT (mounted at /trust).
Visibility (resolved BEFORE any traversal — resolveNeighborhoodScope in routes/trustGraph.ts):
- With
communityId: the caller and the center user must both be active members of that community. - Without
communityId: the caller and center must share at least one active community; the traversal is scoped to that shared set. - A center the caller can't see returns 404 (
NEIGHBORHOOD_NOT_FOUND) — indistinguishable from a non-existent user, so it can't be used to probe account existence.
Traversal (getTrustNeighborhood in src/database/trustEdgeDb.ts): a recursive CTE seeds the center at depth 0 and walks either endpoint of trust_edges_live, constrained to community_id = ANY(allowed) and active membership in each traversed edge's community, stopping at depth. Each user collapses to its minimum (shortest) depth. Capped at 80 nodes (fetched as maxNodes+1, ordered closest-first, to detect truncation); links are returned only between retained nodes. trust_edges_live is a VIEW — read-only.
formed_recently (Sprint 118, ADR-085): each outward link carries a qualitative boolean — the pair's first edge formation (MIN(tel.created_at) across the pair's per-community edges, aggregated in the links query) falls within the projection's 30-day window (FORMED_RECENTLY_WINDOW_DAYS in disclosureProjection.ts). Derived fail-closed at the response boundary (isFormedRecently); the timestamp itself never leaves the service (ADR-082). A long-standing pair adding an edge in a new community is NOT "new" (MIN semantics). Supplements — never replaces — relationship_state.
Validation errors (ADR-074 shape): INVALID_USER_ID / INVALID_DEPTH / INVALID_COMMUNITY_ID → 400.
Response:
{
"success": true,
"data": {
"nodes": [
{ "id": "center", "name": "Me", "trust_score": 0, "karma": 0, "isCurrentUser": true, "degrees_of_separation": 0 },
{ "id": "peer-1", "name": "Alice", "trust_score": 2.5, "karma": 4, "isCurrentUser": false, "degrees_of_separation": 1 }
],
"links": [{ "source": "center", "target": "peer-1", "relationship_state": "warm", "formed_recently": false }],
"meta": { "depth": 2, "truncated": false }
}
}
Only the authenticated caller's own node is marked isCurrentUser, and (Sprint 111 privacy) only that node carries real trust_score/karma — every other node's are zeroed at the data boundary via redactNodeMetrics in trustEdgeDb.ts. This applies to all graph reads (getTrustGraph, getFullCommunityGraph, getTrustGraphAggregate, getTrustNeighborhood), so a member's reputation numbers never leave the API for anyone else — names and edges (structure) are still returned. The frontend additionally hides them in the UI, but the API is the real boundary.
Primary consumer: apps/frontend/src/pages/network.tsx (ego baseline + progressive expansion).
GET /trust/edge
Return a single trust edge for a user pair in a community.
Query params: userA, userB, communityId
Response: Edge object with effective_weight, or { success: true, data: null } if no edge exists (not 404).
GET /trust/communities (Sprint 79 — inter-community depth)
Inter-community depth graph for the calling user. Nodes are the caller's active
communities plus any community reachable by an inter-community edge (organic
trust or fission lineage): { id, name, member_count, status, is_member }. Links
come in two types:
organic— undirected ties fromsocial_graph.community_trust_edges(accrued as members exchange help across communities);weight= interaction strength andactive_recentlyis a fail-closed qualitative boolean derived fromlast_interaction_atagainst the shared 30-dayFORMED_RECENTLY_WINDOW_DAYSwindow via the semanticisActiveRecentlyalias (which delegates toisFormedRecently). The raw timestamp never leaves the service (Sprint 119, ADR-086).fission— directed parent→child lineage from executedcommunities.split_proposals;weight= 1.
Scoped to the caller's communities + one hop of reach, so a user only sees
communities adjacent to ones they belong to (no global enumeration). Implemented
by getCommunityDepthGraph(callingUserId) in src/database/trustEdgeDb.ts.
Auth required: Bearer JWT.
Primary consumer: apps/frontend/src/components/graphs/BelongingGraph.tsx →
CommunityHubGraph.tsx (My Network → Communities). The client normalizes
active_recently to activeRecently before rendering woven-vs-dormant bridge emphasis.
GET /network
Get the current user's local network graph from the materialized connections table.
Response:
{
"success": true,
"data": {
"nodes": [
{
"id": "uuid-123",
"name": "Mike Chen",
"provider_id": null
},
{
"id": "uuid-789",
"name": "Sarah Rodriguez",
"provider_id": "uuid-provider-456"
}
],
"edges": [
{
"source": "uuid-123",
"target": "uuid-789",
"type": "exchange"
},
{
"source": "current-user-id",
"target": "uuid-123",
"type": "community"
}
]
}
}
Implementation: src/routes/network.ts
Data Source: Reads from social_graph.connections materialized table built by the network materialization process.
Node Fields:
id- User UUIDname- User display nameprovider_id- Optional provider UUID (null if user is not a provider)
Edge Fields:
source- Source user UUIDtarget- Target user UUIDtype- Connection type:"exchange"(mutual help exchange) or"community"(co-members)
Key Features
1. Invitation Code Generation
Codes follow format: KARMYQ-{NAME}-{YEAR}-{RANDOM}
Example: KARMYQ-MIKE-2024-A7B3
KARMYQ: PrefixMIKE: Inviter's name (sanitized, alphanumeric only)2024: YearA7B3: Random 4-character suffix
Database Function:
SELECT auth.generate_invitation_code('Mike Chen', 2024);
-- Returns: KARMYQ-MIKECHEN-2024-A7B3
Collision Prevention: Loop until unique code is generated.
2. BFS Path Computation
Algorithm: computeShortestPath(sourceUserId, targetUserId, communityId)
Steps (Sprint 118 / BUG-028 — the adjacency is the SAME edge set the belonging graph discloses, so every "connected" claim is substantiated by the graph):
- Build adjacency list from
social_graph.trust_edges_live(decay-adjusted view), requiring BOTH endpoints to be active members of the edge's community — mirrors the neighborhood links query. Platform-wide union across communities (ADR-077);DISTINCTcollapses a pair's per-community edges to one hop. - Treat graph as bidirectional (trust flows both ways)
- BFS from source to target (target check runs before the depth gate, so an exactly-3° target is found)
- Max depth: 3 degrees
- Return
nullif no path found
Fallbacks when no live-edge path exists: computeCommunityPath (shared active community, worded
as membership — not a trust connection) then computeInvitationPath (accepted-invitation chain).
Example Output (ADR-082: identity + topology only — no karma on path nodes):
{
degrees: 2,
userIds: ['user-a', 'user-b', 'user-c'],
path: [
{ id: 'user-a', name: 'You' },
{ id: 'user-b', name: 'Mike', exchanged_at: '2026-05-15' },
{ id: 'user-c', name: 'Sarah' }
],
trustScore: 3.2, // internal ranking only (sum of community-scoped effective edge weights); never returned outward
connectionType: 'exchange'
}
Implementation: src/services/pathComputation.ts:23
3. Path Caching
Cache Table: auth.social_distances
path_trust_score is DOUBLE PRECISION (Sprint 120 / BUG-030), preserving the fractional
Ebbinghaus-decay score written by single, batch, and precomputed paths without rounding.
TTL: 7 days
Invalidation:
- Automatic (expires_at timestamp)
match_completedclears cache rows for the two participants- Sprint 118 / BUG-028: cached
exchangerows are revalidated on read againsttrust_edges_livewith active endpoint membership for every cached hop. A stale or malformed exchange cache row is deleted and recomputed, so pre-fix completed-match paths cannot survive behind the 7-day TTL. - Sprint 119 / BUG-029: cached
community_memberrows are ALSO revalidated on read — a pre-fix shape (3-node path or 1° degrees) or a pair that no longer shares any active community is deleted and recomputed. Cachedinvitation_chainrows keep normal TTL semantics for topology, but their node names are re-projected through the ADR-082 identity gate on read.
Cache Hit Rate Target: >95% (most paths precomputed)
Query:
SELECT degrees_of_separation, shortest_path, path_trust_score
FROM auth.social_distances
WHERE user_a_id = $1 AND user_b_id = $2 AND community_id = $3
AND expires_at > NOW()
4. Privacy Controls
Users can control social graph visibility via auth.users columns:
show_connection_path BOOLEAN DEFAULT true; -- Others can see path to me
show_who_invited_me BOOLEAN DEFAULT true; -- Show "Invited by X" on profile
show_who_i_invited BOOLEAN DEFAULT false; -- Hide my invitees from public
Default Posture: Transparent (all visible)
Rationale: Transparency builds trust, which is the core value proposition.
Integration with Other Services
Feed Service
Use Case: Rank feed requests by social proximity
Integration:
// Feed Service calls:
POST /paths/batch
{
"target_user_ids": ["requester-1", "requester-2", ...]
}
// Social Graph Service returns:
[
{ "target_user_id": "requester-1", "degrees_of_separation": 1, "trust_score": 92 },
{ "target_user_id": "requester-2", "degrees_of_separation": 3, "trust_score": 234 }
]
// Feed Service uses this for ranking:
score += (degrees === 1 ? 30 : degrees === 2 ? 20 : degrees === 3 ? 10 : 0)
Benefit: Requests from 1° connections rank 30 points higher.
Request Service
Use Case: Show "Connected through X" badge on request cards
Integration:
// Request Service enriches requests with social context
const path = await fetch(`/paths/${requesterId}`);
// Returns:
{
"degrees_of_separation": 2,
"path": [
{ "name": "You" },
{ "name": "Mike Chen", "relation": "invited you" },
{ "name": "Sarah Rodriguez", "relation": "invited by Mike" }
]
}
Display: Show connection path directly on request card.
Auth Service
Use Case: Accept invitation code during signup
Flow:
- User signs up with invitation code
- Auth Service calls
POST /invitations/accept - Social Graph Service links inviter → invitee
- User's
invited_byfield is set
Events Published
None currently. Future consideration:
invitation.sent- When code is generatedinvitation.accepted- When code is usedpath.computed- When new path is calculated
Events Consumed
match_completed
When a match is marked complete in the Request Service.
Listener: src/events/subscriber.ts — the handler body is the exported
handleMatchCompleted(payload) (Sprint 100), so the reconciliation is integration-testable without
Redis/Bull.
Actions (in order):
- Clear trust path cache (
auth.social_distances) for the two users - Upsert
social_graph.connectionsexchange edge - Reconcile per-community trust edges (Sprint 100 / ADR-078): derive the request's communities
from
requests.request_communities(via the payload'srequest_id) and upsert asocial_graph.trust_edgesweighted edge for each — viareconcileMatchCompletedCommunities()→processMatchCompleted(). This no longer depends on the payload carryingcommunity_id(the request-service publisher never set it, so the oldif (payload.community_id)guard meant a community trust edge essentially never formed — the live audit found 0 trust edges for two communities whose pulses counted 9 completed exchanges each). A request cross-posted to several communities now correctly forms an edge in all of them. - Within
processMatchCompleted, if users have different primary communities, incrementsocial_graph.community_trust_edges(Sprint 65)
Historical gaps are repaired by the one-time idempotent backfill
scripts/backfill-community-connections.sql (not a migration).
Performance Targets
| Metric | Target | Max Acceptable |
|---|---|---|
| Path computation (cached) | <50ms | <100ms |
| Path computation (uncached) | <500ms | <1s |
| Batch path computation (50 users) | <2s | <5s |
| Invitation code generation | <100ms | <200ms |
Common Tasks
Generate Invitation Code
curl -X POST http://localhost:3010/invitations/generate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json"
Accept Invitation
curl -X POST http://localhost:3010/invitations/accept \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"invitation_code": "KARMYQ-MIKE-2024-A7B3"}'
Get Path Between Users
curl http://localhost:3010/paths/{targetUserId} \
-H "Authorization: Bearer $TOKEN"
Get Batch Paths (Feed Ranking)
curl -X POST http://localhost:3010/paths/batch \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"target_user_ids": ["uuid-1", "uuid-2", "uuid-3"]}'
View Service Logs
docker logs karmyq-social-graph-service -f
Environment Variables
# Server
PORT=3010
NODE_ENV=development
# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=karmyq_db
DB_USER=karmyq_user
DB_PASSWORD=your_password_here
# Auth
JWT_SECRET=dev_jwt_secret_change_in_production
# Frontend
FRONTEND_URL=http://localhost:3000
# Logging
LOG_LEVEL=info
Testing
Unit Tests
Status: Not yet implemented
Planned Coverage:
- BFS algorithm correctness
- Path selection logic (shortest path, tie-breaking by trust score)
- Privacy controls (hide path when
show_connection_path = false) - Invitation code generation (uniqueness, format validation)
Integration Tests
Status: Not yet implemented
Planned Coverage:
- End-to-end path computation
- Cache hit/miss scenarios
- Batch path computation
- Invitation acceptance flow
Manual Testing
# 1. Generate invitation code
curl -X POST http://localhost:3010/invitations/generate \
-H "Authorization: Bearer $USER_A_TOKEN"
# 2. Accept invitation (as different user)
curl -X POST http://localhost:3010/invitations/accept \
-H "Authorization: Bearer $USER_B_TOKEN" \
-d '{"invitation_code": "KARMYQ-ALICE-2024-A7B3"}'
# 3. Compute path
curl http://localhost:3010/paths/$USER_B_ID \
-H "Authorization: Bearer $USER_A_TOKEN"
Trust Decay Model (Sprint 68)
The trust decay model uses intrinsic Ebbinghaus decay computed live by a PostgreSQL view. No background job modifies trust weights — time and the view formula do all the work.
raw_weight: Peak accumulated weight. Only grows when a new interaction is recorded. Never decayed.stability: Starts at 1.0. Grows by 20% per interaction:stability = stability × 1.20. A higher stability value produces a longer effective half-life.current_weight: Computed bytrust_edges_liveat query time:raw_weight × e^(-days / (stability × half_life))trust_decay_config: Per-community tuning table. A global row (community_id = NULL) acts as the platform default.
All getTrustGraph* functions query trust_edges_live, never trust_edges directly.
The daily trust edge sweep job (trustEdgeSweepJob.ts) removes edges where current_weight < disappearance_threshold.
See ADR-056 for full decision record.
Recent Changes
Sprint 120: True Scores & Graph Polish (2026-07-17)
- FIX (BUG-030):
auth.social_distances.path_trust_scoreis nowDOUBLE PRECISION, matching fractional decay-derived trust scores without rounding. Batch path computation isolates failures per target, logs the target id, and preserves all other results and ranking degrees. - CLARITY: interaction recency uses
isActiveRecently, an alias over the same single 30-day formation window; outwardactive_recentlybehavior is unchanged.
Sprint 119: Truthful Surfaces (2026-07-10)
- NEW (ADR-086):
GET /trust/communitiesorganic links now carryactive_recently, derived fail-closed fromcommunity_trust_edges.last_interaction_atagainst the shared 30-day window. The timestamp remains internal. The frontend normalizes the boolean toactiveRecentlyand uses it to distinguish recently active woven bridges from dormant ones; fission lineage is unchanged. - FIX (BUG-029):
computeCommunityPath(pathComputation.ts) no longer manufactures a path through the community's earliest-joined admin — acommunity_memberpath is now EXACTLY the two endpoints +community_name(newgetSharedCommunityName/getSharedCommunityNameshelpers, scope-preferred + deterministically ordered).connection_typeanddegrees: 2are unchanged (feed proximity ranking readsdegrees_of_separationonly — now uniformly 2, including the former 1° admin special case). Cache-hit responses onGET /paths/:targetUserId+POST /paths/batchare enriched withcommunity_namefrom live shared membership (one set-based query for the whole batch). Cachedcommunity_memberrows are revalidated on read: pre-fix shapes (3-node path / 1° degrees) and pairs that no longer share any community are deleted and recomputed (extends the S118 read-time revalidation; closes the stale-claim and mixed-ranking TTL windows).GET /trust-card/:targetUserIdnow returnscommunity_namefor community paths so the card can name the community instead of drawing a person route. Identity lookups reusegetPublicIdentities(ADR-082 disclosure gate) — includingcomputeInvitationPath(cross-agent review decision), and cachedinvitation_chainnode names re-project through the gate on read (gateCachedPathIdentities) so a departed chain member never stays disclosed for the TTL.
Sprint 118: Invited Arrival & the Living Graph (2026-07-08, ADR-085)
- FIX (BUG-028):
computeShortestPath(pathComputation.ts) now builds its BFS adjacency fromsocial_graph.trust_edges_livewith active-membership joins on BOTH endpoints — the same edge set/trust/neighborhooddiscloses — instead of all-time completedrequests.matches. On the curated demo, 742 of 2103 completed-match pairs had no trust edge at all (seeded matches bypassed thematch_completedevent), so badges claimed connections the graph couldn't show. Topology stays platform-wide (ADR-077); community/invitation fallbacks unchanged. Also fixed: the BFS target check now runs before the depth gate, so exactly-3° paths are found. Cachedexchangerows are revalidated against live graph edges on read; stale pre-fix rows are deleted and recomputed instead of surviving for the 7-day TTL. - NEW:
/trust/neighborhoodlinks carryformed_recently: boolean(see the endpoint section) — links query gainsMIN(tel.created_at) AS formed_at(internal), projection derives the boolean fail-closed againstFORMED_RECENTLY_WINDOW_DAYS = 30.SafeBelongingLinkSchema(shared) gains the optional field.
Sprint 98: Trust Truth Audit (2026-06-14, ADR-077)
- NEW:
src/services/communityContext.ts—resolveCommunityContext(header, jwtCurrentCommunityId)+PLATFORM_COMMUNITY_IDsentinel +isUuid. Single resolver used by/paths/:id,/paths/batch,/trust-card/:id. Fixes BUG-098-002: the routes no longer pass the literal string'platform'into the UUIDauth.social_distances.community_idcolumn (which 500'd for users with nocurrentCommunityId); a malformedX-Community-IDis now a 400, missing context falls back to the platform sentinel. Each response carriesscope: 'community' | 'platform'. - SEMANTICS (ADR-077): exchange-path topology is platform-wide (schema can't attribute a match to one community —
help_requestshas nocommunity_id);communityIdscopes only score/karma/cache key. - FIX (BUG-098-003):
getTrustGraph(ego) andgetTrustGraphAggregatenow filter neighbors through anactive_neighborsCTE (JOIN communities.members ... status='active') and constrain links to the resolved node set, so departed members no longer appear under "your network in this community".getFullCommunityGraphalready filtered active members. - LEGACY (BUG-098-006):
GET /networkmarked legacy in source (frontendgetNetwork()wrapper removed); use/trust/graph*. Orphanedtype='exchange'connections cleaned by migration20260614-trust-truth-repair.sql.
Sprint 90: Visible Decay Model (2026-06-07, ADR-070)
- NEW:
classifyDecayTier(currentWeight, threshold)in@karmyq/shared(src/trust/decayTier.ts) — the SINGLE source of the band math (strongr≥3,warm2–3,fading1.3–2,nearly_forgotten1–1.3,sweptr<1). Consumed by the routes + tests; never inline the bands. - CHANGED:
GET /trust/graph/:communityId+/trust/graph/:communityId/full— each edge now also carriescurrentWeight(= liveeffective_weight),disappearanceThreshold(resolved viagetDecayConfig), anddecayTier. Enriched intrustGraph.tsvia the pureenrichLinksWithDecay(links, threshold)(exported, unit-tested). - NEW:
GET /trust/me/memory?communityId=—{ activeCount, fading[], nearlyForgotten[] }fromtrust_edges_live; backs the profile Memory section. Members-only (DB member check). - NEW:
GET /trust/relationships/fading?communityId=— nearly-forgotten bonds for the re-warming nudge. - NEW: pure helpers
buildMemoryResponse(rows, threshold)+enrichLinksWithDecayexported fromsrc/routes/trustGraph.ts(tested intests/tdd/sprint-90-decay-tier.test.ts). Reads only —trust_edges_liveis a VIEW.
Sprint 68: Interaction Half-Life (2026-05-26)
- NEW:
stability FLOAT NOT NULL DEFAULT 1.0column onsocial_graph.trust_edges - NEW:
social_graph.trust_decay_configtable — per-community Ebbinghaus decay parameters - NEW:
social_graph.trust_edges_liveVIEW — computescurrent_weightat query time - CHANGED:
upsertTrustEdge— now also growsstabilityon each interaction - CHANGED: All
getTrustGraph*functions — querytrust_edges_live; returnraw_weight+effective_weightper link - NEW:
GET /trust/decay-config— global decay config - NEW:
GET /trust/decay-config/:communityId— community-specific decay config (falls back to global) - NEW:
PUT /trust/decay-config/:communityId— admin-only: update community decay parameters - NEW:
src/database/trustDecayConfigDb.ts— decay config read/upsert helpers - NEW:
src/routes/trustDecayConfig.ts— decay config router - NEW: Migration:
infrastructure/postgres/migrations/20260526-interaction-halflife.sql
Sprint 67: Ego-Network Rewrite + Aggregate Endpoint (2026-05-26)
- CHANGED:
GET /trust/graph/:communityId— now returns ego-network (calling user + direct neighbors only), not full community graph. Response shape changed from{ nodes, edges }to{ nodes, links }. - NEW:
GET /trust/graph— aggregate ego-network across all of calling user's communities. Used by dashboard Your Network panel. - CHANGED:
src/database/trustEdgeDb.ts—getTrustGraphnow acceptscallingUserId; addedgetTrustGraphAggregate. NewTrustNode(withisCurrentUser) andTrustLinkinterfaces. - CHANGED:
src/components/NetworkGraph.tsx— now callsgetTrustGraphAggregateinstead of deprecated/networkendpoint.
Sprint 65: Trust Graph Foundation (2026-05-25)
- NEW:
social_graph.trust_edges— weighted, community-scoped, bidirectional user-user edges - NEW:
social_graph.interaction_weights— modular per-type weights (match_completed=10, endorsement=5, karma_given=3, event=2), overridable per community - NEW:
social_graph.community_trust_edges— community-to-community bonds (fractal level 2) - NEW:
GET /trust/graph/:communityId— graph data for Sprint 66 visualizer - NEW:
GET /trust/edge— single edge lookup - CHANGED:
src/events/subscriber.ts— now also callsprocessMatchCompletedto upsert trust edges onmatch_completed - CHANGED:
src/services/pathComputation.ts— trust score now derived from edgeeffective_weight(sum of path edge weights) instead of intermediate node karma - NEW:
docs/adr/ADR-054-trust-graph-architecture.md - BACKFILL: Migration populates trust_edges from existing
requests.matchesfor demo server
Sprint 56: Logger migration (2026-05-17)
- CHANGED:
src/config/logger.ts— now usescreateLogger('social-graph-service')from@karmyq/shared; local 21-line winston setup removed - FIXED: 12 call sites across
subscriber.ts,index.ts,invitations.ts,network.ts,paths.ts,trustCard.tsupdated fromlogger.error('msg', { error })tologger.error('msg', error instanceof Error ? error : undefined)to match shared logger signature
Sprint 44: Structured Logging (2026-04-04)
- NEW: Added
createLogger+requestLoggingMiddlewarefrom@karmyq/shared/utils/loggertosrc/index.ts - Route handler errors now emit
{ service: 'social-graph-service', endpoint, error.message }structured objects via(req as any).logger?.error() src/routes/trustCard.tsuses module-levelloggerfrom./config/loggerfor catch blocks
Sprint 38: Trust Card Endpoint (2026-03-24)
- NEW: Added
GET /trust-card/:targetUserIdendpoint returning trust tier, path, and invitation chain for a target user - Logic: Computes primary path via
computeTrustPath(exchange → community → invitation), then separately computes invitation path for side-by-side display - Karma: Fetches target user's total karma from reputation-service via
REPUTATION_API_URLenv var - Trust tiers: Emerging (0–29 karma), Trusted (30–99), Pillar (100+)
- File:
src/routes/trustCard.ts
Sprint 27: Network Graph Materialization
- NEW: Added
GET /networkendpoint returning materialized connection graph (nodes + edges) - NEW: Subscribed to
match_completedevent to upsert exchange connections intosocial_graph.connections - Schema: Added
social_graph.connectionstable withCREATE UNIQUE INDEXfor expression-based uniqueness - Date: 2026-03-16
Known Issues & TODOs
Current Limitations
- No invitation code expiration
- No rate limiting on invitation generation
- No detection of "invitation farms" (fake accounts)
- Cache invalidation is time-based only (no manual purge)
Future Enhancements
- Invitation code expiration (30-day default)
- Single-use vs. multi-use invitation codes
- Detect and flag suspicious invitation patterns
- Precompute paths for active users (background job)
- Network visualization API (graph view)
- "Introduce me" feature (request introduction through mutual connection)
- Trust endorsements (let users vouch for connections)
Related Documentation
- Design Document: docs/features/SOCIAL_GRAPH_TRUST_PATHS.md
- Migration: infrastructure/postgres/migrations/009_social_graph.sql
- Feed Integration: services/request-service/CONTEXT.md (
/requests/feed)
Sprint 112 — Privacy-safe relationship contracts (ADR-082, 2026-06-24)
Person graph/path/card/memory contracts now carry identity + structure + a qualitative
relationship_state only — never another member's exact reputation or raw edge weight. Internal
weights and numeric path strength are unchanged (feed ranking + decay classification still use them);
projection happens at the response boundary in src/services/disclosureProjection.ts.
GET /trust/graph,/trust/graph/:communityId[/full],/trust/neighborhood/:userId→SafeBelongingNode(notrust_score/karma, incl. caller's node) +SafeBelongingLink(relationship_state, noraw_weight/effective_weight).GET /paths/:targetUserId,POST /paths/batch→ drop outwardtrust_score(degrees + topology + scope only; internalpath_trust_scorecaching kept). Path intermediate-node karma removed at source (pathComputation.ts).GET /trust-card/:targetUserId→ identity + path + degrees + path_type + scope; no karma / tier.GET /trust/me/memory,/trust/relationships/fading→ keep peer identity + decay tier + dates + counts; drop exactcurrentWeight.GET /trust/edge→ retired410 TRUST_EDGE_ENDPOINT_RETIRED(internalgetTrustEdge()kept).GET /trust/decay-config/:communityId→ active-member gated;PUTstays admin-only.GET /invitationsdrops invitee karma;GET /invitations/statsdropsavg_invitee_karma+avg_invitee_trust_score(counts/acceptance/network/tier kept).
Sprint 116 — Reciprocal relationship projection (2026-06-30, ADR-084)
src/services/relationshipContextService.ts builds a deterministic, reciprocal dual-ego projection
from platform-wide completed-help topology. It preserves a path up to six degrees, prioritizes mutual
and path-adjacent one-hop nodes under an eight-per-side default cap, fills by stable UUID order, and
reports truncation. Reversing the two anchors swaps orientation without changing the disclosed path,
shared-node, or unordered-link sets.
src/database/relationshipContextDb.ts reads active identities and active community affiliations,
uses completed requests.matches as topology truth, and aggregates community trust rows only long
enough to derive relationship_state and bond_depth. Exact counts, weights, timestamps, karma, and
reputation never leave the projection. The projection deliberately contains no request reachability
or provider role; request-service owns those after authorizing a concrete request/offer context.
The projection is exposed only through POST /internal/relationship-context; no public member-search
or arbitrary-target route is introduced.
The two request-service-authorized anchor identities may be projected even when one currently has no active community membership (for example, a platform-scoped request participant). That exception is passed explicitly to the identity query and applies only to those two server-derived IDs. Path and surrounding-network identities remain active-membership-only, so this cannot become an inactive-user browse surface; memberless anchors truthfully receive empty affiliations/topology when applicable.
Request-service is the only intended consumer. It owns public request/offer authorization, verifies the returned anchor IDs, and treats timeout/unavailability as a non-blocking 503 for the context panel.
Status: ✅ MVP Complete (v9.1.0) Version: 9.1.0 Last Updated: 2026-06-30 (Sprint 116 reciprocal projection complete)