---
name: forge-feature-flags
description: Feature-flag discipline. Flags as a deployment-decoupling tool, not a permanent toggle. Naming and ownership, default-off + kill-switch, targeting rules (% rollout / tenant cohort / user attribute), removal schedule baked in, dark-launches and gradual rollout. Contains paste-ready flag wrapper + a flag-cleanup audit. Use when introducing feature flags so they do not turn into permanent technical debt.
license: MIT
---

# forge-feature-flags

You are adding a feature flag. Default agent-written flag usage adds an `if (FEATURE_X_ENABLED)` branch, leaves it forever, and ships a parallel "if-not-X" branch that nobody tests. Six months later, the codebase is a maze of dead branches, the flag is permanently on for everyone, and removing it is a multi-week refactor. This skill exists to stop that.

The mental model: **a feature flag is a temporary deployment-decoupling tool.** Use it to ship code that is dormant until you turn it on. Decommission it shortly after launch. A flag that lives more than three months is technical debt with extra steps.

## Quick reference (the things you must never ship)

1. A flag without an owner and a removal date.
2. A flag default-on in production (defaults are off; you turn on deliberately).
3. A flag with no kill switch (cannot disable without redeploying).
4. `FEATURE_X_ENABLED` env var read at request time on every call (no caching, hot path slow).
5. A flag that controls multiple unrelated changes (each change needs its own flag).
6. A flag whose "off" branch nobody tests.
7. A flag still in the codebase 12 months after launch.
8. Hardcoded `flag = true` in tests instead of a typed flag provider.
9. A flag stored in code constants instead of a flag service (cannot toggle without deploy).
10. Negative flag names: `DISABLE_OLD_FLOW`. Always positive: `ENABLE_NEW_FLOW`.

## Hard rules

### Flag lifecycle

**1. Every flag has an owner.** A name in a TICKET. Without an owner, the flag rots.

**2. Every flag has a removal date.** "Remove by 2026-08-01." If you do not know when, you do not need a flag yet.

**3. Three flag types, each with its own lifecycle.**

