# Implementation Plan: OpenFeature Adoption for `@terreno/feature-flags` ## Goal Migrate `@terreno/feature-flags` (backend) and `@terreno/rtk`'s `useFeatureFlags` (frontend) to be built on top of the OpenFeature SDKs (`@openfeature/server-sdk`, `@openfeature/react-sdk`) while preserving: - The Mongoose-backed `FeatureFlag` model as source of truth (no data migration) - The current admin UI via `@terreno/admin-backend` - Backwards-compatible `useFeatureFlags()` API for downstream consumers - The single-bulk-fetch network shape (no per-flag round trips) And add: - A swappable, OpenFeature-standard provider abstraction on the backend (`MongoFeatureFlagProvider`) - Standard OpenFeature React hooks (`useBooleanFlagValue`, `useStringFlagValue`, ``, etc.) available to consumers - Live updates via Mongoose change streams + Socket.io + OpenFeature `PROVIDER_CONFIGURATION_CHANGED` events - Type-safe flag keys via OpenFeature's module augmentation pattern ## Non-goals - Replacing Mongo storage with flagd or a third-party flag service - Dropping the existing admin panel UX - Adding analytics or experiment-results tracking (out of scope; the OF tracking hook is available to consumers but not wired up here) - Allowing anonymous evaluation (stays `IsAuthenticated`) --- ## Models ### `FeatureFlag` (extended, no migration) **File**: `feature-flags/src/featureFlagModel.ts` One new field on the existing schema: ```typescript defaultVariant: { type: String, description: "OpenFeature defaultVariant key. Returned when the flag is disabled or " + "errors during evaluation. For boolean flags, must be 'on' or 'off'. " + "For variant flags, must be one of the keys in `variants`. " + "Auto-populated by pre-save hook for legacy docs (boolean → 'off', variant → first variant key).", }, ``` **No Mongoose getter** — we use a `pre("save")` hook that sets `defaultVariant` when it is `undefined`, plus a normalization step in the `/flagConfiguration` build path that supplies the default at read time without mutating the doc. This avoids getter re-entrancy on `this.type` / `this.variants`. Validation (in the existing `pre("save")` hook): - If `defaultVariant` is set: for boolean flags it must be `"on"` or `"off"`; for variant flags it must be one of `variants[].key`. Validation is **only enforced when the field is set** — legacy docs without `defaultVariant` are not rejected; the hook populates the default before the validate step. - Tested explicitly in `featureFlagModel.test.ts` with both legacy-doc-update and new-doc-create paths. Also update `featureFlagAdminConfig.listFields` to include `"defaultVariant"` so admins see and can edit it. ### `FlagConfiguration` (wire shape, not a Mongoose model) The response shape returned by the new `/feature-flags/flagConfiguration` endpoint. One entry per evaluated flag: ```typescript interface FlagDefinition { variants: Record; // {on: true, off: false} or {compact: "compact", detailed: "detailed"} disabled: boolean; // per-user resolved disabled state defaultVariant: string; } interface FlagConfigurationResponse { data: Record; } ``` This exactly matches OpenFeature's `TypedInMemoryProvider` input shape, so the frontend pipes it straight in. --- ## APIs ### Backend: `MongoFeatureFlagProvider` **File**: `feature-flags/src/openFeatureProvider.ts` (new) A custom OpenFeature server-side provider that delegates to the existing `evaluateFlag()` logic. ```typescript import { type EvaluationContext, type JsonValue, OpenFeatureEventEmitter, type Provider, type ResolutionDetails, } from "@openfeature/server-sdk"; import type {Model} from "mongoose"; import {evaluateFlag} from "./evaluate"; import type {FeatureFlagDocument, SegmentFunction} from "./types"; interface MongoFeatureFlagProviderOptions { flagModel: Model; segments?: Record; } export class MongoFeatureFlagProvider implements Provider { readonly metadata = {name: "MongoFeatureFlagProvider"} as const; readonly runsOn = "server" as const; readonly events = new OpenFeatureEventEmitter(); private flagModel: Model; private segments: Record; constructor(options: MongoFeatureFlagProviderOptions) { this.flagModel = options.flagModel; this.segments = options.segments ?? {}; } async resolveBooleanEvaluation( flagKey: string, defaultValue: boolean, context: EvaluationContext ): Promise> { return this.resolve(flagKey, defaultValue, context, "boolean"); } async resolveStringEvaluation( flagKey: string, defaultValue: string, context: EvaluationContext ): Promise> { return this.resolve(flagKey, defaultValue, context, "variant"); } async resolveNumberEvaluation( flagKey: string, defaultValue: number ): Promise> { // Not supported in v1 — return default with FLAG_NOT_FOUND return {value: defaultValue, reason: "ERROR", errorCode: "FLAG_NOT_FOUND" as const}; } async resolveObjectEvaluation( flagKey: string, defaultValue: T ): Promise> { return {value: defaultValue, reason: "ERROR", errorCode: "FLAG_NOT_FOUND" as const}; } private async resolve( flagKey: string, defaultValue: T, context: EvaluationContext, expectedType: "boolean" | "variant" ): Promise> { // Use findOneOrNone per repo rule (never Model.findOne); the FeatureFlag schema has the plugin. const flag = await this.flagModel.findOneOrNone({key: flagKey, archived: {$ne: true}}); if (!flag) { return {value: defaultValue, reason: "ERROR", errorCode: "FLAG_NOT_FOUND" as const}; } if (flag.type !== expectedType) { return {value: defaultValue, reason: "ERROR", errorCode: "TYPE_MISMATCH" as const}; } if (!flag.enabled) { return {value: defaultValue, reason: "DISABLED", variant: flag.defaultVariant}; } const targetingKey = context.targetingKey ?? ""; const user = context.user ?? context; // accept either {targetingKey, user: {...}} or flat const result = evaluateFlag(flag, targetingKey, user, this.segments); if (expectedType === "boolean") { const value = (result as boolean) as T; return {value, reason: "TARGETING_MATCH", variant: value ? "on" : "off"}; } if (result === null) { return {value: defaultValue, reason: "DISABLED", variant: flag.defaultVariant}; } return {value: result as T, reason: "TARGETING_MATCH", variant: result as string}; } /** Called by the live-updates hook when a flag doc changes. */ emitConfigurationChanged(): void { this.events.emit("PROVIDER_CONFIGURATION_CHANGED"); } } ``` The existing `evaluate.ts` is unchanged. The provider is a thin adapter. ### Backend: `FeatureFlagsApp` (updated) **File**: `feature-flags/src/featureFlagsApp.ts` (updated) New responsibilities: 1. Construct `MongoFeatureFlagProvider` and call `OpenFeature.setProvider(provider, "feature-flags")` (using a domain so we don't stomp on consumer providers). 2. Register a new `GET /feature-flags/flagConfiguration` endpoint that returns the `FlagConfiguration` shape. 3. Keep the existing `GET /feature-flags/evaluate` endpoint working for one release with a deprecation warning header (`Deprecation: true`, `Sunset: `). 4. Keep admin CRUD via `modelRouter` (unchanged). 5. If `liveUpdates.socketIoServer` is provided in options, start a Mongoose change stream on the FeatureFlag collection and emit a `featureFlagsChanged` socket event to all connected clients, plus call `provider.emitConfigurationChanged()`. Updated options: ```typescript interface FeatureFlagsOptions { basePath?: string; segments?: Record; permissions?: ModelRouterOptions["permissions"]; segmentsPermission?: (user: unknown) => boolean; /** Optional Socket.io Server instance for live updates. */ liveUpdates?: { socketIoServer: SocketIOServer; /** Custom event name. Default: "featureFlagsChanged". */ eventName?: string; }; /** Optional. Defaults to a private domain ("feature-flags") so the global default provider is left alone. */ openFeatureDomain?: string; } ``` The `/flagConfiguration` endpoint: ```typescript app.get( `${basePath}/flagConfiguration`, authenticateMiddleware(), asyncHandler(async (req, res) => { const user = req.user as {_id?: unknown; id?: string} | undefined; if (!user) throw new APIError({status: 401, title: "Authentication required"}); const targetingKey = String(user._id ?? user.id); // Match existing evaluateAllFlags() filter — only enabled, non-archived flags ship to clients. // Disabled or archived flags are omitted, matching current behavior where Object.keys(flags) // does NOT include disabled flag keys. const flags = await FeatureFlag.find({archived: {$ne: true}, enabled: true}); const config: Record = {}; for (const flag of flags) { config[flag.key] = buildFlagDefinition(flag, targetingKey, user, this.segments); } return res.json({data: config}); }) ); ``` `buildFlagDefinition` reuses `evaluateFlag()` to determine the resolved value for *this* user, then builds the OF variant shape. **Because `/flagConfiguration` only ships enabled, non-archived flags**, `disabled: false` for every entry in the response (we never embed disabled flags; consumers see them only via the admin panel). The `disabled` field stays in the type for parity with OF's `FlagConfiguration` shape: - Boolean flag → `variants: {on: true, off: false}`, `defaultVariant: result ? "on" : "off"`, `disabled: false` - Variant flag → `variants: {variantKey: variantKey, ...}` (identity map), `defaultVariant: result` (the existing `evaluateFlag()` guarantees a non-null string when `flag.enabled`), `disabled: false` Setting `defaultVariant` to the *resolved* value for this user makes `useBooleanFlagValue` on the frontend Just Work without re-running targeting logic on the client. ### Backend: legacy `/evaluate` (deprecated, kept for one release) The legacy endpoint stays. It calls `evaluateAllFlags()` exactly as today and returns the old `Record` shape, plus a `Deprecation: true` header and a `Sunset` header set to 90 days out. A `logger.warn` fires once per process when first called. --- ## Frontend ### New hook: `useTerrenoFeatureFlags` **File**: `rtk/src/useTerrenoFeatureFlags.ts` (new) ```typescript import {OpenFeature, TypedInMemoryProvider} from "@openfeature/web-sdk"; interface UseTerrenoFeatureFlagsOptions { basePath?: string; // default: "/feature-flags" skip?: boolean; // skip fetch until userId is known userId?: string | null; // current user id — when this changes, refetch & re-bucket socket?: Socket | null; // optional socket for live updates socketEventName?: string; // default: "featureFlagsChanged" domain?: string; // OF provider domain — default: "feature-flags" } interface UseTerrenoFeatureFlagsResult { flags: FlagConfiguration; isLoading: boolean; // true until BOTH RTK query resolves AND OF PROVIDER_READY error: unknown; refetch: () => void; client: Client; // OpenFeature client bound to this hook's domain } export const useTerrenoFeatureFlags = ( api: Api, options?: UseTerrenoFeatureFlagsOptions ): UseTerrenoFeatureFlagsResult => { // 1. RTK Query bulk fetch /feature-flags/flagConfiguration. Cache key INCLUDES userId so a // different user gets a fresh fetch (no stale per-user resolved values). // 2. On userId change: api.util.invalidateTags(["feature-flags"]) AND OpenFeature.setContext( // domain, {targetingKey: userId}) before the refetch lands. // 3. On data success: await OpenFeature.setProvider(domain, new TypedInMemoryProvider(flags)). // isLoading stays true until the PROVIDER_READY event fires (subscribed via client.addHandler). // 4. If options.socket: subscribe to socketEventName → refetch. // 5. Cleanup: do NOT clear the provider on unmount of an individual instance — multiple // components may use useFeatureFlags simultaneously. Use a small ref-count module-level // counter; only clear (OpenFeature.clearProviders) when the count returns to zero. // 6. StrictMode safety: setProvider is idempotent for the same data, but track an in-flight // promise with a ref to prevent double-setting during the StrictMode double-mount window. }; ``` Critical behaviors: - **User switching**: the cache key for the RTK Query endpoint must include `userId` so different users get different cache entries. On `userId` change, the hook calls `OpenFeature.setContext(domain, {targetingKey: userId})` and `refetch()`. The provider is replaced with the new user's values once the new fetch lands. `isLoading` returns to `true` during the transition. - **`isLoading` semantics**: `isLoading: true` until RTK Query has data AND OpenFeature has emitted `PROVIDER_READY` for the new provider. This avoids the race where `useBooleanFlagValue` returns `defaultValue` before the provider is ready. - **No provider clearing on individual unmount** (avoids breaking other mounted consumers). ### Updated BC wrapper: `useFeatureFlags` **File**: `rtk/src/useFeatureFlags.ts` (rewritten as a thin wrapper) ```typescript export const useFeatureFlags = ( api: Api, basePathOrOptions?: string | UseFeatureFlagsOptions ): UseFeatureFlagsResult => { const {basePath, skip} = resolveFeatureFlagsOptions(basePathOrOptions); const {flags, isLoading, error, refetch, client} = useTerrenoFeatureFlags(api, {basePath, skip}); const getFlag = useCallback( (key: string): boolean => client.getBooleanValue(key, false), [client] ); const getVariant = useCallback( (key: string): string | null => { // The InMemoryProvider doesn't natively report reason "DISABLED" — disabled flags are // omitted from /flagConfiguration entirely. So absence in `flags` (the raw FlagConfiguration // map returned by useTerrenoFeatureFlags) means the flag is disabled/missing and we return null. if (!flags[key]) return null; const value = client.getStringValue(key, ""); return value === "" ? null : value; }, [client, flags] ); // Build a Record view from the FlagConfiguration to preserve // the existing flags API surface used by example-frontend profile.tsx (which iterates // Object.keys(flags) and renders {value: boolean|string|null}). // // Projection from FlagDefinition → BC value: // - boolean flag → variants[defaultVariant] // true or false // - variant flag → defaultVariant // string variant key // Disabled flags are omitted from /flagConfiguration so they never appear in this map, // which matches today's behavior where evaluateAllFlags filters enabled: true. const flatFlags = useMemo>(() => { const out: Record = {}; for (const [key, def] of Object.entries(flags)) { const value = def.variants[def.defaultVariant]; out[key] = value ?? null; } return out; }, [flags]); return {flags: flatFlags, getFlag, getVariant, isLoading, error, refetch}; }; ``` `useFeatureFlags`'s return type is unchanged from today, including `flags: Record` whose **keys and values match the prior `/evaluate` payload byte-for-byte for enabled flags**. Existing consumers in `example-frontend/app/(tabs)/index.tsx:105` and `profile.tsx` keep working without edits. A snapshot test in Phase 2 will lock down this projection against the existing `profile.tsx` rendering output. ### Direct OpenFeature usage (recommended for new code) Consumers add `@openfeature/react-sdk` as a direct dep and use OF hooks directly: ```typescript import {useBooleanFlagValue, useStringFlagValue, FeatureFlag} from "@openfeature/react-sdk"; const showSummary = useBooleanFlagValue("todo-summary-card", false); const layout = useStringFlagValue("profile-layout", "compact"); return ( ); ``` `@terreno/rtk` does **not** re-export OpenFeature hooks. Consumers add `@openfeature/react-sdk` to their `package.json` and import directly. (Decision per shaping question.) **Note for consumers**: `useNumberFlagValue` and `useObjectFlagValue` always return the `defaultValue` with this provider — `MongoFeatureFlagProvider` supports only boolean and string (variant) flags, so number/object resolution returns `FLAG_NOT_FOUND` `ResolutionDetails`. Documented in `feature-flags.md` reference. ### SDK regen and the new endpoint Today's `useFeatureFlags` manually injects the `/evaluate` endpoint via RTK Query (`api.injectEndpoints`) because the hook lives in `@terreno/rtk` and runs without depending on consumer-app SDK codegen. We keep the same pattern for `useTerrenoFeatureFlags` — it manually injects `/flagConfiguration` via `api.injectEndpoints`. **The consumer's SDK regen (`bun run sdk`) will ALSO emit a generated hook for `/flagConfiguration`** — that's fine; the generated hook is unused but harmless. To prevent confusion, document in `feature-flags.md` that consumers should call `useTerrenoFeatureFlags`/`useFeatureFlags`, not the generated `useGetFeatureFlagsFlagConfigurationQuery` hook from `openApiSdk.ts`. No rip-out of manual injection. ### Wiring in `example-frontend` **File**: `example-frontend/app/_layout.tsx` (updated) Wrap the app in ``. Add `useTerrenoFeatureFlags(api, {socket})` at the top level after auth so the provider is set as soon as the user is known. Both must come *after* the Redux Provider since the hook uses RTK Query. --- ## Live Updates ### Backend Inside `FeatureFlagsApp.register()`, if `liveUpdates.socketIoServer` is provided: 1. Start a Mongoose change stream: `FeatureFlag.watch([], {fullDocument: "updateLookup"})`. 2. On any change event, emit `liveUpdates.socketIoServer.emit("featureFlagsChanged", {key: change.fullDocument?.key})`. 3. Also call `provider.emitConfigurationChanged()` so any server-side OF clients with cached values invalidate (mostly future-proofing; we don't cache on the server today). 4. Handle the stream's `error` event by logging and attempting one reconnect; subsequent errors disable the stream and log a warning to the consumer. 5. On `app.close()` (if `TerrenoApp` exposes a shutdown hook — add one if not), call `stream.close()`. **Requirement to document**: Mongoose change streams require MongoDB to run as a replica set (even a single-node replica set is fine). The `add-feature-flags.md` how-to gets a new "Live updates" section that calls this out. ### Frontend Inside `useTerrenoFeatureFlags`, if `options.socket` is provided: 1. Subscribe to `socket.on(options.socketEventName ?? "featureFlagsChanged", handler)`. 2. Handler calls `refetch()` on the RTK Query endpoint. 3. The fresh data triggers a fresh `OpenFeature.setProvider(domain, new TypedInMemoryProvider(...))`. 4. OpenFeature React SDK auto-re-renders all `useBooleanFlagValue` / `useStringFlagValue` consumers (this is its built-in behavior on `PROVIDER_CONFIGURATION_CHANGED`). 5. Cleanup: `socket.off(eventName, handler)` on unmount. If `options.socket` is not provided, the hook falls back to RTK Query's existing refetch-on-mount behavior. Live updates are strictly opt-in. --- ## Type-Safe Flag Keys Document in `docs/reference/feature-flags.md` how consumers can narrow flag-key types using module augmentation: ```typescript // example-frontend/types/openfeature.d.ts declare module "@openfeature/core" { export type BooleanFlagKey = "todo-summary-card" | "dark-mode-toggle" | "ai-features" | "todo-priority"; export type StringFlagKey = "profile-layout"; } ``` After this, `useBooleanFlagValue("typo", false)` fails at compile time. Optional per-consumer; not required to use the feature flag package. --- ## Notifications None needed (live updates use Socket.io directly, not the notifier system). --- ## UI No new dedicated screens. The admin panel (auto-generated from `FeatureFlag` schema via `@terreno/admin-backend`) continues to work as today and now also exposes the new `defaultVariant` field automatically because `mongoose-to-swagger` picks up its description. `example-frontend/app/(tabs)/profile.tsx` gets a small additional debug view: alongside the existing "all flags" card, display each flag's `reason` (TARGETING_MATCH / DISABLED / ERROR) via `useBooleanFlagDetails` to demonstrate OF metadata is available. --- ## Phases ### Phase 1 — Backend OpenFeature integration Goal: server-side provider is in place and the new endpoint returns OF-shape responses; existing tests still pass; legacy endpoint kept. - Add `@openfeature/server-sdk` to `feature-flags/package.json`. - Add `defaultVariant` field + validation + computed-default getter to `FeatureFlag` schema. Update `types.ts` accordingly. Update existing pre-save validation to also check `defaultVariant` shape (boolean → "on"/"off"; variant → must be in variants). - Create `feature-flags/src/openFeatureProvider.ts` with `MongoFeatureFlagProvider` as specified above. - Update `feature-flags/src/featureFlagsApp.ts`: - On `register()`, construct the provider with `{flagModel: FeatureFlag, segments: this.segments}` and call `OpenFeature.setProvider(domain, provider)` (domain = `openFeatureDomain ?? "feature-flags"`). - Add the new `/flagConfiguration` route. - Keep the existing `/evaluate` route but emit the deprecation headers + one-shot warn log. - Add tests: - `feature-flags/src/tests/openFeatureProvider.test.ts` — boolean and variant resolution, disabled flags, type mismatch, missing flag, targetingKey hashing, rule + segment paths, missing targetingKey. - Extend `featureFlagsApp.test.ts` — `/flagConfiguration` returns the right OF shape for both boolean and variant flags; respects archived/enabled; verifies `defaultVariant` propagation; verifies `/evaluate` still works and includes `Deprecation` header. - Update `feature-flags/src/index.ts` to export `MongoFeatureFlagProvider`. **Exit criteria**: `bun run test --filter=@terreno/feature-flags` and `bun run compile` pass. The new endpoint returns a valid `FlagConfiguration` for a logged-in user. ### Phase 2 — Frontend OpenFeature integration Goal: `useTerrenoFeatureFlags` exists; legacy `useFeatureFlags` works as a thin BC wrapper; example app renders identically. - Add `@openfeature/web-sdk`, `@openfeature/react-sdk`, and `@openfeature/core` to `rtk/package.json` (peer dep `react`). - Create `rtk/src/useTerrenoFeatureFlags.ts` (RTK Query bulk fetch → `TypedInMemoryProvider` → `OpenFeature.setProvider(domain, ...)` + `setContext(domain, {targetingKey})`). - Rewrite `rtk/src/useFeatureFlags.ts` as a thin wrapper preserving today's `{flags, getFlag, getVariant, isLoading, error, refetch}` API. Same overload signature (string | options object). `getVariant` returns `null` when `reason === "DISABLED" || "ERROR"`. - Update `rtk/src/useFeatureFlags.test.ts` to validate against the new `FlagConfiguration` payload shape. Add `useTerrenoFeatureFlags.test.ts` covering: success path, skip behavior, refetch, domain isolation (doesn't affect the global default provider). - Update `rtk/src/index.ts` to export `useTerrenoFeatureFlags`. **Do not** re-export OpenFeature hooks per shaping decision. **Exit criteria**: `bun run test --filter=@terreno/rtk` and `bun run compile` pass. Calling code in `example-frontend` works without code edits. ### Phase 3 — Live updates (opt-in) Goal: admin edits propagate to connected clients without a page refresh. - Backend: in `FeatureFlagsApp.register()`, if `liveUpdates.socketIoServer` is provided, start `FeatureFlag.watch()` and emit `featureFlagsChanged` on each change. Wire to `provider.emitConfigurationChanged()` too. - Backend tests: `featureFlagsApp.liveUpdates.test.ts` — given a mocked socket.io server, mutating a flag triggers an emission with the expected event payload. Use the in-memory MongoDB test rig and a real replica set if available; otherwise mock the change stream. - Frontend: in `useTerrenoFeatureFlags`, if `options.socket` is provided, subscribe to the event name and call `refetch()` on each. Verify the OF re-render is triggered (component snapshot test). - Documentation: add a "Live updates" section to `docs/how-to/add-feature-flags.md` (Mongoose replica-set requirement, Socket.io setup). **Exit criteria**: opt-in via constructor options; default off; tests cover both opt-in and opt-out paths. ### Phase 4 — Example apps + docs + SDK regen Goal: example apps run, docs reflect the new shape, downstream consumers have a migration path. - `example-backend`: pass `liveUpdates: {socketIoServer: io}` to `FeatureFlagsApp` (the example-backend already constructs a Socket.io server for the existing `useSocketConnection` flow). - `example-frontend`: wrap `_layout.tsx` in ``. Call `useTerrenoFeatureFlags(terrenoApi, {socket})` once at the top of the layout after auth. Update `profile.tsx` to add the `useBooleanFlagDetails` debug row showing `reason` per flag. - Add a `types/openfeature.d.ts` file in `example-frontend` demonstrating the type-safe flag keys augmentation. Update SEED_FLAGS in `example-backend/src/scripts/seed-feature-flags.ts` to include `defaultVariant` on each flag, exercising the new field. - Regenerate SDK: `cd example-frontend && bun run sdk`. - Update `docs/reference/feature-flags.md` to describe the new endpoint shape, the `MongoFeatureFlagProvider` export, the `defaultVariant` field, and how to use OpenFeature hooks directly. Note `/evaluate` is deprecated. - Update `docs/how-to/add-feature-flags.md` to recommend OpenFeature hooks for new code, with `useFeatureFlags` shown as the BC path. - Add a new section "Migrating to OpenFeature" describing the breaking wire change for anyone calling `/evaluate` directly (no SDK update required if using `useFeatureFlags` — the BC wrapper handles it). - Update `docs/implementationPlans/feature-flags.md` with a note linking to this new IP at the top. **Exit criteria**: `bun run frontend:web` + `bun run backend:dev` runs end-to-end. Flags render with the same values as before. Admin edits propagate live to the open frontend. All workspace tests pass: `bun run lint && bun run compile && bun run test`. --- ## Feature Flags & Migrations - No data migration: legacy `FeatureFlag` docs without `defaultVariant` get a computed value via Mongoose getter at read time. - The wire-protocol change is at the SDK boundary — consumers using `useFeatureFlags(api)` get the new behavior automatically because the hook handles both shapes via the BC wrapper. Direct `/evaluate` callers (none expected internally) hit the deprecated endpoint until they migrate. - No new feature flag is needed to gate this change since the package already gates itself. --- ## Activity Log & User Updates None. --- ## Risks & Mitigations | Risk | Mitigation | |---|---| | **User switching pours wrong-user data into globally-shared InMemoryProvider** | RTK Query cache key includes `userId`; on `userId` change the hook re-fetches AND calls `OpenFeature.setContext(domain, {targetingKey})` before swapping the provider. `isLoading` returns to `true` during the transition so consumers don't render stale values. Explicit test required. | | **`@openfeature/react-sdk` may not work in React Native** | Phase 2.1 includes a spike: import the package in a `bun test` using `@testing-library/react-native` and assert `useBooleanFlagValue` returns expected value. If it fails on RN, fall back to importing from `@openfeature/web-sdk` directly and writing a tiny React adapter in `@terreno/rtk`. **Blocking before Phase 2 proceeds.** | | **`OpenFeature.setProvider` is async; consumers can render with `defaultValue` before `PROVIDER_READY`** | `useTerrenoFeatureFlags` subscribes to `PROVIDER_READY` via `client.addHandler` and only flips `isLoading` to `false` once that event fires. Tested explicitly. | | **`OpenFeature` is a global singleton — two copies of `@openfeature/web-sdk` create two singletons** | Declare `@openfeature/web-sdk` and `@openfeature/react-sdk` as **peerDependencies** (not direct deps) in `rtk/package.json`. Consumers install once; bun dedupes automatically. Direct deps remain on `@openfeature/core` for types. | | **Mongoose change streams need a replica set; default test mongo is standalone** | The live-updates tests mock `FeatureFlag.watch()` to return a fake EventEmitter. We do NOT spin up a real replica set in tests. Production deployments must use a replica set — documented in `add-feature-flags.md` "Live updates" section as a prerequisite. Startup logs a warning if `watch()` throws. | | **Provider's `resolveStringEvaluation` could be called with a key whose stored type is `boolean`** | Provider returns `{value: defaultValue, reason: "ERROR", errorCode: "TYPE_MISMATCH"}`. Tested explicitly. | | **`defaultVariant` validation could reject legacy docs on edit** | Pre-save hook auto-populates `defaultVariant` for legacy docs before validation runs; validation only enforces shape constraints, not presence. Tested with both legacy-update and new-create paths. | | **Provider's per-flag `findOneOrNone` is N+1 for server-side OF consumers** | Acknowledged as a regression vs. `evaluateAllFlags` (one query). For our frontend bulk fetch this is fine because `/flagConfiguration` does ONE query and loops in-memory. Document that direct backend OF usage is not for hot paths; add LRU caching in a follow-up if needed. | | **`OpenFeature.setProvider` cleanup on unmount could break other consumers** | Do not clear on unmount of an individual instance. Use a module-level ref count; only clear when the count returns to zero. Documented in `useTerrenoFeatureFlags.ts`. | | **React StrictMode double-mount sets the provider twice** | Use a ref to track the in-flight `setProvider` promise; if a second mount fires while the first is in-flight, skip the second call. Tested with `` wrapper. | | **Socket.io broadcast leaks flag keys to all connected clients** | Documented behavior. Payload contains only `{key}` (no user data). Acceptable since flag keys are not secrets — the same keys ship to every authenticated client via `/flagConfiguration` anyway. | | **Socket reconnects miss change events during disconnect** | After socket reconnect, hook automatically `refetch()`s once (already happens via RTK Query refetch-on-focus and via socket connection event). Document the gap is "at most one stale render" in practice. | | **Type-safe flag keys union can drift from runtime flag set** | Documented in `feature-flags.md` reference: the type union is a compile-time hint only; admin changes to flags may add/remove runtime keys without the TS types updating. Recommend syncing periodically. | --- ## Not included / Future work - Server-side flag value caching keyed by `(flagKey, userId)` for very hot paths. - Anonymous evaluation (would require deriving a stable `targetingKey` from device id or similar). - Object/number flag types (currently return `FLAG_NOT_FOUND` from the provider; can be added by extending the schema and provider). - Native flagd or GrowthBook provider — out of scope; vendor-portability is unlocked by the OF abstraction. - Experiment-results tracking via `useTrack` and analytics integration. - Multi-tenant flag scopes (e.g., per-org overrides). - Flag scheduling (enable/disable at a future date) and audit log of who changed what when.