Skip to content

Adding a data-visibility tier: audit every single-record read path, not just list queries

Applies to: Any change that adds a new visibility tier to a model already served by both a list/collection GraphQL field and one or more single-record (find-by-id) fields — e.g. Resolvers::*Queries methods that return one record vs. a collection.

The trap

A new visibility tier is easy to wire into the query you're already touching (usually a list field feeding some new UI) and easy to forget everywhere else that same model is readable. Nothing fails at review time — the new list query works, the feature demos fine — because the single-record detail queries silently keep enforcing only the old visibility rules. The gap only surfaces when a real user follows a link into the detail view and hits a hard, repeatable NOT FOUND.

What went wrong (OBJ-1584/OBJ-1585, OBJ-1871)

Per-goal accountability-partner sharing added a new Goal.partner_visible tier (shared_with_partner: true on an otherwise-private goal) and wired it into:

  • PublicProfileType#partner_goals (the "Shared with you" list on a partner's profile)
  • Social::GeneratePartnerMissedDayAlertsJob (missed-day notification eligibility)

But the two single-goal detail resolvers in rails_api/app/graphql/resolvers/goal_queries.rbgoal(id:) and public_goal(id:) — were never touched. Both still only recognized "current_user owns it" or "fully public." Every accountability partner who tapped a partner_missed_day notification, or clicked a "Shared with you" card, landed on a query that had no idea the new tier existed and got NOT FOUND on the first click, every time.

This shipped in v4.2.0 (2026-07-22) and ran live in production for 6+ days before diagnosis (OBJ-1871) — the list query and the job worked perfectly, so nothing in normal QA or the PR review surfaced the gap. It reached real users specifically because the list query correctly advertised goals the detail query then refused to serve.

Correct pattern

When a model gains a new visibility tier:

  1. Find every read path for that model, not just the one you're already changing:
    bash
    grep -rn "def .*(" rails_api/app/graphql/resolvers/*_queries.rb | grep -i <model_name>
    Check both Resolvers::*Queries (root-level find/list fields) and any type-level fields that resolve association lookups (e.g. PublicProfileType#partner_goals).
  2. For each single-record lookup, decide explicitly whether the new tier should apply — don't assume "the list query covers it." A partner seeing a goal in a feed and a partner opening that same goal's detail view are two independent authorization decisions.
  3. If a single-record resolver deliberately stays narrower than the list (e.g. an owner-only editable view vs. a read-only shared view), route the client surfaces that reach the new tier at that narrower resolver's URL/route, not the owner-only one — see the fix below.
  4. Write the negative cases, not just the positive one: viewer not in the new tier still gets the pre-existing rejection (NOT FOUND/no existence leak), and any unauthenticated-caller behavior stays unchanged.

The fix that shipped

public_goal(id:) was extended to honor shared_with_partner; goal(id:) (the owner-only editable view) was deliberately left untouched, so partners are always routed to the read-only /public_goal/:id path:

ruby
# rails_api/app/graphql/resolvers/goal_queries.rb
def public_goal(id:)
  goal = Goal.includes(:events, :milestones).public_find(id)
  goal.present? && (!goal.private || partner_can_view?(goal)) ? goal : GraphQL::ExecutionError.new('NOT FOUND')
end

private

def partner_can_view?(goal)
  return false unless current_user.present? && goal.shared_with_partner?

  UserAlly.with_active_partnership.where(user: current_user, ally: goal.user)
          .or(UserAlly.with_active_partnership.where(user: goal.user, ally: current_user)).exists?
end

Unauthenticated callers fall straight through to the pre-existing !goal.private check — no behavior change, no existence leak for a private-and-shared goal. The two frontend notification-tap handlers (ionic_frontend/src/components/ui/NotificationsPanel.vue, ionic_frontend/src/composables/usePushNotifications.ts) were updated in the same PR to route a partner_missed_day notification to /public_goal/:goalId instead of the owner-only /goal/:goalId.

This doc predicted the next tier, and the prediction held (v4.61)

Allies-Only Goal Visibility (v4.61, OBJ-3783–OBJ-3788) added a third goals.visibility tier (allies) to the same model this doc is about, served by the same shape of list + single-record fields. Phase 3 of that milestone (OBJ-3785) is the audit this doc's "Correct pattern" step 1 asks for, run for real against the tree instead of by grepping and assuming: it produced an 18-row table enumerating every read path that touches goal visibility (Resolvers::GoalQueries#goal/#public_goal, PublicProfileType#public_goals/#partner_goals/#ally_goals, both dashboard feed builders, the fan-out job, the four social write gates, the crawler-facing controller, and the admin stats service), with an explicit ruling — open or unchanged — for each one, plus a positive and negative spec per row. The table lives in .planning/milestones/v4.61-allies-only-goal-visibility-ROADMAP.md (Phase 3) and in PR #3205's body, per this doc's own recommendation to make the inventory a PR deliverable, not just code.

public_goal(id:) gained a second, independent gate for the new tier (ally_can_view?, alongside the existing partner_can_view? this doc documents) rather than folding the ally check into partner_can_view? — the two tiers are orthogonal (see Allies § Goal visibility tiers), and keeping their gates separate is what let Phase 3 reason about each independently. The owner-only goal(id:) view was again deliberately left untouched, so an ally — like a partner — is always routed to the read-only /public_goal/:id path, never the owner's editable one.

  • rails_api/app/graphql/resolvers/goal_queries.rbgoal, public_goal, partner_can_view?, ally_can_view? (v4.61)
  • rails_api/app/models/goal.rbpartner_visible scope (the reference implementation for a visibility tier already correctly wired into a list query); ally_visible scope (v4.61)
  • rails_api/spec/graphql/types/query_type_spec.rb — the four-case public_goal spec matrix (partner+shared → ok, partner+not-shared → NOT FOUND, anon+public → ok, anon+shared-private → NOT FOUND), extended in v4.61 to cover allies-only ×
  • Accountability Partners § Per-goal partner sharing — the user-facing feature this gap affected, including the read-path note added alongside this doc
  • Allies § Goal visibility tiers — the v4.61 tier this doc's pattern was applied to next
  • PR #1854 (fix/partner-shared-goal-detail-readpath)
  • PR #3205 (v4.61 Phase 3, OBJ-3785) — the read-path audit that applied this doc's pattern to the allies tier

Last updated: 2026-09-15 (v4.61: recorded that Phase 3's read-path audit table is the artifact this doc's "Correct pattern" step 1 asks for; original entry 2026-07-28, OBJ-1871: public_goal detail read-path fix for per-goal partner sharing)

Loading…