Skip to content

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::AchievementUnlocked and Subscriptions::FeedUpdate (rails_api/app/graphql/subscriptions/), joining the existing notification_update.rb, each with an inline authorize! (not a generic can_subscribe_to? predicate, matching OBJ-2486's Decision B) and a rescue Redis::BaseError fail-soft trigger.
  • Backend: DeclinePartnerRequest now notifies the requester symmetrically with AcceptPartnerRequest and EndPartnership.
  • 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.ts and useNotificationStream.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.ts and useUnifiedFeed.ts wired to the substrate; AchievementWatcher.vue's 10s pollInterval deleted 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 required Vitest + ESLint (ionic_frontend) CI job.
  • Docs: docs/features/notifications.md § Real-Time Delivery (topic inventory for all three subscriptions; corrected a stale context[: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 the UserAlly row).
  • 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), all backlog, linked from this milestone's .planning/ROADMAP.md index entry.

Phases

PhaseNameStatusPlansHighlights
1Subscription substrate + achievement unlocksShipped1Built 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.
2aIdentity-scoped live-subscription substrateShipped1Closed 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.
2Ally / partner request statusShipped1Made DeclinePartnerRequest notify symmetrically with accept/end; consumed the existing stream rather than adding a topic.
3Activity feed live updatesShipped1Added feedUpdate as an invalidation ping; background fan-out job for the one uncovered source; Desi-specced "N new" pill instead of an auto-prepend.
4Reconnect proof, poll audit, deferral recordShipped1One 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 topicAcceptPartnerRequest already fired notificationUpdate to exactly the right device; nobody listened. DeclinePartnerRequest fired nothing. Fixing the notification asymmetry and listening on the stream that already existed was cheaper and smaller than adding a topic.
  • feedUpdate is an invalidation ping, not a rendered item — reconstructing one feed item's viewer-specific shape outside Social::BuildUnifiedFeed would 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 feedUpdate only, never notificationUpdate, 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-e2e job 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 clauseStatus
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.vue carries its own separate partnership listener that did not get the same reconnect catch-up usePartnerStatus.ts gained in this milestone. Filed as OBJ-2905.
  • 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

Loading…