v4.63 — Community Discovery & Seasonal Events
A user opening Communities now sees a list ranked by what they're actually working on and who they're actually accountable to, and, at three points in the year, a platform-wide event they can join.
Summary
Before this milestone, Objectuve's community discovery surface advertised a "Relevance score (0–100)" that was a constant zero on every single response — Community#match_score didn't exist, so the field's resolver raised on every call, was silently swallowed into Sentry, and fell back to 0. Ally overlap was a hardcoded 0. The community health score that was supposed to feed ranking was 20% literal constant and cost 4–6 uncached queries per community. And whatever the server did compute was thrown away anyway: Communities.vue re-sorted the list client-side by raw member count. Worse, recommendedCommunities { members { email } } handed any signed-in user real email addresses for members of communities they hadn't joined, because the field's type delegated straight to the full UserType with no field-level authorization.
This milestone pairs two roadmap items — Community Discovery Improvements (item 41) and Seasonal Events & Challenges (item 43) — because they turned out to be one engineering problem. A ranking formula over goal category, ally overlap, and community health produces a better-ordered but static list; the one signal that makes discovery feel current, and the thing seasonal events exist to provide, is a running platform-wide event. Ten phases shipped: two backend fixes that close a live privacy leak and a broken relevance score, a real ranking service and its resolvers, two UI-SPECs, two frontends, a new SeasonalEvent model with an admin surface and three seeded events (New Year New Goals, Spring Reset, Back to School), participation/badge-award machinery, and a docs reconciliation pass.
The milestone shipped in five days but included a genuine ~66-hour stall: introducing a PostHog feature flag from CI requires the flag to already exist in PostHog, and no crew agent holds the personal API key needed to create one. Phase 3 sat fully finished — code, tests, review, rebase — waiting on that one human action. Josh created the flag directly, and the crew shipped a durable fix (a workflow_dispatch job that can create, but never enable, a flag) the same day, so Phase 7's identical wall didn't cost a second stall. Both discovery_ranking_v2 and seasonal_events_enabled ship live in PostHog at 0% rollout — this is complete build-out, not yet a launch, and nothing this milestone built is reachable by a real user today.
Goal
A user opening Communities sees a list that could only have been built for them — ordered by what they are actually working on, who they are actually accountable to, and which rooms are actually alive — and, at the three times of year when the whole internet is looking for a goal app, sees a platform-wide event they can join and bring someone into.
Scope — What Shipped
- New backend types/methods:
Types::DiscoveryMemberType(allow-listed member preview —public_id, display name, avatar only, neverUserType); a realCommunity#match_score(user); a rebuilt, boundedCommunity#health_score. - New ranking service:
Social::RankCommunities— goal-category match, an invisible ally-overlap weight, the rebuilt health score, and a seasonal-participation term, behinddiscovery_ranking_v2. - New models:
SeasonalEventandSeasonalEventParticipant(PublicRecord,acts_as_paranoid, platform-scoped — deliberately not a nullableCommunityChallenge). - New interactions:
Social::JoinSeasonalEvent,Social::LeaveSeasonalEvent,Social::IncrementSeasonalEventProgress,Social::FinalizeSeasonalEvent— modelled onFinalizeCommunityChallenge's transaction + reprocessing-guard + at-most-once-notification guarantees. - New GraphQL surface:
Types::SeasonalEventType,seasonalEvents/activeSeasonalEvent/activeSeasonalEvents/seasonalEvent(id:)queries, join/leave mutations,me.completedSeasonalEvents. - New frontend components:
SeasonalEventBanner.vue,SeasonalEventView.vue(/events/:id),SeasonalBadgeTile.vue, auseSeasonalEventcomposable — all behindseasonal_events_enabled. - New admin surface: seasonal event CRUD in
admin_dashboard, following the existingCommunityCurationView.vuepattern — events are curated, never user-created. - Frontend behavior removed:
Communities.vue's client-side re-sort — Explore's default order now renders the server's ranked output as-is; thegrowthRate * 2client-side weighting is deleted. - New docs page:
docs/features/seasonal-events.md— model, admin workflow, badge derivation, GraphQL surface as shipped. - New badge enum value:
joined_seasonal_event, appended toUserAction(append-only), deliberately excluded fromONCE_PER_USER_ACTIONS.
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | Close the discovery member leak and the matchScore error path | Shipped | 1 | Unflagged fix for a live email-exposure leak and a Sentry-flooding broken relevance score. |
| 2 | Make community health cheap and real | Shipped | 1 | Rebuilt health_score off its 20%-constant, unbounded-query implementation. |
| 3 | Social::RankCommunities and the three resolvers | Shipped | 1 | The ranking service. Survived a real merge conflict and a ~66h PostHog-flag-creation stall — the milestone's critical path. |
| 4 | UI-SPEC: the discovery page | Shipped | 1 | Desi ruled ally overlap stays an invisible ranking weight, never named or counted. |
| 5 | Discovery frontend | Shipped | 1 | Removed the client-side re-sort; server order renders as returned. Absorbed a stacked-PR auto-close when Phase 3's branch merged. |
| 6 | SeasonalEvent, its admin surface, and the three events | Shipped | 1 | New platform-scoped model, modelled on CommunityChallenge but not community-bound; three events seeded. |
| 7 | Participation, badge award, and the seasonal ranking term | Shipped | 1 | Join/progress/finalize mutations; populated the seasonal ranking term Phase 3 shipped wired-but-inert. |
| 8 | UI-SPEC: seasonal event surfaces | Shipped | 1 | Banner, detail view, badge tile — zero fix rounds. |
| 9 | Seasonal event frontend | Shipped | 1 | Member-facing surfaces consuming Phase 7's API; one known gap (OBJ-3964) tracked outside the milestone. |
| 10 | Docs reconciliation | Shipped | 1 | New docs/features/seasonal-events.md; corrected a stale gotchas.md claim; filed OBJ-3984 rather than absorbing it. |
Key Decisions
mutualAlliesstays permanently0— an invisible ranking weight, never a disclosed count or name. Every publicly discoverable community has a readable member roster, so a "3 people you know are here" count resolves to names in one tap. No consent surface exists for membership disclosure, andprivate_mode/go_it_alone?cover a different preference. Desi's Phase 4 ruling, pinned by a regression spec.- Seasonal events are a new platform-scoped model, not a nullable
CommunityChallenge.CommunityChallenge#community_idisnull: falseat both the DB and model level, with every existing scope/resolver/association assuming a community present. Making it nullable would need a null guard at every one of those call sites.SeasonalEvent/SeasonalEventParticipantmirror the challenge shape without touching it. - Seasonal badges derive from the participant's own row, not a new
BadgeCatalogenum value. The catalog is a frozen, explicitly append-only 21-entry array keyed toUserAction's enum and mirrored 1:1 in frontend code by contract — adding one enum value per event per year is unbounded schema growth for what is really one badge with a date on it.badge_name/badge_iconlive on theseasonal_eventsrow instead, exactly as community-challenge badges already do. community_insights.upcomingEventsstays descoped, not deferred. The originally planned path — serving aSeasonalEventthroughCommunityEventType— contradicts that type's ownnull: falsecommunity fields and would error at execution time. Nothing shipped or planned consumes it; the gap inCommunityEvent's own read path (unrelated to this milestone) is tracked separately as OBJ-3955.- A discovery-safe member type is a separate allow-listed GraphQL type, not a conditional on
UserType. A conditional fails open — whoever adds a field toUserTypenext quarter has no reason to know a discovery resolver reaches it.Types::DiscoveryMemberTypefails closed, pinned by a schema-level regression spec.
Requirements Coverage
7 / 7 requirements satisfied (from MILESTONE-AUDIT.md — the ROADMAP's own 7 milestone-level Success criteria, used as the requirements axis in the absence of a numbered REQUIREMENTS.md).
| Category | Count | Status |
|---|---|---|
Discovery ranking (member-leak fix, matchScore, server-side order, ranking inputs) | 4 | All satisfied |
| Seasonal events (model/admin/badges) | 2 | All satisfied |
| Discovery privacy (unchanged flag-on/flag-off) | 1 | Satisfied |
Outcomes
recommendedCommunitiescan no longer leak a member's email address — the response type is structurally incapable of reachingTypes::UserType, pinned by a regression spec.- The advertised community relevance score is real —
matchScorereturns a deterministic, non-zero value and no longer floods Sentry with swallowed exceptions on every request. - Discovery order is decided once, on the server, and rendered as returned — the client-side re-sort that discarded the server's answer is gone.
- A platform-wide seasonal event can be curated by an admin and joined by a user, with a durable, at-most-once-awarded badge — infrastructure that didn't exist before this milestone.
- Nothing above is live for a real user yet. Both
discovery_ranking_v2andseasonal_events_enabledship at 0% PostHog rollout;seasonal_events_enabledadditionally cannot be raised past 0% until OBJ-3961's ordering fix lands.
Tech Debt
- (Phase 7, OBJ-3961)
active_seasonal_event's resolver orders by earlieststart_datewith no tiebreak, against a UI-SPEC ruling of latest-start_date-with-tiebreaks — harmless at 0% rollout, but gates raising the flag until fixed. - (Phase 9, OBJ-3964) The seasonal event detail view can't distinguish "you didn't finish this event" from "no such event" for a deep link to an ended event, because Phase 7 shipped no public single-event lookup at close. Task 1 (the public
seasonalEvent(id:)query) has since merged (PR #3331); two tasks remain queued. - (pre-existing, OBJ-3955)
communityInsights.upcomingEventsremains a permanent literal[]— theCommunityEventread path for real community events was never written, independent of and untouched by this milestone. - (pre-existing, found in Phase 10, OBJ-3984) A
communities-deep-dive.mdstaleness around theCommunityCardV2Joined-vs-Open CTA, filed rather than absorbed into the docs-reconciliation diff.
Related Artifacts
- Roadmap: v4.63-community-discovery-and-seasonal-events-ROADMAP.md
- Milestone Audit: v4.63-community-discovery-and-seasonal-events-MILESTONE-AUDIT.md
- Feature docs: Seasonal Events, Communities § Community Discovery Ranking
- Feature flags:
discovery_ranking_v2,seasonal_events_enabled - PRD: none — sourced from
docs/product/roadmap.md§ Phase 6 items 41 and 43, not a dedicated PRD - Git tag: not yet tagged — both flags at 0% rollout as of this close
- Merge PRs: #3199, #3244, #3255, #3200, #3330, #3216, #3321, #3235, #3328, #3347
Related Commits
30769ab095— Phase 1: discovery member leak +matchScorefix (PR #3199)15f4ad18ed— Phase 2: community health score rebuild (PR #3244)49ea7954b— Phase 3:Social::RankCommunitiesand the three resolvers (PR #3255)ca9d26f14a— Phase 4: discovery page UI-SPEC (PR #3200)c957f9818— Phase 5: discovery frontend, server-order consumption (PR #3330)11ed35dab7— Phase 6:SeasonalEventmodel, admin surface, three seeded events (PR #3216)6b89aedb7— Phase 7: seasonal participation, badge award, ranking term (PR #3321)014e4d18f5— Phase 8: seasonal event surfaces UI-SPEC (PR #3235)4f89b7b3e— Phase 9: seasonal event frontend (PR #3328)eef2d390c— Phase 10: docs reconciliation (PR #3347)
Last updated: 2026-09-19