v4.45 — Live Surfaces Beyond Notifications
Achievement unlocks, partner request status, and the activity feed now update themselves live over ActionCable — every poll they replaced is gone, and a dropped WebSocket can't leave any of them silently stale.
Summary
Before this milestone, exactly one ActionCable topic existed in production — notificationUpdate — and every other multi-user surface in the app that needed to feel live instead polled. The transport itself was never the risk: the wiring, the auth, the Redis-backed production adapter had all been proven end-to-end on that one topic. The risk nobody had named out loud was that on three of the five candidate surfaces, the poll being deleted was the app's only reconnect catch-up. There is no connection-state handler anywhere in the frontend, and ActionCableLink resubscribing after a drop recovers the stream, not the events missed while it was down. Delete the poll first and build catch-up later, and a dropped WebSocket goes from "user sees stale data for ten seconds" to "user sees stale data until they navigate away and back, or never."
Five phases (four planned, one inserted mid-milestone) shipped in two calendar days, all directly to master. Phase 1 proved the shared catch-up substrate on achievement unlocks — the cleanest surface in the app, since every badge award funnels through one model method — and deleted the heaviest poll in the codebase (USER_QUERY pulling 50 actions and 50 notifications every 10 seconds per open client). Roy's review of that PR surfaced a hazard the plan hadn't scoped: subscription state lived at the JS-bundle level, not the signed-in-identity level, so an in-app account switch (no page reload) could leak one account's live events to another. Phase 2a closed that before either of the next two consumers could inherit it. Phase 2 fixed a real bug — declining a partner request notified nobody — and consumed the existing stream instead of adding a topic. Phase 3 added the milestone's one genuinely new topic, feedUpdate, as an invalidation ping rather than a rendered payload, so the feed's existing viewer-scoped filtering stays the only implementation of itself. Phase 4 is the proof: one test that drops a real WebSocket, generates events on all three surfaces while it's down, restores the connection, and asserts all three catch up — wired into a required CI check, not the app's non-required Cypress E2E lane.
Goal
Three multi-user surfaces update themselves live over the existing ActionCable transport, every poll they replaced is gone, and a dropped WebSocket cannot leave any of them silently stale.
Scope — What Shipped
- Backend:
Subscriptions::AchievementUnlockedandSubscriptions::FeedUpdate(rails_api/app/graphql/subscriptions/), joining the existingnotification_update.rb, each with an inlineauthorize!(not a genericcan_subscribe_to?predicate, matching OBJ-2486's Decision B) and arescue Redis::BaseErrorfail-soft trigger. - Backend:
DeclinePartnerRequestnow notifies the requester symmetrically withAcceptPartnerRequestandEndPartnership. - Backend:
Social::FanOutFeedUpdateJob— background fan-out covering the one feed source with no existing per-recipient event (ally goal events); the other five feed-writing paths already fired a notification-shaped event and needed only a listener added. - Frontend:
useLiveQuery.ts— the shared substrate composable pairing a subscription with an explicit reconnect-catch-up refetch, gained across three phases with exactly one added parameter (onPayload) and zero breaking changes to its 19 pre-existing tests. - Frontend: identity-scoped teardown/restart for both
useLiveQuery.tsanduseNotificationStream.ts— subscriptions now tear down and re-subscribe on sign-out and account switch, closing a cross-account content-leak hazard that predated this milestone. - Frontend:
usePartnerStatus.tsanduseUnifiedFeed.tswired to the substrate;AchievementWatcher.vue's 10spollIntervaldeleted entirely. - Frontend:
ionic_frontend/tests/unit/reconnectCatchUp.spec.ts— one test driving all three real production composables through a shared disconnect/reconnect, enforced by the requiredVitest + ESLint (ionic_frontend)CI job. - Docs:
docs/features/notifications.md§ Real-Time Delivery (topic inventory for all three subscriptions; corrected a stalecontext[:viewer]authorization claim that was never true of this code path),docs/features/activity-feed.md§ Real-Time Delivery (new),docs/development/social-layers.md(corrected: decline/end partnership never delete theUserAllyrow). - Filed, not built here: four follow-up issues (OBJ-2902 team leaderboard, OBJ-2903 export status, OBJ-2904 UI-evidence capture gap, OBJ-2905
AlliesPage.vue's own unconverted partnership listener), allbacklog, linked from this milestone's.planning/ROADMAP.mdindex entry.
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | Subscription substrate + achievement unlocks | Shipped | 1 | Built the reconnect-catch-up substrate; proved it on achievementUnlocked; deleted the app's heaviest poll. Found the real connect/disconnect signal lives in ActionCableLink's callbacks, not a hand-built consumer handler. |
| 2a | Identity-scoped live-subscription substrate | Shipped | 1 | Closed a cross-account staleness + content-leak hazard Roy's Phase 1 review surfaced — subscription state was scoped to the JS bundle, not the signed-in identity. |
| 2 | Ally / partner request status | Shipped | 1 | Made DeclinePartnerRequest notify symmetrically with accept/end; consumed the existing stream rather than adding a topic. |
| 3 | Activity feed live updates | Shipped | 1 | Added feedUpdate as an invalidation ping; background fan-out job for the one uncovered source; Desi-specced "N new" pill instead of an auto-prepend. |
| 4 | Reconnect proof, poll audit, deferral record | Shipped | 1 | One real disconnect/reconnect test proving all three surfaces catch up, on a required CI job; audited every remaining pollInterval; filed and linked four deferral follow-ups; corrected two stale docs claims. |
Key Decisions
- Build the reconnect-catch-up primitive once, first, in Phase 1 — three of five candidate surfaces relied on the very poll this milestone deletes as their only reconnect recovery. Building it once ahead of every consumer avoided five independent, and likely inconsistent, reinventions.
- Partner status gets no new subscription topic —
AcceptPartnerRequestalready firednotificationUpdateto exactly the right device; nobody listened.DeclinePartnerRequestfired nothing. Fixing the notification asymmetry and listening on the stream that already existed was cheaper and smaller than adding a topic. feedUpdateis an invalidation ping, not a rendered item — reconstructing one feed item's viewer-specific shape outsideSocial::BuildUnifiedFeedwould have duplicated its blocked-user and demo-community filtering in a second place. The client always refetches through the real query.- The feed listens to
feedUpdateonly, nevernotificationUpdate, even though the latter would cover most sources for free — two live mechanisms on one surface drift apart, and "you got a notification" is not the same claim as "your feed changed." - The reconnect gate lives in the required Vitest job, not Cypress E2E — this repo's
cypress-e2ejob is explicitly not a required check and additionally skips on PRs touching none of its allow-listed paths; a socket-drop test landing there would produce a green tick that blocks nothing, the exact failure this criterion exists to prevent. - Team leaderboard and export status stayed out of scope, written down rather than silently dropped — team leaderboard needs team-scoped fan-out and a recompute trigger that doesn't exist yet and changes hourly; export status is single-user and its poll only runs while a job is in flight.
Requirements Coverage
3 / 3 goal clauses satisfied, 22 / 22 phase-level acceptance criteria satisfied (see .planning/milestones/v4.45-live-surfaces-beyond-notifications-MILESTONE-AUDIT.md).
| Goal clause | Status |
|---|---|
| Three surfaces update live over ActionCable | ✅ |
| Every poll each surface replaced is gone | ✅ |
| A dropped WebSocket cannot leave any of them silently stale | ✅ |
Outcomes
Achievement unlocks, partner request status, and the activity feed all update within seconds of the triggering event on an open client, with no poll and no navigation required. The app's heaviest recurring query load — a 10-second USER_QUERY poll pulling 50 actions and 50 notifications per open client — is gone. A dropped WebSocket no longer silently strands any of the three surfaces: reconnecting refetches once, catching up on whatever was missed, proven by a single test that exercises all three real production composables together rather than three separate unit-level claims. Two more polls (team leaderboard, export status) remain, both by name and both filed as scoped follow-up work rather than left to look like an oversight.
Tech Debt
- (Milestone-wide) The reconnect claim is proven at the unit level, not against a live socket dropped against a running server. Every phase recorded this honestly rather than claiming more; it's the ceiling of what this milestone's proof establishes, not a silent gap.
- (Phase 3) Motion evidence for the activity feed's live-arrival pill could not be captured — the UI-evidence pipeline fail-closes on auth in the available workdir shape, and its default capture mode only records a route's own mount, not a scripted second-identity interaction. Filed as OBJ-2904.
- (Phase 4)
AlliesPage.vuecarries its own separate partnership listener that did not get the same reconnect catch-upusePartnerStatus.tsgained in this milestone. Filed as OBJ-2905.
Related Artifacts
- Roadmap: v4.45-live-surfaces-beyond-notifications-ROADMAP.md
- Milestone Audit: v4.45-live-surfaces-beyond-notifications-MILESTONE-AUDIT.md
- Source issue: OBJ-2877 — "Expand ActionCable/GraphQL subscriptions beyond notifications," filed by Maggie off a research pass answering "are ActionCable/WebSockets used meaningfully today?"
- Coordination anchor: OBJ-2878 (parent milestone issue)
- Follow-ups filed: OBJ-2902 (team leaderboard), OBJ-2903 (export status), OBJ-2904 (UI-evidence capture gap), OBJ-2905 (
AlliesPage.vuereconnect gap)
Related Commits
6b7f36108— Phase 1: Subscription substrate + achievement unlocks (PR #2613)1f103416c— Phase 2a: Identity-scoped live-subscription substrate (PR #2616)0c644e19— Phase 2: Ally / partner request status (PR #2617)f7907a0e9— Phase 3: Activity feed live updates (PR #2618)8eac3ce65c— Phase 4: Reconnect proof, poll audit, deferral record (PR #2622)
Last updated: 2026-08-24