Skip to content

Feature Flags

PostHog is the source of truth for feature flag state. The code carries the registry of flag keys; PostHog owns rollout.

Reading flags safely

PostHog initializes lazily, from a post-mount idle callback (ionic_frontend/src/main.ts:99-103), not on the pre-mount boot path. Any flag read that runs synchronously during a component's setup() — a plain top-level const enabled = isFeatureFlagEnabled('some-flag') — executes before PostHog has ever loaded a flag payload, so posthog.isFeatureEnabled() returns undefined, isFeatureFlagEnabled() fails closed to false, and if the result was captured in a plain const it never re-evaluates once PostHog does finish loading. This is what happened to App.vue's two toast gates (connected_apps_enabled, ally-request-flow-enabled) — permanently false for every user, at any rollout percentage, until fixed in OBJ-2085.

There are three sanctioned ways to read a flag. Pick based on where the read happens:

  1. useFeatureFlag(flag) — reactive read, safe at a component's top-level setup(). Use this for any component/template gate (a v-if, a computed) that lives for the app's session rather than deciding a single navigation.

    ts
    import { useFeatureFlag } from '@/composables/useFeatureFlag'
    const connectedAppsEnabled = useFeatureFlag('connected_apps_enabled')
    html
    <NewHabitToast v-if="connectedAppsEnabled" />

    Returns a Readonly<Ref<boolean>> that starts false and flips to the real value once PostHog's flags resolve (immediately in dev mode; via onFeatureFlagsResolved() in production, which does not call initPostHog() and has no timeout — App.vue mounts on every boot, so a reactive gate must not force PostHog's ~52ms init cost onto every user's critical path). Unsubscribes automatically on unmount.

  2. await awaitFeatureFlags() then isFeatureFlagEnabled(flag) — one-shot blocking decision for a route guard (beforeEnter) or any other pre-render check that needs an answer before proceeding, not a value that updates later.

    ts
    await awaitFeatureFlags()
    if (!isFeatureFlagEnabled('connected_apps_enabled')) {
      return { name: 'Settings' }
    }

    awaitFeatureFlags() triggers initPostHog() on demand (idempotent — safe to call from every gated guard) and resolves as soon as PostHog reports flags loaded, or after a 1500ms timeout, whichever comes first — it never rejects. 6 call sites in ionic_frontend/src/router/index.ts follow this pattern — evaluateConnectedAppsGate (2 routes), evaluateAllyRequestFlowGate (3 routes), and 4 inline closures (1 route each) — covering 9 gated routes total; ionic_frontend/tests/smoke-playwright/specs/platform/cold-boot-flag-gated-route.spec.ts is the template for a cold-boot regression spec covering a new one. Full incident writeup for why this matters: CLAUDE.md's Common Gotchas, "PostHog flag-gated router guards failed closed on cold boot" (OBJ-2023).

  3. isFeatureFlagEnabled(flag) directly — only inside code that's already behind one of the two patterns above: a computed()/watch() callback (which re-evaluates later), or a function body that runs after a guard's own await awaitFeatureFlags(). It is unsafe only at a component or module's synchronous top-level scope, for the reason above.

Detector: a warn-level ESLint rule, no-unawaited-feature-flag, flags any isFeatureFlagEnabled(...) call sitting at a module or <script setup> top-level scope — the shape that broke App.vue. It does not flag calls inside a function, callback, or lifecycle hook. Warn, not error: ~18 pre-existing top-level call sites (Settings.vue, Dashboard.vue, Communities.vue, GoalCreate.vue, AllyCard.vue, Achievements.vue, and more) would trip it at error level and get switched off — same precedent as no-fabricated-query-state (ionic_frontend/eslint.config.js:52-56). The rule's warning list is the input to a follow-up triage ticket for those pre-existing sites.

Lifecycle

1. INTRODUCE         add key to registry + isFeatureFlagEnabled() callsites
                     → sync to PostHog (disabled, 0% rollout)
