Feature Flag

The Feature Flag capability answers one question on every call: what value should flag X take for this particular caller, right now? A FeatureFlagPlugin is a provider — give it a flag key and a context, it returns a typed value. The trait says nothing about how the value was produced: it might come from a TOML file shipped with the site, from the OpenFeature SDK wrapping a vendor provider, or from a direct SDK like LaunchDarkly or Unleash. All three sit behind the same shape so call sites never branch on backend.

This page covers the trait itself, the FlagValue ABI, why a missing flag is not an error, how deterministic per-user rollouts work, and how the two reference plugins — @bext/flags-static and @bext/flags-openfeature — map onto the shape.

The trait

Every Feature Flag plugin implements one trait, FeatureFlagPlugin, defined in bext-plugin-api::feature_flag:

pub trait FeatureFlagPlugin: Send + Sync {
    fn name(&self) -> &str;

    fn evaluate(&self, key: &str, ctx: &FlagContext) -> Result<FlagValue, String>;

    fn evaluate_batch(
        &self,
        keys: &[&str],
        ctx: &FlagContext,
    ) -> Result<HashMap<String, FlagValue>, String> { /* default walks evaluate */ }

    fn refresh(&self) -> Result<(), String> { Ok(()) }
    fn is_healthy(&self) -> bool { true }
}

The trait is sync to match every other trait in bext-plugin-api, so the shape travels cleanly across the WASM, QuickJS, and nsjail sandboxes that plugins can run inside. Backends that need network I/O (OpenFeature SDK providers, LaunchDarkly, Unleash) either use a blocking client or wrap an async client inside a runtime the same way the JWKS fetcher in @bext/auth-jwt does. The runtime does not expose async across the sandbox boundary, so the host-facing shape stays sync.

