v4.61 — Allies-Only Goal Visibility
A user can set any goal to allies-only: every accepted ally can see it, encourage it, comment on it, and follow it — nobody else can, and every read path this milestone didn't explicitly open keeps hiding it.
Summary
Objectuve's goals carried a two-state public/private boolean plus one orthogonal overlay — shared_with_partner, which lets an owner expose a single private goal to their one confirmed accountability partner. Roadmap item 44 asked for a third state: visible to every accepted ally, not just one partner. Planning found the ask understated its own shape — the exact model that had already burned production once. Per-goal partner sharing (v4.2.0) wired its new tier into the list query and a notification job but never the two single-goal detail resolvers, so every partner who clicked into a shared goal got a hard NOT FOUND for six days before anyone noticed (OBJ-1584/OBJ-1585/OBJ-1871). docs/development/visibility-tier-single-record-readpath-gotcha.md was written specifically to name that trap for the next visibility tier to add. This milestone is that next tier.
The encoding it shipped makes the failure mode structurally unlikely to repeat: allies is a new goals.visibility value, but the pre-existing private boolean stays as a derived mirror — allies maps to private = true for the life of the milestone. Because of that mirror, every one of the roughly seventeen read paths that already existed kept hiding an allies-only goal without anyone touching them; Phases 2 and 3 then opened specific paths one at a time, each with its own positive and negative spec. The default is refuse; every "serve" is a decision somebody wrote down and tested, the precise inverse of how the 2026 partner-sharing gap shipped.
Six phases shipped it: a data-spine migration, a write path (setGoalVisibility plus a cascade for child milestones and follows), a full read-path audit that widened an 18-row table instead of the estimated 17, a design contract for the three-way control, the frontend control itself, and this docs-reconciliation pass. The backend (Phases 1–3) is live and unflagged on master — it is inert by construction, since nothing can set a goal to allies until the frontend ships it. The only user-visible surface, the Goal Detail visibility control, ships behind goal_allies_only_visibility at 0% rollout: this milestone is complete build-out, not yet a launch, and no real user can reach the third state today.
Goal
A user can set any goal to allies-only: every accepted ally can see it, encourage it, comment on it and follow it; nobody else can — not the public web, not a crawler, not a logged-out visitor, not a non-ally. The two visibility booleans the table carries today keep working unchanged throughout, and every read path that is not explicitly opened by this milestone hides an allies-only goal rather than leaking it.
Scope — What Shipped
goals.visibilitycolumn (string,public/allies/private,null: false, indexed) plus abefore_validationinvariant keeping it in sync with the legacyprivateboolean in both directions — an explicitvisibilitywrite always wins, and a legacyprivate-only write can never silently promote a goal into thealliestier. The same migration backfilled the column and fixed a pre-existing bug whereprivatewas nullable and Ruby/SQL disagreed about what a NULL row meant.setGoalVisibilitymutation and avisibilityargument onaddGoal/updateGoal(owner-only), sharing one cascade (GoalTracking::Concerns::GoalVisibilityCascade) so child milestones inherit the parent'svisibilityand goal follows are dropped only when a goal becomes fullyprivate— narrowing from public straight to allies keeps every ally's follow and drops only non-allies'.- A shared, block-aware accepted-ally predicate,
UserAlly.accepted_ally?(user, other), consumed by every opened read path and all four social write gates so none can drift from the others the way the previous tier's lookups did. - An 18-row read-path audit (
Resolvers::GoalQueries#public_goal's newally_can_view?, a newPublicProfileType#allyGoalsfield, both dashboard feed builders, the fan-out job, and the four social write gates — encourage, comment, react, follow) — each row carries a written ruling (opened or deliberately unchanged) and a spec, including the eight rows that stayed unchanged on purpose. GoalVisibilitySheet.vue— a select-then-commit bottom sheet replacing the old overflow-only, unlabelled Public/Private toggle, reachable from the goal chip and theGoalActionBaroverflow menu. All six transitions pass through the same commit weight — narrowing is never friction-free, widening is never a single tap.- A third "Allies only" section on
PublicProfile.vue, mutually exclusive with "Public goals" and "Shared with you" by subtraction in widest-audience-first order, so a goal that is both allies-tier and partner-shared renders once, under the wider tier. - Docs reconciliation (this phase) — every doc asserting the old "no ally sees a private goal" claim rewritten to describe the three-tier model; the GraphQL reference docs verified already current against the generator; the CHANGELOG's four phase entries verified to read as one story; roadmap item 44 ticked with its metrics marked not-yet-measurable at 0% rollout; and the read-path gotcha doc updated to record that Phase 3's audit table is the artifact it predicted.
- Behind
goal_allies_only_visibility, at 0% rollout. Flag off,Goal.vueandPublicProfile.vuebehave exactly as they did before this milestone, and the pre-existing specs pass unmodified.
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | The visibility column and its invariant | Shipped | 1 | Added the column and fixed private's pre-existing NULL-handling bug in the same migration; enum members had to be named everyone/allies/only_me since public/private are unusable Rails enum names — the column values stay public/allies/private. |
| 2 | Write path — setGoalVisibility, create, update, cascade | Shipped | 1 | Found the milestone cascade reached three more call sites than planned (milestone-creation, not just the update path); added require_owner! to the two new follower counts after finding GoalType also backs the non-owner-reachable publicGoal query. |
| 3 | Read-path audit — open the ally tier, path by path | Shipped | 1 | Widened the table from 17 to 18 rows on re-verification against the tree; extracted Social::Concerns::AllyVisibleGoal after Roy caught the decision wrapper around the shared lookup copy-pasted across all four write gates — the lookup itself was correctly shared, one level down wasn't. |
| 4 | UI-SPEC — the three-way visibility control | Shipped | 1 | Found the ROADMAP named the wrong existing control (GoalActionBar.vue, not GoalForm.vue); ruled the partner-sharing card's ally-tier state, the milestone's highest-risk copy decision; pushed a backend requirement (two owner-only follower counts) upstream into Phase 2. |
| 5 | The control itself | Shipped | 1 | Shipped GoalVisibilitySheet.vue; Roy caught an optimistic-update race between Goal.vue's update and the sheet's derived pending-state UI, fixable only with a mounted parent+child test using a deferred mutation promise. |
| 6 | Docs reconciliation | Shipped | 1 | Rewrote every doc claim this milestone falsified; verified the GraphQL reference and CHANGELOG needed no changes; ticked roadmap item 44; closed the loop on the doc that predicted this milestone. |
Key Decisions
alliesmirrorsprivate = true, not a new independent flag. Every one of the ~17 pre-existing read paths hides an allies-only goal by default without being touched; Phases 2–3 then open specific paths one at a time, each with its own spec. This is the structural fix for the exact shape of failure that shipped in v4.2.0 (OBJ-1871) — there, the list query advertised a goal the detail query then refused to serve; here the default is refuse, and every "serve" is a written, tested decision.- Share the decision, not just the lookup. Phase 3's shared
UserAlly.accepted_ally?lookup was correctly extracted per plan — but the decision wrapper around it (goal_visible_to_viewer?) had still been copy-pasted byte-for-byte across all four social write gates before Roy caught it and extractedSocial::Concerns::AllyVisibleGoal. The lesson carried forward: "share the lookup" is not a strong enough instruction on its own — the thing to share is the decision built on top of it. - "Existing specs pass unmodified" has a source-scanning exception. Phase 5's
tokenDrift.spec.tsis a static source-scanning counter (it reads file text, not rendered output), so a flag-gated markup addition still moved its count even though nothing rendered differently. The rule to carry forward: the "unmodified" gate means behavioural specs; a spec that scans source text is exempt by construction, and naming that exemption up front stops a future phase from either weakening the gate or faking the count to satisfy it. - An optimistic update whose pending-state UI lives in a child component needs a mounted parent+child test with a deferred promise.
Goal.vueapplies its optimisticvisibilityupdate synchronously;GoalVisibilitySheetread that value as a prop to derive its own pending-state UI, so mid-commit the sheet briefly showed the post-mutation state before the mutation resolved. An isolated component spec structurally could not see this race — only a mounted parent+child interaction test with a deferred mutation promise caught it, and Phase 5 fixed it by snapshotting the pre-commit tier in the sheet. - Dropping the legacy
privatecolumn is explicitly out of scope. It stays as a synced mirror for the life of this milestone; retiring it is a follow-up once every read path readsvisibilitydirectly, kept separate so a hot-table column drop never shares a milestone with the tier that depends on it. - The backend ships unflagged; only the control is gated. Phases 1–3 are behaviour-neutral on merge by construction — nothing can set
visibility = 'allies'until Phase 5's control exists — sogoal_allies_only_visibilitygates the one thing a user can actually see or act on, matching how prior backend-then-frontend slices (v4.6, v4.57) were sequenced.
Requirements Coverage
45 acceptance criteria recorded across the six phases in .planning/milestones/v4.61-allies-only-goal-visibility-ROADMAP.md, all marked shipped per-phase and independently re-verified at milestone close — see .planning/milestones/v4.61-allies-only-goal-visibility-MILESTONE-AUDIT.md (45/45 requirements, 6/6 phases). The milestone's own six numbered success criteria (see the ROADMAP's "Success criteria (milestone)" section) are the more meaningful summary: criteria 1–5 (correct visibility behavior, negative-spec coverage, the 18-row read-path table, the private/visibility invariant, and flag-off parity) are all met per the per-phase acceptance records; criterion 6 (item 44 ticked with metrics measurable from shipped data) is met for the ticking, but the two success metrics themselves — 20%+ of goals set to allies-only, and 3x encouragements versus private goals — are explicitly not yet measurable while the control sits at 0% rollout.
Outcomes
An accepted ally can now, once the flag ramps, see, encourage, comment on, react to, and follow a goal its owner has marked allies-only — a middle ground between public and private that previously didn't exist. Every pre-existing read path defaults to hiding an allies-only goal unless this milestone explicitly opened it, so the OBJ-1871 failure shape (a list query advertising what a detail query refuses to serve) cannot recur here by construction. Nothing is yet visible to a real user: the milestone is complete build-out sitting behind goal_allies_only_visibility at 0% rollout, pending a rollout decision.
Tech Debt
- (milestone-wide) The legacy
privateboolean column remains as a synced mirror ofvisibility, by design, for the life of this milestone. Retiring it once every read path consumesvisibilitydirectly is an explicit future follow-up, not a gap in this milestone.
SettingsRowSegmented.vue:12's 36px segments against the 44px tap-target floor (hallmark gate 67) — found during Phase 4's UI-SPEC work on an unrelated, already-shipped settings control, and raised to Maggie for its own ticket — shipped fixed via OBJ-3829/PR #3202 (min-h-11, 44px) before this milestone closed, so it is not carried forward as debt here.
Related Artifacts
- Roadmap: v4.61-allies-only-goal-visibility-ROADMAP.md
- Feature docs: Allies § Goal visibility tiers, Accountability Partners § Per-goal partner sharing
- Related gotcha: Visibility Tier: Single-Record Read-Path Gotcha — the doc this milestone's Phase 3 audit closes the loop on
- Roadmap item: docs/product/roadmap.md § 44. Goal Visibility Controls (Allies-Only)
- Git tag: none yet — ships on the next tag to include
masterat this milestone's merge point; production rollout is a separate, later decision
Related Commits
af41e9f09— Phase 1:visibilitycolumn + private/visibility invariant (PR #3189, OBJ-3783)09457d2f0— Phase 4: UI-SPEC for the three-way visibility control (PR #3187)e0645430a— Phase 2:setGoalVisibility, three-value cascade (PR #3193, OBJ-3784)c15f0c913— Phase 3: read-path audit (PR #3205, OBJ-3785)aa9ef7014— Phase 5: the control itself (PR #3220, OBJ-3787)
Last updated: 2026-09-15 Version: v4.8.4 (app version at time of this milestone's close — allies-only visibility itself is not yet on a production tag; see git tag above)