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
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | bigint | PK | Integer ID, never exposed to API clients |
user_id | bigint | FK → users, not null | The user who initiated the request |
ally_id | bigint | FK → users, not null | The user who received the request |
status | string(50) | not null, default pending | One of: pending, accepted, blocked |
accepted_at | datetime | nullable | Set when status transitions to accepted |
created_at | datetime | not null | |
updated_at | datetime | not null |
Indexes:
index_user_allies_on_user_id— single-column index onuser_idindex_user_allies_on_ally_id— single-column index onally_idindex_user_allies_on_user_id_and_ally_id— unique compound index, prevents duplicate pairings
Foreign keys:
user_allies.user_id→users.iduser_allies.ally_id→users.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
| Status | Meaning | Transitions From |
|---|---|---|
pending | Request sent, awaiting response | Initial creation |
accepted | Mutual connection — both users see each other's feed | pending |
blocked | Both 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:
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:
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 / Method | Returns | Notes |
|---|---|---|
initiated_allyships | UserAlly records | Rows where this user is the requester |
received_allyships | UserAlly records | Rows where this user is the recipient |
added_allies | User records | Users this user has sent requests to (any status) |
adding_allies | User records | Users who have sent this user requests (any status) |
allies | User records | Union 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):
require_auth!— rejects unauthenticated requests.- Ownership check: the caller must be the target user (
current_user.public_id == user_id) or an admin, or the resolver raisesGraphQL::ExecutionErrorwith extensions{ code: 'FORBIDDEN' }. - Delegates to the
Social::BuildAllyActivityFeedinteraction, which re-enforces the same auth and ownership checks as guards. - Any
StandardErroris rescued, reported to Sentry, logged viaRails.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):
- Finds accepted ally IDs in both directions (
UserAllyrows withstatus: 'accepted', either asuser_idorally_idof the target user). Returns an empty page early if there are none. - Aggregates three sources, each capped at
limit + offset + 10records for stable pagination:GoalEventon allies' public, incomplete goals →kind: GOAL, action'checked in on'HabitCompletionon allies' public, incomplete goals from the last 7 days →kind: GOAL, action'checked in on'UserActionrows forcommunity_joined(kind: JOIN, action'joined'),community_post_created(kind: POST, action'posted in'),roadmap_step_completed(kind: ACHIEVEMENT, action'completed a milestone on'), andally_added(kind: FOLLOW, action'added an ally')
- 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.visibility — public / 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.
| Tier | Who can see the goal |
|---|---|
public | Anyone — logged-out visitors, crawlers, non-allies. |
allies | The owner and every one of the owner's accepted allies — a regular ally, not just a confirmed accountability partner. |
private | The 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
publicoralliesgoal, the partner already sees it through the tier above —shared_with_partneradds nothing. - On a
privategoal, the partner sees it only if the owner has separately opted it intoshared_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, andremove_allyfields are all registered atmutation_type.rblines 150–154 and the interaction files exist inrails_api/app/interactions/social/. AllyActivityFeed.vueis wired to the live allyActivityFeed query. The component accepts ahasAlliesprop and callsuseQuery(ALLY_ACTIVITY_FEED_QUERY)to derive activities from live API data (seeAllyActivityFeed.vuelines 127–139).mutual_countis always 0.UserAllyType#mutual_countis a placeholder that returns0. No mutual ally computation is implemented.ally_photois always nil. The activity feed builder does not resolve profile photo URLs for allies.community_idandcommunity_nameare populated only forJOIN/POSTitems.GOAL,ACHIEVEMENT, andFOLLOWitems have no community context and readnil.alliesmethod does not filter by status.User#alliesreturns users from allUserAllyrecords regardless of status. Callers that need only accepted allies must filter explicitly — as the resolvers do.- No
public_idonUserAlly. Theuser_alliestable has nopublic_idcolumn, which means individual relationship records cannot be referenced via the public API. Any future mutations (accept, block, remove) will need to acceptuser_id/ally_idpairs 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/10label showingdetails.communityName(JOIN/POST/FOLLOW), falling back todetails.goalNameforGOAL, ordetails.badgeNameforACHIEVEMENT— parsed from the item'sdetailsJSON 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
reasonstring. Powered bysuggestedAlliesquery /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
UserAllyrecord withstatus: 'accepted'viarails console; verify both users appear in each other'suser_alliesquery response - [ ] Create a public, incomplete goal with a goal event for one of the ally users; verify the event appears in
allyActivityFeedfor the other user - [ ] Set
goals.private = trueon the ally's goal; verify the event no longer appears inallyActivityFeed - [ ] Set
goals.completed = trueon the ally's goal; verify the event no longer appears inallyActivityFeed - [ ] Create a
UserAllyrecord withstatus: 'pending'; verify neither user appears in the other'sallyActivityFeedresult - [ ] Call
allyActivityFeedwith a user who has zero accepted allies; verify the response is an empty array with no error - [ ] With
AllyActivityFeed.vuemounted standalone (e.g. via Storybook) andhasAlliestrue, verify it queriesallyActivityFeedlive and renders returned items (not mock data) - [ ] Call
userAllieswith a user who has accepted ally records; verify the response includespublicId,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 aUserAllyrecord withstatus: 'accepted'and verifies ally events surface in the feed result.
Gaps to fill:
# 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 queryRun existing related coverage:
cd rails_api
bundle exec rspec spec/interactions/social/build_unified_feed_spec.rbFrontend Unit Tests
No unit tests exist for AllyActivityFeed.vue or the useAllyActivityFeed composable. When adding tests:
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.
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:
| Argument | Type | Required | Description |
|---|---|---|---|
userId | ID | Yes | public_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.
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:
| Argument | Type | Required | Default | Description |
|---|---|---|---|---|
userId | ID | Yes | — | public_id of the user whose feed to retrieve |
limit | Int | No | 20 | Maximum number of events to return (capped at 50) |
offset | Int | No | 0 | Number 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
userAlliesfilters bystatus: 'accepted'directly. The resolver queriesUserAlly.acceptedin both directions (not viaUser#allies), sopendingandblockedrecords never appear in the result.allyActivityFeedanduserAlliesboth require auth + ownership. Both resolvers callrequire_auth!and require the caller be the target user or an admin, raisingGraphQL::ExecutionError(FORBIDDEN) otherwise.accepted_atis set bySocial::AcceptAllyRequest.rails_api/app/interactions/social/accept_ally_request.rbline 15 setsaccepted_at: Time.currentwhen an ally request is accepted.Uniqueness is enforced at the database level only in one direction. The unique index is on
(user_id, ally_id). A row whereuser_id = A, ally_id = Band a row whereuser_id = B, ally_id = Acan both exist. The model validates uniqueness foruser_idscoped toally_id(validates :user_id, uniqueness: { scope: :ally_id }), which mirrors the database constraint. Duplicate reciprocal rows are not prevented.ally_activity_feedhas no time window for goal events.goal_event_items(inSocial::BuildAllyActivityFeed) orders bygoal_events.created_at DESCwith 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.No
public_idonUserAlly. 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 apublic_idcolumn will need to be added in a migration.AllyActivityFeed.vueis wired to live data. The component callsuseQuery(ALLY_ACTIVITY_FEED_QUERY)and derives activities from the real API response. Mock data was removed in v3.9.181.mutual_countis a placeholder. TheUserAllyType#mutual_countmethod returns0unconditionally. 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.
Related docs
- 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
blockedstatus