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>
This commit is contained in:
Luke Tornquist
2026-09-24 15:31:08 -04:00
committed by GitHub
parent 02753722a7
commit 8f6b66f9ff
15 changed files with 5778 additions and 48 deletions

434
Cargo.lock generated
View File

@@ -138,7 +138,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -149,7 +149,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -596,7 +596,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.29.0",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
@@ -918,6 +918,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -1090,7 +1099,7 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"tokio",
"tower",
"tower 0.5.3",
]
[[package]]
@@ -1232,7 +1241,7 @@ dependencies = [
"reqwest 0.13.4",
"rmcp 1.8.0",
"rustls",
"schemars",
"schemars 1.2.1",
"serde",
"serde_json",
"similar",
@@ -1244,6 +1253,19 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "buzz-feature-flags"
version = "0.1.0"
dependencies = [
"buzz-core",
"http",
"launchdarkly-server-sdk",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "buzz-media"
version = "0.1.0"
@@ -1383,7 +1405,7 @@ dependencies = [
"sqlx",
"thiserror 2.0.18",
"tokio",
"tower",
"tower 0.5.3",
"tower-http",
"tracing",
"tracing-subscriber",
@@ -1449,7 +1471,7 @@ dependencies = [
"tokio",
"tokio-tungstenite 0.29.0",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-http",
"tracing",
"tracing-opentelemetry",
@@ -1808,6 +1830,12 @@ dependencies = [
"half",
]
[[package]]
name = "cidr"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "579504560394e388085d0c080ea587dfa5c15f7e251b4d5247d1e1a61d1d6928"
[[package]]
name = "cipher"
version = "0.4.4"
@@ -1912,7 +1940,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2552,7 +2580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [
"data-encoding",
"syn 1.0.109",
"syn 2.0.117",
]
[[package]]
@@ -2585,6 +2613,37 @@ dependencies = [
"tokio",
]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "deltae"
version = "0.3.2"
@@ -2748,7 +2807,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2969,7 +3028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3024,6 +3083,23 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "eventsource-client"
version = "0.17.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96df8cfa11d3c8e4e1a48b81c9c600ecbe8c11d1326f41e1524cc4d42f7bf6d9"
dependencies = [
"base64 0.22.1",
"bytes",
"futures",
"http",
"launchdarkly-sdk-transport",
"log",
"pin-project",
"rand 0.10.1",
"tokio",
]
[[package]]
name = "evmap"
version = "11.0.0"
@@ -3397,7 +3473,7 @@ dependencies = [
"libc",
"log",
"rustversion",
"windows-link 0.1.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
]
@@ -3562,7 +3638,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http",
"indexmap",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
@@ -3595,6 +3671,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -3643,6 +3725,30 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "headers"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64 0.22.1",
"bytes",
"headers-core",
"http",
"httpdate",
"mime",
"sha1 0.10.6",
]
[[package]]
name = "headers-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http",
]
[[package]]
name = "heapify"
version = "0.2.0"
@@ -3921,6 +4027,25 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-http-proxy"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd1d471ea2f65ba45eddb1d6ab7d58ac2671d2d4ac14da5c6516ae1d97e1327a"
dependencies = [
"bytes",
"futures-util",
"headers",
"http",
"hyper",
"hyper-rustls",
"hyper-util",
"pin-project-lite",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
@@ -4203,6 +4328,17 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -4441,7 +4577,7 @@ dependencies = [
"iroh-base",
"iroh-dns",
"iroh-metrics",
"lru",
"lru 0.18.0",
"n0-error",
"n0-future",
"noq",
@@ -4487,6 +4623,60 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-link 0.2.1",
]
[[package]]
name = "jiff-core"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt",
"log",
]
[[package]]
name = "jiff-static"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -4717,7 +4907,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-http",
"tracing",
]
@@ -4745,6 +4935,77 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
[[package]]
name = "launchdarkly-sdk-transport"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "032d66802c461d7254ed1de887be6ca98ebbe126867d9a60c754baae81c4d10d"
dependencies = [
"bytes",
"futures",
"http",
"http-body-util",
"hyper",
"hyper-http-proxy",
"hyper-rustls",
"hyper-timeout",
"hyper-util",
"log",
"no-proxy",
"tower 0.4.13",
]
[[package]]
name = "launchdarkly-server-sdk"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22259877ec581fa56a9f0b982e583e16fa01b71ba6d3f786ef5ccfc6ca6f92ff"
dependencies = [
"aws-lc-rs",
"bitflags 2.13.0",
"bytes",
"chrono",
"crossbeam-channel",
"data-encoding",
"eventsource-client",
"flate2",
"futures",
"http",
"launchdarkly-sdk-transport",
"launchdarkly-server-sdk-evaluation",
"log",
"lru 0.16.4",
"moka",
"parking_lot",
"rand 0.9.4",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"uuid",
]
[[package]]
name = "launchdarkly-server-sdk-evaluation"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76560a130b2a7e7fdb9c56043168844cde483e08e7f0e4a5e178e3888edc4336"
dependencies = [
"base16ct 0.2.0",
"chrono",
"itertools",
"lazy_static",
"log",
"maplit",
"regex",
"semver",
"serde",
"serde_json",
"serde_with",
"sha1 0.10.6",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -4904,6 +5165,12 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "lru"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
[[package]]
name = "lru"
version = "0.18.0"
@@ -4960,6 +5227,12 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -5277,7 +5550,7 @@ dependencies = [
"rpassword",
"rustls",
"same-file",
"schemars",
"schemars 1.2.1",
"semver",
"serde",
"serde_json",
@@ -5386,7 +5659,7 @@ dependencies = [
"prost-build 0.14.3",
"protoc-bin-vendored",
"rmcp 3.2.0",
"schemars",
"schemars 1.2.1",
"serde",
"serde_json",
"tokio",
@@ -5590,7 +5863,7 @@ dependencies = [
"hyper",
"hyper-rustls",
"hyper-util",
"indexmap",
"indexmap 2.14.0",
"ipnet",
"metrics",
"metrics-util",
@@ -5611,7 +5884,7 @@ dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"hashbrown 0.16.1",
"indexmap",
"indexmap 2.14.0",
"metrics",
"ordered-float 5.3.0",
"quanta",
@@ -6089,6 +6362,15 @@ dependencies = [
"libc",
]
[[package]]
name = "no-proxy"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd3f71e44f009513a82ac2612c0c2da7e2924fad776931164317baa9695e150"
dependencies = [
"cidr",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -6241,7 +6523,7 @@ dependencies = [
"async-wsocket",
"faster-hex",
"futures",
"lru",
"lru 0.18.0",
"negentropy",
"nostr 0.45.1",
"nostr-database",
@@ -6269,7 +6551,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6986,7 +7268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset 0.5.7",
"indexmap",
"indexmap 2.14.0",
]
[[package]]
@@ -6997,7 +7279,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset 0.5.7",
"hashbrown 0.15.5",
"indexmap",
"indexmap 2.14.0",
]
[[package]]
@@ -7143,7 +7425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap",
"indexmap 2.14.0",
"quick-xml 0.39.4",
"serde",
"time",
@@ -7359,7 +7641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55"
dependencies = [
"futures",
"indexmap",
"indexmap 2.14.0",
"nix 0.31.3",
"tokio",
"tracing",
@@ -7890,7 +8172,7 @@ dependencies = [
"hashbrown 0.17.1",
"itertools",
"kasuari",
"lru",
"lru 0.18.0",
"palette",
"serde",
"strum",
@@ -8167,7 +8449,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-http",
"tower-service",
"url",
@@ -8214,7 +8496,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-http",
"tower-service",
"url",
@@ -8282,7 +8564,7 @@ dependencies = [
"pin-project-lite",
"process-wrap",
"rmcp-macros 1.8.0",
"schemars",
"schemars 1.2.1",
"serde",
"serde_json",
"thiserror 2.0.18",
@@ -8306,14 +8588,14 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"indexmap",
"indexmap 2.14.0",
"pastey",
"pin-project-lite",
"process-wrap",
"rand 0.10.1",
"reqwest 0.13.4",
"rmcp-macros 3.2.0",
"schemars",
"schemars 1.2.1",
"serde",
"serde_json",
"sse-stream",
@@ -8465,7 +8747,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8524,7 +8806,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8634,6 +8916,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.1"
@@ -8807,7 +9101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8951,13 +9245,46 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
"jiff",
"schemars 0.9.0",
"schemars 1.2.1",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
@@ -9365,7 +9692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9474,7 +9801,7 @@ dependencies = [
"futures-util",
"hashbrown 0.16.1",
"hashlink",
"indexmap",
"indexmap 2.14.0",
"log",
"memchr",
"percent-encoding",
@@ -9511,7 +9838,7 @@ dependencies = [
"futures-util",
"hashbrown 0.16.1",
"hashlink",
"indexmap",
"indexmap 2.14.0",
"log",
"memchr",
"percent-encoding",
@@ -10035,7 +10362,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -10048,7 +10375,7 @@ dependencies = [
"parking_lot",
"rustix",
"signal-hook",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -10446,7 +10773,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"serde_core",
"serde_spanned",
"toml_datetime",
@@ -10470,7 +10797,7 @@ version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"toml_datetime",
"toml_parser",
"toml_writer",
@@ -10516,7 +10843,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-stream",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
@@ -10544,6 +10871,17 @@ dependencies = [
"tonic",
]
[[package]]
name = "tower"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -10552,7 +10890,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"indexmap",
"indexmap 2.14.0",
"pin-project-lite",
"slab",
"sync_wrapper",
@@ -10586,7 +10924,7 @@ dependencies = [
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
@@ -10790,7 +11128,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -11353,7 +11691,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -12277,7 +12615,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"indexmap 2.14.0",
"memchr",
"typed-path",
"zopfli",

View File

@@ -17,6 +17,7 @@ members = [
"crates/buzz-ws-client",
"crates/buzz-admin",
"crates/buzz-deletion",
"crates/buzz-feature-flags",
"crates/buzz-workflow",
"crates/buzz-media",
"crates/buzz-cli",
@@ -142,6 +143,7 @@ buzz-core = { path = "crates/buzz-core" }
buzz-conformance = { path = "crates/buzz-conformance" }
buzz-db = { path = "crates/buzz-db" }
buzz-deletion = { path = "crates/buzz-deletion" }
buzz-feature-flags = { path = "crates/buzz-feature-flags" }
buzz-auth = { path = "crates/buzz-auth" }
buzz-pubsub = { path = "crates/buzz-pubsub" }
buzz-search = { path = "crates/buzz-search" }

View File

@@ -414,6 +414,10 @@ test-unit:
# because nothing in CI runs `cargo test --workspace` — workspace
# membership alone buys clippy/check, not a single executed test.
cargo nextest run -p buzz-backend-kubernetes
# Feature-flag crate coverage: run once with LaunchDarkly enabled.
# This includes all default tests plus the cfg(feature="launchdarkly")
# tests, avoiding duplicate default-only execution in the unit lane.
cargo nextest run -p buzz-feature-flags --features launchdarkly
# buzz-agent: two infra-free concerns run together by executing the
# whole crate (lib + integration tests), because nothing in CI runs
# `cargo test --workspace`, so without this stanza neither the crate's

View File

@@ -0,0 +1,22 @@
[package]
name = "buzz-feature-flags"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Typed feature-flag contracts and evaluators for Buzz"
[features]
launchdarkly = ["dep:launchdarkly-server-sdk"]
[dependencies]
buzz-core = { workspace = true }
http = "1"
launchdarkly-server-sdk = { version = "=3.2.0", optional = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
tempfile = "3"
tokio = { workspace = true }

View File

@@ -0,0 +1,339 @@
# 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)

View File

@@ -0,0 +1,321 @@
//! Process-wide environment-backed feature-flag evaluator.
//!
//! Environment variables use the fixed `BUZZ_FEATURE_FLAG_` namespace and a
//! normalized form of each flag key.
//!
//! Normalization algorithm:
//! - Uppercase ASCII letters.
//! - Replace each run of non-ASCII-alphanumeric characters with `_`.
//! - Trim leading and trailing `_`.
//!
//! If normalization yields an empty key segment, evaluation falls back to the
//! flag-declared default.
//!
//! Different flag keys can normalize to the same environment variable name
//! (for example `a-b` and `a_b`), so callers must keep normalized names unique.
use std::{
collections::{BTreeSet, HashMap},
ffi::OsString,
fmt::{Debug, Formatter},
sync::{Arc, Mutex},
};
use crate::{BooleanFlag, EvaluationContext, FlagEvaluator, IntegerFlag};
const ENV_NAMESPACE: &str = "BUZZ_FEATURE_FLAG_";
#[derive(Clone)]
enum SnapshotValue {
Unicode(String),
NonUnicode,
}
/// Sanitized diagnostic produced for a malformed configured environment value.
///
/// Diagnostics identify only the provider-neutral flag key and expected type.
/// They never contain raw values or environment-variable names.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum EnvironmentDiagnostic {
/// A present value could not be evaluated as a boolean.
InvalidBooleanValue {
/// Stable provider-neutral flag key.
flag_key: &'static str,
},
/// A present value could not be evaluated as a signed integer.
InvalidIntegerValue {
/// Stable provider-neutral flag key.
flag_key: &'static str,
},
}
#[derive(Default)]
struct DiagnosticState {
seen: BTreeSet<EnvironmentDiagnostic>,
pending: BTreeSet<EnvironmentDiagnostic>,
}
/// Process-wide feature-flag evaluator backed by an immutable environment
/// snapshot.
///
/// This evaluator captures key/value pairs once at construction and never
/// rereads or mutates process environment state afterward. Evaluation ignores
/// [`EvaluationContext`] because all values are process-wide.
#[derive(Clone)]
pub struct EnvironmentEvaluator {
snapshot: HashMap<String, SnapshotValue>,
diagnostics: Arc<Mutex<DiagnosticState>>,
}
impl Default for EnvironmentEvaluator {
fn default() -> Self {
Self::from_process_environment()
}
}
impl EnvironmentEvaluator {
/// Capture a one-time snapshot of the current process environment.
///
/// Only namespaced entries are retained. Entries with non-Unicode names are
/// ignored; non-Unicode values are retained only as invalid-value markers.
pub fn from_process_environment() -> Self {
Self::from_pairs(std::env::vars_os())
}
/// Build an evaluator from a deterministic key/value snapshot.
///
/// Only entries whose names start with `BUZZ_FEATURE_FLAG_` are retained.
/// Entries with non-Unicode names are ignored; non-Unicode values are
/// retained only as invalid-value markers.
pub fn from_pairs<I, K, V>(pairs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<OsString>,
V: Into<OsString>,
{
let mut snapshot = HashMap::new();
for (name, value) in pairs {
let name = name.into();
let Some(name) = name.to_str() else {
continue;
};
if !name.starts_with(ENV_NAMESPACE) {
continue;
}
let value = match value.into().into_string() {
Ok(value) => SnapshotValue::Unicode(value),
Err(_) => SnapshotValue::NonUnicode,
};
snapshot.insert(name.to_owned(), value);
}
Self {
snapshot,
diagnostics: Arc::new(Mutex::new(DiagnosticState::default())),
}
}
/// Drain sanitized diagnostics that have not previously been reported.
///
/// Each malformed value is returned at most once per flag key and expected
/// type, including across cloned evaluators. Missing values do not produce
/// diagnostics.
pub fn take_diagnostics(&self) -> Vec<EnvironmentDiagnostic> {
let mut state = match self.diagnostics.lock() {
Ok(state) => state,
Err(poisoned) => poisoned.into_inner(),
};
std::mem::take(&mut state.pending).into_iter().collect()
}
fn lookup(&self, flag_key: &str) -> Option<&SnapshotValue> {
let env_name = normalize_flag_env_name(flag_key)?;
self.snapshot.get(&env_name)
}
fn record_diagnostic(&self, diagnostic: EnvironmentDiagnostic) {
let mut state = match self.diagnostics.lock() {
Ok(state) => state,
Err(poisoned) => poisoned.into_inner(),
};
if state.seen.insert(diagnostic) {
state.pending.insert(diagnostic);
}
}
}
impl Debug for EnvironmentEvaluator {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EnvironmentEvaluator")
.field("namespace", &ENV_NAMESPACE)
.field("entry_count", &self.snapshot.len())
.field("entries", &"[REDACTED]")
.finish()
}
}
impl FlagEvaluator for EnvironmentEvaluator {
fn evaluate_bool(&self, flag: BooleanFlag, _context: &EvaluationContext) -> bool {
let Some(value) = self.lookup(flag.key()) else {
return flag.default();
};
if let SnapshotValue::Unicode(value) = value {
if let Some(value) = parse_bool(value) {
return value;
}
}
self.record_diagnostic(EnvironmentDiagnostic::InvalidBooleanValue {
flag_key: flag.key(),
});
flag.default()
}
fn evaluate_int(&self, flag: IntegerFlag, _context: &EvaluationContext) -> i64 {
let Some(value) = self.lookup(flag.key()) else {
return flag.default();
};
if let SnapshotValue::Unicode(value) = value {
if let Some(value) = parse_i64(value) {
return value;
}
}
self.record_diagnostic(EnvironmentDiagnostic::InvalidIntegerValue {
flag_key: flag.key(),
});
flag.default()
}
}
fn parse_bool(raw: &str) -> Option<bool> {
let trimmed = raw.trim();
if trimmed.eq_ignore_ascii_case("true") {
Some(true)
} else if trimmed.eq_ignore_ascii_case("false") {
Some(false)
} else {
None
}
}
fn parse_i64(raw: &str) -> Option<i64> {
raw.trim().parse::<i64>().ok()
}
fn normalize_flag_env_name(flag_key: &str) -> Option<String> {
let mut normalized = String::with_capacity(flag_key.len());
let mut in_separator = false;
for character in flag_key.chars() {
if character.is_ascii_alphanumeric() {
normalized.push(character.to_ascii_uppercase());
in_separator = false;
} else if !in_separator {
normalized.push('_');
in_separator = true;
}
}
let trimmed = normalized.trim_matches('_');
if trimmed.is_empty() {
return None;
}
Some(format!("{ENV_NAMESPACE}{trimmed}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BooleanFlag, EvaluationContext};
use buzz_core::CommunityId;
fn community(id: &str) -> CommunityId {
CommunityId::from_uuid(id.parse().expect("valid community UUID"))
}
#[test]
fn normalization_uses_fixed_namespace_and_collapses_separators() {
assert_eq!(
normalize_flag_env_name("relay.feature.query-v2"),
Some("BUZZ_FEATURE_FLAG_RELAY_FEATURE_QUERY_V2".to_owned())
);
assert_eq!(
normalize_flag_env_name("___relay..foo---bar***baz??9___"),
Some("BUZZ_FEATURE_FLAG_RELAY_FOO_BAR_BAZ_9".to_owned())
);
}
#[test]
fn normalization_rejects_empty_segment() {
assert_eq!(normalize_flag_env_name(""), None);
assert_eq!(normalize_flag_env_name("---***___"), None);
}
#[test]
fn snapshot_retains_only_namespaced_entries() {
let evaluator = EnvironmentEvaluator::from_pairs([
("BUZZ_FEATURE_FLAG_RELAY_ENABLED", "true"),
("DATABASE_URL", "sensitive"),
]);
assert_eq!(evaluator.snapshot.len(), 1);
assert!(evaluator
.snapshot
.contains_key("BUZZ_FEATURE_FLAG_RELAY_ENABLED"));
}
#[cfg(unix)]
#[test]
fn non_unicode_names_are_ignored_and_values_become_invalid_markers() {
use std::os::unix::ffi::OsStringExt;
let evaluator = EnvironmentEvaluator::from_pairs([
(
OsString::from("BUZZ_FEATURE_FLAG_RELAY_NON_UNICODE_NAME"),
OsString::from_vec(vec![0x80]),
),
(OsString::from_vec(vec![0x81]), OsString::from("true")),
]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(
evaluator.evaluate_bool(BooleanFlag::new("relay.non-unicode-name", true), &context,)
);
assert_eq!(
evaluator.take_diagnostics(),
vec![EnvironmentDiagnostic::InvalidBooleanValue {
flag_key: "relay.non-unicode-name",
}]
);
}
#[test]
fn poisoned_diagnostic_mutex_recovers_without_panicking() {
let evaluator = EnvironmentEvaluator::from_pairs([(
"BUZZ_FEATURE_FLAG_RELAY_POISONED",
"not-a-boolean",
)]);
let diagnostics = Arc::clone(&evaluator.diagnostics);
let poison_result = std::thread::spawn(move || {
let _guard = diagnostics.lock().expect("diagnostic mutex lock");
panic!("poison diagnostic mutex");
})
.join();
assert!(poison_result.is_err());
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.poisoned", true), &context));
assert_eq!(
evaluator.take_diagnostics(),
vec![EnvironmentDiagnostic::InvalidBooleanValue {
flag_key: "relay.poisoned",
}]
);
}
}

View File

@@ -0,0 +1,49 @@
//! Typed feature-flag keys and declared defaults.
/// Typed boolean feature definition.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BooleanFlag {
key: &'static str,
default: bool,
}
impl BooleanFlag {
/// Construct a typed boolean flag from its stable key and declared default.
pub const fn new(key: &'static str, default: bool) -> Self {
Self { key, default }
}
/// Stable key for this flag.
pub const fn key(self) -> &'static str {
self.key
}
/// Declared default used when an evaluator is unavailable or cannot return a value.
pub const fn default(self) -> bool {
self.default
}
}
/// Typed integer feature definition.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct IntegerFlag {
key: &'static str,
default: i64,
}
impl IntegerFlag {
/// Construct a typed integer flag from its stable key and declared default.
pub const fn new(key: &'static str, default: i64) -> Self {
Self { key, default }
}
/// Stable key for this flag.
pub const fn key(self) -> &'static str {
self.key
}
/// Declared default used when an evaluator is unavailable or cannot return a value.
pub const fn default(self) -> i64 {
self.default
}
}

View File

@@ -0,0 +1,953 @@
use http::{uri::Authority, Uri};
use launchdarkly_server_sdk::{
BuildError, Client, ConfigBuildError, ConfigBuilder, Context, ContextBuilder,
MultiContextBuilder,
};
use std::time::Duration;
use thiserror::Error;
use crate::{BooleanFlag, EvaluationContext, FlagEvaluator, IntegerFlag};
const COMMUNITY_CONTEXT_KIND: &str = "community";
const PUBKEY_CONTEXT_KIND: &str = "pubkey";
/// Runtime inputs required to initialize a LaunchDarkly-backed evaluator.
#[derive(Clone)]
pub struct LaunchDarklyRuntimeConfig {
/// SDK key used to authenticate with LaunchDarkly.
pub sdk_key: String,
/// Optional Relay Proxy endpoint used for all LaunchDarkly service traffic.
pub relay_proxy_endpoint: Option<String>,
}
impl std::fmt::Debug for LaunchDarklyRuntimeConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LaunchDarklyRuntimeConfig")
.field("sdk_key", &"[REDACTED]")
.field("relay_proxy_endpoint", &self.relay_proxy_endpoint)
.finish()
}
}
impl LaunchDarklyRuntimeConfig {
/// Create a runtime config from explicit caller-provided values.
pub fn new(sdk_key: impl Into<String>) -> Self {
Self {
sdk_key: sdk_key.into(),
relay_proxy_endpoint: None,
}
}
/// Set the optional Relay Proxy endpoint.
pub fn with_relay_proxy_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.relay_proxy_endpoint = Some(endpoint.into());
self
}
}
/// Errors building a LaunchDarkly evaluator from runtime inputs.
#[derive(Debug, Error)]
pub enum LaunchDarklyInitError {
/// Caller provided an empty SDK key.
#[error("launchdarkly sdk key must not be empty")]
EmptySdkKey,
/// Caller provided an empty relay-proxy endpoint.
#[error("launchdarkly relay proxy endpoint must not be empty")]
EmptyRelayProxyEndpoint,
/// Caller provided an invalid relay-proxy endpoint.
#[error("launchdarkly relay proxy endpoint must be an absolute http(s) URI with authority")]
InvalidRelayProxyEndpoint(InvalidRelayProxyEndpointReason),
/// LaunchDarkly SDK config build failed.
#[error("launchdarkly config build failed: {0}")]
ConfigBuild(#[from] ConfigBuildError),
/// LaunchDarkly SDK client build failed.
#[error("launchdarkly client build failed: {0}")]
ClientBuild(#[from] BuildError),
}
/// Why a relay-proxy endpoint failed pre-validation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InvalidRelayProxyEndpointReason {
/// Endpoint could not be parsed as a URI.
MalformedUri,
/// Endpoint scheme is not `http` or `https`.
InvalidScheme,
/// Endpoint has no authority section (`host[:port]`).
MissingAuthority,
}
/// Errors starting a LaunchDarkly evaluator and waiting for initialization.
#[derive(Debug, Error)]
pub enum LaunchDarklyStartError {
/// LaunchDarkly client did not initialize before timeout.
#[error("launchdarkly initialization timed out after {0:?}")]
InitializationTimeout(Duration),
/// LaunchDarkly client reported failed initialization.
#[error("launchdarkly initialization failed")]
InitializationFailed,
}
/// Feature-flag evaluator backed by the LaunchDarkly Rust server SDK.
pub struct LaunchDarklyEvaluator {
client: Client,
}
impl std::fmt::Debug for LaunchDarklyEvaluator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LaunchDarklyEvaluator").finish()
}
}
impl LaunchDarklyEvaluator {
/// Wrap an already-constructed LaunchDarkly client.
pub(crate) fn new(client: Client) -> Self {
Self { client }
}
/// Build a LaunchDarkly client from explicit runtime configuration.
pub fn from_runtime_config(
config: LaunchDarklyRuntimeConfig,
) -> Result<Self, LaunchDarklyInitError> {
let sdk_key = config.sdk_key.trim();
if sdk_key.is_empty() {
return Err(LaunchDarklyInitError::EmptySdkKey);
}
let mut builder = ConfigBuilder::new(sdk_key);
if let Some(relay_proxy_endpoint) = config.relay_proxy_endpoint {
if relay_proxy_endpoint.trim().is_empty() {
return Err(LaunchDarklyInitError::EmptyRelayProxyEndpoint);
}
let relay_proxy_endpoint = validate_relay_proxy_endpoint(&relay_proxy_endpoint)?;
let mut service_endpoints = launchdarkly_server_sdk::ServiceEndpointsBuilder::new();
service_endpoints.relay_proxy(&relay_proxy_endpoint);
builder = builder.service_endpoints(&service_endpoints);
}
let config = builder.build()?;
let client = Client::build(config)?;
Ok(Self::new(client))
}
/// Start background tasks and wait for SDK initialization.
///
/// This method must run inside a Tokio runtime.
pub async fn start_with_default_executor_and_wait(
&self,
timeout: Duration,
) -> Result<(), LaunchDarklyStartError> {
self.client.start_with_default_executor();
match self.client.wait_for_initialization(timeout).await {
Some(true) => Ok(()),
Some(false) => Err(LaunchDarklyStartError::InitializationFailed),
None => Err(LaunchDarklyStartError::InitializationTimeout(timeout)),
}
}
/// Gracefully stop background tasks and flush pending analytics events.
///
/// This call blocks the current thread until pending analytics delivery
/// completes. Async callers should run it in a blocking shutdown path.
pub fn close(&self) {
self.client.close();
}
}
impl FlagEvaluator for LaunchDarklyEvaluator {
fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool {
let context = match launchdarkly_context(context) {
Ok(context) => context,
Err(_) => return flag.default(),
};
self.client
.bool_variation(&context, flag.key(), flag.default())
}
fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 {
let context = match launchdarkly_context(context) {
Ok(context) => context,
Err(_) => return flag.default(),
};
let detail =
self.client
.float_variation_detail(&context, flag.key(), flag.default() as f64);
if detail.variation_index.is_none() {
return flag.default();
}
let Some(value) = detail.value else {
return flag.default();
};
strict_f64_to_i64(value).unwrap_or_else(|| flag.default())
}
}
fn strict_f64_to_i64(value: f64) -> Option<i64> {
if !value.is_finite() || value.fract() != 0.0 {
return None;
}
i64::try_from(value as i128).ok()
}
fn validate_relay_proxy_endpoint(endpoint: &str) -> Result<String, LaunchDarklyInitError> {
if endpoint.trim() != endpoint {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MalformedUri,
));
}
if endpoint.contains('#') {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MalformedUri,
));
}
let parsed = endpoint.parse::<Uri>().map_err(|_| {
LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MalformedUri,
)
})?;
if !matches!(parsed.scheme_str(), Some("http" | "https")) {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::InvalidScheme,
));
}
if parsed.query().is_some() {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MalformedUri,
));
}
let Some(authority) = parsed.authority() else {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MissingAuthority,
));
};
if authority.host().is_empty() {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MissingAuthority,
));
}
if authority_has_explicit_port(authority) && authority.port_u16().is_none() {
return Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::MalformedUri,
));
}
Ok(endpoint.to_owned())
}
fn authority_has_explicit_port(authority: &Authority) -> bool {
let authority = authority
.as_str()
.rsplit_once('@')
.map_or_else(|| authority.as_str(), |(_, host_and_port)| host_and_port);
if authority.starts_with('[') {
return authority.contains("]:");
}
authority
.rsplit_once(':')
.map(|(_, port)| !port.is_empty())
.unwrap_or(false)
}
/// Build the LaunchDarkly context used for evaluation.
///
/// The adapter always includes a `community` kind context and optionally adds a
/// global `pubkey` kind context when an actor is present.
fn launchdarkly_context(context: &EvaluationContext) -> Result<Context, String> {
let mut community = ContextBuilder::new(context.community().to_string());
community.kind(COMMUNITY_CONTEXT_KIND);
let community = community.build()?;
let Some(actor_pubkey) = context.actor_pubkey() else {
return Ok(community);
};
let mut actor = ContextBuilder::new(actor_pubkey.to_hex());
actor.kind(PUBKEY_CONTEXT_KIND);
let actor = actor.build()?;
MultiContextBuilder::of(vec![community, actor]).build()
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::{CommunityId, PublicKey};
use launchdarkly_server_sdk::{
Config, Flag, FlagBuilder, FlagValue, Kind, NullEventProcessorBuilder, TestData,
};
fn community(id: &str) -> CommunityId {
CommunityId::from_uuid(id.parse().expect("valid community UUID"))
}
fn actor() -> PublicKey {
PublicKey::from_hex("5581946f95a03e6afb43027ec89b21507f040d35fd6f8594f168f4298f96f9cb")
.expect("valid pubkey")
}
fn kind(name: &'static str) -> Kind {
Kind::try_from(name).expect("valid LaunchDarkly context kind")
}
fn weighted_rollout_flag(key: &str, context_kind: Option<&str>) -> Flag {
let context_kind_json = context_kind.map_or_else(String::new, |context_kind| {
format!(",\"contextKind\":\"{context_kind}\"")
});
serde_json::from_str(&format!(
r#"{{
"key": "{key}",
"version": 1,
"on": true,
"targets": [],
"rules": [],
"prerequisites": [],
"fallthrough": {{
"rollout": {{
"variations": [
{{ "variation": 0, "weight": 1 }},
{{ "variation": 1, "weight": 99999 }}
]
{context_kind_json}
}}
}},
"offVariation": 0,
"variations": [false, true],
"clientSideAvailability": {{
"usingMobileKey": false,
"usingEnvironmentId": false
}},
"salt": "saltyA"
}}"#
))
.expect("valid preconfigured weighted rollout flag")
}
fn test_config(test_data: &TestData) -> Config {
ConfigBuilder::new("sdk-key")
.data_source(test_data)
.event_processor(&NullEventProcessorBuilder::new())
.build()
.expect("launchdarkly test config")
}
fn default_test_config(test_data: &TestData) -> Config {
ConfigBuilder::new("sdk-key")
.data_source(test_data)
.build()
.expect("launchdarkly default test config")
}
async fn started_evaluator(test_data: &TestData) -> LaunchDarklyEvaluator {
let config = test_config(test_data);
let client = Client::build(config).expect("launchdarkly test client");
let evaluator = LaunchDarklyEvaluator::new(client);
evaluator
.start_with_default_executor_and_wait(Duration::from_secs(1))
.await
.expect("launchdarkly initialized");
evaluator
}
#[test]
fn launchdarkly_test_config_uses_null_event_processor() {
let test_data = TestData::new();
let config = test_config(&test_data);
let default_config = default_test_config(&test_data);
let endpoints = launchdarkly_server_sdk::ServiceEndpointsBuilder::new()
.build()
.expect("default service endpoints");
let event_processor = config
.event_processor_builder()
.build(&endpoints, config.sdk_key(), None)
.expect("event processor build");
let default_event_processor = default_config
.event_processor_builder()
.build(&endpoints, default_config.sdk_key(), None)
.expect("default event processor build");
event_processor.close();
default_event_processor.close();
assert!(
event_processor.flush_blocking(Duration::from_millis(10)),
"null event processor should remain a no-op after close"
);
assert!(
!default_event_processor.flush_blocking(Duration::from_millis(10)),
"default event processor should fail flush_blocking after close"
);
}
#[test]
fn runtime_config_debug_redacts_sdk_key() {
let config = LaunchDarklyRuntimeConfig::new("super-secret-sdk-key")
.with_relay_proxy_endpoint("https://relay.internal");
let debug = format!("{config:?}");
assert!(!debug.contains("super-secret-sdk-key"));
assert!(debug.contains("[REDACTED]"));
assert!(debug.contains("https://relay.internal"));
}
#[test]
fn malformed_relay_proxy_endpoint_returns_typed_error_without_panic() {
let result = std::panic::catch_unwind(|| {
LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key")
.with_relay_proxy_endpoint("not a valid endpoint"),
)
});
assert!(
result.is_ok(),
"invalid endpoint should return Err, not panic"
);
assert!(matches!(
result.expect("catch_unwind result"),
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
));
}
#[test]
fn relay_proxy_endpoint_rejects_non_http_scheme_and_missing_authority() {
let invalid_scheme = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key")
.with_relay_proxy_endpoint("ftp://relay.internal:8030"),
);
assert!(matches!(
invalid_scheme,
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(
InvalidRelayProxyEndpointReason::InvalidScheme
))
));
for endpoint in [
"https://",
"http:///missing-authority",
"https:///still-missing-authority",
"https://:8030/path-prefix",
] {
let result = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
);
assert!(matches!(
result,
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
));
}
}
#[test]
fn relay_proxy_endpoint_rejects_query_fragment_and_invalid_ports() {
let invalid_cases = [
"https://relay.internal/path?env=staging",
"https://relay.internal/path#fragment",
"https://relay.internal:notaport/path-prefix",
"https://relay.internal:99999/path-prefix",
];
for endpoint in invalid_cases {
let error = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
)
.expect_err("endpoint should be rejected");
assert!(matches!(
error,
LaunchDarklyInitError::InvalidRelayProxyEndpoint(_)
));
assert!(!format!("{error}").contains(endpoint));
assert!(!format!("{error:?}").contains(endpoint));
}
}
#[test]
fn relay_proxy_endpoint_rejects_leading_or_trailing_whitespace() {
for endpoint in [
" https://relay.internal:8030",
"https://relay.internal:8030 ",
"\thttps://relay.internal:8030",
"https://relay.internal:8030\n",
] {
let result = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
);
assert!(matches!(
result,
Err(LaunchDarklyInitError::InvalidRelayProxyEndpoint(_))
));
}
}
#[test]
fn relay_proxy_endpoint_accepts_absolute_http_and_https_uris() {
let valid_cases = [
"http://relay.internal:8030",
"https://relay.internal",
"https://relay.internal:8443/path-prefix",
];
for endpoint in valid_cases {
let result = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
);
assert!(
result.is_ok(),
"expected valid endpoint {endpoint} to be accepted"
);
}
}
#[test]
fn relay_proxy_endpoint_error_does_not_leak_raw_endpoint_value() {
let endpoint = "https://bad endpoint with spaces";
let error = LaunchDarklyEvaluator::from_runtime_config(
LaunchDarklyRuntimeConfig::new("sdk-key").with_relay_proxy_endpoint(endpoint),
)
.expect_err("invalid endpoint should fail");
assert!(!format!("{error}").contains(endpoint));
assert!(!format!("{error:?}").contains(endpoint));
}
#[tokio::test]
async fn community_only_context_targets_named_community_kind() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("community-only")
.fallthrough_variation(false)
.variation_for_key(kind("community"), community.to_string(), true),
);
let evaluator = started_evaluator(&test_data).await;
assert!(evaluator.evaluate_bool(
BooleanFlag::new("community-only", false),
&EvaluationContext::for_community(community),
));
evaluator.close();
}
#[tokio::test]
async fn actor_context_targets_community_and_pubkey_kinds() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let actor = actor();
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("by-community")
.fallthrough_variation(false)
.variation_for_key(kind("community"), community.to_string(), true),
);
test_data.update(
FlagBuilder::new("by-pubkey")
.fallthrough_variation(false)
.variation_for_key(kind("pubkey"), actor.to_hex(), true),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(community, actor);
assert!(evaluator.evaluate_bool(BooleanFlag::new("by-community", false), &context));
assert!(evaluator.evaluate_bool(BooleanFlag::new("by-pubkey", false), &context));
evaluator.close();
}
#[tokio::test]
async fn same_actor_can_receive_different_variations_by_community() {
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
let actor = actor();
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("community-rollout")
.fallthrough_variation(false)
.variation_for_key(kind("community"), community_a.to_string(), true),
);
let evaluator = started_evaluator(&test_data).await;
let flag = BooleanFlag::new("community-rollout", false);
assert!(evaluator.evaluate_bool(flag, &EvaluationContext::for_actor(community_a, actor),));
assert!(!evaluator.evaluate_bool(flag, &EvaluationContext::for_actor(community_b, actor),));
evaluator.close();
}
#[tokio::test]
async fn community_kind_weighted_rollout_uses_community_key_bucketing() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let test_data = TestData::new();
test_data.use_preconfigured_flag(weighted_rollout_flag(
"community-weighted-rollout",
Some("community"),
));
test_data
.use_preconfigured_flag(weighted_rollout_flag("missing-context-kind-rollout", None));
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_community(community);
assert!(evaluator.evaluate_bool(
BooleanFlag::new("community-weighted-rollout", false),
&context,
));
assert!(!evaluator.evaluate_bool(
BooleanFlag::new("missing-context-kind-rollout", true),
&context,
));
evaluator.close();
}
#[tokio::test]
async fn missing_flag_falls_back_to_declared_default() {
let test_data = TestData::new();
let evaluator = started_evaluator(&test_data).await;
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.feature.missing", true), &context,));
evaluator.close();
}
#[tokio::test]
async fn wrong_type_falls_back_to_declared_default() {
let test_data = TestData::new();
test_data.update(FlagBuilder::new("relay.feature.bool").variation_for_all(true));
test_data.update(
FlagBuilder::new("relay.feature.string")
.value_for_all(FlagValue::Str("red".to_owned())),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.feature.bool", false), &context,));
assert!(
!evaluator.evaluate_bool(BooleanFlag::new("relay.feature.string", false), &context,)
);
evaluator.close();
}
#[tokio::test]
async fn community_only_context_targets_integer_variation() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("community-int")
.variations([FlagValue::from(0_i64), FlagValue::from(13_i64)])
.fallthrough_variation_index(0)
.variation_index_for_key(kind("community"), community.to_string(), 1),
);
let evaluator = started_evaluator(&test_data).await;
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("community-int", -1),
&EvaluationContext::for_community(community),
),
13
);
evaluator.close();
}
#[tokio::test]
async fn actor_context_targets_integer_variation_by_community_and_pubkey() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let actor = actor();
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("by-community-int")
.variations([FlagValue::from(0_i64), FlagValue::from(8_i64)])
.fallthrough_variation_index(0)
.variation_index_for_key(kind("community"), community.to_string(), 1),
);
test_data.update(
FlagBuilder::new("by-pubkey-int")
.variations([FlagValue::from(0_i64), FlagValue::from(21_i64)])
.fallthrough_variation_index(0)
.variation_index_for_key(kind("pubkey"), actor.to_hex(), 1),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(community, actor);
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("by-community-int", -1), &context),
8
);
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("by-pubkey-int", -1), &context),
21
);
evaluator.close();
}
#[tokio::test]
async fn same_actor_can_receive_different_integer_variations_by_community() {
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
let actor = actor();
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("community-int-rollout")
.variations([FlagValue::from(-11_i64), FlagValue::from(99_i64)])
.fallthrough_variation_index(0)
.variation_index_for_key(kind("community"), community_a.to_string(), 1),
);
let evaluator = started_evaluator(&test_data).await;
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("community-int-rollout", -1),
&EvaluationContext::for_actor(community_a, actor),
),
99
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("community-int-rollout", -1),
&EvaluationContext::for_actor(community_b, actor),
),
-11
);
evaluator.close();
}
#[tokio::test]
async fn missing_integer_flag_falls_back_to_declared_default() {
let test_data = TestData::new();
let evaluator = started_evaluator(&test_data).await;
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("relay.feature.missing-int", -7), &context),
-7
);
evaluator.close();
}
#[tokio::test]
async fn missing_integer_flag_preserves_exact_declared_default_bytes() {
let test_data = TestData::new();
let evaluator = started_evaluator(&test_data).await;
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.missing-int-precise-default", default),
&context,
),
default,
"missing-flag fallback must preserve exact declared default"
);
}
evaluator.close();
}
#[tokio::test]
async fn wrong_type_integer_falls_back_to_declared_default() {
let test_data = TestData::new();
test_data
.update(FlagBuilder::new("relay.feature.int").value_for_all(FlagValue::from(17_i64)));
test_data.update(
FlagBuilder::new("relay.feature.int-bool").value_for_all(FlagValue::from(true)),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("relay.feature.int", 0), &context),
17
);
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("relay.feature.int-bool", -5), &context),
-5
);
evaluator.close();
}
#[tokio::test]
async fn wrong_type_integer_flag_preserves_exact_declared_default_bytes() {
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("relay.feature.int-wrong-type-for-precise-default")
.value_for_all(FlagValue::from(true)),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-wrong-type-for-precise-default", default),
&context,
),
default,
"wrong-type fallback must preserve exact declared default"
);
}
evaluator.close();
}
#[tokio::test]
async fn provider_not_ready_preserves_exact_declared_default_bytes() {
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("relay.feature.int-provider-not-ready")
.value_for_all(FlagValue::from(123_i64)),
);
let config = test_config(&test_data);
let client = Client::build(config).expect("launchdarkly test client");
let evaluator = LaunchDarklyEvaluator::new(client);
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
for default in [9_007_199_254_740_993_i64, i64::MAX, i64::MIN] {
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-provider-not-ready", default),
&context,
),
default,
"provider-not-ready fallback must preserve exact declared default"
);
}
evaluator.close();
}
#[tokio::test]
async fn fractional_integer_variation_falls_back_to_declared_default() {
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("relay.feature.int-fractional-positive")
.value_for_all(FlagValue::from(17.75_f64)),
);
test_data.update(
FlagBuilder::new("relay.feature.int-fractional-negative")
.value_for_all(FlagValue::from(-17.75_f64)),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-fractional-positive", 123),
&context,
),
123
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-fractional-negative", -456),
&context,
),
-456
);
evaluator.close();
}
#[tokio::test]
async fn integer_variation_accepts_exact_large_f64_backed_i64_values() {
let test_data = TestData::new();
test_data.update(
FlagBuilder::new("relay.feature.int-large-positive")
.value_for_all(FlagValue::from(1_152_921_504_606_846_976_i64)),
);
test_data.update(
FlagBuilder::new("relay.feature.int-large-negative")
.value_for_all(FlagValue::from(-1_152_921_504_606_846_976_i64)),
);
test_data.update(
FlagBuilder::new("relay.feature.int-min-boundary")
.value_for_all(FlagValue::from(i64::MIN)),
);
test_data.update(
FlagBuilder::new("relay.feature.int-max-rounds-out-of-range")
.value_for_all(FlagValue::from(i64::MAX)),
);
let evaluator = started_evaluator(&test_data).await;
let context = EvaluationContext::for_actor(
community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
actor(),
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-large-positive", 0),
&context,
),
1_152_921_504_606_846_976_i64
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-large-negative", 0),
&context,
),
-1_152_921_504_606_846_976_i64
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-min-boundary", 11),
&context,
),
i64::MIN
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("relay.feature.int-max-rounds-out-of-range", -12),
&context,
),
-12
);
evaluator.close();
}
#[tokio::test]
async fn negative_integer_variation_is_supported() {
let community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let test_data = TestData::new();
test_data.update(FlagBuilder::new("negative-int").value_for_all(FlagValue::from(-42_i64)));
let evaluator = started_evaluator(&test_data).await;
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("negative-int", 3),
&EvaluationContext::for_community(community),
),
-42
);
evaluator.close();
}
}

