Feature flags

Feature flags

Verdict

Flags are adopted, but narrower than a generic “adopt feature flags” answer, because
the repo already solves most of what flags usually buy.

Need Already solved by Where
Env differences Infisical + cfg.get() typed schema packages/config/src/config.ts:10-30
Change a setting without redeploy initConfig() + onConfigReload + SIGHUP reload packages/config/src/bootstrap.ts:69, config.ts:123-152
Operational kill switch RunPod kill switch + spend caps packages/dispatch/src/runpodKillSwitch.ts:20-23, sweep.ts:98-110
Feature on/off presence-based toggles (feature is on iff its secret exists) billing/src/webhook.ts:101-108, notify/src/config-bound.ts:24-29
Dev-only scaffolding gate isFakeFlow() packages/verticals/kits/src/fakeFlow.ts:21-33
Use-case divergence routes + separate packages/verticals/{kits,creator} routes/_marketing/agencies/, routes/studio/creators.tsx

The “flags let you flip things without a redeploy” argument is largely already true
here via Infisical + SIGHUP. cfg.get() genuinely cannot do three things, and those
are the only justification for adding flags:

  1. Percentage rollout — ramp a risky backend change 5% → 25% → 100% keyed on a
    stable bucket. cfg.get() is all-or-nothing.
  2. Per-person / per-property targeting — beta cohort by email, variant by person
    property.
  3. Experiment/analytics join — flag exposure lands in the same PostHog project as
    the conversion events already captured, so a variant can be measured. A config
    value cannot.
  4. Staged feature exposure — a built-but-unproven capability opened to a named
    cohort (design partners, a persona, a percentage) before general release. This is
    genuinely per-person targeting, already justification #2; naming it separately makes
    the feature-launch case explicit so it isn’t read as forbidden by the “No flags for
    use-case selection” non-goal below.

Non-goals