2. ROLLOUT           bump rollout % in PostHog UI as confidence grows
                     (10% → 50% → 100%) — code unchanged
3. GA (this script)  promote to active + 100% rollout in PostHog
                     → soak for at least one full release
4. RETIRE            remove from registry, delete every isFeatureFlagEnabled() callsite,
                     hardcode the "on" branch, archive the flag in PostHog

Stage 3 is the kill-switch state: feature is on for everyone, but the flag still exists in case of regression. Stage 4 is true GA — the flag is gone and the feature is just the product.

Which steps produce a diff: Step 1 (introduce) and Step 4 (retire) are code changes — each ships in a PR. Step 2 (rollout) is a PostHog UI action only — no commit, no PR — see Step 2: Roll out below. Step 3 (promote to GA) runs the sync script from a local/CI shell against the PostHog API — also no commit, no PR (the script has no write access to this repo). Don't cut a PR-shaped task for Step 2 or Step 3.

Step 1: Introduce a new flag

In the same PR:

  1. Add the key to FEATURE_FLAGS (boolean) or MULTIVARIATE_FLAGS (with variant keys) in featureFlags.ts.

  2. Boolean: gate code with isFeatureFlagEnabled('your-key'). Multivariate: use posthog.getFeatureFlag('your-key') and switch on the returned variant.

  3. Run the sync to create it in PostHog (disabled, 0%):

    bash
    POSTHOG_PERSONAL_API_KEY=phx_… node scripts/sync-posthog-feature-flags.mjs --apply

CI runs --ci on every PR that touches featureFlags.ts and blocks merge if the registry contains keys missing from PostHog.

A --apply dispatched against the default master checkout silently creates nothing. --apply reads the flag registry off the working tree, not a git ref — if the flag only exists in featureFlags.ts on a PR branch, running the sync against master's checkout finds nothing new to create and exits clean, which looks identical to "already synced." .github/workflows/posthog-flag-sync-apply.yml (added OBJ-3940, after discovery_ranking_v2 stalled ~66h on exactly this) fixes this by taking an explicit ref input and checking it out before syncing — always dispatch it with ref set to the PR's own branch, never left to default to master/github.sha.

Current Stage 1 examples:

  • editorial-discovery (OBJ-2191, introduced 2026-08-07) — registered in FEATURE_FLAGS, synced disabled/0% rollout via --apply in the same PR, not promoted. Gates the four Communities editorial discovery rails (see Communities § Editorial Discovery Rails). Read via useFeatureFlag('editorial-discovery') (pattern 1 below), not the raw posthog client.
  • celebration_sharing_enabled (v4.56, OBJ-3679–OBJ-3684, introduced 2026-09) — gates the three celebration share controls (Achievements, goal action bar, streak details) client-side only; the shareableMoment query and recordShare mutation are not themselves flag-gated. At 0% rollout — this is the answer to "is celebration sharing shipped?" until it ramps. Read via useFeatureFlag('celebration_sharing_enabled') (pattern 1). See Celebration Sharing Cards.
  • discovery_ranking_v2 (v4.63 Phase 3, OBJ-3809, introduced 2026-09) — gates Social::RankCommunities, the real community discovery ranking engine, across recommendedCommunities, trendingCommunities, communityInsights.suggestedCommunities, and the Explore communities resolver. At 0% rollout. Its registry entry was the flag that stalled OBJ-3940 (see the --apply ref edge above) — a reminder that promoting it later needs the ref-aware workflow if the sync is ever re-run against a branch. See Communities § Community Discovery Ranking.
  • seasonal_events_enabled (v4.63 Phase 7, introduced 2026-09) — gates the seasonal event banner, detail view, badge tile, and the seasonal-participation term in Social::RankCommunities' score. At 0% rollout, and not self-serve — do not raise it above 0% until OBJ-3961 lands. activeSeasonalEvent's resolver ships an ordering bug (earliest start_date, no tiebreak) against a UI-SPEC ruling of latest-start_date-with-tiebreaks; above 0% rollout, two same-day-start active events would show different users a different "the" active event with no way to make it deterministic. See Seasonal Events § Rollout status for the full detail and the follow-up issue.
  • streak_insurance_enabled (v4.67, OBJ-4022, introduced 2026-09-19) — server-side kill switch, checked in Gamification::ClaimStreakInsurance via FeatureFlagService.enabled?('streak_insurance_enabled', user:) and in StreakRepairOfferResolver#insurance_result, plus client-side for the StreakRepairCard.vue insurance lane and SupporterUpgradeModal.vue's upgrade CTA. Mandatory, not optional — this milestone touches a live free mechanic (the existing streak-repair card), so shipping it without a kill switch was a hard requirement from kickoff. At 0% rollout — all six build phases (1, 2, 3, 3b, 4a, 4b) plus the wiring phase (4c) have shipped, but nobody has ramped the flag yet; this is the answer to "is streak insurance live?" until it does. Read via useFeatureFlag('streak_insurance_enabled') client-side (pattern 1). See Habits § Streak Repair and Streak Insurance and docs/operations/rollouts/v4.67-streak-insurance.md for the ramp plan.

