Skip to content

Allies — Feature & Testing Guide

Overview

Allies are Objectuve's lightweight friendship layer — a mutual connection between two users that enables accountability without social noise. Once accepted, an ally relationship surfaces the other person's goal progress in your activity feed, letting you cheer them on without browsing a general social timeline.

The data model, GraphQL API, and all mutations shipped in v2.0 (2026-05-18). UserAlly records are created with a pending status and transition to accepted on confirmation or blocked to suppress a connection. The allyActivityFeed query resolves live data from accepted allies' goal events across five event types (goal check-in, community join, community post, milestone completion, ally added). The mutations to send, accept, block, and remove ally requests are fully implemented and wired in the frontend via usePartnership composable. The AllyActivityFeed.vue component is fully wired to the real query with live data; mock activities were removed in v3.9.181.

Allies are deliberately distinct from community membership. Communities are shared spaces around a goal category; allies are personal connections. An ally's goal does not need to be shared into a community for it to appear in your feed — any public or allies-only, incomplete goal will appear (see Goal visibility tiers below).


Data Model

Table: user_allies

ColumnTypeConstraintsNotes
idbigintPKInteger ID, never exposed to API clients
user_idbigintFK → users, not nullThe user who initiated the request
ally_idbigintFK → users, not nullThe user who received the request
statusstring(50)not null, default pendingOne of: pending, accepted, blocked
accepted_atdatetimenullableSet when status transitions to accepted
created_atdatetimenot null
updated_atdatetimenot null

Indexes:

  • index_user_allies_on_user_id — single-column index on user_id
  • index_user_allies_on_ally_id — single-column index on ally_id
  • index_user_allies_on_user_id_and_ally_idunique compound index, prevents duplicate pairings

Foreign keys:

  • user_allies.user_idusers.id
  • user_allies.ally_idusers.id

UserAlly inherits from ApplicationRecord, not PublicRecord. There is no public_id column on this table; the relationship is identified by the user_id/ally_id pair.


Ally Statuses

StatusMeaningTransitions From
pendingRequest sent, awaiting responseInitial creation
acceptedMutual connection — both users see each other's feedpending
blockedBoth users are suppressed from each other — symmetric, reversible from Settings (see Blocking & Safety)pending or accepted, or created fresh with no prior relationship

The model validates status as present and included in %w[pending accepted blocked]. The UserAlly model defines two named scopes:

ruby
scope :accepted, -> { where(status: 'accepted') }
scope :pending,  -> { where(status: 'pending') }

There is no blocked scope. There is no scope :blocked defined on the model.


User Model Associations

The User model defines four associations and one instance method for ally access:

ruby
has_many :initiated_allyships, class_name: 'UserAlly', foreign_key: :user_id,  dependent: :destroy
has_many :received_allyships,  class_name: 'UserAlly', foreign_key: :ally_id, dependent: :destroy

has_many :added_allies,  through: :initiated_allyships, source: :ally
has_many :adding_allies, through: :received_allyships,  source: :user

def allies
  User.where(id: (added_allies.pluck(:id) + adding_allies.pluck(:id)).uniq)
end
Association / MethodReturnsNotes
initiated_allyshipsUserAlly recordsRows where this user is the requester
received_allyshipsUserAlly recordsRows where this user is the recipient
added_alliesUser recordsUsers this user has sent requests to (any status)
adding_alliesUser recordsUsers who have sent this user requests (any status)
alliesUser recordsUnion of both directions — all connected users regardless of status

The allies method performs two pluck queries and a Ruby-level uniq. It does not filter by status: 'accepted' — it returns users from all UserAlly records in either direction. The ally_activity_feed resolver (and user_allies query resolver) apply their own status: 'accepted' filter directly on UserAlly rather than relying on this method.


Ally Activity Feed

The allyActivityFeed query returns a paginated, time-ordered feed of recent activity from a user's accepted allies, aggregated across goal check-ins, habit completions, and notable user actions (community joins, community posts, milestone completions, new ally connections).

