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:
- Percentage rollout — ramp a risky backend change 5% → 25% → 100% keyed on a
stable bucket.cfg.get()is all-or-nothing. - Per-person / per-property targeting — beta cohort by email, variant by person
property. - 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. - 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/billingexports
./subscription-checkout,./pro-credits,./entitlement,./pro-price-display
alongside./promoand./webhook) is a real plan/credit concept now, but it lives
entirely in thesubscriptionsDB table andentitlement.ts’sisActiveSubscription,
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 (aspricing-analysis.mdonce 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-nodein@brioso/obs-js,posthog-js+@posthog/reactinapps/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.tsreads
PostHog’s own cookieph_<VITE_POSTHOG_PROJECT_TOKEN>_posthogserver-side and pulls
distinct_idout 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
inapps/web, notobs-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()returnsnull→ 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: 2000plus 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, plusagencies-hero-copyalready live.
Flag keys are a typed union inobs-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-flagsskill 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 byKIT_PRICE_CENTSinkits/src/kit-checkout.tsand
SONIC_KIT_PRICEinsonicBranding.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-agenciesPostHog 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 inapps/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 detectionaudio-loudness— EBU R128 loudness normalizationaudio-seamless-loop— zero-crossing seamless loopingaudio-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.