## 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>
13 KiB
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:
BooleanFlagIntegerFlag(i64, including negative values)EvaluationContext(requiredCommunityId, optional actorPublicKey)FlagEvaluator(typedevaluate_boolandevaluate_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-flagsenvironment-feature-flagslaunchdarkly-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.
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):
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, optionalactor), 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
StaticEvaluatoralways returns each flag's declared default.EnvironmentEvaluatorreturns 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
f64values. Integer evaluation accepts only mathematically integral values that convert toi64exactly from thatf64representation; 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:
# 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:
# 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.lockchecked in for the nested--lockedcompile contract. Regenerate it from repo root with:
cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml
- When
buzz-dbadopts flag-gated query selection, add parity tests proving old/new query paths return equivalent rows, ordering, and transactional behavior for the same inputs.