v4.14 Activity Nexus — Connected Apps — Rollout Playbook
Phase: v4.14 milestone Phase 6 (AN-RELEASE) Feature flag: connected_apps_enabled (PostHog flag key, project 368400) Owner: Engineering (release) Last updated: 2026-08-03 Related: docs/product/completed/activity-nexus-prd.md, .planning/milestones/v4.14-activity-nexus-connected-apps-ROADMAP.md, docs/development/feature-flags.md, docs/operations/deployment.md, docs/milestones/v4.14-activity-nexus-connected-apps.md
Summary
v4.14 ships a reusable Connected Apps integration framework with two live providers — Strava (real-time, webhook-driven) and Chess.com (daily poll) — that auto-map incoming activity to existing habits via a confidence-scored, user-reviewed mapping engine. The whole surface is gated behind a single PostHog flag, connected_apps_enabled, evaluated identically by the frontend (isFeatureFlagEnabled('connected_apps_enabled'), ionic_frontend/src/lib/featureFlags.ts) and the backend (FeatureFlagService.enabled?('connected_apps_enabled', user:), rails_api/app/services/feature_flag_service.rb).
Flag introduced in this task (2026-07-24) — no earlier phase gated Connected Apps behind a flag; the surface was reachable by any authenticated user as of Phase 5 close. This task registered connected_apps_enabled in the registry, synced it to PostHog disabled at 0% rollout (node scripts/sync-posthog-feature-flags.mjs --apply), and wired the gate into every entry point below. No public flip has been performed — the flag stays at 0%/disabled until a human executes the Stage 3 step.
When the flag evaluates true for a user:
- Frontend:
/settings/connected-appsand/settings/connected-apps/:provider/mappingsroutes are reachable (ionic_frontend/src/router/index.tsbeforeEnterguards); the "Manage connected apps" card renders on the Security settings tab (ionic_frontend/src/views/Settings.vue). - Backend: the
connectedAppsquery resolves (rails_api/app/graphql/resolvers/integration_queries.rb); theconnectStravaandconnectChessCommutations accept new connections (rails_api/app/graphql/mutations/integrations/connect_strava.rb,connect_chess_com.rb).
When the flag evaluates false (default, fail-closed for unconfigured users): the settings entry point is hidden, direct navigation to either route redirects to /settings, and the GraphQL surface raises FORBIDDEN ('Connected apps are not available yet'). Already-connected users are unaffected by anything in this task — nobody is connected yet, since the surface was ungated (and therefore untested against real data) prior to this flag existing.
Not gated (deliberately): the Strava webhook receiver (POST /webhooks/strava) and the Chess.com poller (Integrations::SyncChessActivityJob) are not flag-checked — they only ever act on connections that already exist, and no connection can be created while the flag is off. The lifecycle mutations (disconnectIntegrationConnection, pauseIntegrationConnection, resumeIntegrationConnection) are likewise ungated for the same reason: they only operate on a caller's own existing connection, which requires having connected while the flag was on for them.
Ramp plan
Three stages, matching the standard lifecycle in docs/development/feature-flags.md. Stage 3 (public) cannot open until OBJ-1744 (AN-LEGAL — Termly Privacy Policy + ToS updates covering Strava GPS/location data and Chess.com ingest) has landed and is live. This is a hard PBC/legal gate, not a technical one — do not flip Stage 3 based on technical readiness alone.
Stage 1 — Internal/QA (PostHog cohort filter)
- PostHog config: PostHog → Feature Flags →
connected_apps_enabled→ Conditions → add a condition matchingis_staff = true(same cohort property used by the v1.9 Goal Roadmap ramp) → 100% rollout for that condition only. Leave the flag's baseactivestate and general rollout at the synced default (disabled / 0%) so no one outside the condition is affected. - Audience: internal/QA accounts only.
- Goal: exercise the full connect → OAuth/username → mapping-review → feed-attribution flow against real (or sandbox) provider accounts before any real user sees it. This stage is also where the Tess live-integration pass (see Release checklist gate, below) must run — it is a hard prerequisite for Stage 2, not just Stage 3.
- How to enable: requires a
feature_flag:write-scoped PostHog personal API key. Perdocs/development/feature-flags.md, always go through the PostHog UI or the sync script for flag state changes — never hand-roll aPATCHagainst the API.
Stage 2 — Limited rollout (percentage)
- PostHog config:
connected_apps_enabled→ bump generalrollout_percentageto a small slice (e.g. 5–10%) while keeping the Stage 1 internal/QA condition active. - Audience: a limited slice of the general population.
- Goal: validate the mapping engine's confidence bands (
Integrations::MapIncomingActivity— see OBJ-1712 re-spec) and theNewHabitToastsurfacing guarantee against organic usage patterns before full exposure. Watch Sentry + the success criteria below. - Gate to advance: no rollback-signal trigger (below) fires for at least 48h at this rollout percentage.
Stage 3 — Public (100% rollout) — NOT performed in this task
- Precondition (hard gate): OBJ-1744 (AN-LEGAL) shipped — Termly Privacy Policy + ToS explicitly cover Strava GPS/location data and Chess.com ingest, published and live.
- Precondition (release checklist): Tess live-integration pass complete (see below), Strava rate-limit-increase application submitted (see below), and the 5 ENV vars provisioned in both staging and production (see below).
- How to enable (a human executes this, not this task): PostHog →
connected_apps_enabled→ set generalrollout_percentageto 100% (cohort conditions become redundant at this point and can be left in place or removed). - Audience: all Objectuve users.
Release checklist gate — required before the Stage 3 flip
| Item | Status as of 2026-08-03 | Owner |
|---|---|---|
| AN-LEGAL (Termly Privacy Policy + ToS) | Landed — Termly Terms & Conditions "Third-Party Integrations" clause and Privacy Policy "connected apps" processing purpose published 2026-07-25 (OBJ-1744, PR #1786; see docs/company/legal-and-policies.md) | Dori |
| Tess live-integration pass — live Strava OAuth connect + live Chess.com round-trip. Per Vicki's Phase 5 close note, neither provider's live path has ever been exercised in this environment (Phase 2/3 specs are WebMock-stubbed; Phase 5 FE specs mock the GraphQL layer). This is real-world OAuth/API behavior risk, not just code-path coverage. | Not yet run — required pre-public-flip check, still blocked on the ENV vars row below. | Tess |
| Strava rate-limit-increase application | Not yet submitted — see next section | Release/Josh |
| 5 Strava ENV vars provisioned in staging + production | Not yet provisioned — the outstanding blocker. Code now defaults 3 of the 5 to nil rather than raising (see the ENV var table below) so a merge to master is safe at 0% rollout, but Stage 1 (internal/QA) cannot meaningfully exercise the live Strava path, and the Tess row above cannot start, until these are provisioned. Requires Josh: Strava API app creation + GCP Secret Manager provisioning + webhook subscription registration — see next section | Release (GCP Secret Manager access required) |
Only AN-LEGAL is satisfied as of this update. This checklist is the authoritative pre-public-flip gate for v4.14 — do not flip Stage 3 until every row is checked off.
Ops/env note — 5 required Strava ENV vars
Carried forward from Phase 2 (flagged in the ROADMAP's Phase 2 deviation note). All five are read via ENV.fetch in rails_api/lib/integration_provider/strava.rb and rails_api/app/controllers/webhooks/strava_controller.rb:
| Variable | Read at | Purpose | Secret? |
|---|---|---|---|
STRAVA_CLIENT_ID | strava.rb:30,44 | OAuth app client id, sent on both the authorize redirect and the code→token exchange | No (public per Strava's own OAuth flow) — but still not committed to source |
STRAVA_CLIENT_SECRET | strava.rb:45 | OAuth app client secret, code→token exchange | Yes |
STRAVA_REDIRECT_URI | strava.rb:31 | Per-environment OAuth callback (.../integrations/strava/oauth_callback) | No — differs per environment, plain value |
Webhook verify token (STRAVA_WEBHOOK_VERIFY_TOKEN) | strava_controller.rb:19 | Echoed back during Strava's hub.challenge subscription-validation handshake (GET /webhooks/strava) | Yes (anyone who knows it can pass the handshake) |
Webhook signing secret (STRAVA_WEBHOOK_SECRET) | strava.rb:79 | HMAC-SHA256 key verifying the X-Strava-Signature header on inbound webhook events (POST /webhooks/strava) | Yes |
Current state — not yet wired: none of the five appear in deploy/service.staging.yaml, deploy/service.production.yaml, deploy/worker.*.yaml, or deploy/crono.*.yaml as of this update (2026-08-03). Temporary, as of the v4.14 re-land (OBJ-1982, 506d03e39): STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET, and STRAVA_REDIRECT_URI now default to nil (ENV.fetch('STRAVA_CLIENT_ID', nil), etc., rails_api/lib/integration_provider/strava.rb) instead of a bare ENV.fetch that raises KeyError. This was a deploy-gate fix, not an ops readiness change: scripts/validate-secrets.sh — a deploy-time-only static check — treats a bare ENV.fetch with no default as a required secret and fails the merge if it's absent from every deploy manifest; since these three vars are unreachable while connected_apps_enabled is at 0% (no connect flow can run), a nil default is safe today and turns an unreachable-code deploy-gate failure into an unreachable-code no-op. This is not a substitute for provisioning B1–B3 below — a nil client id/secret simply means the connect flow fails at Strava's API instead of with a Ruby KeyError, and the code carries an explicit OBJ-1982 comment marking the default as temporary, to be reverted to a bare ENV.fetch once B1–B3 land (restoring OBJ-1340's zero-presence protection for these vars like every other required secret). The two ENV.fetch(..., nil) calls that predate this task (verify token, webhook secret) were already nil-defaulted and fail closed (handshake/signature rejected) rather than raising — unchanged, and correct as a permanent default, not a temporary one.
Action item — before Stage 1 (internal/QA) can meaningfully exercise the live Strava path:
- Create a Strava API application at https://www.strava.com/settings/api (or reuse an existing sandbox app) to obtain a client id/secret pair. Staging and production should use separate Strava API apps, following this repo's established never-share-a-live-resource-across-environments pattern (see the
GCS_BUCKET_NAMEisolation precedent indeploy/service.staging.yaml). - Provision GCP Secret Manager secrets (naming convention matches existing entries like
clerk-secret-key,rails-master-keyindeploy/service.production.yaml):strava-client-id,strava-client-secret,strava-webhook-verify-token,strava-webhook-secret— one set per environment. - Add
secretKeyRefentries for the four secrets above, and a plain per-environmentvalue:entry forSTRAVA_REDIRECT_URI(mirroring theGCS_BUCKET_NAMEplain-value pattern), todeploy/service.staging.yamlanddeploy/service.production.yaml.STRAVA_REDIRECT_URIshould resolve tohttps://staging.api.objectuve.com/integrations/strava/oauth_callback(staging) andhttps://api.objectuve.com/integrations/strava/oauth_callback(production), per the environment/domain table indocs/operations/deployment.md. - Register the webhook subscription with Strava (
POST https://www.strava.com/api/v3/push_subscriptionswithclient_id,client_secret,callback_url=https://<env-api-host>/webhooks/strava, andverify_token= the same value stored inSTRAVA_WEBHOOK_VERIFY_TOKEN) once the callback endpoint is reachable in that environment. There is no in-repo script for this step — it's a one-time manualcurl/Postman call per environment against Strava's API. - This provisioning work requires GCP Secret Manager write access, which this task does not have — hand off to whoever holds that access (same actor who provisioned
clerk-secret-keyet al.).
Strava rate-limit-increase application note
Strava's default free-tier limits (per PRD §9, Open Questions): 200 requests / 15 min, 2,000 requests / day, enforced per API application (not per connected user). Per the PRD's own risk analysis, this becomes a real constraint at roughly 5,000 connected users — comfortably above Stage 1/2 rollout volumes, but a hard blocker if reached mid-ramp without an increase in place.
Action item — pre-public (Stage 3) action item, not blocking Stage 1/2: apply for an increased rate limit via Strava's API settings (https://www.strava.com/settings/api → request a limit increase) before the Stage 3 flip. Strava's application form asks for expected request volume — base the estimate on webhook event volume (one inbound event per new/updated Strava activity per connected user, real-time push, not polled — see A3 in the PRD) projected against the Stage 2 rollout's observed connected-user count, extrapolated to 100%. No such application has been submitted as of this task.
Graceful degradation already in place: per the PRD's risk table and Integrations::AutoCheckIn, a rate-limit failure logs a warning + Sentry alert and does not queue check-ins retroactively — it does not crash or silently corrupt data. This means exceeding the limit degrades to "some activities don't get imported until the next webhook event," not an outage, which lowers the urgency of the application relative to the legal gate but does not remove it from the pre-public checklist.
Rollback procedure
- Disable the flag (PostHog → Feature Flags →
connected_apps_enabled→ set rollout to 0% /active: false). Effect is global within ~30s; both frontend and backend evaluate fail-closed. NeverPATCHthe PostHog API directly for this — use the UI or the sync script's--promote/manual toggle, per the credential-gotcha rule indocs/development/feature-flags.md. - Verify: confirm
/settings/connected-appsredirects to/settingsfor a non-cohort test account, and thatconnectStrava/connectChessComreturn aFORBIDDENGraphQL error. - Disabling the flag does not touch already-created
IntegrationConnection/IntegrationActivity/HabitIntegrationMappingrows or already-checked-inGoalEvents — it only stops new connects and hides the surface. If a rollback is data-related (e.g. bad auto-check-ins), that's a per-mappingauto_check_enabledtoggle or manual data fix, not a flag flip. - Notify Slack
#engineeringof the rollback and root cause.
Rollback signal triggers
- Sentry error rate on
Integrations::AuthorizeConnection,Integrations::ConnectChess,Strava::IngestionJob, orIntegrations::SyncChessActivityJobexceeds 1% of invocations over a 15-minute window. - Any confirmed report of a mis-mapped or silently-checked auto-check-in (the PBC non-negotiable — proposals must never silently check a habit).
- Strava webhook signature-verification failure rate spikes (possible spoofing attempt or a misconfigured/rotated
STRAVA_WEBHOOK_SECRET). - Strava API rate-limit errors observed in Sentry before the rate-limit-increase application (above) has been approved.
Appendix — flag key + observability quick reference
| Surface | Wrapper | Reads from |
|---|---|---|
| Frontend | isFeatureFlagEnabled('connected_apps_enabled') (ionic_frontend/src/lib/featureFlags.ts) | PostHog client (posthog.isFeatureEnabled) |
| Backend | FeatureFlagService.enabled?('connected_apps_enabled', user:) (rails_api/app/services/feature_flag_service.rb) | PostHog server-side via POSTHOG_CLIENT |
| Dev override | localStorage.setItem('objectuve_connected_apps_enabled_override', 'on' | 'off' | 'auto') | local browser (development mode only) |
| Observability | Where |
|---|---|
| Sentry — Strava/Chess.com integration errors | Project filter: transaction: any of AuthorizeConnection, ConnectChess, IngestionJob, SyncChessActivityJob, MapIncomingActivity, AutoCheckIn |
| PostHog — flag state | https://us.posthog.com/project/368400/feature_flags → connected_apps_enabled |
| Lograge — Rails request logs | GCP Cloud Logging: jsonPayload.controller="GraphqlController" (connect mutations) or jsonPayload.controller="Webhooks::StravaController" |