# Feature-Flags Engine v2 The v2 engine ships in `packages/feature-flags/` and adds targeting rules, variants, dependencies, audit-trail, two-tier cache and an SSR-hydrated React hook on top of the existing `FeatureFlag` Prisma table. Legacy boolean-only callers (`FeatureFlagsService.hasFeatureFlag`) keep working unchanged — the migration is additive. ## Storage `prisma/schema/featureFlags.prisma` extends `FeatureFlag` with: | Column | Type | Purpose | |----------------|-------------|------------------------------------------------------| | `rules` | `Json?` | Targeting-rule tree (see below). | | `variants` | `Json[]` | Variant array `[{ key, weight?, value? }, ...]`. | | `enabledFrom` | `DateTime?` | Lower bound for time-based killswitches. | | `enabledUntil` | `DateTime?` | Upper bound for time-based killswitches. | | `parentFlagId` | `String?` | Self-reference. Parent must be enabled for child. | The `FeatureFlagAudit` table is append-only — every CRUD / toggle writes a row with `actorUserId`, `action`, `oldValue`, `newValue`. The app never UPDATEs / DELETEs audit rows; admins can verify tamper-resistance by reading the audit log alone. Migration: `prisma/migrations/20260517100000_05_feature_flags_v2/`. All new columns nullable / defaulted so existing rows keep evaluating through the legacy boolean `enabled` flag. ## Rule-tree schema Every node has a `type` discriminator. Composite trees combine leaves via AND / OR / NOT. Strict union — adding a new leaf type forces an exhaustive evaluator update. ### Leaves ```jsonc // boolean { "type": "boolean", "value": true } // allowlist — OR over tenantIds / userIds / emails { "type": "allowlist", "tenantIds": ["tenant_a", "tenant_b"], "userIds": ["user_42"], "emails": ["alice@example.com"] } // percentage — sticky SHA1(:) % 100 < N { "type": "percentage", "rolloutPercent": 30, "bucketBy": "userId" } // plan-tier — match if ctx.planTier in plans { "type": "plan-tier", "plans": ["pro", "enterprise"] } // role-based — match if ctx.roles intersects roles { "type": "role-based", "roles": ["admin", "beta-tester"] } // time-based — match if now ∈ [enabledFrom, enabledUntil] { "type": "time-based", "enabledFrom": "2026-06-01T00:00:00Z", "enabledUntil": "2026-12-31T23:59:59Z" } ``` ### Composite ```jsonc // enterprise-tier OR (10% of pro users) — gradual roll-out gated by plan { "type": "composite", "op": "OR", "children": [ { "type": "plan-tier", "plans": ["enterprise"] }, { "type": "composite", "op": "AND", "children": [ { "type": "plan-tier", "plans": ["pro"] }, { "type": "percentage", "rolloutPercent": 10 } ] } ] } ``` ### Sticky percentage rollouts The bucket is `SHA1(:) mod 100`, big-endian over the first 4 bytes of the digest. A user that sees a feature at 30% rollout will keep seeing it until the percentage drops below their bucket — no per-request flipping. Switch `bucketBy: "tenantId"` to make whole organisations sticky as one unit. Distribution: tested over 10 000 random user-ids at 30% rollout the observed share stays within ±5%. ## evaluateFlag ```ts import { evaluateFlagForRequest } from "~/utils/services/.server/featureFlagsRequest.server"; const result = await evaluateFlagForRequest("checkout-v2", { request, params }); // result: { enabled: boolean, variant?: FlagVariant, reason: string } if (result.enabled) { render(result.variant?.key ?? "default"); } ``` Reasons (typed union): `flag-not-found`, `disabled`, `time-window-future`, `time-window-expired`, `parent-disabled`, `parent-not-found`, `no-match`, `matched`. Safe to log; never contains PII pulled from the evaluation context. ### Dependency resolution If `parentFlagId` is set the parent must be enabled, in-window, and match its own targeting rules. Cycles are bounded by `maxDepth = 8` and resolve fail-closed to `parent-disabled`. ### Variant selection If multiple variants are configured the engine picks one deterministically based on `SHA1(::variant) mod 100` scaled to the sum of weights (missing `weight` defaults to 1). Single- variant flags return that variant unconditionally. ## Eval cache + pub/sub invalidation Cache lives in the shared-store two-tier helper (#39). Key: `ff::` where `hash(ctx)` covers `(tenantId, userId, email, planTier, roles)`. Default TTL 60 s (`FEATURE_FLAGS_CACHE_TTL_SECONDS` overrides). Channel: `feature-flags:invalidate` (under the shared-store namespace). Web boot calls `subscribeFeatureFlagCache()` once from `cache-bootstrap.server.ts`. On any flag mutation (admin save, CLI toggle, Stripe-driven activation) the publisher calls `invalidateFlagCache(flagName)`; every replica drops its L1 entirely on receipt (cheap — mutations are rare and L1 entries are tiny). To opt out for a single replica (debugging) set `CACHE_INVALIDATION_ENABLED=false` — the subscriber is suppressed and the L1 entries expire via TTL. ## Admin UI Route: `/admin/feature-flags/engine`. Lists every flag with toggle + rule-JSON editor + last 50 audit entries. Visual rule-builder (tree-render with drag-drop, percentage slider, allowlist autocomplete, live affected-count) is a documented follow-up — the JSON editor catches structurally-broken trees by running them through `evaluateRule` against a probe context before persisting. ## Client SDK + SSR hydration ```tsx import { FeatureFlagProvider, useFeatureFlag } from "@toolctrl/feature-flags/client"; // root.tsx loader — pre-evaluate every flag the tree consumes export async function loader({ request, params }: Route.LoaderArgs) { const flags = await evaluateFlagsForContext(["chat-v2", "ab-onboarding"], { ... }); return { flags }; } // root.tsx component — lift the map into context export default function Root({ loaderData }: Route.ComponentProps) { return ( ); } // any descendant — read synchronously, no fetch in render function Page() { const { enabled, variant } = useFeatureFlag("chat-v2"); if (!enabled) return null; return ; } ``` Convenience hooks: `useFeatureFlagEnabled(name): boolean` and `useFeatureFlagVariant(name): string | undefined`. Hydration shape: ```ts type FeatureFlagsHydration = Record; ``` Missing entries fall back to `{ enabled: false, reason: "flag-not-found" }` so a stale client bundle (deployment skew) does not crash the page when a new flag is referenced. ## CLI ```bash toolctrl-cli feature-flags list # every flag with state toolctrl-cli feature-flags list --enabled=true # filter toolctrl-cli feature-flags enable toolctrl-cli feature-flags disable toolctrl-cli feature-flags set-rules --file=rules.json # or '-' for stdin ``` Every mutation writes an audit row and triggers a pub/sub invalidation; no separate cache-clear step. ## Follow-ups - **Visual rule-builder UI.** Drag-drop AND/OR/NOT composer, percentage slider, allowlist search with debounced Tenant/User lookup, variant- JSON Monaco editor. Tracked in the detail-md. - **`POST /api/feature-flags/preview-affected`** — backend that estimates how many tenants/users a rule change would affect, used by the visual builder for live feedback. - **Engine kill-switch `FEATURE_FLAGS_USE_LEGACY=true`** — meta-flag to bypass v2 entirely during a roll-back window. Skeleton in place (the v1 service is still wired); flip-switch wiring is a follow-up.