loom-feature-flags

Feature flag patterns for controlled rollouts, A/B testing, kill switches, and runtime configuration. Use for feature toggles, gradual/percentage/canary rollouts, dark launches, user targeting, experiments, emergency kill switches, and model/infrastructure flag switching.

cosmix/claude-code-setup2 installsMITSynced Aug 22

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: loom-feature-flags
description: Feature flag patterns for controlled rollouts, A/B testing, kill switches, and runtime configuration. Use for feature toggles, gradual/percentage/canary rollouts, dark launches, user targeting, experiments, emergency kill switches, and model/infrastructure flag switching.
license: MIT
---

# Feature Flags

## Overview

Runtime control over feature availability without redeploying. The hard parts are not the `if` check — they are: **bucketing correctness** (stickiness, monotonicity, cross-service consistency), **fail-safe evaluation** (an outage of the flag service must not take down the app), **experiment validity** (exposure logging), and **lifecycle/technical-debt** (stale flags are the #1 real-world feature-flag problem). Optimize for those.

## Flag Taxonomy — Type Drives Lifetime and Ownership

The single most useful classification (from Pete Hodgson / Martin Fowler). Type determines expected lifetime, who owns it, and dynamism. Mixing types under one abstraction is a common mistake.

| Type                | Purpose                                   | Lifetime          | Changes at runtime? | Owner              |
| ------------------- | ----------------------------------------- | ----------------- | ------------------- | ------------------ |
| **Release toggle**  | Ship incomplete/unproven code dark, ramp  | Days–weeks (SHORT)| Per deploy/ramp     | Dev team           |
| **Experiment**      | A/B/multivariate measurement              | Length of test    | Sticky per user     | PM / data science  |
| **Ops / kill-switch**| Disable a subsystem under load/incident  | Long-lived        | On demand (fast)    | Ops / SRE          |
| **Permission**      | Entitlements: plan tier, beta cohort      | Very long / permanent | Per user/segment | Product / billing  |

Consequences:

- **Release toggles must be removed** once the feature is stable — they are debt with a deadline. Track `createdAt + owner + removal ticket`.
- **Ops toggles are permanent** and must be evaluated with *zero* external calls in the hot path (see Kill Switches).
- **Permission toggles** are effectively long-lived config; don't route them through the "delete after rollout" cleanup process.
- Don't overload one flag to do two jobs (a release toggle that also gates a paid tier). Split them; they have different lifetimes.

## Deterministic Bucketing (Correctness Core)

Every percentage rollout, canary, and experiment reduces to: map a stable unit (usually `userId`) to a number in `[0,100)` and compare against a threshold. Get this wrong and users **flicker** in/out of the feature, or increasing the rollout % **reshuffles** everyone.

```typescript
import { createHash } from "crypto";

// Stable, sticky, side-effect-free. Same (unit, flagKey) -> same bucket, forever.
export function bucket(unitId: string, flagKey: string): number {
  // Salt with the FLAG KEY (not a single global salt) so flags are INDEPENDENT:
  // a user unlucky at bucket 2 isn't automatically in every low-% rollout.
  const h = createHash("sha1").update(`${flagKey}:${unitId}`).digest();
  // 32 bits -> [0,1). Prefer this over `int % 100`, which has modulo bias.
  const n = h.readUInt32BE(0) / 0x1_0000_0000;
  return n * 100; // 0.0 .. 100.0
}

// Percentage rollout: enabled iff bucket below the threshold.
export function enabledFor(unitId: string, flag: { key: string; percentage: number }): boolean {
  return bucket(unitId, flag.key) < flag.percentage;
}
```

Rules (violating any of these is a bug, not a style choice):

- **Deterministic, never random per call.** `Math.random() < 0.1` re-rolls on every evaluation → the same user sees the feature appear and vanish across page loads/requests. Always hash a stable unit.
- **Monotonic on ramp-up.** Because the bucket is fixed per `(unit, flag)`, raising `percentage` from 5→10 only *adds* users whose bucket lands in `[5,10)`; nobody already in loses access. **Never change the salt/seed to bump the percentage** — that reshuffles all buckets and yanks the feature from current users (and invalidates any running experiment).
- **Consistent across services.** For a user to see the same variant in web, mobile, and backend, every service must use the **same hash algorithm, same salt convention (flag key), and same unit id.** Pin these; a "harmless" swap of MD5→SHA1 or `userId`→`email` silently re-buckets everyone. (Vendor SDKs like LaunchDarkly guarantee cross-SDK consistency for you — one reason to use them.)
- **Pick the right bucketing unit.** Logged-in → `userId`. Anonymous → a persisted cookie/device id (not the session, or it flickers). Org-level features → `orgId` (so a whole team flips together). Document the unit per flag.
- **MD5/SHA1 are fine here** — this is bucketing, not security, so collision resistance is irrelevant; you only need uniform distribution. Don't reach for bcrypt.

Multivariate uses the same bucket against contiguous ranges:

```typescript
// variants: [{name,value,weight}], weights sum to 100
export function pickVariant<T>(unitId: string, flagKey: string, variants: Variant<T>[]): Variant<T> {
  const b = bucket(unitId, flagKey);
  let acc = 0;
  for (const v of variants) {
    acc += v.weight;
    if (b < acc) return v;
  }
  return variants[variants.length - 1]; // guard float rounding at the top edge
}
```

⚠ Changing a variant's weight re-slices the `[0,100)` line and moves users between variants. To grow one variant without disturbing others, **append** its new share at the top of the range rather than re-slicing from the start.

## Percentage Rollouts and Canary

Ramp `1% → 5% → 25% → 50% → 100%`, watching error rate / latency / business metrics at each step; pause or roll to 0 on regression. Automate the increments but keep a manual gate for the first steps.

```typescript
type Rollout = { flagKey: string; target: number; step: number; intervalMin: number; paused?: boolean };

async function advance(store: FlagStore, r: Rollout): Promise<void> {
  const flag = await store.get(r.flagKey);
  if (!flag || r.paused) return;
  flag.percentage = Math.min((flag.percentage ?? 0) + r.step, r.target); // additive => monotonic
  await store.set(flag);                                                 // same salt: no reshuffle
  if (flag.percentage < r.target) setTimeout(() => advance(store, r), r.intervalMin * 60_000);
}
```

- **Rollback = set percentage 0 (or flip `enabled=false`)**, not a code deploy. That is the entire point of the flag; a rollout without a fast rollback path is theater.
- **Guardrail metrics, not vanity metrics.** Ramp against error rate and latency, plus one business KPI; a 5% cohort with a broken checkout is easy to miss on aggregate dashboards.
- Ring/canary rollout = target internal users → beta cohort → % of general population, expressed as ordered targeting rules over `percentage`.

## Targeting and Evaluation Context

Evaluation is a pure function of `(flag rules, context)`. Rules are ordered; first match wins; fall through to the flag default.

```typescript
type Op = "in" | "notIn" | "equals" | "contains" | "startsWith" | "matches";
type Rule = { attribute: string; operator: Op; values: string[]; value: boolean };

function evaluateTargeting(rules: Rule[], ctx: Record<string, unknown>, def: boolean): boolean {
  for (const r of rules) {
    const attr = String(ctx[r.attribute] ?? "");
    const hit =
      r.operator === "in"         ? r.values.includes(attr)
    : r.operator === "notIn"      ? !r.values.includes(attr)
    : r.operator === "equals"     ? attr === r.values[0]
    : r.operator === "contains"   ? r.values.some((v) => attr.includes(v))
    : r.operator === "startsWith" ? r.values.some((v) => attr.startsWith(v))
    : r.operator === "matches"    ? r.values.some((v) => new RegExp(v).test(attr))
    :                               false;
    if (hit) return r.value;
  }
  return def;
}
```

- **Evaluation must be side-effect-free and cheap** — no DB/network call per flag check. It runs on hot paths, sometimes many flags per request. Load the ruleset once (SDK/cache) and evaluate in memory.
- **`matches` (regex) is a footgun:** rules are often admin-editable → treat as untrusted input. Cap pattern length and beware catastrophic backtracking (ReDoS); prefer `in`/`startsWith` where possible.
- Keep the context minimal and consistent across services (same attribute names). Don't ship secrets/PII into a hosted flag service inside evaluation context.

## Kill Switches (Fail-Safe, No External Calls)

An ops kill switch exists precisely for when things are on fire — including when the flag service itself is degraded. So it must be evaluable **locally**.

```typescript
// Hot path: NO await on a remote store. Read a locally-cached value with a safe default.
function paymentsKilled(cache: FlagCache): boolean {
  // On cache miss / stale / service down -> return the KNOWN-SAFE default,
  // never throw and never block on the network.
  return cache.getBool("kill.payments", /* default */ false);
}

async function processPayment(p: Payment): Promise<PaymentResult> {
  if (paymentsKilled(cache)) throw new ServiceUnavailableError("Payments temporarily disabled");
  return processor.process(p);
}
```

- **Fail to a safe, known state.** Decide per switch what "safe" means and hardcode it as the default: for most *new/risky* features safe = off (fall back to the proven path); for a load-shed switch safe = "not shedding" unless you'd rather shed. The default is a deliberate design decision, documented in code.
- **Never make the availability of a feature depend on the availability of the flag service.** If evaluating the switch requires a network round-trip and that call fails/hangs, you've coupled your app's uptime to the flag provider — the opposite of resilience. Cache aggressively (in-memory + local file/Redis), refresh in the background, tolerate staleness.
- **Propagate activation fast** (pub/sub / streaming), but the hot-path *read* is always local.
- Alert on-call and write an audit record on activate/deactivate; optionally support timed auto-recovery.

## Experiments / A/B Testing

Bucketing is the easy half; **valid measurement** is the hard half.

- **Randomize by hashing a per-experiment salt** so overlapping experiments are statistically independent (orthogonal). Reusing one salt across experiments correlates cohorts and confounds results.
- **Log exposure at evaluation time, only when the flag actually affects the user, exactly once per unit.** This is the crux of A/B validity: your analysis must compare *users who were actually exposed to each variant*, not "everyone we pre-assigned." Pre-assigning all users and counting them as exposed dilutes effects and biases results. Dedup exposures per `(user, experiment)` within the analysis window.
- Log **conversions** keyed to the *same* assigned variant. Compute significance with a two-proportion z-test (or your stats stack); don't eyeball rates — a 3% vs 3.2% gap on small N is noise.

```typescript
function assignAndExpose(exp: Experiment, userId: string, log: ExposureLog): string {
  const v = pickVariant(userId, exp.salt, exp.variants).name; // per-exp salt => orthogonal
  log.once(exp.id, userId, v); // exposure recorded HERE, at the code path that changes behavior
  return v;
}
```

- Don't stop an experiment the moment it crosses significance (peeking inflates false positives) — fix sample size / duration up front, or use a sequential-testing method.
- Guard against sample-ratio mismatch (observed split ≠ configured weights) — it signals a bucketing or logging bug and invalidates the test.

## Flag Lifecycle and Technical Debt

**Stale flags are the #1 feature-flag problem.** Every flag is a live branch in your code; N boolean flags imply up to 2^N reachable states. Untended, they rot into unremovable spaghetti.

- **Attach metadata at creation:** `createdAt`, `owner`, `type`, and — for short-lived types — a **removal ticket** and target date. A release toggle with no removal plan is a bug at birth.
- **Set expiry by type:** release/experiment toggles are short-lived (days–weeks); ops/permission toggles are long-lived. Alert when a *short-lived* flag outlives its expected life or its removal date passes.
- **When done, remove the flag AND the dead branch.** "Make permanent" = delete the flag and the losing code path, keep the winner. Leaving `if (true)` scaffolding is not cleanup.
- **Automate detection:** scan the codebase for flag references, flag stale ones, and file cleanup tickets. Code with no references to a still-"active" flag (or vice-versa) indicates drift.

```typescript
function isStale(f: FlagWithLifecycle, now = new Date()): boolean {
  const ageDays = (now.getTime() - f.createdAt.getTime()) / 86_400_000;
  if (f.plannedRemovalDate && f.plannedRemovalDate < now) return true;       // past removal date
  if (f.type === "release" && ageDays > 60) return true;                     // release toggle overstayed
  if (f.type === "experiment" && f.status === "completed") return true;      // decided, not cleaned up
  if (f.percentage === 100 || f.percentage === 0) return ageDays > 30;       // settled at a terminal %
  return false;
}
```

- **Avoid dependent/nested flags.** Flag B whose meaning depends on flag A creates implicit ordering, hidden coupling, and a combinatorial test space. Keep flags independent; if two must interact, encode it as one multivariate flag, not two coupled booleans.

## Delivery Modes: Local-Eval vs Streaming vs Polling

How the SDK gets flag state governs latency, staleness, load, and privacy. Know the trade-offs:

| Mode                  | Update latency         | Per-eval cost      | Staleness window     | Notes                                                        |
| --------------------- | ---------------------- | ------------------ | -------------------- | ----------------------------------------------------------- |
| **Streaming (SSE)**   | Seconds                | In-memory (0 net)  | ~Real-time           | Best for kill switches; needs a persistent connection       |
| **Polling**           | = poll interval        | In-memory (0 net)  | Up to poll interval  | Simplest; tune interval vs load; can be slow for incidents  |
| **Local evaluation**  | = ruleset refresh      | In-memory (0 net)  | Ruleset age          | SDK holds full ruleset; **no PII leaves your infra**        |
| **Remote per-eval**   | Real-time              | 1 network call/flag| None                 | ⚠ Anti-pattern for hot paths — couples uptime + adds latency|

- **Server SDKs → prefer local evaluation** (or streaming): the SDK downloads targeting rules and evaluates in-process, so no per-flag network call and no user attributes sent to the vendor. **Client/mobile SDKs → the server evaluates** and returns the user's flag set (never ship the full ruleset / other users' targeting to a browser).
- At scale, run a relay/daemon (LaunchDarkly Relay, Unleash Edge/Proxy) so thousands of instances don't each hit the vendor.
- **Every SDK read is against cache** — that's why the default value you pass to `variation(...)` matters: it's what you get during init, on error, or when the flag is missing. Make it the safe fallback.

