Ally Social Graph
Allies are users who have formed an intentional accountability partnership. The ally system is the foundation of Objectuve's social accountability features.
Overview
An ally relationship is a bidirectional connection between two users. When User A sends an ally request to User B, the relationship starts in pending state. User B can then accept (moving the relationship to accepted) or decline (deleting the relationship). Accepted allies appear in each user's ally list, enabling accountability workflows.
Key difference: Ally relationships are separate from and more general than accountability partnerships. All accountability partnerships (existing SendPartnerRequest flow) create an underlying ally relationship with the accountability_partner: true flag. The ally layer shipped in v2.0 allows users to form basic ally relationships without upgrading to accountability partnerships.
User Flows
Searching for and Requesting an Ally
- User A opens Find Allies (entry point in ally list / dashboard)
- User A types another user's username or email (minimum 2 characters)
- User A sees a paginated list of matching users, each with:
- Profile photo
- First name + last name
- Username (
@username) - Ally status button (Send Request / Pending / Already Allies / N/A for blocked or self)
- User A taps "Send Request"
- Backend creates
UserAlly{user: A, ally: B, status: 'pending'} - Backend fires
UserNotification{kind: :ally_request}to User B - User A's button updates to "Pending" (no page reload)
Responding to an Ally Request
- User B opens Pending Requests (entry point in profile / ally list)
- User B sees incoming requests with:
- Requester profile photo
- Requester name
- Accept and Decline buttons
- User B taps Accept or Decline
- Accept: relationship moves to
accepted, User A getsUserNotification{kind: :ally_accepted}, both users' ally lists update - Decline: relationship deleted, no notification to requester (silent)
Managing Allies
- User A opens their ally list (in profile / dashboard)
- User A can:
- Remove: breaks the relationship (hard delete of
UserAllyrow in either direction) - Block: sets
UserAlly.status: 'blocked'
- Remove: breaks the relationship (hard delete of
- Blocked users cannot send requests to the blocker; blocked users are silently excluded from search results
GraphQL API
Queries
searchUsers(query: String!, first: Int, after: String)
- Auth-only (not admin-only)
- Returns paginated user search results with per-user
allyStatusenum - Rate-limited: 10 requests per minute per user
- Excludes: current user, users who have blocked current user
pendingAllyRequests
- Returns all pending incoming ally requests (status: pending, ally: current_user)
- Ordered by creation date descending
userAllies (existing, unchanged)
- Returns accepted allies only (status: accepted)
allyActivityFeed(limit: Int, offset: Int) (updated in Phase 88)
- Returns aggregated activity from accepted allies
- Covers 5 event types:
goal: goal event (user checked in, milestone completed)join: user joined a communitypost: user posted in a communityachieve: user completed a milestonefollow: user accepted an ally request
Mutations
sendAllyRequest(targetUserId: ID!)
- Creates or returns existing
UserAlly{status: 'pending'} - Fires notification to target user (unless already pending or already allies)
- Idempotent: duplicate sends do not fire duplicate notifications
- Fails closed: blocked users cannot send requests
acceptAllyRequest(requestId: ID!)
- Sets
UserAlly{status: 'accepted', accepted_at: Time.current} - Fires notification to requester
- Fires
UserAction{action: :ally_added}for activity feed
declineAllyRequest(requestId: ID!)
- Hard-deletes the
UserAllyrow - No notification to requester (silent decline)
blockAlly(targetUserId: ID!)
- Creates or updates
UserAlly{status: 'blocked'} - Blocked user cannot send requests; excluded from search
removeAlly(targetUserId: ID!)
- Hard-deletes the
UserAllyrow (works bidirectionally) - Either user can remove the ally relationship
Telemetry
Four distinct PostHog event names fire from seven documented sites (three events — ally_request_sent, ally_request_accepted, ally_request_declined — each fire from two components):
| Event | Fire Site | Payload | Notes |
|---|---|---|---|
ally_search_performed | ionic_frontend/src/composables/useAllySearch.ts:104 | { query_length: number } | Fires on debounced search query (≥2 chars) |
ally_request_sent | ionic_frontend/src/views/AllyDiscoveryPage.vue:228 | { target_public_id: string } | Fires on sendAllyRequest mutation success |
ally_request_sent | ionic_frontend/src/components/allies/AllyLookupModal.vue:437 | { target_public_id: string, source: 'lookup_modal' } | Second fire site — same event from the Ally Lookup Modal's Search/Suggested tabs |
ally_request_accepted | ionic_frontend/src/components/allies/AllyRequestToast.vue:167 | { request_public_id: string } | Fires on acceptAllyRequest mutation success from the in-app toast |
ally_request_accepted | ionic_frontend/src/components/allies/PendingAllyRequestRow.vue:121 | { request_public_id: string } | Fires on acceptAllyRequest mutation success from the pending-requests list row |
ally_request_declined | ionic_frontend/src/components/allies/AllyRequestToast.vue:183 | { request_public_id: string } | Fires on declineAllyRequest mutation success from the in-app toast |
ally_request_declined | ionic_frontend/src/components/allies/PendingAllyRequestRow.vue:141 | { request_public_id: string } | Fires on declineAllyRequest mutation success from the pending-requests list row |
Note: PendingAllyRequestsPage.vue (158 lines) does not fire any of these events itself — the accept/decline telemetry lives in the AllyRequestToast.vue and PendingAllyRequestRow.vue components it renders.
User Property: has_ally (boolean)
- Set at boot via
SyncUsermutation - Set immediately on
acceptAllyRequestmutation success - Computed:
user.user_allies.where(status: 'accepted').exists? || UserAlly.where(ally: user, status: 'accepted').exists?
Feature Flags
ally-request-flow-enabled (PostHog flag)
- Controls visibility of all new ally surfaces: search page, pending-requests page, activity-feed real wiring, toast notifications
- Default: false (feature off in production until explicitly enabled)
- Fail-closed: undefined flag → false
- When flag is false, legacy mock data surfaces; when true, real backend data flows
Implementation Notes
Bidirectional Storage Model
UserAlly uses a one-row-per-pair storage model: User A + User B results in a single UserAlly{user_id: A, ally_id: B} row. The row is created by whoever initiates the request. When User B removes or blocks User A, the operation finds the row in either direction (via UserAlly.find_for_users(current_user, ally)) and mutates that single row.
Block Semantics
Updated in v4.23 (OBJ-1889) — blocking is now symmetric and reversible from the UI. The behavior below reflects what's shipped today; see Blocking & Safety for the full write-up. When either user blocks the other:
- The relationship transitions to
status: 'blocked'(or is created fresh atblockedif no prior relationship existed) - Neither user can send requests to the other
- Both users are silently excluded from each other's search results
- Both users'
userAlliesquery, activity feeds, and comment/reaction lists exclude each other — the exclusion isn't one-way
Blocked relationships are reversible from Settings → Privacy → Blocked (/settings/blocked) via unblockUser. Unblocking destroys the UserAlly row outright — it does not restore a prior pending/accepted relationship.
Notification Path
Ally notifications (ally_request, ally_accepted) flow through the existing UserNotification pipeline:
- Fires
UserNotification.generate() - Calls
Social::SendPushNotification(unchanged from pre-v2.0) - Surfaces in-app toast via
useNotificationscomposable (Phase 90) - Also eligible for push via APNs / FCM (same as other user notifications)
Activity Feed Aggregation
allyActivityFeed aggregates events from accepted allies across five sources:
GoalEvent→type: 'goal'UserAction{action: :community_joined}→type: 'join'UserAction{action: :community_post_created}→type: 'post'UserAction{action: :milestone_completed}→type: 'achieve'(reuses:roadmap_step_completed)UserAction{action: :ally_added}→type: 'follow'
All events are ordered by timestamp descending and paginated.
Inviting an Accountability Partner (v2.3)
The invite system solves the social cold-start problem at launch: new users arrive with zero peers in the network, making ally search return empty results. Invites let users pull their real-world allies into the app with a pre-filled request.
User Flows
Generating and Sharing an Invite
- User A opens the Find Allies page or the dashboard partner widget (when no partner exists)
- User A taps Invite an Accountability Partner (feature-flagged with
partner-invite-enabled) - The app calls
createAllyInvitemutation, generating a 43-character token - A share sheet displays with three actions:
- Share (native) — opens Capacitor
Share.share()to send via Messages, WhatsApp, email, etc. - Copy link — copies
https://app.objectuve.com/invite/{token}to clipboard - Copy shareable text — copies pre-written invitation text + link
- Share (native) — opens Capacitor
- User A sends the link to User B via their preferred channel
- The app fires PostHog
partner_invite_createdandpartner_invite_link_sharedevents (withinvite_token+ share method)
Constraints:
- Max 3 active (pending) invites per user at a time (enforced by
AllyInvite.MAX_ACTIVE_INVITES) - Rate limit: max 10 invites per hour per user (enforced by
AllyInvite.rate_limited?) - Invites expire after 7 days (
INVITE_EXPIRY_DAYS = 7)
Accepting an Invite (Existing User)
- User B receives the link and opens it
- If User B is already signed in:
- The app fetches
allyInvitePreview(unauthenticated query) to display inviter name + photo - User B sees a branded landing page: inviter photo, name, and "Start as accountability partners" CTA
- User B taps accept
- The app calls
acceptAllyInvite(invite_token:), creating theUserAlly{status: 'accepted'}relationship - User A receives a notification: "User B accepted your accountability-partner invite"
- The app fires PostHog
partner_invite_acceptedevent - Both users' ally lists update immediately
- The app fetches
Accepting an Invite (New User)
- User B receives the link but is not signed in
- User B opens the invite URL
/invite/{token} - The invite landing page displays (unauthenticated, same branded layout as above)
- User B taps "Create account"
- The app routes to the sign-up flow with the
invite_tokenpre-populated in route state - User B completes sign-up
- The onboarding flow routes to the accept screen (same screen as #2 above)
- After acceptance, User B enters the onboarding wizard, and User A receives a notification
Implementation note: The invite token is stashed in inviteReplayStore (a Pinia store) and replayed after sign-up completes, triggering automatic acceptAllyInvite without requiring a second tap.
Revoking and Managing Invites
- User A opens the share sheet again while invites are active
- Cap-exceeded state (if 3 pending): Shows the 3 active invites with expiry times
- User A can revoke individual invites via inline "Revoke" button
- The app calls
revokeAllyInvite(invite_token:), settingstatus: 'revoked' - The revoked invite can no longer be accepted; anyone who clicks the link afterward sees "This invite has already been used or is no longer valid"
Landing Page States
The invite landing page (PartnerInvitePage.vue) renders 11 states:
| State | Condition | Display |
|---|---|---|
loading | Fetching invite metadata | Spinner + "Loading..." |
valid | Invite is pending + not expired | Inviter photo, name, accept button |
accepted | Invite already accepted by this user | "You're partners." message + link to dashboard |
already_used | Invite was already accepted by someone else | "This invite is taken. Someone already claimed it." |
expired | Invite is expired (7+ days old) | "This link has expired" |
revoked | Inviter revoked the invite | "This link is no longer valid" |
not_found | Token does not exist | "We couldn't find that invite" |
self_invite | Current user is the inviter | "That's you. You can't invite yourself" (FORBIDDEN) |
blocked | Inviter has blocked current user, or vice versa | "You're blocked" (FORBIDDEN) |
flag_off | Feature flag partner-invite-enabled is off | "This feature is not available yet" |
error | Unexpected server error | "Something went wrong" |
All states include:
- Glass-morphism card with brand wordmark
- Inviter avatar (72px with glow ring on valid state)
- Centered layout, mobile-first
- Dark mode support
- Motion-safe animations (no animation on
prefers-reduced-motion) - ARIA roles + live regions for accessibility
Telemetry
Three PostHog events fire from documented sites:
| Event | Fire Site | Payload | Notes |
|---|---|---|---|
partner_invite_created | InviteShareSheet.vue after createAllyInvite success | { invite_token } | Fires when the invite is first generated |
partner_invite_link_shared | InviteShareSheet.vue after Share/Copy | { method: 'native' | 'copy', invite_token } | Fires after user shares the link (either native share or copy-to-clipboard) |
partner_invite_accepted | PartnerInvitePage.vue after acceptAllyInvite success | (none) | Fires when the recipient accepts the invite |
The invite_token property in the first two events allows joining the created→shared→accepted→signup funnel in PostHog to measure conversion rates.
Feature Flag
partner-invite-enabled (PostHog flag)
- Controls visibility of invite CTAs on ally discovery page and dashboard partner widget
- Controls rendering of the landing page flag-off state
- Default: false (feature off in production until explicitly enabled)
- Fail-closed: when flag is undefined or false, invite surfaces are hidden; landing page shows
state-flag-off
Deep Link Configuration
Invite links use the format /invite/{token} and are claimed via:
- iOS: Universal Link via
.well-known/apple-app-site-association(AASA file inmarketing_landing/) - Android: Intent link via
assetlinks.json(Firebase Dynamic Links, configured via Capacitor) - Web: Standard route
/invite/:tokenhandled by Vue Router
The AASA file at marketing_landing/.well-known/apple-app-site-association includes /invite/* in the paths array, allowing iOS to recognize the link as a native app link when the app is installed.
Out of Scope (v2.0)
- Mutual allies / suggestions — No friend-of-friend enrichment; search is intentional (user types a name they know), not discovery
- Real-time updates — Pending-inbox and activity-feed poll/refetch; no ActionCable subscription for live updates
Shipped since v2.0: block-list management UI and unblock-from-UI, both listed as out-of-scope in the original v2.0 write-up, shipped in the v4.23 Guideline 1.2 milestone (OBJ-1889) — see Blocking & Safety.
See Also
- Two-layer social model — Ally layer vs. accountability-partner layer
- GraphQL API reference — Full schema for ally mutations + queries
- v2.0 milestone narrative — Shipping timeline and key decisions
- Blocking & Safety — current block/unblock behavior, symmetric content filtering, and Settings management (supersedes the v2.0 Block Semantics behavior described above)
Version: v2.3 (shipped 2026-05-22)
Last updated: 2026-07-29 (Block Semantics + Out of Scope corrected for v4.23 Guideline 1.2 milestone, OBJ-1889 — see Blocking & Safety)