| Type | Purpose | Lifetime | Example |
| --- | --- | --- | --- |
| **Release flag** | Decouple deploy from launch | Days to 4 weeks | `release_new_checkout` |
| **Experiment flag** | A/B test a variation | 2-8 weeks | `experiment_pricing_v2` |
| **Ops flag** (kill switch) | Disable a feature during incident | Indefinite | `enable_csv_export` (toggle off if it's killing the DB) |

Permission flags ("is this user an admin?") are NOT feature flags. They are authorization. Use roles/policies, not flags.

### Defaults and direction

**4. Defaults are off in production.** You turn on deliberately. A new flag means: "feature exists in code, not in production behavior."

**5. Names are positive: `ENABLE_X` not `DISABLE_X`.** Double negatives in conditions are confusing.

```ts
// BAD
if (!process.env.DISABLE_OLD_CHECKOUT) { ... old checkout ... }

// GOOD
if (flags.enabled("new_checkout")) {
  // new
} else {
  // old (default)
}
```

**6. The "old" branch stays the default until removal.** Removing the old branch happens AFTER the flag is fully rolled out AND removed - not before.

### Naming

**7. Flag names are kebab-case domain phrases.** `new_checkout`, `pricing_v2`, `enable_csv_export`. Not `featureXYZ`, not `flag1`.

**8. Prefix by type.**

```
release_<feature>          new feature, dark-launched
experiment_<feature>       A/B test
ops_<feature>              kill switch
```

### Targeting

**9. Start with the user/tenant/cohort key in mind.** Most flags are bucketed by user_id, tenant_id, or a hash. Decide once, use throughout.

**10. Percent-rollout for safe gradual launches.**

```ts
// 0% → 1% → 10% → 50% → 100%
const rolloutOrder = [0, 1, 10, 25, 50, 75, 100];

// each step, observe error rate + SLI before the next.
```

**11. Allowlist for early-access cohort.** Internal users first, then beta customers, then general.

**12. Server-side evaluation only. Never trust a client claim "I'm in the experiment."**

### Flag service

**13. Use a flag service (LaunchDarkly, Unleash, PostHog, Statsig, GrowthBook).** Not env vars. Toggling without a deploy is the whole point.

**14. If self-hosting, store flags in a hot-reloadable source.** Redis, a JSON file mounted as a config, a row in the DB read once per minute. Not a compiled constant.

**15. Cache flag values within a request.** Reading the flag service 50 times per request is silly. Snapshot at request start; reuse.

```ts
// reference: request-scoped flag snapshot
type FlagSnapshot = (name: string) => boolean;

export function getFlags(userId: string, tenantId: string): FlagSnapshot {
  // Evaluate all flags once for this request
  const evaluations = client.allFlags({ userId, tenantId });
  return (name: string) => evaluations.get(name) ?? false;
}

// in middleware
app.use("*", async (c, next) => {
  const userId   = c.get("userId") as string;
  const tenantId = c.get("tenantId") as string;
  c.set("flags", getFlags(userId, tenantId));
  await next();
});

// in handler
const flags = c.get("flags") as FlagSnapshot;
if (flags("release_new_checkout")) {
  return newCheckout(c);
}
return oldCheckout(c);
```

### Both branches are tested

**16. Both sides of every flag are covered by tests.** Either branch can be live in production.

```ts
describe("checkout", () => {
  it("old checkout: charges via legacy stripe path", async () => {
    const c = mockContext({ flags: { release_new_checkout: false } });
    // ...
  });
  it("new checkout: charges via the new stripe-charge-builder", async () => {
    const c = mockContext({ flags: { release_new_checkout: true } });
    // ...
  });
});
```

**17. Integration tests run in both flag states for high-stakes paths.** Pricing, payment, auth.

### Removal

**18. Schedule removal at flag creation.** Calendar invite for 30 days after launch.

**19. Removal is its own PR.** Delete the flag, delete the "off" branch, delete the flag-service entry. One PR, atomic, reviewable.

**20. Flag-cleanup audit monthly.** List all flags older than 30 days; for each: still needed? remove?

### Audit

**21. Audit log every flag change.** Who toggled, when, from what to what. Required for incident forensics.

**22. Track per-flag firing rate.** A flag that never fires (or always fires) is ready for removal.

### Anti-patterns at scale

- **Nested flags.** `if (flagA && flagB) { ... if (flagC) { ... }}` - exponential combinatorial branches. Refactor.
- **Flag for permissions.** Use roles/policies. Permissions are durable; flags are not.
- **Long-lived experiment flags.** An A/B test that lives 6 months is no longer an experiment.
- **Flag-gated migrations.** A flag that "enables" a DB migration is a recipe for half-migrated state. Migrations are deployed; flags toggle features over a stable schema.

## Common AI-output patterns to reject

| Pattern | Why bad | Fix |
| --- | --- | --- |
| `if (process.env.FEATURE_X)` everywhere | No service, no toggle without deploy | Flag service or hot-reloadable config |
| Negative flag name `DISABLE_OLD` | Double-negatives in code | Positive `ENABLE_NEW` |
| Default on in production | Skipped the gradual rollout | Default off, turn on deliberately |
| Flag never removed | Permanent debt | Removal date at creation |
| No owner | Rots | Owner name in flag metadata |
| Flag controls multiple unrelated changes | Cannot rollback granularly | One flag per change |
| "If admin" check via a flag | Permissions != flags | Roles/policies |
| Reading flag in a hot loop | Slow | Snapshot per request |
| No test of the "off" branch | Untested code in prod | Both branches tested |
| Flag value in code constants | Cannot toggle | Flag service / DB-backed |

## Worked example: a release flag, end to end

**Creation (PR 1):**

```ts
// src/lib/flags.ts - add the flag definition
export const FLAG_DEFINITIONS = {
  // ... existing flags ...
  release_new_checkout: {
    owner: "anna@example.com",
    description: "New Stripe-PaymentIntent-based checkout flow. Old uses Charges API.",
    created: "2026-05-22",
    remove_by: "2026-08-22",     // 3 months
    default: false,
  },
} as const;
```

**Use in handler (same PR or follow-up):**

```ts
app.post("/v1/checkout", async (c) => {
  const flags = c.get("flags") as FlagSnapshot;
  if (flags("release_new_checkout")) {
    return newCheckout(c);
  }
  return oldCheckout(c);
});
```

**Tests for both branches (same PR):**

```ts
describe("POST /v1/checkout", () => {
  it("old checkout: charges via legacy stripe path", async () => { /* ... */ });
  it("new checkout: charges via the new stripe-charge-builder", async () => { /* ... */ });
  it("rollout to ~50%: bucket key produces ~50/50 split", async () => { /* ... */ });
});
```

**Rollout (no PR, flag service):**

```
Day 0:  0% (allowlist of internal users only)
Day 3:  1%
Day 7:  10%
Day 14: 50%  -- observe error rate + p95 latency
Day 21: 100%
```

**Removal (PR 2, on or before 2026-08-22):**

```diff
- app.post("/v1/checkout", async (c) => {
-   const flags = c.get("flags") as FlagSnapshot;
-   if (flags("release_new_checkout")) {
-     return newCheckout(c);
-   }
-   return oldCheckout(c);
- });
+ app.post("/v1/checkout", async (c) => {
+   return newCheckout(c);
+ });
```

Plus delete `oldCheckout`, delete the `release_new_checkout` entry in `FLAG_DEFINITIONS`, delete in the flag service.

This is the complete lifecycle: create with metadata, use in code, test both branches, gradual rollout, scheduled removal.

## Workflow

When adding a feature flag:

1. **Confirm you need a flag.** If the change is small and safe, just ship it.
2. **Pick the flag type.** Release / experiment / ops.
3. **Pick the name. Positive. Domain phrase.**
4. **Add to flag service AND flag definition table.** Default off, owner, remove-by date.
5. **Implement both branches. Test both branches.**
6. **Ship the PR with the flag still default-off.**
7. **Roll out gradually.** Internal → 1% → 10% → 50% → 100%, observing SLIs.
8. **Schedule removal.** Calendar reminder + ticket.
9. **Remove on schedule.** Delete code, delete flag service entry, delete tests.

## Verification

Manual checklist:

- [ ] Every flag has an owner and a `remove_by` date.
- [ ] All flag names are positive (`enable_X`, `release_X`).
- [ ] Both branches of every release flag are covered by tests.
- [ ] Flags read from a hot-reloadable source, not env vars.
- [ ] Flag state is snapshotted per request, not read in hot loops.
- [ ] Monthly flag-cleanup audit on the calendar.

## When to skip this skill

- Small projects with one engineer; you do not need flag infrastructure.
- Pre-production prototypes.
- Permission systems (use roles, not flags).

## Related skills

- [`forge-api-design`](../forge-api-design/SKILL.md) - response shape stays stable across flag values.
- [`forge-tests`](../../testing/forge-tests/SKILL.md) - both branches tested.
- [`forge-naming`](../../dx/forge-naming/SKILL.md) - flag-naming discipline.
- [`forge-observability`](../../infra/forge-observability/SKILL.md) - per-flag firing rate as a metric dimension.