View File

@@ -0,0 +1,217 @@
#![deny(unsafe_code)]
#![warn(missing_docs)]
//! Typed boolean and integer feature flags for Buzz server components.
use buzz_core::{CommunityId, PublicKey};
pub mod environment;
pub use environment::{EnvironmentDiagnostic, EnvironmentEvaluator};
pub mod flags;
pub use flags::{BooleanFlag, IntegerFlag};
#[cfg(feature = "launchdarkly")]
/// LaunchDarkly-backed evaluator adapter.
pub mod launchdarkly;
/// Stable targeting context for one Buzz community and an optional actor.
///
/// Community is always required because the same Nostr identity can participate
/// in multiple isolated Buzz communities. Community-only contexts support
/// background and system evaluations that have no actor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EvaluationContext {
community: CommunityId,
actor_pubkey: Option<PublicKey>,
}
impl EvaluationContext {
/// Construct a community-only evaluation context.
pub const fn for_community(community: CommunityId) -> Self {
Self {
community,
actor_pubkey: None,
}
}
/// Construct an evaluation context for an actor within one community.
pub const fn for_actor(community: CommunityId, actor_pubkey: PublicKey) -> Self {
Self {
community,
actor_pubkey: Some(actor_pubkey),
}
}
/// Community that owns this evaluation.
pub const fn community(&self) -> CommunityId {
self.community
}
/// Optional stable actor key for actor-targeted evaluation.
pub const fn actor_pubkey(&self) -> Option<&PublicKey> {
self.actor_pubkey.as_ref()
}
}
/// Evaluates typed feature flags.
pub trait FlagEvaluator: Send + Sync {
/// Resolve a boolean flag for one Buzz evaluation context.
///
/// Implementations must return [`BooleanFlag::default`] whenever no valid
/// boolean value is available, including provider absence or failure.
fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool;
/// Resolve an integer flag for one Buzz evaluation context.
///
/// Implementations must return [`IntegerFlag::default`] whenever no valid
/// integer value is available, including provider absence or failure.
fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64;
}
/// Static evaluator that should return each flag's declared default.
#[derive(Debug, Default, Clone, Copy)]
pub struct StaticEvaluator;
impl FlagEvaluator for StaticEvaluator {
fn evaluate_bool(&self, flag: BooleanFlag, _context: &EvaluationContext) -> bool {
flag.default()
}
fn evaluate_int(&self, flag: IntegerFlag, _context: &EvaluationContext) -> i64 {
flag.default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::CommunityId;
fn community(id: &str) -> CommunityId {
CommunityId::from_uuid(id.parse().expect("valid community UUID"))
}
fn actor() -> PublicKey {
PublicKey::from_hex("c4f0623bdc8c4f7ecab9f7457f501f3e8f4efcf8f8f6ef6f4d76f42f5bb6f2cb")
.expect("valid pubkey")
}
#[test]
fn static_evaluator_returns_declared_defaults_for_community() {
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(StaticEvaluator.evaluate_bool(BooleanFlag::new("enabled", true), &context));
assert!(!StaticEvaluator.evaluate_bool(BooleanFlag::new("disabled", false), &context));
}
#[test]
fn static_evaluator_returns_declared_integer_defaults_for_community() {
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert_eq!(
StaticEvaluator.evaluate_int(IntegerFlag::new("positive", 7), &context),
7
);
assert_eq!(
StaticEvaluator.evaluate_int(IntegerFlag::new("negative", -9), &context),
-9
);
}
#[test]
fn non_launchdarkly_evaluator_can_target_community_through_public_trait() {
struct CommunityEvaluator {
enabled_community: CommunityId,
}
impl FlagEvaluator for CommunityEvaluator {
fn evaluate_bool(&self, flag: BooleanFlag, context: &EvaluationContext) -> bool {
if context.community() == self.enabled_community {
true
} else {
flag.default()
}
}
fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 {
if context.community() == self.enabled_community {
41
} else {
flag.default()
}
}
}
let enabled_community = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let other_community = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
let evaluator: &dyn FlagEvaluator = &CommunityEvaluator { enabled_community };
assert!(evaluator.evaluate_bool(
BooleanFlag::new("community-targeted-bool", false),
&EvaluationContext::for_community(enabled_community),
));
assert!(!evaluator.evaluate_bool(
BooleanFlag::new("community-targeted-bool", false),
&EvaluationContext::for_community(other_community),
));
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("community-targeted-int", -1),
&EvaluationContext::for_community(enabled_community),
),
41
);
assert_eq!(
evaluator.evaluate_int(
IntegerFlag::new("community-targeted-int", -1),
&EvaluationContext::for_community(other_community),
),
-1
);
}
#[test]
fn same_actor_in_two_communities_is_distinguishable_for_integer_evaluation() {
struct CommunityRolloutEvaluator {
enabled_community: CommunityId,
expected_actor: PublicKey,
}
impl FlagEvaluator for CommunityRolloutEvaluator {
fn evaluate_bool(&self, flag: BooleanFlag, _context: &EvaluationContext) -> bool {
flag.default()
}
fn evaluate_int(&self, flag: IntegerFlag, context: &EvaluationContext) -> i64 {
if context.community() == self.enabled_community
&& context.actor_pubkey() == Some(&self.expected_actor)
{
99
} else {
flag.default()
}
}
}
let actor = actor();
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
let evaluator: &dyn FlagEvaluator = &CommunityRolloutEvaluator {
enabled_community: community_a,
expected_actor: actor,
};
let flag = IntegerFlag::new("community-rollout", -11);
assert_eq!(
evaluator.evaluate_int(flag, &EvaluationContext::for_actor(community_a, actor)),
99
);
assert_eq!(
evaluator.evaluate_int(flag, &EvaluationContext::for_actor(community_b, actor)),
-11
);
}
}

