Files
HiveGrid-Wireframes/hivegrid-cloud/docs/features/feature-flags.md
T

7.6 KiB

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

// 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(<bucketKey>:<flagName>) % 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

// 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(<userId>:<flagName>) 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

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(<userId>:<flagName>: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:<flagName>:<hash(ctx)> 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

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 (
    <FeatureFlagProvider flags={loaderData.flags}>
      <Outlet />
    </FeatureFlagProvider>
  );
}

// any descendant — read synchronously, no fetch in render
function Page() {
  const { enabled, variant } = useFeatureFlag("chat-v2");
  if (!enabled) return null;
  return <Chat variant={variant?.key} />;
}

Convenience hooks: useFeatureFlagEnabled(name): boolean and useFeatureFlagVariant(name): string | undefined.

Hydration shape:

type FeatureFlagsHydration = Record<string, { enabled: boolean; variant?; reason: string }>;

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

toolctrl-cli feature-flags list                      # every flag with state
toolctrl-cli feature-flags list --enabled=true       # filter
toolctrl-cli feature-flags enable  <flag-name>
toolctrl-cli feature-flags disable <flag-name>
toolctrl-cli feature-flags set-rules <flag-name> --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.