Router guards must await flag readiness

If the new flag gates a route (a beforeEnter guard in ionic_frontend/src/router/index.ts), await awaitFeatureFlags() before calling isFeatureFlagEnabled(), not just the plain wrapper — see pattern 2 in Reading flags safely above for why and the reference implementation. Full incident writeup: CLAUDE.md's Common Gotchas, "PostHog flag-gated router guards failed closed on cold boot" (OBJ-2023).

The same failure shape recurs for a plain component-level read (a flag captured in a top-level const during setup(), gating a tile, section, or dependent row) — see pattern 1 above. Confirmed instance: SettingsHub.vue/Settings.vue's critical-path-full-product read, which gated the Notifications tile and its dependent rows (OBJ-2083).

Step 2: Roll out

Use the PostHog UI (https://us.posthog.com/project/368400/feature_flags). Bump rollout_percentage on the default release condition. No code changes, no commit, no PR — this is a PostHog-side action only, taken between merged PRs, not inside one. Monitor Sentry + PostHog analytics between bumps.

Current Step 2 example:

  • async_ai_delivery_enabled (v4.60, OBJ-3904) — gates the async AI delivery path (ActionCable subscription + durable AiRequest row) for all seven AI mutations (GetAdvice, RefineGoal, GenerateMilestones, SuggestGoals, RefineDescription, GetInsight, GenerateGoalDraft). Registered and synced disabled/0% in Phase 1; every consuming code phase has since shipped. At 0% rollout — the staged PostHog percentage ramp (10% → 50% → 100%) that this Step 2 section describes is the open item blocking GA, tracked as OBJ-3904. Read via useFeatureFlag('async_ai_delivery_enabled') (pattern 1 in Reading flags safely).

Step 3: Promote to GA (kill-switch state)

For a boolean flag:

bash
POSTHOG_ALLOW_PROMOTE=1 POSTHOG_PERSONAL_API_KEY=phx_… node scripts/sync-posthog-feature-flags.mjs --promote <key>

To promote every boolean flag in the registry at once:

bash
POSTHOG_ALLOW_PROMOTE=1 POSTHOG_PERSONAL_API_KEY=phx_… node scripts/sync-posthog-feature-flags.mjs --promote-all

The script PATCHes the flag to active: true + 100% rollout. It is idempotent (already-GA flags print · already GA and are skipped). Multivariate flags are refused — promote a winner manually in the UI by setting one variant to 100% and the rest to 0%.

Soak for at least one full release before retiring. The flag remains in the code as a kill-switch — if a regression appears, flip active: false in the UI and the feature disappears for everyone within seconds (no deploy needed).

Step 4: Retire (true GA)

Once the GA flag has soaked clean for at least one release, retire it from the codebase:

  1. Remove the key from FEATURE_FLAGS / MULTIVARIATE_FLAGS.
  2. Remove its entry from DEV_OVERRIDE_KEY_MAP (boolean only).
  3. grep -rn '<flag-key>' ionic_frontend admin_dashboard rails_api agent_runner — delete every callsite. Inline the "on" branch; delete the "off" branch.
  4. Add a one-line note to the historical comment in featureFlags.ts (next to the existing 'new-onboarding-v1-8' note), e.g. 'goal-roadmap-v1-9' was retired in vX.Y.Z.
  5. In PostHog, archive the flag (don't delete — the event history is referenced by past analytics). Do not skip this — a retired flag left live is reported by --reverse-drift and is indistinguishable there from a stranded milestone.
  6. Ship the PR. The drift gate stays green because the key is gone from the registry; the archived flag in PostHog is fine to have around.

Multivariate flags

A multivariate flag is an A/B test — "going GA" means picking a winner, not turning it on. Process:

  1. Analyze results in the PostHog UI; pick a winning variant.
  2. In PostHog, set the winning variant to 100% and the others to 0%. The flag stays multivariate so historical event data still resolves.
  3. Update the code to always take the winning variant's branch.
  4. Retire (Step 4 above): remove from MULTIVARIATE_FLAGS, delete every getFeatureFlag callsite, archive in PostHog.

Why the lifecycle matters

  • Step 3 first, then Step 4. Going straight from "off" or "10%" to "remove from code" skips the kill-switch window — a regression then requires a deploy to roll back instead of one click in PostHog.
  • Always Step 4 eventually. Stale flags rot. Each one is a if (true) { … } else { … } waiting to surprise the next reader. Aim to retire every flag within ~2 releases of hitting GA.
  • Drift goes both ways. The CI gate (--ci) catches code-without-PostHog. --reverse-drift catches PostHog-without-code — see below.

Reverse drift: live in PostHog, absent from master

--ci gates one direction only. The inverse — a flag live in PostHog (active, rollout > 0) whose key is absent from the registry as it exists on origin/master — is what hid the v4.15 strand: dashboard_hierarchy GA'd to 100%, Step 4 retired it from code, and the integration branch carrying that code was never promoted to master. PostHog read "on, 100%", master had zero consuming code, and nothing reported it. Check for it with:

bash
POSTHOG_PERSONAL_API_KEY=phx_… node scripts/sync-posthog-feature-flags.mjs --reverse-drift

Exit 0 clean / 1 drifted / 2 crashed. Read-only — it never POSTs or PATCHes. .github/workflows/posthog-flag-reverse-drift.yml runs it weekly (Mondays 14:30 UTC) and files a deduplicated tracking issue.

Every flag it reports is one of two things, and the signal cannot tell them apart:

  1. Legitimately retired, awaiting archival — Step 4 removed it from code but nobody did Step 4.5 in the PostHog UI. This script has no archive operation; archive it there.
  2. Shipped but unpromoted — the milestone branch never reached master. Cross-check with node scripts/check-branch-promotion.mjs report and see the milestone close ritual — note its output now also has a "Suppressed" section for any branch deliberately parked or shelved via a tag, see Parking a flagged branch.

Because case 1 is common and legitimate, this is deliberately a nag, not a gate — never wire it into a required PR check. On the day it shipped it reported five flags, three of them legitimate retirements.

Presence is tested by registry membership, not by grepping master for the key. A text search matches the historical retirement note in featureFlags.ts (Step 4.4 above adds one), unrelated longer keys that share the prefix (dashboard_hierarchy_migrated_v1, a localStorage marker), and CHANGELOG/docs prose — so it flips to "present" the moment a milestone lands and falsely exonerates the flag. The registry is the typed single source of truth and has none of those failure modes.

Credential gotchas

  • POSTHOG_ALLOW_PROMOTE gates the script, not the credential. sync-posthog-feature-flags.mjs refuses --promote/--promote-all unless POSTHOG_ALLOW_PROMOTE=1 is set (the guard is the first statement in main()), and it is fail-closed — absent var, it exits before any API call and before the API-key check. Keep the guard: it does its real job, which is stopping an accidental --promote-all in a scripted or agent run. But it is not an authorization boundary — any key with feature_flag:write scope can PATCH /api/projects/368400/feature_flags/{id}/ directly to set a flag active at 100% rollout (the real payload nests rollout under filters.groups[] — see promoteFlag()), bypassing the script entirely. The rule, not the gate, is what actually holds, and it is scoped to promotion to GA specifically — the script's only mutating write capability (buildBooleanPayload()/buildMultivariatePayload() create disabled at 0%; promoteFlag() is the one call that flips a flag live): don't promote a flag to GA outside the script. This does not forbid Step 2's PostHog-UI percentage bumps — those are the documented, sanctioned way to move rollout_percentage, and the script has no rollout-percentage capability of its own to bypass. Blast radius if the promote rule is broken is bounded — a wrongly-promoted flag turns a feature on for everyone and is revertible in one click in the PostHog UI, no deploy. Closing the gap structurally needs either a PostHog-side approval workflow or a detective control (a scheduled diff of live PostHog state against the registry). The detective control now exists as of 2026-07-30 — --reverse-drift, run weekly by posthog-flag-reverse-drift.yml (see Reverse drift above). It closes the "promoted outside the script and never registered" case within a week; it does not close the "promoted outside the script but the key is registered" case, and there is still no PostHog-side approval workflow.
  • The env var name is POSTHOG_PERSONAL_API_KEY, exactly — if someone reports no PostHog access, check the name before assuming the key is absent. The script reads process.env.POSTHOG_PERSONAL_API_KEY and exits code 2 (POSTHOG_PERSONAL_API_KEY is required) if unset. A key injected under any other name is invisible to it, and an absent key and a misnamed one produce an identical error.
    • How this bit us: the Multica agent Codi's key was injected as POSTHOG_ACCESS_KEY in its Multica per-agent env config, a name no code path here reads, until it was renamed on 2026-07-20. Whether that misnaming is what stalled OBJ-1519/OBJ-1547 on "no agent can run the sync" was never established — the crew also carried an independent "nobody holds PostHog write access" blocker from Phase 5 of the v4.0 Teams V1 milestone onward (see .planning/milestones/v4.0-teams-v1-ROADMAP.md).
    • Who holds what: Codi's key is scoped feature_flag:write + dashboard:write + insight:write on project 368400 — enough to run the sync and to create Insights/Dashboards. This is the one place that scope list is recorded; other docs should link here rather than restate it.

Overriding flags in smoke tests

Everything above is about reading a flag. Forcing a flag to a specific value for a smoke spec is a different problem, and there are two independent mechanisms — pick based on where the flag is actually evaluated, not on which one you reach for out of habit.

Which kind of gate are you facing

bash
grep -rn 'FeatureFlagService.enabled?' rails_api/app
  • Key shows up in that grep — the flag is (also) gated server-side. A client-side-only override cannot move it: the server re-evaluates the flag itself, independently of whatever the browser's own PostHog client believes. You need registerServerFlagOverrides (below) in addition to, or instead of, the client-side helper.
  • Key does not show up — the flag is read only via the frontend (isFeatureFlagEnabled() / useFeatureFlag() / posthog.getFeatureFlag()). registerFlagOverrides alone is sufficient.

Client-side: registerFlagOverrides

Intercepts the browser's own PostHog /flags network response. Only affects frontend reads (isFeatureFlagEnabled(), useFeatureFlag(), posthog.getFeatureFlag()) — it has no effect on any server-side FeatureFlagService.enabled? call, because that check never touches the browser's PostHog client.

ts
import { registerFlagOverrides } from '../../helpers/flagOverrides'

await registerFlagOverrides(context, [['async_ai_delivery_enabled', 'on']])

Source: ionic_frontend/tests/smoke-playwright/helpers/flagOverrides.ts.

Server-side: registerServerFlagOverrides

Attaches X-Smoke-Secret and X-Smoke-Flag-Overrides headers to every outgoing GraphQL request in the browser context (a Playwright context.route() on the GraphQL endpoint):

ts
import { registerServerFlagOverrides } from '../../helpers/flagOverrides'

await registerServerFlagOverrides(context, [['async_ai_delivery_enabled', 'on']])

Rails-side, SmokeFlagOverrides (included in ApplicationController, rails_api/app/controllers/concerns/smoke_flag_overrides.rb) reads those headers in a before_action. If the secret matches FALLBACK_AUTH_SECRET and the header parses, it sets Current.smoke_flag_overrides — a request-scoped ActiveSupport::CurrentAttributes value (rails_api/app/models/current.rb) — for the duration of that one request. FeatureFlagService.enabled? (rails_api/app/services/feature_flag_service.rb) checks that override first, ahead of the POSTHOG_CLIENT.nil? guard, and returns it directly without ever calling PostHog.

Header format (values are exactly on / off):

X-Smoke-Secret:         <FALLBACK_AUTH_SECRET>
X-Smoke-Flag-Overrides: async_ai_delivery_enabled=on,feature_tour_enabled=off

Wrong secret → 401; a malformed token (anything other than on/off, empty key/value) → 400 naming the offending token.

Both helpers can be registered on the same Playwright context without conflict — they intercept different endpoints (PostHog's /flags vs. the app's own /graphql).

Scope and limits — read before writing a new server-gated spec

  • Request-scoped only. Current resets between requests by design (that's what ActiveSupport::CurrentAttributes is for) and is never inherited by a Sidekiq job or an ActionCable subscription. Concretely: Mutations::Ai::GetAdvice#resolve checks FeatureFlagService.enabled?('async_ai_delivery_enabled', ...) on the request thread to decide dispatch_async vs. resolve_sync — the override reaches that check. But once dispatch_async enqueues Ai::DispatchAiRequestJob.perform_later, that job runs later in a separate Sidekiq process with no Current state at all. The override does not reach Ai::DispatchAiRequestJob or ActionCable subscription execution — any FeatureFlagService.enabled? call from inside a job or a subscription resolver evaluates against real PostHog, not your override.
  • Inert in production. apply_smoke_flag_overrides returns immediately if Rails.env.production? — before the header is parsed, before the secret is checked, with no log line advertising the mechanism exists. Rails.env.production? is the primary gate; the secret is the second. See docs/development/smoke-test-env-gotchas.md for exactly which smoke lanes this does and doesn't affect — it is not simply "the production-labeled lane."
  • A client-only override cannot move a server-side gate. If grepping FeatureFlagService.enabled? for your flag key returns a hit, registerFlagOverrides alone will look like it worked (the browser's own UI gating flips) while the server-side decision — which is what actually matters for dispatch/data behavior — stays on real PostHog rollout. Use registerServerFlagOverrides for that half.

Reference


Last updated: 2026-09-19 — added "Overriding flags in smoke tests" documenting registerFlagOverrides (client-side, existing) vs. registerServerFlagOverrides (server-side, new) and the request-scoped/production-inert limits of the latter (OBJ-4002/OBJ-4005).

Prior update: 2026-09-18 — added discovery_ranking_v2 and seasonal_events_enabled as current Stage 1 examples (both 0% rollout), documented the --apply-against-default-checkout sharp edge from OBJ-3940, and attached the OBJ-3961 rollout precondition to seasonal_events_enabled (OBJ-3816).

Prior update: 2026-09-18 — fixed the Step-2-vs-Credential-gotchas contradiction: scoped "don't change flag state outside the script" to promotion to GA, added async_ai_delivery_enabled as the current Step 2 example, and stated in the Lifecycle block which steps produce a diff (OBJ-3969).

Prior update: 2026-09-13 — added celebration_sharing_enabled as a current Stage 1 example (OBJ-3684).

Prior update: 2026-08-07 — added editorial-discovery as the current Stage 1 example (OBJ-2191).

Loading…