## OpenFeature and Vendor SDKs

**OpenFeature** (CNCF) is the vendor-neutral standard: one evaluation API + a swappable **Provider** (LaunchDarkly, Flagsmith, Unleash, Split, or your own) + **hooks** for logging/telemetry. Prefer coding against OpenFeature so you can change vendors without touching call sites.

```typescript
import { OpenFeature } from "@openfeature/server-sdk";

OpenFeature.setProvider(new YourProvider());            // swap vendor here only
const client = OpenFeature.getClient();

// Always pass a SAFE default (used on init/error/missing flag) + evaluation context.
const showV2 = await client.getBooleanValue("checkout-v2", false, { targetingKey: userId, plan });
```

LaunchDarkly server SDK specifics worth remembering:

```typescript
import * as LD from "launchdarkly-node-server-sdk";
const ld = LD.init(process.env.LAUNCHDARKLY_SDK_KEY!);
await ld.waitForInitialization();                        // else first evals return defaults
const on = await ld.variation("flag-key", { key: userId }, /* default */ false);
const detail = await ld.variationDetail("flag-key", { key: userId }, false); // .value/.reason for debugging
```

- `targetingKey`/user `key` is the **bucketing unit** — pass a stable id, not a per-request value.
- `variationDetail().reason` tells you *why* a value was served (rule match, fallthrough, prerequisite failed) — essential for debugging "why is this user not seeing the feature."