Resolver logic (Resolvers::FeedQueries#ally_activity_feed, rails_api/app/graphql/resolvers/feed_queries.rb):

  1. require_auth! — rejects unauthenticated requests.
  2. Ownership check: the caller must be the target user (current_user.public_id == user_id) or an admin, or the resolver raises GraphQL::ExecutionError with extensions { code: 'FORBIDDEN' }.
  3. Delegates to the Social::BuildAllyActivityFeed interaction, which re-enforces the same auth and ownership checks as guards.
  4. Any StandardError is rescued, reported to Sentry, logged via Rails.logger.error, and results in an empty array rather than propagating to the client.

Social::BuildAllyActivityFeed (rails_api/app/interactions/social/build_ally_activity_feed.rb):

  1. Finds accepted ally IDs in both directions (UserAlly rows with status: 'accepted', either as user_id or ally_id of the target user). Returns an empty page early if there are none.
  2. Aggregates three sources, each capped at limit + offset + 10 records for stable pagination:
    • GoalEvent on allies' public, incomplete goals → kind: GOAL, action 'checked in on'
    • HabitCompletion on allies' public, incomplete goals from the last 7 days → kind: GOAL, action 'checked in on'
    • UserAction rows for community_joined (kind: JOIN, action 'joined'), community_post_created (kind: POST, action 'posted in'), roadmap_step_completed (kind: ACHIEVEMENT, action 'completed a milestone on'), and ally_added (kind: FOLLOW, action 'added an ally')
  3. Merges all items, sorts by timestamp descending, and slices the requested page (limit, capped at 50; offset).

Privacy filtering: Goal-event and habit-completion items are scoped to goals in the Goal.ally_visible scope (visibility: public or visibility: allies) that are completed: false; habit completions are additionally limited to the last 7 days. A roadmap_step_completed (ACHIEVEMENT) item is excluded only when its parent goal is fully private (visibility_only_me?) — public and allies-only milestone achievements both surface. See Goal visibility tiers below for what allies means and how it differs from the accountability-partner overlay.

kind field: Every item carries a kind discriminator (AllyActivityKindEnum: JOIN, POST, ACHIEVEMENT, GOAL, FOLLOW) and a matching action verb — action is no longer a single hardcoded string.

community_id and community_name: Populated (looked up from the joined/posted-in Community) for JOIN and POST items; nil for GOAL, ACHIEVEMENT, and FOLLOW items, which have no community context.

details field: A per-kind JSON payload — e.g. goal_name/goal_public_id for GOAL, community_name/community_public_id for JOIN/POST (plus a truncated preview for POST), goal_name/milestone_name for ACHIEVEMENT, ally_name/ally_public_id for FOLLOW.

ally_photo field: Always returns nil — profile photo URLs are still not populated in the activity feed response.


Goal visibility tiers

Every goal carries a visibility value with three tiers (goals.visibilitypublic / allies / private, v4.61 Allies-Only Goal Visibility, OBJ-3783–OBJ-3788). The owner sets it via the setGoalVisibility mutation or the visibility argument on addGoal/updateGoal (rails_api/app/graphql/mutations/set_goal_visibility.rb) — owner-only, FORBIDDEN otherwise.

TierWho can see the goal
publicAnyone — logged-out visitors, crawlers, non-allies.
alliesThe owner and every one of the owner's accepted allies — a regular ally, not just a confirmed accountability partner.
privateThe owner only, plus the accountability partner iff the owner has separately opted the goal into shared_with_partner (below).

An accepted ally seeing an allies-tier goal is a first-class case, not an edge case: it surfaces in the ally activity feed above (Goal.ally_visible scope), in PublicProfileType#allyGoals on the owner's public profile, via the single-goal public_goal(id:) read path (ally_can_view?), and an accepted ally can encourage, comment on, react to, and follow an allies-only goal exactly as they can a public one. The owner-only editable goal(id:) view is unaffected — an ally always reads through the read-only /public_goal/:id route, never /goal/:id.

Legacy private boolean: the pre-v4.61 goals.private column still exists as a database-level mirror kept in sync with visibility (allies and private both write private: true; only public writes private: false). A legacy write to private alone can never promote a goal into the allies tier — it can only toggle between public and private. Code and docs written before v4.61 that say "no ally sees a private goal" are describing the private tier specifically; they are no longer true of the allies tier, which is private: true at the column level but visible to accepted allies by design.

Rollout: the write path and every read path above are live and unflagged in the backend (they are inert until a goal can actually be set to allies). The only user-facing surface — the visibility control on Goal Detail — is gated behind goal_allies_only_visibility, at 0% rollout as of this writing. No real user can set a goal to allies from the app yet.

How the accountability-partner overlay composes with the tier

An accountability partner (UserAlly#accountability_partner: true — see Accountability partners) is, by construction, always an accepted ally. That means:

  • On a public or allies goal, the partner already sees it through the tier above — shared_with_partner adds nothing.
  • On a private goal, the partner sees it only if the owner has separately opted it into shared_with_partner — this is the one case where the partner overlay actually widens visibility beyond the tier.

shared_with_partner is an orthogonal, per-goal, off-by-default toggle that matters exclusively for private-tier goals. Full mechanics — the Goal.partner_visible scope, the setGoalPartnerSharing mutation, and how the owner's public profile renders "Public goals" / "Allies only" / "Shared with you" as three mutually-exclusive sections — are documented in Accountability partners § Per-goal partner sharing.


Current Limitations

  • Ally lifecycle mutations (send, accept, decline, block, remove) are fully registered in mutation_type.rb. send_ally_request, accept_ally_request, decline_ally_request, block_ally, and remove_ally fields are all registered at mutation_type.rb lines 150–154 and the interaction files exist in rails_api/app/interactions/social/.
  • AllyActivityFeed.vue is wired to the live allyActivityFeed query. The component accepts a hasAllies prop and calls useQuery(ALLY_ACTIVITY_FEED_QUERY) to derive activities from live API data (see AllyActivityFeed.vue lines 127–139).
  • mutual_count is always 0. UserAllyType#mutual_count is a placeholder that returns 0. No mutual ally computation is implemented.
  • ally_photo is always nil. The activity feed builder does not resolve profile photo URLs for allies.
  • community_id and community_name are populated only for JOIN/POST items. GOAL, ACHIEVEMENT, and FOLLOW items have no community context and read nil.
  • allies method does not filter by status. User#allies returns users from all UserAlly records regardless of status. Callers that need only accepted allies must filter explicitly — as the resolvers do.
  • No public_id on UserAlly. The user_allies table has no public_id column, which means individual relationship records cannot be referenced via the public API. Any future mutations (accept, block, remove) will need to accept user_id/ally_id pairs rather than a relationship token.

UI Components

CommunityAlliesSection.vue

Location: ionic_frontend/src/components/communities/CommunityAlliesSection.vue

Purpose: The dedicated Allies module on the Communities page (added OBJ-1569) — consolidates finding allies, reviewing pending requests, and seeing all allies into one place, plus a "See ally activity →" link that deep-links to /feed?filter=ally_activity (the activity feed's Allies tab). Full props/emits/states contract: Communities deep-dive § CommunityAlliesSection. User-facing walkthrough: Communities § Allies section.

AllyActivityFeed.vue

Location: ionic_frontend/src/components/communities/AllyActivityFeed.vue

Purpose: Renders a scrollable card of recent ally activity items with avatar, name, action description, community badge, timestamp, and an optional preview quote.

Layout:

  • Outer card: white/dark rounded container with a header row (Users icon + "Ally Activity" title + "View All" button)
  • Scrollable list: max-h-96 overflow-y-auto, each item as a hover-highlighted row
  • Loading state: 3 pulsing skeleton rows while the query is in flight
  • Error state: alert icon + "Couldn't load activity." message
  • Avatar: initials-based circle with primary gradient (no photo support wired)
  • Detail badge: pill-shaped bg-primary/10 label showing details.communityName (JOIN/POST/FOLLOW), falling back to details.goalName for GOAL, or details.badgeName for ACHIEVEMENT — parsed from the item's details JSON payload
  • Action icon: renders on hover based on activity.kind (JOIN, POST, ACHIEVEMENT, GOAL, FOLLOW)
  • Empty state (has allies, no activity): "Your allies haven't been active yet."
  • Empty state (no allies): "No allies yet." with a "Find an ally" link to the ally discovery route

Data source: The component accepts a hasAllies: boolean prop and calls useQuery(ALLY_ACTIVITY_FEED_QUERY, ...) (fetch policy cache-and-network), deriving activities from result.value.allyActivityFeed. The view-all and activity-click events are emitted to the parent but have no default handlers inside the component.

Used in: Not currently imported/rendered by any view (src/views/Communities.vue and src/views/Dashboard.vue do not reference it). It is exercised only via its own Storybook story. Dashboard.vue implements its own inline ally-feed section directly against the useAllyActivityFeed composable (useCommunity()), rather than rendering this component.

AllyLookupModal.vue (PR #918)

Location: ionic_frontend/src/components/allies/AllyLookupModal.vue

Purpose: 460 × 720 modal for finding and sending ally requests. Opens from the Communities page's dedicated Allies section — "Add an ally" / "Find your first ally" (Search tab) or "Review" on the pending strip (Pending tab). Prior to OBJ-1569 these entry points were split across a header button, a hub-card fallback, and a hub-card ally nudge; they were consolidated into a single section.

Tabs:

  • Search — Live user search (≥ 2 chars). Below threshold shows recent searches (session-only) and an invite link card.
  • Suggested — Co-members of shared communities ranked by overlap count, each showing a reason string. Powered by suggestedAllies query / Social::FindSuggestedAllies.
  • Pending — Incoming requests ("Incoming requests") and outgoing requests ("You sent"). Badge dot appears on the tab label when there are incoming requests.

Sending a request: SEND_ALLY_REQUEST_MUTATION (target user's publicId) → optimistic status flip → ally_request_sent PostHog event. Toast on success/failure.

initialTab prop: When the Communities page's Allies section fires its review-requests emit (the pending strip's "Review" button), Communities.vue's openPendingAllyRequests() opens the modal with initialTab="pending" so the user lands directly on the incoming request.

ARIA: role="dialog", aria-modal="true", aria-labelledby, full tablist/tab/tabpanel pattern. ESC handled by ion-modal :keyboard-close="true".

Backend: Social::FindSuggestedAllies (PR #918)

File: rails_api/app/interactions/social/find_suggested_allies.rb

Suggests users who co-belong to one or more of the caller's communities. Ranked by shared-community count, capped at 25. Excludes self, all existing UserAlly records (any status), and users who blocked or were blocked by the caller. Returns { user, reason, mutual_count, ally_status: 'NONE' } per suggestion.

GraphQL: exposed via suggestedAllies(limit: Int) query field on QueryType, returning [AllySuggestionType].


Testing the Feature

Manual Testing Checklist

  • [ ] Seed two users and create a UserAlly record with status: 'accepted' via rails console; verify both users appear in each other's user_allies query response
  • [ ] Create a public, incomplete goal with a goal event for one of the ally users; verify the event appears in allyActivityFeed for the other user
  • [ ] Set goals.private = true on the ally's goal; verify the event no longer appears in allyActivityFeed
  • [ ] Set goals.completed = true on the ally's goal; verify the event no longer appears in allyActivityFeed
  • [ ] Create a UserAlly record with status: 'pending'; verify neither user appears in the other's allyActivityFeed result
  • [ ] Call allyActivityFeed with a user who has zero accepted allies; verify the response is an empty array with no error
  • [ ] With AllyActivityFeed.vue mounted standalone (e.g. via Storybook) and hasAllies true, verify it queries allyActivityFeed live and renders returned items (not mock data)
  • [ ] Call userAllies with a user who has accepted ally records; verify the response includes publicId, firstName, lastName, username, mutualCount: 0

Backend Unit Tests

No dedicated spec file exists for UserAlly or the ally resolvers. Existing coverage is limited to:

  • spec/interactions/social/build_unified_feed_spec.rb — includes a context for ally activity items in the unified feed, creates a UserAlly record with status: 'accepted' and verifies ally events surface in the feed result.

Gaps to fill:

bash
# Add these spec files:
rails_api/spec/models/user_ally_spec.rb        # validations, uniqueness, scopes
rails_api/spec/requests/ally_activity_spec.rb  # allyActivityFeed query, privacy filters
rails_api/spec/requests/user_allies_spec.rb    # userAllies query

Run existing related coverage:

bash
cd rails_api
bundle exec rspec spec/interactions/social/build_unified_feed_spec.rb

Frontend Unit Tests

No unit tests exist for AllyActivityFeed.vue or the useAllyActivityFeed composable. When adding tests:

bash
cd ionic_frontend

# Component render + empty state
npm run test:unit -- --run -t "AllyActivityFeed"

# Composable query behavior
npm run test:unit -- --run -t "useAllyActivityFeed"

GraphQL API Reference

Queries

userAllies

Returns the accepted allies of a user as UserAllyType objects.

graphql
query UserAlliesQuery($userId: ID!) {
  userAllies(userId: $userId) {
    id           # Integer ID of the ally user (note: not a relationship token)
    publicId     # public_id of the ally user
    firstName
    lastName
    username
    photo {
      imageUrl
    }
    mutualCount  # Always 0 (placeholder)
  }
}

Arguments:

ArgumentTypeRequiredDescription
userIdIDYespublic_id of the user whose ally list to retrieve

Auth: Resolvers::UserQueries#user_allies calls require_auth!, then requires the caller be the target user (current_user.public_id == user_id) or an admin — otherwise raises GraphQL::ExecutionError with extensions { code: 'FORBIDDEN' }.

Returns: Array of UserAllyType. The resolver queries UserAlly.accepted directly in both directions (.where(user: user).or(...where(ally: user))), so only status: 'accepted' relationships are returned — pending and blocked relationships never appear.


allyActivityFeed

Returns recent goal check-ins from a user's accepted allies.

graphql
query AllyActivityFeedQuery($userId: ID!, $limit: Int, $offset: Int) {
  allyActivityFeed(userId: $userId, limit: $limit, offset: $offset) {
    id            # public_id of the source record (GoalEvent, or a prefixed HabitCompletion/UserAction id)
    allyId        # public_id of the ally user
    allyName      # Full name of the ally
    allyPhoto     # Always null
    kind          # GOAL | JOIN | POST | ACHIEVEMENT | FOLLOW
    action        # Verb matching kind, e.g. "checked in on", "joined", "posted in"
    target        # Name of the goal or community acted on
    communityId   # Populated for JOIN/POST; null otherwise
    communityName # Populated for JOIN/POST; null otherwise
    timestamp     # Unix timestamp as string (e.g., "1711497600")
    details       # Per-kind JSON payload
  }
}

Arguments:

ArgumentTypeRequiredDefaultDescription
userIdIDYespublic_id of the user whose feed to retrieve
limitIntNo20Maximum number of events to return (capped at 50)
offsetIntNo0Number of items to skip for pagination

Auth: Resolvers::FeedQueries#ally_activity_feed calls require_auth!, then requires the caller be the target user (current_user.public_id == user_id) or an admin — otherwise raises GraphQL::ExecutionError with extensions { code: 'FORBIDDEN' }.

Returns: Array of AllyActivityType ordered by most recent event first. Returns an empty array (never an error) if the user has no accepted allies or if the query encounters an exception.


Known Behaviors & Edge Cases

  1. userAllies filters by status: 'accepted' directly. The resolver queries UserAlly.accepted in both directions (not via User#allies), so pending and blocked records never appear in the result.

  2. allyActivityFeed and userAllies both require auth + ownership. Both resolvers call require_auth! and require the caller be the target user or an admin, raising GraphQL::ExecutionError (FORBIDDEN) otherwise.

  3. accepted_at is set by Social::AcceptAllyRequest. rails_api/app/interactions/social/accept_ally_request.rb line 15 sets accepted_at: Time.current when an ally request is accepted.

  4. Uniqueness is enforced at the database level only in one direction. The unique index is on (user_id, ally_id). A row where user_id = A, ally_id = B and a row where user_id = B, ally_id = A can both exist. The model validates uniqueness for user_id scoped to ally_id (validates :user_id, uniqueness: { scope: :ally_id }), which mirrors the database constraint. Duplicate reciprocal rows are not prevented.

  5. ally_activity_feed has no time window for goal events. goal_event_items (in Social::BuildAllyActivityFeed) orders by goal_events.created_at DESC with no date filter — old check-ins on long-lived goals will still appear if no newer events exist. Habit-completion items are the exception, capped to the last 7 days.

  6. No public_id on UserAlly. The join table uses only integer IDs. Any future mutation to accept or block a request will need to identify the relationship via a (user_id, ally_id) pair — or a public_id column will need to be added in a migration.

  7. AllyActivityFeed.vue is wired to live data. The component calls useQuery(ALLY_ACTIVITY_FEED_QUERY) and derives activities from the real API response. Mock data was removed in v3.9.181.

  8. mutual_count is a placeholder. The UserAllyType#mutual_count method returns 0 unconditionally. Computing the real intersection of two users' ally lists requires a SQL query that has not been implemented.

Last updated: 2026-09-15 (v4.61 Allies-Only Goal Visibility: added the allies goal-visibility tier, corrected the activity feed's privacy-filtering description, and rewrote the accountability-partner overlay section as "Goal visibility tiers")


Community integration

Allies can be invited to join communities the user has created via the post-create interstitial flow. See Communities → Community creation with ally invite for details on the invitation mutation, feature flag, and analytics.


  • Accountability partners — the overlay partnership feature (one active partner per user)
  • Communities — community spaces and the ally-invite post-create flow
  • Blocking & Safety — the full block/unblock flow, symmetric content filtering, and how it reuses this blocked status

Loading…