View File

@@ -0,0 +1,336 @@
use std::sync::Arc;
use buzz_core::{CommunityId, PublicKey};
use buzz_feature_flags::{
BooleanFlag, EnvironmentDiagnostic, EnvironmentEvaluator, EvaluationContext, FlagEvaluator,
IntegerFlag,
};
const ENV_NAMESPACE: &str = "BUZZ_FEATURE_FLAG_";
fn community(id: &str) -> CommunityId {
CommunityId::from_uuid(id.parse().expect("valid community UUID"))
}
fn actor(hex: &str) -> PublicKey {
PublicKey::from_hex(hex).expect("valid pubkey")
}
#[test]
fn parses_booleans_case_insensitive_with_trim() {
let evaluator = EnvironmentEvaluator::from_pairs([
(
format!("{ENV_NAMESPACE}RELAY_BOOL_TRUE"),
" TrUe \t".to_owned(),
),
(
format!("{ENV_NAMESPACE}RELAY_BOOL_FALSE"),
"\n false ".to_owned(),
),
]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.bool.true", false), &context));
assert!(!evaluator.evaluate_bool(BooleanFlag::new("relay.bool.false", true), &context));
}
#[test]
fn parses_signed_negative_integers_with_trim() {
let evaluator = EnvironmentEvaluator::from_pairs([(
format!("{ENV_NAMESPACE}DB_EVENTS_QUERY_VERSION"),
" -17 ".to_owned(),
)]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("db.events.query-version", 1), &context),
-17
);
}
#[test]
fn missing_flag_values_fall_back_to_declared_defaults() {
let evaluator = EnvironmentEvaluator::from_pairs([] as [(String, String); 0]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.bool.missing", true), &context));
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("relay.int.missing", -9), &context),
-9
);
}
#[test]
fn invalid_and_empty_values_fall_back_to_declared_defaults() {
let evaluator = EnvironmentEvaluator::from_pairs([
(
format!("{ENV_NAMESPACE}RELAY_BOOL_INVALID"),
"maybe".to_owned(),
),
(format!("{ENV_NAMESPACE}RELAY_BOOL_EMPTY"), " ".to_owned()),
(
format!("{ENV_NAMESPACE}RELAY_INT_INVALID"),
"12.5".to_owned(),
),
]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(!evaluator.evaluate_bool(BooleanFlag::new("relay.bool.invalid", false), &context));
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.bool.empty", true), &context));
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("relay.int.invalid", 88), &context),
88
);
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("!!!", -4), &context),
-4,
);
}
#[test]
fn evaluation_context_is_ignored_for_process_wide_values() {
let evaluator = EnvironmentEvaluator::from_pairs([(
format!("{ENV_NAMESPACE}RELAY_PROCESS_WIDE"),
"true".to_owned(),
)]);
let community_a = community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
let community_b = community("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb");
let actor_a = actor("c4f0623bdc8c4f7ecab9f7457f501f3e8f4efcf8f8f6ef6f4d76f42f5bb6f2cb");
let actor_b = actor("f1e2d3c4b5a697887766554433221100ffeeddccbbaa99887766554433221100");
assert!(evaluator.evaluate_bool(
BooleanFlag::new("relay.process-wide", false),
&EvaluationContext::for_actor(community_a, actor_a),
));
assert!(evaluator.evaluate_bool(
BooleanFlag::new("relay.process-wide", false),
&EvaluationContext::for_actor(community_b, actor_b),
));
assert!(evaluator.evaluate_bool(
BooleanFlag::new("relay.process-wide", false),
&EvaluationContext::for_community(community_a),
));
}
#[test]
fn normalization_replaces_non_alnum_runs_and_trims_separators() {
let evaluator = EnvironmentEvaluator::from_pairs([(
format!("{ENV_NAMESPACE}RELAY_FOO_BAR_BAZ_9"),
"true".to_owned(),
)]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(evaluator.evaluate_bool(
BooleanFlag::new("___relay..foo---bar***baz??9___", false),
&context,
));
}
#[test]
fn colliding_flag_keys_share_normalized_environment_name() {
let evaluator =
EnvironmentEvaluator::from_pairs([(format!("{ENV_NAMESPACE}A_B"), "true".to_owned())]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(evaluator.evaluate_bool(BooleanFlag::new("a-b", false), &context));
assert!(evaluator.evaluate_bool(BooleanFlag::new("a_b", false), &context));
}
#[test]
fn snapshot_is_immutable_after_construction() {
let mut source = vec![(format!("{ENV_NAMESPACE}SNAPSHOT_BOOL"), "true".to_owned())];
let evaluator = EnvironmentEvaluator::from_pairs(
source
.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
);
source[0].1 = "false".to_owned();
source.push((format!("{ENV_NAMESPACE}SNAPSHOT_INT"), "99".to_owned()));
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(evaluator.evaluate_bool(BooleanFlag::new("snapshot.bool", false), &context));
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("snapshot.int", -1), &context),
-1
);
}
#[test]
fn debug_does_not_expose_environment_contents() {
let evaluator = EnvironmentEvaluator::from_pairs([(
format!("{ENV_NAMESPACE}SECRET_FLAG"),
"super-secret-value".to_owned(),
)]);
let debug = format!("{evaluator:?}");
assert!(!debug.contains("super-secret-value"));
assert!(!debug.contains("SECRET_FLAG"));
}
#[test]
fn malformed_present_values_default_and_diagnose_once_per_key_and_type() {
let evaluator = EnvironmentEvaluator::from_pairs([
(
format!("{ENV_NAMESPACE}RELAY_INVALID_BOOL"),
"raw-secret-bool".to_owned(),
),
(
format!("{ENV_NAMESPACE}RELAY_INVALID_INT"),
"raw-secret-int".to_owned(),
),
("DATABASE_URL".to_owned(), "unrelated-secret".to_owned()),
]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
let bool_flag = BooleanFlag::new("relay.invalid-bool", true);
let int_flag = IntegerFlag::new("relay.invalid-int", -12);
assert!(evaluator.evaluate_bool(bool_flag, &context));
assert!(evaluator.evaluate_bool(bool_flag, &context));
assert_eq!(evaluator.evaluate_int(int_flag, &context), -12);
assert_eq!(evaluator.evaluate_int(int_flag, &context), -12);
let diagnostics = evaluator.take_diagnostics();
assert_eq!(
diagnostics,
vec![
EnvironmentDiagnostic::InvalidBooleanValue {
flag_key: "relay.invalid-bool",
},
EnvironmentDiagnostic::InvalidIntegerValue {
flag_key: "relay.invalid-int",
},
]
);
let rendered = format!("{diagnostics:?}");
assert!(!rendered.contains("raw-secret-bool"));
assert!(!rendered.contains("raw-secret-int"));
assert!(!rendered.contains("unrelated-secret"));
assert!(!rendered.contains("DATABASE_URL"));
assert!(evaluator.evaluate_bool(bool_flag, &context));
assert_eq!(evaluator.evaluate_int(int_flag, &context), -12);
assert!(evaluator.take_diagnostics().is_empty());
}
#[test]
fn trait_object_evaluation_reports_through_retained_concrete_owner() {
let owner = Arc::new(EnvironmentEvaluator::from_pairs([(
format!("{ENV_NAMESPACE}RELAY_INVALID"),
"not-a-boolean".to_owned(),
)]));
let evaluator: Arc<dyn FlagEvaluator> = owner.clone();
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.invalid", true), &context));
assert_eq!(
owner.take_diagnostics(),
vec![EnvironmentDiagnostic::InvalidBooleanValue {
flag_key: "relay.invalid",
}]
);
}
#[test]
fn same_flag_key_reports_distinct_boolean_and_integer_diagnostics() {
let evaluator = EnvironmentEvaluator::from_pairs([(
format!("{ENV_NAMESPACE}RELAY_SHARED_KEY"),
"invalid-for-both-types".to_owned(),
)]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(!evaluator.evaluate_bool(BooleanFlag::new("relay.shared-key", false), &context));
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("relay.shared-key", -4), &context),
-4
);
assert_eq!(
evaluator.take_diagnostics(),
vec![
EnvironmentDiagnostic::InvalidBooleanValue {
flag_key: "relay.shared-key",
},
EnvironmentDiagnostic::InvalidIntegerValue {
flag_key: "relay.shared-key",
},
]
);
}
#[test]
fn cloned_evaluators_share_dedup_before_and_after_drain() {
let evaluator = EnvironmentEvaluator::from_pairs([(
format!("{ENV_NAMESPACE}RELAY_CLONED"),
"not-a-boolean".to_owned(),
)]);
let clone = evaluator.clone();
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
let flag = BooleanFlag::new("relay.cloned", false);
assert!(!evaluator.evaluate_bool(flag, &context));
assert!(!clone.evaluate_bool(flag, &context));
assert_eq!(
clone.take_diagnostics(),
vec![EnvironmentDiagnostic::InvalidBooleanValue {
flag_key: "relay.cloned",
}]
);
assert!(evaluator.take_diagnostics().is_empty());
assert!(!evaluator.evaluate_bool(flag, &context));
assert!(!clone.evaluate_bool(flag, &context));
assert!(evaluator.take_diagnostics().is_empty());
assert!(clone.take_diagnostics().is_empty());
}
#[test]
fn missing_values_default_without_diagnostics() {
let evaluator = EnvironmentEvaluator::from_pairs([] as [(String, String); 0]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
assert!(evaluator.evaluate_bool(BooleanFlag::new("relay.missing-bool", true), &context));
assert_eq!(
evaluator.evaluate_int(IntegerFlag::new("relay.missing-int", -4), &context),
-4
);
assert!(evaluator.take_diagnostics().is_empty());
}
#[cfg(unix)]
#[test]
fn namespaced_non_unicode_value_defaults_and_diagnoses_once() {
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
let evaluator = EnvironmentEvaluator::from_pairs([(
OsString::from(format!("{ENV_NAMESPACE}RELAY_NON_UNICODE")),
OsString::from_vec(vec![0x80]),
)]);
let context =
EvaluationContext::for_community(community("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
let flag = BooleanFlag::new("relay.non-unicode", false);
assert!(!evaluator.evaluate_bool(flag, &context));
assert!(!evaluator.evaluate_bool(flag, &context));
assert_eq!(
evaluator.take_diagnostics(),
vec![EnvironmentDiagnostic::InvalidBooleanValue {
flag_key: "relay.non-unicode",
}]
);
assert!(!evaluator.evaluate_bool(flag, &context));
assert!(evaluator.take_diagnostics().is_empty());
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
[package]
name = "relay-feature-selection-fixture"
version = "0.0.0"
edition = "2021"
publish = false
[workspace]
[features]
static-feature-flags = []
environment-feature-flags = []
launchdarkly-feature-flags = ["buzz-feature-flags/launchdarkly"]
[dependencies]
buzz-feature-flags = { path = "../../.." }

View File

@@ -0,0 +1,103 @@
#![allow(dead_code)]
use std::sync::Arc;
use buzz_feature_flags::{EnvironmentEvaluator, FlagEvaluator};
#[cfg(feature = "static-feature-flags")]
use buzz_feature_flags::StaticEvaluator;
#[cfg(feature = "launchdarkly-feature-flags")]
use buzz_feature_flags::launchdarkly::{
LaunchDarklyEvaluator, LaunchDarklyInitError, LaunchDarklyRuntimeConfig,
};
#[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(Clone, Debug, Default)]
pub struct FeatureFlagRuntimeConfig;
#[cfg(feature = "launchdarkly-feature-flags")]
#[derive(Clone, Debug)]
pub struct FeatureFlagRuntimeConfig {
pub sdk_key: String,
pub relay_proxy_endpoint: Option<String>,
}
pub struct FeatureFlagCompositionRoot {
pub feature_flags: Arc<dyn FlagEvaluator>,
pub environment_diagnostics: Option<Arc<EnvironmentEvaluator>>,
#[cfg(feature = "launchdarkly-feature-flags")]
pub launchdarkly_lifecycle: Option<Arc<LaunchDarklyEvaluator>>,
}
#[cfg(feature = "static-feature-flags")]
pub fn build_feature_flag_evaluator(_config: FeatureFlagRuntimeConfig) -> FeatureFlagCompositionRoot {
FeatureFlagCompositionRoot {
feature_flags: Arc::new(StaticEvaluator),
environment_diagnostics: None,
}
}
#[cfg(feature = "environment-feature-flags")]
pub fn build_feature_flag_evaluator(
_config: FeatureFlagRuntimeConfig,
) -> FeatureFlagCompositionRoot {
let owner = Arc::new(EnvironmentEvaluator::from_process_environment());
let feature_flags: Arc<dyn FlagEvaluator> = owner.clone();
FeatureFlagCompositionRoot {
feature_flags,
environment_diagnostics: Some(owner),
}
}
#[cfg(feature = "launchdarkly-feature-flags")]
pub fn build_feature_flag_evaluator(
config: FeatureFlagRuntimeConfig,
) -> Result<FeatureFlagCompositionRoot, LaunchDarklyInitError> {
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)?);
let feature_flags: Arc<dyn FlagEvaluator> = owner.clone();
Ok(FeatureFlagCompositionRoot {
feature_flags,
environment_diagnostics: None,
launchdarkly_lifecycle: Some(owner),
})
}
#[cfg(feature = "static-feature-flags")]
pub fn compile_smoke() {
let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig);
}
#[cfg(feature = "environment-feature-flags")]
pub fn compile_smoke() {
let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig);
}
#[cfg(feature = "launchdarkly-feature-flags")]
pub fn compile_smoke() {
let _ = build_feature_flag_evaluator(FeatureFlagRuntimeConfig {
sdk_key: "fixture-sdk-key".to_owned(),
relay_proxy_endpoint: None,
});
}