Writing these down is half the value; unbounded flags are debt.

  • No entitlements/plans via flags. The Pro subscription (packages/billing exports
    ./subscription-checkout, ./pro-credits, ./entitlement, ./pro-price-display
    alongside ./promo and ./webhook) is a real plan/credit concept now, but it lives
    entirely in the subscriptions DB table and entitlement.ts’s isActiveSubscription,
    never behind a flag — the policy this non-goal states held even as the entitlement
    itself shipped. This is the distinction most likely to get lost once staged exposure is
    in scope: stems-behind-a-paid-tier (as pricing-analysis.md once recommended) is an
    entitlement, not exposure — a flag
    controls whether the code path exists for a person, and must never be the billing
    boundary.
  • No flags for use-case selection. Routes already do it. A flag for whether a
    capability is available to this person
    is in scope (justification #4 above); a flag
    for which product they are using is not.
  • No flags for env or secrets. cfg.get() owns that.
  • No OpenFeature abstraction layer, no Flipt. PostHog is already in the stack
    (posthog-node in @brioso/obs-js, posthog-js + @posthog/react in apps/web).
    Zero new infra, zero new deps, no catalog change.

Decision rule

Reach for cfg.get() first. Reach for a flag only when the need is percentage
rollout, per-person targeting, or an experiment that must join PostHog analytics. If
none of those apply, it’s config, not a flag.

Architecture

Flags are evaluated at the edge and the resolved value is injected inward — same
seam as the existing onKitPaid port (packages/billing/src/webhook.ts:24-28). A
domain package receives a resolved value (e.g. backend: "sa3" | "musicgen"), never a
flag key. This preserves the observability invariant and the per-package import bans
in .oxlintrc.json without adding a single exemption.

Infisical ─► packages/config (POSTHOG_PERSONAL_API_KEY)
                │  cfg.get
                ▼
packages/obs-js/src/posthog.ts   ← only place posthog-node is imported
   PostHog client + personalApiKey + featureFlagsPollingInterval: 300_000 (local eval)
                │
                ▼
packages/obs-js/src/index.ts     ← facade adds: flag(), flags(), FlagSubject
                │
     ┌──────────┴───────────┐
     ▼                      ▼
apps/web route handlers /   apps/worker, dispatch sweep
server fns (edge)           (bucket on job.slug)
     │
     └─► resolved value passed as a plain param into verticals/* and ai-workflow

apps/web browser: posthog-js already evaluates flags after init.
   useFeatureFlagEnabled / useFeatureFlagVariantKey from @posthog/react.
   Same distinct id as the server ⇒ server rollout and client experiment agree.

Identity

Percentage rollout is only meaningful with a stable bucket key, and server and client
must agree or the experiment is noise.

  • Logged in: user.id.
  • Anonymous browser traffic: apps/web/src/server/obs/flagSubject.ts reads
    PostHog’s own cookie ph_<VITE_POSTHOG_PROJECT_TOKEN>_posthog server-side and pulls
    distinct_id out of it — the same id posthog-js is already using client-side, so a
    server-evaluated flag and a client-evaluated flag land in the same bucket. This lives
    in apps/web, not obs-js, because it is HTTP/cookie-bound.
  • No browser at all (worker, dispatch sweep): bucket on job.slug, already the obs
    distinct id (packages/obs-js/src/context.ts).
  • On login, call posthog.identify(user.id) client-side so pre/post-auth flag values
    do not flicker.

FlagSubject (packages/obs-js/src/context.ts) is a distinct type from ObsContext,
deliberately: ObsContext’s distinct-id helper falls back to ctx.component, which
would put every anonymous request in one bucket and silently break every percentage
rollout.

Failure behaviour

Flags must never break a request. flag() returns the caller’s default on any
failure:

  • No POSTHOG_API_KEY → getClient() returns null → default. Local dev never
    touches the network.
  • No POSTHOG_PERSONAL_API_KEY → client still constructed, local eval off, flags fall
    back to a remote HTTP call per evaluation. Degraded, not broken.
  • Network/timeout → featureFlagsRequestTimeoutMs: 2000 plus try/catch, resolves to
    the default.

Local-evaluation caveats when authoring a flag: stick to person-property and
percentage-rollout operators. Experience continuity, static cohorts, and is_not_set
force a remote round trip per evaluation.

Lifecycle

  • Naming: <area>-<decision>, kebab-case.
  • Every flag’s PostHog description carries its BRI-* issue and a target removal
    date. A flag past its date is a cleanup ticket.
  • Ceiling of 10 live flags. Raised from 5 because the Workstream 4 audio-tooling
    survivors are 5 candidate flags on their own, plus agencies-hero-copy already live.
    Flag keys are a typed union in obs-js (packages/obs-js/src/index.ts), promoted at
    the same time the ceiling was raised rather than deferred until the old 5-key limit
    was hit.
  • The posthog:cleaning-up-stale-feature-flags skill runs the sweep.

Current flags

  • agencies-hero-copy (client, multivariate) — headline/CTA copy variant on
    _marketing/agencies/index.tsx. Copy only; not a price experiment. The kit’s
    live price is set by KIT_PRICE_CENTS in kits/src/kit-checkout.ts and
    SONIC_KIT_PRICE in sonicBranding.ts — a copy flag driving only the headline
    would advertise a different amount than it charges. A real price test needs a
    flag payload driving both constants, and is its own ticket.
  • agencies-site (client + server, boolean) — gates the whole agencies site (routes,
    nav/footer links, homepage CTAs, sitemap/robots exposure) behind membership of the
    pilot-agencies PostHog cohort during a private pilot. BRI-203. Target
    removal/graduation: 2026-09-15.
  • studio-attach-upload (client, boolean) — hides the composer’s “+” attach-reference
    button (apps/web/src/components/studio/CreatorPromptInput.tsx) while the upload flow
    behind it is still a placeholder dialog with no working upload logic. Default off.
    BRI-243. Graduates (flag removed, button always shown) when BRI-243 ships; no due date
    set on that ticket yet, so there’s no committed removal date — revisit at the next
    stale-flag sweep if BRI-243 is still unscheduled.
  • audio-stems (server, boolean) — staged exposure for Demucs v4 stem separation
    (vocals/drums/bass/other, FLAC-only) on Pro-plan tracks. Pro membership is the
    entitlement (packages/jobs/src/completion.ts); this flag only controls rollout —
    us-only → 25% of Pro → 100% → remove. Dispatched from the single completion-time
    trigger in apps/web/src/server/jobs/handlers.ts (dispatchPendingStems). BRI-157.
    Target removal: once at 100% of Pro for a full billing cycle with no regressions —
    revisit at the next stale-flag sweep if still open past 2026-11-01.

dispatch-sa3-rollout was considered but deliberately not shipped: the SA3
torch.compile rollout (.agents/plans/2026-07-27-sa3-torch-compile-rollout.md) is a
per-endpoint SA3_TORCH_COMPILE env var flipped in the RunPod dashboard, not a
code-level branch — there is nothing for a flag to gate. Do not add one until an
actual code-level backend selection exists to resolve into.

creator-studio-cta (optional, /studio/creators) was also not shipped. Its only
stated purpose was to exercise the anonymous-identity path end to end on real
unauthenticated traffic, which agencies-hero-copy already does — a second call site
adds no new coverage of the mechanism. Add it if /studio/creators itself ever needs
a real copy/CTA experiment; don’t add it just to have a second flag.

Proposed — audio tooling staged exposure (BRI-122)

Names reserved ahead of implementation; no call site exists yet, so there is nothing to
poll for a stale-flag sweep yet. Register the BRI-* issue and a target removal date
(justification #4, staged exposure) when each capability actually ships. All default
off.

  • audio-analysis — Librosa/Essentia BPM + key detection
  • audio-loudness — EBU R128 loudness normalization
  • audio-seamless-loop — zero-crossing seamless looping
  • audio-inpaint-ui — Wavesurfer.js mask-based inpainting UI

Blocker to note, not to fix here. The server-side flag path was built but had zero
production call sites — flag(), flags() (packages/obs-js/src/index.ts:75-95) and
flagSubjectFromRequest() (apps/web/src/server/obs/flagSubject.ts:31-42) were
referenced only by tests until agencies-site (BRI-203) became the first production
caller. posthog.identify(user.id) — required by the Identity section above so a
user’s flags don’t flicker across login — landed in apps/web/src/lib/analytics-identify.ts
(BRI-197, b0ab993), called from _authed.tsx, login.tsx, and components/music/OtpStep.tsx.
Both costs are paid; this note stays only as history for the next of these five flags to ship.