## ML Model and Infrastructure Flags

Same flag machinery, different payload: route to a **model variant** or an **infra endpoint** instead of on/off. Two extra requirements:

- **Performance/health-based routing with fallback.** Beyond static percentage, route by live latency/error-rate and **always define a fallback** variant when nothing meets thresholds. Health checks run out-of-band; the request path reads the cached healthy set (same fail-safe rule as kill switches).
- **Log an inference/exposure record per call** (latency, success, tokens/cost) to feed routing decisions and cost tracking.

```typescript
function pickModel(flag: ModelFlag, userId: string): ModelVariant {
  const eligible = flag.variants.filter((v) => v.healthy &&
    v.latencyMs < flag.thresholds.latencyMs && v.errorRate < flag.thresholds.errorRate);
  if (eligible.length === 0) return flag.variants.find((v) => v.name === flag.fallback)!; // fail-safe
  return flag.routing === "performance"
    ? eligible.sort((a, b) => a.latencyMs - b.latencyMs)[0]
    : pickVariant(userId, flag.key, eligible);           // sticky percentage routing
}
```

Infra flags (DB/cache/CDN failover) follow the same shape: pick the highest-priority **healthy** variant, fall back deliberately when none are healthy, and reuse existing connections when the selected config is unchanged (don't reconnect every call).

## Gotchas

- ⚠ **`Math.random()` bucketing** → per-call flicker. Hash a stable unit instead.
- ⚠ **One global salt for all flags** → correlated cohorts; a user is in *every* low-% rollout. Salt with the flag key.
- ⚠ **Re-salting to bump a rollout %** → reshuffles all users, evicting current ones and breaking experiments. Only ever *raise the threshold*.
- ⚠ **`int % 100` from a hash** → modulo bias. Scale bytes to `[0,1)` instead.
- ⚠ **Remote call per flag evaluation** → adds latency and couples your uptime to the vendor. Evaluate against cache/local ruleset.
- ⚠ **Kill switch that needs the network to say "kill"** → useless during the outage it's meant to handle. Local read + safe default.
- ⚠ **Pre-assigning/exposure-logging all users** → biased A/B results. Log exposure only at the code path that actually affects the user, once per unit.
- ⚠ **Bucketing by session/request id** for anonymous users → flicker across requests. Use a persisted device/cookie id.
- ⚠ **Dependent/nested flags** → combinatorial state explosion and hidden coupling. Keep flags independent; collapse interactions into one multivariate flag.
- ⚠ **No default on SDK read** → undefined behavior on init/error/missing flag. Always pass the safe fallback.
- ⚠ **Flags that never get removed** → permanent branching debt. Attach owner + removal ticket at creation; automate stale detection.

## Testing With Flags

Flags multiply the reachable state space (2^N for N booleans) — test deliberately, not exhaustively:

- **Test both states of the flag under change** (on/off, and each variant that alters behavior). A flag you can't turn off safely isn't a safe rollout.
- **Pin all other flags to defaults** in most tests via a fake/in-memory provider; don't let the matrix explode. OpenFeature's in-memory provider or a test double makes this trivial and hermetic (no network).
- **Test the fallback path**: flag service unreachable / returns default → app still behaves safely.
- Test bucketing **stickiness** (same unit → same result across calls) and **monotonicity** (raising % never drops an already-enabled unit).
- Keep tests deterministic: inject the bucketing seed/unit; never call the real random source or real vendor in unit tests.

## Verification Checklists

Evaluation logic — verify before done:

- [ ] Bucketing hashes a **stable unit** + flag-key salt; no `Math.random` in the path
- [ ] Increasing rollout % is **additive/monotonic**; salt/seed is never changed to ramp
- [ ] Same hash algo + salt convention + unit across all services that must agree
- [ ] Evaluation is side-effect-free and reads from cache/local ruleset (no per-flag network call)
- [ ] Every SDK/lookup call passes a **safe default** for init/error/missing-flag
- [ ] Regex/targeting input is bounded (no ReDoS); no secrets/PII in evaluation context

Kill switch / ops toggle:

- [ ] Hot-path read is **local** (cached), never a blocking remote call
- [ ] Default on cache-miss/service-down is the documented **known-safe** state
- [ ] Activation broadcasts fast (streaming/pub-sub) and writes an audit record + on-call alert

Experiment:

- [ ] Per-experiment salt (orthogonal cohorts)
- [ ] Exposure logged **at evaluation, only when it affects the user, once per unit**
- [ ] Conversions keyed to the same assigned variant; significance computed, not eyeballed
- [ ] Sample-ratio checked; fixed sample size/duration (no peeking)

Lifecycle / debt:

- [ ] Flag created with `type`, `owner`, `createdAt`, and (short-lived types) a **removal ticket**
- [ ] Stale-flag detection automated; short-lived flags alert past expiry
- [ ] "Done" means flag **and** dead branch removed (no `if (true)` scaffolding)
- [ ] No dependent/nested flags introducing hidden coupling

Change management:

- [ ] Every flag change is audit-logged (who / when / old→new / why)
- [ ] Rollback = flip the flag (percentage→0 / enabled→false), no deploy required

More Deployment & CI/CD skills

azure-enterprise-infra-planner

microsoft/azure-skills

Architect and provision enterprise Azure infrastructure from workload descriptions. For cloud architects and platform engineers planning networking, identity, security, compliance, and multi-resource topologies with WAF alignment. Generates Bicep or Terraform directly (no azd). WHEN: 'plan Azure infrastructure', 'architect Azure landing zone', 'design hub-spoke network', 'plan multi-region DR topology', 'set up VNets firewalls and private endpoints', 'subscription-scope Bicep deployment', 'Azure Backup for VM workloads'. PREFER azure-prepare FOR app-centric workflows.

387.5k

azure-kubernetes-app-deploy

microsoft/azure-skills

Use when deploying an existing web application or API to an already-running Azure Kubernetes Service cluster. Detects the framework, generates a Dockerfile and Kubernetes manifests, validates against AKS Deployment Safeguards, and deploys with verification. WHEN: deploy app to AKS, deploy to existing AKS cluster, containerize app for Kubernetes, generate K8s manifests for Azure, set up CI/CD for AKS, my AKS deployment is failing safeguard checks, I have a Django/Express/Spring Boot app to run on AKS. DO NOT USE FOR: creating or provisioning an AKS cluster (use azure-kubernetes), assessing migration to AKS Automatic (use azure-kubernetes-automatic-readiness), or deploying to non-AKS targets like Web Apps, Container Apps, or Functions.

380.4k

finetuning

microsoft/azure-skills

Fine-tune models on Microsoft Foundry using SFT (supervised), DPO (preference), or RFT (reinforcement with graders). Covers dataset preparation, training job submission, deployment, and evaluation. USE FOR: fine-tune, SFT, DPO, RFT, training data, grader, distillation, fine-tuned model, training job, large file upload, calibrate grader, deploy fine-tuned model, evaluate fine-tuned model. DO NOT USE FOR: general model deployment without fine-tuning (use deploy-model), agent creation (use agents), prompt optimization without training (use prompt-optimizer).

323.2k

← All Deployment & CI/CD skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY