Skip to content

Commit 4b5adb2

Browse files
improvement(feature-flags): single FEATURE_FLAGS registry — each entry defines name, description, and fallback in one place
1 parent 0853240 commit 4b5adb2

3 files changed

Lines changed: 43 additions & 20 deletions

File tree

.claude/commands/add-feature-flag.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,18 @@ Critically, **none of this is expressible in code** — gating (especially `admi
3131

3232
## Steps
3333

34-
1. **Register the flag.** Add an entry to `FEATURE_FLAG_FALLBACKS` in `apps/sim/lib/core/config/feature-flags.ts`, mapping the flag name (kebab-case) to the secret consulted when AppConfig isn't the source of truth. A truthy secret turns the flag on globally:
34+
1. **Define the flag.** Add one entry to the `FEATURE_FLAGS` registry in `apps/sim/lib/core/config/feature-flags.ts`. Each entry is the flag's whole definition — name (kebab-case key), `description`, and the `fallback` secret consulted when AppConfig isn't the source of truth (truthy on globally):
3535

3636
```ts
37-
const FEATURE_FLAG_FALLBACKS = {
38-
'<flag-name>': () => env.<FLAG_SECRET>,
37+
const FEATURE_FLAGS = {
38+
'<flag-name>': {
39+
description: '<what this gates>',
40+
fallback: () => env.<FLAG_SECRET>,
41+
},
3942
}
4043
```
4144

42-
Add `<FLAG_SECRET>` to `apps/sim/lib/core/config/env.ts` (and the deployment's secret store). Do **not** add any org/user/admin defaults here — that gating exists only in AppConfig.
45+
Add `<FLAG_SECRET>` to `apps/sim/lib/core/config/env.ts` (and the deployment's secret store). Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `<flag-name>` a valid `FeatureFlagName`.
4346

4447
2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it:
4548

@@ -59,7 +62,7 @@ Critically, **none of this is expressible in code** — gating (especially `admi
5962

6063
4. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts`: use `withAppConfig({ flags: { ... } })` to cover the gating rule (mock `isPlatformAdmin` for the `admins` clause), and toggle the fallback secret to cover the off-AppConfig path.
6164

62-
5. **Clean up after rollout.** When the feature ships to everyone, delete the flag from `FEATURE_FLAG_FALLBACKS`, the `<FLAG_SECRET>` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems.
65+
5. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `<FLAG_SECRET>` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems.
6366

6467
## Notes
6568

.cursor/commands/add-feature-flag.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,18 @@ Critically, **none of this is expressible in code** — gating (especially `admi
2626

2727
## Steps
2828

29-
1. **Register the flag.** Add an entry to `FEATURE_FLAG_FALLBACKS` in `apps/sim/lib/core/config/feature-flags.ts`, mapping the flag name (kebab-case) to the secret consulted when AppConfig isn't the source of truth. A truthy secret turns the flag on globally:
29+
1. **Define the flag.** Add one entry to the `FEATURE_FLAGS` registry in `apps/sim/lib/core/config/feature-flags.ts`. Each entry is the flag's whole definition — name (kebab-case key), `description`, and the `fallback` secret consulted when AppConfig isn't the source of truth (truthy on globally):
3030

3131
```ts
32-
const FEATURE_FLAG_FALLBACKS = {
33-
'<flag-name>': () => env.<FLAG_SECRET>,
32+
const FEATURE_FLAGS = {
33+
'<flag-name>': {
34+
description: '<what this gates>',
35+
fallback: () => env.<FLAG_SECRET>,
36+
},
3437
}
3538
```
3639

37-
Add `<FLAG_SECRET>` to `apps/sim/lib/core/config/env.ts` (and the deployment's secret store). Do **not** add any org/user/admin defaults here — that gating exists only in AppConfig.
40+
Add `<FLAG_SECRET>` to `apps/sim/lib/core/config/env.ts` (and the deployment's secret store). Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `<flag-name>` a valid `FeatureFlagName`.
3841

3942
2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it:
4043

@@ -54,7 +57,7 @@ Critically, **none of this is expressible in code** — gating (especially `admi
5457

5558
4. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts`: use `withAppConfig({ flags: { ... } })` to cover the gating rule (mock `isPlatformAdmin` for the `admins` clause), and toggle the fallback secret to cover the off-AppConfig path.
5659

57-
5. **Clean up after rollout.** When the feature ships to everyone, delete the flag from `FEATURE_FLAG_FALLBACKS`, the `<FLAG_SECRET>` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems.
60+
5. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `<FLAG_SECRET>` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems.
5861

5962
## Notes
6063

apps/sim/lib/core/config/feature-flags.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,25 +46,42 @@ export interface FeatureFlagContext {
4646
* admin access from a code literal. To add a flag, register its name and the secret
4747
* to fall back on.
4848
*/
49-
type FallbackSecret = () => string | boolean | number | undefined
49+
/**
50+
* The single definition of a feature flag. Everything about a flag lives in one
51+
* place: its name (the registry key), a human-readable `description`, and the
52+
* `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on
53+
* globally).
54+
*
55+
* Gating by org/user/admin is deliberately NOT part of a definition — it lives only
56+
* in the hosted AppConfig document, so no environment can grant access from a code
57+
* literal.
58+
*/
59+
interface FeatureFlagDefinition {
60+
description: string
61+
fallback: () => string | boolean | number | undefined
62+
}
5063

51-
const FEATURE_FLAG_FALLBACKS = {
52-
// 'new-canvas': () => env.NEW_CANVAS_ENABLED,
53-
} satisfies Record<string, FallbackSecret>
64+
/** The single registry of known flags. To add a flag, add one entry here. */
65+
const FEATURE_FLAGS = {
66+
// 'new-canvas': {
67+
// description: 'New canvas renderer',
68+
// fallback: () => env.NEW_CANVAS_ENABLED,
69+
// },
70+
} satisfies Record<string, FeatureFlagDefinition>
5471

5572
/**
56-
* The closed set of known feature flags. Derived from the fallback registry, so a
57-
* flag cannot exist — or be checked — without a mandatory fallback secret.
73+
* The closed set of known feature flags. Derived from the registry, so a flag
74+
* cannot exist — or be checked — without a definition (and its mandatory fallback).
5875
*/
59-
export type FeatureFlagName = keyof typeof FEATURE_FLAG_FALLBACKS
76+
export type FeatureFlagName = keyof typeof FEATURE_FLAGS
6077

6178
/** Build the fallback document from each flag's secret. Truthy secret ⇒ enabled. */
6279
function fallbackFlags(): FeatureFlagsConfig {
6380
const flags: Record<string, FeatureFlagRule> = {}
64-
for (const [name, readSecret] of Object.entries(FEATURE_FLAG_FALLBACKS) as Array<
65-
[string, FallbackSecret]
81+
for (const [name, def] of Object.entries(FEATURE_FLAGS) as Array<
82+
[string, FeatureFlagDefinition]
6683
>) {
67-
flags[name] = { enabled: isTruthy(readSecret()) }
84+
flags[name] = { enabled: isTruthy(def.fallback()) }
6885
}
6986
return { flags }
7087
}

0 commit comments

Comments
 (0)