evaluate_batch has a default implementation that walks evaluate one key at a time. Backends with native batch APIs (LaunchDarkly's variationAll, OpenFeature's ProviderEvaluation list, Statsig's getAllGates) override it for efficiency. refresh lets local backends re-read their config file off disk and lets remote backends pull a fresh snapshot; the runtime schedules refresh itself, so plugins do not need to spawn background watchers.

FlagValue — four typed shapes, flat ABI

pub enum FlagValue {
    Bool(bool),
    String(String),
    Int(i64),
    Json(String),
}

Every flag provider the plan tracks — OpenFeature, LaunchDarkly, Unleash, Statsig, Flagsmith — fits inside these four shapes without any of them being special. Json carries a JSON-encoded String rather than a serde_json::Value so the whole enum round-trips across the WASM boundary as plain bytes; if a caller wants structured access, it parses the string on its own side. This is the same trick the Session capability uses to carry session data and the Lifecycle capability uses to carry event payloads — pushing the serde step to the edges keeps the host-function ABI small and the same across every sandbox runtime.

Convenience accessors — FlagValue::as_bool(), .as_str(), .as_int() — exist for the common case of reading a flag inline at a call site. They return Option so callers decide whether a type mismatch is a coerce-and-continue or a fallback-to-default.

FlagContext — targeting without vendor coupling

pub struct FlagContext {
    pub user_id: Option,
    pub attributes: HashMap<String, String>,
}

user_id is the stable subject identifier. Providers that do per-user rollouts or bucketing read it directly; providers that do not ignore it. None means anonymous evaluation — providers that require a subject for their targeting rules should fall back to their configured default value, never error. Explicit anonymous semantics keep logged-out page loads fast without special-casing at call sites.

attributes is the free-form targeting map: country, plan tier, device class, cohort id, experiment bucket, anything. Keys are provider-defined and values are plain strings so the whole map survives any serialisation boundary. If you find yourself wanting a typed struct here, you are pushing vendor-specific shape into the trait and the answer is a custom evaluator on top of the plugin, not a richer FlagContext.

Missing flags are not errors

evaluate returns Result<FlagValue, String>, but a flag that does not exist in the backend is not an Err. Backends return their configured default variant for the unknown key — FlagValue::Bool(false) for local providers, the provider's "default variant" for OpenFeature-style SDKs. This matches the OpenFeature specification and keeps callers from having to distinguish "flag not found" from "flag is off" at every call site, which is almost never the distinction they actually want. An Err only comes out when the provider itself is broken: network down, config file malformed, unknown value kind. That is the interesting case — you want it loud.

Per-user rollouts must be deterministic

A caller evaluating the same flag with the same user id has to see the same variant on every call, in every sandbox, in every process. Otherwise two panels on the same page can disagree, and observability breaks. The reference @bext/flags-static backend achieves this the standard way: it hashes user_id + flag_key and takes the result modulo 100, then compares against the configured rollout_pct. The hash depends only on the inputs, so the answer is stable across restarts, across processes, and across machines. A user bucketed into the rollout at 10% is also in the 11% rollout, the 12% rollout, and so on — no user flips back out as percentages grow. This is the monotonicity property that makes rollouts safe to ramp.

Remote backends inherit the same property from their vendor: OpenFeature providers with targeting_key set behave the same way; LaunchDarkly's rollout bucketing is keyed on the same subject id. The trait carries user_id in FlagContext specifically so every backend can provide this guarantee without reinventing it.

Reference plugins

@bext/flags-static

Lives at crates/bext-impls/bext-flags-static. Loads a TOML or JSON config file at construction:

[flags.new_checkout]
kind = "bool"
default = false
rollout_pct = 25
rollout = true

[flags.variant]
kind = "string"
default = "control"

[flags.batch_size]
kind = "int"
default = 128

[flags.pricing]
kind = "json"
default = { region = "us", tiers = [9, 29, 99] }

Every flag has a kind (bool, string, int, or json) and a default. Flags with a rollout_pct also need a rollout value of the same kind; evaluation picks the variant if the user's bucket falls below the percentage, otherwise the default. Anonymous callers never get the rollout variant — the absence of a user_id means there is nothing to bucket. refresh() re-reads the file from disk, so a deploy that rewrites the config in place picks up the new values on the next refresh tick.

The static backend is the right choice when flag state is versioned with the code: you want the same config every time a given commit runs, and you want rollouts to be reproducible in staging without a separate flag service.

@bext/flags-openfeature

Lives at crates/bext-impls/bext-flags-openfeature. Wraps an open_feature::provider::FeatureProvider — the official Rust SDK's provider trait — behind the sync FeatureFlagPlugin. It owns a small multi-thread tokio runtime and calls block_on on the provider's async resolve methods. This is the same pattern @bext/auth-jwt uses for its JWKS fetcher: the plugin side is sync so it crosses the sandbox boundary cleanly; the runtime side lives inside the plugin and is invisible to callers.

Shape inference walks the provider's typed resolve methods in order: resolve_bool_value, then resolve_int_value, then resolve_string_value. The first one that returns Ok wins; a TypeMismatch error means "not this shape, try the next one". FlagNotFound falls back to FlagValue::Bool(false) so missing flags match the OpenFeature semantic without surfacing as errors. This trick keeps the adapter independent of which flags happen to be defined in the provider — you do not have to tell the adapter up-front that new_checkout is a bool and batch_size is an int.

Because the adapter takes any Arc<dyn FeatureProvider>, you can point it at LaunchDarkly's OpenFeature provider, Flagsmith's, Split's, or an in-process one you wrote yourself. The same test suite that ships with the crate uses a tiny hand-rolled in-memory provider to exercise the round-trip.

Configuration

Flag plugins are configured in the [flags] block of bext.config.toml:

[flags]
provider = "@bext/flags-static"
config_path = "./flags.toml"
refresh_interval_sec = 60

For OpenFeature:

[flags]
provider = "@bext/flags-openfeature"
backend = "launchdarkly"      # or "flagsmith", "split", "unleash", ...
sdk_key = "$LAUNCHDARKLY_SDK_KEY"

Only one provider is active at a time. Sites that want fallback behaviour — static defaults when the remote provider is down — compose providers in code rather than in config, so the composition rule is explicit and the fallback is greppable.

Where it fits

Feature Flags are the mid-tier capability that unlocks progressive delivery across the rest of the stack: a site can gate a new route, a new component, a new API handler, or a new database query on a flag without touching the middleware pipeline or the cache layer. The trait intentionally stops at "give me a value"; traffic splitting, canary promotion, experiment analysis, and flag cleanup workflows live on top of it, not inside it. That split is what keeps the capability cheap to add and the vendor landscape interchangeable.