Files
multica/docs/feature-flags.md
Multica Eve 4a8210912a feat(featureflag): framework-level feature flag system (MUL-3615) (#4496)
* feat(featureflag): framework-level feature flag system (MUL-3615)

Introduces a reusable feature flag framework so future features can adopt
flags without writing infrastructure code.

Backend: server/pkg/featureflag (Go)
- Service / Provider / Decision separation per Martin Fowler's Toggle
  Point / Toggle Router / Toggle Configuration pattern.
- Providers: StaticProvider (rules in source control), EnvProvider
  (FF_<KEY> overrides for ops kill switches), ChainProvider
  (first-hit-wins composition).
- EvalContext carried through context.Context with WithEvalContext /
  EvalContextFrom; supports user_id, workspace_id, free-form attributes.
- PercentRollout via deterministic FNV-1a bucketing; same user always
  lands in the same bucket so experiments do not flap between requests.
- Nil-safe Service: a nil *Service or missing flag returns the caller's
  default so business code never panics on a missing flag.
- 100% unit-test coverage with -race; go vet clean.

Frontend: packages/core/feature-flags (TypeScript)
- Same vocabulary as the Go side (Decision, EvalContext, Rule,
  PercentRollout). FNV-1a parity ensures cross-tier bucket agreement.
- FeatureFlagService + StaticProvider + ChainProvider in pure TS.
- React glue: FeatureFlagsProvider, useFlag(key, default),
  useVariant(key, default). Hooks fall back to the default when no
  provider is mounted so Storybook / unit tests stay simple.
- Vitest tests for service, providers, hash, and React hooks.

Docs: docs/feature-flags.md — wiring, EvalContext, toggle points,
backend-protection note, and the standard best-practice checklist.

The framework intentionally has no third-party Go deps and no API
surface beyond what real callers will need. New providers (DB, remote
config, LaunchDarkly) plug in by implementing Provider; no existing
caller has to change.

Co-authored-by: multica-agent <github@multica.ai>

* fix(featureflag): cross-tier hash parity + variant only when enabled (MUL-3615)

Two must-fix issues from the PR review on #4496:

1. TS hash had a trailing zero separator that Go did not emit, so the
   same (key, identifier) bucketed differently on the two tiers. The
   "user lands in the same bucket on server and client" promise was
   broken. For example billing_new_invoice/user-42 was bucket 97 in Go
   and bucket 11 in TS.

   Fix: TS fnv1a now emits the zero separator BETWEEN parts only, never
   after the last one, matching Go's hash.Write byte stream exactly.
   Verified by parallel golden tests on both sides that pin five
   (key, identifier) -> bucket triples; if either side drifts both tests
   fail and one must be brought back in sync.

2. StaticProvider returned `Rule.Variant` regardless of whether the rule
   evaluated to enabled=true. A 0%-rollout user, a deny-listed user, or
   a default-off user would see variant="experiment-v2", so callers
   branching on Variant() would route control users into the experiment
   arm.

   Fix: Rule.Variant is now the ON-variant only. When the rule evaluates
   to enabled=false the Decision's variant is the canonical "off",
   regardless of what Rule.Variant says. Documented as a behavior
   contract in the Rule godoc / JSDoc and covered by regression tests
   on both sides.

Tests: - go test -race ./pkg/featureflag/...  : all green (1.58s).
  - pnpm --filter @multica/core test     : 661/661 (3 new).
  - pnpm --filter @multica/core typecheck: clean.
Co-authored-by: multica-agent <github@multica.ai>

* fix(featureflag): hash UTF-8 bytes on the TS side for cross-tier parity (MUL-3615)

Follow-up review on PR #4496 caught that the previous hash fix was only
correct for ASCII input. The TS side used `charCodeAt`, which returns
UTF-16 code units, while the Go side hashes the UTF-8 byte
representation. Any non-ASCII flag key or identifier — Chinese flag
names, accented user IDs, emoji — would bucket differently on backend
vs frontend, silently breaking the "same user, same bucket" promise the
PR description makes.

Concretely:
  flag/é         Go 53  vs TS-old 68
  flag/🦄        Go 82  vs TS-old 75
  实验/user-1    Go 90  vs TS-old 4
  flag/用户-1    Go 95  vs TS-old 2

Fix: replace per-char charCodeAt with a module-level `TextEncoder`
('utf-8') and hash each encoded byte. After the fix all four cases above
match Go exactly, and the existing ASCII cases continue to match.

The cross-language golden tables on both sides now include the 5 new
non-ASCII cases alongside the 5 ASCII cases, so any future regression
that swaps UTF-8 for charCodeAt (or vice versa) will fail loudly on
both Go and TS simultaneously.

TextEncoder is part of WHATWG Encoding and is available in every
evergreen browser, in Node 11+, and in Hermes (React Native) >= 0.74,
which covers every runtime that imports @multica/core/feature-flags.

Tests: - go test -race ./pkg/featureflag/...   : all green.
  - pnpm --filter @multica/core test      : 661/661.
  - pnpm --filter @multica/core typecheck : clean.
Co-authored-by: multica-agent <github@multica.ai>

* feat(featureflag): wire into main app config — YAML file + env override (MUL-3615)

Follow-up requested by Yushen on PR #4496: make the feature flag
framework configurable through the existing main-program config system
instead of requiring Go code edits. multica's main app is purely env-var
driven (see .env.example) with optional MULTICA_*_FILE knobs for richer
config; feature flags now follow the same pattern.

server/pkg/featureflag/config.go
  - LoadRulesFromYAMLFile(path) parses a YAML rule set into runtime
    Rule structs. Empty files are a valid "no flags yet" state; missing
    or malformed files surface a hard error so operators see misconfig
    the same way DATABASE_URL parse errors do.
  - NewServiceFromEnv composes the standard provider chain:
      1. EnvProvider("FF_")               (runtime kill-switch path)
      2. StaticProvider from YAML file    (declarative rule set)
    When MULTICA_FEATURE_FLAGS_FILE is unset, only the env layer is
    active and every IsEnabled call falls through to the caller's
    default, so the server can boot before any flag is authored.

server/cmd/server/main.go
  - Construct the Service once at startup right after env-var warnings,
    fail loudly on malformed YAML, log the loaded rule count via the
    Service logger. The Service is held in a local `flags` variable
    ready to be threaded into handler.Handler / service constructors
    when the first flag user lands. Threading is deferred to the PR
    that adds the first business consumer so this PR stays a pure
    framework + config layer.

.env.example
  - New "Feature flags" section documents MULTICA_FEATURE_FLAGS_FILE and
    the FF_<KEY> override convention, with a minimal YAML schema example
    inline.

docs/feature-flags.md
  - Replace the "build a provider manually" example with the
    NewServiceFromEnv pattern that now matches what main.go actually
    does. Show the YAML schema in one place. Note the on-variant /
    off semantics from the previous review round.

server/pkg/featureflag/doc.go
  - Update package doc to mention the gopkg.in/yaml.v3 dependency
    (already a server-level dep) instead of the now-inaccurate
    "no third-party dependencies" claim.

Tests: - go test -race -count=1 ./pkg/featureflag/...   all green; new
    config_test.go covers: simple YAML, full-shape YAML, empty file,
    missing file, malformed YAML, no env var, file-only, env-beats-file,
    bad file surfaces error.
  - go test -race -count=1 -run TestHealth ./cmd/server/...   sanity
    check that the main.go boot path with the new wiring still passes.
  - go vet ./...   clean.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 13:49:59 +08:00

8.2 KiB
Raw Permalink Blame History

Feature Flags

Multica ships a framework-level feature flag implementation:

  • Backend: server/pkg/featureflag — Go package.
  • Frontend: @multica/core/feature-flags — TypeScript module with React hooks.

Both sides share the same vocabulary (Decision, EvalContext, Rule, PercentRollout) and the same FNV-1a percent bucketing, so a flag evaluated on the server and on the client lands in the same bucket for the same user.

The package is designed so new features can adopt feature flags without writing any infrastructure code — drop a rule into the static config, call Service.IsEnabled / useFlag, done.


Core concepts

[Toggle Point] --query--> [Service / Router] --read--> [Provider / Configuration]
   business code                                          static / env / chain
  • A Toggle Point is the single if in business code. It always calls the Service, never the provider directly.
  • The Service (Service in Go, FeatureFlagService in TS) is the router. Business code never depends on which provider is behind it.
  • A Provider is the configuration backend. Today we ship StaticProvider (in-memory rules), EnvProvider (Go only — env-var override), and ChainProvider (composition). A future DB or LaunchDarkly provider plugs in without changing any caller.
  • A Decision is the structured result: { enabled, variant, reason, source }. IsEnabled is the boolean projection, Variant is the raw string. Use Decision for diagnostic endpoints.

Four flag categories (Martin Fowler):

Category Lifetime Owner Example
Release Daysweeks Engineering Hide a half-finished page behind flags_release_v2
Experiment Hoursweeks Product / Data A/B test checkout_algo between control and experiment-v2
Ops Short or evergreen SRE Kill switch billing_disable_invoice_pdf
Permission Years Product plan_gate_enterprise_dashboard

Manage them in the same provider but treat them differently: Release flags get deleted; Ops flags need fast override paths (FF_<KEY> env var); Permission flags use Allow lists; Experiment flags use PercentRollout.


Backend (Go)

Wiring at startup

The server constructs a featureflag.Service once in cmd/server/main.go via the standard helper:

flags, err := featureflag.NewServiceFromEnv(featureflag.WithLogger(slog.Default()))
if err != nil {
    slog.Error("feature flag configuration failed to load", "error", err)
    os.Exit(1)
}

NewServiceFromEnv reads two env vars — both follow the same MULTICA_*_FILE / FF_* conventions documented in .env.example:

Env var Role
MULTICA_FEATURE_FLAGS_FILE Path to the YAML rule set (optional; absent = no static rules).
FF_<FLAG_KEY> Per-flag runtime override. FF_BILLING_NEW_INVOICE_EMAIL=false / 25% / experiment-v2. Beats the YAML, no redeploy.

The provider chain is EnvProvider → YAML StaticProvider. The server can boot with zero flag config — every IsEnabled call falls back to the caller's default until someone authors a rule.

YAML schema

# /etc/multica/feature-flags.yaml
billing_new_invoice_email:
  default: true

checkout_algo:
  default: false
  variant: experiment-v2
  percent:
    percent: 25
    by: user_id

ops_disable_recommendations:
  default: false
  allow: ["user-internal-1", "user-internal-2"]
  allow_by: user_id

Every field except default is optional. variant is the on-variant — see the multi-arm note below. An empty file is a valid "no flags yet" state. Malformed YAML fails startup the same way DATABASE_URL parse errors do, so misconfig surfaces loudly.

Attaching evaluation context to the request

func middleware(flags *featureflag.Service, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ec := featureflag.EvalContext{
            UserID:      currentUserID(r),
            WorkspaceID: currentWorkspaceID(r),
            Attributes:  map[string]string{"plan": currentPlan(r)},
        }
        ctx := featureflag.WithEvalContext(r.Context(), ec)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Toggle point in business code

if flags.IsEnabled(ctx, "billing_new_invoice_email", false) {
    return s.sendNewInvoiceEmail(ctx, invoice)
}
return s.sendLegacyInvoiceEmail(ctx, invoice)

For multi-arm flags:

switch flags.Variant(ctx, "checkout_algo", "control") {
case "experiment-v2":
    return checkoutV2(ctx, order)
case "experiment-v3":
    return checkoutV3(ctx, order)
default:
    return checkoutControl(ctx, order)
}

Rule.Variant is the on-variant: it is only returned when the rule evaluates to enabled=true (allow hit, percent hit, default-on). When the rule evaluates to disabled (deny hit, percent miss, default-off) the Service returns "off" so callers branching on Variant() cannot route control users into the experiment arm. This is exercised by TestStaticProviderVariantOnlyWhenEnabled and is the same on the TS side.

The Service is nil-safe and missing-key-safe: (*Service)(nil).IsEnabled(ctx, "any", true) returns true. Business code never needs to guard against a missing flag.


Frontend (TypeScript / React)

Mounting once at the root

// apps/web/app/_providers.tsx (or the equivalent root)
import {
  FeatureFlagsProvider,
  FeatureFlagService,
  StaticProvider,
} from "@multica/core/feature-flags";

const service = new FeatureFlagService(
  new StaticProvider({
    billing_v2_dashboard: { default: false, allow: ["user-internal"] },
    checkout_algo: { default: true, variant: "experiment-v2",
                     percent: { percent: 25 } },
  }),
);

export function Providers({ children }: { children: ReactNode }) {
  const userId = useCurrentUserId();
  return (
    <FeatureFlagsProvider service={service} context={{ userId }}>
      {children}
    </FeatureFlagsProvider>
  );
}

When the backend pushes a fresh rule set (via an API response or WebSocket), call service.setProvider(new StaticProvider(remoteRules)) and the whole tree re-evaluates.

Toggle point in a component

import { useFlag, useVariant } from "@multica/core/feature-flags";

function BillingPage() {
  const showV2 = useFlag("billing_v2_dashboard", false);
  return showV2 ? <BillingV2 /> : <BillingV1 />;
}

function Checkout() {
  const variant = useVariant("checkout_algo", "control");
  switch (variant) {
    case "experiment-v2": return <CheckoutV2 />;
    case "experiment-v3": return <CheckoutV3 />;
    default:              return <CheckoutControl />;
  }
}

Outside a FeatureFlagsProvider (Storybook, unit tests, error pages) useFlag / useVariant return the supplied default. You never have to mount the provider just to render a component in isolation.

Security note: never rely on the frontend alone

A frontend feature flag controls what the user sees. It does NOT enforce access. Any API route exposing the same capability MUST evaluate the matching backend flag independently. The two flags can share a key but they live in two Service instances and the backend value is the source of truth.


Best-practice checklist

Adopted from Martin Fowler, ConfigCat and Octopus.

  • Naming: {team}_{area}_{behavior}, e.g. billing_checkout_new_payment_flow. No enable_ / disable_ prefixes (redundant).
  • One flag, one purpose: never repurpose an old flag for a new feature. Add a new flag and delete the old one.
  • Plan the death of the flag at birth: open a follow-up issue to remove the flag when the rollout completes. Release flags should live days, not quarters.
  • Convention: Off is the legacy / safe state, On is the new behavior. Lets CI test "all-off (today)" and "all-on (tomorrow)".
  • Kill switch fast path: ops-critical flags should be exposed via EnvProvider so SREs can flip them without a deploy.
  • Backend protection: anything controlling access goes through the backend Service; the frontend flag is presentation only.
  • No secrets in flags: variant values are not Secrets Manager / KMS. Use those for tokens, keys, and passwords.

See docs/design.md and docs/timezone-architecture-rfc.md for prior examples of how this pattern is used across the codebase.