Skip to content

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

  1. User A opens Find Allies (entry point in ally list / dashboard)
  2. User A types another user's username or email (minimum 2 characters)
  3. 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)
  4. User A taps "Send Request"
  5. Backend creates UserAlly{user: A, ally: B, status: 'pending'}
  6. Backend fires UserNotification{kind: :ally_request} to User B
  7. User A's button updates to "Pending" (no page reload)

Responding to an Ally Request

  1. User B opens Pending Requests (entry point in profile / ally list)
  2. User B sees incoming requests with:
    • Requester profile photo
    • Requester name
    • Accept and Decline buttons
  3. User B taps Accept or Decline
  4. Accept: relationship moves to accepted, User A gets UserNotification{kind: :ally_accepted}, both users' ally lists update
  5. Decline: relationship deleted, no notification to requester (silent)

Managing Allies

  1. User A opens their ally list (in profile / dashboard)
  2. User A can:
    • Remove: breaks the relationship (hard delete of UserAlly row in either direction)
    • Block: sets UserAlly.status: 'blocked'
  3. 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 allyStatus enum
  • 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 community
    • post: user posted in a community
    • achieve: user completed a milestone
    • follow: 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 UserAlly row
  • 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 UserAlly row (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):

EventFire SitePayloadNotes
ally_search_performedionic_frontend/src/composables/useAllySearch.ts:104{ query_length: number }Fires on debounced search query (≥2 chars)
ally_request_sentionic_frontend/src/views/AllyDiscoveryPage.vue:228{ target_public_id: string }Fires on sendAllyRequest mutation success
ally_request_sentionic_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_acceptedionic_frontend/src/components/allies/AllyRequestToast.vue:167{ request_public_id: string }Fires on acceptAllyRequest mutation success from the in-app toast
ally_request_acceptedionic_frontend/src/components/allies/PendingAllyRequestRow.vue:121{ request_public_id: string }Fires on acceptAllyRequest mutation success from the pending-requests list row
ally_request_declinedionic_frontend/src/components/allies/AllyRequestToast.vue:183{ request_public_id: string }Fires on declineAllyRequest mutation success from the in-app toast
ally_request_declinedionic_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 SyncUser mutation
  • Set immediately on acceptAllyRequest mutation 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 at blocked if 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' userAllies query, 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 useNotifications composable (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:

  1. GoalEventtype: 'goal'
  2. UserAction{action: :community_joined}type: 'join'
  3. UserAction{action: :community_post_created}type: 'post'
  4. UserAction{action: :milestone_completed}type: 'achieve' (reuses :roadmap_step_completed)
  5. 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

  1. User A opens the Find Allies page or the dashboard partner widget (when no partner exists)
  2. User A taps Invite an Accountability Partner (feature-flagged with partner-invite-enabled)
  3. The app calls createAllyInvite mutation, generating a 43-character token
  4. 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
  5. User A sends the link to User B via their preferred channel
  6. The app fires PostHog partner_invite_created and partner_invite_link_shared events (with invite_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)

  1. User B receives the link and opens it
  2. 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 the UserAlly{status: 'accepted'} relationship
    • User A receives a notification: "User B accepted your accountability-partner invite"
    • The app fires PostHog partner_invite_accepted event
    • Both users' ally lists update immediately

Accepting an Invite (New User)

  1. User B receives the link but is not signed in
  2. User B opens the invite URL /invite/{token}
  3. The invite landing page displays (unauthenticated, same branded layout as above)
  4. User B taps "Create account"
  5. The app routes to the sign-up flow with the invite_token pre-populated in route state
  6. User B completes sign-up
  7. The onboarding flow routes to the accept screen (same screen as #2 above)
  8. 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

  1. User A opens the share sheet again while invites are active
  2. Cap-exceeded state (if 3 pending): Shows the 3 active invites with expiry times
  3. User A can revoke individual invites via inline "Revoke" button
  4. The app calls revokeAllyInvite(invite_token:), setting status: 'revoked'
  5. 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:

StateConditionDisplay
loadingFetching invite metadataSpinner + "Loading..."
validInvite is pending + not expiredInviter photo, name, accept button
acceptedInvite already accepted by this user"You're partners." message + link to dashboard
already_usedInvite was already accepted by someone else"This invite is taken. Someone already claimed it."
expiredInvite is expired (7+ days old)"This link has expired"
revokedInviter revoked the invite"This link is no longer valid"
not_foundToken does not exist"We couldn't find that invite"
self_inviteCurrent user is the inviter"That's you. You can't invite yourself" (FORBIDDEN)
blockedInviter has blocked current user, or vice versa"You're blocked" (FORBIDDEN)
flag_offFeature flag partner-invite-enabled is off"This feature is not available yet"
errorUnexpected 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:

EventFire SitePayloadNotes
partner_invite_createdInviteShareSheet.vue after createAllyInvite success{ invite_token }Fires when the invite is first generated
partner_invite_link_sharedInviteShareSheet.vue after Share/Copy{ method: 'native' | 'copy', invite_token }Fires after user shares the link (either native share or copy-to-clipboard)
partner_invite_acceptedPartnerInvitePage.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

Invite links use the format /invite/{token} and are claimed via:

  • iOS: Universal Link via .well-known/apple-app-site-association (AASA file in marketing_landing/)
  • Android: Intent link via assetlinks.json (Firebase Dynamic Links, configured via Capacitor)
  • Web: Standard route /invite/:token handled 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


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)

Loading…