Files
buzz/crates/buzz-feature-flags/README.md
Luke Tornquist 8f6b66f9ff Select one feature flag provider at compile time (#7677)
## Why

Buzz server components need one typed feature-flag contract without
forcing the open-source build to depend on LaunchDarkly. A staging or
production relay must not be able to choose the wrong provider through
runtime configuration.

## What

- Adds typed boolean and signed `i64` flags with static, environment,
and compile-gated LaunchDarkly evaluators.
- Defines an executable relay artifact contract that compiles exactly
one provider.
- Rejects zero or multiple relay provider features at compile time.
- Runs the LaunchDarkly feature tests and the provider compile matrix in
the unit CI lane and fallback test script.

## How

`buzz-feature-flags` stays provider-neutral. The future relay
composition root will expose one build path per artifact feature:
`static-feature-flags`, `environment-feature-flags`, or
`launchdarkly-feature-flags`. Runtime configuration carries only
settings for the selected provider. It never carries a provider name,
fallback, or precedence rule.

The crate still has no relay consumer. A locked compile fixture proves
each singleton builds and every zero or multi-provider combination fails
until the relay adopts this contract.

This removes the proposed runtime `ProviderMode` and leaves one
provider, one construction path, and one evaluator per artifact.

## Risk

This PR still has no relay or app consumer, so it changes no
user-visible behavior. Risk is limited to the new feature-flag crate,
its compile contract, and its unit-test lane.

## Testing

An external Rust consumer at `30cf968bfe85e82980fda7e63f5bebe649ca939f`
exercised six environment-evaluator scenarios and 59 assertions. The
compile-time correction adds no live consumer to exercise manually.

## Bigger picture

When `buzz-relay` adopts this crate, its build manifests must map each
distribution to one provider feature. That consumer PR must verify live
Relay Proxy reachability, secret ingestion, lifecycle ownership, and
user-visible behavior.

Generated with Codex

---------

Signed-off-by: tornquist <tornquist@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:31:08 -04:00

340 lines
13 KiB
Markdown

# buzz-feature-flags
Provider-neutral, typed feature-flag contracts for Buzz server crates.
This crate is infrastructure-only. It defines a small API for flag evaluation,
plus a static evaluator and an optional LaunchDarkly adapter behind a compile
feature.
## Purpose
`buzz-feature-flags` centralizes typed flag evaluation so server crates can use
flags without importing vendor SDK types.
Provider-neutral types:
- `BooleanFlag`
- `IntegerFlag` (`i64`, including negative values)
- `EvaluationContext` (required `CommunityId`, optional actor `PublicKey`)
- `FlagEvaluator` (typed `evaluate_bool` and `evaluate_int`)
- `StaticEvaluator` (always returns declared defaults)
- `EnvironmentEvaluator` (process-wide immutable environment snapshot)
- `EnvironmentDiagnostic` (sanitized malformed-value report)
LaunchDarkly support is optional and compile-gated behind
`buzz-feature-flags/launchdarkly`.
`EnvironmentEvaluator` is provider-neutral and always available (no Cargo
feature and no external dependency).
## Environment Variable Naming
`EnvironmentEvaluator` reads only the fixed namespace
`BUZZ_FEATURE_FLAG_`.
For each flag key:
- uppercase ASCII letters,
- replace each run of non-ASCII-alphanumeric characters with `_`,
- trim leading/trailing `_`,
- prepend `BUZZ_FEATURE_FLAG_`.
Example: `relay.feature.query-v2` → `BUZZ_FEATURE_FLAG_RELAY_FEATURE_QUERY_V2`.
Normalized names must be unique across declared flags. Collisions (for example
`a-b` and `a_b`) intentionally map to the same environment variable. Enforcing
uniqueness belongs in a future flag registry; this crate does not add a
registry yet.
## Relay Artifact Contract (not yet wired into `buzz-relay`)
The intended composition root is the outer relay artifact. It must select
exactly one provider at compile time:
- `static-feature-flags`
- `environment-feature-flags`
- `launchdarkly-feature-flags`
`launchdarkly-feature-flags` is the only relay feature that forwards to
`buzz-feature-flags/launchdarkly`. `static-feature-flags` and
`environment-feature-flags` keep the reusable crate vendor-neutral and still
allow adapter code to compile in tests.
The relay artifact boundary, not this reusable crate, owns exact-one
enforcement. Consumer integration PRs must carry `exact-one compile_error guards`
verbatim/equivalently. That keeps `buzz-feature-flags` free to compile multiple
adapters for tests while ensuring a staging or production relay artifact cannot
choose a different evaluator at runtime.
This repository currently adds the crate and adapter, but does **not** yet wire
relay `AppState` or handlers to consume it. `crates/buzz-relay/Cargo.toml`
still has no dependency on `buzz-feature-flags`, so current workspace/default
builds remain intentionally unchanged. The executable contract for the future
relay integration lives in
`tests/fixtures/relay-feature-selection`.
## Composition Root Shape (artifact boundary)
Construct once at process startup, then inject shared `Arc<dyn FlagEvaluator>`
into server components.
```rust
use std::sync::Arc;
use std::time::Duration;
use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator, StaticEvaluator};
#[cfg(feature = "launchdarkly-feature-flags")]
use buzz_feature_flags::launchdarkly::{
LaunchDarklyEvaluator, LaunchDarklyInitError, LaunchDarklyRuntimeConfig,
LaunchDarklyStartError,
};
#[cfg(not(any(
feature = "static-feature-flags",
feature = "environment-feature-flags",
feature = "launchdarkly-feature-flags",
)))]
compile_error!(
"select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags"
);
#[cfg(any(
all(feature = "static-feature-flags", feature = "environment-feature-flags"),
all(feature = "static-feature-flags", feature = "launchdarkly-feature-flags"),
all(feature = "environment-feature-flags", feature = "launchdarkly-feature-flags"),
))]
compile_error!(
"select exactly one relay feature flag provider feature: static-feature-flags, environment-feature-flags, or launchdarkly-feature-flags"
);
#[cfg(any(feature = "static-feature-flags", feature = "environment-feature-flags"))]
#[derive(Default)]
struct FeatureFlagRuntimeConfig;
#[cfg(feature = "launchdarkly-feature-flags")]
struct FeatureFlagRuntimeConfig {
sdk_key: String,
relay_proxy_endpoint: Option<String>,
}
struct RelayCompositionRoot {
feature_flags: Arc<dyn FlagEvaluator>,
environment_diagnostics: Option<Arc<EnvironmentEvaluator>>,
#[cfg(feature = "launchdarkly-feature-flags")]
launchdarkly_lifecycle: Option<Arc<LaunchDarklyEvaluator>>,
}
#[cfg(feature = "launchdarkly-feature-flags")]
enum RelayStartupError {
LaunchDarklyInit(LaunchDarklyInitError),
LaunchDarklyStart(LaunchDarklyStartError),
}
#[cfg(feature = "static-feature-flags")]
fn build_feature_flag_evaluator(_config: FeatureFlagRuntimeConfig) -> RelayCompositionRoot {
RelayCompositionRoot {
feature_flags: Arc::new(StaticEvaluator),
environment_diagnostics: None,
}
}
#[cfg(feature = "environment-feature-flags")]
fn build_feature_flag_evaluator(config: FeatureFlagRuntimeConfig) -> RelayCompositionRoot {
let _ = config;
let owner = Arc::new(EnvironmentEvaluator::from_process_environment());
let feature_flags: Arc<dyn FlagEvaluator> = owner.clone();
RelayCompositionRoot {
feature_flags,
environment_diagnostics: Some(owner),
}
}
#[cfg(feature = "launchdarkly-feature-flags")]
async fn build_feature_flag_evaluator(
config: FeatureFlagRuntimeConfig,
) -> Result<RelayCompositionRoot, RelayStartupError> {
let mut runtime_config = LaunchDarklyRuntimeConfig::new(config.sdk_key);
if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint {
runtime_config = runtime_config.with_relay_proxy_endpoint(relay_proxy_endpoint);
}
let owner = Arc::new(
LaunchDarklyEvaluator::from_runtime_config(runtime_config)
.map_err(RelayStartupError::LaunchDarklyInit)?,
);
owner
.start_with_default_executor_and_wait(Duration::from_secs(5))
.await
.map_err(RelayStartupError::LaunchDarklyStart)?;
let feature_flags: Arc<dyn FlagEvaluator> = owner.clone();
Ok(RelayCompositionRoot {
feature_flags,
environment_diagnostics: None,
launchdarkly_lifecycle: Some(owner),
})
}
```
Each relay feature produces exactly one `build_feature_flag_evaluator` path, and
runtime config carries only the selected provider's settings. There is no
runtime provider enum, no provider-name environment variable, and no fallback
selector.
Startup is fail-closed: if the selected provider fails initialization (including
`start_with_default_executor_and_wait` for LaunchDarkly), relay startup must
return an error and stop rather than falling back to another evaluator.
The extra concrete `Arc` is an owner/lifecycle handle to that same evaluator,
not a layered provider. Consumers receive only the cloned `Arc<dyn
FlagEvaluator>` view. Retain the environment owner to drain diagnostics after
evaluations, and retain the LaunchDarkly owner so shutdown can call `close()`.
`EnvironmentEvaluator` snapshots environment values once at construction,
retains only `BUZZ_FEATURE_FLAG_` entries, ignores `EvaluationContext`, and
never rereads process environment during evaluation.
For deterministic tests or embedding, `EnvironmentEvaluator::from_pairs(...)`
constructs the same immutable snapshot from explicit key/value pairs.
Environment entries are untyped at construction, so malformed present values
are detected when a typed flag is first evaluated. `take_diagnostics()` drains
each sanitized diagnostic at most once per flag key and expected type. The
composition root should retain the concrete environment evaluator and emit each
drained diagnostic as a warning through the application's logging stack.
Diagnostics contain no raw value or environment-variable name; the value
snapshot itself remains immutable.
## `buzz-db` Boundary (approved correction)
`buzz-db` may evaluate flags internally when choosing between equivalent query
implementations. It should depend on `buzz-feature-flags` without enabling the
LaunchDarkly feature, and it should never construct or import provider types.
Concise example (query path selection only):
```rust
use buzz_core::CommunityId;
use std::sync::Arc;
use buzz_feature_flags::{EvaluationContext, FlagEvaluator, IntegerFlag};
pub struct Db {
feature_flags: Arc<dyn FlagEvaluator>,
}
impl Db {
pub async fn list_events(
&self,
community: CommunityId,
) -> anyhow::Result<Vec<EventRow>> {
let context = EvaluationContext::for_community(community);
let query_version = self.feature_flags.evaluate_int(
IntegerFlag::new("db.events.query-version", 1),
&context,
);
if query_version >= 2 {
self.list_events_v2_sql(community).await
} else {
self.list_events_v1_sql(community).await
}
}
}
```
SQL details are intentionally omitted in this example; the key point is that
public `Db` methods accept only domain inputs, while feature-flag evaluator
wiring remains internal to `Db` construction.
`EvaluationContext::for_actor` is appropriate only when the existing DB
operation already receives an authoritative authenticated actor for domain
behavior. Feature targeting must not add actor/pubkey parameters to otherwise
actor-free DB APIs.
For LaunchDarkly, the adapter context always includes kind `community` plus
optional global kind `pubkey`. Percentage rollouts must explicitly choose a
kind present in evaluation: rollouts must set `contextKind` to a kind present
in evaluation (`community` or `pubkey`) when they are intended to bucket Buzz
traffic. LaunchDarkly rollouts where configuration omits `contextKind` defaults
to `user`; Buzz does not synthesize a `user` alias, so those rollouts bucket as
zero and pick the first positive-weight variation.
## Guardrails
- Evaluation context inputs are authoritative server-resolved values
(`community`, optional `actor`), not client-supplied targeting attributes.
- Flags may choose between **equivalent** implementations only.
- Flags must not weaken authorization, tenant isolation, ordering guarantees,
transaction/consistency behavior, or schema invariants.
- Declared defaults choose the established safe path.
- Integer values must be validated at the consumer boundary before affecting
behavior.
## Fallback, Startup, and Lifecycle
- `StaticEvaluator` always returns each flag's declared default.
- `EnvironmentEvaluator` returns declared defaults when values are missing,
empty, invalid, non-Unicode, or the normalized flag key is unusable. Present
malformed values also produce a sanitized diagnostic; missing values do not.
- LaunchDarkly adapter returns declared defaults when a flag is missing, wrong
type, or evaluation fails.
- LaunchDarkly numbers are SDK `f64` values. Integer evaluation accepts only
mathematically integral values that convert to `i64` exactly from that `f64`
representation; otherwise it returns each flag's declared default.
- Startup chooses one evaluator at compile time (`StaticEvaluator`,
`EnvironmentEvaluator`, or LaunchDarkly); provider precedence/stacking is out
of scope.
- If LaunchDarkly is used, call evaluator `close()` during process shutdown.
`close()` blocks the calling thread while it flushes analytics, so async
shutdown code should offload it to a blocking shutdown path.
- Safety invariants must never rely on remote-flag availability.
## Build and Test Expectations
Build modes:
```bash
# Provider-neutral (default): no LaunchDarkly dependency activated
cargo build -p buzz-feature-flags
# Provider-neutral environment evaluator is included in the default build
cargo test -p buzz-feature-flags --test environment_evaluator
# LaunchDarkly adapter enabled
cargo build -p buzz-feature-flags --features launchdarkly
```
Dependency-graph expectation checks:
```bash
# Default graph should exclude launchdarkly-server-sdk
cargo tree -p buzz-feature-flags
# Feature graph should include launchdarkly-server-sdk
cargo tree -p buzz-feature-flags --features launchdarkly
```
Testing expectations:
- Run provider-neutral tests and LaunchDarkly-feature tests for this crate.
- Run the relay-artifact compile contract in
`tests/relay_feature_selection_contract.rs`, including zero-feature and
multiple-feature rejection.
- Keep `tests/fixtures/relay-feature-selection/Cargo.lock` checked in for the
nested `--locked` compile contract. Regenerate it from repo root with:
```bash
cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml
```
- When `buzz-db` adopts flag-gated query selection, add parity tests proving
old/new query paths return equivalent rows, ordering, and transactional
behavior for the same inputs.
## References
- [ARCHITECTURE.md](../../ARCHITECTURE.md)
- [docs/multi-tenant-relay.md](../../docs/multi-tenant-relay.md)