View File

@@ -0,0 +1,229 @@
use std::{
env,
ffi::OsString,
path::{Path, PathBuf},
process::Command,
};
use tempfile::TempDir;
const FIXTURE_LOCK_REGEN_RECIPE: &str =
"cargo generate-lockfile --manifest-path crates/buzz-feature-flags/tests/fixtures/relay-feature-selection/Cargo.toml";
fn fixture_manifest() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/relay-feature-selection/Cargo.toml")
}
fn fixture_lockfile() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/relay-feature-selection/Cargo.lock")
}
fn crate_readme() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md")
}
fn cargo_bin() -> OsString {
env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))
}
fn cargo_check(target_dir: &Path, case_name: &str, features: &[&str]) -> std::process::Output {
let mut command = Command::new(cargo_bin());
command
.current_dir(env!("CARGO_MANIFEST_DIR"))
.arg("check")
.arg("--quiet")
.arg("--locked")
.arg("--manifest-path")
.arg(fixture_manifest())
.arg("--no-default-features")
.env("CARGO_TARGET_DIR", target_dir);
if !features.is_empty() {
command.arg("--features").arg(features.join(","));
}
command
.output()
.unwrap_or_else(|error| {
panic!(
"failed to run cargo check for {case_name}: {error}\nfixture lock regeneration recipe: `{FIXTURE_LOCK_REGEN_RECIPE}`"
)
})
}
fn format_valid_case_failed_status(case_name: &str, stdout: &str, stderr: &str) -> String {
format!(
"expected {case_name} to compile successfully\nstdout:\n{stdout}\nstderr:\n{stderr}\nregenerate fixture lock with `{FIXTURE_LOCK_REGEN_RECIPE}`"
)
}
fn format_invalid_case_missing_exact_one(case_name: &str, stderr: &str) -> String {
format!(
"expected {case_name} failure to explain the exact-one contract\nstderr:\n{stderr}\nregenerate fixture lock with `{FIXTURE_LOCK_REGEN_RECIPE}`"
)
}
fn assert_valid_case_success(case_name: &str, output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"{}",
format_valid_case_failed_status(case_name, &stdout, &stderr)
);
}
fn assert_invalid_case_has_exact_one_diagnostic(case_name: &str, output: &std::process::Output) {
assert!(
!output.status.success(),
"expected {case_name} to fail compilation"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("select exactly one relay feature flag provider feature"),
"{}",
format_invalid_case_missing_exact_one(case_name, &stderr)
);
}
#[test]
fn relay_fixture_lock_regeneration_recipe_is_documented() {
let readme = std::fs::read_to_string(crate_readme()).expect("read crate README");
assert!(
readme.contains(FIXTURE_LOCK_REGEN_RECIPE),
"README.md must document fixture lock regeneration with `{FIXTURE_LOCK_REGEN_RECIPE}`"
);
}
#[test]
fn relay_readme_documents_fail_closed_launchdarkly_startup_contract() {
let readme = std::fs::read_to_string(crate_readme()).expect("read crate README");
assert!(
readme.contains("start_with_default_executor_and_wait"),
"README.md must show LaunchDarkly startup via start_with_default_executor_and_wait"
);
assert!(
readme.contains("async fn build_feature_flag_evaluator"),
"README.md must declare LaunchDarkly build_feature_flag_evaluator as async"
);
assert!(
readme.contains(
"start_with_default_executor_and_wait(Duration::from_secs(5))\n .await"
),
"README.md must await start_with_default_executor_and_wait"
);
assert!(
readme.contains("fail-closed"),
"README.md must state fail-closed startup policy"
);
assert!(
readme.contains("exact-one compile_error guards"),
"README.md must require exact-one compile_error guards in the consumer"
);
assert!(
readme.contains("future flag registry"),
"README.md must defer environment-name uniqueness enforcement to a future registry"
);
assert!(
readme.contains("kind `community` plus")
&& readme.contains("optional global kind `pubkey`"),
"README.md must document LaunchDarkly adapter context kinds"
);
assert!(
readme.contains("rollouts must set `contextKind` to a kind present")
&& readme.contains("in evaluation"),
"README.md must document rollout contextKind requirements"
);
assert!(
readme.contains("omits `contextKind` defaults") && readme.contains("to `user`;"),
"README.md must document LaunchDarkly default rollout context kind"
);
assert!(
readme.contains("bucket as")
&& readme.contains("zero and pick the first positive-weight variation"),
"README.md must document missing-kind rollout bucketing behavior"
);
assert!(
readme.contains("close()` blocks the calling thread")
&& readme.contains("while it flushes analytics"),
"README.md must document blocking LaunchDarkly close semantics"
);
assert!(
readme.contains("offload it to a blocking shutdown path"),
"README.md must document async shutdown offloading guidance for close()"
);
}
#[test]
fn valid_case_failed_status_formatter_includes_fixture_lock_regeneration_recipe() {
let message = format_valid_case_failed_status(
"static",
"",
"error: lock file needs update but --locked was passed",
);
assert!(
message.contains(FIXTURE_LOCK_REGEN_RECIPE),
"valid-case failure formatter must include fixture lock recipe\nmessage:\n{message}"
);
}
#[test]
fn invalid_case_missing_exact_one_formatter_includes_fixture_lock_regeneration_recipe() {
let message =
format_invalid_case_missing_exact_one("none", "error: unrelated nested cargo failure");
assert!(
message.contains(FIXTURE_LOCK_REGEN_RECIPE),
"invalid-case failure formatter must include fixture lock recipe\nmessage:\n{message}"
);
}
#[test]
fn relay_artifact_provider_features_require_exactly_one_selection() {
assert!(
fixture_lockfile().is_file(),
"relay feature selection fixture must check in Cargo.lock for locked nested cargo runs\nregenerate it with `{FIXTURE_LOCK_REGEN_RECIPE}`"
);
let target_dir = TempDir::new().expect("temp target dir");
let valid_cases = [
("static", &["static-feature-flags"][..]),
("environment", &["environment-feature-flags"][..]),
("launchdarkly", &["launchdarkly-feature-flags"][..]),
];
for (case_name, features) in valid_cases {
let output = cargo_check(target_dir.path(), case_name, features);
assert_valid_case_success(case_name, &output);
}
let invalid_cases = [
("none", &[][..]),
(
"static-and-environment",
&["static-feature-flags", "environment-feature-flags"][..],
),
(
"static-and-launchdarkly",
&["static-feature-flags", "launchdarkly-feature-flags"][..],
),
(
"environment-and-launchdarkly",
&["environment-feature-flags", "launchdarkly-feature-flags"][..],
),
(
"all-three",
&[
"static-feature-flags",
"environment-feature-flags",
"launchdarkly-feature-flags",
][..],
),
];
for (case_name, features) in invalid_cases {
let output = cargo_check(target_dir.path(), case_name, features);
assert_invalid_case_has_exact_one_diagnostic(case_name, &output);
}
}

View File

@@ -137,6 +137,11 @@ run_unit_tests() {
run_test_step "buzz-backend-kubernetes tests" \
cargo test -p buzz-backend-kubernetes -- --nocapture
# Keep fallback parity with `just test-unit`: one LaunchDarkly-feature run
# exercises both default and feature-gated buzz-feature-flags tests.
run_test_step "buzz-feature-flags tests (launchdarkly)" \
cargo test -p buzz-feature-flags --features launchdarkly -- --nocapture
# buzz-agent model-capabilities corpus: the Rust half of the cross-language
# drift guard. model_capabilities.rs embeds scripts/model-capabilities.json +
# scripts/normative-corpus.json via include_str! and replays the full locked