diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5b0a9f..5f15508c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y ## [Unreleased] +## [26.10.1] - 2026-10-06 + +### Added + +- **Feature flags (`firefly/feature-flags`):** in-process flagd evaluation on OpenFeature, with shared + cross-framework validation, evaluation and percentage-bucket conformance vectors. Definitions come from + config (including shorthand), a watched JSON/YAML file, an HTTP sync endpoint or a writable database store + with audit history and optimistic concurrency; PHP-FPM workers share refresh state through the cache. +- **Feature flag gates:** `#[FeatureFlag]` on beans and controllers, the `feature-flag` route middleware, + `@featureflag` / `@featurevariant` in Blade, and the `FeatureFlags` facade. Missing or erroring flags use + the caller's default, which is closed unless explicitly enabled; source refresh failures retain the last + good definitions. Bean gates support fallbacks and compile through `firefly:cache`. +- **Feature flag management:** the `flags` actuator endpoint, admin **Feature flags** page, + `php artisan firefly:flags`, `featureflags` health component, `feature_flag_evaluations_total`, change and + optional exposure events, bearer-token flagd sync server, and `withFeatureFlags()` test overrides. + The guide and both book editions include a tested Lumen rollout example. +- **Actuator:** `EndpointRequest::$rawBody` carries the original JSON request text beside its parsed body. + +### Changed + +- **Runtime dependencies:** added `open-feature/sdk: ^2.3` and moved `symfony/yaml: ^7.4|^8.0` + from development to runtime requirements for JSON/YAML feature-flag sources. + ### Fixed - **Release workflow:** the publication job waits for the tag on Composer's metadata endpoint diff --git a/README.md b/README.md index 45b6ee2e..78624eb0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ PHP 8.3+ Laravel 13 License: Apache 2.0 - Version: 26.09.11 + Version: 26.10.1 PHPStan: max Code Style: Pint

@@ -71,10 +71,11 @@ event-driven, actuator-observed microservice, chapter by chapter — every listi **[Download the book in English or Spanish, as PDF or EPUB](https://fireflyframework.github.io/fireflyframework-php/book/).** The published editions include the single-package installation and bundled installer. -The book is **structurally complete and bilingual (English + Spanish)**: a quick start, **fifteen chapters** +The book is **structurally complete and bilingual (English + Spanish)**: a quick start, **sixteen chapters** across four parts — Foundations (DI, config, HTTP), Modelling & Persisting the Domain (repositories, DDD), Coordinating & Securing the App (CQRS, EDA + transactional outbox, `#[Transactional]`, security, OAuth2 and -OpenID Connect), and Observability, Testing & Delivery (actuator, testing, the CLI + zero-reflection cache) +OpenID Connect), and Observability, Testing & Delivery (actuator, testing, the CLI + zero-reflection cache, +feature flags) — plus a Laravel→LaraFly cheat-sheet and a glossary. The two editions are line-for-line the same book: every chapter file has the same sections, in the same order, at the same line numbers, and every `php` listing and every `source:`-marked excerpt in the Spanish edition is the English one character for character — the only @@ -157,7 +158,7 @@ LaraFly is not a fork of Laravel and does not hide it — every package layers c `Illuminate\Container`, Eloquent, the HTTP kernel, and the queue/cache/scheduler, so everything you already know about Laravel keeps working underneath. -Three capabilities in that list are recent enough — and far enough from what teams assume a PHP framework +Four capabilities in that list are recent enough — and far enough from what teams assume a PHP framework will have — that they are worth naming outright: - **Both halves of OAuth2, as two packages you install.** @@ -188,6 +189,10 @@ will have — that they are worth naming outright: becomes a `DuplicateKeyException` carrying a 409 and a fixed sentence, with the driver's message — which has the statement and its bindings interpolated into it — left on `previous`, for the log and never for the wire. +- **Feature flags shared with PyFly.** [`firefly/feature-flags`](docs/modules/feature-flags.md) evaluates + [flagd](https://flagd.dev) definitions in-process on OpenFeature with the same semantics and percentage buckets + as PyFly. Gate a bean method or route with `#[FeatureFlag]` and a view with `@featureflag`; change flags at runtime + from the admin dashboard, `/actuator/flags` or `php artisan firefly:flags`, with an audit trail. ### Who is LaraFly for? @@ -1061,7 +1066,7 @@ Full flag reference and generated-file contents: [CLI](docs/cli.md). One published library, `fireflyframework/larafly`, contains the components under `packages/*` and the bundled application skeleton. Its `replace` entries satisfy component requirements at the framework's version; -component names remain useful module boundaries, with their own test suites. The 32 +component names remain useful module boundaries, with their own test suites. The 33 [module guides](docs/modules/) below group them by concern: | Group | Module | Package(s) | @@ -1095,6 +1100,7 @@ component names remain useful module boundaries, with their own test suites. The | Operations | [Admin Dashboard](docs/modules/admin.md) — the browser dashboard over the actuator, read in-process | `firefly/admin` | | Operations | [Bean Graph](docs/modules/bean-graph.md) — the dashboard's drawn dependency graph, with cycle reporting | `firefly/admin` | | Operations | [Data Browser](docs/modules/data-browser.md) — the dashboard's database browser over `CrudRepository` beans, off by default | `firefly/admin` | +| Operations | [Feature Flags](docs/modules/feature-flags.md) — flagd flags on OpenFeature, gates, layered sources, a writable store and audit trail | `firefly/feature-flags` | | Testing | [Testing](docs/modules/testing.md) — `FireflyTestCase`, recording doubles, Pest expectations | `firefly/testing` | | Testing | [Integration Testing](docs/modules/integration-testing.md) — `@group integration`, testcontainers | `firefly/testing` | | Tooling | [Installer](docs/modules/installer.md) — the global `firefly new` scaffolding tool | `firefly/installer` | @@ -1116,7 +1122,7 @@ Start at the **[documentation table of contents](docs/README.md)** — it groups - [Laravel ↔ Spring Boot Comparison](docs/laravel-comparison.md) — concept-by-concept mapping for both audiences. - [Versioning](docs/versioning.md) · [Contributing](docs/contributing.md) · [Publishing](docs/publishing.md). - Every [module guide](#modules) above. -- [*LaraFly by Example*](book/README.md) — the complete bilingual book (15 chapters + appendices, PDF + EPUB). +- [*LaraFly by Example*](book/README.md) — the complete bilingual book (16 chapters + appendices, PDF + EPUB). - [`samples/lumen/`](samples/lumen/) — the wallet-and-ledger sample this README's showcases are drawn from; run its own test suite with `vendor/bin/pest samples/lumen/tests`. @@ -1138,9 +1144,10 @@ still ahead, accurately: round-trip test (RabbitMQ, Postgres, Kafka) gated behind Docker + a live connection string — these run on demand, not in the default `vendor/bin/pest`/CI pass; wiring a scheduled/opt-in CI lane for them is still open. -- **More actuator endpoints.** Fifteen ship today — `/health`, `/info`, `/env`, `/beans`, `/conditions`, +- **More actuator endpoints.** Sixteen ship today — `/health`, `/info`, `/env`, `/beans`, `/conditions`, `/mappings`, `/loggers`, `/scheduledtasks`, `/caches`, `/configprops`, `/metrics`, `/prometheus`, - `/process`, `/httpexchanges`, and `/oauth2clients` when the authorization server is installed — and + `/process`, `/httpexchanges`, `/oauth2clients` when the authorization server is installed, and `/flags` + when feature flags are enabled — and `firefly.management.server.port` moves the whole surface onto a second listener, with `ManagementPortGuard` making the actuator refuse the application port and `php artisan firefly:management:serve` running that second listener in development. Spring Boot's `/refresh`, `/threaddump` and `/shutdown` are still not @@ -1155,7 +1162,7 @@ still ahead, accurately: today via a plain `#[EventListener]`; a dedicated `firefly/eventsourcing`-style package for event sourcing/snapshots/projections is future work, as it is in PyFly. - **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *LaraFly by Example* - [book](book/README.md) (15 chapters + appendices, EN + ES, PDF + EPUB), and a + [book](book/README.md) (16 chapters + appendices, EN + ES, PDF + EPUB), and a [docs table of contents](docs/README.md) all shipped with the documentation-parity milestone. Deeper guides (more recipes, more diagrams) continue to grow from here. diff --git a/book/README.md b/book/README.md index 8245b017..e83a48a9 100644 --- a/book/README.md +++ b/book/README.md @@ -111,7 +111,7 @@ book/ src/ # EN manuscript (Markdown) 00-front/ # title/copyright/dedication/preface/conventions 00-quickstart.md # "Build Lumen step by step" quick start - 01..13-*.md # the fifteen chapters, 4A and 10A included (Parts I-IV) + 01..13a-*.md # the sixteen chapters, 4A, 10A and 13A included (Parts I-IV) 90-appendix-a-laravel.md # Laravel -> LaraFly cheat-sheet 94-glossary.md # glossary src-es/ # ES manuscript, same structure/filenames @@ -137,7 +137,7 @@ book/ ## Manuscript status The manuscript is **structurally complete** in both languages: a five-file front -matter, a "Build Lumen step by step" quick start, fifteen chapters across four +matter, a "Build Lumen step by step" quick start, sixteen chapters across four parts — - **Part I — Foundations**: Why LaraFly, Dependency Injection & Auto-Configuration, @@ -148,7 +148,7 @@ parts — Architecture & the Transactional Outbox, Transactions & the `#[Transactional]` proxy, Security, OAuth2 and OpenID Connect - **Part IV — Observability, Testing & Delivery**: Observability/Actuator, - Testing, the CLI & the Zero-Reflection Cache + Testing, the CLI & the Zero-Reflection Cache, Feature Flags — plus **Appendix A** (Laravel → LaraFly cheat-sheet) and a **Glossary**. Every chapter walks the real `samples/lumen` project. Every fenced ` ```php ` listing diff --git a/book/book.es.yaml b/book/book.es.yaml index 179a3f26..2dde5263 100644 --- a/book/book.es.yaml +++ b/book/book.es.yaml @@ -76,6 +76,7 @@ parts: - {id: ch11, file: 11-observability-actuator.md, num: 11, title: "Observabilidad: Salud, Métricas y el Actuator"} - {id: ch12, file: 12-testing.md, num: 12, title: "Probar Aplicaciones LaraFly"} - {id: ch13, file: 13-cli-cache.md, num: 13, title: "La CLI y la Caché Sin Reflexión"} + - {id: ch13a, file: 13a-feature-flags.md, num: "13A", title: "Feature Flags entre LaraFly y PyFly"} - title: "Apéndices" chapters: - {id: appa, file: 90-appendix-a-laravel.md, num: "A", title: "Hoja de Referencia Laravel → LaraFly"} diff --git a/book/book.yaml b/book/book.yaml index 1e75f071..eb85445a 100644 --- a/book/book.yaml +++ b/book/book.yaml @@ -74,6 +74,7 @@ parts: - {id: ch11, file: 11-observability-actuator.md, num: 11, title: "Observability: Health, Metrics, and the Actuator"} - {id: ch12, file: 12-testing.md, num: 12, title: "Testing LaraFly Applications"} - {id: ch13, file: 13-cli-cache.md, num: 13, title: "The CLI and the Zero-Reflection Cache"} + - {id: ch13a, file: 13a-feature-flags.md, num: "13A", title: "Feature Flags Across LaraFly and PyFly"} - title: "Appendices" chapters: - {id: appa, file: 90-appendix-a-laravel.md, num: "A", title: "Laravel → LaraFly Cheat-Sheet"} diff --git a/book/build/build.py b/book/build/build.py index a0a071ff..fe96b6ef 100644 --- a/book/build/build.py +++ b/book/build/build.py @@ -51,8 +51,8 @@ def _items_from_manifest(cfg: dict, man: Path, *, contents_label: str) -> list[d # 1) front matter for fm in cfg.get("front", []): p = man / fm["file"] - if not p.exists(): - continue + if not p.is_file(): + raise FileNotFoundError(f"Configured manuscript file is missing: {p}") items.append({ "kind": "front", "id": fm["id"], @@ -66,7 +66,11 @@ def _items_from_manifest(cfg: dict, man: Path, *, contents_label: str) -> list[d for part in cfg.get("parts", []): ptitle_full = part["title"] eyebrow, ptitle = _split_part(ptitle_full) - chapters = [ch for ch in part["chapters"] if (man / ch["file"]).exists()] + chapters = part["chapters"] + for ch in chapters: + p = man / ch["file"] + if not p.is_file(): + raise FileNotFoundError(f"Configured manuscript file is missing: {p}") if not chapters: continue # stable divider id from the eyebrow, e.g. "Part I" -> "part-i" diff --git a/book/src-es/00-front/00-preface.md b/book/src-es/00-front/00-preface.md index 9467405c..733a0946 100644 --- a/book/src-es/00-front/00-preface.md +++ b/book/src-es/00-front/00-preface.md @@ -18,6 +18,8 @@ Cada capítulo hace avanzar **Lumen**, un servicio de monedero digital y libro m El recorrido empieza con suavidad. El **Inicio rápido** te lleva desde el instalador incluido `firefly new` hasta un endpoint en ejecución y consultable con curl, previendo en miniatura los estereotipos, el contenedor y la ruta de arranque compilada antes de que ningún capítulo te pida razonar sobre ellos. El **Capítulo 1** da un paso atrás y argumenta el enfoque completo — qué problema resuelve LaraFly y sobre qué pilares se sostiene. El **Capítulo 2** abre la sala de máquinas: el contenedor de inyección de dependencias, los atributos de estereotipo y el escaneo de componentes que compila tus clases anotadas en un manifiesto de arranque en caché, sin reflexión. Las partes posteriores de este libro — que llegarán en los capítulos siguientes — construyen hacia afuera desde esa base hacia la configuración, HTTP, la persistencia, el modelado de dominio, CQRS, la arquitectura orientada a eventos, la seguridad y la observabilidad, siempre a través de la misma base de código de `Lumen`, siempre con código que puedes ejecutar. +El Capítulo 13A cierra el recorrido de producción con feature flags: Lumen mantiene apagada una vista de la wallet, selecciona una cohorte compartida y prueba la ruta y el ciclo del override contra el framework real. + Cuando hayas terminado de trabajar todo el libro, tendrás un modelo mental de cada capa de un servicio LaraFly de producción, y una aplicación real y probada que lo demuestre. ### Cómo usar este libro diff --git a/book/src-es/03-configuration.md b/book/src-es/03-configuration.md index 1a8fe3a6..2f35577f 100644 --- a/book/src-es/03-configuration.md +++ b/book/src-es/03-configuration.md @@ -373,6 +373,8 @@ Todo lo que ha mostrado este capítulo lee, en algún punto, a través de `env() --- +El Capítulo 13A aplica estas reglas de configuración a `firefly.feature-flags.enabled`, los documentos de banderas por capas y los tokens de sincronización; deja las banderas en `config/firefly.php` y los secretos en el entorno. + ## Lo que aprendiste {.recap} | Concepto | Qué hace | diff --git a/book/src-es/04-first-http-api.md b/book/src-es/04-first-http-api.md index 6b063aa8..4c689558 100644 --- a/book/src-es/04-first-http-api.md +++ b/book/src-es/04-first-http-api.md @@ -621,6 +621,8 @@ En el arranque, `RouteWiringPass` registra una **ruta nativa de Laravel** por ca --- +El Capítulo 13A añade `#[FeatureFlag]` a una acción de controlador: su middleware rechaza la ruta apagada antes de vincular el cuerpo y no revela la clave en la respuesta de problema. + ## Lo que construiste {.recap} La Parte I está completa. Lumen ahora **arranca** (Capítulo 1), está **conectado** (la inyección por constructor del Capítulo 2 sin código de cableado), está **configurado** (los ajustes tipados y conscientes de perfil del Capítulo 3), y **sirve** — una API REST validada en `/api/v1/wallets` con: diff --git a/book/src-es/09-transactions.md b/book/src-es/09-transactions.md index 8267ddbf..0fcc240a 100644 --- a/book/src-es/09-transactions.md +++ b/book/src-es/09-transactions.md @@ -556,6 +556,8 @@ La segunda prueba es la que importa. `Wallet::withdraw()` sobre el origen corri --- +El Capítulo 13A utiliza la misma frontera transaccional para las escrituras versionadas del almacén de banderas y sus filas de auditoría, incluidas las reglas de commit y rollback del llamador. + ## Lo que aprendiste {.recap} | Concepto | Qué hace | diff --git a/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md index 9a407d73..21ccee25 100644 --- a/book/src-es/11-observability-actuator.md +++ b/book/src-es/11-observability-actuator.md @@ -1102,6 +1102,8 @@ Es un *interruptor de funcionalidad*, no un endpoint de configuración remota: l --- +El Capítulo 13A amplía esta superficie con el endpoint `flags`, la salud de las fuentes, las métricas de evaluación y los eventos de exposición opcionales; las vistas previas de gestión no emiten métricas ni exposiciones. + ## Lo que aprendiste {.recap} | Concepto | Qué hace | diff --git a/book/src-es/12-testing.md b/book/src-es/12-testing.md index d9a45b78..487d3f03 100644 --- a/book/src-es/12-testing.md +++ b/book/src-es/12-testing.md @@ -531,6 +531,8 @@ abstract class LumenTestCase extends FireflyDatabaseTestCase CqrsWiringProvider::class, EdaServiceProvider::class, EdaWiringProvider::class, + FeatureFlagsServiceProvider::class, + FeatureFlagsWiringProvider::class, SecurityServiceProvider::class, SecurityWiringProvider::class, ActuatorServiceProvider::class, @@ -756,6 +758,8 @@ Lee dos veces la última frase, porque es la razón por la que este método exis --- +El Capítulo 13A prueba el `WalletRollout` incluido en Lumen con `withFeatureFlags()`, vectores de selección compartidos y una ruta protegida; después limpia los overrides para comprobar que vuelve el comportamiento anterior. + ## Lo que aprendiste {.recap} | Concepto | Qué hace | diff --git a/book/src-es/13-cli-cache.md b/book/src-es/13-cli-cache.md index e6fc689f..535dfceb 100644 --- a/book/src-es/13-cli-cache.md +++ b/book/src-es/13-cli-cache.md @@ -185,6 +185,7 @@ final class ManifestCacheWriter { return new ProxyPlanner([ new ObservabilityAdviceSource, + new FeatureFlagAdviceSource, new MethodSecurityAdviceSource, new ResilienceAdviceSource, new TransactionalAdviceSource, @@ -514,6 +515,8 @@ Cada aplicación que `firefly new` andamia es por tanto, desde el primerísimo ` --- +El Capítulo 13A presenta `firefly:flags` para inspeccionar y cambiar banderas en ejecución; sus puertas de método también entran en el plan de proxy compilado en vez de reflejarse en cada petición. + ## Lo que aprendiste {.recap} | Concepto | Qué hace | diff --git a/book/src-es/13a-feature-flags.md b/book/src-es/13a-feature-flags.md new file mode 100644 index 00000000..4ed2b686 --- /dev/null +++ b/book/src-es/13a-feature-flags.md @@ -0,0 +1,105 @@ +Parte IV — Observabilidad, Pruebas y Entrega · Capítulo 13A + +# Feature Flags entre LaraFly y PyFly {.chtitle} + +Al terminar este capítulo usarás `FeatureFlags` en Lumen, distinguirás una bandera deshabilitada del valor por defecto del llamador, asignarás una cohorte estable, operarás el almacén sin perder una transacción externa y probarás un despliegue con `withFeatureFlags()`. Los Capítulos 3, 4, 9, 11, 12 y 13 aportan la configuración, HTTP, transacciones, actuator, pruebas y CLI que utilizamos aquí. + +--- + +## Empezar a oscuras y elegir una variante + +Instala la biblioteca LaraFly publicada y activa `firefly.feature-flags.enabled` en `config/firefly.php`. El subsistema está apagado por defecto. Una bandera de configuración puede usar la abreviatura booleana o de cadena; un archivo, documento HTTP o escritura de almacén exige la definición flagd completa. `true` crea variantes booleanas `on`/`off` y selecciona `on`; `false` selecciona `off`. Una cadena como `v2` crea una sola variante de cadena. Los números y objetos no tienen abreviatura. La guía del módulo enumera todas las claves de configuración. + +El despliegue incluido en Lumen conserva el texto anterior de la wallet cuando la bandera booleana está apagada y elige una vista nombrada desde una bandera multivariante: + + +```php +flags->isEnabled('wallet-balance-v2') ? 'Available balance' : 'Balance'; + } + + public function checkoutView(): string + { + return match ($this->flags->getString('wallet-checkout-view', 'legacy')) { + 'v2' => 'wallet.v2', + default => 'wallet.legacy', + }; + } +} +``` + +El arnés de pruebas de la muestra arranca `FeatureFlagsServiceProvider` y `FeatureFlagsWiringProvider`, con `wallet-balance-v2` apagada y `wallet-checkout-view` en `legacy`. Su prueba cambia ambas mediante `withFeatureFlags()`, comprueba el resultado visible, limpia el override y comprueba la restauración. El valor por defecto de `getString()` también lleva una bandera ausente o inválida a la vista anterior. `details()` devuelve valor, variante, motivo, código de error y metadatos; `getInt()`, `getFloat()` y `getObject()` solicitan otros tipos. Una bandera booleana nunca se trata como número. El estado `DISABLED` devuelve el valor del llamador con motivo `DISABLED`; una bandera ausente lo devuelve con `FLAG_NOT_FOUND`. + +## Selección y cohortes estables + +Una definición completa contiene `state`, `variants`, un `defaultVariant` opcional, reglas JSON Logic en `targeting` y `metadata` escalar. La regla devuelve el nombre de una variante; `null` elige la predeterminada. `$evaluators` compartidos se referencian estructuralmente mediante `{"$ref":"name"}`; una referencia ausente o cíclica produce `PARSE_ERROR` al evaluar. LaraFly y PyFly ejecutan los mismos vectores de conformidad. La prueba de Lumen consume los casos compartidos `pro-reports`: el plan `pro` resuelve `on`, el gratuito resuelve `off`, y ambos comprueban variante y motivo `TARGETING_MATCH`. + +Para despliegues porcentuales, `fractional` calcula MurmurHash3 x86 de 32 bits sin signo sobre la clave de bandera y un `targetingKey` estable. La misma clave cae en el mismo grupo en ambos frameworks. El tráfico anónimo no tiene clave estable y recibe la variante predeterminada salvo que la aplicación aporte una. Usa un id de usuario autenticado u otro identificador duradero; un id de sesión cambiante mueve a las personas entre cohortes. + +El contexto ambiente incluye `application`, `profiles`, nombre del principal, `roles` sin `ROLE_` y `tenant` desde el atributo configurado del principal. Un `#[Component]` que implemente `EvaluationContextContributor` puede añadir `plan`; los contribuidores posteriores prevalecen y los atributos explícitos del llamador prevalecen sobre todos. En contexto JSON, un `targetingKey` explícito admite cadena no vacía o entero; un argumento separado no vacío tiene prioridad. LaraFly descarta atributos no admitidos, incluidos objetos Eloquent/Arrayable/Jsonable y nombres de aspecto numérico, con diagnóstico DEBUG. Pasa el id del modelo, no el modelo. Fechas y horas se convierten en milisegundos desde la época UTC en ambos evaluadores. + +## Proteger método, ruta y vista + +`#[FeatureFlag('wallet-balance-v2')]` protege un método de bean con proxy; `fallback:` llama a un método hermano con los mismos argumentos. En una acción de controlador, el middleware rechaza antes de vincular argumentos; un fallback que requiere argumentos vinculados se ejecuta en el interceptor. Los archivos de rutas pueden usar `feature-flag:key,variant`; Blade ofrece `@featureflag` y `@featurevariant`. Una ruta rechazada devuelve el problema configurado con estado 404, 403 o 503 sin revelar la clave. La puerta permanece cerrada por defecto: una bandera ausente, deshabilitada o errónea no autoriza. Usa `default: true` solo si deseas abrirla explícitamente. Una bandera no cambia el registro de beans después del arranque; la condición de propiedad del Capítulo 3 resuelve esa decisión distinta. + +El `WalletRolloutGate` incluido hace concreto el fallback; su prueba llama al bean con proxy antes, durante y después del override: + + +```php +Part IV — Observability, Testing & Delivery · Chapter 13A + +# Feature Flags Across LaraFly and PyFly {.chtitle} + +By the end of this chapter you will use `FeatureFlags` in Lumen, distinguish a disabled flag from a caller default, target a stable cohort, operate a store without losing an outer transaction, and test a rollout with `withFeatureFlags()`. Chapters 3, 4, 9, 11, 12 and 13 supply the configuration, HTTP, transaction, actuator, testing and CLI foundations used here. + +--- + +## Start dark, then choose a variant + +Install the published LaraFly library as usual and enable `firefly.feature-flags.enabled` in `config/firefly.php`. The subsystem is off by default. A configured flag may use boolean or string shorthand; a file, HTTP document or store write must use the full flagd definition. `true` means enabled with `on`/`off` boolean variants and `on` as the default; `false` chooses `off`. A string such as `v2` creates one string variant. A number or object has no shorthand. See the configuration reference in the module guide for every key. + +Lumen's checked-in rollout keeps the existing wallet wording when its boolean flag is dark and chooses a named view from a multivariate flag: + + +```php +flags->isEnabled('wallet-balance-v2') ? 'Available balance' : 'Balance'; + } + + public function checkoutView(): string + { + return match ($this->flags->getString('wallet-checkout-view', 'legacy')) { + 'v2' => 'wallet.v2', + default => 'wallet.legacy', + }; + } +} +``` + +The sample boots `FeatureFlagsServiceProvider` and `FeatureFlagsWiringProvider` in its test harness, with `wallet-balance-v2` off and `wallet-checkout-view` set to `legacy`. Its focused test flips both through `withFeatureFlags()`, checks the visible result, clears the override and checks restoration. The `getString()` caller default also maps a missing or invalid flag to the legacy view. `details()` returns the value, variant, reason, error code and metadata when a caller needs to explain an assignment; `getInt()`, `getFloat()` and `getObject()` request other types. A boolean flag is never treated as a number. A flag in state `DISABLED` returns the caller default, with reason `DISABLED`; a missing flag returns the caller default with `FLAG_NOT_FOUND`. + +## Targeting and stable cohorts + +A full definition has `state`, `variants`, an optional `defaultVariant`, JSON Logic `targeting` and scalar `metadata`. A targeting rule returns a variant name; `null` selects the default. Shared `$evaluators` can be referenced structurally with `{"$ref":"name"}`; a missing or cyclic reference fails that evaluation with `PARSE_ERROR`. LaraFly and PyFly run the same versioned conformance vectors. Lumen's test consumes the shared `pro-reports` cases: a `pro` plan resolves `on`, a free plan resolves `off`, and both assert the variant and `TARGETING_MATCH` reason. + +For percentage rollouts, `fractional` hashes the flag key and stable `targetingKey` with unsigned MurmurHash3 x86 32-bit. The same key lands in the same bucket in both frameworks. Anonymous traffic has no stable key and receives the default variant unless the application contributes one. Use an authenticated user id or another durable identifier; a changing session id silently moves users between cohorts. + +The ambient context includes `application`, `profiles`, principal name, `roles` without `ROLE_`, and `tenant` from the configured principal attribute. A `#[Component]` implementing `EvaluationContextContributor` can add `plan`; later contributors override earlier ones, and the caller's explicit attributes override them all. An explicit `targetingKey` accepts a nonempty string or integer in JSON context; a separate nonempty key argument takes priority. LaraFly drops unsupported attributes, including Eloquent/Arrayable/Jsonable objects and numeric-looking names, with a DEBUG diagnostic. Pass a model's id, not the model. Date and time values become UTC epoch milliseconds for both evaluators. + +## Gate a method, route or view + +`#[FeatureFlag('wallet-balance-v2')]` gates a proxied bean method; `fallback:` invokes a sibling method with the original arguments. On a controller action, the route middleware refuses before binding; a fallback that needs bound arguments runs in the method interceptor. Route files can use `feature-flag:key,variant`, and Blade offers `@featureflag` and `@featurevariant`. A refused route returns the configured 404, 403 or 503 problem response without disclosing the key. The default gate is closed: missing, disabled or erroring flags do not authorize the feature. Set `default: true` only for a deliberate open fallback. A flag cannot change bean registration after boot; Chapter 3's property condition handles that distinct decision. + +The checked-in `WalletRolloutGate` makes the fallback concrete; its test calls the proxied bean before, during and after an override: + + +```php + overflowed). */ img,svg,table,pre.code,div.code{ max-width:100%; } pre.code, div.code > pre{ white-space:pre-wrap; overflow-wrap:anywhere; } +section#ch13a div.code{ break-inside:avoid; } /* Long PHP type names must not set a table's minimum width beyond the page. */ table{ table-layout:fixed; } th,td,th code,td code{ overflow-wrap:anywhere; } diff --git a/composer.json b/composer.json index 62a76a69..b0e106a3 100644 --- a/composer.json +++ b/composer.json @@ -27,13 +27,15 @@ "illuminate/translation": "^13.0", "illuminate/validation": "^13.0", "monolog/monolog": "^3.0", + "open-feature/sdk": "^2.3", "php-amqplib/php-amqplib": "^3.7", "psr/http-message": "^1.0 || ^2.0", "psr/log": "^3.0", "swagger-api/swagger-ui": "^5.17", "symfony/console": "^7.4|^8.0", "symfony/expression-language": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0" + "symfony/process": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "require-dev": { "deptrac/deptrac": "^4.6", @@ -48,7 +50,6 @@ "pestphp/pest": "^4.5", "pestphp/pest-plugin-browser": "^4.3", "phpstan/phpstan": "^2.1", - "symfony/yaml": "^7.4|^8.0", "symplify/monorepo-builder": "^11.2" }, "autoload-dev": { @@ -67,6 +68,7 @@ "Firefly\\Eda\\Postgres\\Tests\\": "packages/eda-postgres/tests/", "Firefly\\Eda\\Rabbitmq\\Tests\\": "packages/eda-rabbitmq/tests/", "Firefly\\Eda\\Tests\\": "packages/eda/tests/", + "Firefly\\FeatureFlags\\Tests\\": "packages/feature-flags/tests/", "Firefly\\Firefly\\Tests\\": "packages/firefly/tests/", "Firefly\\Installer\\Tests\\": "packages/installer/tests/", "Firefly\\Messaging\\Tests\\": "packages/messaging/tests/", @@ -131,6 +133,7 @@ "firefly/eda-kafka": "self.version", "firefly/eda-postgres": "self.version", "firefly/eda-rabbitmq": "self.version", + "firefly/feature-flags": "self.version", "firefly/firefly": "self.version", "firefly/installer": "self.version", "firefly/kernel": "self.version", @@ -163,6 +166,7 @@ "Firefly\\Eda\\Kafka\\": "packages/eda-kafka/src/", "Firefly\\Eda\\Postgres\\": "packages/eda-postgres/src/", "Firefly\\Eda\\Rabbitmq\\": "packages/eda-rabbitmq/src/", + "Firefly\\FeatureFlags\\": "packages/feature-flags/src/", "Firefly\\Installer\\": "packages/installer/src/", "Firefly\\Kernel\\": "packages/kernel/src/", "Firefly\\Messaging\\": "packages/messaging/src/", @@ -253,6 +257,8 @@ "Firefly\\Scheduling\\Postgres\\SchedulingPostgresServiceProvider", "Firefly\\Security\\OAuth2\\Client\\SecurityOAuth2ClientServiceProvider", "Firefly\\Security\\OAuth2\\Client\\SecurityOAuth2ClientWiringProvider", + "Firefly\\FeatureFlags\\FeatureFlagsServiceProvider", + "Firefly\\FeatureFlags\\FeatureFlagsWiringProvider", "Firefly\\Cli\\Boot\\FireflyCacheServiceProvider" ] } diff --git a/deptrac.yaml b/deptrac.yaml index fd66422b..e1eab59b 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -98,6 +98,10 @@ deptrac: collectors: - type: directory value: packages/admin/src/.* + - name: FeatureFlags + collectors: + - type: directory + value: packages/feature-flags/src/.* - name: OpenApi collectors: - type: directory @@ -334,6 +338,11 @@ deptrac: # is a `suggest`, not a require. The direction is Security -> OpenApi and NEVER the reverse: the # generator names no Security type, which is what keeps it generating a document in an app with no # security at all. + # + # The => FeatureFlags edge is two classes (src/FeatureFlags): firefly/feature-flags owns an + # EvaluationContextContributor port and a FlagActorSource port, and this package fills them from the + # session-held principal (targetingKey, roles, tenant; the actor of a flag write). Guarded by + # #[ConditionalOnClass]; firefly/feature-flags is a `suggest`. Security -> FeatureFlags, NEVER the reverse. Security: - Kernel - Container @@ -346,6 +355,7 @@ deptrac: - Validation - Actuator - OpenApi + - FeatureFlags # SecurityOAuth2Client is the OAuth2 client / OIDC login capability over Security: it reuses Security's # principal model, session repository, entry point, login page ports, event publisher, JWKS providers and @@ -422,6 +432,9 @@ deptrac: # generalised out of the transactional proxy in the first place. Observability CONSUMES those ports and adds # nothing to them; the direction is Observability -> Data and NEVER the reverse (Data names no meter, no span # and no tracer). + # + # The => FeatureFlags edge is one class (src/FeatureFlags): MeterRegistryFeatureFlagMetrics backs the + # FeatureFlagMetrics port with feature_flag_evaluations_total{flag, variant, reason} — the CqrsMetrics seam. Observability: - Kernel - Container @@ -434,6 +447,7 @@ deptrac: - Eda - Resilience - Data + - FeatureFlags # Admin is the browser front-end for Actuator: it renders the SAME ActuatorEndpoint beans the JSON surface # serves, resolved in-process from Actuator's registry rather than fetched over HTTP (=> Actuator), mounts @@ -459,6 +473,27 @@ deptrac: - Actuator - Data + # FeatureFlags is the feature-flag capability on OpenFeature (open-feature/sdk, unlayered): a flagd evaluator, + # layered flag sources, a cache-backed registry and a writable store. It reads Config, carries Container/Context + # attributes, extends AutoConfigure's base and reuses Kernel exceptions. + # + # The => Data edge is the #[FeatureFlag] attribute's half (src/Gating, src/Scanner): a FeatureFlagAdviceSource + + # FeatureFlagMethodInterceptor on the method-interceptor chain Data owns, and the compiled ProxyPlan the route + # gate reads. The => Web edge is the route gate (RouteManifest, the #[RestController] stereotype the scanner + # recognises). The => Actuator edge is the `flags` endpoint and the health indicator (the EdaRabbitmq + # precedent). The => Resilience edge is Duration::parse (the Scheduling precedent). Every edge points one way: + # Security (the principal contributor), Observability (the metrics adapter), Testing and Cli depend on it. + FeatureFlags: + - Kernel + - Container + - Config + - Context + - AutoConfigure + - Data + - Web + - Actuator + - Resilience + # OpenApi is a top-of-stack capability (like Web/Cqrs/Security/Actuator): it generates an OpenAPI 3.1 # document from manifests that already exist, so it is nearly all READS. It reads Web's RouteManifest/ # RouteDescriptor (paths, verbs, statuses, binding plans) and Validation's ConstraintManifest + Rule @@ -483,6 +518,9 @@ deptrac: # package's frozen ports, so it depends on ALL layers (and SecurityOAuth2Client, for actingAsOidcUser()). # It is depended on by NONE — consumers use it only from their tests/, which Deptrac's src-only # collectors never see (no cycle; dev-scoped dep). + # + # The => FeatureFlags edge is the test-override layer: FeatureFlagOverrides and withFeatureFlags() install it + # over the composed flag set, so a test pins a flag without touching config, a file or the store. Testing: - Kernel - Container @@ -504,6 +542,7 @@ deptrac: - SecurityOAuth2Server - Actuator - Observability + - FeatureFlags # Cli is the top-of-stack developer-experience package: firefly:cache drives each of the 12 # compile-producing packages' scanner/compiler pair directly (D4 DIRECT DEPENDENCY). UNLIKE Testing @@ -515,6 +554,9 @@ deptrac: # beside the transactional, security and observability ones, so firefly:cache compiles #[Retry]/ # #[CircuitBreaker]/… into the same plan — which is a genuine call into the package, and # packages/cli/composer.json requires it accordingly. + # FeatureFlags is listed for the same reason: ManifestCacheWriter's planner names FeatureFlagAdviceSource, so + # firefly:cache compiles #[FeatureFlag] into the same proxy plan; packages/cli/composer.json requires it + # accordingly. # It is depended on by NONE. It does NOT depend on Testing (dev-only). Cli: - Kernel @@ -534,6 +576,7 @@ deptrac: - Actuator - Observability - Resilience + - FeatureFlags # The bundled installer reads the framework version for its local skeleton repository. Installer: diff --git a/docs/README.md b/docs/README.md index 532bcea4..aea53661 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,11 +28,11 @@ ## Module Guides Every module guide lives under [`modules/`](modules/), in the ten groups `mkdocs.yml`'s navigation and the -site's own [Modules](modules.md) landing page use. All 32 are listed here, and neither the list nor the +site's own [Modules](modules.md) landing page use. All 33 are listed here, and neither the list nor the number is maintained on trust: `tests/ModuleDocumentationTest.php` fails the build if a guide is missing from the navigation, from the table below, from the root `README.md`'s table, from `docs/index.md` or from the landing page, and `tests/SiteNavigationTest.php` reads that number off `docs/modules/*.md` itself, so a -thirty-third guide turns this page red rather than quietly making it wrong. +thirty-fourth guide turns this page red rather than quietly making it wrong. ### Foundation @@ -102,6 +102,7 @@ thirty-third guide turns this page red rather than quietly making it wrong. | [Admin Dashboard](modules/admin.md) | `firefly/admin` — the browser dashboard over the actuator; reads its endpoints in-process, so its own URL is the security boundary | | [Bean Graph](modules/bean-graph.md) | The dashboard's drawn dependency graph — components, `#[Bean]` products and `#[ConfigProperties]` DTOs as nodes, interface-resolved edges, longest-path layering, cycle reporting | | [Data Browser](modules/data-browser.md) | A Django-style database browser over `CrudRepository` beans — **off by default**, writes behind a second gate, with filtering, paging, relations you can walk, and an entity map | +| [Feature Flags](modules/feature-flags.md) | `firefly/feature-flags` — flagd flags on OpenFeature, gates, layered sources, writable store and audit trail | ### Testing @@ -122,7 +123,7 @@ thirty-third guide turns this page red rather than quietly making it wrong. | Document | Description | |----------|-------------| -| [Modules](modules.md) | The grouped index of all 32 module guides, and how a package wires itself | +| [Modules](modules.md) | The grouped index of all 33 module guides, and how a package wires itself | | [CLI Reference](cli.md) | Every `firefly:*` command and `make:firefly-*` generator, including the four contributed by capability packages | | [Laravel Comparison](laravel-comparison.md) | Side-by-side concept mapping for developers coming from plain Laravel | | [Versioning](versioning.md) | CalVer (`YY.MM.Patch`), no `version` field, how Packagist derives releases from tags | diff --git a/docs/architecture.md b/docs/architecture.md index b7844979..385e9408 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,7 @@ taxonomy without acquiring a framework dependency — and it is the reason `dept ## Ports and adapters, enforced The rule that domain code depends only on ports is not a convention here; it is a build step. -`deptrac.yaml` declares **one layer per package** — 28 of them, each collecting `packages//src/.*` — +`deptrac.yaml` declares **one layer per package** — 29 of them, each collecting `packages//src/.*` — and then a `ruleset` that gives, for every layer, the *complete* list of layers it is allowed to reference. Anything not on that list is a violation, and `composer check` runs `deptrac analyse` as its fourth step, so a `use` statement pointing the wrong way fails the gate rather than a review. @@ -268,10 +268,10 @@ onto every record beside the correlation and request ids, and `firefly.logging.s `json`, `ecs` or `logstash`, or empty for plain text — decides how a line is rendered, attached per channel at resolution time. -Metrics and introspection are the actuator's. Fifteen `ActuatorEndpoint` implementations ship, discovered +Metrics and introspection are the actuator's. Sixteen `ActuatorEndpoint` implementations ship, discovered into the `ActuatorRegistry` — health, info, env, beans, conditions, mappings, loggers, scheduledtasks, caches, -configprops, metrics, prometheus, process, httpexchanges, and oauth2clients when the authorization server is -installed — and the sensitive ones answer **404** until named in +configprops, metrics, prometheus, process, httpexchanges, oauth2clients when the authorization server is +installed, and flags when feature flags are enabled — and the sensitive ones answer **404** until named in `firefly.management.endpoints.web.exposure.include`. `firefly/admin` renders those same beans server-side by resolving them **in-process from that registry**, never by fetching its own HTTP surface: one data path, and pages the JSON surface deliberately keeps unexposed. That is also why the dashboard's own URL is its entire diff --git a/docs/book.md b/docs/book.md index 6c49c094..2aab71d3 100644 --- a/docs/book.md +++ b/docs/book.md @@ -1,7 +1,7 @@ # LaraFly by Example Learn LaraFly by building **Lumen**, the wallet-and-ledger service included in the framework. -Both editions cover the same application: a quick start, fifteen chapters, a Laravel cheat-sheet, +Both editions cover the same application: a quick start, sixteen chapters, a Laravel cheat-sheet, and a glossary. Every PHP listing is checked against the repository or linted by PHP. ## Download the book @@ -27,7 +27,7 @@ composer global require fireflyframework/larafly firefly new my-app ``` -All 29 components are included in that package. An existing application starts with +All 30 components are included in that package. An existing application starts with `composer require fireflyframework/larafly`; compatible legacy `firefly/*` requirements are then satisfied by the framework's `replace` declarations. See [Installation](installation.md) for details. diff --git a/docs/cli.md b/docs/cli.md index 9241f7ad..f208be69 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -8,10 +8,10 @@ framework stereotype (`make:firefly-*`), and thinly delegates to Laravel's own ` commands (`firefly:serve`/`firefly:schedule`/`firefly:db`). Its own Deptrac `Cli` layer — depends on the rest of the framework, depended on by nothing. -Four more `firefly:*` commands ship with the capability they belong to rather than with the console: +Five more `firefly:*` commands ship with the capability they belong to rather than with the console: `firefly:management:serve` (`firefly/actuator`), `firefly:openapi` (`firefly/openapi`), -`firefly:eda:consume` (`firefly/eda`) and `firefly:outbox:relay` (`firefly/eda-postgres`). They are all -listed at the end of this page. +`firefly:eda:consume` (`firefly/eda`), `firefly:outbox:relay` (`firefly/eda-postgres`) and `firefly:flags` +(`firefly/feature-flags`). They are all listed at the end of this page. ## `firefly:cache` @@ -329,3 +329,4 @@ command is part of that capability rather than of the console. | `firefly:management:serve` | `firefly/actuator` | Runs a second dev listener for the actuator on the management port. `--host=`, `--port=`. See [`firefly:management:serve`](#fireflymanagementserve) above. | | `firefly:eda:consume` | `firefly/eda` | Runs the configured broker `EventConsumer`, dispatching to `#[EventListener]` handlers until stopped. `--destination=*` (repeatable; overrides `firefly.eda.destinations`), `--max-messages=`, `--time-limit=`, `--sleep=0` (idle ms between empty polls), `--poll-timeout=5000` (block ms per poll). The bound destinations are echoed at startup, so a worker subscribed to nothing is visible on line one. Errors out when no broker `EventConsumer` is bound — the `memory` and `queue` providers have no consumer loop, and `queue` uses `php artisan queue:work`. See [EDA](modules/eda.md). | | `firefly:outbox:relay` | `firefly/eda-postgres` | **Optional.** Forwards committed `firefly_eda_outbox` rows to the downstream broker named by `firefly.eda.postgres.relay.downstream_provider` (claim → publish → mark `PUBLISHED`/`FAILED`). `--max-messages=`, `--time-limit=`, `--sleep=1` (seconds between empty batches), `--batch-size=50`. In-process delivery is `firefly:eda:consume`'s job, not this command's, so an app that only needs `#[EventListener]` handlers never runs it; an unset or misconfigured `downstream_provider` fails loudly before a single row is claimed. See [EDA Brokers](modules/eda-brokers.md). | +| `firefly:flags` | `firefly/feature-flags` | Runs the `flags` management operations in-process: `list`, `show {key}`, `evaluate {key}` (`--context=`, `--targeting-key=`), and the writes `enable`, `disable`, `default-variant {key} {variant}`, `put {key} --file=`, `delete {key}` (each with `--expected-version=`). `--json` prints the management JSON, including `{error,message}` on a refusal. Writes need `firefly.feature-flags.management.writes` and a flag store; the actor is the principal, else `cli:`. An accepted write with pending local refresh exits successfully and asks the operator to check visibility again. | diff --git a/docs/feature-flags-contract.md b/docs/feature-flags-contract.md new file mode 100644 index 00000000..a0b234ed --- /dev/null +++ b/docs/feature-flags-contract.md @@ -0,0 +1,398 @@ +# Feature flag contract + +PyFly and LaraFly evaluate feature flags identically. A flag document written for one framework evaluates to the +same value, variant and reason in the other, and a user lands in the same percentage bucket in a Python service and +in a PHP service. This page is the contract both frameworks implement. Each framework's test suite runs the same +conformance files, byte for byte, so a difference fails the build in either repository. + +The contract builds on two OpenFeature standards rather than inventing a format: + +- **The flagd flag-definition document** (`https://flagd.dev/schema/v0/flags.json`): flags, variants, a default + variant, and targeting rules in JSON Logic with the `fractional`, `sem_ver`, `starts_with` and `ends_with` + operators. +- **The flagd in-process evaluator semantics**, pinned to the evaluator suite of `open-feature/flagd-testbed` + `v3.10.2` with the `fractional-v2` bucketing algorithm. + +Firefly adds only a configuration shorthand and four reserved metadata keys. Both normalize to plain flagd before +anything is evaluated, so the evaluated document can always be served to, or read from, any flagd tooling. + +## Flag documents + + +```json +{ + "flags": { + "new-checkout": { + "state": "ENABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "off", + "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, + "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"} + } + }, + "$evaluators": {"is-beta": {"in": ["beta", {"var": "roles"}]}}, + "metadata": {} +} +``` + +### Rules every definition must satisfy + +| Rule | Message when broken | +|---|---| +| The key matches `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$` | `invalid flag key` | +| The definition is a JSON object | `flag definition must be an object` | +| `state` is `ENABLED` or `DISABLED` | `state must be ENABLED or DISABLED` | +| `variants` is a non-empty object | `variants must be a non-empty object` | +| Every variant value has the same JSON type: boolean, string, number, or object/array (`null` is not a flag value) | `variants must share one type` | +| `defaultVariant` names a variant, or is absent or `null` | `defaultVariant is not a variant` | +| `targeting`, when present, is an object (`{}` means no targeting) | `targeting must be an object` | +| Flag `metadata` values are strings, numbers or booleans | `metadata values must be scalars` | +| `metadata.kind` is one of `release`, `experiment`, `ops`, `permission` | `kind must be one of release, experiment, ops, permission` | +| `metadata.expires` is a calendar date `YYYY-MM-DD` | `expires must be a YYYY-MM-DD date` | +| `metadata.owner` is a string | `owner must be a string` | +| `metadata.description` is a string | `description must be a string` | +| Flag `metadata` keys are strings (a YAML `on:` key is a boolean, not a string) | `metadata keys must be strings` | +| Metadata keys are not empty | `metadata keys must not be empty` | +| Numbers anywhere in a definition are finite (YAML accepts `.nan` and `.inf`; JSON does not) | `numbers must be finite` | +| A definition nests at most 256 levels anywhere (the flag object is level 1; each object or array inside adds one) | `definition nests too deeply` | + +When a definition breaks more than one rule, the first rule in the table's order is reported, except that depth is +checked before finite numbers (a value too deep to walk is refused before its numbers are inspected). +| The document itself is an object | `document must be an object` (key ``) | +| The document's `flags`, when present and not null, is an object | `flags must be an object` | +| The document's `$evaluators`, when present and not null, is an object whose names are strings and whose rules are objects | `$evaluators must be an object`, `evaluator names must be strings`, `targeting must be an object` (key `$evaluators`) | +| The document's `metadata`, when present and not null, is an object whose keys are strings and whose values are strings, numbers or booleans | `metadata must be an object`, `metadata keys must be strings`, `metadata values must be scalars` | + +A YAML key that YAML reads as a date (an unquoted `2024-01-01:`) is not portable: PyFly refuses it (`metadata keys +must be strings`), while LaraFly's YAML parser turns it into a Unix-timestamp key before any check can see it. Quote +such keys. The same holds for any key at any depth inside a definition that YAML reads as something other than text +(`on:`, `null:`, `1.5:`, a date): PyFly refuses it, LaraFly's YAML 1.2 parser reads `on` and `null` as text. Quote keys. +Likewise an impossible calendar date written unquoted as a YAML value (`expires: 2025-02-30`): PyFly's +YAML parser refuses the file, while LaraFly's rolls the date over (`2025-03-02`) before validation sees it. Quote +dates in YAML; JSON cannot express the case. + +An empty list (`[]`) where an object is expected — a flag's `targeting` or `metadata`, or a document section — counts +as an empty object (`{}`): PHP cannot tell the two apart in native configuration arrays. A non-empty list is still an +error. + +Fields other than `state`, `variants`, `defaultVariant`, `targeting` and `metadata` are ignored: they are neither +an error nor evaluated, so a stray `description` beside `state` does not reject the document. + +JSON member names are preserved exactly, including a leading U+0000 (written `\u0000` in JSON), wherever the +rules above allow that name. Native object-property restrictions must not drop a member, change its name or make +an otherwise valid document unreadable. This includes nested object values and flag/document metadata. Canonical +fingerprints and store/sync round trips preserve these names; the flag-key pattern and reserved metadata rules +still apply. + +A `{"$ref": "name"}` that names no evaluator is not a load error. The document loads, and evaluating that flag +returns the caller's default with the error code `PARSE_ERROR`, as flagd does. + +`$ref`s resolve on the parsed document, structurally and transitively: an evaluator may reference another evaluator, +whatever their names sort as, and strings inside an evaluator are kept exactly as written (a backslash stays a +backslash). A reference cycle, like a missing name, makes evaluating the flag that reaches it a `PARSE_ERROR`. So +does a flag whose targeting would expand to more than 10 000 JSON values (every object, array and scalar counts one, +and so does every `$ref` resolved along the way, so a chain of references to references stays bounded too): +references that fan out compound quickly, and the limit keeps a pathological document from exhausting memory. And so +does a flag whose expanded targeting nests deeper than 128 levels: the targeting object is level 1, each object or +array inside a container adds one, and a resolved `$ref` takes the place of its `{"$ref": …}` object without adding +a level. Both limits apply to one flag at a time; the rest of the document evaluates normally. References resolve only +inside a flag's `targeting`. + +### Reserved metadata + +| Key | Meaning | +|---|---| +| `description` | what the flag is for | +| `owner` | the team or person responsible for it | +| `kind` | `release` (ship dark, then turn on), `experiment` (variants under test), `ops` (kill switch or operational lever), `permission` (an entitlement) | +| `expires` | the last day the flag should exist | + +A flag whose `expires` date is before today (UTC) is **expired**. It still evaluates normally. The admin page, the +`flags` actuator endpoint, the health details and one warning at startup list it as flag debt to remove. + +### Shorthand + +Configuration and test overrides accept a shorthand for the two most common flags: + +| Written | Normalized definition | +|---|---| +| `true` | `{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}` | +| `false` | `{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"}` | +| `"v2"` | `{"state": "ENABLED", "variants": {"v2": "v2"}, "defaultVariant": "v2"}` | + +Numbers, arrays and objects have no shorthand; write the full definition. + +## Evaluation + +| Situation | Value | Variant | Reason | Error code | +|---|---|---|---|---| +| flag `DISABLED` | caller default | none | `DISABLED` | | +| no targeting | `defaultVariant` value | `defaultVariant` | `STATIC` | | +| targeting returns a variant name | that variant's value | that variant | `TARGETING_MATCH` | | +| targeting returns `null` | `defaultVariant` value | `defaultVariant` | `DEFAULT` | | +| no `defaultVariant` (absent, `null` or `""`) and nothing matched | caller default | none | `DEFAULT` | | +| unknown flag | caller default | none | `ERROR` | `FLAG_NOT_FOUND` | +| value type differs from the requested type (a boolean is never a number) | caller default | none | `ERROR` | `TYPE_MISMATCH` | +| invalid targeting or a missing `$ref` | caller default | none | `ERROR` | `PARSE_ERROR` | +| targeting returns a name that is not a variant | caller default | none | `ERROR` | `GENERAL` | + +Targeting rules see the evaluation context attributes as variables, plus `targetingKey`, `$flagd.flagKey` (the flag +being evaluated) and `$flagd.timestamp` (Unix seconds). + +### Percentage bucketing + +`fractional` assigns a stable bucket: + +1. `bucketBy` is the explicit first argument when it is an expression, otherwise `$flagd.flagKey` concatenated with + `targetingKey`. +2. `hash` is MurmurHash3 x86 32-bit of the UTF-8 bytes of `bucketBy`, seed 0, read as an **unsigned** integer. +3. Weights are non-negative integers (a negative weight counts as 0); their total must not exceed 2³¹−1. +4. `bucket = (hash × totalWeight) >> 32`; the variant is the first whose cumulative weight exceeds `bucket`. +5. Without a targeting key and without an explicit expression the operator yields `null`, so the default variant + applies. + + +```json +{"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]} +``` + +## Evaluation context + +Each framework builds an ambient context and merges the caller's explicit context over it; the caller wins. +Within an explicit JSON context, `targetingKey` accepts a non-empty string or an integer (converted to decimal +text). A boolean is not an integer. Null and an empty string supply no key; other JSON types are ignored with a +DEBUG diagnostic, preserving a lower-precedence key when present. `targetingKey` is removed from the ordinary +attributes. A separately supplied non-empty targeting-key argument takes precedence over the explicit context. +Language-specific API signatures still apply to that separate argument. + +| Attribute | Value | +|---|---| +| `targetingKey` | the stable identifier of the authenticated principal; absent for anonymous traffic | +| `roles` | the principal's role names, without a `ROLE_` prefix | +| `tenant` | the tenant identifier, read from the principal attribute named by `context.tenant-attribute` (default `tenant`) | +| `application` | the application name | +| `profiles` | the active profiles | + +Context values are JSON values, with one conversion: a date-time value (a Python `datetime`/`date`, a PHP +`DateTimeInterface`) is evaluated as its Unix epoch time in **milliseconds**, a number, in both frameworks, so a rule +such as `{">": [{"var": "signupAt"}, 1735689600000]}` decides the same everywhere and never depends on the host's time +zone. The number is computed exactly the same way in both: the whole number of microseconds since the epoch, divided +by 1 000 and correctly rounded to the nearest floating-point number; a date-time without a zone is read as UTC, and a date is midnight UTC. + +Applications add attributes, such as a subscription `plan`, with context contributor beans that run after the +built-in ones. Anonymous traffic has no targeting key, so a percentage rollout gives it the default variant unless +the application contributes a stable anonymous identifier. + +## Sources and precedence + +Flags come from layered sources, lowest precedence first: + +1. **config**: definitions inline in the application configuration (`feature-flags.flags`, `feature-flags.evaluators`) +2. **file**: a flagd document in JSON or YAML, reloaded when it changes +3. **http**: a flagd document polled from another service's sync endpoint +4. **store**: the writable layer behind the admin page, the actuator endpoint and the CLI +5. **test overrides**: test support only + +The highest layer that defines a key supplies its **whole** definition; definitions are never merged field by +field. Shared evaluators merge by name with the same precedence, and document metadata merges by key. Every +composed flag records its `origin` and the lower layers it `overrides`. The test-only layer's public source name is +`test-overrides`, including composition origins and layer entries. Its change events use the same name. It is +excluded from the shared document served by the sync endpoint. + +A source that fails keeps its last good document, and a document that fails validation is rejected as a whole. At +startup an invalid definition in the configuration or in the file stops the application with the flag key and the +reason; a remote or store failure never stops it. Each change to the effective set publishes a +`FeatureFlagsChanged` event naming the changed keys. + +Loading includes serializing and adopting a source snapshot. A serialization or read-back failure is a source +failure under the same startup/last-good rules; it must not publish an empty successful source or replace its +previous accepted document. + +## The store + +The store layer is two relational tables with the same logical schema in both frameworks, so two applications that +share a database share their runtime flag changes. + + +```sql +CREATE TABLE firefly_feature_flags ( + flag_key VARCHAR(128) NOT NULL PRIMARY KEY, + definition TEXT NOT NULL, + version INTEGER NOT NULL, + updated_at TIMESTAMP NOT NULL, + updated_by VARCHAR(255) NULL +); + +CREATE TABLE firefly_feature_flag_changes ( + id BIGINT NOT NULL PRIMARY KEY, + flag_key VARCHAR(128) NOT NULL, + action VARCHAR(16) NOT NULL, + definition TEXT NULL, + previous TEXT NULL, + actor VARCHAR(255) NULL, + changed_at TIMESTAMP NOT NULL +); + +CREATE INDEX firefly_feature_flag_changes_key ON firefly_feature_flag_changes (flag_key, id); +``` + +The SQL above describes logical types. Flag keys compare exactly, including case, on every supported backend. +MySQL and MariaDB use a binary collation for flag keys and `LONGTEXT` for JSON payloads, so their ordinary +case-insensitive collations and 64 KiB `TEXT` limit do not change the contract. Timestamps store zoneless UTC with +microsecond precision: `DATETIME(6)` on MySQL/MariaDB, `TIMESTAMP(6) WITHOUT TIME ZONE` on PostgreSQL, and equivalent +UTC timestamp text on SQLite. A connection's session timezone must not reinterpret those stored UTC values. + +- `definition` holds one flagd flag object as JSON. `version` starts at 1 and grows by one per update. Times are UTC. + `id` is auto-incremented. `action` is `put` or `delete`; `definition` is the state after the change (null on + delete) and `previous` the state before it (null on the first put). +- Every write updates `firefly_feature_flags` and appends one `firefly_feature_flag_changes` row in the same + transaction. +- After a durable put or delete commits, the writer returns its committed change even if the local refresh is + refused or fails, a change/update listener throws, or warning logging fails. Refresh and + `FeatureFlagUpdated` publication are attempted independently after commit. A failed refresh retains the + last good composition; observer failures are logged best-effort and never reported as a failed write. +- The store **revision** is the highest change `id` (0 when there is none). Readers poll it every + `refresh-interval` and reload the rows only when it moved. When its local refresh succeeds, the process that + writes sees its change at once. +- A write may carry `expectedVersion`. When the stored version differs, or a row exists where `0` was expected, the + write is a `conflict` and changes nothing. + Create-only writes (`expectedVersion: 0`) arbitrate absence with an atomic insert, without first creating an + unnecessary read snapshot or missing-row lock. Two competing creates yield one stored definition and one audit + entry, with a conflict for the loser unless the database aborts its parent transaction as described below. + Without an expected version, a store-owned root operation retries a known + row race for at most three attempts using fresh transactions; it never replays a caller-owned transaction. + This does not promise recovery from arbitrary database errors. +- Native transaction aborts remain database transaction failures. In particular, MariaDB with snapshot isolation + enabled can abort the entire caller transaction when its existing snapshot conflicts with a concurrent insert; + a savepoint cannot preserve earlier caller writes after that engine-level abort. The caller must roll back and + retry its complete unit of work. Neither an operation-level conflict exception nor a transaction counter proves + that the parent is still usable. Native conformance tests pin both the final flag/audit state and preservation + or loss of caller-owned marker rows under the configured backend isolation; no aborted parent may report a + successful commit. +- Enabling, disabling or changing the default variant of a flag the store does not hold copies the current + effective definition into the store with the change applied. That override shadows the lower layers until it is + deleted, which reverts the flag to the next layer. + +## The sync endpoint + +A service serves its effective flag set; other services poll it as their `http` source. + +- `GET` on the configured path (default `/feature-flags/flagd.json`) returns the composed document, every layer + except test overrides, as flagd JSON with `ETag: ""` and `Cache-Control: no-cache`. +- A request whose `If-None-Match` contains the current ETag (strong or weak, including a comma-separated list), + or is `*`, gets `304 Not Modified`. Authentication is checked before the condition. +- A request must carry `Authorization: Bearer `; a missing or wrong token gets `401`. Enabling the + endpoint without a token stops startup unless `server.allow-anonymous` is true. +- The polling client sends `If-None-Match`, its bearer token and a timeout. A `304` keeps its document; any error + keeps its last good document and shows in health. A `304` without a previously accepted revision is an error. + When a valid response omits its ETag or sends an empty one, the client uses the quoted SHA-256 of its exact body + bytes as the revision and sends it on the next conditional request. + +## The `flags` actuator endpoint + +Both frameworks answer with the same JSON. + +`GET /actuator/flags` lists the provider, the enabled sources in precedence order, and every flag. Every timestamp +in the management JSON is UTC ISO-8601 with seconds and a `Z` suffix: + + +```json +{ + "provider": {"name": "firefly", "status": "READY"}, + "writable": true, + "writesEnabled": true, + "sources": [ + {"name": "config", "enabled": true, "status": "UP", "flags": 2, "lastRefresh": "2026-10-01T09:30:00Z", + "error": null, "revision": null} + ], + "flags": [ + {"key": "new-checkout", "state": "ENABLED", "type": "boolean", "variants": ["on", "off"], + "defaultVariant": "off", "targeting": true, "origin": "store", "overrides": ["config"], + "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}, "expired": false, "version": 3} + ] +} +``` + +`GET /actuator/flags/{key}` returns `key`, `definition`, `origin`, `layers` (each source's definition), +`version`, `expired` and `history` (the latest 50 changes, newest first: `id`, `action`, `actor`, `changedAt`). +An unknown key is a 404 with `{"error":"unknown-flag","message":""}` in both frameworks. + +`POST /actuator/flags/{key}` takes an `action`: + +| Action | Body | Needs writes | +|---|---|---| +| `evaluate` | `context` (object), `targetingKey` | no | +| `enable`, `disable` | `expectedVersion` (optional) | yes | +| `default-variant` | `variant`, `expectedVersion` (optional) | yes | +| `put` | `definition`, `expectedVersion` (optional) | yes | +| `delete` | `expectedVersion` (optional) | yes | + +`evaluate` answers `key`, `value`, `variant`, `reason`, `errorCode` and `metadata`. The preview evaluates with the +body's `context` and `targetingKey` plus the process attributes `application` and `profiles`, never the caller's +own principal, and it records no metric and no exposure event. Evaluating or deleting a key that no layer defines +(for `delete`: that the store does not hold) is `unknown-flag`. A successful write answers +the key's `GET` body after the write, or `{"key": …, "deleted": true}` when a delete leaves no layer defining it. +If a successful `put` has no locally visible definition because refresh is deferred, fails, or is refused, it +answers `{"key": …, "refreshPending": true}` instead. This receipt does not claim a deletion or a completed +caller-owned transaction. When a last-good definition remains visible, the response is its current `GET` body; +the response alone does not guarantee that this process has adopted the latest store revision. Normal polling +recovers visibility. A post-write diagnostic failure must not turn an accepted write into a failed operation. +Writes need `management.writes` and a store. + +Errors answer `{"error": "", "message": ""}`: + +| Code | Meaning | LaraFly status | +|---|---|---| +| `writes-disabled` | `management.writes` is false | 403 | +| `not-writable` | no store is configured | 409 | +| `invalid-definition` | the definition breaks a rule above | 422 | +| `unknown-flag` | no layer defines the key | 404 | +| `unknown-variant` | the variant is not one of the flag's variants | 422 | +| `conflict` | `expectedVersion` did not match | 409 | +| `bad-request` | the body is malformed | 400 | + +PyFly's actuator answers every POST management error body with 400, the contract of its write operations; GET +unknown selectors still answer 404. Malformed JSON, an empty body, or a non-object POST body answers 400 with +`bad-request` in both frameworks. The `error` code is the portable signal. The actor recorded for a write is the +authenticated principal's name, or `actuator`, `cli:` or `admin` when there is none. This fallback comes +from the trusted calling channel: HTTP query parameters, headers and body fields cannot select the admin actor. + +For an HTTP `evaluate` request, an omitted or null `context` means no explicit attributes. A supplied non-null +context must be a JSON object; arrays (including `[]`) and scalars are `bad-request`. This JSON boundary does not +change either framework's native empty-map representation. + +The CLI `evaluate` option `--context`, when supplied, must contain the text of a JSON object. Empty text, whitespace, +malformed JSON, JSON null, arrays and scalars are `bad-request` with exit 1; `--json` preserves the portable error +body. Omitting the option or supplying `{}` provides no explicit attributes. This input check applies before local +or remote dispatch and never performs an evaluation or write for invalid input. + +## Telemetry and events + +- Counter `feature_flag_evaluations_total` with labels `flag`, `variant` (`none` when there is no variant) and + `reason` (`ERROR` for a failed evaluation). Telemetry never changes an evaluation. +- `FeatureFlagsChanged(changedKeys, origin)` when the effective set changes; `origin` is the refreshed source's + name, `startup` for the first composition, or `test-overrides`. +- `FeatureFlagUpdated(key, action, actor, previous, current)` after a committed store write. +- `FeatureFlagEvaluated(key, value, variant, reason, errorCode, targetingKey)` after every evaluation, only when + `events.evaluations` is true. These are the exposure records an experiment's analysis needs. + Exposure snapshots are best-effort: before copying, inspect at most 10 000 value occurrences. The root and every + container or scalar (including null) each count one; object keys do not count; repeated references count again. + Exactly 10 000 is allowed. If another occurrence remains, omit the exposure and log at DEBUG best-effort, leaving + the evaluation result and its metric unchanged. This is only an exposure-snapshot limit, not a definition + acceptance, stored-value, caller-default or evaluation limit. Cyclic runtime values exhaust this budget; no + partially copied exposure is published. + +## Conformance files + +Both repositories carry the same files, and a test in each recomputes their SHA-256 manifest: + +- `testbed/`: the flagd-testbed `v3.10.2` evaluator suite. Every scenario runs except those tagged + `@fractional-v1`, the legacy bucketing. +- `firefly-vectors.json`: shorthand normalization, validation messages, layer composition, expiry, evaluation + cases, and five bucketing tables of 301 targeting keys each, generated by the reference evaluator. +- `exposure-vectors.json`, `observer-vectors.json`, `override-vectors.json`, `context-vectors.json`, + `management-vectors.json`, `management-transport-vectors.json`, `store-concurrency-vectors.json`, and `http-vectors.json`: shared boundary and + lifecycle cases consumed by the corresponding framework adapters. +- `MANIFEST.sha256`: the SHA-256 of every payload file above. + +To change the contract, change the files in both repositories in the same release and regenerate the manifest. diff --git a/docs/index.md b/docs/index.md index b8327686..495c9f17 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ CQRS, event-driven architecture, first-party security including both halves of O directly onto Laravel's own runtime. Nothing is forked or wrapped: a LaraFly app is, in every respect a Laravel developer would recognize, still a Laravel app — it just boots like a Spring Boot one. -The whole framework ships as one Composer library, `fireflyframework/larafly`, containing all 29 components, +The whole framework ships as one Composer library, `fireflyframework/larafly`, containing all 30 components, wired together by a single zero-reflection boot pipeline: a component scan compiles to a cached manifest once, and every request after that runs against plain PHP arrays — no runtime reflection on the hot path. @@ -103,7 +103,7 @@ See [Installation](installation.md) for requirements and manual setup, and ## The modules -Every capability above ships in the root library; configuration selects optional features. The [module index](modules.md) lays all 32 +Every capability above ships in the root library; configuration selects optional features. The [module index](modules.md) lays all 33 guides out by concern, with a line on each saying what it is for, and the same grouping is the site's **Modules** tab. @@ -116,7 +116,7 @@ guides out by concern, with a line on each saying what it is for, and the same g | **Eventing & Messaging** | [EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [Messaging](modules/messaging.md) | | **CQRS** | [Command/Query](modules/cqrs.md) | | **Security** | [Security](modules/security.md) · [OAuth2 Client](modules/security-oauth2-client.md) · [OAuth2 Authorization Server](modules/security-oauth2-server.md) | -| **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) · [Tracing](modules/tracing.md) · [Logging](modules/logging.md) · [Admin Dashboard](modules/admin.md) · [Bean Graph](modules/bean-graph.md) · [Data Browser](modules/data-browser.md) | +| **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) · [Tracing](modules/tracing.md) · [Logging](modules/logging.md) · [Admin Dashboard](modules/admin.md) · [Bean Graph](modules/bean-graph.md) · [Data Browser](modules/data-browser.md) · [Feature Flags](modules/feature-flags.md) | | **Testing** | [Testing](modules/testing.md) · [Integration Testing](modules/integration-testing.md) | | **Tooling** | [Installer](modules/installer.md) | @@ -126,7 +126,7 @@ Want to see it all working together? The [Lumen sample](https://github.com/fireflyframework/fireflyframework-php/tree/main/samples/lumen) is a runnable digital-wallet & ledger vertical slice exercising `#[Transactional]`, CQRS, domain events over EDA, method security, and a REST layer with RFC-7807 problem-details. The guided, book-style *LaraFly by Example* -book — 15 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available +book — 16 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available as [PDF and EPUB downloads in English and Spanish](book.md). ## Quick Links diff --git a/docs/modules.md b/docs/modules.md index ab1208ac..69241792 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -1,12 +1,12 @@ # Modules -LaraFly publishes one `fireflyframework/larafly` library containing the **29 component directories** under -`packages/*`, each with its own tests or metadata. The **32 guides** below explain their public surfaces. +LaraFly publishes one `fireflyframework/larafly` library containing the **30 component directories** under +`packages/*`, each with its own tests or metadata. The **33 guides** below explain their public surfaces. Several components carry more than one guide: `firefly/data` answers for *Data & Repositories*, *Relational Data* and *Transactions*. Laravel discovers the root library's providers, and configuration selects optional features. A bean you declare wins over the framework default. Two idioms deliver that outcome, and -which one you are looking at matters the moment you go reading the source. **17 of the 29 packages** carry a +which one you are looking at matters the moment you go reading the source. **18 of the 30 packages** carry a `#[Configuration]` class whose `#[Bean]` methods sit behind `#[ConditionalOnMissingBean]`, so a bean of yours makes the framework's back off silently — `packages/data/src/DataAutoConfiguration.php` is the model. The **other 12** have no such class to go looking for; `firefly/web`, the one nearly every reader installs, guards @@ -64,8 +64,8 @@ each binding in `WebServiceProvider::register()` with a `bound()` check instead, Operations [Actuator](modules/actuator.md) · [Observability](modules/observability.md) · [Tracing](modules/tracing.md) · [Logging](modules/logging.md) · [Admin Dashboard](modules/admin.md) · -[Bean Graph](modules/bean-graph.md) · [Data Browser](modules/data-browser.md) -Health, info and metrics over an actuator surface, OpenTelemetry-shaped tracing with W3C propagation, structured JSON/ECS/Logstash logging, and a server-rendered dashboard with a drawn bean graph and a database browser. +[Bean Graph](modules/bean-graph.md) · [Data Browser](modules/data-browser.md) · [Feature Flags](modules/feature-flags.md) +Health, info and metrics over an actuator surface, OpenTelemetry-shaped tracing with W3C propagation, structured JSON/ECS/Logstash logging, a server-rendered dashboard with a bean graph and database browser, and feature flags changed at runtime.
diff --git a/docs/modules/actuator.md b/docs/modules/actuator.md index 32fc4d5d..1921de98 100644 --- a/docs/modules/actuator.md +++ b/docs/modules/actuator.md @@ -23,6 +23,9 @@ always-on, and secured entirely by M11 config with zero code edge to `firefly/se **per-process** on the default `memory` authorizations driver — a map rebuilt in every PHP worker — which is why the payload states its own storage model in `authorizations.processLocal`; `authorizations.driver = eloquent` is what makes them describe the deployment +- `/actuator/flags` (+ `/actuator/flags/{key}`, GET/POST) — supplied by [`firefly/feature-flags`](feature-flags.md) + when enabled: flags with origins and expiry, layers and history, previews, and writes when management writes + and a store are enabled !!! tip "A browser view over all of this" `firefly/admin` renders these same endpoints as a server-side dashboard, reading them **in-process** rather diff --git a/docs/modules/admin.md b/docs/modules/admin.md index 69ced98f..c124e40b 100644 --- a/docs/modules/admin.md +++ b/docs/modules/admin.md @@ -25,7 +25,7 @@ component requirement above. Most pages are a view over one `ActuatorEndpoint`'s payload; four read the container instead. The menu groups them the way an operator thinks rather than the way the packages are laid out — *what is it doing right now*, -*what did it wire at boot*, *what is its data*, *how is it configured* — because a flat list of eighteen links +*what did it wire at boot*, *what is its data*, *how is it configured* — because a flat list of nineteen links is a worse menu than four short ones. | Group | Page | Path | Endpoint | Answers | @@ -44,11 +44,18 @@ is a worse menu than four short ones. | Configuration | Config properties | `/firefly/configprops` | `configprops` | Every `#[ConfigProperties]` DTO the application bound, with the values it resolved | | Configuration | Caches | `/firefly/caches` | `caches` | The cache stores this application has configured | | Configuration | Loggers | `/firefly/loggers` | `loggers` | Log channels and their levels, with a control to change one | +| Configuration | **Feature flags** | `/firefly/flags` | `flags` | Every feature flag, its defining layer and history, an evaluation preview, and controls to toggle, edit and delete when a flag store and `firefly.feature-flags.management.writes` are enabled | | Configuration | **Feature switches** | `/firefly/settings` | — | Every framework switch, where its value came from, and — outside production — a control. **Off by default**; see [below](#the-feature-switch-console) | | Data | **Datasource** | `/firefly/datasource` | — | Connections, connection reuse, and the compiled `#[Transactional]` contract | | Data | **Browse data** | `/firefly/data` | — | The records behind your repositories — see [Data Browser](data-browser.md). **Off by default** | | Data | **Entity map** | `/firefly/data-map` | — | The entities and the foreign keys between them, drawn | +The Feature flags page shows the effective definition, each source layer and store change history. It offers +controls only when a store is configured and `firefly.feature-flags.management.writes` is enabled. Its evaluation +preview uses the context entered on the page and records no metric or exposure event. If a write is accepted but +the new definition is not yet visible, the page asks you to check again after refresh; it does not report a +deletion. Writes made by an authenticated principal retain that principal in history. + The four pages with no endpoint read the container rather than the actuator, and each decides its own visibility: an entry that led to "there is nothing here" is worse than no entry. @@ -469,7 +476,7 @@ container image, and a web form that edits the file holding your database passwo - **No instance registry.** Spring Boot Admin is a separate server that many applications register *with*, giving one console across a fleet. This is a per-instance dashboard, which is what makes the in-process read possible; a fleet view would need a different design and is not planned. -- **No write operations besides the log level, the data browser and the feature switches.** `/caches` is read-only +- **No write operations besides the log level, the data browser, the feature switches and feature flags.** `/caches` is read-only for the same reason it is read-only on the JSON surface — `firefly/actuator` carries no code edge to `firefly/security` and so cannot say who asked. - **The Health panel ignores `show-details` entirely**, reading the contributor registry rather than the JSON diff --git a/docs/modules/feature-flags.md b/docs/modules/feature-flags.md new file mode 100644 index 00000000..0601c888 --- /dev/null +++ b/docs/modules/feature-flags.md @@ -0,0 +1,561 @@ +# Feature Flags + +`firefly/feature-flags` is LaraFly's feature-flag subsystem, built on [OpenFeature](https://openfeature.dev): flags are +[flagd](https://flagd.dev) flag definitions, evaluated in-process by a PHP port of flagd's evaluator, and an +application gates code with an attribute, a route middleware, a Blade directive or a facade call. Operators change +flags at runtime from the [admin dashboard](admin.md), the `flags` actuator endpoint or `php artisan firefly:flags`, +with an audit trail, and every PHP-FPM worker sees the change within the source's refresh interval. + +The same flag document evaluates to the same value, variant and reason in LaraFly and in PyFly, and a user lands in +the same percentage bucket in both: the two frameworks share [one contract](../feature-flags-contract.md) and run the +same conformance files in their test suites. + +The checked-in [Lumen sample](https://github.com/fireflyframework/fireflyframework-php/tree/main/samples/lumen) exercises the facade, a gated route, shared targeting +vectors, test overrides, store transactions and preview exposure behavior. Both editions of *LaraFly by Example* +develop that sample in Chapter 13A. + +## Quick start + +Switch the subsystem on and declare flags in `config/firefly.php`: + + +```php +return [ + 'feature-flags' => [ + 'enabled' => true, + 'flags' => [ + 'new-checkout' => false, + 'checkout-flow' => 'v2', + ], + ], +]; +``` + +`true` and `false` are shorthand for a boolean flag with the variants `on` and `off`; a string is shorthand for a +flag with that single variant. Then ask for a flag wherever you need it: + + +```php +use Firefly\FeatureFlags\FeatureFlags; + +final class CheckoutPage +{ + public function __construct(private readonly FeatureFlags $flags) {} + + public function template(): string + { + if (! $this->flags->isEnabled('new-checkout')) { + return 'checkout.legacy'; + } + + return $this->flags->getString('checkout-flow', 'v1') === 'v2' ? 'checkout.v2' : 'checkout.v1'; + } +} +``` + +`FeatureFlags` has a typed getter per flag type — `isEnabled()`, `getString()`, `getInt()`, `getFloat()`, +`getObject()` — plus `variant()` and `details()`, which returns the value, the variant, the reason, the error code +and the flag's metadata. Every getter takes the default to answer when the flag is missing or fails, and an optional +evaluation context and targeting key. Under the facade sits a named OpenFeature client (`Client` is a bean too), so +code written against the OpenFeature PHP SDK works unchanged. + +## Defining flags + +A flag is a flagd flag definition: a `state`, the `variants` it can answer, the `defaultVariant`, optional +`targeting` rules in JSON Logic, and optional `metadata`. + + +```json +{ + "flags": { + "new-checkout": { + "state": "ENABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "off", + "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, + "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"} + } + }, + "$evaluators": {"is-beta": {"in": ["beta", {"var": "roles"}]}} +} +``` + +- **Keys** match `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`; they appear in URLs and CLI arguments. +- **Variants** all hold one JSON type: boolean, string, number, or object. +- **Targeting** returns a variant name, or `null` to fall back to `defaultVariant`. The operators are flagd's: + JSON Logic plus `fractional`, `sem_ver`, `starts_with` and `ends_with`; `{"$ref": "is-beta"}` reuses a shared rule + from `$evaluators`. +- **Metadata** values are scalars. Four keys are reserved: `description`, `owner`, `kind` (`release`, `experiment`, + `ops` or `permission`) and `expires` (`YYYY-MM-DD`). A flag past its `expires` date still evaluates; it is flagged + as expired in the admin page, the actuator, the health details, and one warning per day in the log. +- A field flagd does not define is kept as written and ignored. + +An invalid definition in `flags` or in a flag file refuses the boot, naming the key and the reason (for example +`Invalid feature flag [bad key] from source [config]: invalid flag key.`); an invalid document from the sync +endpoint or the store is rejected as a whole and the last good one kept. +After a file has loaded successfully, a later invalid edit also leaves its last good document in force. Replace a +file atomically (write a sibling file, then rename it) so a polling worker never parses a partial write. Quote YAML +date-like keys and values: YAML parsers otherwise disagree about dates and non-string keys that JSON cannot express. + +## Targeting recipes + +Every recipe is a flag definition you can put in `flags`, a flag file or the store. + +**Release toggle and kill switch.** A boolean flag, off by default; turning it on (or a `DISABLED` state, which +makes every gate answer its default) needs no deploy once the store is enabled. + + +```json +{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"} +``` + +**Progressive rollout.** `fractional` hashes the flag key and the targeting key (the signed-in principal's name), +so a user stays in their bucket as the percentages move, in PHP and in Python alike. Anonymous traffic has no +targeting key and gets the default variant. + + +```json +{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", + "targeting": {"fractional": [["on", 10], ["off", 90]]}} +``` + +**Beta cohort.** `roles` holds the principal's `ROLE_` authorities without the prefix. + + +```json +{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", + "targeting": {"if": [{"in": ["BETA", {"var": "roles"}]}, "on", null]}} +``` + +**Per-tenant or per-plan entitlement.** `tenant` comes from the principal attribute named by +`firefly.feature-flags.context.tenant-attribute`; `plan` comes from a context contributor of your own (below). + + +```json +{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", + "targeting": {"if": [{"or": [{"in": [{"var": "tenant"}, ["acme", "globex"]]}, {"==": [{"var": "plan"}, "enterprise"]}]}, "on", null]}} +``` + +**Multivariate experiment.** A weighted split with a holdout; switch `firefly.feature-flags.events.evaluations` on to +publish a `FeatureFlagEvaluated` event per evaluation — the exposure record the experiment's analysis needs. + + +```json +{"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", + "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, + "metadata": {"kind": "experiment", "owner": "growth"}} +``` + +## Gating code + +**On a bean.** `#[FeatureFlag]` on a method (or a class: every public method) of any stereotyped bean. While the +flag is off, the call answers the `fallback` method — called with the same arguments — or throws +`FeatureFlagDisabledException`. `variant:` gates on one variant; `default: true` opens the gate when the flag is +missing. + + +```php + +```php + 'Available balance']; + } +} +``` + +Routes declared in route files use the same middleware by its alias, `feature-flag:{key}[,{variant}]`: + + +A gated route that is off answers `firefly.feature-flags.web.disabled-status` — 404 (the default: a dark launch looks +like a page that does not exist), 403 or 503 — as problem+json, and never names the flag. + +**In a template.** + + +```blade +@featureflag('new-checkout') + @include('checkout.v2') +@else + @include('checkout.legacy') +@endfeatureflag + +@featurevariant('checkout-flow', 'v2') +

Try the faster checkout.

+@endfeaturevariant +``` + +Every gate fails **closed** on a missing flag, an evaluation failure, a type mismatch or a switched-off subsystem: +it answers the gate's default, which is "off" unless the gate says `default: true`. A source failure after a +successful load retains the last-good document, so a previously enabled gate can remain open during an outage. +A flag never decides which beans exist: +bean conditions are evaluated once at boot, so a boot-time switch is a property — use `#[ConditionalOnProperty]`. + +## Evaluation context + +The framework builds a context for every evaluation, and the caller's own attributes win over it: + +| Attribute | Value | +|---|---| +| `targetingKey` | the authenticated principal's name; absent when anonymous | +| `roles` | the principal's `ROLE_` authorities, without the prefix | +| `tenant` | the principal attribute named by `firefly.feature-flags.context.tenant-attribute` | +| `application` | `app.name` | +| `profiles` | the active Firefly profiles | + +Add your own attributes with a context contributor — any `#[Component]` implementing +`EvaluationContextContributor`; `#[Order]` sets the order, and a later contributor may override an earlier one: + + +```php +use Firefly\Container\Attributes\Component; +use Firefly\FeatureFlags\Context\EvaluationContextBuilder; +use Firefly\FeatureFlags\Context\EvaluationContextContributor; + +#[Component] +final class PlanContributor implements EvaluationContextContributor +{ + public function __construct(private readonly Subscriptions $subscriptions) {} + + public function contribute(EvaluationContextBuilder $context): void + { + $customer = $context->targetingKey(); + if ($customer !== null) { + $context->set('plan', $this->subscriptions->planOf($customer)); + } + } +} +``` + +An explicit context overlays all contributors. `targetingKey` in JSON context accepts a nonempty string or an +integer converted to decimal text; a separate nonempty targeting-key argument wins over it. LaraFly refuses an +Eloquent model, `Arrayable` or `Jsonable` object as a targeting key: pass the user's stable id. It drops unsupported +attribute values and numeric-looking attribute names with a DEBUG diagnostic. A `DateTimeInterface` value becomes +UTC epoch milliseconds, as in PyFly. + +## Sources and precedence + +Flags come from up to four sources, lowest precedence first: **config** (`flags` and `evaluators`) → a watched +**file** → another service's **http** sync endpoint → the writable **store**. The highest source that defines a key +supplies its whole definition; the admin page and the actuator show which source won (`origin`) and which it +shadows (`overrides`). + + +```php +return [ + 'feature-flags' => [ + 'enabled' => true, + 'sources' => [ + 'file' => ['enabled' => true, 'path' => base_path('flags.yaml'), 'refresh-interval' => '5s'], + 'http' => ['enabled' => true, 'url' => 'https://control-plane.internal/feature-flags/flagd.json', 'token' => env('FLAGS_TOKEN'), 'refresh-interval' => '30s', 'timeout' => '2s'], + 'store' => ['enabled' => true, 'driver' => 'database', 'connection' => null, 'refresh-interval' => '5s'], + ], + ], +]; +``` + +PHP-FPM shares nothing between requests, so the registry keeps each source's last good document and its revision +in the application cache. A request re-checks a source only when its `refresh-interval` has elapsed — a file's +modification time and size, the store's newest change id, an HTTP conditional GET under a non-blocking lock (one +worker fetches; the others serve the last good document meanwhile) — and the request that sees a change publishes +`FeatureFlagsChanged`. A long-lived process (Octane, a queue worker) re-checks on the same schedule. When the cache +store is down, sources are read in-process and flags keep evaluating. + +Give each application its own application-cache prefix and retain source entries without eviction; a fresh worker +needs those entries to recover the last good file or remote document. A file revision uses whole-second mtime and +size: same-size edits within the same timestamp can remain unseen indefinitely, even after atomic rename. Publish +atomically **and** advance the file's mtime to a distinct second (or change its size); do not rely on rename alone. + +Change events are best effort, not durable or exactly once. Rolling workers with different configuration can +alternate shared change announcements. Cache outages suspend shared announcements; recovery with no composed +record reports `startup`. Use the store audit history when you need a durable record of operator writes. + +## The store + +The store is the writable layer behind the admin page, the actuator endpoint and the CLI. A write replaces the +key's definition for every lower source until it is deleted; every write also appends a change row (who, what, +when), and a write may carry `expectedVersion` so two operators cannot overwrite each other. +The relational tables keep exact-case keys, JSON payloads and zoneless UTC microsecond timestamps across SQLite, +PostgreSQL, MySQL and MariaDB. A write inside the caller's transaction remains pending until that caller commits; +rollback removes both the definition and audit row. If MariaDB aborts an existing snapshot after a competing insert, +the caller must roll back and retry its complete operation. A post-commit local refresh failure preserves the +committed write and the last good local composition. + +With the `database` driver, publish and run the migration: + + +```bash +php artisan vendor:publish --tag=firefly-feature-flags-migrations +php artisan migrate +``` + +The migration is also loaded automatically while the store is enabled with the `database` driver. It creates +`firefly_feature_flags` and `firefly_feature_flag_changes`, the same tables PyFly creates: two services that share +a database share their flags, and either framework reads the rows the other wrote. `connection` picks the Laravel +connection (null is the default one). The `memory` driver keeps flags per process, for tests and demos. + +## Managing flags + +**Actuator.** `GET /actuator/flags` lists the provider, the sources and every flag; `GET /actuator/flags/{key}` +describes one, with each source's definition and the latest 50 changes; `POST /actuator/flags/{key}` evaluates +(`{"action": "evaluate", "context": {…}, "targetingKey": "…"}`) or writes (`enable`, `disable`, +`default-variant`, `put`, `delete`). The answers are the same JSON PyFly answers, and a refusal is +`{"error": code, "message": text}` with a status per code: `writes-disabled` 403, `not-writable` 409, +`invalid-definition` 422, `unknown-flag` 404, `unknown-variant` 422, `conflict` 409, `bad-request` 400. + +The sensitive `/actuator/flags` endpoint answers 404 until `flags` is named in `firefly.management.endpoints.web.exposure.include`, and +writes additionally need `firefly.feature-flags.management.writes` and a store: + + +```php +return [ + 'management' => [ + 'endpoints' => ['web' => ['exposure' => ['include' => 'health,info,flags']]], + ], + 'feature-flags' => [ + 'enabled' => true, + 'sources' => ['store' => ['enabled' => true]], + 'management' => ['writes' => true], + ], +]; +``` + + +```bash +curl -s http://localhost:8000/actuator/flags +curl -s -X POST http://localhost:8000/actuator/flags/new-checkout -H 'Content-Type: application/json' -d '{"action":"enable"}' +curl -s -X POST http://localhost:8000/actuator/flags/new-checkout -H 'Content-Type: application/json' -d '{"action":"evaluate","targetingKey":"ada"}' +``` + +The actor recorded for a write is the authenticated principal's name, or `actuator` when there is none. An +`evaluate` is a preview: it uses the context you send plus the application's own attributes — never yours — and +counts in no metric and no exposure record. +After a successful `put`, `{"key":"…","refreshPending":true}` means no new local definition is visible yet; +it does not claim a caller-owned transaction has committed. If an older last-good definition remains visible, the +response describes that current view. Poll the detail endpoint or wait for the source refresh before assuming the +local process has adopted the new revision. + +**Admin.** The [admin dashboard](admin.md)'s **Feature flags** page lists every flag with its state, origin and +expiry, toggles one, edits its definition as JSON, sets its default variant, previews an evaluation and shows its +history. Its writes follow the same rules and record the actor `admin` when nobody is signed in. + +**CLI.** + + +```bash +php artisan firefly:flags list +php artisan firefly:flags show new-checkout --json +php artisan firefly:flags evaluate checkout-flow --context='{"plan":"pro"}' --targeting-key=ada +php artisan firefly:flags enable new-checkout --expected-version=0 +php artisan firefly:flags disable new-checkout --expected-version=1 +php artisan firefly:flags default-variant checkout-flow v2 +php artisan firefly:flags put banner --file=banner.json +php artisan firefly:flags delete banner +``` + +A command-line write records `cli:`. +For `evaluate`, omit `--context` or pass `--context='{}'` when there are no explicit attributes. If supplied, +`--context` must contain a JSON object; empty text, JSON null, arrays, scalars and malformed JSON fail with +`bad-request` and exit 1 before any evaluation. Add `--json` to receive the portable error body. + +## The sync server + +One service can serve its effective flag set to others — a control plane serving every service of a product, in +either framework. The server answers `GET` on its path with the composed flagd document, an `ETag`, and `304` for a +matching `If-None-Match`; it requires `Authorization: Bearer `, and enabling it without a token refuses the +boot unless `allow-anonymous` is set. + + +```php +return [ + 'feature-flags' => [ + 'enabled' => true, + 'server' => ['enabled' => true, 'path' => '/feature-flags/flagd.json', 'token' => env('FLAGS_SERVER_TOKEN')], + ], +]; +``` + +A consumer points its `http` source at that URL with the same token (see [Sources](#sources-and-precedence)). Test +overrides are never served. + +## Observability + +- **Metric:** `feature_flag_evaluations_total{flag, variant, reason}` on the meter registry when + [Observability](observability.md) is installed and `firefly.observability.metrics.enabled` is on (the default). + When metrics are disabled or no meter is available, feature flags use `NoOpFeatureFlagMetrics`; evaluation still + works but no counter is recorded. `variant` is `none` when there is none, and a failed evaluation counts with + `reason="ERROR"` when the meter is active. +- **Health:** the `featureflags` component is UP while every source has loaded at least once, DOWN while one never + has, and lists each source's status, the flag count and the expired flags. +- **Events:** `FeatureFlagsChanged(changedKeys, origin)` when the effective set changes, `FeatureFlagUpdated` after a + committed store write, and — with `firefly.feature-flags.events.evaluations` — `FeatureFlagEvaluated` per + evaluation. + + +```php +use Firefly\FeatureFlags\Event\FeatureFlagEvaluated; +use Illuminate\Support\Facades\Event; + +Event::listen(FeatureFlagEvaluated::class, function (FeatureFlagEvaluated $exposure): void { + Exposures::record($exposure->key, $exposure->variant, $exposure->targetingKey); +}); +``` + +Telemetry never changes an evaluation: a failing meter or listener is logged at debug and the flag's value stands. +When an exposure value contains more than 10,000 value occurrences, the exposure event is omitted, while the +evaluation result and metric remain. The budget counts the root and every nested container or scalar, including +repeated references; a cycle also exhausts the budget. + +An application may install a different OpenFeature provider for evaluation. Firefly's registry, source history, +writable store and management operations then have no Firefly document to operate on; keep operator expectations +limited to that provider's own capabilities. The PHP SDK does not expose Firefly's flag metadata, so use +`FeatureFlags::details()` when metadata matters. + +## Testing + +`withFeatureFlags()` overrides flags of the running application for the rest of a test, above every source, +shorthand included; see [Testing](testing.md#feature-flag-overrides). + + +```php +it('shows the faster checkout to the treatment group', function (): void { + withFeatureFlags(['new-checkout' => true, 'checkout-flow' => 'v2']); + + $this->get('/checkout')->assertSee('Try the faster checkout.'); +}); +``` + +## Configuration + +Every key, as the configuration reference documents it: + + +```php +'feature-flags' => [ + 'enabled' => env('FIREFLY_FEATURE_FLAGS_ENABLED', false), + + // 'flags' => [ + // 'new-checkout' => false, + // 'checkout-flow' => [ + // 'state' => 'ENABLED', + // 'variants' => ['control' => 'v1', 'treatment' => 'v2'], + // 'defaultVariant' => 'control', + // 'targeting' => ['fractional' => [['control', 50], ['treatment', 50]]], + // 'metadata' => ['owner' => 'payments', 'kind' => 'experiment', 'expires' => '2099-12-31'], + // ], + // ], + // 'evaluators' => [ + // 'is-beta' => ['in' => ['beta', ['var' => 'roles']]], + // ], + + // … + // 'sources' => [ + // 'file' => ['enabled' => false, 'path' => '', 'refresh-interval' => '5s'], + // 'http' => ['enabled' => false, 'url' => '', 'token' => env('FIREFLY_FEATURE_FLAGS_HTTP_TOKEN', ''), 'refresh-interval' => '30s', 'timeout' => '2s'], + // 'store' => ['enabled' => false, 'driver' => 'database', 'connection' => null, 'refresh-interval' => '5s'], + // ], + + // The principal attribute that holds the tenant id (the `tenant` evaluation-context attribute). + // 'context' => ['tenant-attribute' => 'tenant'], + + // Status of a #[FeatureFlag]-gated route or method when the flag is off: 404, 403 or 503. + // 'web' => ['disabled-status' => 404], + + // Publish a FeatureFlagEvaluated event per evaluation (exposure records for experiments). + // 'events' => ['evaluations' => false], + + // Allow writes from the admin page, POST /actuator/flags/{key} and php artisan firefly:flags. + // 'management' => ['writes' => false], + + // Serve this application's effective flag set to other services (bearer token required unless + // allow-anonymous is true; enabling it without a token refuses the boot). + // 'server' => ['enabled' => false, 'path' => '/feature-flags/flagd.json', 'token' => env('FIREFLY_FEATURE_FLAGS_SERVER_TOKEN', ''), 'allow-anonymous' => false], +], +``` + +| Key (under `firefly.feature-flags`) | Default | Meaning | +|---|---|---| +| `enabled` | `false` | the master switch | +| `flags` | `[]` | inline definitions, flagd or shorthand | +| `evaluators` | `[]` | shared `$evaluators` rules | +| `sources.file.enabled` / `.path` / `.refresh-interval` | `false` / `''` / `5s` | a watched flagd document (`.json`, `.yaml`, `.yml`) | +| `sources.http.enabled` / `.url` / `.token` / `.refresh-interval` / `.timeout` | `false` / `''` / `''` / `30s` / `2s` | the sync client | +| `sources.store.enabled` / `.driver` / `.connection` / `.refresh-interval` | `false` / `database` / `null` / `5s` | the writable layer | +| `context.tenant-attribute` | `tenant` | the principal attribute holding the tenant id | +| `web.disabled-status` | `404` | the status of a gated route or method that is off: 404, 403 or 503 | +| `events.evaluations` | `false` | publish `FeatureFlagEvaluated` | +| `management.writes` | `false` | allow writes from the admin page, the actuator and the CLI | +| `server.enabled` / `.path` / `.token` / `.allow-anonymous` | `false` / `/feature-flags/flagd.json` / `''` / `false` | the sync server | + +## Troubleshooting + +- **Every gate is closed.** `firefly.feature-flags.enabled` is off, or the key is misspelt: gates fail closed. The + actuator's `GET /actuator/flags/{key}` answers `unknown-flag` for a key no source defines. +- **A change does not show.** Each source is re-checked once per `refresh-interval`; the writing process sees its + own write when its local refresh succeeds. If that refresh is deferred or fails, the committed write remains + durable and normal polling restores local visibility. A store write made with the cache store down is seen by + other workers when their own interval elapses. +- **The boot fails with `Invalid feature flag [...]`.** A definition in `flags` or in the flag file breaks a rule + above; the message names the key, the source and the rule. +- **The boot rejects a source or sync server setting.** An enabled file source needs a path. An enabled HTTP source + needs a URL and a positive `refresh-interval`; zero is allowed for the cheaper file and store checks. An enabled + sync server needs a token unless `allow-anonymous` is true, and its path cannot be empty or `/`. +- **Sync returns 401/403 before the controller.** The sync controller's bearer token does not bypass application + Security HTTP/JWT filters. Configure the sync path explicitly in the application's security rules so its intended + machine client can reach the controller, and retain the controller's own token check. Inspect both filter and + controller responses before changing credentials. +- **`writes-disabled` / `not-writable`.** Set `firefly.feature-flags.management.writes`, and enable the store. +- **A percentage rollout ignores anonymous users.** They have no targeting key; contribute a stable anonymous id + as `targetingKey` from a context contributor, or target on another attribute. +- **`#[FeatureFlag]` refuses the boot.** The scanner names the method and the reason: remove `final`, move the + attribute to a public instance method, or name a fallback that exists. diff --git a/docs/modules/testing.md b/docs/modules/testing.md index 8ba6d2e1..6441f08d 100644 --- a/docs/modules/testing.md +++ b/docs/modules/testing.md @@ -156,6 +156,22 @@ between requests, so `actingAsPrincipal()` — a principal in the holder for one login redirect, exactly as an anonymous browser is. The token, introspection, revocation and userinfo helpers are machine endpoints and need no sign-in. +## Feature flag overrides + +`withFeatureFlags(array $flags)` from `Firefly\Testing\functions.php` overlays definitions in the running +application for the rest of the test. It accepts shorthand or full flagd definitions, stays in this process, +and is neither stored nor served by the sync endpoint. The returned `FeatureFlagOverrides` supports `set()`, +`forget()`, `merge()` and `clear()`. Enable `firefly.feature-flags.enabled` in the test configuration first. + + +```php +it('hides the beta report while its flag is off', function (): void { + withFeatureFlags(['beta-reports' => false]); + + $this->get('/reports/beta')->assertNotFound(); +}); +``` + ## Pest expectations Registered once, at monorepo boot, by `Firefly\Testing\Pest\FireflyExpectations::register()` — called from the diff --git a/docs/publishing.md b/docs/publishing.md index 580b84f6..8175d246 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -7,7 +7,7 @@ cross-repository `ACCESS_TOKEN` are needed. ## Package identity and compatibility -The root package replaces all 29 former component names, including `firefly/firefly`, with +The root package replaces all 30 former component names, including `firefly/firefly`, with `self.version`. Packagist's `firefly` vendor namespace belongs to another publisher, so the public package uses the Firefly Framework organization's namespace. `firefly/lumen` is a sample, not a replacement; `firefly/skeleton` is a bundled project template, not a second published package. @@ -81,7 +81,7 @@ CI runs these package checks on PHP 8.3, 8.4 and 8.5 for PRs to `main` and pushe To repeat the public install check locally: ```bash -RELEASE_TAG=v26.09.11 RELEASE_SHA="$(git rev-parse 'v26.09.11^{commit}')" php scripts/check-package-install.php --published +RELEASE_TAG=v26.10.1 RELEASE_SHA="$(git rev-parse 'v26.10.1^{commit}')" php scripts/check-package-install.php --published ``` An unmerged branch or a local consumer check is not a published release. diff --git a/docs/versioning.md b/docs/versioning.md index 97a989eb..1c501117 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -21,14 +21,14 @@ The single place the current version *is* asserted in code is: ```php final class Version { - public const string VERSION = '26.09.11'; + public const string VERSION = '26.10.1'; } ``` -`Firefly\Kernel\Version::VERSION` has exactly two readers in the shipped packages, and `grep -rn -'Version::VERSION' packages` is the whole list: `AboutCommand`, which prints `LaraFly ` as the first -line of `php artisan firefly:about`, and `RuntimeInfoContributor`, which puts it at -`runtime.firefly.version` in `/actuator/info`. +`Firefly\Kernel\Version::VERSION` is the shipped runtime version source. `AboutCommand` prints it in +`php artisan firefly:about`, `RuntimeInfoContributor` exposes it at `runtime.firefly.version` in `/actuator/info`, +the installer's `NewCommand` uses it when creating applications, and `FeatureFlags::client()` supplies it to +OpenFeature client metadata. Consistency across the three human-visible surfaces that *should* always agree with it — the `Version::VERSION` constant, the CHANGELOG's latest `## [x.y.z]` heading, and the README version badge — is diff --git a/mkdocs.yml b/mkdocs.yml index 8a3b88d0..57205475 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -155,6 +155,7 @@ nav: - Admin Dashboard: modules/admin.md - Bean Graph: modules/bean-graph.md - Data Browser: modules/data-browser.md + - Feature Flags: modules/feature-flags.md - Testing: - Testing: modules/testing.md - Integration Testing: modules/integration-testing.md @@ -162,6 +163,7 @@ nav: - Installer: modules/installer.md - Reference: - CLI Reference: cli.md + - Feature Flag Contract: feature-flags-contract.md - Versioning: versioning.md - Contributing: - Contributing: contributing.md diff --git a/packages/actuator/src/Endpoint/EndpointRequest.php b/packages/actuator/src/Endpoint/EndpointRequest.php index ffa614c8..93f43d50 100644 --- a/packages/actuator/src/Endpoint/EndpointRequest.php +++ b/packages/actuator/src/Endpoint/EndpointRequest.php @@ -8,6 +8,8 @@ * The request an ActuatorEndpoint receives — the HTTP method, the path segments AFTER the endpoint id (e.g. * /actuator/health/liveness → subPath ['liveness']), the parsed query, and the parsed JSON/form body. Built by * ActuatorDispatchAction from the illuminate Request; scalar-only so endpoints stay trivially testable. + * The optional raw JSON body preserves object and number shapes lost by PHP's parsed array. + * The origin is trusted in-process metadata; HTTP dispatch never derives it from request input. */ final readonly class EndpointRequest { @@ -21,5 +23,7 @@ public function __construct( public array $subPath, public array $query = [], public array $body = [], + public ?string $rawBody = null, + public string $origin = 'actuator', ) {} } diff --git a/packages/actuator/src/Web/ActuatorDispatchAction.php b/packages/actuator/src/Web/ActuatorDispatchAction.php index f588d8b4..7d8f2d2b 100644 --- a/packages/actuator/src/Web/ActuatorDispatchAction.php +++ b/packages/actuator/src/Web/ActuatorDispatchAction.php @@ -60,7 +60,7 @@ public function __invoke(Request $request, string $path): Response /** @var array $body */ $body = $request->isJson() ? (array) $request->json()->all() : $request->request->all(); - $result = $endpoint->handle(new EndpointRequest($request->getMethod(), $subPath, $query, $body)); + $result = $endpoint->handle(new EndpointRequest($request->getMethod(), $subPath, $query, $body, $request->isJson() ? $request->getContent() : null)); if ($result === null) { return $this->problems->render($this->notFound(), $request); } diff --git a/packages/actuator/tests/Support/RawBodyCapstoneTestCase.php b/packages/actuator/tests/Support/RawBodyCapstoneTestCase.php new file mode 100644 index 00000000..d01ef8e7 --- /dev/null +++ b/packages/actuator/tests/Support/RawBodyCapstoneTestCase.php @@ -0,0 +1,14 @@ + */ + public array $seen = []; + + public function endpointId(): string + { + return 'capture'; + } + + public function enabled(): bool + { + return true; + } + + public function handle(EndpointRequest $request): EndpointResponse + { + $this->seen[] = $request; + + return EndpointResponse::json(['ok' => true]); + } + }; + /** @var ActuatorRegistry $registry */ + $registry = $this->app()->make(ActuatorRegistry::class); + $registry->register($endpoint); + + $this->call('POST', '/actuator/capture', [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], '{"variants":{"none":{}}}')->assertOk(); + $this->post('/actuator/capture', ['level' => 'debug'])->assertOk(); + + expect($endpoint->seen[0]->rawBody)->toBe('{"variants":{"none":{}}}') + ->and($endpoint->seen[0]->body)->toBe(['variants' => ['none' => []]]) + ->and($endpoint->seen[1]->rawBody)->toBeNull() + ->and(new EndpointRequest('GET', []))->toHaveProperty('rawBody', null); +}); diff --git a/packages/admin/resources/views/flags.blade.php b/packages/admin/resources/views/flags.blade.php new file mode 100644 index 00000000..e76ffc9b --- /dev/null +++ b/packages/admin/resources/views/flags.blade.php @@ -0,0 +1,211 @@ +@extends('firefly-admin::layout') +@section('title', 'Feature flags') +@section('body') + @php + $sources = is_array($overview['sources'] ?? null) ? $overview['sources'] : []; + $provider = is_array($overview['provider'] ?? null) ? $overview['provider'] : []; + @endphp + +
+

Feature flags

+

Inspect each flag, the layer that defines it, and its change history. Changes written to the flag store + become visible to other processes on their next refresh.

+
+ + @if (session('data-message')) +

{{ session('data-message') }}

+ @endif + + @if ($unavailable ?? false) +

Feature flag data is unavailable. Retry after the flags endpoint recovers.

+ @elseif (! ($overview['writable'] ?? false)) +

Read-only: no flag store is configured. Enable firefly.feature-flags.sources.store to change flags at runtime.

+ @elseif (! ($overview['writesEnabled'] ?? false)) +

Read-only: enable firefly.feature-flags.management.writes to change flags.

+ @endif + + @if ($unavailable ?? false) + @elseif (isset($rawDetail)) +
+ @include('firefly-admin::_panel-head', ['title' => 'Complete flag detail JSON', 'count' => '']) +

These JSON member names require the complete response to preserve their shape. Use the CLI or actuator to edit this definition.

+
{{ $rawDetail }}
+
+ @elseif (isset($missing)) + @include('firefly-admin::_empty', ['title' => 'No such flag', 'body' => 'No layer defines '.e($missing).'. Back to every flag.']) + @elseif ($detail === null) +
+ @include('firefly-admin::_panel-head', ['title' => 'Flags', 'count' => count($flags), 'filter' => 'flags-body', 'placeholder' => 'Filter flags…']) + @if ($flags === []) + @include('firefly-admin::_empty', ['title' => 'No flags defined', 'body' => 'Define a flag in configuration, a flag file, or the store.']) + @else +
+ + + @foreach ($flags as $flag) + + + + + + + + + + @endforeach + +
KeyStateTypeDefaultFromVersion
{{ $flag['key'] ?? '' }} + @if ($flag['expired'] ?? false)expired@endif + {{ $flag['state'] ?? '' }}{{ $flag['type'] ?? '' }}{{ ($flag['targeting'] ?? false) ? ' · targeted' : '' }}{{ $flag['defaultVariant'] ?? '—' }}{{ $flag['origin'] ?? '' }}{{ $flag['version'] ?? '—' }} + @if ($writable) + @php $enabled = ($flag['state'] ?? '') === 'ENABLED'; @endphp +
+ @csrf + + + + @if (($flag['version'] ?? null) !== null) + + @endif + +
+ @endif +
+ @endif +
+ +
+ @include('firefly-admin::_panel-head', ['title' => 'Sources', 'count' => count($sources)]) +
+ + + @foreach ($sources as $source) + + + + + + + + @endforeach + +
SourceStatusFlagsLast refreshError
{{ $source['name'] ?? '' }}{{ $source['status'] ?? '' }}{{ $source['flags'] ?? 0 }}{{ $source['lastRefresh'] ?? '—' }}{{ $source['error'] ?? '' }}
+

Provider {{ $provider['name'] ?? '' }}, {{ $provider['status'] ?? '' }}.

+
+ @else + @php + $key = (string) ($detail['key'] ?? ''); + $definition = is_array($detail['definition'] ?? null) ? $detail['definition'] : []; + $history = is_array($detail['history'] ?? null) ? $detail['history'] : []; + $version = $detail['version'] ?? null; + @endphp + +

← Every flag

+
+ @include('firefly-admin::_panel-head', ['title' => $key, 'count' => ($definition['state'] ?? '').' · from '.($detail['origin'] ?? '')]) + @if ($detail['expired'] ?? false) +

Expired. Its expires date has passed. Remove the flag or update the date.

+ @endif + @if ($writable) +
+ @csrf + + + @if ($version !== null)@endif + +
+
+ @csrf + + + @if ($version !== null)@endif + + +
+ @if ($version !== null) +
+ @csrf + + + + +
+ @endif + @endif + +
+ @csrf + + + @if ($version !== null)@endif + + + @if ($writable)@endif +
+
+ +
+ @include('firefly-admin::_panel-head', ['title' => 'Evaluation preview', 'count' => '']) +
+ + + + + + + +
+ @if ($evaluation !== null) + @if ($evaluation['ok']) + + + + + +
{{ $evaluation['valueLabel'] }}{{ $evaluation['valueJson'] }}
Variant{{ $evaluation['body']['variant'] ?? '—' }}
Reason{{ $evaluation['body']['reason'] ?? '' }}
Error{{ $evaluation['body']['errorCode'] ?? '—' }}
+ @else +

{{ $evaluation['error'] }}

+ @endif + @endif +

The preview uses only this context and the application's attributes. It records no metric or exposure event.

+
+ +
+ @include('firefly-admin::_panel-head', ['title' => 'Layers', 'count' => count($layerRows)]) +
+ + + @foreach ($layerRows as $layer) + + @endforeach + +
SourceDefinition
{{ $layer['source'] }}{{ $layer['definition'] }}
+
+ +
+ @include('firefly-admin::_panel-head', ['title' => 'History', 'count' => count($history)]) + @if ($history === []) + @include('firefly-admin::_empty', ['title' => 'No changes recorded', 'body' => 'Only writes to the flag store are recorded; this flag has none.']) + @else +
+ + + @foreach ($history as $change) + + + + + + + @endforeach + +
#ActionActorWhen (UTC)
{{ $change['id'] ?? '' }}{{ $change['action'] ?? '' }}{{ $change['actor'] ?? '—' }}{{ $change['changedAt'] ?? '' }}
+ @endif +
+ @endif +@endsection diff --git a/packages/admin/src/AdminEndpointReader.php b/packages/admin/src/AdminEndpointReader.php index 9640ad71..b0701f36 100644 --- a/packages/admin/src/AdminEndpointReader.php +++ b/packages/admin/src/AdminEndpointReader.php @@ -6,6 +6,7 @@ use Firefly\Actuator\Endpoint\ActuatorRegistry; use Firefly\Actuator\Endpoint\EndpointRequest; +use Firefly\Actuator\Endpoint\EndpointResponse; use Firefly\Actuator\Health\HealthContributorRegistry; use Firefly\Config\Config; use Illuminate\Contracts\Container\Container; @@ -151,4 +152,23 @@ public function write(string $id, array $subPath, array $body): bool return $response !== null && $response->status >= 200 && $response->status < 300; } + + /** + * @param list $subPath + * @param array $query + * @param array $body + */ + public function call(string $method, string $id, array $subPath = [], array $query = [], array $body = [], ?string $rawBody = null): ?EndpointResponse + { + $endpoint = $this->registry->get($id); + if ($endpoint === null || ! $this->has($id)) { + return null; + } + + try { + return $endpoint->handle(new EndpointRequest($method, $subPath, $query, $body, $rawBody, origin: 'admin')); + } catch (Throwable) { + return null; + } + } } diff --git a/packages/admin/src/Web/AdminAction.php b/packages/admin/src/Web/AdminAction.php index 5ecb8d84..b6621a35 100644 --- a/packages/admin/src/Web/AdminAction.php +++ b/packages/admin/src/Web/AdminAction.php @@ -40,6 +40,7 @@ use Illuminate\Http\Response; use Illuminate\Routing\Router; use Illuminate\Session\Store; +use stdClass; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Throwable; @@ -112,6 +113,10 @@ public function __invoke(Request $request, string $page = ''): SymfonyResponse return $this->datasourcePage($request, $current); } + if ($slug === 'flags') { + return $request->isMethod('POST') ? $this->flagsWrite($request) : $this->flagsPage($request, $current); + } + if ($slug === 'settings') { return $request->isMethod('POST') ? $this->settingsWrite($request) : $this->settingsPage($current); } @@ -252,6 +257,192 @@ private function settingsWrite(Request $request): SymfonyResponse return $this->redirect($back, $this->console->set($key, $request->input('value') === '1')); } + private function flagsPage(Request $request, AdminPage $current): SymfonyResponse + { + $overview = $this->flagsOverview(); + if ($overview === null) { + return $this->flagsUnavailable($current); + } + $writable = ($overview['writable'] ?? false) === true && ($overview['writesEnabled'] ?? false) === true; + $key = $request->query('flag'); + + if (! is_string($key) || $key === '') { + return $this->html($this->render('flags', [ + 'overview' => $overview, + 'flags' => is_array($overview['flags'] ?? null) ? array_values($overview['flags']) : [], + 'writable' => $writable, + 'detail' => null, + ], $current), 200); + } + + $response = $this->reader->call('GET', 'flags', [$key]); + if ($response !== null && $response->status === 404) { + return $this->html($this->render('flags', [ + 'overview' => $overview, 'flags' => [], 'writable' => $writable, 'detail' => null, 'missing' => $key, + ], $current), 404); + } + + if ($response === null || $response->status !== 200 || ! is_string($response->body)) { + return $this->flagsUnavailable($current); + } + $detail = json_decode($response->body, true); + if (! is_array($detail) || ! is_string($detail['key'] ?? null) || ! is_array($detail['definition'] ?? null) + || ! is_array($detail['layers'] ?? null) || ! is_array($detail['history'] ?? null)) { + return $this->flagsUnavailable($current); + } + $objects = json_decode($response->body); + if (! $objects instanceof stdClass) { + return $this->html($this->render('flags', [ + 'overview' => $overview, 'flags' => [], 'writable' => false, 'detail' => null, + 'rawDetail' => $response->body, + ], $current), 200); + } + $definition = property_exists($objects, 'definition') ? $objects->definition : new stdClass; + $variants = $definition instanceof stdClass && property_exists($definition, 'variants') && $definition->variants instanceof stdClass + ? array_map(static fn (int|string $name): string => (string) $name, array_keys(get_object_vars($definition->variants))) + : []; + + $layers = []; + if (property_exists($objects, 'layers') && is_array($objects->layers)) { + foreach ($objects->layers as $layer) { + if ($layer instanceof stdClass) { + $layers[] = [ + 'source' => is_string($layer->source ?? null) ? $layer->source : '', + 'definition' => (string) json_encode($layer->definition ?? null, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), + ]; + } + } + } + + $context = $request->query('context'); + $targetingKey = $request->query('targetingKey'); + + return $this->html($this->render('flags', [ + 'overview' => $overview, + 'flags' => [], + 'writable' => $writable, + 'detail' => $detail, + 'definitionJson' => (string) json_encode($definition, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), + 'variants' => $variants, + 'layerRows' => $layers, + 'context' => is_string($context) ? $context : '', + 'targetingKey' => is_string($targetingKey) ? $targetingKey : '', + 'evaluation' => $request->query('evaluate') !== null + ? $this->flagsEvaluation($key, is_string($context) ? $context : '', is_string($targetingKey) ? $targetingKey : '') + : null, + ], $current), 200); + } + + private function flagsWrite(Request $request): RedirectResponse + { + $list = $this->settings->url('flags'); + $key = $request->input('flag'); + $op = $request->input('op'); + + if (! is_string($key) || $key === '' || ! in_array($op, ['enable', 'disable', 'default-variant', 'put', 'delete'], true)) { + return $this->redirect($list, 'Refused: no flag or no operation was named.'); + } + + $back = $request->input('back') === 'list' ? $list : $list.'?flag='.rawurlencode($key); + $body = ['action' => $op]; + $expected = $request->input('expectedVersion'); + if ($request->exists('expectedVersion')) { + $digits = is_int($expected) && $expected >= 0 ? (string) $expected : $expected; + if (! is_string($digits) || preg_match('/^[0-9]+$/D', $digits) !== 1) { + return $this->redirect($back, 'Refused: expectedVersion must be a nonnegative integer within the supported range.'); + } + $digits = ltrim($digits, '0'); + $limit = (string) PHP_INT_MAX; + if (strlen($digits) > strlen($limit) || (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)) { + return $this->redirect($back, 'Refused: expectedVersion must be a nonnegative integer within the supported range.'); + } + $body['expectedVersion'] = (int) $digits; + } + + $raw = null; + if ($op === 'default-variant') { + $variant = $request->input('variant'); + $body['variant'] = is_string($variant) ? $variant : ''; + } + if ($op === 'put') { + $definition = $request->input('definition'); + if (! is_string($definition) || ! str_starts_with(ltrim($definition), '{') || ! json_validate($definition)) { + return $this->redirect($back, 'Refused: the definition is not a JSON object.'); + } + $raw = '{"action":"put","definition":'.trim($definition) + .(isset($body['expectedVersion']) ? ',"expectedVersion":'.$body['expectedVersion'] : '').'}'; + } + + $response = $this->reader->call('POST', 'flags', [$key], [], $body, $raw); + if ($response === null) { + return $this->redirect($back, 'Refused: the flags endpoint is unavailable.'); + } + + $answer = is_string($response->body) ? json_decode($response->body, true) : null; + if ($response->status >= 300) { + return $this->redirect($back, 'Refused: '.(is_array($answer) && is_string($answer['message'] ?? null) ? $answer['message'] : 'the write was not accepted.')); + } + + if (is_array($answer) && ($answer['deleted'] ?? false) === true) { + return $this->redirect($list, "Deleted [{$key}]."); + } + + if (is_array($answer) && ($answer['refreshPending'] ?? false) === true) { + return $this->redirect($back, "Write accepted for [{$key}]. Refresh pending; check this page again for visibility."); + } + + return $this->redirect($back, match ($op) { + 'enable' => "Enabled [{$key}].", + 'disable' => "Disabled [{$key}].", + 'default-variant' => "Set the default variant of [{$key}].", + 'put' => "Saved [{$key}].", + default => "Deleted the stored override of [{$key}].", + }); + } + + private function flagsUnavailable(AdminPage $current): SymfonyResponse + { + return $this->html($this->render('flags', [ + 'overview' => [], 'flags' => [], 'detail' => null, 'writable' => false, 'unavailable' => true, + ], $current), 503); + } + + /** @return array|null */ + private function flagsOverview(): ?array + { + $response = $this->reader->call('GET', 'flags'); + $body = $response !== null && $response->status === 200 && is_string($response->body) ? json_decode($response->body, true) : null; + + return is_array($body) && is_array($body['flags'] ?? null) && array_is_list($body['flags']) + && is_array($body['sources'] ?? null) && is_array($body['provider'] ?? null) + && is_bool($body['writable'] ?? null) && is_bool($body['writesEnabled'] ?? null) ? $body : null; + } + + /** @return array{ok: bool, body: array|null, error: string|null, valueJson?: string, valueLabel?: string} */ + private function flagsEvaluation(string $key, string $context, string $targetingKey): array + { + $context = trim($context) === '' ? '{}' : trim($context); + if (! str_starts_with($context, '{') || ! json_validate($context)) { + return ['ok' => false, 'body' => null, 'error' => 'The context is not a JSON object.']; + } + + $raw = '{"action":"evaluate","context":'.$context.',"targetingKey":'.($targetingKey === '' ? 'null' : (string) json_encode($targetingKey)).'}'; + $response = $this->reader->call('POST', 'flags', [$key], [], [], $raw); + $body = $response !== null && is_string($response->body) ? json_decode($response->body, true) : null; + + if ($response === null || $response->status !== 200 || ! is_array($body)) { + return ['ok' => false, 'body' => null, 'error' => is_array($body) && is_string($body['message'] ?? null) ? $body['message'] : 'The evaluation failed.']; + } + + $objects = json_decode($response->body); + + return ['ok' => true, 'body' => $body, 'error' => null, + 'valueJson' => $objects instanceof stdClass + ? (string) json_encode($objects->value ?? null, JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION) + : $response->body, + 'valueLabel' => $objects instanceof stdClass ? 'Value' : 'Complete evaluation JSON']; + } + /** * The data-layer page. * diff --git a/packages/admin/src/Web/AdminPage.php b/packages/admin/src/Web/AdminPage.php index 7aa2f4cb..410ed80d 100644 --- a/packages/admin/src/Web/AdminPage.php +++ b/packages/admin/src/Web/AdminPage.php @@ -70,6 +70,8 @@ public static function all(): array 'The cache stores this application has configured.'), new self('loggers', 'Loggers', 'loggers', self::GROUP_CONFIG, 'Log channels and their levels.'), + new self('flags', 'Feature flags', 'flags', self::GROUP_CONFIG, + 'Every feature flag, its source and history, with controls when writes are enabled.'), // `requires` is null and its own settings decide whether it appears — see AdminAction::nav(). // It is the only page that CHANGES the application rather than describing it, which is why it is // off by default and refused outright in production. diff --git a/packages/admin/tests/AdminEndpointReaderCallTest.php b/packages/admin/tests/AdminEndpointReaderCallTest.php new file mode 100644 index 00000000..bac51bd8 --- /dev/null +++ b/packages/admin/tests/AdminEndpointReaderCallTest.php @@ -0,0 +1,55 @@ +broken) { + throw new RuntimeException('down'); + } + + $this->seen = $request; + + return EndpointResponse::text('{"error":"conflict","message":"m"}', 409, 'application/json'); + } + }; + $registry = new ActuatorRegistry; + $registry->register($endpoint); + $reader = new AdminEndpointReader($registry, new Config(new Repository([]))); + + $response = $reader->call('POST', 'flags', ['k'], [], ['action' => 'put'], '{"action":"put"}'); + + expect([$response?->status, $response?->body])->toBe([409, '{"error":"conflict","message":"m"}']) + ->and([$endpoint->seen?->method, $endpoint->seen?->subPath, $endpoint->seen?->query, $endpoint->seen?->rawBody, $endpoint->seen?->origin]) + ->toBe(['POST', ['k'], [], '{"action":"put"}', 'admin']) + ->and($reader->call('GET', 'nothing'))->toBeNull(); + + $endpoint->broken = true; + + expect($reader->call('GET', 'flags'))->toBeNull(); +}); diff --git a/packages/admin/tests/AdminFlagsPageTest.php b/packages/admin/tests/AdminFlagsPageTest.php new file mode 100644 index 00000000..f55a76ae --- /dev/null +++ b/packages/admin/tests/AdminFlagsPageTest.php @@ -0,0 +1,286 @@ + */ + public array $requests = []; + + public int $writeStatus = 200; + + public bool $pending = false; + + public bool $writable = true; + + public bool $writesEnabled = true; + + public bool $available = true; + + public bool $external = false; + + public ?EndpointResponse $overviewResponse = null; + + public ?EndpointResponse $detailResponse = null; + + public bool $throwOnRead = false; + + public string $previewValue = '{"t":"x","w":1.0}'; + + public function endpointId(): string + { + return 'flags'; + } + + public function enabled(): bool + { + return $this->available; + } + + public function handle(EndpointRequest $request): EndpointResponse + { + $this->requests[] = $request; + $key = $request->subPath[0] ?? null; + if ($request->method === 'GET' && $this->throwOnRead) { + throw new RuntimeException('Read unavailable'); + } + if ($request->method === 'GET' && $key === null && $this->overviewResponse !== null) { + return $this->overviewResponse; + } + if ($request->method === 'GET' && $key !== null && $this->detailResponse !== null) { + return $this->detailResponse; + } + + if ($request->method === 'GET' && $key === null) { + return EndpointResponse::text('{"provider":{"name":"firefly","status":"READY"},"writable":'.($this->writable ? 'true' : 'false').',"writesEnabled":'.($this->writesEnabled ? 'true' : 'false').',' + .'"sources":[{"name":"'.($this->external ? 'http' : 'config').'","enabled":true,"status":"UP","flags":2,"lastRefresh":"2026-10-01T09:30:00Z","error":null,"revision":null}],' + .'"flags":[{"key":"banner","state":"ENABLED","type":"object","variants":["none","promo"],"defaultVariant":"none","targeting":false,"origin":"store","overrides":["config"],"metadata":{},"expired":false,"version":3},' + .'{"key":"legacy-export","state":"ENABLED","type":"boolean","variants":["on","off"],"defaultVariant":"on","targeting":false,"origin":"config","overrides":[],"metadata":{"expires":"2025-01-01"},"expired":true,"version":null}]}', 200, 'application/json'); + } + + if ($request->method === 'GET' && $key === 'banner') { + return EndpointResponse::text('{"key":"banner","definition":{"state":"ENABLED","variants":{"none":{},"promo":{"t":"x","w":1.0}},"defaultVariant":"none"},"origin":"store",' + .'"layers":[{"source":"store","definition":{"state":"ENABLED","variants":{"none":{},"promo":{"t":"x","w":1.0}},"defaultVariant":"none"}}],' + .'"version":3,"expired":false,"history":[{"id":9,"action":"put","actor":"ada","changedAt":"2026-10-01T09:30:00Z"}]}', 200, 'application/json'); + } + + if ($request->method === 'POST' && $request->rawBody !== null && str_contains($request->rawBody, '"evaluate"')) { + return EndpointResponse::text('{"key":"banner","value":'.$this->previewValue.',"variant":"promo","reason":"TARGETING_MATCH","errorCode":null,"metadata":{}}', 200, 'application/json'); + } + + if ($request->method === 'POST') { + if ($this->writeStatus !== 200) { + return EndpointResponse::text('{"error":"conflict","message":"Flag [banner] is at version 4, the write expected 3."}', $this->writeStatus, 'application/json'); + } + + return EndpointResponse::text($this->pending ? '{"key":"banner","refreshPending":true}' : '{"key":"banner"}', 200, 'application/json'); + } + + return EndpointResponse::text('{"error":"unknown-flag","message":"No layer defines flag ['.$key.']."}', 404, 'application/json'); + } +} + +function featureFlagsAdminScripted(): AdminFlagsScriptedEndpoint +{ + return AdminFlagsScriptedEndpoint::$current ?? throw new LogicException('beforeEach registers the scripted endpoint.'); +} + +function featureFlagsAdminLastRequest(): EndpointRequest +{ + $last = end(featureFlagsAdminScripted()->requests); + + return $last instanceof EndpointRequest ? $last : throw new LogicException('No endpoint request was recorded.'); +} + +beforeEach(function (): void { + /** @var AdminCapstoneTestCase $this */ + AdminFlagsScriptedEndpoint::$current = new AdminFlagsScriptedEndpoint; + /** @var ActuatorRegistry $registry */ + $registry = $this->app()->make(ActuatorRegistry::class); + $registry->register(AdminFlagsScriptedEndpoint::$current); +}); + +it('lists flags with state, origin, expiry and a toggle', function (): void { + /** @var AdminCapstoneTestCase $this */ + $this->get('/firefly/flags')->assertOk()->assertSee('Feature flags')->assertSee('banner') + ->assertSee('legacy-export')->assertSee('expired')->assertSee('Disable')->assertSee('2026-10-01T09:30:00Z') + ->assertSee('name="expectedVersion" value="3"', false); +}); + +it('shows exact definition shapes, layers and history', function (): void { + /** @var AdminCapstoneTestCase $this */ + $this->get('/firefly/flags?flag=banner')->assertOk()->assertSee('"none": {}')->assertSee('"w": 1.0') + ->assertSee('Save definition')->assertSee('Delete stored override')->assertSee('ada', false) + ->assertSee('name="expectedVersion" value="3"', false); +}); + +it('answers 404 for an unknown key', function (): void { + /** @var AdminCapstoneTestCase $this */ + $this->get('/firefly/flags?flag=nope')->assertNotFound()->assertSee('No such flag'); +}); + +it('previews an evaluation with typed context and trusted admin origin', function (): void { + /** @var AdminCapstoneTestCase $this */ + $this->get('/firefly/flags?flag=banner&evaluate=1&context='.rawurlencode('{"plan":"pro"}').'&targetingKey=u-1') + ->assertOk()->assertSee('TARGETING_MATCH'); + + $evaluate = featureFlagsAdminLastRequest(); + expect($evaluate->rawBody)->toBe('{"action":"evaluate","context":{"plan":"pro"},"targetingKey":"u-1"}') + ->and($evaluate->query)->toBe([]) + ->and($evaluate->origin)->toBe('admin'); +}); + +it('posts a toggle in-process and reports its result', function (): void { + /** @var AdminCapstoneTestCase $this */ + $this->post('/firefly/flags', ['flag' => 'banner', 'op' => 'disable', 'back' => 'list']) + ->assertRedirect('/firefly/flags')->assertSessionHas('data-message', 'Disabled [banner].'); + + $write = featureFlagsAdminLastRequest(); + expect([$write->method, $write->subPath, $write->query, $write->body, $write->origin]) + ->toBe(['POST', ['banner'], [], ['action' => 'disable'], 'admin']); +}); + +it('sends the editor JSON untouched as the raw body', function (): void { + /** @var AdminCapstoneTestCase $this */ + $this->post('/firefly/flags', ['flag' => 'banner', 'op' => 'put', 'expectedVersion' => '3', + 'definition' => '{"state":"ENABLED","variants":{"none":{}},"defaultVariant":"none"}']) + ->assertRedirect('/firefly/flags?flag=banner')->assertSessionHas('data-message', 'Saved [banner].'); + + expect(featureFlagsAdminLastRequest()->rawBody) + ->toBe('{"action":"put","definition":{"state":"ENABLED","variants":{"none":{}},"defaultVariant":"none"},"expectedVersion":3}'); +}); + +it('reports a pending refresh as a successful write with polling guidance', function (): void { + /** @var AdminCapstoneTestCase $this */ + featureFlagsAdminScripted()->pending = true; + $this->post('/firefly/flags', ['flag' => 'banner', 'op' => 'put', 'definition' => '{"state":"ENABLED","variants":{"none":{}},"defaultVariant":"none"}']) + ->assertSessionHas('data-message', 'Write accepted for [banner]. Refresh pending; check this page again for visibility.'); +}); + +it('reports endpoint refusals and invalid definitions', function (): void { + /** @var AdminCapstoneTestCase $this */ + featureFlagsAdminScripted()->writeStatus = 409; + $this->post('/firefly/flags', ['flag' => 'banner', 'op' => 'enable']) + ->assertSessionHas('data-message', 'Refused: Flag [banner] is at version 4, the write expected 3.'); + $this->post('/firefly/flags', ['flag' => 'banner', 'op' => 'put', 'definition' => '[1, 2]']) + ->assertSessionHas('data-message', 'Refused: the definition is not a JSON object.'); + $this->post('/firefly/flags', ['op' => 'enable']) + ->assertSessionHas('data-message', 'Refused: no flag or no operation was named.'); +}); + +it('shows an external source without offering controls when there is no store', function (): void { + /** @var AdminCapstoneTestCase $this */ + featureFlagsAdminScripted()->writable = false; + featureFlagsAdminScripted()->external = true; + + $this->get('/firefly/flags')->assertOk()->assertSee('Read-only: no flag store is configured.') + ->assertSee('http')->assertDontSee('Disable'); + $this->get('/firefly/flags?flag=banner')->assertOk()->assertDontSee('Save definition') + ->assertDontSee('Delete stored override')->assertSee('disabled', false); +}); + +it('hides controls when writes are disabled and hides the page when the endpoint is disabled', function (): void { + /** @var AdminCapstoneTestCase $this */ + featureFlagsAdminScripted()->writesEnabled = false; + $this->get('/firefly/flags')->assertOk()->assertSee('Read-only: enable') + ->assertDontSee('Disable'); + + featureFlagsAdminScripted()->available = false; + $this->get('/firefly/flags')->assertNotFound()->assertSee('This page has no endpoint to read'); +}); + +it('escapes an unknown key before placing it in the page', function (): void { + /** @var AdminCapstoneTestCase $this */ + $this->get('/firefly/flags?flag='.rawurlencode('')) + ->assertNotFound()->assertSee('<script>alert(1)</script>', false) + ->assertDontSee('', false); +}); + +it('rejects a tokenless POST through the real CSRF middleware', function (): void { + /** @var AdminCapstoneTestCase $this */ + // Laravel bypasses CSRF in the testing environment. Switch only this application's environment so + // this request takes the same middleware branch as deployed HTTP traffic. + $this->app()->instance('env', 'local'); + $this->post('/firefly/flags', ['flag' => 'banner', 'op' => 'disable'])->assertStatus(419); + + expect(featureFlagsAdminScripted()->requests)->toBe([]); +}); + +it('refuses a malformed submitted version before endpoint dispatch', function (mixed $version): void { + /** @var AdminCapstoneTestCase $this */ + $this->post('/firefly/flags', ['flag' => 'banner', 'op' => 'disable', 'expectedVersion' => $version]) + ->assertSessionHas('data-message', 'Refused: expectedVersion must be a nonnegative integer within the supported range.'); + expect(featureFlagsAdminScripted()->requests)->toBe([]); +})->with(['text' => ['oops'], 'negative' => ['-1'], 'array' => [['3']], 'overflow' => [(string) PHP_INT_MAX.'0'], 'empty' => [''], 'null' => [null]]); + +it('preserves an explicit zero optimistic version', function (): void { + /** @var AdminCapstoneTestCase $this */ + $this->post('/firefly/flags', ['flag' => 'banner', 'op' => 'enable', 'expectedVersion' => '0'])->assertRedirect(); + expect(featureFlagsAdminLastRequest()->body['expectedVersion'] ?? null)->toBe(0); +}); + +it('reports unavailable reads without inventing empty or missing flags', function (bool $detail, string $failure): void { + /** @var AdminCapstoneTestCase $this */ + $endpoint = featureFlagsAdminScripted(); + if ($failure === 'throw') { + $endpoint->throwOnRead = true; + } else { + $response = EndpointResponse::text($failure === 'malformed' ? '{broken' : ($failure === 'shape' ? '{}' : 'unavailable'), $failure === 'failed' ? 503 : 200); + if ($detail) { + $endpoint->detailResponse = $response; + } else { + $endpoint->overviewResponse = $response; + } + } + $this->get('/firefly/flags'.($detail ? '?flag=banner' : ''))->assertStatus(503) + ->assertSee('Feature flag data is unavailable')->assertDontSee('No flags defined')->assertDontSee('No such flag') + ->assertDontSee('no flag store is configured')->assertDontSee('Save definition')->assertDontSee('Disable'); +})->with([false, true])->with(['throw', 'failed', 'malformed', 'shape']); + +it('renders a genuinely empty overview as empty', function (): void { + /** @var AdminCapstoneTestCase $this */ + featureFlagsAdminScripted()->overviewResponse = EndpointResponse::text('{"flags":[],"sources":[],"provider":{},"writable":false,"writesEnabled":false}', 200); + $this->get('/firefly/flags')->assertOk()->assertSee('No flags defined')->assertDontSee('Feature flag data is unavailable'); +}); + +it('renders the exact JSON shape of preview values', function (string $value): void { + /** @var AdminCapstoneTestCase $this */ + featureFlagsAdminScripted()->previewValue = $value; + $this->get('/firefly/flags?flag=banner&evaluate=1')->assertOk() + ->assertSee('data-evaluation="value">'.e($value).'', false); +})->with(['{}', '[]', '{"0":{},"1":[{},[],1.0]}', '1.0']); + +it('shows a complete faithful preview when PHP cannot represent a member as an object property', function (): void { + /** @var AdminCapstoneTestCase $this */ + featureFlagsAdminScripted()->previewValue = '{"\\u0000nested":{"0":{},"list":[],"float":1.0}}'; + $this->get('/firefly/flags?flag=banner&evaluate=1')->assertOk()->assertSee('Complete evaluation JSON') + ->assertSee(featureFlagsAdminScripted()->previewValue)->assertDontSee('data-evaluation="value">null', false); +}); + +it('shows faithful complete details without offering a lossy definition editor', function (): void { + /** @var AdminCapstoneTestCase $this */ + $definition = '{"state":"ENABLED","variants":{"v":{"\\u0000nested":{"0":{},"list":[],"float":1.0}}},"defaultVariant":"v"}'; + featureFlagsAdminScripted()->detailResponse = EndpointResponse::text('{"key":"banner","definition":'.$definition.',"layers":[],"history":[],"origin":"store","version":3}', 200); + $this->get('/firefly/flags?flag=banner')->assertOk()->assertSee('Complete flag detail JSON')->assertSee($definition) + ->assertDontSee('Save definition')->assertDontSee('name="definition"', false)->assertDontSee('Feature flag data is unavailable'); +}); + +it('forwards valid NUL members in raw definition and preview context without object decoding', function (): void { + /** @var AdminCapstoneTestCase $this */ + $definition = '{"state":"ENABLED","variants":{"v":{"\\u0000name":{}}},"defaultVariant":"v"}'; + $this->post('/firefly/flags', ['flag' => 'banner', 'op' => 'put', 'definition' => $definition]) + ->assertSessionHas('data-message', 'Saved [banner].'); + expect(featureFlagsAdminLastRequest()->rawBody)->toBe('{"action":"put","definition":'.$definition.'}'); + $this->get('/firefly/flags?flag=banner&evaluate=1&context='.rawurlencode('{"\\u0000name":1.0}'))->assertOk()->assertSee('TARGETING_MATCH'); + expect(featureFlagsAdminLastRequest()->rawBody)->toContain('"context":{"\\u0000name":1.0}'); +}); diff --git a/packages/cli/composer.json b/packages/cli/composer.json index 6694ebd4..82f1e05b 100644 --- a/packages/cli/composer.json +++ b/packages/cli/composer.json @@ -29,6 +29,7 @@ "firefly/cqrs": "*@dev", "firefly/data": "*@dev", "firefly/eda": "*@dev", + "firefly/feature-flags": "*@dev", "firefly/kernel": "*@dev", "firefly/messaging": "*@dev", "firefly/observability": "*@dev", diff --git a/packages/cli/src/Cache/ManifestCacheWriter.php b/packages/cli/src/Cache/ManifestCacheWriter.php index 6a1e718a..99d5c6fd 100644 --- a/packages/cli/src/Cache/ManifestCacheWriter.php +++ b/packages/cli/src/Cache/ManifestCacheWriter.php @@ -17,6 +17,7 @@ use Firefly\Data\Transaction\TransactionalManifestCompiler; use Firefly\Eda\Listener\EventListenerManifestCompiler; use Firefly\Eda\Scanner\EventListenerScanner; +use Firefly\FeatureFlags\Gating\FeatureFlagAdviceSource; use Firefly\Messaging\Listener\MessageListenerManifestCompiler; use Firefly\Messaging\Scanner\MessageListenerScanner; use Firefly\Observability\Method\ObservabilityAdviceSource; @@ -97,15 +98,16 @@ public function writeProxies(array $psr4, string $dir): int } /** - * The advice every proxy runs, in the order the plan will chain it: metrics (50) outside security (100) - * outside resilience (200) outside transactions (1000). D4 direct dependency like every other scanner - * here — the uncached boot collects the same four sources as #[Component]s through Container::getAll(), + * The advice every proxy runs, in the order the plan will chain it: metrics (50) outside feature flags (80) + * outside security (100) outside resilience (200) outside transactions (1000). D4 direct dependency like + * every other scanner here — the uncached boot collects the same five sources as #[Component]s through Container::getAll(), * so a cached app and a dev app enforce an identical plan. */ private function planner(): ProxyPlanner { return new ProxyPlanner([ new ObservabilityAdviceSource, + new FeatureFlagAdviceSource, new MethodSecurityAdviceSource, new ResilienceAdviceSource, new TransactionalAdviceSource, diff --git a/packages/cli/tests/Cache/CachedBootTest.php b/packages/cli/tests/Cache/CachedBootTest.php index 313881b4..c12fc00e 100644 --- a/packages/cli/tests/Cache/CachedBootTest.php +++ b/packages/cli/tests/Cache/CachedBootTest.php @@ -15,6 +15,9 @@ use Firefly\Data\Proxy\Advice; use Firefly\Data\Proxy\ProxyPlan; use Firefly\Data\Transaction\TransactionalManifest; +use Firefly\FeatureFlags\FeatureFlagsServiceProvider; +use Firefly\FeatureFlags\Gating\FeatureFlagAdviceSource; +use Firefly\FeatureFlags\Gating\FeatureFlagDisabledException; use Firefly\Observability\Method\ObservabilityAdviceSource; use Firefly\Resilience\Method\ResilienceAdviceSource; use Firefly\Security\Access\Method\MethodSecurityAdviceSource; @@ -73,7 +76,7 @@ function cachedBootPsr4(): array ->and($plan->hasProxyFor(DemoSecuredService::class))->toBeTrue() ->and(array_keys($plan->adviceFor(DemoSecuredService::class)))->toBe([MethodSecurityAdviceSource::ID]) ->and($plan->methodsFor(DemoSecuredService::class)['secret'][0]['row']['expression'])->toBe("hasRole('ADMIN')") - // …and the same for the metric-only #[Service]: the third source in planner(), which nothing else + // …and the same for the metric-only #[Service]: its source in planner(), which nothing else // in this package's suite exercises. Deleting `new ObservabilityAdviceSource` from planner() used to // leave the whole suite green while a cached app recorded no method meter at all. ->and($plan->hasProxyFor(DemoTimedService::class))->toBeTrue() @@ -103,7 +106,7 @@ function cachedBootPsr4(): array | The chain, on the compiled artifact. ObservabilityAdviceSource's order 50 is the constant every claim about | #[Timed] rests on — "a refusal is still counted", "the commit is inside the timer" — and a class carrying | ONE advice can never contradict it, which is what every other fixture here carries. DemoLayeredService - | carries all four on one method, so ProxyPlanner's sort and ProxyPlan::adviceFor()'s sort both have to + | carries all five on one method, so ProxyPlanner's sort and ProxyPlan::adviceFor()'s sort both have to | agree on the whole sequence before this passes. Changing 50 to 150 leaves every other test in the | monorepo green and turns method metrics into a meter that goes quiet exactly when an operator needs it. | @@ -114,7 +117,7 @@ function cachedBootPsr4(): array | application lost all six resilience attributes — the proxies still generated, just with no resilience | advice in them at all. */ -it('chains metrics OUTSIDE security, security outside resilience and resilience outside the transaction', function () { +it('chains metrics outside feature flags, feature flags outside security, security outside resilience and resilience outside the transaction', function () { $dir = sys_get_temp_dir().'/firefly-cache-'.bin2hex(random_bytes(6)); (new ManifestCacheWriter)->write(cachedBootPsr4(), $dir); @@ -123,17 +126,19 @@ function cachedBootPsr4(): array $advice = $plan->adviceFor(DemoLayeredService::class); // The advice set on the class, outermost first, and the orders that put it in that sequence. - expect(array_keys($advice))->toBe([ObservabilityAdviceSource::ID, MethodSecurityAdviceSource::ID, ResilienceAdviceSource::ID, Advice::TRANSACTIONAL]) + expect(array_keys($advice))->toBe([ObservabilityAdviceSource::ID, FeatureFlagAdviceSource::ID, MethodSecurityAdviceSource::ID, ResilienceAdviceSource::ID, Advice::TRANSACTIONAL]) ->and($advice[ObservabilityAdviceSource::ID]->order)->toBe(50) + ->and($advice[FeatureFlagAdviceSource::ID]->order)->toBe(80) ->and($advice[ResilienceAdviceSource::ID]->order)->toBe(200) - ->and($advice[ObservabilityAdviceSource::ID]->order)->toBeLessThan($advice[MethodSecurityAdviceSource::ID]->order) + ->and($advice[ObservabilityAdviceSource::ID]->order)->toBeLessThan($advice[FeatureFlagAdviceSource::ID]->order) + ->and($advice[FeatureFlagAdviceSource::ID]->order)->toBeLessThan($advice[MethodSecurityAdviceSource::ID]->order) ->and($advice[MethodSecurityAdviceSource::ID]->order)->toBeLessThan($advice[ResilienceAdviceSource::ID]->order) ->and($advice[ResilienceAdviceSource::ID]->order)->toBeLessThan($advice[Advice::TRANSACTIONAL]->order); // …and the per-method rows, which are what ProxyPlanner::proxyMethods() turns into the generated chain: // the same sequence, so the emitted proceed() really does reach the metric link first. expect(array_column($plan->methodsFor(DemoLayeredService::class)['all'], 'advice')) - ->toBe([ObservabilityAdviceSource::ID, MethodSecurityAdviceSource::ID, ResilienceAdviceSource::ID, Advice::TRANSACTIONAL]); + ->toBe([ObservabilityAdviceSource::ID, FeatureFlagAdviceSource::ID, MethodSecurityAdviceSource::ID, ResilienceAdviceSource::ID, Advice::TRANSACTIONAL]); // ResilienceAdviceSource::render() runs on the CACHED path alone — the resilience capstones take the // scan branch by design — so this is the only place the emitted descriptor literal is proved to parse @@ -144,7 +149,8 @@ function cachedBootPsr4(): array expect($layeredProxy)->toContain('__fireflyResilienceInterceptor') ->and($layeredProxy)->toContain('\\Firefly\\Resilience\\Method\\ResilienceMethodDescriptor::fromArray(') - ->and($plan->methodsFor(DemoLayeredService::class)['all'][2]['row']['retry'])->toBe('demo'); + ->and($layeredProxy)->toContain('\\Firefly\\FeatureFlags\\Gating\\FeatureFlagMethodDescriptor::fromArray(') + ->and($plan->methodsFor(DemoLayeredService::class)['all'][3]['row']['retry'])->toBe('demo'); }); it('boots the fixture app on the CACHED zero-reflection path with a working #[Transactional] proxy', function () { @@ -169,6 +175,7 @@ function cachedBootPsr4(): array 'component_manifest' => $dir.'/'.FireflyCachePaths::COMPONENT, 'context_manifest' => $dir.'/'.FireflyCachePaths::CONTEXT, ], + 'feature-flags' => ['enabled' => true, 'flags' => ['demo-layered' => false]], ], // Seeds a value that DIFFERS from DemoConfigProperties's constructor default ('hello'), so // assertion (d) below can distinguish "the DTO was POPULATED FROM CONFIG" from "the DTO merely @@ -180,6 +187,7 @@ function cachedBootPsr4(): array ValidationServiceProvider::class, WebServiceProvider::class, DataServiceProvider::class, + FeatureFlagsServiceProvider::class, FireflyCacheServiceProvider::class, ], // ValidationAutoConfiguration's validator() bean is an eager singleton needing the Illuminate validation @@ -247,6 +255,10 @@ function cachedBootAssertions(Application $app): void expect($timed::class)->toBe(DemoTimedService::class.ProxyPlan::PROXY_SUFFIX) ->and($timed->measured())->toBe('measured'); + /** @var DemoLayeredService $layered */ + $layered = $context->get(DemoLayeredService::class); + expect(fn () => $layered->all('dark'))->toThrow(FeatureFlagDisabledException::class); + // Category C: the compiled TransactionalManifest is populated (the #[Configuration] #[Bean] loaded it). /** @var TransactionalManifest $manifest */ $manifest = $context->get(TransactionalManifest::class); diff --git a/packages/cli/tests/Fixtures/App/DemoLayeredService.php b/packages/cli/tests/Fixtures/App/DemoLayeredService.php index 57b04e19..ab59a2c4 100644 --- a/packages/cli/tests/Fixtures/App/DemoLayeredService.php +++ b/packages/cli/tests/Fixtures/App/DemoLayeredService.php @@ -6,21 +6,22 @@ use Firefly\Container\Attributes\Service; use Firefly\Data\Transaction\Attributes\Transactional; +use Firefly\FeatureFlags\Gating\FeatureFlag; use Firefly\Observability\Method\Timed; use Firefly\Resilience\Method\Retry; use Firefly\Security\Access\Attributes\PreAuthorize; /** - * The one fixture that carries ALL FOUR advice kinds on a single method, so the compiled plan has something + * The one fixture that carries ALL FIVE advice kinds on a single method, so the compiled plan has something * to say about their relative order. DemoTimedService, DemoSecuredService and DemoTransactionalService each * prove that their own AdviceSource reaches planner(); none of them can prove that metrics (50) is chained - * OUTSIDE security (100), which is outside resilience (200), which is outside the transaction (1000), + * OUTSIDE feature flags (80), which is outside security (100), which is outside resilience (200), which is outside the transaction (1000), * because a class with one advice has no chain. * * The number 50 is the single most load-bearing constant in firefly/observability's method metrics — the * whole argument for #[Timed] being worth anything under a permissions misconfiguration rests on the metric * link running before MethodSecurityInterceptor throws — and it was, until this fixture, justified only in a - * docblock. #[Retry] is here for the same reason and with the same history: planner() names four + * docblock. #[Retry] is here for the same reason and with the same history: planner() names five * AdviceSources and this package's suite could not tell whether the fourth was in the list at all, so * deleting `new ResilienceAdviceSource` left every test green while every cached application silently lost * all six resilience attributes. NOT final: the proxy extends it. @@ -29,6 +30,7 @@ class DemoLayeredService { #[Timed('demo.layered')] + #[FeatureFlag('demo-layered', default: true)] #[PreAuthorize("hasRole('ADMIN')")] #[Retry('demo')] #[Transactional] diff --git a/packages/feature-flags/.gitattributes b/packages/feature-flags/.gitattributes new file mode 100644 index 00000000..538b69a4 --- /dev/null +++ b/packages/feature-flags/.gitattributes @@ -0,0 +1,2 @@ +/tests export-ignore +/.gitattributes export-ignore diff --git a/packages/feature-flags/LICENSE b/packages/feature-flags/LICENSE new file mode 100644 index 00000000..2240005d --- /dev/null +++ b/packages/feature-flags/LICENSE @@ -0,0 +1,204 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Copyright 2026 Firefly Software Solutions Inc. diff --git a/packages/feature-flags/README.md b/packages/feature-flags/README.md new file mode 100644 index 00000000..a2dfaed9 --- /dev/null +++ b/packages/feature-flags/README.md @@ -0,0 +1,14 @@ +# firefly/feature-flags + +Feature flags for LaraFly on OpenFeature. Flags are flagd flag definitions — the format the OpenFeature +ecosystem shares — evaluated in-process with the same semantics PyFly uses, so a user gets the same +variant and the same percentage bucket in a PHP service and in a Python one. Definitions come from +`firefly.feature-flags.flags`, a watched flagd file, another service's sync endpoint and a writable store; +the highest layer that defines a key wins. Code reads flags through the `FeatureFlags` service or any +OpenFeature client, gates a bean method or a route with `#[FeatureFlag]`, and a Blade view with +`@featureflag`. Operators change flags at runtime from the admin dashboard, the `flags` actuator endpoint +and `php artisan firefly:flags`, with every change recorded. + +See [Feature Flags](../../docs/modules/feature-flags.md) for the full guide. + +Apache-2.0 © Firefly Software Solutions Inc. diff --git a/packages/feature-flags/cache/firefly-feature-flags-components.php b/packages/feature-flags/cache/firefly-feature-flags-components.php new file mode 100644 index 00000000..65755771 --- /dev/null +++ b/packages/feature-flags/cache/firefly-feature-flags-components.php @@ -0,0 +1,441 @@ + [ + 'class' => 'Firefly\\FeatureFlags\\Boot\\FeatureFlagsLifecycle', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Kernel\\Lifecycle', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Container\\Container', + 1 => 'OpenFeature\\interfaces\\flags\\API', + ], + ], + 1 => [ + 'class' => 'Firefly\\FeatureFlags\\Context\\ApplicationEvaluationContextContributor', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => -200, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\FeatureFlags\\Context\\EvaluationContextContributor', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Contracts\\Config\\Repository', + ], + ], + 2 => [ + 'class' => 'Firefly\\FeatureFlags\\Evaluation\\FlagdEvaluatorConfiguration', + 'stereotype' => 'configuration', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 700, + 'qualifier' => null, + 'interfaces' => [ + ], + 'beans' => [ + 0 => [ + 'method' => 'flagdEvaluator', + 'returns' => 'Firefly\\FeatureFlags\\Evaluation\\FlagdEvaluator', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + ], + ], + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], + 3 => [ + 'class' => 'Firefly\\FeatureFlags\\FeatureFlagsAutoConfiguration', + 'stereotype' => 'configuration', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 700, + 'qualifier' => null, + 'interfaces' => [ + ], + 'beans' => [ + 0 => [ + 'method' => 'featureFlagsSettings', + 'returns' => 'Firefly\\FeatureFlags\\FeatureFlagsSettings', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], + ], + 1 => [ + 'method' => 'featureFlagMetrics', + 'returns' => 'Firefly\\FeatureFlags\\Telemetry\\FeatureFlagMetrics', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + ], + ], + 2 => [ + 'method' => 'flagRegistry', + 'returns' => 'Firefly\\FeatureFlags\\Registry\\FlagRegistry', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Container\\Container', + 1 => 'Illuminate\\Contracts\\Cache\\Repository', + 2 => 'Firefly\\Context\\Event\\ApplicationEventPublisher', + 3 => 'Psr\\Log\\LoggerInterface', + ], + ], + 3 => [ + 'method' => 'openFeatureProvider', + 'returns' => 'OpenFeature\\interfaces\\provider\\Provider', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\FeatureFlags\\Registry\\FlagRegistry', + 1 => 'Firefly\\FeatureFlags\\Evaluation\\FlagdEvaluator', + 2 => 'Psr\\Log\\LoggerInterface', + ], + ], + 4 => [ + 'method' => 'evaluationContextResolver', + 'returns' => 'Firefly\\FeatureFlags\\Context\\EvaluationContextResolver', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Container\\Container', + 1 => 'Psr\\Log\\LoggerInterface', + ], + ], + 5 => [ + 'method' => 'featureFlags', + 'returns' => 'Firefly\\FeatureFlags\\FeatureFlags', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'OpenFeature\\interfaces\\provider\\Provider', + 1 => 'Firefly\\FeatureFlags\\Context\\EvaluationContextResolver', + 2 => 'Firefly\\FeatureFlags\\Telemetry\\FeatureFlagMetrics', + 3 => 'Firefly\\Context\\Event\\ApplicationEventPublisher', + 4 => 'Firefly\\FeatureFlags\\FeatureFlagsSettings', + 5 => 'Firefly\\Container\\Container', + 6 => 'Psr\\Log\\LoggerInterface', + ], + ], + 6 => [ + 'method' => 'featureFlagsClient', + 'returns' => 'OpenFeature\\interfaces\\flags\\Client', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\FeatureFlags\\FeatureFlags', + ], + ], + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], + 4 => [ + 'class' => 'Firefly\\FeatureFlags\\Gating\\FeatureFlagAdviceSource', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Data\\Proxy\\AdviceSource', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], + 5 => [ + 'class' => 'Firefly\\FeatureFlags\\Gating\\FeatureFlagsGatingConfiguration', + 'stereotype' => 'configuration', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 700, + 'qualifier' => null, + 'interfaces' => [ + ], + 'beans' => [ + 0 => [ + 'method' => 'featureFlagGate', + 'returns' => 'Firefly\\FeatureFlags\\Gating\\FeatureFlagGate', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Container\\Container', + 1 => 'Firefly\\Config\\Config', + ], + ], + 1 => [ + 'method' => 'routeGateDecisions', + 'returns' => 'Firefly\\FeatureFlags\\Gating\\RouteGateDecisions', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Illuminate\\Contracts\\Container\\Container', + ], + ], + 2 => [ + 'method' => 'featureFlagMethodInterceptor', + 'returns' => 'Firefly\\FeatureFlags\\Gating\\FeatureFlagMethodInterceptor', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\FeatureFlags\\Gating\\FeatureFlagGate', + 1 => 'Firefly\\FeatureFlags\\Gating\\RouteGateDecisions', + ], + ], + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], + 6 => [ + 'class' => 'Firefly\\FeatureFlags\\Management\\FeatureFlagsHealthIndicator', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Actuator\\Health\\ConditionalHealthIndicator', + 1 => 'Firefly\\Actuator\\Health\\HealthIndicator', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Container\\Container', + 1 => 'Closure', + ], + ], + 7 => [ + 'class' => 'Firefly\\FeatureFlags\\Management\\FeatureFlagsManagementConfiguration', + 'stereotype' => 'configuration', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 700, + 'qualifier' => null, + 'interfaces' => [ + ], + 'beans' => [ + 0 => [ + 'method' => 'flagManagement', + 'returns' => 'Firefly\\FeatureFlags\\Management\\FlagManagement', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\FeatureFlags\\FeatureFlagsSettings', + 1 => 'OpenFeature\\interfaces\\provider\\Provider', + 2 => 'Firefly\\Container\\Container', + 3 => 'Psr\\Log\\LoggerInterface', + ], + ], + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], + 8 => [ + 'class' => 'Firefly\\FeatureFlags\\Management\\FlagsEndpoint', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\Actuator\\Endpoint\\ActuatorEndpoint', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Container\\Container', + ], + ], + 9 => [ + 'class' => 'Firefly\\FeatureFlags\\Source\\ConfigFlagSource', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\FeatureFlags\\Source\\FlagSource', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\FeatureFlags\\FeatureFlagsSettings', + ], + ], + 10 => [ + 'class' => 'Firefly\\FeatureFlags\\Source\\FileFlagSource', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\FeatureFlags\\Source\\FlagSource', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\FeatureFlags\\FeatureFlagsSettings', + ], + ], + 11 => [ + 'class' => 'Firefly\\FeatureFlags\\Source\\HttpFlagSource', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\FeatureFlags\\Source\\FlagSource', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\FeatureFlags\\FeatureFlagsSettings', + 1 => 'Illuminate\\Http\\Client\\Factory', + ], + ], + 12 => [ + 'class' => 'Firefly\\FeatureFlags\\Source\\StoreFlagSource', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\FeatureFlags\\Source\\FlagSource', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\FeatureFlags\\FeatureFlagsSettings', + 1 => 'Firefly\\Container\\Container', + ], + ], + 13 => [ + 'class' => 'Firefly\\FeatureFlags\\Store\\FeatureFlagStoreConfiguration', + 'stereotype' => 'configuration', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 700, + 'qualifier' => null, + 'interfaces' => [ + ], + 'beans' => [ + 0 => [ + 'method' => 'flagStore', + 'returns' => 'Firefly\\FeatureFlags\\Store\\FlagStore', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\FeatureFlags\\FeatureFlagsSettings', + 1 => 'Illuminate\\Contracts\\Container\\Container', + ], + ], + 1 => [ + 'method' => 'flagStoreWriter', + 'returns' => 'Firefly\\FeatureFlags\\Store\\FlagStoreWriter', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\FeatureFlags\\Store\\FlagStore', + 1 => 'Firefly\\FeatureFlags\\Registry\\FlagRegistry', + 2 => 'Firefly\\Context\\Event\\ApplicationEventPublisher', + 3 => 'Psr\\Log\\LoggerInterface', + ], + ], + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], +]; diff --git a/packages/feature-flags/cache/firefly-feature-flags-context.php b/packages/feature-flags/cache/firefly-feature-flags-context.php new file mode 100644 index 00000000..9a6d318a --- /dev/null +++ b/packages/feature-flags/cache/firefly-feature-flags-context.php @@ -0,0 +1,523 @@ + [ + 'class' => 'Firefly\\FeatureFlags\\Boot\\FeatureFlagsLifecycle', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + ], + 'beanConditions' => [ + ], + ], + 1 => [ + 'class' => 'Firefly\\FeatureFlags\\Context\\ApplicationEvaluationContextContributor', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + ], + 'beanConditions' => [ + ], + ], + 2 => [ + 'class' => 'Firefly\\FeatureFlags\\Evaluation\\FlagdEvaluatorConfiguration', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + ], + 'beanConditions' => [ + 0 => [ + 'method' => 'flagdEvaluator', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Evaluation\\FlagdEvaluator', + ], + ], + ], + ], + ], + ], + 3 => [ + 'class' => 'Firefly\\FeatureFlags\\FeatureFlagsAutoConfiguration', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + ], + 'beanConditions' => [ + 0 => [ + 'method' => 'featureFlagsSettings', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\FeatureFlagsSettings', + ], + ], + ], + ], + 1 => [ + 'method' => 'featureFlagMetrics', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Telemetry\\FeatureFlagMetrics', + ], + ], + ], + ], + 2 => [ + 'method' => 'flagRegistry', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'OpenFeature\\interfaces\\provider\\Provider', + ], + ], + 2 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Registry\\FlagRegistry', + ], + ], + ], + ], + 3 => [ + 'method' => 'openFeatureProvider', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'OpenFeature\\interfaces\\provider\\Provider', + ], + ], + ], + ], + 4 => [ + 'method' => 'evaluationContextResolver', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Context\\EvaluationContextResolver', + ], + ], + ], + ], + 5 => [ + 'method' => 'featureFlags', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\FeatureFlags', + ], + ], + ], + ], + 6 => [ + 'method' => 'featureFlagsClient', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'OpenFeature\\interfaces\\flags\\Client', + ], + ], + ], + ], + ], + ], + 4 => [ + 'class' => 'Firefly\\FeatureFlags\\Gating\\FeatureFlagsGatingConfiguration', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + ], + 'beanConditions' => [ + 0 => [ + 'method' => 'featureFlagGate', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Gating\\FeatureFlagGate', + ], + ], + ], + ], + 1 => [ + 'method' => 'routeGateDecisions', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Gating\\RouteGateDecisions', + ], + ], + ], + ], + 2 => [ + 'method' => 'featureFlagMethodInterceptor', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Gating\\FeatureFlagMethodInterceptor', + ], + ], + ], + ], + ], + ], + 5 => [ + 'class' => 'Firefly\\FeatureFlags\\Management\\FeatureFlagsManagementConfiguration', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + ], + 'beanConditions' => [ + 0 => [ + 'method' => 'flagManagement', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Management\\FlagManagement', + ], + ], + ], + ], + ], + ], + 6 => [ + 'class' => 'Firefly\\FeatureFlags\\Source\\ConfigFlagSource', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'OpenFeature\\interfaces\\provider\\Provider', + ], + ], + ], + 'beanConditions' => [ + ], + ], + 7 => [ + 'class' => 'Firefly\\FeatureFlags\\Source\\FileFlagSource', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.sources.file.enabled', + 1 => 'true', + 2 => false, + ], + ], + 2 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'OpenFeature\\interfaces\\provider\\Provider', + ], + ], + ], + 'beanConditions' => [ + ], + ], + 8 => [ + 'class' => 'Firefly\\FeatureFlags\\Source\\HttpFlagSource', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.sources.http.enabled', + 1 => 'true', + 2 => false, + ], + ], + 2 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'OpenFeature\\interfaces\\provider\\Provider', + ], + ], + ], + 'beanConditions' => [ + ], + ], + 9 => [ + 'class' => 'Firefly\\FeatureFlags\\Source\\StoreFlagSource', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.sources.store.enabled', + 1 => 'true', + 2 => false, + ], + ], + 2 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'OpenFeature\\interfaces\\provider\\Provider', + ], + ], + ], + 'beanConditions' => [ + ], + ], + 10 => [ + 'class' => 'Firefly\\FeatureFlags\\Store\\FeatureFlagStoreConfiguration', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + ], + 'beanConditions' => [ + 0 => [ + 'method' => 'flagStore', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.sources.store.enabled', + 1 => 'true', + 2 => false, + ], + ], + 2 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Registry\\FlagRegistry', + ], + ], + 3 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Store\\FlagStore', + ], + ], + ], + ], + 1 => [ + 'method' => 'flagStoreWriter', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.sources.store.enabled', + 1 => 'true', + 2 => false, + ], + ], + 2 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Registry\\FlagRegistry', + ], + ], + 3 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Store\\FlagStoreWriter', + ], + ], + ], + ], + ], + ], +]; diff --git a/packages/feature-flags/composer.json b/packages/feature-flags/composer.json new file mode 100644 index 00000000..4bc031f8 --- /dev/null +++ b/packages/feature-flags/composer.json @@ -0,0 +1,73 @@ +{ + "name": "firefly/feature-flags", + "description": "LaraFly feature flags on OpenFeature: flagd documents evaluated in-process, layered config/file/http/store sources, #[FeatureFlag] gating, a writable store with an audit trail and a sync endpoint.", + "type": "library", + "license": "Apache-2.0", + "homepage": "https://github.com/fireflyframework/fireflyframework-php", + "authors": [ + { + "name": "Firefly Software Solutions Inc.", + "homepage": "https://github.com/fireflyframework" + } + ], + "keywords": [ + "firefly", + "laravel", + "feature-flags", + "openfeature", + "flagd" + ], + "support": { + "issues": "https://github.com/fireflyframework/fireflyframework-php/issues", + "source": "https://github.com/fireflyframework/fireflyframework-php/tree/main/packages/feature-flags" + }, + "require": { + "php": "^8.3", + "firefly/actuator": "*@dev", + "firefly/autoconfigure": "*@dev", + "firefly/config": "*@dev", + "firefly/container": "*@dev", + "firefly/context": "*@dev", + "firefly/data": "*@dev", + "firefly/kernel": "*@dev", + "firefly/resilience": "*@dev", + "firefly/web": "*@dev", + "illuminate/cache": "^13.0", + "illuminate/console": "^13.0", + "illuminate/container": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/database": "^13.0", + "illuminate/events": "^13.0", + "illuminate/http": "^13.0", + "illuminate/routing": "^13.0", + "illuminate/support": "^13.0", + "open-feature/sdk": "^2.3", + "psr/log": "^3.0", + "symfony/yaml": "^7.4|^8.0" + }, + "extra": { + "laravel": { + "providers": [ + "Firefly\\FeatureFlags\\FeatureFlagsServiceProvider", + "Firefly\\FeatureFlags\\FeatureFlagsWiringProvider" + ] + }, + "branch-alias": { + "dev-main": "26.x-dev" + } + }, + "autoload": { + "psr-4": { + "Firefly\\FeatureFlags\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Firefly\\FeatureFlags\\Tests\\": "tests/" + } + }, + "minimum-stability": "stable", + "config": { + "sort-packages": true + } +} diff --git a/packages/feature-flags/database/migrations/2026_10_01_000000_create_firefly_feature_flags_tables.php b/packages/feature-flags/database/migrations/2026_10_01_000000_create_firefly_feature_flags_tables.php new file mode 100644 index 00000000..7f0eb938 --- /dev/null +++ b/packages/feature-flags/database/migrations/2026_10_01_000000_create_firefly_feature_flags_tables.php @@ -0,0 +1,31 @@ +getConnection())); + } + + public function down(): void + { + FeatureFlagSchema::drop(Schema::connection($this->getConnection())); + } +}; diff --git a/packages/feature-flags/src/Boot/FeatureFlagsLifecycle.php b/packages/feature-flags/src/Boot/FeatureFlagsLifecycle.php new file mode 100644 index 00000000..18b81ffd --- /dev/null +++ b/packages/feature-flags/src/Boot/FeatureFlagsLifecycle.php @@ -0,0 +1,126 @@ +|null */ + private static ?WeakMap $active = null; + + private static int $nextSequence = 0; + + private ?Provider $installed = null; + + private ?Provider $previous = null; + + private ?self $previousOwner = null; + + private int $sequence = 0; + + public function __construct( + private readonly FireflyContainer $beans, + private readonly ?API $api = null, + ) {} + + public function start(): void + { + $provider = $this->beans->get(Provider::class); + if ($provider instanceof Provider) { + $api = $this->api(); + $this->previous = $api->getProvider(); + $this->previousOwner = $this->activeOwner($this->previous); + $this->installed = $provider; + $this->sequence = ++self::$nextSequence; + self::$active ??= new WeakMap; + self::$active[$this] = true; + } + + try { + if ($this->beans->has(FlagRegistry::class)) { + $registry = $this->beans->get(FlagRegistry::class); + if ($registry instanceof FlagRegistry) { + $registry->start(); + } + } + + if ($provider instanceof Provider) { + $this->api()->setProvider($provider); + } + } catch (Throwable $failure) { + $this->stop(); + throw $failure; + } + } + + public function stop(): void + { + if ($this->installed !== null) { + if ($this->ownsCurrentProvider() && $this->previous !== null) { + $this->api()->setProvider($this->previous); + } + + // A later application may still be running. Link its restoration past this stopped installation. + foreach (self::$active ?? [] as $lifecycle => $_) { + if ($lifecycle->previousOwner === $this) { + $lifecycle->previous = $this->previous; + $lifecycle->previousOwner = $this->previousOwner; + } + } + } + + if (self::$active !== null) { + unset(self::$active[$this]); + } + $this->installed = null; + $this->previous = null; + $this->previousOwner = null; + } + + private function api(): API + { + return $this->api ?? OpenFeatureAPI::getInstance(); + } + + private function ownsCurrentProvider(): bool + { + if ($this->api()->getProvider() !== $this->installed) { + return false; + } + + foreach (self::$active ?? [] as $lifecycle => $_) { + if ($lifecycle !== $this && $lifecycle->api() === $this->api() && $lifecycle->installed === $this->installed && $lifecycle->sequence > $this->sequence) { + return false; + } + } + + return true; + } + + private function activeOwner(Provider $provider): ?self + { + $owner = null; + foreach (self::$active ?? [] as $lifecycle => $_) { + if ($lifecycle->api() === $this->api() && $lifecycle->installed === $provider && ($owner === null || $lifecycle->sequence > $owner->sequence)) { + $owner = $lifecycle; + } + } + + return $owner; + } +} diff --git a/packages/feature-flags/src/Console/FlagsCommand.php b/packages/feature-flags/src/Console/FlagsCommand.php new file mode 100644 index 00000000..c6df4b62 --- /dev/null +++ b/packages/feature-flags/src/Console/FlagsCommand.php @@ -0,0 +1,197 @@ + */ + private const array ACTIONS = ['list', 'show', 'evaluate', 'enable', 'disable', 'default-variant', 'put', 'delete']; + + protected $signature = 'firefly:flags + {action : list, show, evaluate, enable, disable, default-variant, put or delete} + {key? : The flag key (every action but list)} + {value? : The variant, for default-variant} + {--file= : put: a JSON file holding one flagd flag definition} + {--context= : evaluate: the evaluation context, a JSON object} + {--targeting-key= : evaluate: the targeting key} + {--expected-version= : writes: require this stored version (0 means absent)} + {--json : Print the response body as JSON}'; + + protected $description = 'List, inspect, evaluate and change feature flags.'; + + public function handle(FireflyContainer $beans): int + { + try { + $management = $beans->has(FlagManagement::class) ? $beans->get(FlagManagement::class) : null; + if (! $management instanceof FlagManagement) { + throw new FlagManagementException(ManagementError::BadRequest, 'Feature flags are switched off: set firefly.feature-flags.enabled to true.'); + } + + $action = $this->stringArgument('action') ?? ''; + if (! in_array($action, self::ACTIONS, true)) { + throw new FlagManagementException(ManagementError::BadRequest, "Unknown action [{$action}]: use one of ".implode(', ', self::ACTIONS).'.'); + } + + if ($action === 'list') { + return $this->render($management->overview()); + } + + $key = $this->stringArgument('key') ?? throw new FlagManagementException(ManagementError::BadRequest, "{$action} needs a flag key."); + $result = $action === 'show' + ? $management->describe($key) + : $management->apply($key, $this->body($action), self::actor()); + + return $this->render($result); + } catch (FlagManagementException $refused) { + if ($this->option('json') === true) { + $this->line(Json::encode($refused->toArray())); + } else { + $this->components->error("{$refused->error->value}: {$refused->getMessage()}"); + } + + return self::FAILURE; + } + } + + /** @return array */ + private function body(string $action): array + { + $body = ['action' => $action]; + + $expected = $this->option('expected-version'); + if (is_string($expected)) { + $digits = ltrim($expected, '0'); + $limit = (string) PHP_INT_MAX; + if (preg_match('/^\d+$/D', $expected) !== 1 || strlen($digits) > strlen($limit) + || (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)) { + throw new FlagManagementException(ManagementError::BadRequest, '--expected-version must be a non-negative integer within the PHP integer range.'); + } + $body['expectedVersion'] = (int) $expected; + } + + if ($action === 'default-variant') { + $body['variant'] = $this->stringArgument('value') ?? throw new FlagManagementException(ManagementError::BadRequest, 'default-variant needs the variant: firefly:flags default-variant {key} {variant}.'); + } + + if ($action === 'put') { + $file = $this->stringOption('file') ?? throw new FlagManagementException(ManagementError::BadRequest, 'put needs --file=.'); + $contents = is_file($file) && is_readable($file) ? @file_get_contents($file) : false; + if ($contents === false) { + throw new FlagManagementException(ManagementError::BadRequest, "Cannot read [{$file}]."); + } + $definition = self::decode($contents, "[{$file}] is not valid JSON."); + if (! Json::isObject($definition)) { + throw new FlagManagementException(ManagementError::BadRequest, "[{$file}] must contain a JSON object."); + } + $body['definition'] = $definition; + } + + if ($action === 'evaluate') { + $context = $this->option('context'); + if (is_string($context)) { + $decoded = self::decode($context, '--context must be a JSON object.'); + if (! Json::isObject($decoded)) { + throw new FlagManagementException(ManagementError::BadRequest, '--context must be a JSON object.'); + } + $body['context'] = $decoded; + } + $targetingKey = $this->stringOption('targeting-key'); + if ($targetingKey !== null) { + $body['targetingKey'] = $targetingKey; + } + } + + return $body; + } + + /** @param array $body */ + private function render(array $body): int + { + if ($this->option('json') === true) { + $this->line(Json::encode($body)); + + return self::SUCCESS; + } + + if (isset($body['flags']) && is_array($body['flags'])) { + $rows = []; + foreach ($body['flags'] as $flag) { + if (is_array($flag)) { + $rows[] = [ + self::cell($flag['key'] ?? ''), + self::cell($flag['state'] ?? ''), + self::cell($flag['type'] ?? ''), + self::cell($flag['defaultVariant'] ?? ''), + self::cell($flag['origin'] ?? ''), + ($flag['expired'] ?? false) === true ? 'yes' : '', + ]; + } + } + $this->table(['Key', 'State', 'Type', 'Default', 'Origin', 'Expired'], $rows); + + return self::SUCCESS; + } + + if (($body['refreshPending'] ?? false) === true) { + $this->components->info('Write accepted for ['.self::cell($body['key'] ?? '').']; local visibility is pending. Check again after the next refresh.'); + + return self::SUCCESS; + } + + if (($body['deleted'] ?? false) === true) { + $this->components->info('Deleted ['.self::cell($body['key'] ?? '').'].'); + + return self::SUCCESS; + } + + $this->line(json_encode($body, JSON_PRETTY_PRINT | Json::ENCODE_FLAGS | JSON_THROW_ON_ERROR)); + + return self::SUCCESS; + } + + private static function decode(string $json, string $refusal): mixed + { + try { + return Json::decode($json); + } catch (JsonException) { + throw new FlagManagementException(ManagementError::BadRequest, $refusal); + } + } + + private static function actor(): string + { + $entry = function_exists('posix_geteuid') && function_exists('posix_getpwuid') ? posix_getpwuid(posix_geteuid()) : false; + $user = is_array($entry) && $entry['name'] !== '' ? $entry['name'] : null; + + return 'cli:'.($user ?? (getenv('USER') ?: getenv('USERNAME') ?: 'unknown')); + } + + private static function cell(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } + + private function stringArgument(string $name): ?string + { + $value = $this->argument($name); + + return is_string($value) && $value !== '' ? $value : null; + } + + private function stringOption(string $name): ?string + { + $value = $this->option($name); + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/packages/feature-flags/src/Context/ApplicationEvaluationContextContributor.php b/packages/feature-flags/src/Context/ApplicationEvaluationContextContributor.php new file mode 100644 index 00000000..6c72650c --- /dev/null +++ b/packages/feature-flags/src/Context/ApplicationEvaluationContextContributor.php @@ -0,0 +1,44 @@ +|null */ + private ?array $profiles = null; + + public function __construct(private readonly Repository $config) {} + + public function contribute(EvaluationContextBuilder $context): void + { + if ($this->profiles === null) { + $name = $this->config->get('app.name'); + $this->application = is_string($name) && $name !== '' ? $name : null; + $this->profiles = (new ProfileResolver($this->config))->resolve()->all(); + } + + if ($this->application !== null) { + $context->set('application', $this->application); + } + $context->set('profiles', $this->profiles); + } +} diff --git a/packages/feature-flags/src/Context/EvaluationContextBuilder.php b/packages/feature-flags/src/Context/EvaluationContextBuilder.php new file mode 100644 index 00000000..955053fe --- /dev/null +++ b/packages/feature-flags/src/Context/EvaluationContextBuilder.php @@ -0,0 +1,64 @@ + */ + private array $attributes = []; + + public function targetingKey(): ?string + { + return $this->targetingKey; + } + + /** An empty key is no key (anonymous traffic). */ + public function setTargetingKey(?string $targetingKey): self + { + $this->targetingKey = $targetingKey === '' ? null : $targetingKey; + + return $this; + } + + public function has(string $name): bool + { + return array_key_exists($name, $this->attributes); + } + + public function get(string $name): mixed + { + return $this->attributes[$name] ?? null; + } + + public function set(string $name, mixed $value): self + { + $this->attributes[$name] = $value; + + return $this; + } + + public function remove(string $name): self + { + unset($this->attributes[$name]); + + return $this; + } + + /** + * @return array + */ + public function attributes(): array + { + return $this->attributes; + } +} diff --git a/packages/feature-flags/src/Context/EvaluationContextContributor.php b/packages/feature-flags/src/Context/EvaluationContextContributor.php new file mode 100644 index 00000000..86ee9cc5 --- /dev/null +++ b/packages/feature-flags/src/Context/EvaluationContextContributor.php @@ -0,0 +1,22 @@ + $user->id]`, + * read as `"42"`) or a Stringable such as a UUID. Any other type is refused with a DEBUG line naming it, and the + * ambient key applies — and so is an Eloquent model or a collection (Arrayable/Jsonable): both are Stringable, but + * as their JSON text, which would key a rollout on every attribute the model holds (pass `$user->id`). A + * contributor that throws is skipped (logged at DEBUG): context is best-effort, an evaluation must not fail. + * + * Not ambient (`ambient: false`, the management preview): only the PROCESS attributes — what the + * ApplicationEvaluationContextContributor gives, `application` and `profiles` — under the explicit context. No + * other contributor runs, so neither the caller's principal, roles nor tenant can reach the evaluation. + * + * Top-level attributes are fitted to what OpenFeature's Attributes carry through the SDK client — null, scalars, + * arrays and DateTime; any other value makes the SDK fail the whole evaluation, and a numeric-looking name (an int + * key in PHP) makes its attribute merge, which every client evaluation runs, drop every attribute (first name) or + * fail (a later one): + * + * - a DateTimeImmutable becomes the equal DateTime (same instant, zone and microseconds); date-times are not + * converted further here — the evaluator turns them into epoch milliseconds; + * - a Stringable becomes its string — except an Eloquent model or a collection (Arrayable/Jsonable), whose string + * is its JSON text: it is dropped (pass the values a rule reads, such as `$user->plan`); + * - a JSON object (stdClass) becomes its members when they form Json's faithful array (non-empty, not a list). + * `{}` and a list-like object (`{"0": …}`) have no such form at the top level — as arrays they would read as + * lists, a different JSON value — so they are dropped rather than altered; + * - a numeric-looking name, an enum, any other object or a resource is dropped. + * + * Every dropped attribute is logged at DEBUG, once per evaluation, with its name and the reason. Values INSIDE an + * array are left exactly as they are: the SDK carries arrays as `mixed[]` without looking in, and the evaluator + * reads Json's faithful form (a nested stdClass is a JSON object there), so a nested `{}` keeps its meaning. + */ +final class EvaluationContextResolver +{ + /** + * @param list $contributors in #[Order] order + */ + public function __construct( + private readonly array $contributors = [], + private readonly LoggerInterface $logger = new NullLogger, + ) {} + + /** Every contributor, in order. */ + public function ambient(): EvaluationContextBuilder + { + return $this->contribute($this->contributors); + } + + /** The process attributes only: the ApplicationEvaluationContextContributor's `application` and `profiles`. */ + public function process(): EvaluationContextBuilder + { + return $this->contribute(array_values(array_filter( + $this->contributors, + static fn (EvaluationContextContributor $contributor): bool => $contributor instanceof ApplicationEvaluationContextContributor, + ))); + } + + /** + * @param array $explicit attribute name => value; `targetingKey` names the targeting key + */ + public function resolve(array $explicit = [], ?string $targetingKey = null, bool $ambient = true): EvaluationContext + { + $base = $ambient ? $this->ambient() : $this->process(); + $explicitKey = array_key_exists('targetingKey', $explicit) ? $this->explicitKey($explicit['targetingKey']) : null; + unset($explicit['targetingKey']); + + $key = match (true) { + $targetingKey !== null && $targetingKey !== '' => $targetingKey, + $explicitKey !== null => $explicitKey, + default => $base->targetingKey(), + }; + + $attributes = []; + foreach (array_replace($base->attributes(), $explicit) as $name => $value) { + if (! is_string($name)) { + $this->dropped((string) $name, 'its name is numeric-looking (an int key in PHP), which OpenFeature cannot carry'); + + continue; + } + + $this->carry($attributes, $name, $value); + } + + return new EvaluationContext($key, new Attributes($attributes)); + } + + /** The explicit targeting key as text: a non-empty string, an int or a Stringable; null (logged) otherwise. */ + private function explicitKey(mixed $key): ?string + { + if ($key === null || $key === '') { + return null; + } + + if (is_string($key)) { + return $key; + } + + if (is_int($key)) { + return (string) $key; + } + + if ($key instanceof Arrayable || $key instanceof Jsonable) { + return $this->refusedKey($key, 'an Eloquent model or a collection is not read as its JSON text: pass $user->id'); + } + + if ($key instanceof Stringable) { + try { + $text = (string) $key; + } catch (Throwable $failure) { + return $this->refusedKey($key, 'its string conversion failed: '.$failure->getMessage()); + } + + return $text === '' ? null : $text; + } + + return $this->refusedKey($key, 'a targeting key is a string, an int or a Stringable'); + } + + private function refusedKey(mixed $key, string $reason): null + { + $this->logger->debug('The explicit evaluation-context targetingKey of type {type} was refused ({reason}); the ambient key, if any, applies.', [ + 'type' => get_debug_type($key), + 'reason' => $reason, + ]); + + return null; + } + + /** + * Puts the value OpenFeature carries for top-level attribute $name into $attributes, or logs why there is none. + * + * @param array|null> $attributes + */ + private function carry(array &$attributes, string $name, mixed $value): void + { + if ($value === null || is_bool($value) || is_int($value) || is_float($value) || is_string($value) || is_array($value) || $value instanceof DateTime) { + $attributes[$name] = $value; + + return; + } + + if ($value instanceof DateTimeImmutable) { + $attributes[$name] = DateTime::createFromImmutable($value); + + return; + } + + if ($value instanceof Arrayable || $value instanceof Jsonable) { + $this->dropped($name, 'an Eloquent model or a collection ('.get_debug_type($value).'), which is not read as its JSON text: pass the values a rule reads, such as $user->plan'); + + return; + } + + if ($value instanceof Stringable) { + try { + $attributes[$name] = (string) $value; + } catch (Throwable $failure) { + $this->dropped($name, 'its string conversion failed: '.$failure->getMessage()); + } + + return; + } + + if ($value instanceof stdClass) { + $members = get_object_vars($value); + if ($members !== [] && ! array_is_list($members)) { + $attributes[$name] = $members; + } else { + $this->dropped($name, 'an empty or list-like JSON object ({} or {"0": …}) would read as a list once it is an array, the only top-level form OpenFeature carries'); + } + + return; + } + + $this->dropped($name, match (true) { + $value instanceof UnitEnum => 'an enum ('.$value::class.'), which OpenFeature cannot carry: pass its value', + is_object($value) => 'an object of class '.$value::class.', which OpenFeature cannot carry: pass a JSON value, a date-time or a Stringable', + default => 'a value of type '.get_debug_type($value).', which OpenFeature cannot carry', + }); + } + + private function dropped(string $name, string $reason): void + { + $this->logger->debug('Evaluation-context attribute [{name}] was dropped: {reason}.', ['name' => $name, 'reason' => $reason]); + } + + /** + * @param list $contributors + */ + private function contribute(array $contributors): EvaluationContextBuilder + { + $context = new EvaluationContextBuilder; + foreach ($contributors as $contributor) { + try { + $contributor->contribute($context); + } catch (Throwable $failure) { + $this->logger->debug('Evaluation-context contributor [{contributor}] failed and was skipped: {error}', [ + 'contributor' => $contributor::class, + 'error' => $failure->getMessage(), + ]); + } + } + + return $context; + } +} diff --git a/packages/feature-flags/src/Definition/FlagDefinition.php b/packages/feature-flags/src/Definition/FlagDefinition.php new file mode 100644 index 00000000..3754bdcd --- /dev/null +++ b/packages/feature-flags/src/Definition/FlagDefinition.php @@ -0,0 +1,176 @@ + $raw the flagd flag object in Json's faithful form + */ + public function __construct(public string $key, public array $raw) {} + + public static function fromJsonValue(string $key, mixed $value): self + { + return new self($key, Json::members($value)); + } + + /** '' when absent. */ + public function state(): string + { + $state = $this->raw['state'] ?? null; + + return is_string($state) ? $state : ''; + } + + public function isDisabled(): bool + { + return $this->state() === 'DISABLED'; + } + + /** + * Variant name => value. A numeric-looking name (`"1"`) is an int key here; variantNames() gives it as text. + * + * @return array + */ + public function variants(): array + { + return Json::members($this->raw['variants'] ?? null); + } + + /** + * @return list + */ + public function variantNames(): array + { + return array_map(static fn (int|string $name): string => (string) $name, array_keys($this->variants())); + } + + public function hasVariant(string $name): bool + { + return array_key_exists($name, $this->variants()); + } + + public function variantValue(string $name): mixed + { + return $this->variants()[$name] ?? null; + } + + /** A boolean names the variant `'true'`/`'false'`; `''` or null is no default variant. */ + public function defaultVariant(): ?string + { + $default = $this->raw['defaultVariant'] ?? null; + if (is_bool($default)) { + return $default ? 'true' : 'false'; + } + + return is_string($default) && $default !== '' ? $default : null; + } + + /** null when absent, null or an empty object. */ + public function targeting(): mixed + { + $targeting = $this->raw['targeting'] ?? null; + + if ($targeting === null || $targeting === [] || ($targeting instanceof stdClass && get_object_vars($targeting) === [])) { + return null; + } + + return $targeting; + } + + /** + * The scalar metadata entries. A numeric-looking name (`"1"`, `"2024"`) is an int key: PHP arrays cannot hold + * it as a string. Read a name with `(string) $name`. + * + * @return array + */ + public function metadata(): array + { + $metadata = []; + foreach (Json::members($this->raw['metadata'] ?? null) as $name => $value) { + if (is_bool($value) || is_int($value) || is_float($value) || is_string($value)) { + $metadata[$name] = $value; + } + } + + return $metadata; + } + + /** 'boolean', 'string', 'number' or 'object', read off the first variant. */ + public function valueType(): string + { + $values = array_values($this->variants()); + $first = $values[0] ?? null; + + return match (true) { + is_bool($first) => 'boolean', + is_int($first), is_float($first) => 'number', + is_string($first) => 'string', + default => 'object', + }; + } + + /** A number flag is Integer when every variant is an int, else Float. */ + public function flagType(): FlagType + { + return match ($this->valueType()) { + 'boolean' => FlagType::Boolean, + 'string' => FlagType::String, + 'number' => array_filter($this->variants(), static fn (mixed $value): bool => ! is_int($value)) === [] ? FlagType::Integer : FlagType::Float, + default => FlagType::Object, + }; + } + + public function expires(): ?string + { + $expires = $this->metadata()['expires'] ?? null; + + return is_string($expires) ? $expires : null; + } + + /** `expires` is before `$today` (both YYYY-MM-DD, UTC): the flag still evaluates, it is flag debt. */ + public function isExpired(string $today): bool + { + $expires = $this->expires(); + + return $expires !== null && strcmp($expires, $today) < 0; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->raw; + } + + /** + * The definition as it must be ENCODED: the three object positions a PHP array may have flattened to `[]` + * (config written as PHP arrays) are forced back to objects. + * + * @return stdClass|array + */ + public function toJsonValue(): stdClass|array + { + $value = $this->raw; + foreach (['variants', 'metadata', 'targeting'] as $position) { + if (array_key_exists($position, $value) && (is_array($value[$position]) || $value[$position] instanceof stdClass)) { + $value[$position] = Json::object($value[$position]); + } + } + + return Json::object($value); + } +} diff --git a/packages/feature-flags/src/Definition/FlagDefinitions.php b/packages/feature-flags/src/Definition/FlagDefinitions.php new file mode 100644 index 00000000..ea937d82 --- /dev/null +++ b/packages/feature-flags/src/Definition/FlagDefinitions.php @@ -0,0 +1,323 @@ + */ + public const array KINDS = ['release', 'experiment', 'ops', 'permission']; + + /** + * The deepest a flag definition, an evaluator rule or the document metadata may nest (R-depth-validate): the + * definition itself is level 1, and each object or array inside adds one. + */ + public const int MAX_DEPTH = 256; + + /** + * Shorthand → flagd. Only config and test overrides accept shorthand; full definitions pass through + * untouched and anything else is left for validate() to refuse. + * + * @param array $flags + * @return array + */ + public static function normalize(array $flags): array + { + $normalized = []; + foreach ($flags as $key => $definition) { + $normalized[$key] = match (true) { + $definition === true => ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'on'], + $definition === false => ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off'], + is_string($definition) => ['state' => 'ENABLED', 'variants' => Json::object([$definition => $definition]), 'defaultVariant' => $definition], + default => $definition, + }; + } + + return $normalized; + } + + /** + * Validate a flagd document (`flags`, `$evaluators`, `metadata`) as a whole and return it. + * + * A document that is not an object is refused with the key ``. Each section is empty when absent, + * null or `[]`; a present section that is not an object is refused with the section's name as the key. A single + * flag that breaks a rule is reported with its own key, an evaluator rule that is not an object with the key + * `$evaluators` (the reason names the evaluator). A `$ref` naming no evaluator is not a load error: evaluating + * that flag reports it. + * + * @throws InvalidFlagDefinition + */ + public static function parseDocument(mixed $document): FlagDocument + { + if (! Json::isObject($document)) { + throw new InvalidFlagDefinition('', 'document must be an object'); + } + + $members = Json::members($document); + + $definitions = []; + foreach (self::section($members, 'flags', 'flags must be an object') as $key => $definition) { + $key = (string) $key; + self::validate($key, $definition); + $definitions[$key] = FlagDefinition::fromJsonValue($key, self::emptyListsAsObjects(Json::members($definition))); + } + + $evaluators = self::section($members, '$evaluators', '$evaluators must be an object'); + foreach ($evaluators as $name => $rule) { + if (! Json::isObject($rule)) { + throw new InvalidFlagDefinition('$evaluators', sprintf("targeting must be an object (evaluator '%s')", $name)); + } + $reason = self::walkReason($rule); + if ($reason !== null) { + throw new InvalidFlagDefinition('$evaluators', sprintf("%s (evaluator '%s')", $reason, $name)); + } + } + + $metadata = self::section($members, 'metadata', 'metadata must be an object'); + $reason = self::metadataReason($metadata); + if ($reason !== null) { + throw new InvalidFlagDefinition('metadata', $reason); + } + $reason = self::walkReason($metadata); + if ($reason !== null) { + throw new InvalidFlagDefinition('metadata', $reason); + } + + return new FlagDocument($definitions, $evaluators, $metadata); + } + + /** + * @throws InvalidFlagDefinition + */ + public static function validate(string $key, mixed $definition): void + { + if (preg_match(self::KEY_PATTERN, $key) !== 1) { + throw new InvalidFlagDefinition($key, 'invalid flag key'); + } + + if (! Json::isObject($definition)) { + throw new InvalidFlagDefinition($key, 'flag definition must be an object'); + } + + $members = Json::members($definition); + + if (! in_array($members['state'] ?? null, ['ENABLED', 'DISABLED'], true)) { + throw new InvalidFlagDefinition($key, 'state must be ENABLED or DISABLED'); + } + + $variants = Json::members($members['variants'] ?? null); + if ($variants === []) { + throw new InvalidFlagDefinition($key, 'variants must be a non-empty object'); + } + + $types = array_values(array_unique(array_map(self::jsonType(...), array_values($variants)))); + if (! in_array($types, [['boolean'], ['string'], ['number'], ['object']], true)) { + throw new InvalidFlagDefinition($key, 'variants must share one type'); + } + + $default = $members['defaultVariant'] ?? null; + if ($default !== null && (! is_string($default) || ! array_key_exists($default, $variants))) { + throw new InvalidFlagDefinition($key, is_bool($default) + ? 'defaultVariant is not a variant (a boolean names no variant: write the name as text)' + : 'defaultVariant is not a variant'); + } + + $targeting = $members['targeting'] ?? null; + if ($targeting !== null && ! Json::isObject($targeting) && $targeting !== []) { + throw new InvalidFlagDefinition($key, 'targeting must be an object'); + } + + $metadata = $members['metadata'] ?? null; + if ($metadata !== null) { + self::validateMetadata($key, $metadata); + } + + $reason = self::walkReason($definition); + if ($reason !== null) { + throw new InvalidFlagDefinition($key, $reason); + } + } + + /** + * Keys of the flags whose `expires` is before $today (YYYY-MM-DD, UTC), sorted. + * + * @param array $flags + * @return list + */ + public static function expiredKeys(array $flags, string $today): array + { + $expired = []; + foreach ($flags as $flag) { + if ($flag->isExpired($today)) { + $expired[] = $flag->key; + } + } + sort($expired, SORT_STRING); + + return $expired; + } + + /** + * A validated flag's members with a `[]` targeting or metadata replaced by `{}`: the same JSON value, so the + * definition still encodes as written, and a reader of the raw definition meets the contract's "no targeting". + * + * @param array $members + * @return array + */ + private static function emptyListsAsObjects(array $members): array + { + foreach (['targeting', 'metadata'] as $position) { + if (array_key_exists($position, $members) && $members[$position] === []) { + $members[$position] = new stdClass; + } + } + + return $members; + } + + /** + * The members of a document section: none when absent, null or `[]`. + * + * @param array $document + * @return array + * + * @throws InvalidFlagDefinition when the section is present and not an object + */ + private static function section(array $document, string $name, string $reason): array + { + $section = $document[$name] ?? null; + if ($section !== null && ! Json::isObject($section) && $section !== []) { + throw new InvalidFlagDefinition($name, $reason); + } + + return Json::members($section); + } + + /** + * A flag's `metadata`: a map of scalars with non-empty names, and the four reserved keys well-formed. + * + * @throws InvalidFlagDefinition + */ + private static function validateMetadata(string $key, mixed $metadata): void + { + $reason = Json::isObject($metadata) || $metadata === [] ? self::metadataReason(Json::members($metadata)) : 'metadata values must be scalars'; + if ($reason !== null) { + throw new InvalidFlagDefinition($key, $reason); + } + + $reserved = Json::members($metadata); + if (array_key_exists('kind', $reserved) && ! in_array($reserved['kind'], self::KINDS, true)) { + throw new InvalidFlagDefinition($key, 'kind must be one of release, experiment, ops, permission'); + } + if (array_key_exists('expires', $reserved) && ! self::isDate($reserved['expires'])) { + throw new InvalidFlagDefinition($key, 'expires must be a YYYY-MM-DD date'); + } + if (array_key_exists('owner', $reserved) && ! is_string($reserved['owner'])) { + throw new InvalidFlagDefinition($key, 'owner must be a string'); + } + if (array_key_exists('description', $reserved) && ! is_string($reserved['description'])) { + throw new InvalidFlagDefinition($key, 'description must be a string'); + } + } + + /** + * Why a metadata map (a flag's or the document's) breaks the contract, or null: every value a string, number or + * boolean, then no empty name. + * + * @param array $metadata + */ + private static function metadataReason(array $metadata): ?string + { + foreach ($metadata as $value) { + if (! is_bool($value) && ! is_int($value) && ! is_float($value) && ! is_string($value)) { + return 'metadata values must be scalars'; + } + } + + return array_key_exists('', $metadata) ? 'metadata keys must not be empty' : null; + } + + /** + * Why $value (a flag definition, an evaluator rule or the document metadata: level 1) breaks the two rules that + * reach its every corner, or null. `definition nests too deeply` when an object or array sits deeper than + * MAX_DEPTH (reported first, whatever else the walk met); otherwise `numbers must be finite` when INF or NAN + * stands anywhere — JSON has neither (YAML's `.inf`/`.nan` and PHP produce them) and encoding one throws, so the + * definition could be neither composed, stored nor served. Walks with a stack of its own and never descends past + * MAX_DEPTH: no recursion, and bounded work whatever the input's depth. + */ + private static function walkReason(mixed $value): ?string + { + $nonFinite = false; + /** @var list $pending */ + $pending = [[$value, 1]]; + while ($pending !== []) { + [$item, $level] = array_pop($pending); + if ($item instanceof stdClass) { + $item = get_object_vars($item); + } + if (is_array($item)) { + if ($level > self::MAX_DEPTH) { + return 'definition nests too deeply'; + } + foreach ($item as $member) { + $pending[] = [$member, $level + 1]; + } + } elseif (is_float($item) && ! is_finite($item)) { + $nonFinite = true; + } + } + + return $nonFinite ? 'numbers must be finite' : null; + } + + /** The JSON type a variant value has; null and anything JSON cannot hold share no type with a variant. */ + private static function jsonType(mixed $value): string + { + return match (true) { + is_bool($value) => 'boolean', + is_int($value), is_float($value) => 'number', + is_string($value) => 'string', + is_array($value), $value instanceof stdClass => 'object', + default => get_debug_type($value), + }; + } + + private static function isDate(mixed $value): bool + { + return is_string($value) + && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/D', $value, $parts) === 1 + && checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]); + } +} diff --git a/packages/feature-flags/src/Definition/FlagDocument.php b/packages/feature-flags/src/Definition/FlagDocument.php new file mode 100644 index 00000000..af35a1ea --- /dev/null +++ b/packages/feature-flags/src/Definition/FlagDocument.php @@ -0,0 +1,113 @@ + $flags keyed by flag key (PHP stores "2024" as int 2024: read FlagDefinition::$key) + * @param array $evaluators + * @param array $metadata as decoded: a non-scalar value is kept so validation can reject it + */ + public function __construct( + public array $flags = [], + public array $evaluators = [], + public array $metadata = [], + ) {} + + public static function fromJsonValue(mixed $document): self + { + $members = Json::members($document); + + $flags = []; + foreach (Json::members($members['flags'] ?? null) as $key => $definition) { + $flags[$key] = FlagDefinition::fromJsonValue((string) $key, $definition); + } + + return new self($flags, Json::members($members['$evaluators'] ?? null), Json::members($members['metadata'] ?? null)); + } + + /** @throws \JsonException */ + public static function fromJson(string $json): self + { + return self::fromJsonValue(Json::decode($json)); + } + + public function flag(string $key): ?FlagDefinition + { + return $this->flags[$key] ?? null; + } + + /** + * @return list + */ + public function keys(): array + { + return array_map(static fn (FlagDefinition $flag): string => $flag->key, array_values($this->flags)); + } + + /** `{"flags":{…},"$evaluators":{…},"metadata":{…}}`, always all three, each an object even when empty. */ + public function toJsonValue(): stdClass + { + $flags = []; + foreach ($this->flags as $flag) { + $flags[$flag->key] = $flag->toJsonValue(); + } + + return (object) [ + 'flags' => Json::object($flags), + '$evaluators' => Json::object($this->evaluators), + 'metadata' => Json::object($this->metadata), + ]; + } + + /** @throws \JsonException as Json::encode() (INF or NAN in a definition) */ + public function toJson(): string + { + return Json::encode($this->toJsonValue()); + } + + /** + * sha256 of the canonical form: independent of member order, sensitive to every value. + * + * @throws \JsonException as Json::encode() + */ + public function fingerprint(): string + { + return hash('sha256', Json::canonical($this->toJsonValue())); + } + + /** + * Flag key => sha256 of that flag's canonical definition, sorted by key as text. A numeric-looking key is an + * int key (PHP arrays cannot hold it as a string): read it with `(string) $key`. + * + * @return array + * + * @throws \JsonException as Json::encode() + */ + public function keyFingerprints(): array + { + $fingerprints = []; + foreach ($this->flags as $flag) { + $fingerprints[$flag->key] = hash('sha256', Json::canonical($flag->toJsonValue())); + } + ksort($fingerprints, SORT_STRING); + + return $fingerprints; + } +} diff --git a/packages/feature-flags/src/Definition/InvalidFlagDefinition.php b/packages/feature-flags/src/Definition/InvalidFlagDefinition.php new file mode 100644 index 00000000..547cd885 --- /dev/null +++ b/packages/feature-flags/src/Definition/InvalidFlagDefinition.php @@ -0,0 +1,48 @@ +` for the document as a + * whole. + */ +final class InvalidFlagDefinition extends ConfigurationException +{ + public function __construct( + private readonly string $flagKey, + private readonly string $reason, + private readonly ?string $source = null, + ) { + parent::__construct( + sprintf('Invalid feature flag [%s]%s: %s.', $flagKey, $source === null ? '' : " from source [{$source}]", $reason), + 'INVALID_FEATURE_FLAG', + ); + } + + public function flagKey(): string + { + return $this->flagKey; + } + + public function reason(): string + { + return $this->reason; + } + + public function source(): ?string + { + return $this->source; + } + + public function fromSource(string $source): self + { + return new self($this->flagKey, $this->reason, $source); + } +} diff --git a/packages/feature-flags/src/Definition/Json.php b/packages/feature-flags/src/Definition/Json.php new file mode 100644 index 00000000..ee54a592 --- /dev/null +++ b/packages/feature-flags/src/Definition/Json.php @@ -0,0 +1,167 @@ +getCode() !== JSON_ERROR_INVALID_PROPERTY_NAME) { + throw $failure; + } + } + + // Native validation keeps syntax, depth and number semantics. PHP objects alone cannot hold leading + // NUL names. Prefix every member token (not values), decode natively, then remove exactly one prefix. + json_decode($json, true, 512, JSON_THROW_ON_ERROR); + $prefixed = preg_replace_callback('/"[^"\\\\]*+(?:\\\\.[^"\\\\]*+)*+"(\s*:)?/s', + static fn (array $token): string => isset($token[1]) ? '"_'.substr($token[0], 1) : $token[0], $json); + if ($prefixed === null) { + throw new JsonException('Cannot tokenize validated JSON: '.preg_last_error_msg()); + } + + return self::normalizeValue(json_decode($prefixed, false, 512, JSON_THROW_ON_ERROR), true); + } + + /** + * A stdClass tree (json_decode() without assoc, Yaml::PARSE_OBJECT_FOR_MAP) in the faithful form above. + */ + public static function normalize(mixed $value): mixed + { + return self::normalizeValue($value, false); + } + + private static function normalizeValue(mixed $value, bool $prefixed): mixed + { + if ($value instanceof stdClass) { + $members = []; + foreach (get_object_vars($value) as $key => $member) { + $members[$prefixed ? substr((string) $key, 1) : $key] = self::normalizeValue($member, $prefixed); + } + + return $members === [] || array_is_list($members) ? (object) $members : $members; + } + + if (is_array($value)) { + return array_map(static fn (mixed $member): mixed => self::normalizeValue($member, $prefixed), $value); + } + + return $value; + } + + /** @throws JsonException on INF or NAN, malformed UTF-8, or nesting deeper than 512 levels */ + public static function encode(mixed $value): string + { + return json_encode($value, self::ENCODE_FLAGS | JSON_THROW_ON_ERROR); + } + + /** + * The encoding with object members sorted by name, recursively; `[]` and `{}` stay distinct. + * + * @throws JsonException as encode() + */ + public static function canonical(mixed $value): string + { + return self::encode(self::sorted($value)); + } + + /** + * A stdClass, or a non-empty array that is not a list. + */ + public static function isObject(mixed $value): bool + { + return $value instanceof stdClass || (is_array($value) && $value !== [] && ! array_is_list($value)); + } + + /** + * An array that is a list, so `[]` is a list. + * + * @phpstan-assert-if-true list $value + */ + public static function isList(mixed $value): bool + { + return is_array($value) && array_is_list($value); + } + + /** + * An object's members; anything else (a list, `[]`, a scalar, null) has none. + * + * @return array + */ + public static function members(mixed $value): array + { + if ($value instanceof stdClass) { + return get_object_vars($value); + } + + return is_array($value) && ! array_is_list($value) ? $value : []; + } + + /** + * Deep stdClass → array, for the OpenFeature SDK and PHP callers (which cannot tell `{}` from `[]`). + */ + public static function toPhp(mixed $value): mixed + { + if ($value instanceof stdClass) { + $value = get_object_vars($value); + } + + return is_array($value) ? array_map(self::toPhp(...), $value) : $value; + } + + /** + * A value for an OBJECT position: `[]` or a list-like array becomes a stdClass so it encodes as `{…}`. + * + * @param array|stdClass $members + * @return array|stdClass + */ + public static function object(array|stdClass $members): array|stdClass + { + if ($members instanceof stdClass) { + return $members; + } + + return $members === [] || array_is_list($members) ? (object) $members : $members; + } + + private static function sorted(mixed $value): mixed + { + if (self::isObject($value)) { + $members = []; + foreach (self::members($value) as $key => $member) { + $members[(string) $key] = self::sorted($member); + } + ksort($members, SORT_STRING); + + return self::object($members); + } + + return is_array($value) ? array_map(self::sorted(...), $value) : $value; + } +} diff --git a/packages/feature-flags/src/Evaluation/DefaultFlagdEvaluator.php b/packages/feature-flags/src/Evaluation/DefaultFlagdEvaluator.php new file mode 100644 index 00000000..d21fe5b8 --- /dev/null +++ b/packages/feature-flags/src/Evaluation/DefaultFlagdEvaluator.php @@ -0,0 +1,354 @@ +> + */ + private readonly WeakMap $expansions; + + /** + * @param (Closure(): int)|null $clock Unix seconds for `$flagd.timestamp` + */ + public function __construct(private readonly ?Closure $clock = null) + { + if (PHP_INT_SIZE < 8) { + throw new LogicException('Feature flag bucketing needs 64-bit integers: (hash * totalWeight) >> 32 overflows a 32-bit PHP build.'); + } + + $this->logic = FlagdOperators::jsonLogic(); + $this->expansions = new WeakMap; + } + + public function evaluate(FlagDocument $document, string $flagKey, FlagType $type, mixed $default, ?string $targetingKey = null, array $attributes = []): Resolution + { + try { + return $this->resolve($document, $flagKey, $type, $default, $targetingKey, $attributes); + } catch (Throwable $failure) { + return Resolution::error($default, EvaluationError::General, "Evaluating flag [{$flagKey}] failed: {$failure->getMessage()}"); + } + } + + /** + * @param array $attributes + */ + private function resolve(FlagDocument $document, string $flagKey, FlagType $type, mixed $default, ?string $targetingKey, array $attributes): Resolution + { + $flag = $document->flag($flagKey); + if ($flag === null) { + return Resolution::error($default, EvaluationError::FlagNotFound, "Flag [{$flagKey}] is not defined."); + } + + $metadata = array_replace(self::scalars($document->metadata), $flag->metadata()); + + if ($flag->isDisabled()) { + return self::typed(new Resolution($default, null, EvaluationReason::Disabled, metadata: $metadata), $type, $default); + } + + $expansion = $this->expansion($document, $flagKey, $flag); + if ($expansion === null) { + return Resolution::error($default, EvaluationError::ParseError, sprintf( + 'The targeting of flag [%s] expands past %d JSON values or %d levels.', + $flagKey, + RefResolver::MAX_VALUES, + RefResolver::MAX_DEPTH, + )); + } + $targeting = $expansion[0]; + + if (! self::pythonTruthy($targeting)) { + return self::typed(self::fallThrough($flag, $default, $metadata, EvaluationReason::Static), $type, $default); + } + + if (! Json::isObject($targeting)) { + return Resolution::error($default, EvaluationError::ParseError, "The targeting of flag [{$flagKey}] is not an object."); + } + + try { + $result = $this->logic->apply($targeting, $this->data($flagKey, $targetingKey, $attributes)); + } catch (UnknownOperator $unknown) { + return Resolution::error($default, EvaluationError::ParseError, "Invalid targeting for flag [{$flagKey}]: {$unknown->getMessage()}"); + } catch (JsonLogicError $failure) { + return Resolution::error($default, EvaluationError::General, "Targeting for flag [{$flagKey}] failed: {$failure->getMessage()}"); + } + + if ($result === null) { + return self::typed(self::fallThrough($flag, $default, $metadata, EvaluationReason::Default), $type, $default); + } + + $variant = match (true) { + is_bool($result) => $result ? 'true' : 'false', + default => PythonText::str($result), + }; + if ($variant === null || ! $flag->hasVariant($variant) || $variant === '' || $flag->variantValue($variant) === null) { + return Resolution::error($default, EvaluationError::General, sprintf( + 'Targeting for flag [%s] resolved [%s], which is not one of its variants.', + $flagKey, + $variant ?? get_debug_type($result), + )); + } + + return self::typed(new Resolution(self::copied($flag->variantValue($variant)), $variant, EvaluationReason::TargetingMatch, metadata: $metadata), $type, $default); + } + + /** + * [the flag's targeting with its references expanded] ([null] for no targeting), or null when it expands past + * RefResolver's limits: worked out on the first evaluation of the flag in this document and kept with the + * document. + * + * @return array{0: mixed}|null + */ + private function expansion(FlagDocument $document, string $flagKey, FlagDefinition $flag): ?array + { + $targeting = $flag->targeting(); + if ($targeting === null) { + return [null]; + } + + $expansions = $this->expansions[$document] ?? []; + if (! array_key_exists($flagKey, $expansions)) { + $expansions[$flagKey] = RefResolver::withinLimits($targeting, $document->evaluators) + ? [RefResolver::expand($targeting, $document->evaluators)] + : null; + $this->expansions[$document] = $expansions; + } + + return $expansions[$flagKey]; + } + + /** + * $value with every array and stdClass inside it rebuilt, in the same faithful form (`{}` stays a stdClass): + * the document's own objects never leave the evaluator. + */ + private static function copied(mixed $value): mixed + { + if ($value instanceof stdClass) { + $copy = new stdClass; + foreach (get_object_vars($value) as $member => $item) { + $copy->{$member} = self::copied($item); + } + + return $copy; + } + + if (is_array($value)) { + $copy = []; + foreach ($value as $key => $item) { + $copy[$key] = self::copied($item); + } + + return $copy; + } + + return $value; + } + + /** + * The defaultVariant's value with $reason, or the caller's default with DEFAULT when the flag names none. + * + * @param array $metadata + */ + private static function fallThrough(FlagDefinition $flag, mixed $default, array $metadata, EvaluationReason $reason): Resolution + { + $variant = $flag->defaultVariant(); + if ($variant === null) { + return new Resolution($default, null, EvaluationReason::Default, metadata: $metadata); + } + + if (! $flag->hasVariant($variant)) { + return Resolution::error($default, EvaluationError::General, "Flag [{$flag->key}] names default variant [{$variant}], which is not one of its variants."); + } + + return new Resolution(self::copied($flag->variantValue($variant)), $variant, $reason, metadata: $metadata); + } + + /** + * The type check (FlagType::accepts(): a boolean is never a number), then a float request's number as a float. + * DISABLED and a DEFAULT without a variant carry the caller's default unchecked, as flagd does, and a float + * request gets it as float(default) there — an integer or a boolean, as Python's float() reads them. An error + * passes through unchanged. + */ + private static function typed(Resolution $resolution, FlagType $type, mixed $default): Resolution + { + if ($resolution->error !== null) { + return $resolution; + } + + $checked = $resolution->reason !== EvaluationReason::Disabled + && ($resolution->reason !== EvaluationReason::Default || $resolution->variant !== null); + $value = $resolution->value; + if ($checked && ! $type->accepts($value)) { + return Resolution::error($default, EvaluationError::TypeMismatch, sprintf('Flag resolved a %s value; a %s was requested.', get_debug_type($value), $type->value)); + } + + $numeric = is_int($value) || (! $checked && is_bool($value)); + + return $type === FlagType::Float && $numeric ? $resolution->withValue((float) $value) : $resolution; + } + + /** + * @param array $metadata + * @return array + */ + private static function scalars(array $metadata): array + { + return array_filter($metadata, static fn (mixed $value): bool => is_bool($value) || is_int($value) || is_float($value) || is_string($value)); + } + + /** Python's bool() of a JSON value: null, false, 0, 0.0, "", [] and {} are false. */ + private static function pythonTruthy(mixed $value): bool + { + return match (true) { + $value === null, $value === false, $value === 0, $value === '', $value === [] => false, + is_float($value) => $value !== 0.0, + $value instanceof stdClass => get_object_vars($value) !== [], + default => true, + }; + } + + /** + * @param array $attributes + * @return array + */ + private function data(string $flagKey, ?string $targetingKey, array $attributes): array + { + $budget = RefResolver::MAX_VALUES; + $changed = false; + $data = self::withEpochMillis($attributes, 1, $budget, $changed); + $data = is_array($data) ? $data : $attributes; + $data['$flagd'] = ['flagKey' => $flagKey, 'timestamp' => $this->clock !== null ? ($this->clock)() : time()]; + $data['targetingKey'] = $targetingKey; + + return $data; + } + + /** + * PyFly's _epoch_millis_context walk: every value read spends one unit of the budget (none left: the value + * stays as it is); a date-time becomes its epoch milliseconds; a container past the depth limit is not + * entered; a container is rebuilt only when something inside it changed ($changed), else handed back as is. + * + * @param int $level the nesting level of $node (the attributes are level 1) + */ + private static function withEpochMillis(mixed $node, int $level, int &$budget, bool &$changed): mixed + { + if (--$budget < 0) { + return $node; + } + if ($node instanceof DateTimeInterface) { + $changed = true; + + return self::epochMillis($node); + } + if ($level > RefResolver::MAX_DEPTH || (! is_array($node) && ! $node instanceof stdClass)) { + return $node; + } + + $inside = false; + $members = []; + foreach (is_array($node) ? $node : get_object_vars($node) as $key => $item) { + $members[$key] = self::withEpochMillis($item, $level + 1, $budget, $inside); + } + if (! $inside) { + return $node; + } + + $changed = true; + if (is_array($node)) { + return $members; + } + $copy = new stdClass; + foreach ($members as $member => $item) { + $copy->{$member} = $item; + } + + return $copy; + } + + /** + * The whole microseconds since the epoch over 1000, correctly rounded: the exact decimal text, read once + * (PHP's text-to-float conversion rounds correctly; dividing an integer past 2^53 would round twice). + */ + private static function epochMillis(DateTimeInterface $value): float + { + $seconds = (int) $value->format('U'); + $micros = (int) $value->format('u'); + + if ($seconds >= 0) { + $sign = ''; + $digits = $seconds.str_pad((string) $micros, 6, '0', STR_PAD_LEFT); + } elseif ($micros === 0) { + $sign = '-'; + $digits = substr((string) $seconds, 1).'000000'; + } else { + // seconds * 1e6 + micros is negative: its magnitude is (|seconds| - 1) * 1e6 + (1e6 - micros). + $sign = '-'; + $digits = (-($seconds + 1)).str_pad((string) (1_000_000 - $micros), 6, '0', STR_PAD_LEFT); + } + + return (float) ($sign.substr($digits, 0, -3).'.'.substr($digits, -3)); + } +} diff --git a/packages/feature-flags/src/Evaluation/EvaluationError.php b/packages/feature-flags/src/Evaluation/EvaluationError.php new file mode 100644 index 00000000..2606a485 --- /dev/null +++ b/packages/feature-flags/src/Evaluation/EvaluationError.php @@ -0,0 +1,26 @@ + ErrorCode::FLAG_NOT_FOUND(), + self::ParseError => ErrorCode::PARSE_ERROR(), + self::TypeMismatch => ErrorCode::TYPE_MISMATCH(), + self::General => ErrorCode::GENERAL(), + }; + } +} diff --git a/packages/feature-flags/src/Evaluation/EvaluationReason.php b/packages/feature-flags/src/Evaluation/EvaluationReason.php new file mode 100644 index 00000000..6fe10d6b --- /dev/null +++ b/packages/feature-flags/src/Evaluation/EvaluationReason.php @@ -0,0 +1,15 @@ + self::Boolean, + is_int($default) => self::Integer, + is_float($default) => self::Float, + is_string($default) => self::String, + default => self::Object, + }; + } + + /** Float accepts int and float; Object accepts arrays and stdClass. */ + public function accepts(mixed $value): bool + { + return match ($this) { + self::Boolean => is_bool($value), + self::String => is_string($value), + self::Integer => is_int($value), + self::Float => is_int($value) || is_float($value), + self::Object => is_array($value) || $value instanceof stdClass, + }; + } + + /** + * @return bool|string|int|float|array + */ + public function zero(): bool|string|int|float|array + { + return match ($this) { + self::Boolean => false, + self::String => '', + self::Integer => 0, + self::Float => 0.0, + self::Object => [], + }; + } +} diff --git a/packages/feature-flags/src/Evaluation/FlagdEvaluator.php b/packages/feature-flags/src/Evaluation/FlagdEvaluator.php new file mode 100644 index 00000000..eeae0042 --- /dev/null +++ b/packages/feature-flags/src/Evaluation/FlagdEvaluator.php @@ -0,0 +1,26 @@ + $attributes the evaluation-context attributes (targetingKey excluded) + */ + public function evaluate( + FlagDocument $document, + string $flagKey, + FlagType $type, + mixed $default, + ?string $targetingKey = null, + array $attributes = [], + ): Resolution; +} diff --git a/packages/feature-flags/src/Evaluation/FlagdEvaluatorConfiguration.php b/packages/feature-flags/src/Evaluation/FlagdEvaluatorConfiguration.php new file mode 100644 index 00000000..f60b531e --- /dev/null +++ b/packages/feature-flags/src/Evaluation/FlagdEvaluatorConfiguration.php @@ -0,0 +1,28 @@ + Fractional::evaluate(...), + 'sem_ver' => SemVer::evaluate(...), + 'starts_with' => StringOps::startsWith(...), + 'ends_with' => StringOps::endsWith(...), + ]); + } +} diff --git a/packages/feature-flags/src/Evaluation/Fractional.php b/packages/feature-flags/src/Evaluation/Fractional.php new file mode 100644 index 00000000..c36ffa9f --- /dev/null +++ b/packages/feature-flags/src/Evaluation/Fractional.php @@ -0,0 +1,173 @@ +> 32`, which stays an int on a 64-bit build because hash < 2^32 and + * totalWeight <= 2^31 - 1, so the product is below 2^63. A bucket is `[variant]` (weight 1) or + * `[variant, weight]`; the weight must be an integer (not a bool, not a float) and a negative one counts as 0. + * A malformed bucket, or weights summing past 2^31 - 1, make the operator null. + * + * Where the reference raises rather than answers, this throws a JsonLogicError (GENERAL): reading the bucket key + * from data that is not an object (`some`, `map`, … hand each item to the operator as its data) or from a + * `$flagd` that is not one, and joining a flag key and a targeting key that are not both text — except two + * numbers Python adds to zero, which it reads as no key (null). A PHP `[]` is an empty object here, as + * everywhere an object is expected. One limit is PHP's: a weight written as an integer beyond 64 bits decodes + * to a float and is refused, where Python clamps a huge negative one to 0. + */ +final class Fractional +{ + public const int MAX_WEIGHT_SUM = 2147483647; + + /** + * @param list $args + * + * @throws JsonLogicError where the reference raises + */ + public static function evaluate(mixed $data, array $args): mixed + { + if ($args === []) { + return null; + } + + if (is_string($args[0])) { + $bucketBy = $args[0]; + $args = array_slice($args, 1); + } else { + $bucketBy = self::contextBucketKey($data); + } + + if ($bucketBy === null || $bucketBy === '') { + return null; + } + + $fractions = []; + $total = 0; + foreach ($args as $arg) { + if (! Json::isList($arg) || $arg === [] || count($arg) > 2) { + return null; + } + $weight = 1; + if (count($arg) === 2) { + if (! is_int($arg[1])) { + return null; + } + $weight = max(0, $arg[1]); + } + // Python sums first and refuses the total after; both give null, and stopping here keeps $total an int. + if ($weight > self::MAX_WEIGHT_SUM - $total) { + return null; + } + $fractions[] = [$arg[0], $weight]; + $total += $weight; + } + + $bucket = (self::hash($bucketBy) * $total) >> 32; + $end = 0; + foreach ($fractions as [$variant, $weight]) { + $end += $weight; + if ($bucket < $end) { + return $variant; + } + } + + return null; + } + + public static function hash(string $bucketBy): int + { + /** @var array{1: int} $unpacked */ + $unpacked = unpack('N', hash('murmur3a', $bucketBy, true)); + + return $unpacked[1]; + } + + /** + * The reference's `data.get("$flagd", {}).get("flagKey", "") + data.get("targetingKey")`, after its + * `if not targeting_key: return None`. + * + * @throws JsonLogicError + */ + private static function contextBucketKey(mixed $data): ?string + { + if (! self::isObject($data)) { + throw new JsonLogicError(sprintf('fractional reads its bucket key from the data, which is %s, not an object.', get_debug_type($data))); + } + + $context = Json::members($data); + $flagd = array_key_exists('$flagd', $context) ? $context['$flagd'] : []; + if (! self::isObject($flagd)) { + throw new JsonLogicError(sprintf('fractional reads $flagd.flagKey from an object, not %s.', get_debug_type($flagd))); + } + + $flagdMembers = Json::members($flagd); + $seed = array_key_exists('flagKey', $flagdMembers) ? $flagdMembers['flagKey'] : ''; + $targetingKey = $context['targetingKey'] ?? null; + + if (! self::pythonTruthy($targetingKey)) { + return null; + } + + if (is_string($seed) && is_string($targetingKey)) { + return $seed.$targetingKey; + } + + // Python adds two numbers instead of joining them; a zero sum is falsy (null), any other fails in mmh3. + $left = self::pythonNumber($seed); + $right = self::pythonNumber($targetingKey); + if ($left !== null && $right !== null) { + $sum = $left + $right; + if ($sum === 0 || $sum === 0.0) { + return null; + } + } + + throw new JsonLogicError(sprintf( + 'fractional cannot join a flag key (%s) and a targeting key (%s) into a bucket key.', + get_debug_type($seed), + get_debug_type($targetingKey), + )); + } + + /** A stdClass, a non-list array, or `[]` (an empty object, as everywhere an object is expected). */ + private static function isObject(mixed $value): bool + { + return $value instanceof stdClass || (is_array($value) && ($value === [] || ! array_is_list($value))); + } + + /** Python's `bool()`: an empty container is false, NaN is true. */ + private static function pythonTruthy(mixed $value): bool + { + return match (true) { + $value === null => false, + is_bool($value) => $value, + is_int($value) => $value !== 0, + is_float($value) => $value !== 0.0, + is_string($value) => $value !== '', + is_array($value) => $value !== [], + $value instanceof stdClass => get_object_vars($value) !== [], + default => true, + }; + } + + /** The number Python sees (a bool is an int there), or null for anything else. */ + private static function pythonNumber(mixed $value): int|float|null + { + return match (true) { + is_bool($value) => (int) $value, + is_int($value), is_float($value) => $value, + default => null, + }; + } +} diff --git a/packages/feature-flags/src/Evaluation/JsonLogic.php b/packages/feature-flags/src/Evaluation/JsonLogic.php new file mode 100644 index 00000000..d12bd3e5 --- /dev/null +++ b/packages/feature-flags/src/Evaluation/JsonLogic.php @@ -0,0 +1,1092 @@ +', '<=', '>=', '!', '!!', '+', '*', '-', '/', '%', + 'in', 'min', 'max', 'cat', 'log', 'var', 'substr', 'merge', 'missing', 'missing_some', + ]; + + /** The whitespace float() strips: ASCII spaces, and every non-ASCII str.isspace() character. */ + private const array FLOAT_SPACE = [ + "\t", "\n", "\x0B", "\x0C", "\r", ' ', "\u{85}", "\u{A0}", "\u{1680}", "\u{2000}", "\u{2001}", "\u{2002}", "\u{2003}", + "\u{2004}", "\u{2005}", "\u{2006}", "\u{2007}", "\u{2008}", "\u{2009}", "\u{200A}", "\u{2028}", "\u{2029}", "\u{202F}", + "\u{205F}", "\u{3000}", + ]; + + /** Python's str.strip() whitespace (every str.isspace() character), which to_number() strips before its "inf" check. */ + private const array STRIP_SPACE = [...self::FLOAT_SPACE, "\x1C", "\x1D", "\x1E", "\x1F"]; + + /** + * float()'s decimal grammar on lower-cased ASCII text without its underscores. Only single-character repeats: + * a repeated group would recurse once per repetition and fail on a long run of digits. + */ + private const string FLOAT_SYNTAX = '/^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:e[+-]?[0-9]+)?$/D'; + + /** @var array the ASCII digit of each Unicode decimal digit met so far (a pure function of it) */ + private static array $digits = []; + + /** + * @param array): mixed> $operations flagd's operators, keyed by name; a + * name shared with a builtin replaces it + */ + public function __construct(private readonly array $operations = []) {} + + /** + * @throws UnknownOperator + * @throws JsonLogicError + */ + public function apply(mixed $logic, mixed $data = null): mixed + { + return $this->evaluate($logic, $data, 0); + } + + public static function truthy(mixed $value): bool + { + return match (true) { + $value === null => false, + is_bool($value) => $value, + is_float($value) => ! is_nan($value) && $value !== 0.0, + is_int($value) => $value !== 0, + is_string($value) => $value !== '', + Json::isList($value) => $value !== [], + default => true, + }; + } + + /** + * @throws JsonLogicError on data nested deeper than MAX_DATA_DEPTH + */ + public static function toNumber(mixed $value): int|float + { + $number = self::numeric($value); + + return is_bool($number) ? (int) $number : $number; + } + + /** + * @throws JsonLogicError on data nested deeper than MAX_DATA_DEPTH + */ + public static function toStr(mixed $value): string + { + return self::text($value, 0); + } + + /** + * @throws JsonLogicError + */ + private static function text(mixed $value, int $depth): string + { + if (is_string($value)) { + return $value; + } + if (is_bool($value)) { + return $value ? 'True' : 'False'; + } + if (is_float($value)) { + return self::formatG15($value); + } + if (is_int($value)) { + return (string) $value; + } + if ($value === null) { + return 'null'; + } + if (Json::isList($value)) { + self::nested($depth); + $parts = []; + foreach ($value as $item) { + $parts[] = self::text($item, $depth + 1); + } + + return implode(',', $parts); + } + + return '[object Object]'; + } + + /** + * @throws JsonLogicError on data nested deeper than MAX_DATA_DEPTH + */ + public static function looseEquals(mixed $a, mixed $b): bool + { + $kindA = self::kind($a); + $kindB = self::kind($b); + + if ($kindA === $kindB) { + // Two lists or two objects: the reference asks `a is b`. A stdClass has an identity to compare; an + // array is a value and has none, so two arrays are never the same one. + return $kindA === 'list' || $kindA === 'object' ? is_object($a) && $a === $b : $a === $b; + } + + if (self::isNumeric($a) || self::isNumeric($b)) { + return self::compareNumbers(self::toNumber($a), self::toNumber($b)) === 0; + } + + if ($a === null || $b === null) { + return false; + } + + if (is_string($a)) { + return $a === self::toStr($b); + } + + if (is_string($b)) { + return self::toStr($a) === $b; + } + + return false; + } + + /** + * Python's `==`. A container is compared with itself by identity first, as Python's own containers do, so an + * object (PHP's only containers with an identity) is equal to itself however it nests — even when it contains + * itself. + * + * @throws JsonLogicError on data nested deeper than MAX_DATA_DEPTH + */ + public static function strictEquals(mixed $a, mixed $b): bool + { + return self::equal($a, $b, 0); + } + + /** + * @throws JsonLogicError + */ + private static function equal(mixed $a, mixed $b, int $depth): bool + { + if (is_object($a) && $a === $b) { + return true; + } + + if (self::isNumeric($a) && self::isNumeric($b)) { + return self::compareNumbers(self::toNumber($a), self::toNumber($b)) === 0; + } + + if (Json::isList($a) && Json::isList($b)) { + if (count($a) !== count($b)) { + return false; + } + self::nested($depth); + foreach ($a as $index => $item) { + if (! self::equal($item, $b[$index], $depth + 1)) { + return false; + } + } + + return true; + } + + if (Json::isObject($a) && Json::isObject($b)) { + $left = Json::members($a); + $right = Json::members($b); + if (count($left) !== count($right)) { + return false; + } + self::nested($depth); + foreach ($left as $key => $item) { + if (! array_key_exists($key, $right) || ! self::equal($item, $right[$key], $depth + 1)) { + return false; + } + } + + return true; + } + + return $a === $b; + } + + /** + * @throws UnknownOperator + * @throws JsonLogicError + */ + private function evaluate(mixed $logic, mixed $data, int $depth): mixed + { + if ($depth >= self::MAX_DEPTH) { + throw new JsonLogicError('JSON Logic nests deeper than '.self::MAX_DEPTH.' levels.'); + } + $depth++; + + if (Json::isList($logic)) { + $items = []; + foreach ($logic as $item) { + $items[] = $this->evaluate($item, $data, $depth); + } + + return $items; + } + + $operation = self::operation($logic); + if ($operation === null) { + return $logic; + } + + [$op, $args] = $operation; + + switch ($op) { + case 'if': + case '?:': + return $this->conditional($args, $data, $depth); + case 'and': + $current = null; + foreach ($args as $arg) { + $current = $this->evaluate($arg, $data, $depth); + if (! self::truthy($current)) { + return $current; + } + } + + return $current; + case 'or': + $current = null; + foreach ($args as $arg) { + $current = $this->evaluate($arg, $data, $depth); + if (self::truthy($current)) { + return $current; + } + } + + return $current; + case 'filter': + if (count($args) < 2) { + return []; + } + $items = $this->evaluate($args[0], $data, $depth); + if (! Json::isList($items)) { + return []; + } + $kept = []; + foreach ($items as $item) { + if (self::truthy($this->evaluate($args[1], $item, $depth))) { + $kept[] = $item; + } + } + + return $kept; + case 'map': + if ($args === []) { + return []; + } + $items = $this->evaluate($args[0], $data, $depth); + if (! Json::isList($items)) { + return []; + } + $mapped = []; + foreach ($items as $item) { + $mapped[] = $this->evaluate($args[1] ?? null, $item, $depth); + } + + return $mapped; + case 'reduce': + if ($args === []) { + return null; + } + $items = $this->evaluate($args[0], $data, $depth); + // The initial accumulator is the third argument as written: the reference does not evaluate it. + $accumulator = $args[2] ?? null; + if (! Json::isList($items)) { + return $accumulator; + } + foreach ($items as $item) { + $accumulator = $this->evaluate($args[1] ?? null, ['accumulator' => $accumulator, 'current' => $item], $depth); + } + + return $accumulator; + case 'all': + if (count($args) < 2) { + return false; + } + $items = $this->evaluate($args[0], $data, $depth); + if (! Json::isList($items) || $items === []) { + return false; + } + foreach ($items as $item) { + if (! self::truthy($this->evaluate($args[1], $item, $depth))) { + return false; + } + } + + return true; + case 'some': + case 'none': + if (count($args) < 2) { + return $op === 'none'; + } + $items = $this->evaluate($args[0], $data, $depth); + if (! Json::isList($items)) { + return $op === 'none'; + } + foreach ($items as $item) { + if (self::truthy($this->evaluate($args[1], $item, $depth))) { + return $op === 'some'; + } + } + + return $op === 'none'; + } + + $values = []; + foreach ($args as $arg) { + $values[] = $this->evaluate($arg, $data, $depth); + } + + if (isset($this->operations[$op])) { + return $this->registered($op, $data, $values); + } + + $a = $values[0] ?? null; + $b = $values[1] ?? null; + $c = $values[2] ?? null; + + return match ($op) { + '==' => self::looseEquals($a, $b), + '!=' => ! self::looseEquals($a, $b), + '===' => self::strictEquals($a, $b), + '!==' => ! self::strictEquals($a, $b), + '<' => self::chain($a, $b, $c, -1, -1), + '<=' => self::chain($a, $b, $c, -1, 0), + '>' => self::chain($a, $b, $c, 1, 1), + '>=' => self::chain($a, $b, $c, 1, 0), + '!' => ! self::truthy($a), + '!!' => self::truthy($a), + '+' => self::sum($values), + '*' => self::product($values), + '-' => $b === null ? -self::toNumber($a) : self::toNumber($a) - self::toNumber($b), + '/' => self::divide($a, $b), + '%' => self::modulo($a, $b), + 'min' => self::extreme($values, -1), + 'max' => self::extreme($values, 1), + 'in' => self::in($a, $b), + 'cat' => self::concatenate($values), + 'substr' => self::substr($a, $b, $c), + 'merge' => self::merge($values), + 'var' => count($values) > 2 + ? throw new JsonLogicError('var takes a key and a default, not '.count($values).' arguments.') + : self::variable($data, $a, $b), + 'missing' => self::missing($data, $values), + 'missing_some' => self::missingSome($data, $values[0] ?? 0, $b), + 'log' => $a, + default => $this->unrecognized($op), + }; + } + + /** + * @param list $values + * + * @throws JsonLogicError + */ + private function registered(string $op, mixed $data, array $values): mixed + { + try { + return ($this->operations[$op])($data, $values); + } catch (JsonLogicError $failure) { + throw $failure; + } catch (Throwable $failure) { + throw new JsonLogicError("Operation [{$op}] failed: {$failure->getMessage()}", 0, $failure); + } + } + + /** + * The reference walks a dotted name through its operation table: a first segment it does not hold is an + * unrecognized operation; one it holds is a function, and indexing a function is a TypeError (GENERAL). + * + * @throws UnknownOperator + * @throws JsonLogicError + */ + private function unrecognized(string $op): never + { + $head = strstr($op, '.', true); + if ($head !== false && (isset($this->operations[$head]) || in_array($head, self::BUILTINS, true))) { + throw new JsonLogicError("Operation [{$head}] has no member operations, so [{$op}] cannot run."); + } + + throw new UnknownOperator($op); + } + + /** + * @param list $args + * + * @throws UnknownOperator + * @throws JsonLogicError + */ + private function conditional(array $args, mixed $data, int $depth): mixed + { + $count = count($args); + $index = 0; + + while ($index < $count - 1) { + if (self::truthy($this->evaluate($args[$index], $data, $depth))) { + return $this->evaluate($args[$index + 1], $data, $depth); + } + $index += 2; + } + + return $index < $count ? $this->evaluate($args[$index], $data, $depth) : null; + } + + /** + * A single-member object is an operation; everything else is data and evaluates to itself. + * + * @return array{0: string, 1: list}|null + */ + private static function operation(mixed $logic): ?array + { + if (! Json::isObject($logic)) { + return null; + } + + $members = Json::members($logic); + if (count($members) !== 1) { + return null; + } + + $name = array_key_first($members); + $args = $members[$name]; + + return [(string) $name, Json::isList($args) ? $args : [$args]]; + } + + /** + * Refuses to go below a container at $depth when that would pass MAX_DATA_DEPTH. + * + * @throws JsonLogicError + */ + private static function nested(int $depth): void + { + if ($depth >= self::MAX_DATA_DEPTH) { + throw new JsonLogicError('Data nests deeper than '.self::MAX_DATA_DEPTH.' levels (or contains itself).'); + } + } + + private static function kind(mixed $value): string + { + return match (true) { + $value === null => 'null', + is_bool($value) => 'bool', + is_int($value) => 'int', + is_float($value) => 'float', + is_string($value) => 'string', + Json::isList($value) => 'list', + default => 'object', + }; + } + + /** + * Python's numbers: int, float and bool (a bool IS an int there). + * + * @phpstan-assert-if-true bool|int|float $value + */ + private static function isNumeric(mixed $value): bool + { + return is_int($value) || is_float($value) || is_bool($value); + } + + /** + * The reference's to_number(), which hands a boolean back unchanged (it is already a Python number). + * + * @throws JsonLogicError on one-item lists nested deeper than MAX_DATA_DEPTH + */ + private static function numeric(mixed $value, int $depth = 0): int|float|bool + { + if (self::isNumeric($value)) { + return $value; + } + if ($value === null) { + return 0; + } + if (Json::isList($value)) { + if (count($value) === 1) { + self::nested($depth); + } + + return match (count($value)) { + 0 => 0, + 1 => self::numeric($value[0] ?? null, $depth + 1), + default => NAN, + }; + } + if (is_string($value)) { + return self::parseFloat($value); + } + + return NAN; + } + + /** + * Python's ordering of two numbers, exact across int and float: -1, 0 or 1, or null when either is NaN. + */ + private static function compareNumbers(int|float $a, int|float $b): ?int + { + if (is_int($a)) { + return is_int($b) ? $a <=> $b : self::compareIntWithFloat($a, $b); + } + if (is_int($b)) { + $order = self::compareIntWithFloat($b, $a); + + return $order === null ? null : -$order; + } + + return is_nan($a) || is_nan($b) ? null : $a <=> $b; + } + + private static function compareIntWithFloat(int $int, float $float): ?int + { + if (is_nan($float)) { + return null; + } + if ($float >= 9223372036854775808.0) { + return -1; + } + if ($float < -9223372036854775808.0) { + return 1; + } + + // Within the int range a float's floor is an exact int, so the comparison loses nothing. + $floor = floor($float); + $whole = (int) $floor; + if ($int !== $whole) { + return $int <=> $whole; + } + + return $floor === $float ? 0 : -1; + } + + /** + * `<`, `<=`, `>`, `>=` with an optional third argument (a between test); $strict is the order that must hold, + * $loose the other order also accepted (equal for `<=`/`>=`). + */ + private static function chain(mixed $a, mixed $b, mixed $c, int $strict, int $loose): bool + { + $order = self::order($a, $b); + if ($order !== $strict && $order !== $loose) { + return false; + } + if ($c === null) { + return true; + } + $order = self::order($b, $c); + + return $order === $strict || $order === $loose; + } + + /** The reference's less_than() family: numbers if either side is one, else text if either side is text. */ + private static function order(mixed $a, mixed $b): ?int + { + if (self::isNumeric($a) || self::isNumeric($b) || (! is_string($a) && ! is_string($b))) { + return self::compareNumbers(self::toNumber($a), self::toNumber($b)); + } + + return strcmp(self::toStr($a), self::toStr($b)) <=> 0; + } + + /** + * @param list $values + */ + private static function sum(array $values): int|float + { + $sum = 0; + foreach ($values as $value) { + $sum += self::toNumber($value); + } + + return $sum; + } + + /** + * @param list $values + */ + private static function product(array $values): int|float + { + $product = 1; + foreach ($values as $value) { + $product *= self::toNumber($value); + } + + return $product; + } + + /** + * @throws JsonLogicError + */ + private static function divide(mixed $a, mixed $b): float + { + $divisor = self::toNumber($b); + if ($divisor === 0 || $divisor === 0.0) { + throw new JsonLogicError('Division by zero.'); + } + + return (float) self::toNumber($a) / (float) $divisor; + } + + /** + * @throws JsonLogicError + */ + private static function modulo(mixed $a, mixed $b): int|float + { + $x = self::toNumber($a); + $y = self::toNumber($b); + if ($y === 0 || $y === 0.0) { + throw new JsonLogicError('Modulo by zero.'); + } + + if (is_int($x) && is_int($y)) { + $remainder = $x % $y; + + return $remainder !== 0 && ($remainder < 0) !== ($y < 0) ? $remainder + $y : $remainder; + } + + $remainder = fmod((float) $x, (float) $y); + if ($remainder === 0.0) { + return $y < 0 ? -0.0 : 0.0; + } + + return ($remainder < 0) !== ($y < 0) ? $remainder + $y : $remainder; + } + + /** + * Python's min()/max() over to_number(): the first of equals wins, NaN never replaces, a boolean stays one. + * + * @param list $values + */ + private static function extreme(array $values, int $better): int|float|bool + { + if ($values === []) { + return NAN; + } + + $best = self::numeric($values[0]); + foreach (array_slice($values, 1) as $value) { + $number = self::numeric($value); + if (self::compareNumbers(self::toNumber($number), self::toNumber($best)) === $better) { + $best = $number; + } + } + + return $best; + } + + private static function in(mixed $needle, mixed $haystack): bool + { + if (Json::isList($haystack)) { + foreach ($haystack as $item) { + if (self::strictEquals($needle, $item)) { + return true; + } + } + + return false; + } + + return is_string($haystack) && str_contains($haystack, self::toStr($needle)); + } + + /** + * @param list $values + */ + private static function concatenate(array $values): string + { + $text = ''; + foreach ($values as $value) { + $text .= self::toStr($value); + } + + return $text; + } + + /** + * The reference's op_substr over code points: a negative index counts from the end, a negative length stops + * that far from the end, NaN reads as 0 (index) or as an empty slice (length). + * + * @throws JsonLogicError + */ + private static function substr(mixed $string, mixed $index, mixed $length): string + { + $text = self::toStr($string); + $size = mb_strlen($text, 'UTF-8'); + $start = self::toNumber($index); + + if (is_float($start) && is_nan($start)) { + $from = 0; + } elseif ($start < 0) { + $back = -$start; + $from = $back >= $size ? 0 : $size - self::truncate($back); + } else { + $from = min(self::truncate($start), $size); + } + + if ($length === null) { + $to = $size; + } else { + $count = self::toNumber($length); + if (is_float($count) && is_nan($count)) { + $to = $from; + } else { + $count = self::truncate($count); + if ($count < 0) { + $to = max($size + $count, $from); + } else { + $to = $count >= $size - $from ? $size : $from + $count; + } + } + } + + return $to > $from ? mb_substr($text, $from, $to - $from, 'UTF-8') : ''; + } + + /** + * Python's int() of a number: toward zero, exact for any finite value (clamped here to the int range, which + * every caller then clamps to a string length), an OverflowError (GENERAL) for an infinity. + * + * @throws JsonLogicError + */ + private static function truncate(int|float $number): int + { + if (is_int($number)) { + return $number; + } + if (is_infinite($number)) { + throw new JsonLogicError('Cannot convert an infinite number to an integer.'); + } + if ($number >= 9223372036854775808.0) { + return PHP_INT_MAX; + } + if ($number < -9223372036854775808.0) { + return PHP_INT_MIN; + } + + return (int) $number; + } + + /** + * @param list $values + * @return list + */ + private static function merge(array $values): array + { + $merged = []; + foreach ($values as $value) { + if (Json::isList($value)) { + array_push($merged, ...$value); + } else { + $merged[] = $value; + } + } + + return $merged; + } + + /** + * The reference's op_var: a number indexes a list or a string (an object holds only text keys, so a number + * never finds a member); text is a dot path whose list and string steps are canonical indexes or `length`. + * + * @throws JsonLogicError + */ + private static function variable(mixed $data, mixed $key, mixed $default): mixed + { + if ($key === null || $key === '') { + return $data; + } + + if (self::isNumeric($key)) { + if (! Json::isList($data) && ! is_string($data)) { + return $default; + } + $index = self::exactIndex($key); + if ($index === null || $index < 0 || $index >= self::length($data)) { + return $default; + } + + return is_string($data) ? mb_substr($data, $index, 1, 'UTF-8') : $data[$index]; + } + + $value = $data; + foreach (explode('.', self::toStr($key)) as $property) { + if (Json::isList($value) || is_string($value)) { + if ($property === 'length') { + return self::length($value); + } + if (preg_match('/^(?:0|[1-9][0-9]*)$/D', $property) !== 1) { + return $default; + } + $position = (int) $property; + if ($position >= self::length($value)) { + return $default; + } + $value = is_string($value) ? mb_substr($value, $position, 1, 'UTF-8') : $value[$position]; + } elseif (Json::isObject($value)) { + $members = Json::members($value); + $value = array_key_exists($property, $members) ? $members[$property] : null; + } else { + return $default; + } + } + + return $value ?? $default; + } + + /** + * A numeric `var` key as a list index: null when the reference's int(key) differs from the key (a fraction, + * NaN, a float beyond the int range), an OverflowError (GENERAL) for an infinity. + * + * @throws JsonLogicError + */ + private static function exactIndex(int|float|bool $key): ?int + { + if (! is_float($key)) { + return (int) $key; + } + if (is_nan($key)) { + return null; + } + if (is_infinite($key)) { + throw new JsonLogicError('Cannot convert an infinite number to an integer.'); + } + if ($key >= 9223372036854775808.0 || $key < -9223372036854775808.0) { + return null; + } + + $index = (int) $key; + + return (float) $index === $key ? $index : null; + } + + /** + * @param list|string $value + */ + private static function length(array|string $value): int + { + return is_string($value) ? mb_strlen($value, 'UTF-8') : count($value); + } + + /** + * @param list $values + * @return list + * + * @throws JsonLogicError + */ + private static function missing(mixed $data, array $values): array + { + $keys = $values !== [] && Json::isList($values[0]) ? $values[0] : $values; + + $missing = []; + foreach ($keys as $key) { + $value = self::variable($data, $key, null); + if ($value === null || $value === '') { + $missing[] = $key; + } + } + + return $missing; + } + + /** + * @return list + * + * @throws JsonLogicError + */ + private static function missingSome(mixed $data, mixed $need, mixed $keys): array + { + if (! Json::isList($keys)) { + return []; + } + + $missing = self::missing($data, [$keys]); + $order = self::compareNumbers(count($keys) - count($missing), self::toNumber($need)); + + return $order !== null && $order >= 0 ? [] : $missing; + } + + /** + * The reference's to_number() of text: NaN for "inf"/"+inf"/"-inf" once Python's strip() and lower() ran, + * else Python's float() — which reads Unicode decimal digits as digits and strips non-ASCII spaces — or NaN + * where float() raises. + */ + private static function parseFloat(string $value): float + { + if (! mb_check_encoding($value, 'UTF-8')) { + return NAN; + } + + if (in_array(strtolower(self::strip($value, self::STRIP_SPACE)), ['inf', '+inf', '-inf'], true)) { + return NAN; + } + + $ascii = preg_replace_callback('/[^\x00-\x7F]/u', self::asciiDigit(...), self::strip($value, self::FLOAT_SPACE)); + if ($ascii === null) { + return NAN; + } + + $text = strtolower($ascii); + if (str_contains($text, '_')) { + // float() accepts an underscore only between two digits, then reads the text without it. + if (preg_match('/(? $spaces + */ + private static function strip(string $value, array $spaces): string + { + $start = 0; + $end = strlen($value); + while ($start < $end) { + $lead = ord($value[$start]); + $width = $lead < 0x80 ? 1 : ($lead < 0xE0 ? 2 : ($lead < 0xF0 ? 3 : 4)); + if (! in_array(substr($value, $start, $width), $spaces, true)) { + break; + } + $start += $width; + } + while ($end > $start) { + $from = $end - 1; + while ($from > $start && (ord($value[$from]) & 0xC0) === 0x80) { + $from--; + } + if (! in_array(substr($value, $from, $end - $from), $spaces, true)) { + break; + } + $end = $from; + } + + return substr($value, $start, $end - $start); + } + + /** + * float()'s view of one non-ASCII character: a Unicode decimal digit is its ASCII digit, anything else makes + * the text unparsable. Unicode encodes every decimal digit in runs of ten from 0, so a digit's value is its + * distance from the start of its runs modulo ten; no run starts right after a surrogate, so the walk back + * never asks mb_chr() for one. + * + * @param array $match + */ + private static function asciiDigit(array $match): string + { + $character = $match[0] ?? ''; + if (isset(self::$digits[$character])) { + return self::$digits[$character]; + } + if (preg_match('/^\p{Nd}$/u', $character) !== 1) { + return '?'; + } + + $code = mb_ord($character, 'UTF-8'); + $distance = 0; + while ($code - $distance > 0 && preg_match('/^\p{Nd}$/u', mb_chr($code - $distance - 1, 'UTF-8')) === 1) { + $distance++; + } + + return self::$digits[$character] = (string) ($distance % 10); + } + + /** Python's `'%.15g' % value`. */ + private static function formatG15(float $value): string + { + if (is_nan($value)) { + return 'nan'; + } + if (is_infinite($value)) { + return $value > 0 ? 'inf' : '-inf'; + } + if ($value === 0.0) { + return fdiv(1.0, $value) < 0 ? '-0' : '0'; + } + + [$mantissa, $exponent] = explode('e', sprintf('%.14e', $value)); + $power = (int) $exponent; + + if ($power < -4 || $power >= 15) { + $digits = str_contains($mantissa, '.') ? rtrim(rtrim($mantissa, '0'), '.') : $mantissa; + + return $digits.'e'.($power < 0 ? '-' : '+').str_pad((string) abs($power), 2, '0', STR_PAD_LEFT); + } + + $fixed = sprintf('%.'.(14 - $power).'F', $value); + + return str_contains($fixed, '.') ? rtrim(rtrim($fixed, '0'), '.') : $fixed; + } +} diff --git a/packages/feature-flags/src/Evaluation/JsonLogicError.php b/packages/feature-flags/src/Evaluation/JsonLogicError.php new file mode 100644 index 00000000..7575d0c4 --- /dev/null +++ b/packages/feature-flags/src/Evaluation/JsonLogicError.php @@ -0,0 +1,10 @@ + $open the ids of the objects being written on this path + */ + private static function written(mixed $value, array $open, int $depth): ?string + { + if (is_string($value)) { + return self::quote($value); + } + if (is_bool($value)) { + return $value ? 'True' : 'False'; + } + if ($value === null) { + return 'None'; + } + if (is_int($value)) { + return (string) $value; + } + if (is_float($value)) { + return self::float($value); + } + + if ((is_array($value) || $value instanceof stdClass) && $depth >= self::MAX_DEPTH) { + return null; + } + + if (Json::isList($value)) { + $items = []; + foreach ($value as $item) { + $text = self::written($item, $open, $depth + 1); + if ($text === null) { + return null; + } + $items[] = $text; + } + + return '['.implode(', ', $items).']'; + } + + if (is_array($value) || $value instanceof stdClass) { + if ($value instanceof stdClass) { + if (isset($open[spl_object_id($value)])) { + return '{...}'; + } + $open[spl_object_id($value)] = true; + } + $items = []; + foreach (Json::members($value) as $name => $member) { + $key = self::quote((string) $name); + $text = self::written($member, $open, $depth + 1); + if ($key === null || $text === null) { + return null; + } + $items[] = $key.': '.$text; + } + + return '{'.implode(', ', $items).'}'; + } + + return null; + } + + /** + * Python's repr() of a float (what str() gives), independent of PHP's `precision` ini: the shortest digits + * that read back as the same float, fixed notation for 1e-4 <= |x| < 1e16 (with ".0" when whole), an + * exponent of at least two digits otherwise. + */ + public static function float(float $value): string + { + if (is_nan($value)) { + return 'nan'; + } + if (is_infinite($value)) { + return $value > 0 ? 'inf' : '-inf'; + } + + $sign = $value < 0 || ($value === 0.0 && fdiv(1.0, $value) < 0) ? '-' : ''; + $magnitude = abs($value); + + // Sixteen decimals in scientific notation is seventeen significant digits, which always read back. + $precision = 0; + while ($precision < 16 && (float) sprintf('%.'.$precision.'e', $magnitude) !== $magnitude) { + $precision++; + } + [$mantissa, $exponent] = explode('e', sprintf('%.'.$precision.'e', $magnitude)); + $digits = rtrim(str_replace('.', '', $mantissa), '0'); + $digits = $digits === '' ? '0' : $digits; + $point = (int) $exponent + 1; + + if ($point <= -4 || $point > 16) { + $text = $digits[0].(strlen($digits) > 1 ? '.'.substr($digits, 1) : ''); + + return sprintf('%s%se%s%02d', $sign, $text, $point - 1 < 0 ? '-' : '+', abs($point - 1)); + } + + return $sign.match (true) { + $point <= 0 => '0.'.str_repeat('0', -$point).$digits, + $point >= strlen($digits) => $digits.str_repeat('0', $point - strlen($digits)).'.0', + default => substr($digits, 0, $point).'.'.substr($digits, $point), + }; + } + + /** + * repr() of text: single quotes unless the text holds a single quote and no double one; a backslash, the + * quote, \t, \n and \r escaped; any other control or unprintable character as \xNN, \uNNNN or \UNNNNNNNN. + */ + private static function quote(string $text): ?string + { + if (! mb_check_encoding($text, 'UTF-8')) { + return null; + } + + $quote = str_contains($text, "'") && ! str_contains($text, '"') ? '"' : "'"; + $escaped = preg_replace_callback( + '/[\x00-\x1F\x7F\\\\\'"]|[^\x00-\x7F]/u', + static function (array $match) use ($quote): string { + $character = $match[0]; + + return match (true) { + $character === '\\' => '\\\\', + $character === $quote => '\\'.$quote, + $character === '"', $character === "'" => $character, + $character === "\t" => '\t', + $character === "\n" => '\n', + $character === "\r" => '\r', + strlen($character) === 1 => sprintf('\x%02x', ord($character)), + preg_match(self::NOT_PRINTABLE, $character) !== 1 => $character, + default => self::escape(mb_ord($character, 'UTF-8')), + }; + }, + $text, + ); + + return $escaped === null ? null : $quote.$escaped.$quote; + } + + private static function escape(int|false $code): string + { + return match (true) { + $code === false => '', + $code <= 0xFF => sprintf('\x%02x', $code), + $code <= 0xFFFF => sprintf('\u%04x', $code), + default => sprintf('\U%08x', $code), + }; + } +} diff --git a/packages/feature-flags/src/Evaluation/RefResolver.php b/packages/feature-flags/src/Evaluation/RefResolver.php new file mode 100644 index 00000000..808dd807 --- /dev/null +++ b/packages/feature-flags/src/Evaluation/RefResolver.php @@ -0,0 +1,148 @@ + $evaluators the document's `$evaluators` + */ + public static function withinLimits(mixed $targeting, array $evaluators): bool + { + $budget = self::MAX_VALUES; + + return self::fits($targeting, $evaluators, [], 1, $budget); + } + + /** + * $targeting with every resolvable reference replaced by its expanded evaluator; a missing name or a cycle + * left as its `{"$ref": …}` object. Call it only within the limits (withinLimits()). + * + * @param array $evaluators the document's `$evaluators` + */ + public static function expand(mixed $targeting, array $evaluators): mixed + { + return self::expanded($targeting, $evaluators, []); + } + + /** + * @param array $evaluators + * @param array $resolving the evaluator names being resolved on this path + */ + private static function fits(mixed $node, array $evaluators, array $resolving, int $level, int &$budget): bool + { + while (($name = self::resolvable($node, $evaluators, $resolving)) !== null) { + $resolving[$name] = true; + $node = $evaluators[$name]; + if (--$budget < 0) { + return false; + } + } + + if (--$budget < 0) { + return false; + } + if (! is_array($node) && ! $node instanceof stdClass) { + return true; + } + if ($level > self::MAX_DEPTH) { + return false; + } + + foreach (is_array($node) ? $node : get_object_vars($node) as $child) { + if (! self::fits($child, $evaluators, $resolving, $level + 1, $budget)) { + return false; + } + } + + return true; + } + + /** + * @param array $evaluators + * @param array $resolving + */ + private static function expanded(mixed $node, array $evaluators, array $resolving): mixed + { + while (($name = self::resolvable($node, $evaluators, $resolving)) !== null) { + $resolving[$name] = true; + $node = $evaluators[$name]; + } + + if ($node instanceof stdClass) { + $object = new stdClass; + foreach (get_object_vars($node) as $member => $child) { + $object->{$member} = self::expanded($child, $evaluators, $resolving); + } + + return $object; + } + + if (is_array($node)) { + $array = []; + foreach ($node as $key => $child) { + $array[$key] = self::expanded($child, $evaluators, $resolving); + } + + return $array; + } + + return $node; + } + + /** + * The evaluator name $node references, when that evaluator exists and is not already being resolved on this + * path; null otherwise (not a reference, a missing name, or a cycle). + * + * @param array $evaluators + * @param array $resolving + */ + private static function resolvable(mixed $node, array $evaluators, array $resolving): ?string + { + if (! Json::isObject($node)) { + return null; + } + + $members = Json::members($node); + $name = count($members) === 1 ? ($members['$ref'] ?? null) : null; + + return is_string($name) && array_key_exists($name, $evaluators) && ! array_key_exists($name, $resolving) ? $name : null; + } +} diff --git a/packages/feature-flags/src/Evaluation/Resolution.php b/packages/feature-flags/src/Evaluation/Resolution.php new file mode 100644 index 00000000..ad7c2b37 --- /dev/null +++ b/packages/feature-flags/src/Evaluation/Resolution.php @@ -0,0 +1,66 @@ + $metadata + */ + public function __construct( + public mixed $value, + public ?string $variant, + public EvaluationReason $reason, + public ?EvaluationError $error = null, + public ?string $errorMessage = null, + public array $metadata = [], + ) {} + + /** The caller's default, no variant, reason ERROR and the code. */ + public static function error(mixed $default, EvaluationError $error, string $message): self + { + return new self($default, null, EvaluationReason::Error, $error, $message); + } + + public function withValue(mixed $value): self + { + return new self($value, $this->variant, $this->reason, $this->error, $this->errorMessage, $this->metadata); + } + + /** The SDK's view: plain PHP arrays (never stdClass), the reason and error code as OpenFeature strings. */ + public function toResolutionDetails(): ResolutionDetails + { + $value = Json::toPhp($this->value); + $builder = (new ResolutionDetailsBuilder) + ->withValue(is_bool($value) || is_string($value) || is_int($value) || is_float($value) || is_array($value) ? $value : null) + ->withReason($this->reason->value); + + if ($this->variant !== null) { + $builder->withVariant($this->variant); + } + + if ($this->error !== null) { + $builder->withError(new ResolutionError($this->error->toOpenFeature(), $this->errorMessage)); + } + + return $builder->build(); + } +} diff --git a/packages/feature-flags/src/Evaluation/SemVer.php b/packages/feature-flags/src/Evaluation/SemVer.php new file mode 100644 index 00000000..72103811 --- /dev/null +++ b/packages/feature-flags/src/Evaluation/SemVer.php @@ -0,0 +1,188 @@ + $args + * + * @throws JsonLogicError where the reference raises + */ + public static function evaluate(mixed $data, array $args): ?bool + { + if (count($args) !== 3) { + return null; + } + + [$left, $operator, $right] = $args; + $a = self::parse($left); + $b = self::parse($right); + if ($a === null || $b === null) { + return null; + } + + return match ($operator) { + '=' => self::compare($a, $b) === 0, + '!=' => self::compare($a, $b) !== 0, + '<' => self::compare($a, $b) < 0, + '<=' => self::compare($a, $b) <= 0, + '>' => self::compare($a, $b) > 0, + '>=' => self::compare($a, $b) >= 0, + '^' => $a['major'] === $b['major'], + '~' => $a['major'] === $b['major'] && $a['minor'] === $b['minor'], + default => null, + }; + } + + /** + * @return array{major: string, minor: string, patch: string, prerelease: string|null}|null + */ + public static function parse(mixed $value): ?array + { + $version = match (true) { + is_string($value) => $value, + is_int($value) => (string) $value, + is_float($value) => PythonText::float($value), + default => null, + }; + if ($version === null) { + return null; + } + + if (str_starts_with($version, 'v') || str_starts_with($version, 'V')) { + $version = substr($version, 1); + } + + $numeric = explode('+', explode('-', $version, 2)[0], 2)[0]; + $dots = substr_count($numeric, '.'); + if ($dots < 2) { + $version = $numeric.str_repeat('.0', 2 - $dots).substr($version, strlen($numeric)); + } + + if (preg_match(self::SHAPE, $version, $parts) !== 1) { + return null; + } + + [, $major, $minor, $patch] = $parts; + $prerelease = ($parts[4] ?? '') !== '' ? $parts[4] : null; + $build = $parts[5] ?? null; + + foreach ([$major, $minor, $patch] as $number) { + if (strlen($number) > self::PYTHON_INT_MAX_STR_DIGITS) { + return null; + } + } + + if ($prerelease !== null) { + foreach (explode('.', $prerelease) as $identifier) { + // A numeric identifier has no leading zero; an alphanumeric one may (`00a`). + if ($identifier === '' || (ctype_digit($identifier) && $identifier !== '0' && $identifier[0] === '0')) { + return null; + } + } + } + + if ($build !== null && in_array('', explode('.', $build), true)) { + return null; + } + + return ['major' => $major, 'minor' => $minor, 'patch' => $patch, 'prerelease' => $prerelease]; + } + + /** + * python-semver's Version.compare(): the cores as integers, then the prereleases (a release above any of + * its prereleases). + * + * @param array{major: string, minor: string, patch: string, prerelease: string|null} $a + * @param array{major: string, minor: string, patch: string, prerelease: string|null} $b + * + * @throws JsonLogicError + */ + private static function compare(array $a, array $b): int + { + foreach (['major', 'minor', 'patch'] as $part) { + $order = self::numeric($a[$part], $b[$part]); + if ($order !== 0) { + return $order; + } + } + + $natural = self::natural($a['prerelease'], $b['prerelease']); + + return match (true) { + $natural === 0 => 0, + $a['prerelease'] === null => 1, + $b['prerelease'] === null => -1, + default => $natural, + }; + } + + /** + * python-semver's _nat_cmp(): dot-separated identifiers, digits as numbers and below text, then the longer + * list above. Python converts every numeric identifier of both sides before it compares any. + * + * @throws JsonLogicError + */ + private static function natural(?string $a, ?string $b): int + { + $left = explode('.', $a ?? ''); + $right = explode('.', $b ?? ''); + + foreach ([...$left, ...$right] as $identifier) { + if (strlen($identifier) > self::PYTHON_INT_MAX_STR_DIGITS && ctype_digit($identifier)) { + throw new JsonLogicError('sem_ver: a numeric prerelease identifier exceeds the limit ('.self::PYTHON_INT_MAX_STR_DIGITS.' digits) for integer string conversion.'); + } + } + + $shared = min(count($left), count($right)); + for ($i = 0; $i < $shared; $i++) { + $x = $left[$i]; + $y = $right[$i]; + $order = match (true) { + ctype_digit($x) && ctype_digit($y) => self::numeric($x, $y), + ctype_digit($x) => -1, + ctype_digit($y) => 1, + default => strcmp($x, $y) <=> 0, + }; + if ($order !== 0) { + return $order; + } + } + + return count($left) <=> count($right); + } + + /** Non-negative integers of any length without leading zeros, compared exactly. */ + private static function numeric(string $a, string $b): int + { + return strlen($a) <=> strlen($b) ?: strcmp($a, $b) <=> 0; + } +} diff --git a/packages/feature-flags/src/Evaluation/StringOps.php b/packages/feature-flags/src/Evaluation/StringOps.php new file mode 100644 index 00000000..f2583a0d --- /dev/null +++ b/packages/feature-flags/src/Evaluation/StringOps.php @@ -0,0 +1,29 @@ + $args + */ + public static function startsWith(mixed $data, array $args): ?bool + { + return count($args) === 2 && is_string($args[0]) && is_string($args[1]) ? str_starts_with($args[0], $args[1]) : null; + } + + /** + * @param list $args + */ + public static function endsWith(mixed $data, array $args): ?bool + { + return count($args) === 2 && is_string($args[0]) && is_string($args[1]) ? str_ends_with($args[0], $args[1]) : null; + } +} diff --git a/packages/feature-flags/src/Evaluation/UnknownOperator.php b/packages/feature-flags/src/Evaluation/UnknownOperator.php new file mode 100644 index 00000000..edf9d26c --- /dev/null +++ b/packages/feature-flags/src/Evaluation/UnknownOperator.php @@ -0,0 +1,14 @@ +|null $previous + * @param array|null $current + */ + public function __construct( + public string $key, + public string $action, + public ?string $actor, + public ?array $previous, + public ?array $current, + ) {} +} diff --git a/packages/feature-flags/src/Event/FeatureFlagsChanged.php b/packages/feature-flags/src/Event/FeatureFlagsChanged.php new file mode 100644 index 00000000..a490c213 --- /dev/null +++ b/packages/feature-flags/src/Event/FeatureFlagsChanged.php @@ -0,0 +1,30 @@ + $changedKeys + */ + public function __construct( + public array $changedKeys, + public string $origin, + ) {} +} diff --git a/packages/feature-flags/src/FeatureFlags.php b/packages/feature-flags/src/FeatureFlags.php new file mode 100644 index 00000000..9e066864 --- /dev/null +++ b/packages/feature-flags/src/FeatureFlags.php @@ -0,0 +1,197 @@ + true), on which the Firefly hooks record neither a metric nor an + * exposure event. A hook of your own can read it the same way (`$hints->get(FeatureFlags::PREVIEW_HINT) === true`). + * The SDK still merges the OpenFeature API's and the client's own evaluation contexts underneath (Firefly sets + * neither), as PyFly's preview does. + * + * The provider this application configured is (re)asserted on the OpenFeature API before evaluating: the global API + * is a process-wide singleton, and a second application booted in the same process (a test suite) must not + * evaluate through the first one's provider. + */ +final class FeatureFlags +{ + public const string CLIENT_NAME = 'firefly'; + + /** The hook hint of a preview evaluation: Firefly's hooks record no metric and no exposure event for it. */ + public const string PREVIEW_HINT = 'firefly.preview'; + + private ?Client $client = null; + + /** + * @param list $hooks attached to the `firefly` client only, so a reboot never accumulates them + */ + public function __construct( + private readonly Provider $provider, + private readonly EvaluationContextResolver $context, + private readonly array $hooks = [], + private readonly ?FlagDocumentSource $documents = null, + private readonly ?API $api = null, + ) {} + + public function client(): Client + { + $api = $this->api ?? OpenFeatureAPI::getInstance(); + if ($api->getProvider() !== $this->provider) { + $api->setProvider($this->provider); + } + + if ($this->client === null) { + $client = $api->getClient(self::CLIENT_NAME, Version::VERSION); + $client->setHooks([...$client->getHooks(), ...$this->hooks]); + $this->client = $client; + } + + return $this->client; + } + + public function provider(): Provider + { + return $this->provider; + } + + /** + * @param array $context + */ + public function isEnabled(string $key, bool $default = false, array $context = [], ?string $targetingKey = null): bool + { + return $this->client()->getBooleanValue($key, $default, $this->context->resolve($context, $targetingKey)) ?? $default; + } + + /** + * @param array $context + */ + public function getString(string $key, string $default, array $context = [], ?string $targetingKey = null): string + { + return $this->client()->getStringValue($key, $default, $this->context->resolve($context, $targetingKey)) ?? $default; + } + + /** + * @param array $context + */ + public function getInt(string $key, int $default, array $context = [], ?string $targetingKey = null): int + { + return $this->client()->getIntegerValue($key, $default, $this->context->resolve($context, $targetingKey)); + } + + /** + * @param array $context + */ + public function getFloat(string $key, float $default, array $context = [], ?string $targetingKey = null): float + { + return $this->client()->getFloatValue($key, $default, $this->context->resolve($context, $targetingKey)); + } + + /** + * @param array $default + * @param array $context + * @return array + */ + public function getObject(string $key, array $default = [], array $context = [], ?string $targetingKey = null): array + { + return $this->client()->getObjectValue($key, $default, $this->context->resolve($context, $targetingKey)); + } + + /** + * The whole evaluation; the type is the default's type. `ambient: false` is a preview (see the class). + * + * @param bool|string|int|float|array $default + * @param array $context + */ + public function details(string $key, bool|string|int|float|array $default, array $context = [], ?string $targetingKey = null, bool $ambient = true): FlagEvaluation + { + $evaluationContext = $this->context->resolve($context, $targetingKey, $ambient); + $options = $ambient ? null : new EvaluationOptions([], new HookHints([self::PREVIEW_HINT => true])); + $client = $this->client(); + + $details = match (true) { + is_bool($default) => $client->getBooleanDetails($key, $default, $evaluationContext, $options), + is_string($default) => $client->getStringDetails($key, $default, $evaluationContext, $options), + is_int($default) => $client->getIntegerDetails($key, $default, $evaluationContext, $options), + is_float($default) => $client->getFloatDetails($key, $default, $evaluationContext, $options), + default => $client->getObjectDetails($key, $default, $evaluationContext, $options), + }; + + return FlagEvaluation::fromDetails($key, $details, $this->metadata($key)); + } + + /** + * The variant the flag resolves to, evaluated with the flag's own type when the flag is Firefly's (string + * evaluation otherwise); null when the flag is missing, disabled, has no matching variant or fails. + * + * @param array $context + */ + public function variant(string $key, array $context = [], ?string $targetingKey = null): ?string + { + $type = $this->flagType($key) ?? FlagType::String; + $evaluation = $this->details($key, $type->zero(), $context, $targetingKey); + + return $evaluation->errorCode === null ? $evaluation->variant : null; + } + + private function flagType(string $key): ?FlagType + { + try { + return $this->documents?->document()->flag($key)?->flagType(); + } catch (Throwable) { + return null; + } + } + + /** + * The flag's metadata over the document's (scalars only), or none for a flag Firefly does not hold. + * + * @return array + */ + private function metadata(string $key): array + { + try { + $document = $this->documents?->document(); + } catch (Throwable) { + return []; + } + + $flag = $document?->flag($key); + if ($document === null || $flag === null) { + return []; + } + + $shared = array_filter($document->metadata, static fn (mixed $value): bool => is_bool($value) || is_int($value) || is_float($value) || is_string($value)); + + return array_replace($shared, $flag->metadata()); + } +} diff --git a/packages/feature-flags/src/FeatureFlagsAutoConfiguration.php b/packages/feature-flags/src/FeatureFlagsAutoConfiguration.php new file mode 100644 index 00000000..bfaed9fa --- /dev/null +++ b/packages/feature-flags/src/FeatureFlagsAutoConfiguration.php @@ -0,0 +1,127 @@ +getAll(FlagSource::class), static fn (object $source): bool => $source instanceof FlagSource)); + + /** @var list $sources */ + return new FlagRegistry($sources, new CacheBook($cache, $logger), $events, $logger); + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.feature-flags.enabled', havingValue: 'true')] + #[ConditionalOnMissingBean(Provider::class)] + public function openFeatureProvider(FlagRegistry $registry, FlagdEvaluator $evaluator, ?LoggerInterface $logger = null): Provider + { + $provider = new FireflyFlagProvider($registry, $evaluator); + if ($logger !== null) { + $provider->setLogger($logger); + } + + return $provider; + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.feature-flags.enabled', havingValue: 'true')] + #[ConditionalOnMissingBean(EvaluationContextResolver::class)] + public function evaluationContextResolver(FireflyContainer $beans, ?LoggerInterface $logger = null): EvaluationContextResolver + { + $contributors = array_values(array_filter($beans->getAll(EvaluationContextContributor::class), static fn (object $contributor): bool => $contributor instanceof EvaluationContextContributor)); + + /** @var list $contributors */ + return new EvaluationContextResolver($contributors, $logger ?? new NullLogger); + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.feature-flags.enabled', havingValue: 'true')] + #[ConditionalOnMissingBean(FeatureFlags::class)] + public function featureFlags( + Provider $provider, + EvaluationContextResolver $context, + FeatureFlagMetrics $metrics, + ApplicationEventPublisher $events, + FeatureFlagsSettings $settings, + FireflyContainer $beans, + ?LoggerInterface $logger = null, + ): FeatureFlags { + $logger ??= new NullLogger; + $hooks = [new MetricsHook($metrics, $logger)]; + if ($settings->publishEvaluations) { + $hooks[] = new ExposureEventHook($events, $logger); + } + + $registry = $provider instanceof FireflyFlagProvider && $beans->has(FlagRegistry::class) + ? $beans->get(FlagRegistry::class) + : null; + + return new FeatureFlags($provider, $context, $hooks, $registry instanceof FlagDocumentSource ? $registry : null); + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.feature-flags.enabled', havingValue: 'true')] + #[ConditionalOnMissingBean(Client::class)] + public function featureFlagsClient(FeatureFlags $flags): Client + { + return $flags->client(); + } +} diff --git a/packages/feature-flags/src/FeatureFlagsServiceProvider.php b/packages/feature-flags/src/FeatureFlagsServiceProvider.php new file mode 100644 index 00000000..f3d86e45 --- /dev/null +++ b/packages/feature-flags/src/FeatureFlagsServiceProvider.php @@ -0,0 +1,25 @@ + */ + public const array DISABLED_STATUSES = [404, 403, 503]; + + /** @var list */ + public const array STORE_DRIVERS = ['database', 'memory']; + + /** + * @param array $flags + * @param array $evaluators + */ + public function __construct( + public bool $enabled, + public array $flags, + public array $evaluators, + public FileSourceSettings $file, + public HttpSourceSettings $http, + public StoreSourceSettings $store, + public string $tenantAttribute, + public int $disabledStatus, + public bool $publishEvaluations, + public bool $writes, + public ServerSettings $server, + ) {} + + public static function fromConfig(Config $config): self + { + $durations = new DurationReader($config); + + $file = new FileSourceSettings( + enabled: $config->bool('firefly.feature-flags.sources.file.enabled', false), + path: $config->string('firefly.feature-flags.sources.file.path', ''), + refreshInterval: $durations->get('firefly.feature-flags.sources.file.refresh-interval', 5.0), + ); + if ($file->enabled && trim($file->path) === '') { + throw new ConfigurationException('firefly.feature-flags.sources.file.enabled is on but firefly.feature-flags.sources.file.path is empty: name the flagd document (.json, .yaml or .yml) to watch.'); + } + + $http = new HttpSourceSettings( + enabled: $config->bool('firefly.feature-flags.sources.http.enabled', false), + url: $config->string('firefly.feature-flags.sources.http.url', ''), + token: $config->string('firefly.feature-flags.sources.http.token', ''), + refreshInterval: $durations->get('firefly.feature-flags.sources.http.refresh-interval', 30.0, refuseZeroBecause: 'zero would send the remote server one conditional GET per request, with no stampede lock'), + timeout: $durations->get('firefly.feature-flags.sources.http.timeout', 2.0, refuseZeroBecause: "Laravel's HTTP client reads zero as no timeout at all"), + ); + if ($http->enabled && trim($http->url) === '') { + throw new ConfigurationException('firefly.feature-flags.sources.http.enabled is on but firefly.feature-flags.sources.http.url is empty: name the sync endpoint to poll.'); + } + + $driver = $config->string('firefly.feature-flags.sources.store.driver', 'database'); + if (! in_array($driver, self::STORE_DRIVERS, true)) { + throw new ConfigurationException("firefly.feature-flags.sources.store.driver must be 'database' or 'memory', got [{$driver}]."); + } + $connection = $config->get('firefly.feature-flags.sources.store.connection'); + if ($connection !== null && ! is_string($connection)) { + throw new ConfigurationException('firefly.feature-flags.sources.store.connection must name a database connection (null or an empty string for the default one), got '.get_debug_type($connection).'.'); + } + $store = new StoreSourceSettings( + enabled: $config->bool('firefly.feature-flags.sources.store.enabled', false), + driver: $driver, + connection: $connection === null || $connection === '' ? null : $connection, + refreshInterval: $durations->get('firefly.feature-flags.sources.store.refresh-interval', 5.0), + ); + + $server = new ServerSettings( + enabled: $config->bool('firefly.feature-flags.server.enabled', false), + path: '/'.ltrim($config->string('firefly.feature-flags.server.path', '/feature-flags/flagd.json'), '/'), + token: $config->string('firefly.feature-flags.server.token', ''), + allowAnonymous: $config->bool('firefly.feature-flags.server.allow-anonymous', false), + ); + if ($server->enabled && $server->token === '' && ! $server->allowAnonymous) { + throw new ConfigurationException('firefly.feature-flags.server.enabled is on without firefly.feature-flags.server.token: set a token (clients send it as a bearer token) or set firefly.feature-flags.server.allow-anonymous to true.'); + } + if ($server->enabled && $server->path === '/') { + throw new ConfigurationException('firefly.feature-flags.server.enabled is on but firefly.feature-flags.server.path is the application root: name a path such as /feature-flags/flagd.json.'); + } + + return new self( + enabled: $config->bool('firefly.feature-flags.enabled', false), + flags: $config->array('firefly.feature-flags.flags', []), + evaluators: $config->array('firefly.feature-flags.evaluators', []), + file: $file, + http: $http, + store: $store, + tenantAttribute: $config->string('firefly.feature-flags.context.tenant-attribute', 'tenant'), + disabledStatus: self::disabledStatus($config), + publishEvaluations: $config->bool('firefly.feature-flags.events.evaluations', false), + writes: $config->bool('firefly.feature-flags.management.writes', false), + server: $server, + ); + } + + /** web.disabled-status, readable while the subsystem is off (the gate needs it). */ + public static function disabledStatus(Config $config): int + { + $status = $config->int('firefly.feature-flags.web.disabled-status', 404); + if (! in_array($status, self::DISABLED_STATUSES, true)) { + throw new ConfigurationException("firefly.feature-flags.web.disabled-status must be 404, 403 or 503, got [{$status}]."); + } + + return $status; + } +} diff --git a/packages/feature-flags/src/FeatureFlagsWiringProvider.php b/packages/feature-flags/src/FeatureFlagsWiringProvider.php new file mode 100644 index 00000000..c6b1c44a --- /dev/null +++ b/packages/feature-flags/src/FeatureFlagsWiringProvider.php @@ -0,0 +1,52 @@ + + */ + public function passes(): array + { + return [new FeatureFlagRouteGatingPass, new FlagdSyncRouteRegistrar]; + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([FlagsCommand::class]); + } + + $migrations = dirname(__DIR__).'/database/migrations'; + $this->publishes([$migrations.'/'.self::MIGRATION => $this->app->databasePath('migrations/'.self::MIGRATION)], 'firefly-feature-flags-migrations'); + + $config = new Config($this->app->make('config')); + if ($config->bool('firefly.feature-flags.enabled', false) + && $config->bool('firefly.feature-flags.sources.store.enabled', false) + && $config->string('firefly.feature-flags.sources.store.driver', 'database') === 'database') { + $this->loadMigrationsFrom($migrations); + } + + $this->callAfterResolving('blade.compiler', static function (BladeCompiler $blade): void { + BladeDirectives::register($blade); + }); + } +} diff --git a/packages/feature-flags/src/FlagEvaluation.php b/packages/feature-flags/src/FlagEvaluation.php new file mode 100644 index 00000000..e03fa487 --- /dev/null +++ b/packages/feature-flags/src/FlagEvaluation.php @@ -0,0 +1,89 @@ + $metadata + */ + public function __construct( + public string $key, + public mixed $value, + public ?string $variant, + public string $reason, + public ?string $errorCode = null, + public ?string $errorMessage = null, + public array $metadata = [], + ) {} + + /** + * An evaluation made through an OpenFeature client; $metadata applies only when it did not fail. + * + * @param array $metadata + */ + public static function fromDetails(string $key, EvaluationDetails $details, array $metadata): self + { + $error = $details->getError(); + + return new self( + $key, + ValueCopy::of($details->getValue()), + $error === null ? $details->getVariant() : null, + $error === null ? ($details->getReason() ?? 'UNKNOWN') : 'ERROR', + $error?->getResolutionErrorCode()->getValue(), + $error?->getResolutionErrorMessage(), + $error === null ? $metadata : [], + ); + } + + /** An evaluation made by FireflyFlagProvider::resolution() (or an evaluator), metadata included. */ + public static function fromResolution(string $key, Resolution $resolution): self + { + return new self( + $key, + ValueCopy::of($resolution->value), + $resolution->variant, + $resolution->reason->value, + $resolution->error?->value, + $resolution->errorMessage, + $resolution->metadata, + ); + } + + /** + * The contract's `evaluate` body (CONTRACT.md "The `flags` actuator endpoint"); the metadata is always a JSON + * object, `{}` when there is none. + * + * @return array{key: string, value: mixed, variant: ?string, reason: string, errorCode: ?string, metadata: stdClass|array} + */ + public function toArray(): array + { + /** @var stdClass|array $metadata */ + $metadata = Json::object($this->metadata); + + return [ + 'key' => $this->key, + 'value' => $this->value, + 'variant' => $this->variant, + 'reason' => $this->reason, + 'errorCode' => $this->errorCode, + 'metadata' => $metadata, + ]; + } +} diff --git a/packages/feature-flags/src/Gating/BladeDirectives.php b/packages/feature-flags/src/Gating/BladeDirectives.php new file mode 100644 index 00000000..3b3e2554 --- /dev/null +++ b/packages/feature-flags/src/Gating/BladeDirectives.php @@ -0,0 +1,24 @@ +if('featureflag', static fn (string $key, bool $default = false): bool => self::gate()->allows($key, null, $default)); + $blade->if('featurevariant', static fn (string $key, string $variant): bool => self::gate()->allows($key, $variant)); + } + + private static function gate(): FeatureFlagGate + { + /** @var FeatureFlagGate */ + return Container::getInstance()->make(FeatureFlagGate::class); + } +} diff --git a/packages/feature-flags/src/Gating/FeatureFlag.php b/packages/feature-flags/src/Gating/FeatureFlag.php new file mode 100644 index 00000000..ef09bda8 --- /dev/null +++ b/packages/feature-flags/src/Gating/FeatureFlag.php @@ -0,0 +1,19 @@ +scanProxyAdvice($psr4); + } + + public function render(array $row): string + { + return '\\'.FeatureFlagMethodDescriptor::class.'::fromArray('.var_export($row, true).')'; + } +} diff --git a/packages/feature-flags/src/Gating/FeatureFlagDisabledException.php b/packages/feature-flags/src/Gating/FeatureFlagDisabledException.php new file mode 100644 index 00000000..c3ac09df --- /dev/null +++ b/packages/feature-flags/src/Gating/FeatureFlagDisabledException.php @@ -0,0 +1,29 @@ + ['ACCESS_DENIED', 'Access denied', ErrorCategory::Security, 403], + 503 => ['SERVICE_UNAVAILABLE', 'Service unavailable', ErrorCategory::Infrastructure, 503], + default => ['RESOURCE_NOT_FOUND', 'Resource not found', ErrorCategory::Business, 404], + }; + + parent::__construct($message, $code, $httpStatus, $category, ErrorSeverity::Info); + } + + public function flagKey(): string + { + return $this->flagKey; + } +} diff --git a/packages/feature-flags/src/Gating/FeatureFlagGate.php b/packages/feature-flags/src/Gating/FeatureFlagGate.php new file mode 100644 index 00000000..aa4f5779 --- /dev/null +++ b/packages/feature-flags/src/Gating/FeatureFlagGate.php @@ -0,0 +1,49 @@ +disabledStatus = FeatureFlagsSettings::disabledStatus($config); + } + + public function allows(string $key, ?string $variant = null, bool $default = false): bool + { + try { + $flags = $this->beans->has(FeatureFlags::class) ? $this->beans->get(FeatureFlags::class) : null; + if (! $flags instanceof FeatureFlags) { + return $default; + } + + if ($variant === null) { + return $flags->isEnabled($key, $default); + } + + $resolved = $flags->variant($key); + + return $resolved === null ? $default : $resolved === $variant; + } catch (Throwable) { + return $default; + } + } + + public function disabled(string $key): FeatureFlagDisabledException + { + return new FeatureFlagDisabledException($key, $this->disabledStatus); + } +} diff --git a/packages/feature-flags/src/Gating/FeatureFlagMethodDescriptor.php b/packages/feature-flags/src/Gating/FeatureFlagMethodDescriptor.php new file mode 100644 index 00000000..d72999fa --- /dev/null +++ b/packages/feature-flags/src/Gating/FeatureFlagMethodDescriptor.php @@ -0,0 +1,49 @@ +class.'::'.$this->method; + } + + public function middleware(): string + { + return FeatureFlagGate::MIDDLEWARE_ALIAS.':'.$this->key.','.($this->variant ?? '').','.($this->default ? 'true' : 'false'); + } + + /** @return FeatureFlagMethodRow */ + public function toArray(): array + { + return [ + 'class' => $this->class, + 'method' => $this->method, + 'key' => $this->key, + 'variant' => $this->variant, + 'default' => $this->default, + 'fallback' => $this->fallback, + 'route' => $this->route, + ]; + } + + /** @param FeatureFlagMethodRow $data */ + public static function fromArray(array $data): self + { + return new self($data['class'], $data['method'], $data['key'], $data['variant'] ?? null, $data['default'] ?? false, $data['fallback'] ?? null, $data['route'] ?? false); + } +} diff --git a/packages/feature-flags/src/Gating/FeatureFlagMethodInterceptor.php b/packages/feature-flags/src/Gating/FeatureFlagMethodInterceptor.php new file mode 100644 index 00000000..09df7491 --- /dev/null +++ b/packages/feature-flags/src/Gating/FeatureFlagMethodInterceptor.php @@ -0,0 +1,71 @@ +> */ + private WeakMap $fallbacks; + + public function __construct( + private readonly FeatureFlagGate $gate, + private readonly RouteGateDecisions $decisions, + ) { + $this->fallbacks = new WeakMap; + } + + public function invoke(MethodInvocation $invocation): mixed + { + $rule = $invocation->descriptor(FeatureFlagMethodDescriptor::class); + if ($rule === null) { + return $invocation->proceed(); + } + + $receiver = $invocation->getThis(); + $method = strtolower($invocation->getMethod()); + if (($this->fallbacks[$receiver][$method] ?? 0) > 0) { + return $invocation->proceed(); + } + + if ($rule->route && $rule->fallback === null && $this->decisions->passed($rule)) { + return $invocation->proceed(); + } + + if ($this->gate->allows($rule->key, $rule->variant, $rule->default)) { + return $invocation->proceed(); + } + + if ($rule->fallback === null) { + throw $this->gate->disabled($rule->key); + } + + $fallbackMethod = strtolower($rule->fallback); + $active = $this->fallbacks[$receiver] ?? []; + $active[$fallbackMethod] = ($active[$fallbackMethod] ?? 0) + 1; + $this->fallbacks[$receiver] = $active; + + try { + /** @var callable $fallback */ + $fallback = [$receiver, $rule->fallback]; + + return $fallback(...$invocation->getArguments()); + } finally { + $active = $this->fallbacks[$receiver]; + if (--$active[$fallbackMethod] === 0) { + unset($active[$fallbackMethod]); + } + if ($active === []) { + unset($this->fallbacks[$receiver]); + } else { + $this->fallbacks[$receiver] = $active; + } + } + } +} diff --git a/packages/feature-flags/src/Gating/FeatureFlagMiddleware.php b/packages/feature-flags/src/Gating/FeatureFlagMiddleware.php new file mode 100644 index 00000000..bd8df191 --- /dev/null +++ b/packages/feature-flags/src/Gating/FeatureFlagMiddleware.php @@ -0,0 +1,31 @@ +gate->allows($key, $variantName, $defaultEnabled)) { + throw $this->gate->disabled($key); + } + + $this->decisions->record($request, $key, $variantName, $defaultEnabled); + + return $next($request); + } +} diff --git a/packages/feature-flags/src/Gating/FeatureFlagRouteGatingPass.php b/packages/feature-flags/src/Gating/FeatureFlagRouteGatingPass.php new file mode 100644 index 00000000..63ff8cdf --- /dev/null +++ b/packages/feature-flags/src/Gating/FeatureFlagRouteGatingPass.php @@ -0,0 +1,84 @@ +container; + /** @var Router $router */ + $router = $container->make('router'); + $router->aliasMiddleware(FeatureFlagGate::MIDDLEWARE_ALIAS, FeatureFlagMiddleware::class); + + if (! $container->bound(ProxyPlan::class) || ! $container->bound(RouteManifest::class)) { + return; + } + + $rules = []; + $plan = $container->make(ProxyPlan::class); + foreach ($plan->classes() as $class) { + foreach ($plan->methodsFor($class) as $method => $rows) { + foreach ($rows as $row) { + if ($row['advice'] !== FeatureFlagAdviceSource::ID || ($row['row']['route'] ?? false) !== true || ($row['row']['fallback'] ?? null) !== null) { + continue; + } + + /** @var array{class: string, method: string, key: string, variant?: string|null, default?: bool, fallback?: string|null, route?: bool} $data */ + $data = $row['row']; + $rules[$class.'::'.$method] = FeatureFlagMethodDescriptor::fromArray($data); + } + } + } + + if ($rules === []) { + return; + } + + $decisions = $container->make(RouteGateDecisions::class); + $routes = []; + foreach ($router->getRoutes()->getRoutes() as $route) { + if ($route->getDomain() !== null) { + continue; + } + foreach ($route->methods() as $verb) { + if (! is_string($verb)) { + continue; + } + + $routes[$verb.' '.$route->uri()] = $route; + } + } + + foreach ($container->make(RouteManifest::class)->all() as $descriptor) { + $rule = $rules[$descriptor->controllerClass.'::'.$descriptor->methodName] ?? null; + $uri = trim($descriptor->path, '/'); + $route = $routes[strtoupper($descriptor->httpMethod).' '.($uri === '' ? '/' : $uri)] ?? null; + + if ($rule !== null && $route !== null) { + $decisions->register($route, $rule); + $route->middleware($rule->middleware()); + } + } + } +} diff --git a/packages/feature-flags/src/Gating/FeatureFlagsGatingConfiguration.php b/packages/feature-flags/src/Gating/FeatureFlagsGatingConfiguration.php new file mode 100644 index 00000000..f5895e64 --- /dev/null +++ b/packages/feature-flags/src/Gating/FeatureFlagsGatingConfiguration.php @@ -0,0 +1,40 @@ + */ + private WeakMap $routes; + + public function __construct(private readonly Container $app) + { + $this->routes = new WeakMap; + } + + public function register(Route $route, FeatureFlagMethodDescriptor $rule): void + { + $this->routes[$route] = $rule; + } + + public function record(Request $request, string $key, ?string $variant, bool $default): void + { + $route = $request->route(); + if (! $route instanceof Route) { + return; + } + + $rule = $this->routes[$route] ?? null; + if ($rule !== null && $rule->key === $key && $rule->variant === $variant && $rule->default === $default) { + $request->attributes->set(self::ATTRIBUTE, $rule); + } + } + + public function passed(FeatureFlagMethodDescriptor $rule): bool + { + if (! $this->app->bound('request')) { + return false; + } + + $request = $this->app->make('request'); + $route = $request->route(); + if (! $route instanceof Route) { + return false; + } + + $routedRule = $this->routes[$route] ?? null; + + return $routedRule !== null + && $request->attributes->get(self::ATTRIBUTE) === $routedRule + && $routedRule->toArray() === $rule->toArray(); + } +} diff --git a/packages/feature-flags/src/Management/FeatureFlagsHealthIndicator.php b/packages/feature-flags/src/Management/FeatureFlagsHealthIndicator.php new file mode 100644 index 00000000..dae59caa --- /dev/null +++ b/packages/feature-flags/src/Management/FeatureFlagsHealthIndicator.php @@ -0,0 +1,73 @@ +today = $today ?? static fn (): string => gmdate('Y-m-d'); + } + + public function available(): bool + { + return $this->registry() !== null; + } + + public function health(): Health + { + $registry = $this->registry(); + if ($registry === null) { + return Health::unknown(); + } + + $composition = $registry->composition(false); + $sources = []; + $down = false; + foreach ($registry->states() as $state) { + $sources[$state->name] = [ + 'status' => $state->status(), + 'flags' => $state->flags, + 'lastRefresh' => $state->lastRefresh, + 'error' => $state->error, + ]; + $down = $down || ! $state->loaded; + } + + $details = [ + 'sources' => $sources, + 'flags' => count($composition->flags), + 'expired' => FlagDefinitions::expiredKeys($composition->document()->flags, ($this->today)()), + ]; + + return $down ? Health::down($details) : Health::up($details); + } + + private function registry(): ?FlagRegistry + { + $registry = $this->beans->has(FlagRegistry::class) ? $this->beans->get(FlagRegistry::class) : null; + + return $registry instanceof FlagRegistry ? $registry : null; + } +} diff --git a/packages/feature-flags/src/Management/FeatureFlagsManagementConfiguration.php b/packages/feature-flags/src/Management/FeatureFlagsManagementConfiguration.php new file mode 100644 index 00000000..4a780936 --- /dev/null +++ b/packages/feature-flags/src/Management/FeatureFlagsManagementConfiguration.php @@ -0,0 +1,62 @@ +has(FlagRegistry::class) ? $beans->get(FlagRegistry::class) : null; + $writer = $beans->has(FlagStoreWriter::class) ? $beans->get(FlagStoreWriter::class) : null; + + $preview = array_values(array_filter( + $beans->getAll(EvaluationContextContributor::class), + static fn (object $contributor): bool => $contributor instanceof ApplicationEvaluationContextContributor, + )); + $actors = array_values(array_filter( + $beans->getAll(FlagActorSource::class), + static fn (object $source): bool => $source instanceof FlagActorSource, + )); + + /** @var list $preview */ + /** @var list $actors */ + return new FlagManagement( + $settings, + $provider, + $registry instanceof FlagRegistry ? $registry : null, + $writer instanceof FlagStoreWriter ? $writer : null, + new EvaluationContextResolver($preview), + $actors, + logger: $logger ?? new NullLogger, + ); + } +} diff --git a/packages/feature-flags/src/Management/FlagActorSource.php b/packages/feature-flags/src/Management/FlagActorSource.php new file mode 100644 index 00000000..7107125a --- /dev/null +++ b/packages/feature-flags/src/Management/FlagActorSource.php @@ -0,0 +1,15 @@ + FeatureFlags); without one the caller's fallback is + * recorded — `actuator`, `admin` or `cli:` (spec §4.8). + */ +interface FlagActorSource +{ + public function actor(): ?string; +} diff --git a/packages/feature-flags/src/Management/FlagManagement.php b/packages/feature-flags/src/Management/FlagManagement.php new file mode 100644 index 00000000..694ac275 --- /dev/null +++ b/packages/feature-flags/src/Management/FlagManagement.php @@ -0,0 +1,413 @@ + */ + public const array WRITE_ACTIONS = ['enable', 'disable', 'default-variant', 'put', 'delete']; + + /** @var Closure(): string */ + private readonly Closure $today; + + /** + * @param list $actors + * @param (Closure(): string)|null $today the UTC date, Y-m-d + */ + public function __construct( + private readonly FeatureFlagsSettings $settings, + private readonly Provider $provider, + private readonly ?FlagRegistry $registry = null, + private readonly ?FlagStoreWriter $writer = null, + private readonly EvaluationContextResolver $preview = new EvaluationContextResolver, + private readonly array $actors = [], + ?Closure $today = null, + private readonly LoggerInterface $logger = new NullLogger, + ) { + $this->today = $today ?? static fn (): string => gmdate('Y-m-d'); + } + + public function writable(): bool + { + return $this->writer !== null; + } + + public function writesEnabled(): bool + { + return $this->settings->writes; + } + + /** + * `GET /actuator/flags`. + * + * @return array{provider: array{name: string, status: string}, writable: bool, writesEnabled: bool, sources: list>, flags: list>} + */ + public function overview(): array + { + $sources = []; + $flags = []; + + if ($this->registry !== null) { + $composition = $this->registry->composition(); + foreach ($this->registry->states() as $state) { + $sources[] = [ + 'name' => $state->name, + 'enabled' => true, + 'status' => $state->status(), + 'flags' => $state->flags, + 'lastRefresh' => $state->lastRefresh, + 'error' => $state->error, + 'revision' => $this->registry->source($state->name)?->reportedRevision($state->revision), + ]; + } + + $versions = []; + try { + foreach ($this->writer?->store()->all() ?? [] as $stored) { + $versions[$stored->key] = $stored->version; + } + } catch (Throwable $failure) { + // An operator can still inspect the last-good composition during a store outage. + $this->warn('Flag store versions are unavailable for the management overview: {error}.', null, $failure); + } + + $today = ($this->today)(); + foreach ($composition->flags as $flag) { + $flags[] = $this->summary($flag, $versions[$flag->definition->key] ?? null, $today); + } + } + + return [ + 'provider' => ['name' => $this->provider->getMetadata()->getName(), 'status' => 'READY'], + 'writable' => $this->writable(), + 'writesEnabled' => $this->writesEnabled(), + 'sources' => $sources, + 'flags' => $flags, + ]; + } + + /** + * `GET /actuator/flags/{key}`. + * + * @return array{key: string, definition: stdClass|array, origin: string, layers: list}>, version: int|null, expired: bool, history: list} + * + * @throws FlagManagementException + */ + public function describe(string $key): array + { + $flag = $this->composed($key); + $store = $this->writer?->store(); + $version = null; + $history = []; + if ($store !== null) { + try { + $version = $store->get($key)?->version; + $history = array_map(static fn (FlagChange $change): array => $change->toHistoryRow(), $store->history($key, self::HISTORY_LIMIT)); + } catch (Throwable $failure) { + // Store diagnostics must not obscure a visible last-good definition. + $this->warn('Flag store diagnostics are unavailable for [{key}]: {error}.', $key, $failure); + } + } + + return [ + 'key' => $key, + 'definition' => $flag->definition->toJsonValue(), + 'origin' => $flag->origin, + 'layers' => array_map( + static fn (array $layer): array => ['source' => $layer['source'], 'definition' => $layer['definition']->toJsonValue()], + $flag->layers, + ), + 'version' => $version, + 'expired' => $flag->definition->isExpired(($this->today)()), + 'history' => $history, + ]; + } + + /** + * The `evaluate` action: explicit context only (plus application/profiles), no hooks. + * + * @param array $context + * @return array{key: string, value: mixed, variant: ?string, reason: string, errorCode: ?string, metadata: stdClass|array} + * + * @throws FlagManagementException + */ + public function evaluate(string $key, array $context = [], ?string $targetingKey = null): array + { + $evaluationContext = $this->preview->resolve($context, $targetingKey, ambient: false); + + if ($this->provider instanceof FireflyFlagProvider && $this->registry !== null) { + $type = $this->composed($key)->definition->flagType(); + + $default = $type === FlagType::Object ? new stdClass : $type->zero(); + + return FlagEvaluation::fromResolution($key, $this->provider->resolution($key, $type, $default, $evaluationContext))->toArray(); + } + + // External-provider mode: Firefly knows no flag types, so the preview is a boolean evaluation, default false. + $details = $this->provider->resolveBooleanValue($key, false, $evaluationContext); + $error = $details->getError(); + + return (new FlagEvaluation( + $key, + $details->getValue(), + $error === null ? $details->getVariant() : null, + $error === null ? ($details->getReason() ?? 'UNKNOWN') : 'ERROR', + $error?->getResolutionErrorCode()->getValue(), + $error?->getResolutionErrorMessage(), + ))->toArray(); + } + + /** + * `POST /actuator/flags/{key}`: `evaluate`, or one of the WRITE_ACTIONS. Answers the key's describe() body + * after a write, or `{"key", "deleted": true}` when a delete leaves no layer defining the key. + * + * @param mixed $body the decoded request body (Json's faithful form, or a plain array from a form) + * @param string $fallbackActor recorded when no FlagActorSource names the caller + * @return array + * + * @throws FlagManagementException + */ + public function apply(string $key, mixed $body, string $fallbackActor): array + { + if (! Json::isObject($body)) { + throw new FlagManagementException(ManagementError::BadRequest, 'The request body must be a JSON object naming an action.'); + } + + $members = Json::members($body); + $action = $members['action'] ?? null; + if (! is_string($action) || ($action !== 'evaluate' && ! in_array($action, self::WRITE_ACTIONS, true))) { + throw new FlagManagementException(ManagementError::BadRequest, 'The action must be one of evaluate, '.implode(', ', self::WRITE_ACTIONS).'.'); + } + + if ($action === 'evaluate') { + return $this->evaluate($key, self::context($members['context'] ?? null), self::targetingKey($members['targetingKey'] ?? null)); + } + + $writer = $this->writer(); + $expected = self::expectedVersion($members['expectedVersion'] ?? null); + $actor = $this->actor($fallbackActor); + + try { + switch ($action) { + case 'put': + if (! array_key_exists('definition', $members)) { + throw new FlagManagementException(ManagementError::BadRequest, 'put needs a definition: one flagd flag object.'); + } + $writer->put($key, $members['definition'], $actor, $expected); + break; + + case 'delete': + if ($writer->store()->get($key) === null) { + throw new FlagManagementException(ManagementError::UnknownFlag, "The store holds no flag [{$key}]; only a stored flag can be deleted."); + } + $writer->delete($key, $actor, $expected); + break; + + default: + $writer->put($key, $this->changed($key, $action, $members), $actor, $expected); + } + } catch (InvalidFlagDefinition $invalid) { + throw new FlagManagementException(ManagementError::InvalidDefinition, $invalid->getMessage(), $invalid); + } catch (FlagStoreConflict $conflict) { + throw new FlagManagementException(ManagementError::Conflict, $conflict->getMessage(), $conflict); + } + + try { + return $this->describe($key); + } catch (Throwable $failure) { + $this->warn('Flag management detail is unavailable after an accepted write to [{key}]: {error}.', $key, $failure); + + return ['key' => $key, $action === 'delete' ? 'deleted' : 'refreshPending' => true]; + } + } + + /** + * The definition `enable`, `disable` or `default-variant` writes: the stored one, or else the effective one. + * + * @param array $members + * @return array + * + * @throws FlagManagementException + */ + private function changed(string $key, string $action, array $members): array + { + $stored = $this->writer?->store()->get($key); + $composed = $this->registry?->composition(false)->flag($key); + $base = $stored !== null + ? FlagDefinition::fromJsonValue($key, $stored->definition) + : ($composed === null + ? throw new FlagManagementException(ManagementError::UnknownFlag, "No layer defines flag [{$key}].") + : $composed->definition); + + $definition = Json::members($base->toJsonValue()); + + if ($action === 'default-variant') { + $variant = $members['variant'] ?? null; + if (! is_string($variant) || $variant === '') { + throw new FlagManagementException(ManagementError::BadRequest, 'default-variant needs a variant: the name of one of the flag\'s variants.'); + } + if (! $base->hasVariant($variant)) { + throw new FlagManagementException(ManagementError::UnknownVariant, "Flag [{$key}] has no variant [{$variant}]; its variants are ".implode(', ', $base->variantNames()).'.'); + } + $definition['defaultVariant'] = $variant; + + return $definition; + } + + $definition['state'] = $action === 'enable' ? 'ENABLED' : 'DISABLED'; + + return $definition; + } + + /** + * @throws FlagManagementException + */ + private function composed(string $key): ComposedFlag + { + return $this->registry?->composition()->flag($key) + ?? throw new FlagManagementException(ManagementError::UnknownFlag, "No layer defines flag [{$key}]."); + } + + /** + * @throws FlagManagementException + */ + private function writer(): FlagStoreWriter + { + if (! $this->settings->writes) { + throw new FlagManagementException(ManagementError::WritesDisabled, 'Flag writes are disabled: set firefly.feature-flags.management.writes to true.'); + } + + return $this->writer + ?? throw new FlagManagementException(ManagementError::NotWritable, 'No flag store is configured: enable firefly.feature-flags.sources.store to make flags writable.'); + } + + private function actor(string $fallback): string + { + foreach ($this->actors as $source) { + try { + $actor = $source->actor(); + } catch (Throwable) { + $actor = null; + } + + if ($actor !== null && $actor !== '') { + return $actor; + } + } + + return $fallback; + } + + private function warn(string $message, ?string $key, Throwable $failure): void + { + try { + $this->logger->warning($message, ['key' => $key, 'error' => $failure->getMessage()]); + } catch (Throwable) { + // A diagnostic failure or logger failure cannot undo an accepted write. + } + } + + /** + * @return array + */ + private function summary(ComposedFlag $flag, ?int $version, string $today): array + { + $definition = $flag->definition; + + return [ + 'key' => $definition->key, + 'state' => $definition->state(), + 'type' => $definition->valueType(), + 'variants' => $definition->variantNames(), + 'defaultVariant' => $definition->defaultVariant(), + 'targeting' => $definition->targeting() !== null, + 'origin' => $flag->origin, + 'overrides' => $flag->overrides, + 'metadata' => Json::object($definition->metadata()), + 'expired' => $definition->isExpired($today), + 'version' => $version, + ]; + } + + /** + * @return array + * + * @throws FlagManagementException + */ + private static function context(mixed $context): array + { + if ($context === null) { + return []; + } + + if (! Json::isObject($context)) { + throw new FlagManagementException(ManagementError::BadRequest, 'context must be an object of evaluation attributes.'); + } + + $attributes = []; + foreach (Json::members($context) as $name => $value) { + $attributes[(string) $name] = Json::toPhp($value); + } + + return $attributes; + } + + /** + * @throws FlagManagementException + */ + private static function targetingKey(mixed $targetingKey): ?string + { + if ($targetingKey !== null && ! is_string($targetingKey)) { + throw new FlagManagementException(ManagementError::BadRequest, 'targetingKey must be a string.'); + } + + return $targetingKey; + } + + /** + * @throws FlagManagementException + */ + private static function expectedVersion(mixed $expected): ?int + { + if ($expected !== null && (! is_int($expected) || $expected < 0)) { + throw new FlagManagementException(ManagementError::BadRequest, 'expectedVersion must be a non-negative integer.'); + } + + return $expected; + } +} diff --git a/packages/feature-flags/src/Management/FlagManagementException.php b/packages/feature-flags/src/Management/FlagManagementException.php new file mode 100644 index 00000000..8eec5758 --- /dev/null +++ b/packages/feature-flags/src/Management/FlagManagementException.php @@ -0,0 +1,25 @@ + $this->error->value, 'message' => $this->getMessage()]; + } +} diff --git a/packages/feature-flags/src/Management/FlagsEndpoint.php b/packages/feature-flags/src/Management/FlagsEndpoint.php new file mode 100644 index 00000000..649ae3c3 --- /dev/null +++ b/packages/feature-flags/src/Management/FlagsEndpoint.php @@ -0,0 +1,87 @@ +beans->has(FlagManagement::class); + } + + public function handle(EndpointRequest $request): ?EndpointResponse + { + $management = $this->beans->has(FlagManagement::class) ? $this->beans->get(FlagManagement::class) : null; + if (! $management instanceof FlagManagement || count($request->subPath) > 1) { + return null; + } + + $key = $request->subPath[0] ?? null; + + try { + if ($request->method === 'GET') { + return self::json($key === null ? $management->overview() : $management->describe($key)); + } + + if ($request->method === 'POST' && $key !== null) { + return self::json($management->apply($key, self::body($request), $request->origin === 'admin' ? 'admin' : 'actuator')); + } + } catch (FlagManagementException $refused) { + return self::json($refused->toArray(), $refused->error->status()); + } + + return null; + } + + /** + * @throws FlagManagementException + */ + private static function body(EndpointRequest $request): mixed + { + if ($request->rawBody === null || trim($request->rawBody) === '') { + return $request->body; + } + + try { + return Json::decode($request->rawBody); + } catch (JsonException) { + throw new FlagManagementException(ManagementError::BadRequest, 'The request body is not valid JSON.'); + } + } + + /** + * @param array $body + */ + private static function json(array $body, int $status = 200): EndpointResponse + { + return EndpointResponse::text(Json::encode($body), $status, 'application/json'); + } +} diff --git a/packages/feature-flags/src/Management/ManagementError.php b/packages/feature-flags/src/Management/ManagementError.php new file mode 100644 index 00000000..b4742b88 --- /dev/null +++ b/packages/feature-flags/src/Management/ManagementError.php @@ -0,0 +1,31 @@ + 403, + self::NotWritable, self::Conflict => 409, + self::InvalidDefinition, self::UnknownVariant => 422, + self::UnknownFlag => 404, + self::BadRequest => 400, + }; + } +} diff --git a/packages/feature-flags/src/Provider/FireflyFlagProvider.php b/packages/feature-flags/src/Provider/FireflyFlagProvider.php new file mode 100644 index 00000000..0f609fdc --- /dev/null +++ b/packages/feature-flags/src/Provider/FireflyFlagProvider.php @@ -0,0 +1,117 @@ +evaluate($flagKey, FlagType::Boolean, $defaultValue, $context)->toResolutionDetails(); + } + + public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluate($flagKey, FlagType::String, $defaultValue, $context)->toResolutionDetails(); + } + + public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluate($flagKey, FlagType::Integer, $defaultValue, $context)->toResolutionDetails(); + } + + public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluate($flagKey, FlagType::Float, $defaultValue, $context)->toResolutionDetails(); + } + + /** + * @param mixed[] $defaultValue + */ + public function resolveObjectValue(string $flagKey, array $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluate($flagKey, FlagType::Object, $defaultValue, $context)->toResolutionDetails(); + } + + /** + * The Firefly view of one evaluation, metadata included (what the SDK's ResolutionDetails cannot carry), with a + * value that is the caller's own copy. No OpenFeature client is involved, so no hook runs: an evaluation made + * here records no metric and no exposure event. + */ + public function resolution(string $flagKey, FlagType $type, mixed $default, ?EvaluationContext $context = null): Resolution + { + $resolution = $this->evaluate($flagKey, $type, $default, $context); + + return $resolution->withValue(ValueCopy::of($resolution->value)); + } + + /** The evaluator's answer; callers defensively copy or convert values from custom evaluators too. */ + private function evaluate(string $flagKey, FlagType $type, mixed $default, ?EvaluationContext $context): Resolution + { + $resolution = $this->attempt($flagKey, $type, $default, $context); + + if ($resolution->error !== null) { + $this->logger?->debug('Feature flag [{flag}] evaluated with {error}: {message}', [ + 'flag' => $flagKey, + 'error' => $resolution->error->value, + 'message' => $resolution->errorMessage, + ]); + } + + return $resolution; + } + + private function attempt(string $flagKey, FlagType $type, mixed $default, ?EvaluationContext $context): Resolution + { + try { + $document = $this->documents->document(); + } catch (Throwable $failure) { + return Resolution::error($default, EvaluationError::General, 'The flag set could not be read: '.$failure->getMessage()); + } + + try { + return $this->evaluator->evaluate( + $document, + $flagKey, + $type, + $default, + $context?->getTargetingKey(), + $context?->getAttributes()->toArray() ?? [], + ); + } catch (Throwable $failure) { + return Resolution::error($default, EvaluationError::General, "Evaluating flag [{$flagKey}] failed: {$failure->getMessage()}"); + } + } +} diff --git a/packages/feature-flags/src/Provider/ValueCopy.php b/packages/feature-flags/src/Provider/ValueCopy.php new file mode 100644 index 00000000..14a7da5b --- /dev/null +++ b/packages/feature-flags/src/Provider/ValueCopy.php @@ -0,0 +1,153 @@ + $objects */ + $objects = new SplObjectStorage; + + $arrays = []; + + return self::copy($value, 1, $objects, $arrays); + } + + /** Exposure-only budget: containers and scalars count once per occurrence, including repeated references. */ + public static function forExposure(mixed $value): mixed + { + $remaining = 10000; + $stack = [self::members([$value])]; + while ($stack !== []) { + $iterator = $stack[array_key_last($stack)]; + if (! $iterator->valid()) { + array_pop($stack); + + continue; + } + if ($remaining-- === 0) { + throw new UnexpectedValueException('Exposure snapshot exceeds 10000 value occurrences'); + } + $member = $iterator->current(); + $iterator->next(); + if (is_array($member) || $member instanceof stdClass) { + $stack[] = self::members($member); + } + } + + /** @var SplObjectStorage $objects */ + $objects = new SplObjectStorage; + $arrays = []; + + // Native array equality can expand compact graphs again; the bounded snapshot needs no array memo. + return self::copy($value, 1, $objects, $arrays, false); + } + + /** @return Generator */ + private static function members(mixed $value): Generator + { + if ($value instanceof stdClass) { + $value = get_object_vars($value); + } + if (is_array($value)) { + foreach ($value as $member) { + yield $member; + } + } + } + + /** + * @param SplObjectStorage $objects + * @param array, array}>> $arrays + */ + private static function copy(mixed $value, int $depth, SplObjectStorage $objects, array &$arrays, bool $memoizeArrays = true): mixed + { + if ($value instanceof stdClass && $objects->offsetExists($value)) { + return $objects[$value]; + } + if ($depth > self::MAX_DEPTH) { + throw new UnexpectedValueException('Flag value exceeds the snapshot depth limit'); + } + + if ($value instanceof stdClass) { + $copy = new stdClass; + $objects[$value] = $copy; + foreach (get_object_vars($value) as $name => $member) { + $copy->{$name} = self::copy($member, $depth + 1, $objects, $arrays, $memoizeArrays); + } + + return $copy; + } + + if (is_array($value)) { + if ($memoizeArrays) { + $signature = self::arraySignature($value); + foreach ($arrays[$signature] ?? [] as [$original, $snapshot]) { + if ($original === $value) { + return $snapshot; + } + } + } + + $copy = []; + foreach ($value as $key => $member) { + if (is_array($member) && ReflectionReference::fromArrayElement($value, $key) !== null) { + throw new UnexpectedValueException('Flag value contains unsupported array references'); + } + $copy[$key] = self::copy($member, $depth + 1, $objects, $arrays, $memoizeArrays); + } + if ($memoizeArrays) { + $arrays[$signature][] = [$value, $copy]; + } + + return $copy; + } + + return $value; + } + + /** @param array $value */ + private static function arraySignature(array $value): string + { + $shape = []; + foreach ($value as $key => $member) { + if (is_array($member) && ReflectionReference::fromArrayElement($value, $key) !== null) { + throw new UnexpectedValueException('Flag value contains unsupported array references'); + } + $shape[] = [$key, get_debug_type($member), match (true) { + is_array($member) => count($member), + is_object($member) => spl_object_id($member), + is_resource($member) => get_resource_id($member), + default => $member, + }]; + } + + return hash('sha256', serialize($shape)); + } +} diff --git a/packages/feature-flags/src/Registry/CacheBook.php b/packages/feature-flags/src/Registry/CacheBook.php new file mode 100644 index 00000000..a2b4fd86 --- /dev/null +++ b/packages/feature-flags/src/Registry/CacheBook.php @@ -0,0 +1,142 @@ +cache->get(self::key($key)); + $this->recovered(); + + return $value; + } catch (Throwable $failure) { + $this->degrade($failure); + + return null; + } + } + + public function put(string $key, mixed $value): void + { + try { + $this->cache->forever(self::key($key), $value); + $this->recovered(); + } catch (Throwable $failure) { + $this->degrade($failure); + } + } + + /** True for exactly one caller per key and window (Cache::add); every caller while the store is down. */ + public function first(string $key, int $seconds): bool + { + try { + $first = $this->cache->add(self::key($key), true, max(1, $seconds)); + $this->recovered(); + + return $first; + } catch (Throwable $failure) { + $this->degrade($failure); + + return true; + } + } + + /** + * A non-blocking lock: the release closure, or null when another process holds it. A store without atomic + * locks (or a store that is down) grants a no-op lock rather than refusing every refresh. + * + * @return (Closure(): void)|null + */ + public function lock(string $key, int $seconds): ?Closure + { + try { + $store = $this->cache->getStore(); + if (! $store instanceof LockProvider) { + $this->recovered(); + + return static function (): void {}; + } + + $lock = $store->lock(self::key($key), max(1, $seconds)); + $acquired = $lock->get() === true; + $this->recovered(); + + return $acquired ? function () use ($lock): void { + try { + $lock->release(); + } catch (Throwable $failure) { + $this->degrade($failure); // the lock expires on its own; the refresh that held it stands + } + } : null; + } catch (Throwable $failure) { + $this->degrade($failure); + + return static function (): void {}; + } + } + + public function degraded(): bool + { + return $this->degraded; + } + + /** The full cache key: every entry of the bookkeeping lives under PREFIX. */ + private static function key(string $key): string + { + return self::PREFIX.$key; + } + + private function recovered(): void + { + $this->degraded = false; + $this->warned = false; + } + + private function degrade(Throwable $failure): void + { + $this->degraded = true; + if ($this->warned) { + return; + } + + $this->warned = true; + $this->logger->warning('Feature flags cannot reach the cache store ({error}); each process loads its flag sources itself until the store is back.', ['error' => $failure->getMessage()]); + } +} diff --git a/packages/feature-flags/src/Registry/ComposedFlag.php b/packages/feature-flags/src/Registry/ComposedFlag.php new file mode 100644 index 00000000..86b2cde8 --- /dev/null +++ b/packages/feature-flags/src/Registry/ComposedFlag.php @@ -0,0 +1,22 @@ + $overrides the lower layers that also define the key, lowest first + * @param list $layers every layer defining the key, lowest first + */ + public function __construct( + public FlagDefinition $definition, + public string $origin, + public array $overrides, + public array $layers, + ) {} +} diff --git a/packages/feature-flags/src/Registry/Composer.php b/packages/feature-flags/src/Registry/Composer.php new file mode 100644 index 00000000..a940f638 --- /dev/null +++ b/packages/feature-flags/src/Registry/Composer.php @@ -0,0 +1,48 @@ + $layers source name and document, lowest precedence first + */ + public static function compose(array $layers): Composition + { + $byKey = []; + $evaluators = []; + $metadata = []; + + foreach ($layers as [$source, $document]) { + foreach ($document->flags as $definition) { + $byKey[$definition->key][] = ['source' => $source, 'definition' => $definition]; + } + foreach ($document->evaluators as $name => $rule) { + $evaluators[$name] = $rule; + } + $metadata = array_replace($metadata, $document->metadata); + } + + $flags = []; + foreach ($byKey as $key => $layersOfKey) { + $winner = $layersOfKey[array_key_last($layersOfKey)]; + $flags[(string) $key] = new ComposedFlag( + $winner['definition'], + $winner['source'], + array_map(static fn (array $layer): string => $layer['source'], array_slice($layersOfKey, 0, -1)), + $layersOfKey, + ); + } + ksort($flags, SORT_STRING); + + return new Composition($flags, $evaluators, $metadata); + } +} diff --git a/packages/feature-flags/src/Registry/Composition.php b/packages/feature-flags/src/Registry/Composition.php new file mode 100644 index 00000000..f51e6a85 --- /dev/null +++ b/packages/feature-flags/src/Registry/Composition.php @@ -0,0 +1,128 @@ + $flags keyed by flag key, sorted as text + * @param array $evaluators + * @param array $metadata + */ + public function __construct( + public array $flags = [], + public array $evaluators = [], + public array $metadata = [], + ) {} + + public function flag(string $key): ?ComposedFlag + { + return $this->flags[$key] ?? null; + } + + public function document(): FlagDocument + { + $definitions = []; + foreach ($this->flags as $flag) { + $definitions[$flag->definition->key] = $flag->definition; + } + + return new FlagDocument($definitions, $this->evaluators, $this->metadata); + } + + /** + * Flag key => sha256 of everything an evaluation of that key reads, sorted by key as text: the whole composed + * definition (fields flagd does not read included), the metadata it reports (the document's, overridden key by key + * by its own) and every evaluator its targeting references, directly or through another evaluator (a name no + * evaluator has counts as absent). Values compare as JSON does: `true` is not `1` and `1` is not `1.0`. Two + * compositions answer a key differently only if its fingerprint differs, so FeatureFlagsChanged names exactly the + * keys whose fingerprint moved. A numeric-looking key is an int key: read it with `(string) $key`. + * + * @return array + * + * @throws \JsonException as Json::encode() (never for a validated document) + */ + public function fingerprints(): array + { + $fingerprints = []; + foreach ($this->flags as $flag) { + $definition = $flag->definition; + $fingerprints[$definition->key] = hash('sha256', Json::canonical([ + 'definition' => $definition->toJsonValue(), + 'metadata' => Json::object(array_replace($this->metadata, $definition->metadata())), + 'evaluators' => Json::object($this->evaluatorsUsedBy($definition)), + ])); + } + ksort($fingerprints, SORT_STRING); + + return $fingerprints; + } + + /** + * The evaluators $definition's targeting depends on, transitively: name => rule, null for a name no evaluator has. + * References resolve inside targeting only (spec §4.1), so a variant that looks like one is a value. + * + * @return array + */ + private function evaluatorsUsedBy(FlagDefinition $definition): array + { + $used = []; + $pending = self::references($definition->targeting()); + while ($pending !== []) { + $name = array_pop($pending); + if (array_key_exists($name, $used)) { + continue; + } + $rule = $this->evaluators[$name] ?? null; + $used[$name] = $rule; + if ($rule !== null) { + array_push($pending, ...self::references($rule)); + } + } + + return $used; + } + + /** + * Every evaluator name $value references: a `{"$ref": name}` object with exactly one member, a text value, at any + * depth (an object that also holds other members is not a reference). Walks with a stack of its own. + * + * @return list + */ + private static function references(mixed $value): array + { + $names = []; + $pending = [$value]; + while ($pending !== []) { + $node = array_pop($pending); + if ($node instanceof stdClass) { + $node = get_object_vars($node); + } + if (! is_array($node)) { + continue; + } + if (count($node) === 1 && is_string($node['$ref'] ?? null)) { + $names[] = $node['$ref']; + + continue; + } + foreach ($node as $child) { + $pending[] = $child; + } + } + + return $names; + } +} diff --git a/packages/feature-flags/src/Registry/FlagDocumentSource.php b/packages/feature-flags/src/Registry/FlagDocumentSource.php new file mode 100644 index 00000000..a554a8da --- /dev/null +++ b/packages/feature-flags/src/Registry/FlagDocumentSource.php @@ -0,0 +1,13 @@ + */ + private readonly array $sources; + + /** @var Closure(): float */ + private readonly Closure $clock; + + private ?Composition $shared = null; + + /** The shared composition with the test layer on top; null without test overrides. */ + private ?Composition $effective = null; + + /** The effective composition's document, built once per composition. */ + private ?FlagDocument $document = null; + + /** @var list the layers of the shared composition, lowest precedence first */ + private array $layers = []; + + /** @var list those layers as source name and document JSON (unchanged = same composition) */ + private array $signature = []; + + private float $memoUntil = 0.0; + + private ?FlagDocument $testOverrides = null; + + /** @var array what this process last knew, for when the cache store knows nothing */ + private array $states = []; + + /** @var array per source, the last document JSON decoded and its document */ + private array $documents = []; + + /** The last composition could not be recorded (another worker held the record lock): record it at the next refresh. */ + private bool $recordPending = false; + + /** + * @param iterable $sources + * @param (Closure(): float)|null $clock Unix seconds + * + * @throws ConfigurationException two sources share a name, or a source takes a name the registry reserves + */ + public function __construct( + iterable $sources, + private readonly CacheBook $book, + private readonly ApplicationEventPublisher $events, + private readonly LoggerInterface $logger = new NullLogger, + ?Closure $clock = null, + ) { + $ordered = [...$sources]; + usort($ordered, static fn (FlagSource $a, FlagSource $b): int => $a->precedence() <=> $b->precedence()); + + $names = []; + foreach ($ordered as $source) { + $name = $source->name(); + if (in_array($name, [self::TEST_LAYER, self::TEST_OVERRIDES_ORIGIN, self::STARTUP_ORIGIN, self::RECORD], true)) { + throw new ConfigurationException(sprintf('A feature flag source cannot be named [%s]: the registry reserves %s, %s and %s for its own origins and %s for its shared record.', $name, self::TEST_LAYER, self::TEST_OVERRIDES_ORIGIN, self::STARTUP_ORIGIN, self::RECORD)); + } + if (isset($names[$name])) { + throw new ConfigurationException(sprintf('Two feature flag sources are named [%s]: every source needs a name of its own (it keys the source\'s shared state).', $name)); + } + $names[$name] = true; + } + + $this->sources = $ordered; + $this->clock = $clock ?? static fn (): float => microtime(true); + } + + /** + * Boot: check what is due, then warn once a UTC day across every worker per expired flag. + * + * @throws InvalidFlagDefinition the configuration, or a flag file that never loaded, breaks a rule: names the + * flag key, the reason and the source + * @throws FlagSourceUnavailable a flag file that never loaded cannot be read + */ + public function start(): void + { + $composition = $this->shared(refresh: true); + $today = gmdate('Y-m-d', (int) floor(($this->clock)())); + $definitions = array_map(static fn (ComposedFlag $flag) => $flag->definition, $composition->flags); + + foreach (FlagDefinitions::expiredKeys($definitions, $today) as $key) { + $flag = $composition->flag($key); + if ($flag !== null && $this->book->first('expired:'.$today.':'.$key, 86400)) { + $this->logger->warning('Feature flag [{key}] expired on {expires} and is still defined by [{origin}]: remove it, or move its expires date.', [ + 'key' => $key, + 'expires' => $flag->definition->expires(), + 'origin' => $flag->origin, + ]); + } + } + } + + /** The effective document the provider evaluates: every source, test overrides on top. */ + public function document(): FlagDocument + { + $composition = $this->composition(); + + return $this->document ??= $composition->document(); + } + + /** + * @param bool $includeTestOverrides false for what this process serves to others (sync endpoint, health) + */ + public function composition(bool $includeTestOverrides = true): Composition + { + $shared = $this->shared(); + + return $includeTestOverrides ? ($this->effective ?? $shared) : $shared; + } + + /** + * Check every source that is due (or forced), compose, and announce a change of the effective set. + * + * @param bool $force re-check sources now, even before their refresh-interval elapsed + * @param string|null $only with $force: re-check only this source (a store write refreshes the store alone) + * @return Composition without test overrides + */ + public function refresh(bool $force = false, ?string $only = null): Composition + { + $now = ($this->clock)(); + $loaded = []; + $layers = []; + $moved = []; + + foreach ($this->sources as $source) { + $name = $source->name(); + $before = $this->state($name); + $forced = $force && ($only === null || $only === $name); + $deferred = $source instanceof StoreFlagSource && $source->transactionActive(); + $after = ! $deferred && ($forced || $this->due($source, $before, $now)) ? $this->check($source, $before, $now, $forced) : $before; + $document = $after === null ? null : $this->decoded($after); + + if (! $deferred && $after !== null && $after->loaded && $document === null) { + // A cached entry this process cannot read (corrupt, or written in another shape): unknown, so reload it. + $this->logger->warning('The cached document of feature flag source [{source}] cannot be read; reloading the source.', ['source' => $name]); + $after = $this->check($source, null, $now, forced: true, known: false); + $document = $this->decoded($after); + } + if ($after === null || $after->document === null || $document === null) { + continue; // a source that never loaded contributes nothing + } + if ($before === null || $before->document !== $after->document) { + $moved[] = $name; + } + $this->states[$name] = $after; // the newest document this process serves, loaded here or adopted from the cache + $loaded[] = [$name, $after->document]; + $layers[] = [$name, $document]; + } + + $this->memoUntil = $now + $this->memoSeconds(); + + $shared = $this->shared; + if ($shared === null || $loaded !== $this->signature || $this->recordPending) { + $overridden = $this->testOverrides === null ? null : $this->effective; + $shared = Composer::compose($layers); + $this->layers = $layers; + $this->signature = $loaded; + $this->shared = $shared; + $this->effective = $this->testOverrides === null ? null : Composer::compose([...$layers, [self::TEST_LAYER, $this->testOverrides]]); + $this->document = null; + $this->observe($shared, $loaded, $this->origin($moved, $loaded), $overridden); + } + + return $shared; + } + + /** + * Every registered source's state, in precedence order (a source that never ran yet reports DOWN). + * + * @return list + */ + public function states(): array + { + return array_map(fn (FlagSource $source): SourceState => $this->state($source->name()) ?? SourceState::initial($source->name()), $this->sources); + } + + /** + * @return list + */ + public function sources(): array + { + return $this->sources; + } + + public function source(string $name): ?FlagSource + { + foreach ($this->sources as $source) { + if ($source->name() === $name) { + return $source; + } + } + + return null; + } + + /** + * The test-support layer above every source (FeatureFlagOverrides), replaced as a whole; null removes it. It is + * in-process only, never shared or served. Publishes FeatureFlagsChanged(keys, 'test-overrides') for the keys + * whose effective flag it changes, and nothing when it changes none. + */ + public function overrideForTests(?FlagDocument $overrides): void + { + $shared = $this->shared(); + $before = ($this->effective ?? $shared)->fingerprints(); + + $this->testOverrides = $overrides; + $this->effective = $overrides === null ? null : Composer::compose([...$this->layers, [self::TEST_LAYER, $overrides]]); + $this->document = null; + + $changed = self::changedKeys($before, ($this->effective ?? $shared)->fingerprints()); + if ($changed !== []) { + $this->publish(new FeatureFlagsChanged($changed, self::TEST_OVERRIDES_ORIGIN)); + } + } + + public function testOverrides(): ?FlagDocument + { + return $this->testOverrides; + } + + /** The shared composition, refreshed first when there is none yet, its memo expired, or $refresh asks. */ + private function shared(bool $refresh = false): Composition + { + $shared = $this->shared; + if ($refresh || $shared === null || ($this->clock)() >= $this->memoUntil) { + return $this->refresh(); + } + + return $shared; + } + + /** + * The cache's state of the source, else this process's own (a store outage, an evicted entry). A cached state that + * never loaded does not displace this process's loaded one: it is an evicted entry written back by a worker that + * could not load the source, and the last good document stays in use (and is written back at the next check). + */ + private function state(string $name): ?SourceState + { + $cached = SourceState::fromArray($this->book->get('source:'.$name)); + $own = $this->states[$name] ?? null; + if ($cached === null || $cached->name !== $name) { + return $own; + } + + return ! $cached->loaded && $own !== null && $own->loaded ? $own : $cached; + } + + /** + * The state's document, decoded once per JSON text; null when it has none or cannot be read: not JSON, or another + * shape than the one SourceState::withLoaded() writes (its flag count disagrees). + */ + private function decoded(SourceState $state): ?FlagDocument + { + $json = $state->loaded ? $state->document : null; + if ($json === null) { + return null; + } + + $memo = $this->documents[$state->name] ?? null; + if ($memo !== null && $memo[0] === $json) { + return $memo[1]; + } + + try { + $document = FlagDocument::fromJson($json); + } catch (Throwable) { + return null; + } + if (count($document->flags) !== $state->flags) { + return null; + } + + $this->documents[$state->name] = [$json, $document]; + + return $document; + } + + private function due(FlagSource $source, ?SourceState $state, float $now): bool + { + if ($state === null) { + return true; + } + + $interval = $source->refreshInterval(); + + return $interval <= 0.0 || $now - $state->checkedAt >= $interval; + } + + /** + * Load the source and record the result in the cache and in this process. A polling source loads under its lock: + * the holder re-reads the state once it has the lock (another worker may have checked it in between) and writes + * the new state before letting the lock go, so the next holder reads this check instead of loading again. + * + * @param bool $known false when the cached state cannot be read: load from scratch, ignoring it + * + * @throws InvalidFlagDefinition|FlagSourceUnavailable|Throwable as load() + */ + private function check(FlagSource $source, ?SourceState $before, float $now, bool $forced, bool $known = true): SourceState + { + $name = $source->name(); + $interval = $source->refreshInterval(); + if ($interval <= 0.0) { + return $this->settle($name, $before, $this->load($source, $before ?? SourceState::initial($name), $now), everyRefresh: true); + } + + $release = $this->book->lock('lock:'.$name, (int) ceil($interval) + 1); + if ($release === null) { + if ($before !== null && ! $forced) { + return $before; // another worker is checking it: serve the last good document meanwhile + } + + return $this->settle($name, $before, $this->load($source, $before ?? SourceState::initial($name), $now)); + } + + try { + $latest = $known ? ($this->state($name) ?? $before) : null; + if (! $forced && $latest !== null && ! $this->due($source, $latest, $now)) { + return $latest; // another worker checked it between this one reading its state and taking the lock + } + + return $this->settle($name, $latest, $this->load($source, $latest ?? SourceState::initial($name), $now)); + } finally { + $release(); + } + } + + /** + * Remember a check in this process and in the cache. A source checked on every refresh that found nothing new + * writes nothing: its check time tells nothing, and a cache write per request would. + */ + private function settle(string $name, ?SourceState $before, SourceState $after, bool $everyRefresh = false): SourceState + { + if ($everyRefresh && $before !== null && $after->sameAs($before)) { + $this->states[$name] = $before; + + return $before; + } + + $this->states[$name] = $after; + $this->book->put('source:'.$name, $after->toArray()); + + return $after; + } + + /** + * @throws InvalidFlagDefinition|FlagSourceUnavailable|Throwable only when the failure refuses the boot: always for + * configuration, for a flag file until it ever loaded + */ + private function load(FlagSource $source, SourceState $before, float $now): SourceState + { + $name = $source->name(); + + try { + $snapshot = $source->load($before->loaded ? $before->revision : null); + + return $snapshot === null ? $before->withUnchanged($now) : $before->withLoaded($snapshot->document, $snapshot->revision, $now); + } catch (Throwable $failure) { + if ($source->failsStartup() && ($name === FlagSource::CONFIG || ! $before->loaded)) { + throw $failure instanceof InvalidFlagDefinition ? $failure->fromSource($name) : $failure; + } + + $error = $failure->getMessage() !== '' ? $failure->getMessage() : $failure::class; + if ($before->error !== $error) { + $this->logger->warning('Feature flag source [{source}] failed{kept}: {error}', [ + 'source' => $name, + 'kept' => $before->loaded ? '; its last good document stays in use' : '', + 'error' => $error, + ]); + } + + return $before->withFailure($error, $now); + } + + } + + /** + * Record the shared composition for every worker, then announce what changed for this process. + * + * Without test overrides that is the shared transition, announced once across all workers. While test overrides + * are active the application sees the effective set, so the keys are those whose EFFECTIVE flag changed in this + * process: a source change to a key an override shadows is not announced (clearing the override announces it), + * and an overridden key whose targeting references a changed evaluator is. + * + * @param list $loaded the composed layers as source name and document JSON + * @param Composition|null $overridden the effective composition before this refresh, while test overrides are active + */ + private function observe(Composition $shared, array $loaded, string $origin, ?Composition $overridden): void + { + $transition = $this->record($shared, $loaded, hash('sha256', implode("\0", array_merge(...$loaded)))); + + if ($overridden !== null && $this->effective !== null) { + $changed = self::changedKeys($overridden->fingerprints(), $this->effective->fingerprints()); + if ($changed !== []) { + $this->publish(new FeatureFlagsChanged($changed, $origin)); + } + + return; + } + + if ($transition !== null && $this->book->first($transition['guard'], self::CHANGE_WINDOW)) { + $this->publish(new FeatureFlagsChanged($transition['changed'], $transition['startup'] ? self::STARTUP_ORIGIN : $origin)); + } + } + + /** + * Compare the shared composition with the last one the shared bookkeeping recorded and record it: the transition + * to announce (its keys, whether it is the first record, the guard key that lets one worker announce it), or null + * when there is none, the store is down, or this process composed documents older than the shared ones. + * + * The record only moves forward. It is read, compared and written under the non-blocking lock `lock:composed` + * (a worker that finds it held skips: the holder records, or this process at its next refresh, or the next + * request), and right before writing a transition the shared source states are read again: a worker whose layers + * no longer match them (it served a last good document while another worker loaded a newer one) neither writes + * nor announces — it would announce a revert, and the next request the change again. The common case — the same + * layers as the record — is answered from one read, without the lock. + * + * @param list $loaded the composed layers as source name and document JSON + * @param string $layersHash sha256 of $loaded: the same layers compose the same set, so the common case + * (nothing moved since another worker recorded it) fingerprints no flag + * @return array{changed: non-empty-list, startup: bool, guard: string}|null + */ + private function record(Composition $shared, array $loaded, string $layersHash): ?array + { + $previous = self::composedRecord($this->book->get(self::RECORD)); + if ($this->book->degraded()) { + return null; // no shared memory of the previous set: every process would announce a "change" + } + if ($previous !== null && $previous['layers'] === $layersHash) { + $this->recordPending = false; + + return null; // the common case, checked without the lock + } + + $release = $this->book->lock('lock:'.self::RECORD, self::RECORD_LOCK); + if ($release === null) { + $this->recordPending = true; // another worker is recording: it, or this process's next refresh, records + + return null; + } + + try { + $this->recordPending = false; + $previous = self::composedRecord($this->book->get(self::RECORD)); + if ($previous !== null && $previous['layers'] === $layersHash) { + return null; + } + + $keys = $shared->fingerprints(); + $fingerprint = hash('sha256', Json::canonical(Json::object($keys))); + if ($previous !== null && $previous['fingerprint'] === $fingerprint) { + $this->book->put(self::RECORD, [...$previous, 'layers' => $layersHash]); // other documents, the same effective flags + + return null; + } + if (! $this->current($loaded)) { + return null; + } + + // The generation makes each transition's guard key unique, so flipping back and forth is announced every time. + $generation = ($previous['generation'] ?? 0) + 1; + $this->book->put(self::RECORD, ['generation' => $generation, 'layers' => $layersHash, 'fingerprint' => $fingerprint, 'keys' => $keys]); + + $changed = self::changedKeys($previous['keys'] ?? [], $keys); + + return $changed === [] ? null : ['changed' => $changed, 'startup' => $previous === null, 'guard' => 'changed:'.$generation.':'.$fingerprint]; + } finally { + $release(); + } + } + + /** + * Whether the source states hold, right now, the documents this process composed. + * + * @param list $loaded + */ + private function current(array $loaded): bool + { + $states = []; + foreach ($this->sources as $source) { + $state = $this->state($source->name()); + if ($state !== null && $state->loaded && $state->document !== null) { + $states[] = [$source->name(), $state->document]; + } + } + + return $states === $loaded; + } + + /** + * The highest-precedence source whose document moved, else the highest-precedence loaded source (the change + * came from another process or deploy), else the highest-precedence source. + * + * @param list $moved + * @param list $loaded + */ + private function origin(array $moved, array $loaded): string + { + if ($moved !== []) { + return $moved[count($moved) - 1]; + } + if ($loaded !== []) { + return $loaded[count($loaded) - 1][0]; + } + + return $this->sources === [] ? self::STARTUP_ORIGIN : $this->sources[count($this->sources) - 1]->name(); + } + + private function publish(FeatureFlagsChanged $event): void + { + try { + $this->events->publish($event); + } catch (Throwable $failure) { + $this->logger->warning('A FeatureFlagsChanged listener failed ({error}); the flags it announced stay in force.', ['error' => $failure->getMessage()]); + } + } + + /** Seconds until a polling source may be due again: its interval (0 = every read); configuration never is. */ + private function memoSeconds(): float + { + $seconds = INF; + foreach ($this->sources as $source) { + if ($source->name() !== FlagSource::CONFIG) { + $seconds = min($seconds, max(0.0, $source->refreshInterval())); + } + } + + return $seconds; + } + + /** + * The keys whose fingerprint differs between two Composition::fingerprints() maps, added and removed keys + * included, sorted as text. + * + * @param array $before + * @param array $after + * @return list + */ + private static function changedKeys(array $before, array $after): array + { + $changed = []; + foreach ([...array_keys($before), ...array_keys($after)] as $key) { + if (($before[$key] ?? null) !== ($after[$key] ?? null)) { + $changed[(string) $key] = (string) $key; + } + } + $changed = array_values($changed); + sort($changed, SORT_STRING); + + return $changed; + } + + /** + * The shared record observe() writes, or null for anything else. + * + * @return array{generation: int, layers: string, fingerprint: string, keys: array}|null + */ + private static function composedRecord(mixed $record): ?array + { + if (! is_array($record) + || ! is_int($record['generation'] ?? null) + || ! is_string($record['layers'] ?? null) + || ! is_string($record['fingerprint'] ?? null) + || ! is_array($record['keys'] ?? null)) { + return null; + } + + $keys = []; + foreach ($record['keys'] as $key => $fingerprint) { + if (! is_string($fingerprint)) { + return null; + } + $keys[$key] = $fingerprint; + } + + return ['generation' => $record['generation'], 'layers' => $record['layers'], 'fingerprint' => $record['fingerprint'], 'keys' => $keys]; + } +} diff --git a/packages/feature-flags/src/Registry/SourceState.php b/packages/feature-flags/src/Registry/SourceState.php new file mode 100644 index 00000000..937928dc --- /dev/null +++ b/packages/feature-flags/src/Registry/SourceState.php @@ -0,0 +1,124 @@ + $this->name, + 'loaded' => $this->loaded, + 'document' => $this->document, + 'revision' => $this->revision, + 'checkedAt' => $this->checkedAt, + 'lastRefresh' => $this->lastRefresh, + 'error' => $this->error, + 'flags' => $this->flags, + ]; + } + + /** @throws \JsonException if the document cannot be serialized and read back */ + public function withLoaded(FlagDocument $document, ?string $revision, float $now): self + { + $json = $document->toJson(); + FlagDocument::fromJson($json); + + return new self($this->name, true, $json, $revision, $now, self::timestamp($now), null, count($document->flags)); + } + + /** A successful check that found nothing new: it refreshed, and any earlier error is over. */ + public function withUnchanged(float $now): self + { + return new self($this->name, $this->loaded, $this->document, $this->revision, $now, self::timestamp($now), null, $this->flags); + } + + public function withFailure(string $error, float $now): self + { + return new self($this->name, $this->loaded, $this->document, $this->revision, $now, $this->lastRefresh, $error, $this->flags); + } + + /** The same source, document, revision and error: only the check and refresh times may differ. */ + public function sameAs(self $other): bool + { + return $this->name === $other->name + && $this->loaded === $other->loaded + && $this->document === $other->document + && $this->revision === $other->revision + && $this->error === $other->error; + } + + /** The last good document; an empty one when the source never loaded. */ + public function document(): FlagDocument + { + return $this->document === null ? new FlagDocument : FlagDocument::fromJson($this->document); + } + + public function status(): string + { + return match (true) { + ! $this->loaded => 'DOWN', + $this->error !== null => 'STALE', + default => 'UP', + }; + } + + private static function timestamp(float $now): string + { + return gmdate('Y-m-d\TH:i:s\Z', (int) floor($now)); + } +} diff --git a/packages/feature-flags/src/Scanner/FeatureFlagScanner.php b/packages/feature-flags/src/Scanner/FeatureFlagScanner.php new file mode 100644 index 00000000..2a1884f6 --- /dev/null +++ b/packages/feature-flags/src/Scanner/FeatureFlagScanner.php @@ -0,0 +1,332 @@ + $psr4 namespace-prefix => absolute directory + * @return array>> class => method => row + */ + public function scanProxyAdvice(array $psr4): array + { + $advice = []; + foreach ($this->scan($psr4) as $rule) { + $advice[$rule->class][$rule->method] = $rule->toArray(); + } + + foreach ($advice as $class => $methods) { + ksort($methods); + $advice[$class] = $methods; + } + ksort($advice); + + /** @var array>> $advice */ + return $advice; + } + + /** + * @param array $psr4 + * @return list + */ + public function scan(array $psr4): array + { + $classes = $this->classes($psr4); + $produced = $this->beanFactoryTypes($classes); + + $compiled = []; + foreach ($classes as $class) { + $rules = $this->compile($class); + if ($rules !== []) { + $compiled[$class] = $rules; + } + } + + $rules = []; + foreach ($compiled as $class => $classRules) { + $reflection = new ReflectionClass($class); + $classFlag = $reflection->getAttributes(FeatureFlag::class) !== []; + + if ($this->postProcessed($reflection, $produced)) { + if ($reflection->isFinal()) { + throw new ConfigurationException("#[FeatureFlag] on {$class} cannot be applied: the class is final and a proxy must extend it. Remove `final`, or gate the call with FeatureFlags::isEnabled() where it is made."); + } + $rules = [...$rules, ...$classRules]; + + continue; + } + + $covered = $this->coveredBySubclasses($class, $classes, $produced, $compiled); + foreach ($classRules as $rule) { + if (! $classFlag && $reflection->getMethod($rule->method)->getDeclaringClass()->getName() !== $class) { + continue; + } + + if (! isset($covered[$rule->method])) { + throw new ConfigurationException("#[FeatureFlag] on {$rule->site()} cannot be applied: nothing post-processes {$class} — it has no #[Component]-family stereotype, no #[Bean] method returns it and no post-processed subclass compiles a row for {$rule->method}() — so no proxy would ever run the gate. Add a stereotype such as #[Service], or gate the call with FeatureFlags::isEnabled()."); + } + } + } + + return $rules; + } + + /** + * @param class-string $class + * @return list + */ + private function compile(string $class): array + { + $reflection = new ReflectionClass($class); + $this->refuseAbstractAncestorRule($reflection); + $classFlag = $this->first($reflection->getAttributes(FeatureFlag::class)); + $route = $reflection->getAttributes(RestController::class, ReflectionAttribute::IS_INSTANCEOF) !== []; + $fallbacks = $this->fallbackTargets($reflection, $classFlag); + + $rules = []; + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $own = $this->first($method->getAttributes(FeatureFlag::class)); + $site = $class.'::'.$method->getName(); + + if ($method->isStatic() || $method->isConstructor() || str_starts_with($method->getName(), '__')) { + if ($own !== null) { + throw new ConfigurationException("#[FeatureFlag] on {$site} cannot be applied: ".($method->isStatic() ? 'a static call has no instance for a proxy to wrap' : 'a constructor or `__` method is never routed through the advice chain').'. Move the attribute to a public instance method, or gate the call with FeatureFlags::isEnabled().'); + } + + continue; + } + + $flag = $own ?? (isset($fallbacks[strtolower($method->getName())]) ? null : $classFlag); + if ($flag === null) { + continue; + } + + if ($method->isFinal()) { + if ($own === null && $method->getDeclaringClass()->getName() !== $class) { + continue; + } + + throw new ConfigurationException("#[FeatureFlag] on {$site} cannot be applied: the method is final and a proxy must override it. Remove `final` (a class-level #[FeatureFlag] applies to every public method, this one included)."); + } + + $this->assertFlag($flag, $site); + if ($flag->fallback !== null) { + $this->assertFallback($reflection, $method, $flag->fallback, $site); + } + + $rules[] = new FeatureFlagMethodDescriptor($class, $method->getName(), $flag->key, $flag->variant, $flag->default, $flag->fallback, $route); + } + + return $rules; + } + + private function assertFlag(FeatureFlag $flag, string $site): void + { + if (preg_match(FlagDefinitions::KEY_PATTERN, $flag->key) !== 1) { + throw new ConfigurationException("#[FeatureFlag] on {$site} names [{$flag->key}], which is not a valid flag key (^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\$)."); + } + + if ($flag->variant !== null && ($flag->variant === '' || str_contains($flag->variant, ','))) { + throw new ConfigurationException("#[FeatureFlag] on {$site} gates on variant [{$flag->variant}]: a variant used by a gate must be non-empty and contain no comma."); + } + } + + /** + * @param ReflectionClass $reflection + */ + private function assertFallback(ReflectionClass $reflection, ReflectionMethod $gated, string $fallback, string $site): void + { + if (strcasecmp($fallback, $gated->getName()) === 0) { + throw new ConfigurationException("#[FeatureFlag] on {$site} names the gated method itself as its fallback, which would answer by calling the gate again. Name a DIFFERENT method that returns the degraded answer."); + } + + if (! $reflection->hasMethod($fallback)) { + throw new ConfigurationException("#[FeatureFlag] on {$site} names fallback [{$fallback}], which {$reflection->getName()} does not declare."); + } + + $recovery = $reflection->getMethod($fallback); + if (! $recovery->isPublic() || $recovery->isStatic()) { + throw new ConfigurationException("#[FeatureFlag] on {$site} names fallback [{$fallback}], which is ".($recovery->isStatic() ? 'static' : 'not public').'. The proxy calls the fallback on the bean from outside the class: make it a public instance method.'); + } + + if ($recovery->getNumberOfRequiredParameters() > $gated->getNumberOfParameters()) { + throw new ConfigurationException("#[FeatureFlag] on {$site} names fallback [{$fallback}], which requires {$recovery->getNumberOfRequiredParameters()} arguments; the gated call supplies {$gated->getNumberOfParameters()}. Give the fallback the gated method's signature."); + } + } + + /** + * Methods some #[FeatureFlag] on this class names as its fallback: the class-level attribute skips them. + * + * @param ReflectionClass $reflection + * @return array + */ + private function fallbackTargets(ReflectionClass $reflection, ?FeatureFlag $classFlag): array + { + $targets = []; + if ($classFlag?->fallback !== null) { + $targets[strtolower($classFlag->fallback)] = true; + } + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $fallback = $this->first($method->getAttributes(FeatureFlag::class))?->fallback; + if ($fallback !== null && strcasecmp($fallback, $method->getName()) !== 0) { + $targets[strtolower($fallback)] = true; + } + } + + return $targets; + } + + /** + * The methods every post-processed subclass of $class compiles a row for (an inherited method attribute IS + * visible on the subclass; a class attribute is not inherited by PHP). + * + * @param list $classes + * @param array $produced + * @param array> $compiled + * @return array + */ + private function coveredBySubclasses(string $class, array $classes, array $produced, array $compiled): array + { + $covered = null; + foreach ($classes as $candidate) { + if ($candidate === $class || ! is_subclass_of($candidate, $class) || ! $this->postProcessed(new ReflectionClass($candidate), $produced)) { + continue; + } + + $own = []; + foreach ($compiled[$candidate] ?? [] as $rule) { + $own[$rule->method] = true; + } + $covered = $covered === null ? $own : array_intersect_key($covered, $own); + } + + return $covered ?? []; + } + + /** + * @param ReflectionClass $reflection + * @param array $produced + */ + private function postProcessed(ReflectionClass $reflection, array $produced): bool + { + return $reflection->getAttributes(Component::class, ReflectionAttribute::IS_INSTANCEOF) !== [] + || isset($produced[$reflection->getName()]); + } + + /** + * @param list $classes + * @return array + */ + private function beanFactoryTypes(array $classes): array + { + $types = []; + foreach ($classes as $class) { + $reflection = new ReflectionClass($class); + if ($reflection->getAttributes(Component::class, ReflectionAttribute::IS_INSTANCEOF) === []) { + continue; + } + + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $returns = $method->getReturnType(); + if ($method->getAttributes(Bean::class) !== [] && $returns instanceof ReflectionNamedType && ! $returns->isBuiltin() && class_exists($returns->getName())) { + $types[$returns->getName()] = true; + } + } + } + + return $types; + } + + /** + * @param list> $attributes + */ + private function first(array $attributes): ?FeatureFlag + { + return $attributes === [] ? null : $attributes[0]->newInstance(); + } + + /** @param ReflectionClass $reflection */ + private function refuseAbstractAncestorRule(ReflectionClass $reflection): void + { + for ($parent = $reflection->getParentClass(); $parent !== false; $parent = $parent->getParentClass()) { + if ($parent->isAbstract() && $parent->getAttributes(FeatureFlag::class) !== []) { + throw new ConfigurationException("#[FeatureFlag] on {$parent->getName()} cannot be applied: a class-level #[FeatureFlag] is not inherited by {$reflection->getName()}, so no proxy could enforce it. Put the attribute on the concrete service or its methods."); + } + } + } + + /** + * Concrete classes under the PSR-4 roots, sorted (the ResilienceMethodScanner walk). + * + * @param array $psr4 + * @return list + */ + private function classes(array $psr4): array + { + $classes = []; + foreach ($psr4 as $prefix => $directory) { + if (! is_dir($directory)) { + continue; + } + $prefix = rtrim($prefix, '\\').'\\'; + $root = rtrim((string) realpath($directory), DIRECTORY_SEPARATOR); + /** @var iterable $files */ + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)); + foreach ($files as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + $relative = substr((string) $file->getRealPath(), strlen($root) + 1, -4); + $class = $prefix.str_replace(DIRECTORY_SEPARATOR, '\\', $relative); + if (! class_exists($class)) { + continue; + } + $reflection = new ReflectionClass($class); + if (! $reflection->isAbstract() && ! $reflection->isInterface()) { + $classes[] = $class; + } + } + } + sort($classes); + + return $classes; + } +} diff --git a/packages/feature-flags/src/Server/FlagdSyncController.php b/packages/feature-flags/src/Server/FlagdSyncController.php new file mode 100644 index 00000000..dab892d8 --- /dev/null +++ b/packages/feature-flags/src/Server/FlagdSyncController.php @@ -0,0 +1,42 @@ +settings->server->token; + if ($token !== '' && ! hash_equals($token, (string) $request->bearerToken())) { + return new Response('', 401, ['WWW-Authenticate' => 'Bearer realm="feature-flags"', 'Cache-Control' => 'no-cache']); + } + + $body = $this->registry->composition(false)->document()->toJson(); + $etag = '"'.hash('sha256', $body).'"'; + $headers = ['ETag' => $etag, 'Cache-Control' => 'no-cache']; + + $match = array_map(static function (string $value): string { + $value = trim($value); + + return str_starts_with($value, 'W/') ? substr($value, 2) : $value; + }, explode(',', (string) $request->headers->get('If-None-Match', ''))); + if (in_array($etag, $match, true) || in_array('*', $match, true)) { + return new Response('', 304, $headers); + } + + return new Response($body, 200, ['Content-Type' => 'application/json'] + $headers); + } +} diff --git a/packages/feature-flags/src/Server/FlagdSyncRouteRegistrar.php b/packages/feature-flags/src/Server/FlagdSyncRouteRegistrar.php new file mode 100644 index 00000000..f8ad7207 --- /dev/null +++ b/packages/feature-flags/src/Server/FlagdSyncRouteRegistrar.php @@ -0,0 +1,49 @@ +container; + if (! $container->bound(FeatureFlagsSettings::class) || ! $container->bound(FlagRegistry::class)) { + return; + } + + /** @var FeatureFlagsSettings $settings */ + $settings = $container->make(FeatureFlagsSettings::class); + if (! $settings->server->enabled) { + return; + } + + /** @var Router $router */ + $router = $container->make('router'); + $router->get($settings->server->path, static fn (Request $request): Response => $container->make(FlagdSyncController::class)($request)) + ->name(self::ROUTE_NAME); + } +} diff --git a/packages/feature-flags/src/Settings/DurationReader.php b/packages/feature-flags/src/Settings/DurationReader.php new file mode 100644 index 00000000..91397d23 --- /dev/null +++ b/packages/feature-flags/src/Settings/DurationReader.php @@ -0,0 +1,61 @@ +get('firefly.feature-flags.sources.file.refresh-interval', + * 5.0)`. That literal is both the key read and the key every refusal names, so the two cannot drift apart. The + * method is called get() on purpose: tests/ConfigReferenceTest.php and DocsCodeAudit::keyLiterals() find a key by + * a `->get('firefly.…')` call, so each duration key stays visible to both. + * + * Accepted: a duration in the framework grammar (`'500ms'`, `'5s'`, `'PT1M'`, see Duration::parse()) or a finite + * number of seconds. Null is unset, as everywhere in Config: an absent key, or `env('X')` with X unset, gives the + * default. Refused: a negative value, a non-finite number, an empty or unparsable string and any other type; and + * zero too when the caller says why zero is unsafe for that key (`$refuseZeroBecause`, quoted in the refusal). + * + * @internal + */ +final readonly class DurationReader +{ + public function __construct(private Config $config) {} + + public function get(string $key, float $default, ?string $refuseZeroBecause = null): float + { + $value = $this->config->get($key); + if ($value === null) { + return $default; + } + + if (is_int($value) || is_float($value)) { + $seconds = (float) $value; + if (! is_finite($seconds)) { + throw new ConfigurationException("Configuration key [{$key}] must be a finite number of seconds, got [".var_export($value, true).'].'); + } + } elseif (is_string($value) && trim($value) !== '') { + try { + $seconds = Duration::parse($value); + } catch (ConfigurationException $exception) { + throw new ConfigurationException("Configuration key [{$key}] is not a duration this framework can parse. {$exception->getMessage()}", previous: $exception); + } + } else { + throw new ConfigurationException("Configuration key [{$key}] must be a duration such as '5s', '500ms' or 'PT1M', or a number of seconds, got ".get_debug_type($value).'.'); + } + + if ($seconds < 0.0) { + throw new ConfigurationException("Configuration key [{$key}] must not be negative, got [".var_export($value, true).'].'); + } + if ($refuseZeroBecause !== null && $seconds <= 0.0) { + throw new ConfigurationException("Configuration key [{$key}] must be greater than zero, got [".var_export($value, true)."]: {$refuseZeroBecause}."); + } + + return $seconds; + } +} diff --git a/packages/feature-flags/src/Settings/FileSourceSettings.php b/packages/feature-flags/src/Settings/FileSourceSettings.php new file mode 100644 index 00000000..9d93ef55 --- /dev/null +++ b/packages/feature-flags/src/Settings/FileSourceSettings.php @@ -0,0 +1,15 @@ +settings->flags, $this->settings->evaluators])); + if ($revision === $knownRevision) { + return null; + } + + $document = FlagDefinitions::parseDocument([ + 'flags' => FlagDefinitions::normalize($this->settings->flags), + '$evaluators' => $this->settings->evaluators, + ]); + + return new SourceSnapshot($document, $revision); + } +} diff --git a/packages/feature-flags/src/Source/FileFlagSource.php b/packages/feature-flags/src/Source/FileFlagSource.php new file mode 100644 index 00000000..90882380 --- /dev/null +++ b/packages/feature-flags/src/Source/FileFlagSource.php @@ -0,0 +1,177 @@ +`. A file that does not parse is + * refused as a whole with the key ``, and the reason names the file. + * + * Symfony Yaml reads only `true`/`false` as booleans, as YAML 1.2 and flagd do, so `on`/`off` stay variant names. + * An unquoted date value (`expires: 2025-01-01`) becomes the text flagd expects, not the int timestamp Symfony Yaml + * would give, and a timestamp becomes ISO-8601 text the way PyFly writes one (seconds, microseconds only when + * non-zero, the offset only when the file wrote one). A PHP tag (`!php/const`, `!php/object`) is refused instead of + * read as null. + * + * Known divergences from PyFly, all YAML-only (CONTRACT.md: quote dates in YAML; JSON cannot express them): + * - An impossible calendar date written unquoted as a value (`expires: 2025-02-30`) reaches this class already rolled + * over by PHP's DateTimeImmutable (`2025-03-02`; `2025-00-00` gives `2024-11-30`), so it loads as that real date. + * PyFly's YAML parser refuses the file, and the same text in JSON is a string that `expires` validation refuses. + * Nothing after parsing can tell, so quote dates: `expires: '2025-02-30'` is refused as it should be. + * - An unquoted date as a block-mapping KEY (`2024-01-01: x`) reaches this class as the int key 1704067200 — + * Symfony Yaml evaluates block keys without the parse flags, so nothing tells it apart from a key written + * `1704067200:` — and is read as the text "1704067200" where PyFly refuses the key. As a flow-mapping key + * (`{2024-01-01: x}`) it is refused as invalid YAML. Quote date keys. + * + * The revision is `mtime-size` at whole seconds (PHP's stat()): an edit that keeps the size and lands within the + * same second as the previous one goes unseen until the mtime or the size moves again. The revision is read before + * the contents, so it never claims newer bytes than the ones parsed. + */ +#[Component] +#[ConditionalOnProperty(name: 'firefly.feature-flags.enabled', havingValue: 'true')] +#[ConditionalOnProperty(name: 'firefly.feature-flags.sources.file.enabled', havingValue: 'true')] +#[ConditionalOnMissingBean(Provider::class)] +final class FileFlagSource implements FlagSource +{ + private const int YAML_FLAGS = Yaml::PARSE_OBJECT_FOR_MAP | Yaml::PARSE_DATETIME | Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE; + + public function __construct(private readonly FeatureFlagsSettings $settings) {} + + public function name(): string + { + return self::FILE; + } + + public function precedence(): int + { + return 200; + } + + public function refreshInterval(): float + { + return $this->settings->file->refreshInterval; + } + + public function failsStartup(): bool + { + return true; + } + + public function reportedRevision(?string $revision): ?string + { + return $revision; + } + + public function load(?string $knownRevision): ?SourceSnapshot + { + $path = $this->settings->file->path; + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if (! in_array($extension, ['json', 'yaml', 'yml'], true)) { + throw new FlagSourceUnavailable("The flag file [{$path}] must end in .json, .yaml or .yml."); + } + + clearstatcache(true, $path); + $stat = is_file($path) ? @stat($path) : false; + if ($stat === false) { + throw new FlagSourceUnavailable("The flag file [{$path}] does not exist or cannot be read."); + } + + $revision = $stat['mtime'].'-'.$stat['size']; + if ($revision === $knownRevision) { + return null; + } + + $contents = @file_get_contents($path); + if ($contents === false) { + throw new FlagSourceUnavailable("The flag file [{$path}] cannot be read."); + } + + $document = self::decode($path, $extension, $contents) ?? new stdClass; // only an absent document is empty + + return new SourceSnapshot(FlagDefinitions::parseDocument($document), $revision); + } + + /** + * The document as JSON would decode it; null for an absent one. + * + * @throws InvalidFlagDefinition the file does not parse + */ + private static function decode(string $path, string $extension, string $contents): mixed + { + if ($extension === 'json') { + try { + return Json::decode($contents); + } catch (JsonException $failure) { + throw self::unparsable($path, 'JSON', $failure); + } + } + + try { + $parsed = Yaml::parse($contents, self::YAML_FLAGS); + } catch (Throwable $failure) { + // Not only the documented ParseException: Symfony Yaml stores a block-mapping key as a stdClass property, + // and a key starting with "\0" throws an Error. Anything parsing throws means the file is not valid YAML. + throw self::unparsable($path, 'YAML', $failure); + } + + return Json::normalize(self::datesAsText($parsed)); + } + + private static function unparsable(string $path, string $format, Throwable $failure): InvalidFlagDefinition + { + return new InvalidFlagDefinition('', sprintf('the flag file [%s] is not valid %s (%s)', $path, $format, rtrim($failure->getMessage(), '.'))); + } + + /** Every YAML date or timestamp in $value replaced by its text, at any depth. */ + private static function datesAsText(mixed $value): mixed + { + if ($value instanceof DateTimeInterface) { + return self::isoText($value); + } + + if ($value instanceof stdClass) { + // through an array: a member name starting with "\0" (a flow-mapping key) cannot be assigned as a property + return (object) array_map(self::datesAsText(...), get_object_vars($value)); + } + + return is_array($value) ? array_map(self::datesAsText(...), $value) : $value; + } + + /** + * `2025-01-01` for a date; otherwise Python's isoformat(), which PyFly writes: `2025-01-01T10:30:00`, with + * `.ffffff` when there are microseconds and the offset when the file wrote one. Symfony Yaml places a timestamp + * written without an offset in the zone named "UTC" (an offset written as `Z` or `+00:00` keeps that name), and + * reads a date as such a timestamp at midnight. + */ + private static function isoText(DateTimeInterface $at): string + { + $offsetWritten = $at->getTimezone()->getName() !== 'UTC'; + if (! $offsetWritten && $at->format('H:i:s.u') === '00:00:00.000000') { + return $at->format('Y-m-d'); + } + + return $at->format('Y-m-d\TH:i:s') + .($at->format('u') === '000000' ? '' : $at->format('.u')) + .($offsetWritten ? $at->format('P') : ''); + } +} diff --git a/packages/feature-flags/src/Source/FlagSource.php b/packages/feature-flags/src/Source/FlagSource.php new file mode 100644 index 00000000..23595419 --- /dev/null +++ b/packages/feature-flags/src/Source/FlagSource.php @@ -0,0 +1,50 @@ +settings->http->refreshInterval; + } + + public function failsStartup(): bool + { + return false; + } + + public function reportedRevision(?string $revision): ?string + { + return $revision; + } + + public function load(?string $knownRevision): ?SourceSnapshot + { + $settings = $this->settings->http; + $request = $this->http->timeout($settings->timeout)->connectTimeout($settings->timeout)->acceptJson(); + if ($settings->token !== '') { + $request = $request->withToken($settings->token); + } + if ($knownRevision !== null) { + $request = $request->withHeaders(['If-None-Match' => $knownRevision]); + } + + try { + $response = $request->get($settings->url); + } catch (Throwable $failure) { + throw new FlagSourceUnavailable("The sync endpoint [{$settings->url}] did not answer: {$failure->getMessage()}", 0, $failure); + } + + if ($response->status() === 304 && $knownRevision !== null) { + return null; + } + if (! $response->successful()) { + throw new FlagSourceUnavailable("The sync endpoint [{$settings->url}] answered HTTP {$response->status()}."); + } + + $body = $response->body(); + try { + $document = FlagDefinitions::parseDocument(Json::decode($body)); + } catch (JsonException $failure) { + throw new InvalidFlagDefinition('', 'the flag document is not valid JSON ('.$failure->getMessage().')'); + } + + $etag = $response->header('ETag'); + + return new SourceSnapshot($document, $etag !== '' ? $etag : '"'.hash('sha256', $body).'"'); + } +} diff --git a/packages/feature-flags/src/Source/SourceSnapshot.php b/packages/feature-flags/src/Source/SourceSnapshot.php new file mode 100644 index 00000000..eb80a798 --- /dev/null +++ b/packages/feature-flags/src/Source/SourceSnapshot.php @@ -0,0 +1,16 @@ +settings->store->refreshInterval; + } + + public function failsStartup(): bool + { + return false; + } + + public function reportedRevision(?string $revision): ?string + { + return $revision; + } + + public function transactionActive(): bool + { + try { + $store = $this->store(); + } catch (Throwable) { + return false; + } + + return $store instanceof TransactionAwareFlagStore && $store->transactionActive(); + } + + public function load(?string $knownRevision): ?SourceSnapshot + { + $store = $this->store(); + if ($store instanceof TransactionAwareFlagStore && $store->transactionActive()) { + return null; + } + + try { + $revision = (string) $store->revision(); + if ($revision === $knownRevision) { + return null; + } + + $flags = []; + foreach ($store->all() as $stored) { + $flags[$stored->key] = $stored->definition; + } + } catch (Throwable $failure) { + throw new FlagSourceUnavailable('The flag store cannot be read: '.$failure->getMessage(), 0, $failure); + } + + return new SourceSnapshot(FlagDefinitions::parseDocument(['flags' => Json::object($flags)]), $revision); + } + + private function store(): FlagStore + { + $store = $this->beans->get(FlagStore::class); + if (! $store instanceof FlagStore) { + throw new FlagSourceUnavailable('No FlagStore bean is configured.'); + } + + return $store; + } +} diff --git a/packages/feature-flags/src/Store/CommitAwareFlagStore.php b/packages/feature-flags/src/Store/CommitAwareFlagStore.php new file mode 100644 index 00000000..d7ac0b06 --- /dev/null +++ b/packages/feature-flags/src/Store/CommitAwareFlagStore.php @@ -0,0 +1,14 @@ +connection = $connection; + $this->callbacks = new StoreCommitCallbacks($connection); + $this->clock = $clock ?? static fn (): DateTimeImmutable => new DateTimeImmutable('now', new DateTimeZone('UTC')); + } + + public function transactionActive(): bool + { + return $this->connection->transactionLevel() > 0; + } + + public function afterCommit(Closure $callback): void + { + $this->callbacks->afterCommit($callback); + } + + public function all(): array + { + $flags = []; + foreach ($this->connection->table(FeatureFlagSchema::FLAGS)->useWritePdo()->orderBy('flag_key')->get() as $row) { + $flag = self::storedFlag($row); + $flags[$flag->key] = $flag; + } + + return $flags; + } + + public function get(string $key): ?StoredFlag + { + $row = $this->connection->table(FeatureFlagSchema::FLAGS)->useWritePdo()->where('flag_key', $key)->first(); + + return $row === null ? null : self::storedFlag($row); + } + + public function revision(): int + { + $max = $this->connection->table(FeatureFlagSchema::CHANGES)->useWritePdo()->max('id'); + + return is_numeric($max) ? (int) $max : 0; + } + + public function put(string $key, array $definition, ?string $actor, ?int $expectedVersion = null): FlagChange + { + $definition = Json::members(Json::decode(self::encode($key, $definition))); + + $attempts = $expectedVersion === null && ! $this->transactionActive() ? 3 : 1; + for ($attempt = 1; ; $attempt++) { + $rowRaced = false; + try { + return $this->putAttempt($key, $definition, $actor, $expectedVersion, $rowRaced); + } catch (FlagStoreConflict|QueryException $error) { + if ($expectedVersion === 0 && $rowRaced && $error instanceof FlagStoreConflict) { + throw new FlagStoreConflict($key, $expectedVersion, $this->get($key)?->version); + } + if (! $rowRaced || $attempts === 1) { + throw $error; + } + if ($attempt >= $attempts) { + throw new FlagStoreConflict($key, $expectedVersion, null); + } + } + } + } + + /** + * @param array $definition + */ + private function putAttempt(string $key, array $definition, ?string $actor, ?int $expectedVersion, bool &$rowRaced): FlagChange + { + return $this->connection->transaction(function () use ($key, $definition, $actor, $expectedVersion, &$rowRaced): FlagChange { + // Create-only writes let the primary key arbitrate absence without InnoDB gap locks. + $current = match ($expectedVersion) { + 0 => null, + null => $this->get($key), + default => $this->locked($key), + }; + FlagStoreConflict::check($key, $expectedVersion, $current?->version); + + $now = ($this->clock)(); + $encoded = self::encode($key, $definition); + $stamp = self::stamp($now); + + try { + if ($current === null) { + try { + $this->connection->table(FeatureFlagSchema::FLAGS)->insert([ + 'flag_key' => $key, 'definition' => $encoded, 'version' => 1, 'updated_at' => $stamp, 'updated_by' => $actor, + ]); + } catch (UniqueConstraintViolationException) { + $rowRaced = true; + throw new FlagStoreConflict($key, $expectedVersion, null); + } + } else { + $updated = $this->connection->table(FeatureFlagSchema::FLAGS) + ->where('flag_key', $key) + ->where('version', $current->version) + ->update(['definition' => $encoded, 'version' => $current->version + 1, 'updated_at' => $stamp, 'updated_by' => $actor]); + if ($updated !== 1) { + $rowRaced = true; + throw new FlagStoreConflict($key, $expectedVersion ?? $current->version, null); + } + } + } catch (QueryException $error) { + // MariaDB snapshot isolation reports a changed row as ER_CHECKREAD, aborting the transaction. + $rowRaced = $this->connection->getDriverName() === 'mysql' && ($error->errorInfo[1] ?? null) === 1020; + if ($rowRaced) { + $this->callbacks->nativeTransactionAborted($error); + } + throw $error; + } + + return $this->record($key, FlagChange::PUT, $definition, $current?->definition, $actor, $now); + }); + } + + public function delete(string $key, ?string $actor, ?int $expectedVersion = null): ?FlagChange + { + /** @var FlagChange|null */ + return $this->connection->transaction(function () use ($key, $actor, $expectedVersion): ?FlagChange { + $current = $this->locked($key); + FlagStoreConflict::check($key, $expectedVersion, $current?->version); + if ($current === null) { + return null; + } + + $deleted = $this->connection->table(FeatureFlagSchema::FLAGS)->where('flag_key', $key)->where('version', $current->version)->delete(); + if ($deleted !== 1) { + throw new FlagStoreConflict($key, $expectedVersion ?? $current->version, null); + } + + return $this->record($key, FlagChange::DELETE, null, $current->definition, $actor, ($this->clock)()); + }); + } + + public function history(string $key, int $limit = 50): array + { + if ($limit <= 0) { + return []; + } + $changes = []; + $rows = $this->connection->table(FeatureFlagSchema::CHANGES)->useWritePdo()->where('flag_key', $key)->orderByDesc('id')->limit(max(0, $limit))->get(); + foreach ($rows as $row) { + $changes[] = new FlagChange( + self::int($row, 'id'), + self::string($row, 'flag_key'), + self::string($row, 'action'), + self::definition($row, 'definition'), + self::definition($row, 'previous'), + self::nullableString($row, 'actor'), + self::instant(self::string($row, 'changed_at')), + ); + } + + return $changes; + } + + private function locked(string $key): ?StoredFlag + { + $row = $this->connection->table(FeatureFlagSchema::FLAGS)->where('flag_key', $key)->lockForUpdate()->first(); + + return $row === null ? null : self::storedFlag($row); + } + + /** + * @param array|null $definition + * @param array|null $previous + */ + private function record(string $key, string $action, ?array $definition, ?array $previous, ?string $actor, DateTimeImmutable $now): FlagChange + { + $id = $this->connection->table(FeatureFlagSchema::CHANGES)->insertGetId([ + 'flag_key' => $key, + 'action' => $action, + 'definition' => $definition === null ? null : self::encode($key, $definition), + 'previous' => $previous === null ? null : self::encode($key, $previous), + 'actor' => $actor, + 'changed_at' => self::stamp($now), + ]); + + return new FlagChange((int) $id, $key, $action, $definition, $previous, $actor, $now); + } + + /** + * @param array $definition + */ + private static function encode(string $key, array $definition): string + { + return Json::canonical((new FlagDefinition($key, $definition))->toJsonValue()); + } + + private static function stamp(DateTimeImmutable $now): string + { + return $now->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s.u'); + } + + private static function instant(string $value): DateTimeImmutable + { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } + + private static function storedFlag(object $row): StoredFlag + { + return new StoredFlag( + self::string($row, 'flag_key'), + self::definition($row, 'definition') ?? [], + self::int($row, 'version'), + self::instant(self::string($row, 'updated_at')), + self::nullableString($row, 'updated_by'), + ); + } + + /** + * @return array|null + */ + private static function definition(object $row, string $column): ?array + { + $value = self::nullableString($row, $column); + + return $value === null ? null : Json::members(Json::decode($value)); + } + + private static function string(object $row, string $column): string + { + $value = get_object_vars($row)[$column] ?? null; + + return is_scalar($value) ? (string) $value : ''; + } + + private static function nullableString(object $row, string $column): ?string + { + $value = get_object_vars($row)[$column] ?? null; + + return is_scalar($value) ? (string) $value : null; + } + + private static function int(object $row, string $column): int + { + $value = get_object_vars($row)[$column] ?? null; + + return is_numeric($value) ? (int) $value : 0; + } +} diff --git a/packages/feature-flags/src/Store/FeatureFlagSchema.php b/packages/feature-flags/src/Store/FeatureFlagSchema.php new file mode 100644 index 00000000..f7f2769b --- /dev/null +++ b/packages/feature-flags/src/Store/FeatureFlagSchema.php @@ -0,0 +1,75 @@ +getConnection(); + $collation = null; + if ($connection instanceof MySqlConnection) { + $modern = version_compare($connection->getServerVersion(), $connection->isMaria() ? '10.2.2' : '8.0.1', '>='); + $collation = $modern ? ($connection->isMaria() ? 'utf8mb4_nopad_bin' : 'utf8mb4_0900_bin') : 'utf8mb4_bin'; + } + if (! $schema->hasTable(self::FLAGS)) { + $schema->create(self::FLAGS, static function (Blueprint $table) use ($collation): void { + $key = $table->string('flag_key', 128)->primary(); + if ($collation !== null) { + $key->charset('utf8mb4')->collation($collation); + } + $table->longText('definition'); + $table->integer('version'); + if ($collation !== null) { + $table->dateTime('updated_at', 6); + } else { + $table->timestamp('updated_at', 6); + } + $table->string('updated_by', 255)->nullable(); + }); + } + + if (! $schema->hasTable(self::CHANGES)) { + $schema->create(self::CHANGES, static function (Blueprint $table) use ($collation): void { + $table->bigIncrements('id'); + $key = $table->string('flag_key', 128); + if ($collation !== null) { + $key->charset('utf8mb4')->collation($collation); + } + $table->string('action', 16); + $table->longText('definition')->nullable(); + $table->longText('previous')->nullable(); + $table->string('actor', 255)->nullable(); + if ($collation !== null) { + $table->dateTime('changed_at', 6); + } else { + $table->timestamp('changed_at', 6); + } + $table->index(['flag_key', 'id'], self::CHANGES_INDEX); + }); + } + } + + public static function drop(Builder $schema): void + { + $schema->dropIfExists(self::CHANGES); + $schema->dropIfExists(self::FLAGS); + } +} diff --git a/packages/feature-flags/src/Store/FeatureFlagStoreConfiguration.php b/packages/feature-flags/src/Store/FeatureFlagStoreConfiguration.php new file mode 100644 index 00000000..f0da5a37 --- /dev/null +++ b/packages/feature-flags/src/Store/FeatureFlagStoreConfiguration.php @@ -0,0 +1,56 @@ +store->driver === 'memory') { + return new MemoryFlagStore; + } + + // Resolve only for the database driver: a nullable resolver parameter is still made by Laravel. + $databases = $app->bound('db') ? $app->make('db') : null; + if (! $databases instanceof ConnectionResolverInterface) { + throw new ConfigurationException('firefly.feature-flags.sources.store.driver is database but no database connection resolver (db) is bound.'); + } + + return new DatabaseFlagStore($databases->connection($settings->store->connection)); + } + + #[Bean] + #[ConditionalOnProperty(name: 'firefly.feature-flags.enabled', havingValue: 'true')] + #[ConditionalOnProperty(name: 'firefly.feature-flags.sources.store.enabled', havingValue: 'true')] + #[ConditionalOnBean(FlagRegistry::class)] + #[ConditionalOnMissingBean(FlagStoreWriter::class)] + public function flagStoreWriter(FlagStore $store, FlagRegistry $registry, ApplicationEventPublisher $events, ?LoggerInterface $logger = null): FlagStoreWriter + { + return new FlagStoreWriter($store, $registry, $events, $logger ?? new NullLogger); + } +} diff --git a/packages/feature-flags/src/Store/FlagChange.php b/packages/feature-flags/src/Store/FlagChange.php new file mode 100644 index 00000000..b4471978 --- /dev/null +++ b/packages/feature-flags/src/Store/FlagChange.php @@ -0,0 +1,40 @@ +|null $definition + * @param array|null $previous + */ + public function __construct( + public int $id, + public string $key, + public string $action, + public ?array $definition, + public ?array $previous, + public ?string $actor, + public DateTimeImmutable $changedAt, + ) {} + + /** @return array{id: int, action: string, actor: ?string, changedAt: string} */ + public function toHistoryRow(): array + { + return [ + 'id' => $this->id, + 'action' => $this->action, + 'actor' => $this->actor, + 'changedAt' => $this->changedAt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'), + ]; + } +} diff --git a/packages/feature-flags/src/Store/FlagStore.php b/packages/feature-flags/src/Store/FlagStore.php new file mode 100644 index 00000000..2a158c26 --- /dev/null +++ b/packages/feature-flags/src/Store/FlagStore.php @@ -0,0 +1,23 @@ + */ + public function all(): array; + + public function get(string $key): ?StoredFlag; + + public function revision(): int; + + /** @param array $definition */ + public function put(string $key, array $definition, ?string $actor, ?int $expectedVersion = null): FlagChange; + + public function delete(string $key, ?string $actor, ?int $expectedVersion = null): ?FlagChange; + + /** @return list */ + public function history(string $key, int $limit = 50): array; +} diff --git a/packages/feature-flags/src/Store/FlagStoreConflict.php b/packages/feature-flags/src/Store/FlagStoreConflict.php new file mode 100644 index 00000000..44c6d65c --- /dev/null +++ b/packages/feature-flags/src/Store/FlagStoreConflict.php @@ -0,0 +1,27 @@ +store; + } + + /** @throws InvalidFlagDefinition|FlagStoreConflict */ + public function put(string $key, mixed $definition, ?string $actor, ?int $expectedVersion = null): FlagChange + { + // Parsing validates once and preserves object-shaped values, including an empty object variant. + $document = FlagDefinitions::parseDocument(['flags' => Json::object([$key => $definition])]); + $flag = $document->flag($key); + if ($flag === null) { + throw new InvalidFlagDefinition($key, 'flag definition must be an object'); + } + $validated = Json::members(Json::decode(Json::canonical($flag->toJsonValue()))); + $change = $this->store->put($key, $validated, $actor, $expectedVersion); + $this->afterCommit($change); + + return $change; + } + + /** @throws FlagStoreConflict */ + public function delete(string $key, ?string $actor, ?int $expectedVersion = null): ?FlagChange + { + $change = $this->store->delete($key, $actor, $expectedVersion); + if ($change !== null) { + $this->afterCommit($change); + } + + return $change; + } + + private function afterCommit(FlagChange $change): void + { + // Snapshot now: a SQL callback may run much later, after the caller mutates its input or another write. + $previous = $change->previous === null ? null : Json::encode(Json::object($change->previous)); + $current = $change->definition === null ? null : Json::encode(Json::object($change->definition)); + $notify = function () use ($change, $previous, $current): void { + try { + $this->registry->refresh(force: true, only: FlagSource::STORE); + foreach ($this->registry->states() as $state) { + if ($state->name === FlagSource::STORE && $state->status() !== 'UP') { + $this->warn('Feature flag store refresh failed after commit ({error}).', $change, new RuntimeException($state->error ?? $state->status())); + break; + } + } + } catch (Throwable $error) { + $this->warn('Feature flag store refresh failed after commit ({error}).', $change, $error); + } + + try { + $this->events->publish(new FeatureFlagUpdated( + $change->key, + $change->action, + $change->actor, + $previous === null ? null : Json::members(Json::decode($previous)), + $current === null ? null : Json::members(Json::decode($current)), + )); + } catch (Throwable $error) { + $this->warn('FeatureFlagUpdated listener failed after commit ({error}).', $change, $error); + } + }; + + if ($this->store instanceof CommitAwareFlagStore) { + $this->store->afterCommit($notify); + } else { + $notify(); + } + } + + private function warn(string $message, FlagChange $change, Throwable $error): void + { + try { + $this->logger->warning($message, ['key' => $change->key, 'action' => $change->action, 'error' => $error->getMessage()]); + } catch (Throwable) { + // Logging is an observer too; the committed change must still return. + } + } +} diff --git a/packages/feature-flags/src/Store/MemoryFlagStore.php b/packages/feature-flags/src/Store/MemoryFlagStore.php new file mode 100644 index 00000000..ecd1768b --- /dev/null +++ b/packages/feature-flags/src/Store/MemoryFlagStore.php @@ -0,0 +1,112 @@ + */ + private array $flags = []; + + /** @var list */ + private array $changes = []; + + private readonly Closure $clock; + + /** @param (Closure(): DateTimeImmutable)|null $clock */ + public function __construct(?Closure $clock = null) + { + $this->clock = $clock ?? static fn (): DateTimeImmutable => new DateTimeImmutable('now', new DateTimeZone('UTC')); + } + + public function all(): array + { + $flags = []; + foreach ($this->flags as $key => $flag) { + $flags[$key] = $this->copyFlag($flag); + } + ksort($flags, SORT_STRING); + + return $flags; + } + + public function get(string $key): ?StoredFlag + { + $flag = $this->flags[$key] ?? null; + + return $flag === null ? null : $this->copyFlag($flag); + } + + public function revision(): int + { + return count($this->changes); + } + + public function put(string $key, array $definition, ?string $actor, ?int $expectedVersion = null): FlagChange + { + $current = $this->flags[$key] ?? null; + FlagStoreConflict::check($key, $expectedVersion, $current?->version); + $now = ($this->clock)(); + $owned = self::copyDefinition($definition); + $next = new StoredFlag($key, $owned, ($current === null ? 0 : $current->version) + 1, $now, $actor); + $change = new FlagChange(count($this->changes) + 1, $key, FlagChange::PUT, $owned, $current?->definition, $actor, $now); + $this->flags[$key] = $next; + $this->changes[] = $change; + + return $this->copyChange($change); + } + + public function delete(string $key, ?string $actor, ?int $expectedVersion = null): ?FlagChange + { + $current = $this->flags[$key] ?? null; + FlagStoreConflict::check($key, $expectedVersion, $current?->version); + if ($current === null) { + return null; + } + $now = ($this->clock)(); + $change = new FlagChange(count($this->changes) + 1, $key, FlagChange::DELETE, null, $current->definition, $actor, $now); + unset($this->flags[$key]); + $this->changes[] = $change; + + return $this->copyChange($change); + } + + public function history(string $key, int $limit = 50): array + { + $rows = array_values(array_filter($this->changes, static fn (FlagChange $change): bool => $change->key === $key)); + + return array_map($this->copyChange(...), array_slice(array_reverse($rows), 0, max(0, $limit))); + } + + /** @param array $definition + * @return array + */ + private static function copyDefinition(array $definition): array + { + return Json::members(Json::decode(Json::canonical(Json::object($definition)))); + } + + private function copyFlag(StoredFlag $flag): StoredFlag + { + return new StoredFlag($flag->key, self::copyDefinition($flag->definition), $flag->version, $flag->updatedAt, $flag->updatedBy); + } + + private function copyChange(FlagChange $change): FlagChange + { + return new FlagChange( + $change->id, + $change->key, + $change->action, + $change->definition === null ? null : self::copyDefinition($change->definition), + $change->previous === null ? null : self::copyDefinition($change->previous), + $change->actor, + $change->changedAt, + ); + } +} diff --git a/packages/feature-flags/src/Store/StoreCommitCallbacks.php b/packages/feature-flags/src/Store/StoreCommitCallbacks.php new file mode 100644 index 00000000..e7afd60f --- /dev/null +++ b/packages/feature-flags/src/Store/StoreCommitCallbacks.php @@ -0,0 +1,99 @@ + */ + private array $pending = []; + + private ?Throwable $nativeAbort = null; + + public function __construct(private readonly Connection $connection) + { + $dispatcher = $connection->getEventDispatcher(); + if ($dispatcher === null) { + $dispatcher = new Dispatcher; + $connection->setEventDispatcher($dispatcher); + } + $dispatcher->listen(TransactionBeginning::class, function (TransactionBeginning $event): void { + if ($event->connection === $this->connection && $event->connection->transactionLevel() === 1) { + $this->pending = []; + $this->nativeAbort = null; + } + }); + $dispatcher->listen(TransactionCommitting::class, function (TransactionCommitting $event): void { + if ($event->connection === $this->connection && $this->nativeAbort !== null) { + $cause = $this->nativeAbort; + // transaction(callback) decrements its level after a commit exception. End PDO's stale + // transaction first, while Laravel can still perform a root rollback; never report commit success. + try { + $this->connection->rollBack(0); + } finally { + throw new FlagStoreTransactionAborted($cause); + } + } + }); + $dispatcher->listen(TransactionCommitted::class, function (TransactionCommitted $event): void { + if ($event->connection !== $this->connection || $this->nativeAbort !== null) { + return; + } + $level = $event->connection->transactionLevel(); + if ($level > 0) { + $this->pending = array_map(static fn (array $entry): array => [min($entry[0], $level), $entry[1]], $this->pending); + + return; + } + $callbacks = $this->pending; + $this->pending = []; + foreach ($callbacks as [, $callback]) { + $callback(); + } + }); + $dispatcher->listen(TransactionRolledBack::class, function (TransactionRolledBack $event): void { + if ($event->connection === $this->connection) { + $level = $event->connection->transactionLevel(); + $this->pending = array_values(array_filter($this->pending, static fn (array $entry): bool => $entry[0] <= $level)); + } + }); + } + + public function nativeTransactionAborted(Throwable $cause): void + { + $this->nativeAbort = $cause; + $this->pending = []; + } + + /** @param Closure(): void $callback */ + public function afterCommit(Closure $callback): void + { + if ($this->nativeAbort !== null) { + return; + } + $level = $this->connection->transactionLevel(); + if ($level === 0) { + $callback(); + } else { + $this->pending[] = [$level, $callback]; + } + } +} diff --git a/packages/feature-flags/src/Store/StoredFlag.php b/packages/feature-flags/src/Store/StoredFlag.php new file mode 100644 index 00000000..9906268b --- /dev/null +++ b/packages/feature-flags/src/Store/StoredFlag.php @@ -0,0 +1,19 @@ + $definition */ + public function __construct( + public string $key, + public array $definition, + public int $version, + public DateTimeImmutable $updatedAt, + public ?string $updatedBy, + ) {} +} diff --git a/packages/feature-flags/src/Store/TransactionAwareFlagStore.php b/packages/feature-flags/src/Store/TransactionAwareFlagStore.php new file mode 100644 index 00000000..123ee131 --- /dev/null +++ b/packages/feature-flags/src/Store/TransactionAwareFlagStore.php @@ -0,0 +1,11 @@ + the exposure noted for each evaluation in flight */ + private WeakMap $exposures; + + public function __construct( + private readonly ApplicationEventPublisher $events, + private readonly LoggerInterface $logger = new NullLogger, + ) { + $this->exposures = new WeakMap; + } + + public function before(HookContext $context, HookHints $hints): ?EvaluationContext + { + return null; + } + + public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void + { + if ($hints->get(FeatureFlags::PREVIEW_HINT) === true) { + return; + } + + unset($this->exposures[$context]); + try { + $error = $details->getError(); + $failed = $error !== null || $details->getReason() === 'ERROR'; + + $this->exposures[$context] = new FeatureFlagEvaluated( + $context->getFlagKey(), + ValueCopy::forExposure($details->getValue()), + $failed ? null : $details->getVariant(), + $failed ? 'ERROR' : ($details->getReason() ?? 'UNKNOWN'), + $error?->getResolutionErrorCode()->getValue(), + $context->getEvaluationContext()->getTargetingKey(), + ); + } catch (Throwable $failure) { + $this->logFailure($context->getFlagKey(), $failure); + } + } + + public function error(HookContext $context, Throwable $error, HookHints $hints): void + { + if ($hints->get(FeatureFlags::PREVIEW_HINT) === true) { + return; + } + + unset($this->exposures[$context]); + try { + $this->exposures[$context] = new FeatureFlagEvaluated( + $context->getFlagKey(), + ValueCopy::forExposure($context->getDefaultValue()), + null, + 'ERROR', + self::errorCode($error), + $context->getEvaluationContext()->getTargetingKey(), + ); + } catch (Throwable $failure) { + $this->logFailure($context->getFlagKey(), $failure); + } + } + + public function finally(HookContext $context, HookHints $hints): void + { + $event = $this->exposures[$context] ?? null; + unset($this->exposures[$context]); + if ($event === null) { + return; + } + + try { + $this->events->publish($event); + } catch (Throwable $failure) { + $this->logFailure($event->key, $failure); + } + } + + public function supportsFlagValueType(string $flagValueType): bool + { + return true; + } + + private function logFailure(string $key, Throwable $failure): void + { + try { + $this->logger->debug('Publishing the exposure of feature flag [{flag}] failed: {error}', ['flag' => $key, 'error' => $failure->getMessage()]); + } catch (Throwable) { + // Logging is best effort too: telemetry must not change an evaluation. + } + } + + /** The code the SDK answers a thrown failure with: the one it carries, else GENERAL. */ + private static function errorCode(Throwable $error): string + { + $general = ErrorCode::GENERAL()->getValue(); + if (! $error instanceof ThrowableWithResolutionError) { + return $general; + } + + try { + return $error->getResolutionError()->getResolutionErrorCode()->getValue(); + } catch (Throwable) { + return $general; + } + } +} diff --git a/packages/feature-flags/src/Telemetry/FeatureFlagMetrics.php b/packages/feature-flags/src/Telemetry/FeatureFlagMetrics.php new file mode 100644 index 00000000..e797722e --- /dev/null +++ b/packages/feature-flags/src/Telemetry/FeatureFlagMetrics.php @@ -0,0 +1,14 @@ + the [variant, reason] noted for each evaluation in flight */ + private WeakMap $outcomes; + + public function __construct( + private readonly FeatureFlagMetrics $metrics, + private readonly LoggerInterface $logger = new NullLogger, + ) { + $this->outcomes = new WeakMap; + } + + public function before(HookContext $context, HookHints $hints): ?EvaluationContext + { + return null; + } + + public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void + { + if ($hints->get(FeatureFlags::PREVIEW_HINT) === true) { + return; + } + + $variant = $details->getVariant(); + $this->outcomes[$context] = $details->getError() !== null || $details->getReason() === 'ERROR' + ? ['none', 'ERROR'] + : [$variant === null || $variant === '' ? 'none' : $variant, $details->getReason() ?? 'UNKNOWN']; + } + + public function error(HookContext $context, Throwable $error, HookHints $hints): void + { + if ($hints->get(FeatureFlags::PREVIEW_HINT) === true) { + return; + } + + $this->outcomes[$context] = ['none', 'ERROR']; + } + + public function finally(HookContext $context, HookHints $hints): void + { + $outcome = $this->outcomes[$context] ?? null; + unset($this->outcomes[$context]); + if ($outcome === null) { + return; + } + + [$variant, $reason] = $outcome; + try { + $this->metrics->recordEvaluation($context->getFlagKey(), $variant, $reason); + } catch (Throwable $failure) { + try { + $this->logger->debug('Recording the evaluation of feature flag [{flag}] failed: {error}', ['flag' => $context->getFlagKey(), 'error' => $failure->getMessage()]); + } catch (Throwable) { + // Logging is best effort too: telemetry must not change an evaluation. + } + } + } + + public function supportsFlagValueType(string $flagValueType): bool + { + return true; + } +} diff --git a/packages/feature-flags/src/Telemetry/NoOpFeatureFlagMetrics.php b/packages/feature-flags/src/Telemetry/NoOpFeatureFlagMetrics.php new file mode 100644 index 00000000..351d6889 --- /dev/null +++ b/packages/feature-flags/src/Telemetry/NoOpFeatureFlagMetrics.php @@ -0,0 +1,10 @@ + dirname(__DIR__).'/src']; + $cache = dirname(__DIR__).'/cache'; + + $components = sys_get_temp_dir().'/firefly-feature-flags-fresh-c-'.bin2hex(random_bytes(6)).'.php'; + $context = sys_get_temp_dir().'/firefly-feature-flags-fresh-x-'.bin2hex(random_bytes(6)).'.php'; + + try { + (new AutoConfigManifestCompiler)->write($src, $components, $context); + + expect(ComponentManifest::load($components))->toEqual(ComponentManifest::load($cache.'/firefly-feature-flags-components.php')) + ->and(ContextManifest::load($context))->toEqual(ContextManifest::load($cache.'/firefly-feature-flags-context.php')); + } finally { + @unlink($components); + @unlink($context); + } +}); diff --git a/packages/feature-flags/tests/Conformance/MANIFEST.sha256 b/packages/feature-flags/tests/Conformance/MANIFEST.sha256 new file mode 100644 index 00000000..303e4e9d --- /dev/null +++ b/packages/feature-flags/tests/Conformance/MANIFEST.sha256 @@ -0,0 +1,24 @@ +f47ea73118b7efa7648b944b9921e5120a711db35c26d88e15a12958c54d8097 cli-input-vectors.json +f7b17c03618b3c03b387c3c6ea49ce2b46cff3429f0a1aebc86dc7788f388b5a context-vectors.json +d14f713aa8196d765f3bd9d886b52a73e24ec0acddbe5c4a0dfeba926cfa01a1 exposure-vectors.json +2065a5672a54a72adc125932ece631824d04a7524cc73e15be82612ad5cb9033 firefly-vectors.json +8f656618d96a13ff5bb93ba30189bd3e328970a3915bfd39f6f9bb249e7419fe http-vectors.json +a41894e067cd117e3a146da1ac1e0c0522ca50dc28b8aa80ba83003c2246e332 management-transport-vectors.json +be87cb28bd6075ab4b03069a2f2f88531b349eee99f92d5f52215e98dd9027f8 management-vectors.json +46b02fa95a14af3e3598ea255c3771f7914765a6d3988402ad10f0a184131bdf observer-vectors.json +bf92b09408fb31563c98e15741ab98d68ca1d511763cdc1cb5a2f0ddd23d54ac override-vectors.json +0b2f730da0fa8298e1024ac4461c49ac8e5b601a6c98a5e69d96b66fba706601 store-concurrency-vectors.json +f964c4c458fcc3741ac1639dc28bef1e42c016b37cab9fe84400fc3b597dc5ab testbed/LICENSE +b1dbd97a0907abffb384923f5a140d1307b2c4bb2b3299fc3e3c11406b941334 testbed/SOURCE.md +62feadf4fbf95696e56839f8e0feafc36d9573f67a37c22a96722e5721bbb2d4 testbed/evaluator/flags/testkit-flags.json +5b0cd778e21fed444feedbfa5a25ac058a30809ce4b44d6df56c5a2840e1e599 testbed/evaluator/gherkin/disabled.feature +a6cfaab4518dcfb3ee5b0f0886f84c3b1a1f2948f4ae289599ea731cd0139240 testbed/evaluator/gherkin/errors.feature +407d12c02a2c0213258b6e61cf914eb80f18a8d57318398f85e6cf5d4c07cf03 testbed/evaluator/gherkin/evaluation.feature +1f3c6ce05e4bb3f96f202ce2a79bcf72d5575775b1010f746d63eb66d26fc33b testbed/evaluator/gherkin/evaluator-refs.feature +95adac13ab953cf78e1fff919ce66b2395f716cd988537a7119ec66bbeb7a82b testbed/evaluator/gherkin/fractional.feature +eec9ba093968ce4ed6cc2b68ac70c7f197545fb310c335efeba76a893fa13122 testbed/evaluator/gherkin/metadata.feature +78a6255f0dc1846cd5a148fc0f7f4b20109c50590a94b7a76b73c67bfe8f9c5e testbed/evaluator/gherkin/no-default-variant.feature +4bba88f15e3e8266af4c19bfa86ece882c19e2b406ac21b9543128ccc616c57f testbed/evaluator/gherkin/semver.feature +2ed75d12b2e4710370645f60923f540b014a80653c43bf857a1f43888dcf673d testbed/evaluator/gherkin/string.feature +9e381b848f62336e725bd283d5e1581c1526fe67dc59487f14161757a448aa01 testbed/evaluator/gherkin/targeting.feature +e0e9239ada2e2d5eb534b1c8bd35adc690ba58fdad3dafd39d03eecccd071e84 testbed/evaluator/gherkin/zero-values.feature diff --git a/packages/feature-flags/tests/Conformance/cli-input-vectors.json b/packages/feature-flags/tests/Conformance/cli-input-vectors.json new file mode 100644 index 00000000..9f73644d --- /dev/null +++ b/packages/feature-flags/tests/Conformance/cli-input-vectors.json @@ -0,0 +1 @@ +{"version":1,"flags":{"kill":true},"action":"evaluate","key":"kill","cases":[{"name":"omitted context","options":{},"expect":{"exit":0,"value":true}},{"name":"empty context text","options":{"context":""},"expect":{"exit":1,"error":"bad-request"}},{"name":"whitespace context","options":{"context":" "},"expect":{"exit":1,"error":"bad-request"}},{"name":"malformed context","options":{"context":"{bad"},"expect":{"exit":1,"error":"bad-request"}},{"name":"null context","options":{"context":"null"},"expect":{"exit":1,"error":"bad-request"}},{"name":"empty list context","options":{"context":"[]"},"expect":{"exit":1,"error":"bad-request"}},{"name":"list context","options":{"context":"[1]"},"expect":{"exit":1,"error":"bad-request"}},{"name":"boolean context","options":{"context":"true"},"expect":{"exit":1,"error":"bad-request"}},{"name":"number context","options":{"context":"1"},"expect":{"exit":1,"error":"bad-request"}},{"name":"string context","options":{"context":"\"text\""},"expect":{"exit":1,"error":"bad-request"}},{"name":"empty object context","options":{"context":"{}"},"expect":{"exit":0,"value":true}},{"name":"object context","options":{"context":"{\"plan\":\"pro\"}"},"expect":{"exit":0,"value":true}}]} diff --git a/packages/feature-flags/tests/Conformance/context-vectors.json b/packages/feature-flags/tests/Conformance/context-vectors.json new file mode 100644 index 00000000..3a615916 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/context-vectors.json @@ -0,0 +1 @@ +{"version":1,"cases":[{"name":"integer","ambientKey":"ambient","context":{"targetingKey":42,"plan":"pro"},"expect":{"targetingKey":"42","attributes":{"plan":"pro"},"refused":false}},{"name":"text","ambientKey":"ambient","context":{"targetingKey":"42","plan":"pro"},"expect":{"targetingKey":"42","attributes":{"plan":"pro"},"refused":false}},{"name":"zero integer","ambientKey":"ambient","context":{"targetingKey":0,"plan":"pro"},"expect":{"targetingKey":"0","attributes":{"plan":"pro"},"refused":false}},{"name":"negative integer","ambientKey":"ambient","context":{"targetingKey":-42,"plan":"pro"},"expect":{"targetingKey":"-42","attributes":{"plan":"pro"},"refused":false}},{"name":"boolean true","ambientKey":"ambient","context":{"targetingKey":true,"plan":"pro"},"expect":{"targetingKey":"ambient","attributes":{"plan":"pro"},"refused":true}},{"name":"boolean false","ambientKey":"ambient","context":{"targetingKey":false,"plan":"pro"},"expect":{"targetingKey":"ambient","attributes":{"plan":"pro"},"refused":true}},{"name":"whole float","ambientKey":"ambient","context":{"targetingKey":42.0,"plan":"pro"},"expect":{"targetingKey":"ambient","attributes":{"plan":"pro"},"refused":true}},{"name":"fractional float","ambientKey":"ambient","context":{"targetingKey":42.5,"plan":"pro"},"expect":{"targetingKey":"ambient","attributes":{"plan":"pro"},"refused":true}},{"name":"object","ambientKey":"ambient","context":{"targetingKey":{},"plan":"pro"},"expect":{"targetingKey":"ambient","attributes":{"plan":"pro"},"refused":true}},{"name":"list","ambientKey":"ambient","context":{"targetingKey":[],"plan":"pro"},"expect":{"targetingKey":"ambient","attributes":{"plan":"pro"},"refused":true}},{"name":"null","ambientKey":"ambient","context":{"targetingKey":null,"plan":"pro"},"expect":{"targetingKey":"ambient","attributes":{"plan":"pro"},"refused":false}},{"name":"empty text","ambientKey":"ambient","context":{"targetingKey":"","plan":"pro"},"expect":{"targetingKey":"ambient","attributes":{"plan":"pro"},"refused":false}}]} diff --git a/packages/feature-flags/tests/Conformance/exposure-vectors.json b/packages/feature-flags/tests/Conformance/exposure-vectors.json new file mode 100644 index 00000000..599220dd --- /dev/null +++ b/packages/feature-flags/tests/Conformance/exposure-vectors.json @@ -0,0 +1 @@ +{"version":1,"limit":10000,"cases":[{"name":"scalar snapshot","value":true,"occurrences":1,"expose":true},{"name":"nested containers and null","value":{"items":[null,false,1,"x"],"empty":{}},"occurrences":7,"expose":true},{"name":"list at 10000","value":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"occurrences":10000,"expose":true},{"name":"list over 10000","value":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"occurrences":10001,"expose":false},{"name":"object at 10000","value":{"items":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"occurrences":10000,"expose":true},{"name":"object over 10000","value":{"items":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"occurrences":10001,"expose":false}]} diff --git a/packages/feature-flags/tests/Conformance/firefly-vectors.json b/packages/feature-flags/tests/Conformance/firefly-vectors.json new file mode 100644 index 00000000..758a157d --- /dev/null +++ b/packages/feature-flags/tests/Conformance/firefly-vectors.json @@ -0,0 +1,104 @@ +{ + "version": 1, + "today": "2026-10-01", + "cases": [ + {"name": "shorthand true is an enabled boolean flag on 'on'", "kind": "normalize", "input": {"kill-switch": true}, "expect": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}}}, + {"name": "shorthand false is an enabled boolean flag on 'off'", "kind": "normalize", "input": {"new-checkout": false}, "expect": {"new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"}}}, + {"name": "shorthand string is a single-variant string flag named by its value", "kind": "normalize", "input": {"checkout-flow": "v2"}, "expect": {"checkout-flow": {"state": "ENABLED", "variants": {"v2": "v2"}, "defaultVariant": "v2"}}}, + {"name": "a full definition passes through unchanged", "kind": "normalize", "input": {"banner-color": {"state": "DISABLED", "variants": {"red": "#f00", "blue": "#00f"}, "defaultVariant": "blue", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "red", null]}, "metadata": {"owner": "web", "kind": "experiment", "expires": "2026-12-31", "rank": 3, "sticky": true}}}, "expect": {"banner-color": {"state": "DISABLED", "variants": {"red": "#f00", "blue": "#00f"}, "defaultVariant": "blue", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "red", null]}, "metadata": {"owner": "web", "kind": "experiment", "expires": "2026-12-31", "rank": 3, "sticky": true}}}}, + {"name": "shorthand and full definitions mix in one map", "kind": "normalize", "input": {"a": true, "b": "blue", "c": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"}}, "expect": {"a": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "b": {"state": "ENABLED", "variants": {"blue": "blue"}, "defaultVariant": "blue"}, "c": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"}}}, + {"name": "a well-formed document is valid", "kind": "validate", "input": {"flags": {"a.b_c-1": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"}, "no-default": {"state": "ENABLED", "variants": {"x": 1, "y": 2}}, "null-default": {"state": "ENABLED", "variants": {"x": 1.5}, "defaultVariant": null}, "empty-targeting": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {}}, "objects": {"state": "ENABLED", "variants": {"a": {"k": 1}, "b": [1, 2]}, "defaultVariant": "a"}, "with-ref": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta"}, "on", null]}}, "with-missing-ref": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "nope"}, "on", null]}}, "meta": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "metadata": {"description": "d", "owner": "o", "kind": "ops", "expires": "2027-01-31", "n": 1, "f": 0.5, "b": false}}}, "$evaluators": {"beta": {"in": ["beta", {"var": "roles"}]}}}, "expect": {"valid": true}}, + {"name": "a key with a space is invalid", "kind": "validate", "input": {"flags": {"bad key": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"}}}, "expect": {"valid": false, "key": "bad key", "error": "invalid flag key"}}, + {"name": "a key starting with a dash is invalid", "kind": "validate", "input": {"flags": {"-lead": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"}}}, "expect": {"valid": false, "key": "-lead", "error": "invalid flag key"}}, + {"name": "a key longer than 128 characters is invalid", "kind": "validate", "input": {"flags": {"kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"}}}, "expect": {"valid": false, "key": "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk", "error": "invalid flag key"}}, + {"name": "state must be ENABLED or DISABLED", "kind": "validate", "input": {"flags": {"s": {"state": "ON", "variants": {"on": true, "off": false}, "defaultVariant": "off"}}}, "expect": {"valid": false, "key": "s", "error": "state must be ENABLED or DISABLED"}}, + {"name": "a missing state is invalid", "kind": "validate", "input": {"flags": {"s": {"variants": {"on": true}, "defaultVariant": "on"}}}, "expect": {"valid": false, "key": "s", "error": "state must be ENABLED or DISABLED"}}, + {"name": "variants must be a non-empty object", "kind": "validate", "input": {"flags": {"v": {"state": "ENABLED", "variants": {}}}}, "expect": {"valid": false, "key": "v", "error": "variants must be a non-empty object"}}, + {"name": "variants as a list are invalid", "kind": "validate", "input": {"flags": {"v": {"state": "ENABLED", "variants": [true, false]}}}, "expect": {"valid": false, "key": "v", "error": "variants must be a non-empty object"}}, + {"name": "variants must share one type", "kind": "validate", "input": {"flags": {"m": {"state": "ENABLED", "variants": {"a": true, "b": "x"}}}}, "expect": {"valid": false, "key": "m", "error": "variants must share one type"}}, + {"name": "integer and float variants share the number type only if all are numbers", "kind": "validate", "input": {"flags": {"n": {"state": "ENABLED", "variants": {"a": 1, "b": "2"}}}}, "expect": {"valid": false, "key": "n", "error": "variants must share one type"}}, + {"name": "defaultVariant must name a variant", "kind": "validate", "input": {"flags": {"d": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "maybe"}}}, "expect": {"valid": false, "key": "d", "error": "defaultVariant is not a variant"}}, + {"name": "targeting must be an object", "kind": "validate", "input": {"flags": {"t": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": ["on"]}}}, "expect": {"valid": false, "key": "t", "error": "targeting must be an object"}}, + {"name": "metadata values must be scalars", "kind": "validate", "input": {"flags": {"md": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "metadata": {"tags": ["a"]}}}}, "expect": {"valid": false, "key": "md", "error": "metadata values must be scalars"}}, + {"name": "kind must be a known kind", "kind": "validate", "input": {"flags": {"k": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "metadata": {"kind": "temporary"}}}}, "expect": {"valid": false, "key": "k", "error": "kind must be one of release, experiment, ops, permission"}}, + {"name": "expires must be a calendar date", "kind": "validate", "input": {"flags": {"e": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "metadata": {"expires": "2026-13-01"}}}}, "expect": {"valid": false, "key": "e", "error": "expires must be a YYYY-MM-DD date"}}, + {"name": "expires must not carry a time", "kind": "validate", "input": {"flags": {"e2": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "metadata": {"expires": "2026-12-01T00:00:00Z"}}}}, "expect": {"valid": false, "key": "e2", "error": "expires must be a YYYY-MM-DD date"}}, + {"name": "owner must be a string", "kind": "validate", "input": {"flags": {"o": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "metadata": {"owner": 7}}}}, "expect": {"valid": false, "key": "o", "error": "owner must be a string"}}, + {"name": "description must be a string", "kind": "validate", "input": {"flags": {"ds": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "metadata": {"description": true}}}}, "expect": {"valid": false, "key": "ds", "error": "description must be a string"}}, + {"name": "a flag definition must be an object", "kind": "validate", "input": {"flags": {"x": 42}}, "expect": {"valid": false, "key": "x", "error": "flag definition must be an object"}}, + {"name": "flag metadata keys must not be empty", "kind": "validate", "input": {"flags": {"mk": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "metadata": {"": "x"}}}}, "expect": {"valid": false, "key": "mk", "error": "metadata keys must not be empty"}}, + {"name": "a null variant value is not a flag value", "kind": "validate", "input": {"flags": {"nv": {"state": "ENABLED", "variants": {"a": null}}}}, "expect": {"valid": false, "key": "nv", "error": "variants must share one type"}}, + {"name": "a null variant value cannot share a type with others", "kind": "validate", "input": {"flags": {"nv2": {"state": "ENABLED", "variants": {"a": "x", "b": null}, "defaultVariant": "a"}}}, "expect": {"valid": false, "key": "nv2", "error": "variants must share one type"}}, + {"name": "an evaluator rule must be an object", "kind": "validate", "input": {"flags": {"x": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off"}}, "$evaluators": {"broken": 5}}, "expect": {"valid": false, "key": "$evaluators", "error": "targeting must be an object"}}, + {"name": "a definition nesting deeper than 256 levels is invalid", "kind": "validate", "input": {"flags": {"deep": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [true]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}}}}, "expect": {"valid": false, "key": "deep", "error": "definition nests too deeply"}}, + {"name": "a definition nesting about 200 levels is valid", "kind": "validate", "input": {"flags": {"deep-ok": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [{"and": [true]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}]}}}}, "expect": {"valid": true}}, + {"name": "an empty defaultVariant naming a variant called \"\" is valid", "kind": "validate", "input": {"flags": {"ed": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}}}, "expect": {"valid": true}}, + {"name": "an empty list stands for an empty object in targeting, metadata and the document sections", "kind": "validate", "input": {"flags": {"el": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": [], "metadata": []}}, "$evaluators": []}, "expect": {"valid": true}}, + {"name": "the highest layer defining a key supplies its whole definition", "kind": "compose", "layers": [{"source": "config", "flags": {"alpha": true, "checkout-flow": "v1", "gamma": false}}, {"source": "file", "flags": {"checkout-flow": {"state": "ENABLED", "variants": {"v1": "v1", "v2": "v2"}, "defaultVariant": "v1"}}}, {"source": "http", "flags": {"gamma": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on", "metadata": {"owner": "ops"}}}}, {"source": "store", "flags": {"checkout-flow": {"state": "DISABLED", "variants": {"v1": "v1", "v2": "v2"}, "defaultVariant": "v2"}}}], "expect": {"flags": {"alpha": {"origin": "config", "overrides": [], "definition": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}}, "checkout-flow": {"origin": "store", "overrides": ["config", "file"], "definition": {"state": "DISABLED", "variants": {"v1": "v1", "v2": "v2"}, "defaultVariant": "v2"}}, "gamma": {"origin": "http", "overrides": ["config"], "definition": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on", "metadata": {"owner": "ops"}}}}}}, + {"name": "a disabled definition in a higher layer still shadows the lower one", "kind": "compose", "layers": [{"source": "config", "flags": {"beta": true}}, {"source": "store", "flags": {"beta": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}}}], "expect": {"flags": {"beta": {"origin": "store", "overrides": ["config"], "definition": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}}}}}, + {"name": "evaluators merge per name with the same precedence", "kind": "compose", "layers": [{"source": "config", "flags": {"x": true}, "$evaluators": {"beta": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es"]]}}}, {"source": "http", "flags": {}, "$evaluators": {"eu": {"in": [{"var": "region"}, ["es", "fr", "de"]]}}}], "expect": {"flags": {"x": {"origin": "config", "overrides": [], "definition": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}}}, "evaluators": {"beta": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de"]]}}}}, + {"name": "an empty layer changes nothing", "kind": "compose", "layers": [{"source": "config", "flags": {"z": "blue"}}, {"source": "file", "flags": {}}], "expect": {"flags": {"z": {"origin": "config", "overrides": [], "definition": {"state": "ENABLED", "variants": {"blue": "blue"}, "defaultVariant": "blue"}}}}}, + {"name": "a flag expires the day after its expires date (UTC)", "kind": "expiry", "flags": {"yesterday": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on", "metadata": {"expires": "2026-09-30"}}, "today": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on", "metadata": {"expires": "2026-10-01"}}, "later": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on", "metadata": {"expires": "2026-12-31"}}, "never": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "long-ago": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on", "metadata": {"expires": "2025-01-01"}}}, "expect": {"expired": ["long-ago", "yesterday"]}}, + {"name": "a kill switch defined by shorthand is on", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "kill-switch", "type": "boolean", "default": false, "expect": {"value": true, "variant": "on", "reason": "STATIC"}}, + {"name": "a beta user gets the release", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "new-checkout", "type": "boolean", "default": false, "targetingKey": "u-1", "context": {"roles": ["beta", "staff"]}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "a regular user falls through to the default variant", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "new-checkout", "type": "boolean", "default": true, "targetingKey": "u-2", "context": {"roles": ["staff"]}, "expect": {"value": false, "variant": "off", "reason": "DEFAULT"}}, + {"name": "an anonymous user without roles falls through", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "new-checkout", "type": "boolean", "default": true, "expect": {"value": false, "variant": "off", "reason": "DEFAULT"}}, + {"name": "a shared evaluator selects beta users", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "beta-segment", "type": "boolean", "default": false, "targetingKey": "u-3", "context": {"roles": ["beta"]}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "a shared evaluator rejects others", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "beta-segment", "type": "boolean", "default": true, "targetingKey": "u-4", "context": {"roles": []}, "expect": {"value": false, "variant": "off", "reason": "TARGETING_MATCH"}}, + {"name": "a pro plan is entitled", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "pro-reports", "type": "boolean", "default": false, "targetingKey": "u-5", "context": {"tenant": "t1", "plan": "pro"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "a free plan is not entitled", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "pro-reports", "type": "boolean", "default": true, "targetingKey": "u-6", "context": {"tenant": "t2", "plan": "free"}, "expect": {"value": false, "variant": "off", "reason": "TARGETING_MATCH"}}, + {"name": "a missing plan is not entitled", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "pro-reports", "type": "boolean", "default": true, "targetingKey": "u-7", "context": {"tenant": "t3"}, "expect": {"value": false, "variant": "off", "reason": "TARGETING_MATCH"}}, + {"name": "the pilot tenant gets the feature", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "tenant-pilot", "type": "boolean", "default": false, "targetingKey": "u-8", "context": {"tenant": "acme"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "another tenant falls through", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "tenant-pilot", "type": "boolean", "default": true, "targetingKey": "u-9", "context": {"tenant": "globex"}, "expect": {"value": false, "variant": "off", "reason": "DEFAULT"}}, + {"name": "experiment arm for alice", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "checkout-flow", "type": "string", "default": "fallback", "targetingKey": "alice@example.com", "expect": {"value": "v1", "variant": "control", "reason": "TARGETING_MATCH"}}, + {"name": "experiment arm for bob", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "checkout-flow", "type": "string", "default": "fallback", "targetingKey": "bob@example.com", "expect": {"value": "v2", "variant": "treatment", "reason": "TARGETING_MATCH"}}, + {"name": "experiment arm for carol", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "checkout-flow", "type": "string", "default": "fallback", "targetingKey": "carol@example.com", "expect": {"value": "v1", "variant": "control", "reason": "TARGETING_MATCH"}}, + {"name": "experiment arm for dave", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "checkout-flow", "type": "string", "default": "fallback", "targetingKey": "dave@example.com", "expect": {"value": "v2", "variant": "treatment", "reason": "TARGETING_MATCH"}}, + {"name": "experiment without a targeting key uses the default variant", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "checkout-flow", "type": "string", "default": "fallback", "expect": {"value": "v1", "variant": "control", "reason": "DEFAULT"}}, + {"name": "10 percent rollout, user 42", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "rollout-10", "type": "boolean", "default": false, "targetingKey": "user-42", "expect": {"value": false, "variant": "off", "reason": "TARGETING_MATCH"}}, + {"name": "10 percent rollout, user 7", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "rollout-10", "type": "boolean", "default": false, "targetingKey": "user-7", "expect": {"value": false, "variant": "off", "reason": "TARGETING_MATCH"}}, + {"name": "10 percent rollout, user 4 is inside the 10 percent", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "rollout-10", "type": "boolean", "default": false, "targetingKey": "user-4", "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "semver gate passes a newer app", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "min-app-version", "type": "boolean", "default": false, "targetingKey": "u-10", "context": {"appVersion": "2.10.1"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "semver gate rejects an older app", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "min-app-version", "type": "boolean", "default": true, "targetingKey": "u-11", "context": {"appVersion": "2.3.9"}, "expect": {"value": false, "variant": "off", "reason": "TARGETING_MATCH"}}, + {"name": "semver gate accepts a v prefix", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "min-app-version", "type": "boolean", "default": false, "targetingKey": "u-12", "context": {"appVersion": "v2.4.0"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "ends_with on an email", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "email-domain", "type": "boolean", "default": false, "targetingKey": "u-13", "context": {"email": "ana@firefly.dev"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "ends_with rejects another domain", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "email-domain", "type": "boolean", "default": true, "targetingKey": "u-14", "context": {"email": "ana@example.org"}, "expect": {"value": false, "variant": "off", "reason": "TARGETING_MATCH"}}, + {"name": "a disabled flag returns the caller default", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "paused", "type": "boolean", "default": false, "targetingKey": "u-15", "expect": {"value": false, "variant": null, "reason": "DISABLED"}}, + {"name": "an integer flag for an admin", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "page-size", "type": "integer", "default": 25, "targetingKey": "u-16", "context": {"roles": ["admin"]}, "expect": {"value": 50, "variant": "large", "reason": "TARGETING_MATCH"}}, + {"name": "an integer flag for others", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "page-size", "type": "integer", "default": 25, "targetingKey": "u-17", "context": {"roles": []}, "expect": {"value": 10, "variant": "small", "reason": "DEFAULT"}}, + {"name": "a float flag without targeting", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "sample-rate", "type": "float", "default": 1.0, "expect": {"value": 0.05, "variant": "low", "reason": "STATIC"}}, + {"name": "an object flag on staging", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "banner", "type": "object", "default": {}, "targetingKey": "u-18", "context": {"profiles": ["staging", "eu"]}, "expect": {"value": {"title": "50% off", "dismissible": false, "ttl": 3}, "variant": "promo", "reason": "TARGETING_MATCH"}}, + {"name": "an object flag elsewhere", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "banner", "type": "object", "default": {}, "targetingKey": "u-19", "context": {"profiles": ["prod"]}, "expect": {"value": {"title": "Hello", "dismissible": true}, "variant": "plain", "reason": "DEFAULT"}}, + {"name": "an evaluator over a list of regions", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "region-gate", "type": "boolean", "default": false, "targetingKey": "u-20", "context": {"region": "es"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "an evaluator rejecting a region", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "region-gate", "type": "boolean", "default": true, "targetingKey": "u-21", "context": {"region": "us"}, "expect": {"value": false, "variant": "off", "reason": "TARGETING_MATCH"}}, + {"name": "no default variant and no match returns the caller default", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "no-default", "type": "string", "default": "caller", "targetingKey": "u-22", "context": {"tenant": "globex"}, "expect": {"value": "caller", "variant": null, "reason": "DEFAULT"}}, + {"name": "no default variant with a match", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "no-default", "type": "string", "default": "caller", "targetingKey": "u-23", "context": {"tenant": "acme"}, "expect": {"value": "B", "variant": "b", "reason": "TARGETING_MATCH"}}, + {"name": "no default variant and no targeting returns the caller default", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "no-default-static", "type": "string", "default": "caller", "targetingKey": "u-25", "expect": {"value": "caller", "variant": null, "reason": "DEFAULT"}}, + {"name": "targeting naming a variant that does not exist is a general error", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "bad-variant", "type": "string", "default": "caller", "targetingKey": "u-26", "expect": {"value": "caller", "variant": null, "reason": "ERROR", "errorCode": "GENERAL"}}, + {"name": "an empty defaultVariant means no default variant, even when a variant is named \"\"", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "empty-default", "type": "string", "default": "caller", "targetingKey": "u-39", "expect": {"value": "caller", "variant": null, "reason": "DEFAULT"}}, + {"name": "a backslash inside a shared evaluator is kept as written", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "windows-path", "type": "boolean", "default": false, "targetingKey": "u-27", "context": {"path": "C:\\temp\\new"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "a backslash path that differs does not match", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "windows-path", "type": "boolean", "default": true, "targetingKey": "u-28", "context": {"path": "C:\\temp\\old"}, "expect": {"value": false, "variant": "off", "reason": "TARGETING_MATCH"}}, + {"name": "an evaluator referencing an evaluator resolves transitively", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "nested-segment", "type": "boolean", "default": false, "targetingKey": "u-29", "context": {"roles": ["staff"], "region": "fr"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "a transitive reference rejects a region outside the segment", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "nested-segment", "type": "boolean", "default": true, "targetingKey": "u-30", "context": {"roles": ["staff"], "region": "us"}, "expect": {"value": false, "variant": "off", "reason": "TARGETING_MATCH"}}, + {"name": "transitive references resolve whatever the evaluator names sort as", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "nested-segment-reverse", "type": "boolean", "default": false, "targetingKey": "u-31", "context": {"roles": ["staff"], "region": "de"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "a reference cycle is a parse error", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "cyclic-segment", "type": "boolean", "default": true, "targetingKey": "u-32", "expect": {"value": true, "variant": null, "reason": "ERROR", "errorCode": "PARSE_ERROR"}}, + {"name": "an unknown flag", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "does-not-exist", "type": "boolean", "default": true, "targetingKey": "u-24", "expect": {"value": true, "variant": null, "reason": "ERROR", "errorCode": "FLAG_NOT_FOUND"}}, + {"name": "a type mismatch returns the caller default", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "checkout-flow", "type": "boolean", "default": false, "targetingKey": "alice@example.com", "expect": {"value": false, "variant": null, "reason": "ERROR", "errorCode": "TYPE_MISMATCH"}}, + {"name": "a fan-out of references within the expansion limit evaluates", "kind": "evaluate", "document": {"flags": {"fan-out-small": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "fan-5"}, "on", "off"]}}, "fan-out-huge": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "fan-20"}, "on", "off"]}}, "deep-chain-short": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "chain-20"}, "on", "off"]}}, "deep-chain-long": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "chain-200"}, "on", "off"]}}}, "$evaluators": {"fan-0": {"==": [{"var": "tier"}, "gold"]}, "fan-1": {"or": [{"$ref": "fan-0"}, {"$ref": "fan-0"}]}, "fan-2": {"or": [{"$ref": "fan-1"}, {"$ref": "fan-1"}]}, "fan-3": {"or": [{"$ref": "fan-2"}, {"$ref": "fan-2"}]}, "fan-4": {"or": [{"$ref": "fan-3"}, {"$ref": "fan-3"}]}, "fan-5": {"or": [{"$ref": "fan-4"}, {"$ref": "fan-4"}]}, "fan-6": {"or": [{"$ref": "fan-5"}, {"$ref": "fan-5"}]}, "fan-7": {"or": [{"$ref": "fan-6"}, {"$ref": "fan-6"}]}, "fan-8": {"or": [{"$ref": "fan-7"}, {"$ref": "fan-7"}]}, "fan-9": {"or": [{"$ref": "fan-8"}, {"$ref": "fan-8"}]}, "fan-10": {"or": [{"$ref": "fan-9"}, {"$ref": "fan-9"}]}, "fan-11": {"or": [{"$ref": "fan-10"}, {"$ref": "fan-10"}]}, "fan-12": {"or": [{"$ref": "fan-11"}, {"$ref": "fan-11"}]}, "fan-13": {"or": [{"$ref": "fan-12"}, {"$ref": "fan-12"}]}, "fan-14": {"or": [{"$ref": "fan-13"}, {"$ref": "fan-13"}]}, "fan-15": {"or": [{"$ref": "fan-14"}, {"$ref": "fan-14"}]}, "fan-16": {"or": [{"$ref": "fan-15"}, {"$ref": "fan-15"}]}, "fan-17": {"or": [{"$ref": "fan-16"}, {"$ref": "fan-16"}]}, "fan-18": {"or": [{"$ref": "fan-17"}, {"$ref": "fan-17"}]}, "fan-19": {"or": [{"$ref": "fan-18"}, {"$ref": "fan-18"}]}, "fan-20": {"or": [{"$ref": "fan-19"}, {"$ref": "fan-19"}]}, "chain-0": {"==": [{"var": "tier"}, "gold"]}, "chain-1": {"and": [{"$ref": "chain-0"}]}, "chain-2": {"and": [{"$ref": "chain-1"}]}, "chain-3": {"and": [{"$ref": "chain-2"}]}, "chain-4": {"and": [{"$ref": "chain-3"}]}, "chain-5": {"and": [{"$ref": "chain-4"}]}, "chain-6": {"and": [{"$ref": "chain-5"}]}, "chain-7": {"and": [{"$ref": "chain-6"}]}, "chain-8": {"and": [{"$ref": "chain-7"}]}, "chain-9": {"and": [{"$ref": "chain-8"}]}, "chain-10": {"and": [{"$ref": "chain-9"}]}, "chain-11": {"and": [{"$ref": "chain-10"}]}, "chain-12": {"and": [{"$ref": "chain-11"}]}, "chain-13": {"and": [{"$ref": "chain-12"}]}, "chain-14": {"and": [{"$ref": "chain-13"}]}, "chain-15": {"and": [{"$ref": "chain-14"}]}, "chain-16": {"and": [{"$ref": "chain-15"}]}, "chain-17": {"and": [{"$ref": "chain-16"}]}, "chain-18": {"and": [{"$ref": "chain-17"}]}, "chain-19": {"and": [{"$ref": "chain-18"}]}, "chain-20": {"and": [{"$ref": "chain-19"}]}, "chain-21": {"and": [{"$ref": "chain-20"}]}, "chain-22": {"and": [{"$ref": "chain-21"}]}, "chain-23": {"and": [{"$ref": "chain-22"}]}, "chain-24": {"and": [{"$ref": "chain-23"}]}, "chain-25": {"and": [{"$ref": "chain-24"}]}, "chain-26": {"and": [{"$ref": "chain-25"}]}, "chain-27": {"and": [{"$ref": "chain-26"}]}, "chain-28": {"and": [{"$ref": "chain-27"}]}, "chain-29": {"and": [{"$ref": "chain-28"}]}, "chain-30": {"and": [{"$ref": "chain-29"}]}, "chain-31": {"and": [{"$ref": "chain-30"}]}, "chain-32": {"and": [{"$ref": "chain-31"}]}, "chain-33": {"and": [{"$ref": "chain-32"}]}, "chain-34": {"and": [{"$ref": "chain-33"}]}, "chain-35": {"and": [{"$ref": "chain-34"}]}, "chain-36": {"and": [{"$ref": "chain-35"}]}, "chain-37": {"and": [{"$ref": "chain-36"}]}, "chain-38": {"and": [{"$ref": "chain-37"}]}, "chain-39": {"and": [{"$ref": "chain-38"}]}, "chain-40": {"and": [{"$ref": "chain-39"}]}, "chain-41": {"and": [{"$ref": "chain-40"}]}, "chain-42": {"and": [{"$ref": "chain-41"}]}, "chain-43": {"and": [{"$ref": "chain-42"}]}, "chain-44": {"and": [{"$ref": "chain-43"}]}, "chain-45": {"and": [{"$ref": "chain-44"}]}, "chain-46": {"and": [{"$ref": "chain-45"}]}, "chain-47": {"and": [{"$ref": "chain-46"}]}, "chain-48": {"and": [{"$ref": "chain-47"}]}, "chain-49": {"and": [{"$ref": "chain-48"}]}, "chain-50": {"and": [{"$ref": "chain-49"}]}, "chain-51": {"and": [{"$ref": "chain-50"}]}, "chain-52": {"and": [{"$ref": "chain-51"}]}, "chain-53": {"and": [{"$ref": "chain-52"}]}, "chain-54": {"and": [{"$ref": "chain-53"}]}, "chain-55": {"and": [{"$ref": "chain-54"}]}, "chain-56": {"and": [{"$ref": "chain-55"}]}, "chain-57": {"and": [{"$ref": "chain-56"}]}, "chain-58": {"and": [{"$ref": "chain-57"}]}, "chain-59": {"and": [{"$ref": "chain-58"}]}, "chain-60": {"and": [{"$ref": "chain-59"}]}, "chain-61": {"and": [{"$ref": "chain-60"}]}, "chain-62": {"and": [{"$ref": "chain-61"}]}, "chain-63": {"and": [{"$ref": "chain-62"}]}, "chain-64": {"and": [{"$ref": "chain-63"}]}, "chain-65": {"and": [{"$ref": "chain-64"}]}, "chain-66": {"and": [{"$ref": "chain-65"}]}, "chain-67": {"and": [{"$ref": "chain-66"}]}, "chain-68": {"and": [{"$ref": "chain-67"}]}, "chain-69": {"and": [{"$ref": "chain-68"}]}, "chain-70": {"and": [{"$ref": "chain-69"}]}, "chain-71": {"and": [{"$ref": "chain-70"}]}, "chain-72": {"and": [{"$ref": "chain-71"}]}, "chain-73": {"and": [{"$ref": "chain-72"}]}, "chain-74": {"and": [{"$ref": "chain-73"}]}, "chain-75": {"and": [{"$ref": "chain-74"}]}, "chain-76": {"and": [{"$ref": "chain-75"}]}, "chain-77": {"and": [{"$ref": "chain-76"}]}, "chain-78": {"and": [{"$ref": "chain-77"}]}, "chain-79": {"and": [{"$ref": "chain-78"}]}, "chain-80": {"and": [{"$ref": "chain-79"}]}, "chain-81": {"and": [{"$ref": "chain-80"}]}, "chain-82": {"and": [{"$ref": "chain-81"}]}, "chain-83": {"and": [{"$ref": "chain-82"}]}, "chain-84": {"and": [{"$ref": "chain-83"}]}, "chain-85": {"and": [{"$ref": "chain-84"}]}, "chain-86": {"and": [{"$ref": "chain-85"}]}, "chain-87": {"and": [{"$ref": "chain-86"}]}, "chain-88": {"and": [{"$ref": "chain-87"}]}, "chain-89": {"and": [{"$ref": "chain-88"}]}, "chain-90": {"and": [{"$ref": "chain-89"}]}, "chain-91": {"and": [{"$ref": "chain-90"}]}, "chain-92": {"and": [{"$ref": "chain-91"}]}, "chain-93": {"and": [{"$ref": "chain-92"}]}, "chain-94": {"and": [{"$ref": "chain-93"}]}, "chain-95": {"and": [{"$ref": "chain-94"}]}, "chain-96": {"and": [{"$ref": "chain-95"}]}, "chain-97": {"and": [{"$ref": "chain-96"}]}, "chain-98": {"and": [{"$ref": "chain-97"}]}, "chain-99": {"and": [{"$ref": "chain-98"}]}, "chain-100": {"and": [{"$ref": "chain-99"}]}, "chain-101": {"and": [{"$ref": "chain-100"}]}, "chain-102": {"and": [{"$ref": "chain-101"}]}, "chain-103": {"and": [{"$ref": "chain-102"}]}, "chain-104": {"and": [{"$ref": "chain-103"}]}, "chain-105": {"and": [{"$ref": "chain-104"}]}, "chain-106": {"and": [{"$ref": "chain-105"}]}, "chain-107": {"and": [{"$ref": "chain-106"}]}, "chain-108": {"and": [{"$ref": "chain-107"}]}, "chain-109": {"and": [{"$ref": "chain-108"}]}, "chain-110": {"and": [{"$ref": "chain-109"}]}, "chain-111": {"and": [{"$ref": "chain-110"}]}, "chain-112": {"and": [{"$ref": "chain-111"}]}, "chain-113": {"and": [{"$ref": "chain-112"}]}, "chain-114": {"and": [{"$ref": "chain-113"}]}, "chain-115": {"and": [{"$ref": "chain-114"}]}, "chain-116": {"and": [{"$ref": "chain-115"}]}, "chain-117": {"and": [{"$ref": "chain-116"}]}, "chain-118": {"and": [{"$ref": "chain-117"}]}, "chain-119": {"and": [{"$ref": "chain-118"}]}, "chain-120": {"and": [{"$ref": "chain-119"}]}, "chain-121": {"and": [{"$ref": "chain-120"}]}, "chain-122": {"and": [{"$ref": "chain-121"}]}, "chain-123": {"and": [{"$ref": "chain-122"}]}, "chain-124": {"and": [{"$ref": "chain-123"}]}, "chain-125": {"and": [{"$ref": "chain-124"}]}, "chain-126": {"and": [{"$ref": "chain-125"}]}, "chain-127": {"and": [{"$ref": "chain-126"}]}, "chain-128": {"and": [{"$ref": "chain-127"}]}, "chain-129": {"and": [{"$ref": "chain-128"}]}, "chain-130": {"and": [{"$ref": "chain-129"}]}, "chain-131": {"and": [{"$ref": "chain-130"}]}, "chain-132": {"and": [{"$ref": "chain-131"}]}, "chain-133": {"and": [{"$ref": "chain-132"}]}, "chain-134": {"and": [{"$ref": "chain-133"}]}, "chain-135": {"and": [{"$ref": "chain-134"}]}, "chain-136": {"and": [{"$ref": "chain-135"}]}, "chain-137": {"and": [{"$ref": "chain-136"}]}, "chain-138": {"and": [{"$ref": "chain-137"}]}, "chain-139": {"and": [{"$ref": "chain-138"}]}, "chain-140": {"and": [{"$ref": "chain-139"}]}, "chain-141": {"and": [{"$ref": "chain-140"}]}, "chain-142": {"and": [{"$ref": "chain-141"}]}, "chain-143": {"and": [{"$ref": "chain-142"}]}, "chain-144": {"and": [{"$ref": "chain-143"}]}, "chain-145": {"and": [{"$ref": "chain-144"}]}, "chain-146": {"and": [{"$ref": "chain-145"}]}, "chain-147": {"and": [{"$ref": "chain-146"}]}, "chain-148": {"and": [{"$ref": "chain-147"}]}, "chain-149": {"and": [{"$ref": "chain-148"}]}, "chain-150": {"and": [{"$ref": "chain-149"}]}, "chain-151": {"and": [{"$ref": "chain-150"}]}, "chain-152": {"and": [{"$ref": "chain-151"}]}, "chain-153": {"and": [{"$ref": "chain-152"}]}, "chain-154": {"and": [{"$ref": "chain-153"}]}, "chain-155": {"and": [{"$ref": "chain-154"}]}, "chain-156": {"and": [{"$ref": "chain-155"}]}, "chain-157": {"and": [{"$ref": "chain-156"}]}, "chain-158": {"and": [{"$ref": "chain-157"}]}, "chain-159": {"and": [{"$ref": "chain-158"}]}, "chain-160": {"and": [{"$ref": "chain-159"}]}, "chain-161": {"and": [{"$ref": "chain-160"}]}, "chain-162": {"and": [{"$ref": "chain-161"}]}, "chain-163": {"and": [{"$ref": "chain-162"}]}, "chain-164": {"and": [{"$ref": "chain-163"}]}, "chain-165": {"and": [{"$ref": "chain-164"}]}, "chain-166": {"and": [{"$ref": "chain-165"}]}, "chain-167": {"and": [{"$ref": "chain-166"}]}, "chain-168": {"and": [{"$ref": "chain-167"}]}, "chain-169": {"and": [{"$ref": "chain-168"}]}, "chain-170": {"and": [{"$ref": "chain-169"}]}, "chain-171": {"and": [{"$ref": "chain-170"}]}, "chain-172": {"and": [{"$ref": "chain-171"}]}, "chain-173": {"and": [{"$ref": "chain-172"}]}, "chain-174": {"and": [{"$ref": "chain-173"}]}, "chain-175": {"and": [{"$ref": "chain-174"}]}, "chain-176": {"and": [{"$ref": "chain-175"}]}, "chain-177": {"and": [{"$ref": "chain-176"}]}, "chain-178": {"and": [{"$ref": "chain-177"}]}, "chain-179": {"and": [{"$ref": "chain-178"}]}, "chain-180": {"and": [{"$ref": "chain-179"}]}, "chain-181": {"and": [{"$ref": "chain-180"}]}, "chain-182": {"and": [{"$ref": "chain-181"}]}, "chain-183": {"and": [{"$ref": "chain-182"}]}, "chain-184": {"and": [{"$ref": "chain-183"}]}, "chain-185": {"and": [{"$ref": "chain-184"}]}, "chain-186": {"and": [{"$ref": "chain-185"}]}, "chain-187": {"and": [{"$ref": "chain-186"}]}, "chain-188": {"and": [{"$ref": "chain-187"}]}, "chain-189": {"and": [{"$ref": "chain-188"}]}, "chain-190": {"and": [{"$ref": "chain-189"}]}, "chain-191": {"and": [{"$ref": "chain-190"}]}, "chain-192": {"and": [{"$ref": "chain-191"}]}, "chain-193": {"and": [{"$ref": "chain-192"}]}, "chain-194": {"and": [{"$ref": "chain-193"}]}, "chain-195": {"and": [{"$ref": "chain-194"}]}, "chain-196": {"and": [{"$ref": "chain-195"}]}, "chain-197": {"and": [{"$ref": "chain-196"}]}, "chain-198": {"and": [{"$ref": "chain-197"}]}, "chain-199": {"and": [{"$ref": "chain-198"}]}, "chain-200": {"and": [{"$ref": "chain-199"}]}}}, "flag": "fan-out-small", "type": "boolean", "default": false, "targetingKey": "u-33", "context": {"tier": "gold"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "a fan-out of references beyond 10 000 values is a parse error", "kind": "evaluate", "document": {"flags": {"fan-out-small": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "fan-5"}, "on", "off"]}}, "fan-out-huge": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "fan-20"}, "on", "off"]}}, "deep-chain-short": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "chain-20"}, "on", "off"]}}, "deep-chain-long": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "chain-200"}, "on", "off"]}}}, "$evaluators": {"fan-0": {"==": [{"var": "tier"}, "gold"]}, "fan-1": {"or": [{"$ref": "fan-0"}, {"$ref": "fan-0"}]}, "fan-2": {"or": [{"$ref": "fan-1"}, {"$ref": "fan-1"}]}, "fan-3": {"or": [{"$ref": "fan-2"}, {"$ref": "fan-2"}]}, "fan-4": {"or": [{"$ref": "fan-3"}, {"$ref": "fan-3"}]}, "fan-5": {"or": [{"$ref": "fan-4"}, {"$ref": "fan-4"}]}, "fan-6": {"or": [{"$ref": "fan-5"}, {"$ref": "fan-5"}]}, "fan-7": {"or": [{"$ref": "fan-6"}, {"$ref": "fan-6"}]}, "fan-8": {"or": [{"$ref": "fan-7"}, {"$ref": "fan-7"}]}, "fan-9": {"or": [{"$ref": "fan-8"}, {"$ref": "fan-8"}]}, "fan-10": {"or": [{"$ref": "fan-9"}, {"$ref": "fan-9"}]}, "fan-11": {"or": [{"$ref": "fan-10"}, {"$ref": "fan-10"}]}, "fan-12": {"or": [{"$ref": "fan-11"}, {"$ref": "fan-11"}]}, "fan-13": {"or": [{"$ref": "fan-12"}, {"$ref": "fan-12"}]}, "fan-14": {"or": [{"$ref": "fan-13"}, {"$ref": "fan-13"}]}, "fan-15": {"or": [{"$ref": "fan-14"}, {"$ref": "fan-14"}]}, "fan-16": {"or": [{"$ref": "fan-15"}, {"$ref": "fan-15"}]}, "fan-17": {"or": [{"$ref": "fan-16"}, {"$ref": "fan-16"}]}, "fan-18": {"or": [{"$ref": "fan-17"}, {"$ref": "fan-17"}]}, "fan-19": {"or": [{"$ref": "fan-18"}, {"$ref": "fan-18"}]}, "fan-20": {"or": [{"$ref": "fan-19"}, {"$ref": "fan-19"}]}, "chain-0": {"==": [{"var": "tier"}, "gold"]}, "chain-1": {"and": [{"$ref": "chain-0"}]}, "chain-2": {"and": [{"$ref": "chain-1"}]}, "chain-3": {"and": [{"$ref": "chain-2"}]}, "chain-4": {"and": [{"$ref": "chain-3"}]}, "chain-5": {"and": [{"$ref": "chain-4"}]}, "chain-6": {"and": [{"$ref": "chain-5"}]}, "chain-7": {"and": [{"$ref": "chain-6"}]}, "chain-8": {"and": [{"$ref": "chain-7"}]}, "chain-9": {"and": [{"$ref": "chain-8"}]}, "chain-10": {"and": [{"$ref": "chain-9"}]}, "chain-11": {"and": [{"$ref": "chain-10"}]}, "chain-12": {"and": [{"$ref": "chain-11"}]}, "chain-13": {"and": [{"$ref": "chain-12"}]}, "chain-14": {"and": [{"$ref": "chain-13"}]}, "chain-15": {"and": [{"$ref": "chain-14"}]}, "chain-16": {"and": [{"$ref": "chain-15"}]}, "chain-17": {"and": [{"$ref": "chain-16"}]}, "chain-18": {"and": [{"$ref": "chain-17"}]}, "chain-19": {"and": [{"$ref": "chain-18"}]}, "chain-20": {"and": [{"$ref": "chain-19"}]}, "chain-21": {"and": [{"$ref": "chain-20"}]}, "chain-22": {"and": [{"$ref": "chain-21"}]}, "chain-23": {"and": [{"$ref": "chain-22"}]}, "chain-24": {"and": [{"$ref": "chain-23"}]}, "chain-25": {"and": [{"$ref": "chain-24"}]}, "chain-26": {"and": [{"$ref": "chain-25"}]}, "chain-27": {"and": [{"$ref": "chain-26"}]}, "chain-28": {"and": [{"$ref": "chain-27"}]}, "chain-29": {"and": [{"$ref": "chain-28"}]}, "chain-30": {"and": [{"$ref": "chain-29"}]}, "chain-31": {"and": [{"$ref": "chain-30"}]}, "chain-32": {"and": [{"$ref": "chain-31"}]}, "chain-33": {"and": [{"$ref": "chain-32"}]}, "chain-34": {"and": [{"$ref": "chain-33"}]}, "chain-35": {"and": [{"$ref": "chain-34"}]}, "chain-36": {"and": [{"$ref": "chain-35"}]}, "chain-37": {"and": [{"$ref": "chain-36"}]}, "chain-38": {"and": [{"$ref": "chain-37"}]}, "chain-39": {"and": [{"$ref": "chain-38"}]}, "chain-40": {"and": [{"$ref": "chain-39"}]}, "chain-41": {"and": [{"$ref": "chain-40"}]}, "chain-42": {"and": [{"$ref": "chain-41"}]}, "chain-43": {"and": [{"$ref": "chain-42"}]}, "chain-44": {"and": [{"$ref": "chain-43"}]}, "chain-45": {"and": [{"$ref": "chain-44"}]}, "chain-46": {"and": [{"$ref": "chain-45"}]}, "chain-47": {"and": [{"$ref": "chain-46"}]}, "chain-48": {"and": [{"$ref": "chain-47"}]}, "chain-49": {"and": [{"$ref": "chain-48"}]}, "chain-50": {"and": [{"$ref": "chain-49"}]}, "chain-51": {"and": [{"$ref": "chain-50"}]}, "chain-52": {"and": [{"$ref": "chain-51"}]}, "chain-53": {"and": [{"$ref": "chain-52"}]}, "chain-54": {"and": [{"$ref": "chain-53"}]}, "chain-55": {"and": [{"$ref": "chain-54"}]}, "chain-56": {"and": [{"$ref": "chain-55"}]}, "chain-57": {"and": [{"$ref": "chain-56"}]}, "chain-58": {"and": [{"$ref": "chain-57"}]}, "chain-59": {"and": [{"$ref": "chain-58"}]}, "chain-60": {"and": [{"$ref": "chain-59"}]}, "chain-61": {"and": [{"$ref": "chain-60"}]}, "chain-62": {"and": [{"$ref": "chain-61"}]}, "chain-63": {"and": [{"$ref": "chain-62"}]}, "chain-64": {"and": [{"$ref": "chain-63"}]}, "chain-65": {"and": [{"$ref": "chain-64"}]}, "chain-66": {"and": [{"$ref": "chain-65"}]}, "chain-67": {"and": [{"$ref": "chain-66"}]}, "chain-68": {"and": [{"$ref": "chain-67"}]}, "chain-69": {"and": [{"$ref": "chain-68"}]}, "chain-70": {"and": [{"$ref": "chain-69"}]}, "chain-71": {"and": [{"$ref": "chain-70"}]}, "chain-72": {"and": [{"$ref": "chain-71"}]}, "chain-73": {"and": [{"$ref": "chain-72"}]}, "chain-74": {"and": [{"$ref": "chain-73"}]}, "chain-75": {"and": [{"$ref": "chain-74"}]}, "chain-76": {"and": [{"$ref": "chain-75"}]}, "chain-77": {"and": [{"$ref": "chain-76"}]}, "chain-78": {"and": [{"$ref": "chain-77"}]}, "chain-79": {"and": [{"$ref": "chain-78"}]}, "chain-80": {"and": [{"$ref": "chain-79"}]}, "chain-81": {"and": [{"$ref": "chain-80"}]}, "chain-82": {"and": [{"$ref": "chain-81"}]}, "chain-83": {"and": [{"$ref": "chain-82"}]}, "chain-84": {"and": [{"$ref": "chain-83"}]}, "chain-85": {"and": [{"$ref": "chain-84"}]}, "chain-86": {"and": [{"$ref": "chain-85"}]}, "chain-87": {"and": [{"$ref": "chain-86"}]}, "chain-88": {"and": [{"$ref": "chain-87"}]}, "chain-89": {"and": [{"$ref": "chain-88"}]}, "chain-90": {"and": [{"$ref": "chain-89"}]}, "chain-91": {"and": [{"$ref": "chain-90"}]}, "chain-92": {"and": [{"$ref": "chain-91"}]}, "chain-93": {"and": [{"$ref": "chain-92"}]}, "chain-94": {"and": [{"$ref": "chain-93"}]}, "chain-95": {"and": [{"$ref": "chain-94"}]}, "chain-96": {"and": [{"$ref": "chain-95"}]}, "chain-97": {"and": [{"$ref": "chain-96"}]}, "chain-98": {"and": [{"$ref": "chain-97"}]}, "chain-99": {"and": [{"$ref": "chain-98"}]}, "chain-100": {"and": [{"$ref": "chain-99"}]}, "chain-101": {"and": [{"$ref": "chain-100"}]}, "chain-102": {"and": [{"$ref": "chain-101"}]}, "chain-103": {"and": [{"$ref": "chain-102"}]}, "chain-104": {"and": [{"$ref": "chain-103"}]}, "chain-105": {"and": [{"$ref": "chain-104"}]}, "chain-106": {"and": [{"$ref": "chain-105"}]}, "chain-107": {"and": [{"$ref": "chain-106"}]}, "chain-108": {"and": [{"$ref": "chain-107"}]}, "chain-109": {"and": [{"$ref": "chain-108"}]}, "chain-110": {"and": [{"$ref": "chain-109"}]}, "chain-111": {"and": [{"$ref": "chain-110"}]}, "chain-112": {"and": [{"$ref": "chain-111"}]}, "chain-113": {"and": [{"$ref": "chain-112"}]}, "chain-114": {"and": [{"$ref": "chain-113"}]}, "chain-115": {"and": [{"$ref": "chain-114"}]}, "chain-116": {"and": [{"$ref": "chain-115"}]}, "chain-117": {"and": [{"$ref": "chain-116"}]}, "chain-118": {"and": [{"$ref": "chain-117"}]}, "chain-119": {"and": [{"$ref": "chain-118"}]}, "chain-120": {"and": [{"$ref": "chain-119"}]}, "chain-121": {"and": [{"$ref": "chain-120"}]}, "chain-122": {"and": [{"$ref": "chain-121"}]}, "chain-123": {"and": [{"$ref": "chain-122"}]}, "chain-124": {"and": [{"$ref": "chain-123"}]}, "chain-125": {"and": [{"$ref": "chain-124"}]}, "chain-126": {"and": [{"$ref": "chain-125"}]}, "chain-127": {"and": [{"$ref": "chain-126"}]}, "chain-128": {"and": [{"$ref": "chain-127"}]}, "chain-129": {"and": [{"$ref": "chain-128"}]}, "chain-130": {"and": [{"$ref": "chain-129"}]}, "chain-131": {"and": [{"$ref": "chain-130"}]}, "chain-132": {"and": [{"$ref": "chain-131"}]}, "chain-133": {"and": [{"$ref": "chain-132"}]}, "chain-134": {"and": [{"$ref": "chain-133"}]}, "chain-135": {"and": [{"$ref": "chain-134"}]}, "chain-136": {"and": [{"$ref": "chain-135"}]}, "chain-137": {"and": [{"$ref": "chain-136"}]}, "chain-138": {"and": [{"$ref": "chain-137"}]}, "chain-139": {"and": [{"$ref": "chain-138"}]}, "chain-140": {"and": [{"$ref": "chain-139"}]}, "chain-141": {"and": [{"$ref": "chain-140"}]}, "chain-142": {"and": [{"$ref": "chain-141"}]}, "chain-143": {"and": [{"$ref": "chain-142"}]}, "chain-144": {"and": [{"$ref": "chain-143"}]}, "chain-145": {"and": [{"$ref": "chain-144"}]}, "chain-146": {"and": [{"$ref": "chain-145"}]}, "chain-147": {"and": [{"$ref": "chain-146"}]}, "chain-148": {"and": [{"$ref": "chain-147"}]}, "chain-149": {"and": [{"$ref": "chain-148"}]}, "chain-150": {"and": [{"$ref": "chain-149"}]}, "chain-151": {"and": [{"$ref": "chain-150"}]}, "chain-152": {"and": [{"$ref": "chain-151"}]}, "chain-153": {"and": [{"$ref": "chain-152"}]}, "chain-154": {"and": [{"$ref": "chain-153"}]}, "chain-155": {"and": [{"$ref": "chain-154"}]}, "chain-156": {"and": [{"$ref": "chain-155"}]}, "chain-157": {"and": [{"$ref": "chain-156"}]}, "chain-158": {"and": [{"$ref": "chain-157"}]}, "chain-159": {"and": [{"$ref": "chain-158"}]}, "chain-160": {"and": [{"$ref": "chain-159"}]}, "chain-161": {"and": [{"$ref": "chain-160"}]}, "chain-162": {"and": [{"$ref": "chain-161"}]}, "chain-163": {"and": [{"$ref": "chain-162"}]}, "chain-164": {"and": [{"$ref": "chain-163"}]}, "chain-165": {"and": [{"$ref": "chain-164"}]}, "chain-166": {"and": [{"$ref": "chain-165"}]}, "chain-167": {"and": [{"$ref": "chain-166"}]}, "chain-168": {"and": [{"$ref": "chain-167"}]}, "chain-169": {"and": [{"$ref": "chain-168"}]}, "chain-170": {"and": [{"$ref": "chain-169"}]}, "chain-171": {"and": [{"$ref": "chain-170"}]}, "chain-172": {"and": [{"$ref": "chain-171"}]}, "chain-173": {"and": [{"$ref": "chain-172"}]}, "chain-174": {"and": [{"$ref": "chain-173"}]}, "chain-175": {"and": [{"$ref": "chain-174"}]}, "chain-176": {"and": [{"$ref": "chain-175"}]}, "chain-177": {"and": [{"$ref": "chain-176"}]}, "chain-178": {"and": [{"$ref": "chain-177"}]}, "chain-179": {"and": [{"$ref": "chain-178"}]}, "chain-180": {"and": [{"$ref": "chain-179"}]}, "chain-181": {"and": [{"$ref": "chain-180"}]}, "chain-182": {"and": [{"$ref": "chain-181"}]}, "chain-183": {"and": [{"$ref": "chain-182"}]}, "chain-184": {"and": [{"$ref": "chain-183"}]}, "chain-185": {"and": [{"$ref": "chain-184"}]}, "chain-186": {"and": [{"$ref": "chain-185"}]}, "chain-187": {"and": [{"$ref": "chain-186"}]}, "chain-188": {"and": [{"$ref": "chain-187"}]}, "chain-189": {"and": [{"$ref": "chain-188"}]}, "chain-190": {"and": [{"$ref": "chain-189"}]}, "chain-191": {"and": [{"$ref": "chain-190"}]}, "chain-192": {"and": [{"$ref": "chain-191"}]}, "chain-193": {"and": [{"$ref": "chain-192"}]}, "chain-194": {"and": [{"$ref": "chain-193"}]}, "chain-195": {"and": [{"$ref": "chain-194"}]}, "chain-196": {"and": [{"$ref": "chain-195"}]}, "chain-197": {"and": [{"$ref": "chain-196"}]}, "chain-198": {"and": [{"$ref": "chain-197"}]}, "chain-199": {"and": [{"$ref": "chain-198"}]}, "chain-200": {"and": [{"$ref": "chain-199"}]}}}, "flag": "fan-out-huge", "type": "boolean", "default": true, "targetingKey": "u-34", "context": {"tier": "gold"}, "expect": {"value": true, "variant": null, "reason": "ERROR", "errorCode": "PARSE_ERROR"}}, + {"name": "a chain of references within 128 nesting levels evaluates", "kind": "evaluate", "document": {"flags": {"fan-out-small": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "fan-5"}, "on", "off"]}}, "fan-out-huge": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "fan-20"}, "on", "off"]}}, "deep-chain-short": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "chain-20"}, "on", "off"]}}, "deep-chain-long": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "chain-200"}, "on", "off"]}}}, "$evaluators": {"fan-0": {"==": [{"var": "tier"}, "gold"]}, "fan-1": {"or": [{"$ref": "fan-0"}, {"$ref": "fan-0"}]}, "fan-2": {"or": [{"$ref": "fan-1"}, {"$ref": "fan-1"}]}, "fan-3": {"or": [{"$ref": "fan-2"}, {"$ref": "fan-2"}]}, "fan-4": {"or": [{"$ref": "fan-3"}, {"$ref": "fan-3"}]}, "fan-5": {"or": [{"$ref": "fan-4"}, {"$ref": "fan-4"}]}, "fan-6": {"or": [{"$ref": "fan-5"}, {"$ref": "fan-5"}]}, "fan-7": {"or": [{"$ref": "fan-6"}, {"$ref": "fan-6"}]}, "fan-8": {"or": [{"$ref": "fan-7"}, {"$ref": "fan-7"}]}, "fan-9": {"or": [{"$ref": "fan-8"}, {"$ref": "fan-8"}]}, "fan-10": {"or": [{"$ref": "fan-9"}, {"$ref": "fan-9"}]}, "fan-11": {"or": [{"$ref": "fan-10"}, {"$ref": "fan-10"}]}, "fan-12": {"or": [{"$ref": "fan-11"}, {"$ref": "fan-11"}]}, "fan-13": {"or": [{"$ref": "fan-12"}, {"$ref": "fan-12"}]}, "fan-14": {"or": [{"$ref": "fan-13"}, {"$ref": "fan-13"}]}, "fan-15": {"or": [{"$ref": "fan-14"}, {"$ref": "fan-14"}]}, "fan-16": {"or": [{"$ref": "fan-15"}, {"$ref": "fan-15"}]}, "fan-17": {"or": [{"$ref": "fan-16"}, {"$ref": "fan-16"}]}, "fan-18": {"or": [{"$ref": "fan-17"}, {"$ref": "fan-17"}]}, "fan-19": {"or": [{"$ref": "fan-18"}, {"$ref": "fan-18"}]}, "fan-20": {"or": [{"$ref": "fan-19"}, {"$ref": "fan-19"}]}, "chain-0": {"==": [{"var": "tier"}, "gold"]}, "chain-1": {"and": [{"$ref": "chain-0"}]}, "chain-2": {"and": [{"$ref": "chain-1"}]}, "chain-3": {"and": [{"$ref": "chain-2"}]}, "chain-4": {"and": [{"$ref": "chain-3"}]}, "chain-5": {"and": [{"$ref": "chain-4"}]}, "chain-6": {"and": [{"$ref": "chain-5"}]}, "chain-7": {"and": [{"$ref": "chain-6"}]}, "chain-8": {"and": [{"$ref": "chain-7"}]}, "chain-9": {"and": [{"$ref": "chain-8"}]}, "chain-10": {"and": [{"$ref": "chain-9"}]}, "chain-11": {"and": [{"$ref": "chain-10"}]}, "chain-12": {"and": [{"$ref": "chain-11"}]}, "chain-13": {"and": [{"$ref": "chain-12"}]}, "chain-14": {"and": [{"$ref": "chain-13"}]}, "chain-15": {"and": [{"$ref": "chain-14"}]}, "chain-16": {"and": [{"$ref": "chain-15"}]}, "chain-17": {"and": [{"$ref": "chain-16"}]}, "chain-18": {"and": [{"$ref": "chain-17"}]}, "chain-19": {"and": [{"$ref": "chain-18"}]}, "chain-20": {"and": [{"$ref": "chain-19"}]}, "chain-21": {"and": [{"$ref": "chain-20"}]}, "chain-22": {"and": [{"$ref": "chain-21"}]}, "chain-23": {"and": [{"$ref": "chain-22"}]}, "chain-24": {"and": [{"$ref": "chain-23"}]}, "chain-25": {"and": [{"$ref": "chain-24"}]}, "chain-26": {"and": [{"$ref": "chain-25"}]}, "chain-27": {"and": [{"$ref": "chain-26"}]}, "chain-28": {"and": [{"$ref": "chain-27"}]}, "chain-29": {"and": [{"$ref": "chain-28"}]}, "chain-30": {"and": [{"$ref": "chain-29"}]}, "chain-31": {"and": [{"$ref": "chain-30"}]}, "chain-32": {"and": [{"$ref": "chain-31"}]}, "chain-33": {"and": [{"$ref": "chain-32"}]}, "chain-34": {"and": [{"$ref": "chain-33"}]}, "chain-35": {"and": [{"$ref": "chain-34"}]}, "chain-36": {"and": [{"$ref": "chain-35"}]}, "chain-37": {"and": [{"$ref": "chain-36"}]}, "chain-38": {"and": [{"$ref": "chain-37"}]}, "chain-39": {"and": [{"$ref": "chain-38"}]}, "chain-40": {"and": [{"$ref": "chain-39"}]}, "chain-41": {"and": [{"$ref": "chain-40"}]}, "chain-42": {"and": [{"$ref": "chain-41"}]}, "chain-43": {"and": [{"$ref": "chain-42"}]}, "chain-44": {"and": [{"$ref": "chain-43"}]}, "chain-45": {"and": [{"$ref": "chain-44"}]}, "chain-46": {"and": [{"$ref": "chain-45"}]}, "chain-47": {"and": [{"$ref": "chain-46"}]}, "chain-48": {"and": [{"$ref": "chain-47"}]}, "chain-49": {"and": [{"$ref": "chain-48"}]}, "chain-50": {"and": [{"$ref": "chain-49"}]}, "chain-51": {"and": [{"$ref": "chain-50"}]}, "chain-52": {"and": [{"$ref": "chain-51"}]}, "chain-53": {"and": [{"$ref": "chain-52"}]}, "chain-54": {"and": [{"$ref": "chain-53"}]}, "chain-55": {"and": [{"$ref": "chain-54"}]}, "chain-56": {"and": [{"$ref": "chain-55"}]}, "chain-57": {"and": [{"$ref": "chain-56"}]}, "chain-58": {"and": [{"$ref": "chain-57"}]}, "chain-59": {"and": [{"$ref": "chain-58"}]}, "chain-60": {"and": [{"$ref": "chain-59"}]}, "chain-61": {"and": [{"$ref": "chain-60"}]}, "chain-62": {"and": [{"$ref": "chain-61"}]}, "chain-63": {"and": [{"$ref": "chain-62"}]}, "chain-64": {"and": [{"$ref": "chain-63"}]}, "chain-65": {"and": [{"$ref": "chain-64"}]}, "chain-66": {"and": [{"$ref": "chain-65"}]}, "chain-67": {"and": [{"$ref": "chain-66"}]}, "chain-68": {"and": [{"$ref": "chain-67"}]}, "chain-69": {"and": [{"$ref": "chain-68"}]}, "chain-70": {"and": [{"$ref": "chain-69"}]}, "chain-71": {"and": [{"$ref": "chain-70"}]}, "chain-72": {"and": [{"$ref": "chain-71"}]}, "chain-73": {"and": [{"$ref": "chain-72"}]}, "chain-74": {"and": [{"$ref": "chain-73"}]}, "chain-75": {"and": [{"$ref": "chain-74"}]}, "chain-76": {"and": [{"$ref": "chain-75"}]}, "chain-77": {"and": [{"$ref": "chain-76"}]}, "chain-78": {"and": [{"$ref": "chain-77"}]}, "chain-79": {"and": [{"$ref": "chain-78"}]}, "chain-80": {"and": [{"$ref": "chain-79"}]}, "chain-81": {"and": [{"$ref": "chain-80"}]}, "chain-82": {"and": [{"$ref": "chain-81"}]}, "chain-83": {"and": [{"$ref": "chain-82"}]}, "chain-84": {"and": [{"$ref": "chain-83"}]}, "chain-85": {"and": [{"$ref": "chain-84"}]}, "chain-86": {"and": [{"$ref": "chain-85"}]}, "chain-87": {"and": [{"$ref": "chain-86"}]}, "chain-88": {"and": [{"$ref": "chain-87"}]}, "chain-89": {"and": [{"$ref": "chain-88"}]}, "chain-90": {"and": [{"$ref": "chain-89"}]}, "chain-91": {"and": [{"$ref": "chain-90"}]}, "chain-92": {"and": [{"$ref": "chain-91"}]}, "chain-93": {"and": [{"$ref": "chain-92"}]}, "chain-94": {"and": [{"$ref": "chain-93"}]}, "chain-95": {"and": [{"$ref": "chain-94"}]}, "chain-96": {"and": [{"$ref": "chain-95"}]}, "chain-97": {"and": [{"$ref": "chain-96"}]}, "chain-98": {"and": [{"$ref": "chain-97"}]}, "chain-99": {"and": [{"$ref": "chain-98"}]}, "chain-100": {"and": [{"$ref": "chain-99"}]}, "chain-101": {"and": [{"$ref": "chain-100"}]}, "chain-102": {"and": [{"$ref": "chain-101"}]}, "chain-103": {"and": [{"$ref": "chain-102"}]}, "chain-104": {"and": [{"$ref": "chain-103"}]}, "chain-105": {"and": [{"$ref": "chain-104"}]}, "chain-106": {"and": [{"$ref": "chain-105"}]}, "chain-107": {"and": [{"$ref": "chain-106"}]}, "chain-108": {"and": [{"$ref": "chain-107"}]}, "chain-109": {"and": [{"$ref": "chain-108"}]}, "chain-110": {"and": [{"$ref": "chain-109"}]}, "chain-111": {"and": [{"$ref": "chain-110"}]}, "chain-112": {"and": [{"$ref": "chain-111"}]}, "chain-113": {"and": [{"$ref": "chain-112"}]}, "chain-114": {"and": [{"$ref": "chain-113"}]}, "chain-115": {"and": [{"$ref": "chain-114"}]}, "chain-116": {"and": [{"$ref": "chain-115"}]}, "chain-117": {"and": [{"$ref": "chain-116"}]}, "chain-118": {"and": [{"$ref": "chain-117"}]}, "chain-119": {"and": [{"$ref": "chain-118"}]}, "chain-120": {"and": [{"$ref": "chain-119"}]}, "chain-121": {"and": [{"$ref": "chain-120"}]}, "chain-122": {"and": [{"$ref": "chain-121"}]}, "chain-123": {"and": [{"$ref": "chain-122"}]}, "chain-124": {"and": [{"$ref": "chain-123"}]}, "chain-125": {"and": [{"$ref": "chain-124"}]}, "chain-126": {"and": [{"$ref": "chain-125"}]}, "chain-127": {"and": [{"$ref": "chain-126"}]}, "chain-128": {"and": [{"$ref": "chain-127"}]}, "chain-129": {"and": [{"$ref": "chain-128"}]}, "chain-130": {"and": [{"$ref": "chain-129"}]}, "chain-131": {"and": [{"$ref": "chain-130"}]}, "chain-132": {"and": [{"$ref": "chain-131"}]}, "chain-133": {"and": [{"$ref": "chain-132"}]}, "chain-134": {"and": [{"$ref": "chain-133"}]}, "chain-135": {"and": [{"$ref": "chain-134"}]}, "chain-136": {"and": [{"$ref": "chain-135"}]}, "chain-137": {"and": [{"$ref": "chain-136"}]}, "chain-138": {"and": [{"$ref": "chain-137"}]}, "chain-139": {"and": [{"$ref": "chain-138"}]}, "chain-140": {"and": [{"$ref": "chain-139"}]}, "chain-141": {"and": [{"$ref": "chain-140"}]}, "chain-142": {"and": [{"$ref": "chain-141"}]}, "chain-143": {"and": [{"$ref": "chain-142"}]}, "chain-144": {"and": [{"$ref": "chain-143"}]}, "chain-145": {"and": [{"$ref": "chain-144"}]}, "chain-146": {"and": [{"$ref": "chain-145"}]}, "chain-147": {"and": [{"$ref": "chain-146"}]}, "chain-148": {"and": [{"$ref": "chain-147"}]}, "chain-149": {"and": [{"$ref": "chain-148"}]}, "chain-150": {"and": [{"$ref": "chain-149"}]}, "chain-151": {"and": [{"$ref": "chain-150"}]}, "chain-152": {"and": [{"$ref": "chain-151"}]}, "chain-153": {"and": [{"$ref": "chain-152"}]}, "chain-154": {"and": [{"$ref": "chain-153"}]}, "chain-155": {"and": [{"$ref": "chain-154"}]}, "chain-156": {"and": [{"$ref": "chain-155"}]}, "chain-157": {"and": [{"$ref": "chain-156"}]}, "chain-158": {"and": [{"$ref": "chain-157"}]}, "chain-159": {"and": [{"$ref": "chain-158"}]}, "chain-160": {"and": [{"$ref": "chain-159"}]}, "chain-161": {"and": [{"$ref": "chain-160"}]}, "chain-162": {"and": [{"$ref": "chain-161"}]}, "chain-163": {"and": [{"$ref": "chain-162"}]}, "chain-164": {"and": [{"$ref": "chain-163"}]}, "chain-165": {"and": [{"$ref": "chain-164"}]}, "chain-166": {"and": [{"$ref": "chain-165"}]}, "chain-167": {"and": [{"$ref": "chain-166"}]}, "chain-168": {"and": [{"$ref": "chain-167"}]}, "chain-169": {"and": [{"$ref": "chain-168"}]}, "chain-170": {"and": [{"$ref": "chain-169"}]}, "chain-171": {"and": [{"$ref": "chain-170"}]}, "chain-172": {"and": [{"$ref": "chain-171"}]}, "chain-173": {"and": [{"$ref": "chain-172"}]}, "chain-174": {"and": [{"$ref": "chain-173"}]}, "chain-175": {"and": [{"$ref": "chain-174"}]}, "chain-176": {"and": [{"$ref": "chain-175"}]}, "chain-177": {"and": [{"$ref": "chain-176"}]}, "chain-178": {"and": [{"$ref": "chain-177"}]}, "chain-179": {"and": [{"$ref": "chain-178"}]}, "chain-180": {"and": [{"$ref": "chain-179"}]}, "chain-181": {"and": [{"$ref": "chain-180"}]}, "chain-182": {"and": [{"$ref": "chain-181"}]}, "chain-183": {"and": [{"$ref": "chain-182"}]}, "chain-184": {"and": [{"$ref": "chain-183"}]}, "chain-185": {"and": [{"$ref": "chain-184"}]}, "chain-186": {"and": [{"$ref": "chain-185"}]}, "chain-187": {"and": [{"$ref": "chain-186"}]}, "chain-188": {"and": [{"$ref": "chain-187"}]}, "chain-189": {"and": [{"$ref": "chain-188"}]}, "chain-190": {"and": [{"$ref": "chain-189"}]}, "chain-191": {"and": [{"$ref": "chain-190"}]}, "chain-192": {"and": [{"$ref": "chain-191"}]}, "chain-193": {"and": [{"$ref": "chain-192"}]}, "chain-194": {"and": [{"$ref": "chain-193"}]}, "chain-195": {"and": [{"$ref": "chain-194"}]}, "chain-196": {"and": [{"$ref": "chain-195"}]}, "chain-197": {"and": [{"$ref": "chain-196"}]}, "chain-198": {"and": [{"$ref": "chain-197"}]}, "chain-199": {"and": [{"$ref": "chain-198"}]}, "chain-200": {"and": [{"$ref": "chain-199"}]}}}, "flag": "deep-chain-short", "type": "boolean", "default": false, "targetingKey": "u-35", "context": {"tier": "gold"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "a chain of references nesting deeper than 128 levels is a parse error", "kind": "evaluate", "document": {"flags": {"fan-out-small": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "fan-5"}, "on", "off"]}}, "fan-out-huge": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "fan-20"}, "on", "off"]}}, "deep-chain-short": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "chain-20"}, "on", "off"]}}, "deep-chain-long": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "chain-200"}, "on", "off"]}}}, "$evaluators": {"fan-0": {"==": [{"var": "tier"}, "gold"]}, "fan-1": {"or": [{"$ref": "fan-0"}, {"$ref": "fan-0"}]}, "fan-2": {"or": [{"$ref": "fan-1"}, {"$ref": "fan-1"}]}, "fan-3": {"or": [{"$ref": "fan-2"}, {"$ref": "fan-2"}]}, "fan-4": {"or": [{"$ref": "fan-3"}, {"$ref": "fan-3"}]}, "fan-5": {"or": [{"$ref": "fan-4"}, {"$ref": "fan-4"}]}, "fan-6": {"or": [{"$ref": "fan-5"}, {"$ref": "fan-5"}]}, "fan-7": {"or": [{"$ref": "fan-6"}, {"$ref": "fan-6"}]}, "fan-8": {"or": [{"$ref": "fan-7"}, {"$ref": "fan-7"}]}, "fan-9": {"or": [{"$ref": "fan-8"}, {"$ref": "fan-8"}]}, "fan-10": {"or": [{"$ref": "fan-9"}, {"$ref": "fan-9"}]}, "fan-11": {"or": [{"$ref": "fan-10"}, {"$ref": "fan-10"}]}, "fan-12": {"or": [{"$ref": "fan-11"}, {"$ref": "fan-11"}]}, "fan-13": {"or": [{"$ref": "fan-12"}, {"$ref": "fan-12"}]}, "fan-14": {"or": [{"$ref": "fan-13"}, {"$ref": "fan-13"}]}, "fan-15": {"or": [{"$ref": "fan-14"}, {"$ref": "fan-14"}]}, "fan-16": {"or": [{"$ref": "fan-15"}, {"$ref": "fan-15"}]}, "fan-17": {"or": [{"$ref": "fan-16"}, {"$ref": "fan-16"}]}, "fan-18": {"or": [{"$ref": "fan-17"}, {"$ref": "fan-17"}]}, "fan-19": {"or": [{"$ref": "fan-18"}, {"$ref": "fan-18"}]}, "fan-20": {"or": [{"$ref": "fan-19"}, {"$ref": "fan-19"}]}, "chain-0": {"==": [{"var": "tier"}, "gold"]}, "chain-1": {"and": [{"$ref": "chain-0"}]}, "chain-2": {"and": [{"$ref": "chain-1"}]}, "chain-3": {"and": [{"$ref": "chain-2"}]}, "chain-4": {"and": [{"$ref": "chain-3"}]}, "chain-5": {"and": [{"$ref": "chain-4"}]}, "chain-6": {"and": [{"$ref": "chain-5"}]}, "chain-7": {"and": [{"$ref": "chain-6"}]}, "chain-8": {"and": [{"$ref": "chain-7"}]}, "chain-9": {"and": [{"$ref": "chain-8"}]}, "chain-10": {"and": [{"$ref": "chain-9"}]}, "chain-11": {"and": [{"$ref": "chain-10"}]}, "chain-12": {"and": [{"$ref": "chain-11"}]}, "chain-13": {"and": [{"$ref": "chain-12"}]}, "chain-14": {"and": [{"$ref": "chain-13"}]}, "chain-15": {"and": [{"$ref": "chain-14"}]}, "chain-16": {"and": [{"$ref": "chain-15"}]}, "chain-17": {"and": [{"$ref": "chain-16"}]}, "chain-18": {"and": [{"$ref": "chain-17"}]}, "chain-19": {"and": [{"$ref": "chain-18"}]}, "chain-20": {"and": [{"$ref": "chain-19"}]}, "chain-21": {"and": [{"$ref": "chain-20"}]}, "chain-22": {"and": [{"$ref": "chain-21"}]}, "chain-23": {"and": [{"$ref": "chain-22"}]}, "chain-24": {"and": [{"$ref": "chain-23"}]}, "chain-25": {"and": [{"$ref": "chain-24"}]}, "chain-26": {"and": [{"$ref": "chain-25"}]}, "chain-27": {"and": [{"$ref": "chain-26"}]}, "chain-28": {"and": [{"$ref": "chain-27"}]}, "chain-29": {"and": [{"$ref": "chain-28"}]}, "chain-30": {"and": [{"$ref": "chain-29"}]}, "chain-31": {"and": [{"$ref": "chain-30"}]}, "chain-32": {"and": [{"$ref": "chain-31"}]}, "chain-33": {"and": [{"$ref": "chain-32"}]}, "chain-34": {"and": [{"$ref": "chain-33"}]}, "chain-35": {"and": [{"$ref": "chain-34"}]}, "chain-36": {"and": [{"$ref": "chain-35"}]}, "chain-37": {"and": [{"$ref": "chain-36"}]}, "chain-38": {"and": [{"$ref": "chain-37"}]}, "chain-39": {"and": [{"$ref": "chain-38"}]}, "chain-40": {"and": [{"$ref": "chain-39"}]}, "chain-41": {"and": [{"$ref": "chain-40"}]}, "chain-42": {"and": [{"$ref": "chain-41"}]}, "chain-43": {"and": [{"$ref": "chain-42"}]}, "chain-44": {"and": [{"$ref": "chain-43"}]}, "chain-45": {"and": [{"$ref": "chain-44"}]}, "chain-46": {"and": [{"$ref": "chain-45"}]}, "chain-47": {"and": [{"$ref": "chain-46"}]}, "chain-48": {"and": [{"$ref": "chain-47"}]}, "chain-49": {"and": [{"$ref": "chain-48"}]}, "chain-50": {"and": [{"$ref": "chain-49"}]}, "chain-51": {"and": [{"$ref": "chain-50"}]}, "chain-52": {"and": [{"$ref": "chain-51"}]}, "chain-53": {"and": [{"$ref": "chain-52"}]}, "chain-54": {"and": [{"$ref": "chain-53"}]}, "chain-55": {"and": [{"$ref": "chain-54"}]}, "chain-56": {"and": [{"$ref": "chain-55"}]}, "chain-57": {"and": [{"$ref": "chain-56"}]}, "chain-58": {"and": [{"$ref": "chain-57"}]}, "chain-59": {"and": [{"$ref": "chain-58"}]}, "chain-60": {"and": [{"$ref": "chain-59"}]}, "chain-61": {"and": [{"$ref": "chain-60"}]}, "chain-62": {"and": [{"$ref": "chain-61"}]}, "chain-63": {"and": [{"$ref": "chain-62"}]}, "chain-64": {"and": [{"$ref": "chain-63"}]}, "chain-65": {"and": [{"$ref": "chain-64"}]}, "chain-66": {"and": [{"$ref": "chain-65"}]}, "chain-67": {"and": [{"$ref": "chain-66"}]}, "chain-68": {"and": [{"$ref": "chain-67"}]}, "chain-69": {"and": [{"$ref": "chain-68"}]}, "chain-70": {"and": [{"$ref": "chain-69"}]}, "chain-71": {"and": [{"$ref": "chain-70"}]}, "chain-72": {"and": [{"$ref": "chain-71"}]}, "chain-73": {"and": [{"$ref": "chain-72"}]}, "chain-74": {"and": [{"$ref": "chain-73"}]}, "chain-75": {"and": [{"$ref": "chain-74"}]}, "chain-76": {"and": [{"$ref": "chain-75"}]}, "chain-77": {"and": [{"$ref": "chain-76"}]}, "chain-78": {"and": [{"$ref": "chain-77"}]}, "chain-79": {"and": [{"$ref": "chain-78"}]}, "chain-80": {"and": [{"$ref": "chain-79"}]}, "chain-81": {"and": [{"$ref": "chain-80"}]}, "chain-82": {"and": [{"$ref": "chain-81"}]}, "chain-83": {"and": [{"$ref": "chain-82"}]}, "chain-84": {"and": [{"$ref": "chain-83"}]}, "chain-85": {"and": [{"$ref": "chain-84"}]}, "chain-86": {"and": [{"$ref": "chain-85"}]}, "chain-87": {"and": [{"$ref": "chain-86"}]}, "chain-88": {"and": [{"$ref": "chain-87"}]}, "chain-89": {"and": [{"$ref": "chain-88"}]}, "chain-90": {"and": [{"$ref": "chain-89"}]}, "chain-91": {"and": [{"$ref": "chain-90"}]}, "chain-92": {"and": [{"$ref": "chain-91"}]}, "chain-93": {"and": [{"$ref": "chain-92"}]}, "chain-94": {"and": [{"$ref": "chain-93"}]}, "chain-95": {"and": [{"$ref": "chain-94"}]}, "chain-96": {"and": [{"$ref": "chain-95"}]}, "chain-97": {"and": [{"$ref": "chain-96"}]}, "chain-98": {"and": [{"$ref": "chain-97"}]}, "chain-99": {"and": [{"$ref": "chain-98"}]}, "chain-100": {"and": [{"$ref": "chain-99"}]}, "chain-101": {"and": [{"$ref": "chain-100"}]}, "chain-102": {"and": [{"$ref": "chain-101"}]}, "chain-103": {"and": [{"$ref": "chain-102"}]}, "chain-104": {"and": [{"$ref": "chain-103"}]}, "chain-105": {"and": [{"$ref": "chain-104"}]}, "chain-106": {"and": [{"$ref": "chain-105"}]}, "chain-107": {"and": [{"$ref": "chain-106"}]}, "chain-108": {"and": [{"$ref": "chain-107"}]}, "chain-109": {"and": [{"$ref": "chain-108"}]}, "chain-110": {"and": [{"$ref": "chain-109"}]}, "chain-111": {"and": [{"$ref": "chain-110"}]}, "chain-112": {"and": [{"$ref": "chain-111"}]}, "chain-113": {"and": [{"$ref": "chain-112"}]}, "chain-114": {"and": [{"$ref": "chain-113"}]}, "chain-115": {"and": [{"$ref": "chain-114"}]}, "chain-116": {"and": [{"$ref": "chain-115"}]}, "chain-117": {"and": [{"$ref": "chain-116"}]}, "chain-118": {"and": [{"$ref": "chain-117"}]}, "chain-119": {"and": [{"$ref": "chain-118"}]}, "chain-120": {"and": [{"$ref": "chain-119"}]}, "chain-121": {"and": [{"$ref": "chain-120"}]}, "chain-122": {"and": [{"$ref": "chain-121"}]}, "chain-123": {"and": [{"$ref": "chain-122"}]}, "chain-124": {"and": [{"$ref": "chain-123"}]}, "chain-125": {"and": [{"$ref": "chain-124"}]}, "chain-126": {"and": [{"$ref": "chain-125"}]}, "chain-127": {"and": [{"$ref": "chain-126"}]}, "chain-128": {"and": [{"$ref": "chain-127"}]}, "chain-129": {"and": [{"$ref": "chain-128"}]}, "chain-130": {"and": [{"$ref": "chain-129"}]}, "chain-131": {"and": [{"$ref": "chain-130"}]}, "chain-132": {"and": [{"$ref": "chain-131"}]}, "chain-133": {"and": [{"$ref": "chain-132"}]}, "chain-134": {"and": [{"$ref": "chain-133"}]}, "chain-135": {"and": [{"$ref": "chain-134"}]}, "chain-136": {"and": [{"$ref": "chain-135"}]}, "chain-137": {"and": [{"$ref": "chain-136"}]}, "chain-138": {"and": [{"$ref": "chain-137"}]}, "chain-139": {"and": [{"$ref": "chain-138"}]}, "chain-140": {"and": [{"$ref": "chain-139"}]}, "chain-141": {"and": [{"$ref": "chain-140"}]}, "chain-142": {"and": [{"$ref": "chain-141"}]}, "chain-143": {"and": [{"$ref": "chain-142"}]}, "chain-144": {"and": [{"$ref": "chain-143"}]}, "chain-145": {"and": [{"$ref": "chain-144"}]}, "chain-146": {"and": [{"$ref": "chain-145"}]}, "chain-147": {"and": [{"$ref": "chain-146"}]}, "chain-148": {"and": [{"$ref": "chain-147"}]}, "chain-149": {"and": [{"$ref": "chain-148"}]}, "chain-150": {"and": [{"$ref": "chain-149"}]}, "chain-151": {"and": [{"$ref": "chain-150"}]}, "chain-152": {"and": [{"$ref": "chain-151"}]}, "chain-153": {"and": [{"$ref": "chain-152"}]}, "chain-154": {"and": [{"$ref": "chain-153"}]}, "chain-155": {"and": [{"$ref": "chain-154"}]}, "chain-156": {"and": [{"$ref": "chain-155"}]}, "chain-157": {"and": [{"$ref": "chain-156"}]}, "chain-158": {"and": [{"$ref": "chain-157"}]}, "chain-159": {"and": [{"$ref": "chain-158"}]}, "chain-160": {"and": [{"$ref": "chain-159"}]}, "chain-161": {"and": [{"$ref": "chain-160"}]}, "chain-162": {"and": [{"$ref": "chain-161"}]}, "chain-163": {"and": [{"$ref": "chain-162"}]}, "chain-164": {"and": [{"$ref": "chain-163"}]}, "chain-165": {"and": [{"$ref": "chain-164"}]}, "chain-166": {"and": [{"$ref": "chain-165"}]}, "chain-167": {"and": [{"$ref": "chain-166"}]}, "chain-168": {"and": [{"$ref": "chain-167"}]}, "chain-169": {"and": [{"$ref": "chain-168"}]}, "chain-170": {"and": [{"$ref": "chain-169"}]}, "chain-171": {"and": [{"$ref": "chain-170"}]}, "chain-172": {"and": [{"$ref": "chain-171"}]}, "chain-173": {"and": [{"$ref": "chain-172"}]}, "chain-174": {"and": [{"$ref": "chain-173"}]}, "chain-175": {"and": [{"$ref": "chain-174"}]}, "chain-176": {"and": [{"$ref": "chain-175"}]}, "chain-177": {"and": [{"$ref": "chain-176"}]}, "chain-178": {"and": [{"$ref": "chain-177"}]}, "chain-179": {"and": [{"$ref": "chain-178"}]}, "chain-180": {"and": [{"$ref": "chain-179"}]}, "chain-181": {"and": [{"$ref": "chain-180"}]}, "chain-182": {"and": [{"$ref": "chain-181"}]}, "chain-183": {"and": [{"$ref": "chain-182"}]}, "chain-184": {"and": [{"$ref": "chain-183"}]}, "chain-185": {"and": [{"$ref": "chain-184"}]}, "chain-186": {"and": [{"$ref": "chain-185"}]}, "chain-187": {"and": [{"$ref": "chain-186"}]}, "chain-188": {"and": [{"$ref": "chain-187"}]}, "chain-189": {"and": [{"$ref": "chain-188"}]}, "chain-190": {"and": [{"$ref": "chain-189"}]}, "chain-191": {"and": [{"$ref": "chain-190"}]}, "chain-192": {"and": [{"$ref": "chain-191"}]}, "chain-193": {"and": [{"$ref": "chain-192"}]}, "chain-194": {"and": [{"$ref": "chain-193"}]}, "chain-195": {"and": [{"$ref": "chain-194"}]}, "chain-196": {"and": [{"$ref": "chain-195"}]}, "chain-197": {"and": [{"$ref": "chain-196"}]}, "chain-198": {"and": [{"$ref": "chain-197"}]}, "chain-199": {"and": [{"$ref": "chain-198"}]}, "chain-200": {"and": [{"$ref": "chain-199"}]}}}, "flag": "deep-chain-long", "type": "boolean", "default": true, "targetingKey": "u-36", "context": {"tier": "gold"}, "expect": {"value": true, "variant": null, "reason": "ERROR", "errorCode": "PARSE_ERROR"}}, + {"name": "references within the budget once each resolved reference costs one", "kind": "evaluate", "document": {"flags": {"ref-cost-under": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "alias-700"}, "on", "off"]}}, "ref-cost-over": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "alias-800"}, "on", "off"]}}}, "$evaluators": {"fan-0": {"==": [{"var": "tier"}, "gold"]}, "fan-1": {"or": [{"$ref": "fan-0"}, {"$ref": "fan-0"}]}, "fan-2": {"or": [{"$ref": "fan-1"}, {"$ref": "fan-1"}]}, "fan-3": {"or": [{"$ref": "fan-2"}, {"$ref": "fan-2"}]}, "fan-4": {"or": [{"$ref": "fan-3"}, {"$ref": "fan-3"}]}, "fan-5": {"or": [{"$ref": "fan-4"}, {"$ref": "fan-4"}]}, "fan-6": {"or": [{"$ref": "fan-5"}, {"$ref": "fan-5"}]}, "fan-7": {"or": [{"$ref": "fan-6"}, {"$ref": "fan-6"}]}, "fan-8": {"or": [{"$ref": "fan-7"}, {"$ref": "fan-7"}]}, "fan-9": {"or": [{"$ref": "fan-8"}, {"$ref": "fan-8"}]}, "fan-10": {"or": [{"$ref": "fan-9"}, {"$ref": "fan-9"}]}, "alias-0": {"$ref": "fan-10"}, "alias-1": {"$ref": "alias-0"}, "alias-2": {"$ref": "alias-1"}, "alias-3": {"$ref": "alias-2"}, "alias-4": {"$ref": "alias-3"}, "alias-5": {"$ref": "alias-4"}, "alias-6": {"$ref": "alias-5"}, "alias-7": {"$ref": "alias-6"}, "alias-8": {"$ref": "alias-7"}, "alias-9": {"$ref": "alias-8"}, "alias-10": {"$ref": "alias-9"}, "alias-11": {"$ref": "alias-10"}, "alias-12": {"$ref": "alias-11"}, "alias-13": {"$ref": "alias-12"}, "alias-14": {"$ref": "alias-13"}, "alias-15": {"$ref": "alias-14"}, "alias-16": {"$ref": "alias-15"}, "alias-17": {"$ref": "alias-16"}, "alias-18": {"$ref": "alias-17"}, "alias-19": {"$ref": "alias-18"}, "alias-20": {"$ref": "alias-19"}, "alias-21": {"$ref": "alias-20"}, "alias-22": {"$ref": "alias-21"}, "alias-23": {"$ref": "alias-22"}, "alias-24": {"$ref": "alias-23"}, "alias-25": {"$ref": "alias-24"}, "alias-26": {"$ref": "alias-25"}, "alias-27": {"$ref": "alias-26"}, "alias-28": {"$ref": "alias-27"}, "alias-29": {"$ref": "alias-28"}, "alias-30": {"$ref": "alias-29"}, "alias-31": {"$ref": "alias-30"}, "alias-32": {"$ref": "alias-31"}, "alias-33": {"$ref": "alias-32"}, "alias-34": {"$ref": "alias-33"}, "alias-35": {"$ref": "alias-34"}, "alias-36": {"$ref": "alias-35"}, "alias-37": {"$ref": "alias-36"}, "alias-38": {"$ref": "alias-37"}, "alias-39": {"$ref": "alias-38"}, "alias-40": {"$ref": "alias-39"}, "alias-41": {"$ref": "alias-40"}, "alias-42": {"$ref": "alias-41"}, "alias-43": {"$ref": "alias-42"}, "alias-44": {"$ref": "alias-43"}, "alias-45": {"$ref": "alias-44"}, "alias-46": {"$ref": "alias-45"}, "alias-47": {"$ref": "alias-46"}, "alias-48": {"$ref": "alias-47"}, "alias-49": {"$ref": "alias-48"}, "alias-50": {"$ref": "alias-49"}, "alias-51": {"$ref": "alias-50"}, "alias-52": {"$ref": "alias-51"}, "alias-53": {"$ref": "alias-52"}, "alias-54": {"$ref": "alias-53"}, "alias-55": {"$ref": "alias-54"}, "alias-56": {"$ref": "alias-55"}, "alias-57": {"$ref": "alias-56"}, "alias-58": {"$ref": "alias-57"}, "alias-59": {"$ref": "alias-58"}, "alias-60": {"$ref": "alias-59"}, "alias-61": {"$ref": "alias-60"}, "alias-62": {"$ref": "alias-61"}, "alias-63": {"$ref": "alias-62"}, "alias-64": {"$ref": "alias-63"}, "alias-65": {"$ref": "alias-64"}, "alias-66": {"$ref": "alias-65"}, "alias-67": {"$ref": "alias-66"}, "alias-68": {"$ref": "alias-67"}, "alias-69": {"$ref": "alias-68"}, "alias-70": {"$ref": "alias-69"}, "alias-71": {"$ref": "alias-70"}, "alias-72": {"$ref": "alias-71"}, "alias-73": {"$ref": "alias-72"}, "alias-74": {"$ref": "alias-73"}, "alias-75": {"$ref": "alias-74"}, "alias-76": {"$ref": "alias-75"}, "alias-77": {"$ref": "alias-76"}, "alias-78": {"$ref": "alias-77"}, "alias-79": {"$ref": "alias-78"}, "alias-80": {"$ref": "alias-79"}, "alias-81": {"$ref": "alias-80"}, "alias-82": {"$ref": "alias-81"}, "alias-83": {"$ref": "alias-82"}, "alias-84": {"$ref": "alias-83"}, "alias-85": {"$ref": "alias-84"}, "alias-86": {"$ref": "alias-85"}, "alias-87": {"$ref": "alias-86"}, "alias-88": {"$ref": "alias-87"}, "alias-89": {"$ref": "alias-88"}, "alias-90": {"$ref": "alias-89"}, "alias-91": {"$ref": "alias-90"}, "alias-92": {"$ref": "alias-91"}, "alias-93": {"$ref": "alias-92"}, "alias-94": {"$ref": "alias-93"}, "alias-95": {"$ref": "alias-94"}, "alias-96": {"$ref": "alias-95"}, "alias-97": {"$ref": "alias-96"}, "alias-98": {"$ref": "alias-97"}, "alias-99": {"$ref": "alias-98"}, "alias-100": {"$ref": "alias-99"}, "alias-101": {"$ref": "alias-100"}, "alias-102": {"$ref": "alias-101"}, "alias-103": {"$ref": "alias-102"}, "alias-104": {"$ref": "alias-103"}, "alias-105": {"$ref": "alias-104"}, "alias-106": {"$ref": "alias-105"}, "alias-107": {"$ref": "alias-106"}, "alias-108": {"$ref": "alias-107"}, "alias-109": {"$ref": "alias-108"}, "alias-110": {"$ref": "alias-109"}, "alias-111": {"$ref": "alias-110"}, "alias-112": {"$ref": "alias-111"}, "alias-113": {"$ref": "alias-112"}, "alias-114": {"$ref": "alias-113"}, "alias-115": {"$ref": "alias-114"}, "alias-116": {"$ref": "alias-115"}, "alias-117": {"$ref": "alias-116"}, "alias-118": {"$ref": "alias-117"}, "alias-119": {"$ref": "alias-118"}, "alias-120": {"$ref": "alias-119"}, "alias-121": {"$ref": "alias-120"}, "alias-122": {"$ref": "alias-121"}, "alias-123": {"$ref": "alias-122"}, "alias-124": {"$ref": "alias-123"}, "alias-125": {"$ref": "alias-124"}, "alias-126": {"$ref": "alias-125"}, "alias-127": {"$ref": "alias-126"}, "alias-128": {"$ref": "alias-127"}, "alias-129": {"$ref": "alias-128"}, "alias-130": {"$ref": "alias-129"}, "alias-131": {"$ref": "alias-130"}, "alias-132": {"$ref": "alias-131"}, "alias-133": {"$ref": "alias-132"}, "alias-134": {"$ref": "alias-133"}, "alias-135": {"$ref": "alias-134"}, "alias-136": {"$ref": "alias-135"}, "alias-137": {"$ref": "alias-136"}, "alias-138": {"$ref": "alias-137"}, "alias-139": {"$ref": "alias-138"}, "alias-140": {"$ref": "alias-139"}, "alias-141": {"$ref": "alias-140"}, "alias-142": {"$ref": "alias-141"}, "alias-143": {"$ref": "alias-142"}, "alias-144": {"$ref": "alias-143"}, "alias-145": {"$ref": "alias-144"}, "alias-146": {"$ref": "alias-145"}, "alias-147": {"$ref": "alias-146"}, "alias-148": {"$ref": "alias-147"}, "alias-149": {"$ref": "alias-148"}, "alias-150": {"$ref": "alias-149"}, "alias-151": {"$ref": "alias-150"}, "alias-152": {"$ref": "alias-151"}, "alias-153": {"$ref": "alias-152"}, "alias-154": {"$ref": "alias-153"}, "alias-155": {"$ref": "alias-154"}, "alias-156": {"$ref": "alias-155"}, "alias-157": {"$ref": "alias-156"}, "alias-158": {"$ref": "alias-157"}, "alias-159": {"$ref": "alias-158"}, "alias-160": {"$ref": "alias-159"}, "alias-161": {"$ref": "alias-160"}, "alias-162": {"$ref": "alias-161"}, "alias-163": {"$ref": "alias-162"}, "alias-164": {"$ref": "alias-163"}, "alias-165": {"$ref": "alias-164"}, "alias-166": {"$ref": "alias-165"}, "alias-167": {"$ref": "alias-166"}, "alias-168": {"$ref": "alias-167"}, "alias-169": {"$ref": "alias-168"}, "alias-170": {"$ref": "alias-169"}, "alias-171": {"$ref": "alias-170"}, "alias-172": {"$ref": "alias-171"}, "alias-173": {"$ref": "alias-172"}, "alias-174": {"$ref": "alias-173"}, "alias-175": {"$ref": "alias-174"}, "alias-176": {"$ref": "alias-175"}, "alias-177": {"$ref": "alias-176"}, "alias-178": {"$ref": "alias-177"}, "alias-179": {"$ref": "alias-178"}, "alias-180": {"$ref": "alias-179"}, "alias-181": {"$ref": "alias-180"}, "alias-182": {"$ref": "alias-181"}, "alias-183": {"$ref": "alias-182"}, "alias-184": {"$ref": "alias-183"}, "alias-185": {"$ref": "alias-184"}, "alias-186": {"$ref": "alias-185"}, "alias-187": {"$ref": "alias-186"}, "alias-188": {"$ref": "alias-187"}, "alias-189": {"$ref": "alias-188"}, "alias-190": {"$ref": "alias-189"}, "alias-191": {"$ref": "alias-190"}, "alias-192": {"$ref": "alias-191"}, "alias-193": {"$ref": "alias-192"}, "alias-194": {"$ref": "alias-193"}, "alias-195": {"$ref": "alias-194"}, "alias-196": {"$ref": "alias-195"}, "alias-197": {"$ref": "alias-196"}, "alias-198": {"$ref": "alias-197"}, "alias-199": {"$ref": "alias-198"}, "alias-200": {"$ref": "alias-199"}, "alias-201": {"$ref": "alias-200"}, "alias-202": {"$ref": "alias-201"}, "alias-203": {"$ref": "alias-202"}, "alias-204": {"$ref": "alias-203"}, "alias-205": {"$ref": "alias-204"}, "alias-206": {"$ref": "alias-205"}, "alias-207": {"$ref": "alias-206"}, "alias-208": {"$ref": "alias-207"}, "alias-209": {"$ref": "alias-208"}, "alias-210": {"$ref": "alias-209"}, "alias-211": {"$ref": "alias-210"}, "alias-212": {"$ref": "alias-211"}, "alias-213": {"$ref": "alias-212"}, "alias-214": {"$ref": "alias-213"}, "alias-215": {"$ref": "alias-214"}, "alias-216": {"$ref": "alias-215"}, "alias-217": {"$ref": "alias-216"}, "alias-218": {"$ref": "alias-217"}, "alias-219": {"$ref": "alias-218"}, "alias-220": {"$ref": "alias-219"}, "alias-221": {"$ref": "alias-220"}, "alias-222": {"$ref": "alias-221"}, "alias-223": {"$ref": "alias-222"}, "alias-224": {"$ref": "alias-223"}, "alias-225": {"$ref": "alias-224"}, "alias-226": {"$ref": "alias-225"}, "alias-227": {"$ref": "alias-226"}, "alias-228": {"$ref": "alias-227"}, "alias-229": {"$ref": "alias-228"}, "alias-230": {"$ref": "alias-229"}, "alias-231": {"$ref": "alias-230"}, "alias-232": {"$ref": "alias-231"}, "alias-233": {"$ref": "alias-232"}, "alias-234": {"$ref": "alias-233"}, "alias-235": {"$ref": "alias-234"}, "alias-236": {"$ref": "alias-235"}, "alias-237": {"$ref": "alias-236"}, "alias-238": {"$ref": "alias-237"}, "alias-239": {"$ref": "alias-238"}, "alias-240": {"$ref": "alias-239"}, "alias-241": {"$ref": "alias-240"}, "alias-242": {"$ref": "alias-241"}, "alias-243": {"$ref": "alias-242"}, "alias-244": {"$ref": "alias-243"}, "alias-245": {"$ref": "alias-244"}, "alias-246": {"$ref": "alias-245"}, "alias-247": {"$ref": "alias-246"}, "alias-248": {"$ref": "alias-247"}, "alias-249": {"$ref": "alias-248"}, "alias-250": {"$ref": "alias-249"}, "alias-251": {"$ref": "alias-250"}, "alias-252": {"$ref": "alias-251"}, "alias-253": {"$ref": "alias-252"}, "alias-254": {"$ref": "alias-253"}, "alias-255": {"$ref": "alias-254"}, "alias-256": {"$ref": "alias-255"}, "alias-257": {"$ref": "alias-256"}, "alias-258": {"$ref": "alias-257"}, "alias-259": {"$ref": "alias-258"}, "alias-260": {"$ref": "alias-259"}, "alias-261": {"$ref": "alias-260"}, "alias-262": {"$ref": "alias-261"}, "alias-263": {"$ref": "alias-262"}, "alias-264": {"$ref": "alias-263"}, "alias-265": {"$ref": "alias-264"}, "alias-266": {"$ref": "alias-265"}, "alias-267": {"$ref": "alias-266"}, "alias-268": {"$ref": "alias-267"}, "alias-269": {"$ref": "alias-268"}, "alias-270": {"$ref": "alias-269"}, "alias-271": {"$ref": "alias-270"}, "alias-272": {"$ref": "alias-271"}, "alias-273": {"$ref": "alias-272"}, "alias-274": {"$ref": "alias-273"}, "alias-275": {"$ref": "alias-274"}, "alias-276": {"$ref": "alias-275"}, "alias-277": {"$ref": "alias-276"}, "alias-278": {"$ref": "alias-277"}, "alias-279": {"$ref": "alias-278"}, "alias-280": {"$ref": "alias-279"}, "alias-281": {"$ref": "alias-280"}, "alias-282": {"$ref": "alias-281"}, "alias-283": {"$ref": "alias-282"}, "alias-284": {"$ref": "alias-283"}, "alias-285": {"$ref": "alias-284"}, "alias-286": {"$ref": "alias-285"}, "alias-287": {"$ref": "alias-286"}, "alias-288": {"$ref": "alias-287"}, "alias-289": {"$ref": "alias-288"}, "alias-290": {"$ref": "alias-289"}, "alias-291": {"$ref": "alias-290"}, "alias-292": {"$ref": "alias-291"}, "alias-293": {"$ref": "alias-292"}, "alias-294": {"$ref": "alias-293"}, "alias-295": {"$ref": "alias-294"}, "alias-296": {"$ref": "alias-295"}, "alias-297": {"$ref": "alias-296"}, "alias-298": {"$ref": "alias-297"}, "alias-299": {"$ref": "alias-298"}, "alias-300": {"$ref": "alias-299"}, "alias-301": {"$ref": "alias-300"}, "alias-302": {"$ref": "alias-301"}, "alias-303": {"$ref": "alias-302"}, "alias-304": {"$ref": "alias-303"}, "alias-305": {"$ref": "alias-304"}, "alias-306": {"$ref": "alias-305"}, "alias-307": {"$ref": "alias-306"}, "alias-308": {"$ref": "alias-307"}, "alias-309": {"$ref": "alias-308"}, "alias-310": {"$ref": "alias-309"}, "alias-311": {"$ref": "alias-310"}, "alias-312": {"$ref": "alias-311"}, "alias-313": {"$ref": "alias-312"}, "alias-314": {"$ref": "alias-313"}, "alias-315": {"$ref": "alias-314"}, "alias-316": {"$ref": "alias-315"}, "alias-317": {"$ref": "alias-316"}, "alias-318": {"$ref": "alias-317"}, "alias-319": {"$ref": "alias-318"}, "alias-320": {"$ref": "alias-319"}, "alias-321": {"$ref": "alias-320"}, "alias-322": {"$ref": "alias-321"}, "alias-323": {"$ref": "alias-322"}, "alias-324": {"$ref": "alias-323"}, "alias-325": {"$ref": "alias-324"}, "alias-326": {"$ref": "alias-325"}, "alias-327": {"$ref": "alias-326"}, "alias-328": {"$ref": "alias-327"}, "alias-329": {"$ref": "alias-328"}, "alias-330": {"$ref": "alias-329"}, "alias-331": {"$ref": "alias-330"}, "alias-332": {"$ref": "alias-331"}, "alias-333": {"$ref": "alias-332"}, "alias-334": {"$ref": "alias-333"}, "alias-335": {"$ref": "alias-334"}, "alias-336": {"$ref": "alias-335"}, "alias-337": {"$ref": "alias-336"}, "alias-338": {"$ref": "alias-337"}, "alias-339": {"$ref": "alias-338"}, "alias-340": {"$ref": "alias-339"}, "alias-341": {"$ref": "alias-340"}, "alias-342": {"$ref": "alias-341"}, "alias-343": {"$ref": "alias-342"}, "alias-344": {"$ref": "alias-343"}, "alias-345": {"$ref": "alias-344"}, "alias-346": {"$ref": "alias-345"}, "alias-347": {"$ref": "alias-346"}, "alias-348": {"$ref": "alias-347"}, "alias-349": {"$ref": "alias-348"}, "alias-350": {"$ref": "alias-349"}, "alias-351": {"$ref": "alias-350"}, "alias-352": {"$ref": "alias-351"}, "alias-353": {"$ref": "alias-352"}, "alias-354": {"$ref": "alias-353"}, "alias-355": {"$ref": "alias-354"}, "alias-356": {"$ref": "alias-355"}, "alias-357": {"$ref": "alias-356"}, "alias-358": {"$ref": "alias-357"}, "alias-359": {"$ref": "alias-358"}, "alias-360": {"$ref": "alias-359"}, "alias-361": {"$ref": "alias-360"}, "alias-362": {"$ref": "alias-361"}, "alias-363": {"$ref": "alias-362"}, "alias-364": {"$ref": "alias-363"}, "alias-365": {"$ref": "alias-364"}, "alias-366": {"$ref": "alias-365"}, "alias-367": {"$ref": "alias-366"}, "alias-368": {"$ref": "alias-367"}, "alias-369": {"$ref": "alias-368"}, "alias-370": {"$ref": "alias-369"}, "alias-371": {"$ref": "alias-370"}, "alias-372": {"$ref": "alias-371"}, "alias-373": {"$ref": "alias-372"}, "alias-374": {"$ref": "alias-373"}, "alias-375": {"$ref": "alias-374"}, "alias-376": {"$ref": "alias-375"}, "alias-377": {"$ref": "alias-376"}, "alias-378": {"$ref": "alias-377"}, "alias-379": {"$ref": "alias-378"}, "alias-380": {"$ref": "alias-379"}, "alias-381": {"$ref": "alias-380"}, "alias-382": {"$ref": "alias-381"}, "alias-383": {"$ref": "alias-382"}, "alias-384": {"$ref": "alias-383"}, "alias-385": {"$ref": "alias-384"}, "alias-386": {"$ref": "alias-385"}, "alias-387": {"$ref": "alias-386"}, "alias-388": {"$ref": "alias-387"}, "alias-389": {"$ref": "alias-388"}, "alias-390": {"$ref": "alias-389"}, "alias-391": {"$ref": "alias-390"}, "alias-392": {"$ref": "alias-391"}, "alias-393": {"$ref": "alias-392"}, "alias-394": {"$ref": "alias-393"}, "alias-395": {"$ref": "alias-394"}, "alias-396": {"$ref": "alias-395"}, "alias-397": {"$ref": "alias-396"}, "alias-398": {"$ref": "alias-397"}, "alias-399": {"$ref": "alias-398"}, "alias-400": {"$ref": "alias-399"}, "alias-401": {"$ref": "alias-400"}, "alias-402": {"$ref": "alias-401"}, "alias-403": {"$ref": "alias-402"}, "alias-404": {"$ref": "alias-403"}, "alias-405": {"$ref": "alias-404"}, "alias-406": {"$ref": "alias-405"}, "alias-407": {"$ref": "alias-406"}, "alias-408": {"$ref": "alias-407"}, "alias-409": {"$ref": "alias-408"}, "alias-410": {"$ref": "alias-409"}, "alias-411": {"$ref": "alias-410"}, "alias-412": {"$ref": "alias-411"}, "alias-413": {"$ref": "alias-412"}, "alias-414": {"$ref": "alias-413"}, "alias-415": {"$ref": "alias-414"}, "alias-416": {"$ref": "alias-415"}, "alias-417": {"$ref": "alias-416"}, "alias-418": {"$ref": "alias-417"}, "alias-419": {"$ref": "alias-418"}, "alias-420": {"$ref": "alias-419"}, "alias-421": {"$ref": "alias-420"}, "alias-422": {"$ref": "alias-421"}, "alias-423": {"$ref": "alias-422"}, "alias-424": {"$ref": "alias-423"}, "alias-425": {"$ref": "alias-424"}, "alias-426": {"$ref": "alias-425"}, "alias-427": {"$ref": "alias-426"}, "alias-428": {"$ref": "alias-427"}, "alias-429": {"$ref": "alias-428"}, "alias-430": {"$ref": "alias-429"}, "alias-431": {"$ref": "alias-430"}, "alias-432": {"$ref": "alias-431"}, "alias-433": {"$ref": "alias-432"}, "alias-434": {"$ref": "alias-433"}, "alias-435": {"$ref": "alias-434"}, "alias-436": {"$ref": "alias-435"}, "alias-437": {"$ref": "alias-436"}, "alias-438": {"$ref": "alias-437"}, "alias-439": {"$ref": "alias-438"}, "alias-440": {"$ref": "alias-439"}, "alias-441": {"$ref": "alias-440"}, "alias-442": {"$ref": "alias-441"}, "alias-443": {"$ref": "alias-442"}, "alias-444": {"$ref": "alias-443"}, "alias-445": {"$ref": "alias-444"}, "alias-446": {"$ref": "alias-445"}, "alias-447": {"$ref": "alias-446"}, "alias-448": {"$ref": "alias-447"}, "alias-449": {"$ref": "alias-448"}, "alias-450": {"$ref": "alias-449"}, "alias-451": {"$ref": "alias-450"}, "alias-452": {"$ref": "alias-451"}, "alias-453": {"$ref": "alias-452"}, "alias-454": {"$ref": "alias-453"}, "alias-455": {"$ref": "alias-454"}, "alias-456": {"$ref": "alias-455"}, "alias-457": {"$ref": "alias-456"}, "alias-458": {"$ref": "alias-457"}, "alias-459": {"$ref": "alias-458"}, "alias-460": {"$ref": "alias-459"}, "alias-461": {"$ref": "alias-460"}, "alias-462": {"$ref": "alias-461"}, "alias-463": {"$ref": "alias-462"}, "alias-464": {"$ref": "alias-463"}, "alias-465": {"$ref": "alias-464"}, "alias-466": {"$ref": "alias-465"}, "alias-467": {"$ref": "alias-466"}, "alias-468": {"$ref": "alias-467"}, "alias-469": {"$ref": "alias-468"}, "alias-470": {"$ref": "alias-469"}, "alias-471": {"$ref": "alias-470"}, "alias-472": {"$ref": "alias-471"}, "alias-473": {"$ref": "alias-472"}, "alias-474": {"$ref": "alias-473"}, "alias-475": {"$ref": "alias-474"}, "alias-476": {"$ref": "alias-475"}, "alias-477": {"$ref": "alias-476"}, "alias-478": {"$ref": "alias-477"}, "alias-479": {"$ref": "alias-478"}, "alias-480": {"$ref": "alias-479"}, "alias-481": {"$ref": "alias-480"}, "alias-482": {"$ref": "alias-481"}, "alias-483": {"$ref": "alias-482"}, "alias-484": {"$ref": "alias-483"}, "alias-485": {"$ref": "alias-484"}, "alias-486": {"$ref": "alias-485"}, "alias-487": {"$ref": "alias-486"}, "alias-488": {"$ref": "alias-487"}, "alias-489": {"$ref": "alias-488"}, "alias-490": {"$ref": "alias-489"}, "alias-491": {"$ref": "alias-490"}, "alias-492": {"$ref": "alias-491"}, "alias-493": {"$ref": "alias-492"}, "alias-494": {"$ref": "alias-493"}, "alias-495": {"$ref": "alias-494"}, "alias-496": {"$ref": "alias-495"}, "alias-497": {"$ref": "alias-496"}, "alias-498": {"$ref": "alias-497"}, "alias-499": {"$ref": "alias-498"}, "alias-500": {"$ref": "alias-499"}, "alias-501": {"$ref": "alias-500"}, "alias-502": {"$ref": "alias-501"}, "alias-503": {"$ref": "alias-502"}, "alias-504": {"$ref": "alias-503"}, "alias-505": {"$ref": "alias-504"}, "alias-506": {"$ref": "alias-505"}, "alias-507": {"$ref": "alias-506"}, "alias-508": {"$ref": "alias-507"}, "alias-509": {"$ref": "alias-508"}, "alias-510": {"$ref": "alias-509"}, "alias-511": {"$ref": "alias-510"}, "alias-512": {"$ref": "alias-511"}, "alias-513": {"$ref": "alias-512"}, "alias-514": {"$ref": "alias-513"}, "alias-515": {"$ref": "alias-514"}, "alias-516": {"$ref": "alias-515"}, "alias-517": {"$ref": "alias-516"}, "alias-518": {"$ref": "alias-517"}, "alias-519": {"$ref": "alias-518"}, "alias-520": {"$ref": "alias-519"}, "alias-521": {"$ref": "alias-520"}, "alias-522": {"$ref": "alias-521"}, "alias-523": {"$ref": "alias-522"}, "alias-524": {"$ref": "alias-523"}, "alias-525": {"$ref": "alias-524"}, "alias-526": {"$ref": "alias-525"}, "alias-527": {"$ref": "alias-526"}, "alias-528": {"$ref": "alias-527"}, "alias-529": {"$ref": "alias-528"}, "alias-530": {"$ref": "alias-529"}, "alias-531": {"$ref": "alias-530"}, "alias-532": {"$ref": "alias-531"}, "alias-533": {"$ref": "alias-532"}, "alias-534": {"$ref": "alias-533"}, "alias-535": {"$ref": "alias-534"}, "alias-536": {"$ref": "alias-535"}, "alias-537": {"$ref": "alias-536"}, "alias-538": {"$ref": "alias-537"}, "alias-539": {"$ref": "alias-538"}, "alias-540": {"$ref": "alias-539"}, "alias-541": {"$ref": "alias-540"}, "alias-542": {"$ref": "alias-541"}, "alias-543": {"$ref": "alias-542"}, "alias-544": {"$ref": "alias-543"}, "alias-545": {"$ref": "alias-544"}, "alias-546": {"$ref": "alias-545"}, "alias-547": {"$ref": "alias-546"}, "alias-548": {"$ref": "alias-547"}, "alias-549": {"$ref": "alias-548"}, "alias-550": {"$ref": "alias-549"}, "alias-551": {"$ref": "alias-550"}, "alias-552": {"$ref": "alias-551"}, "alias-553": {"$ref": "alias-552"}, "alias-554": {"$ref": "alias-553"}, "alias-555": {"$ref": "alias-554"}, "alias-556": {"$ref": "alias-555"}, "alias-557": {"$ref": "alias-556"}, "alias-558": {"$ref": "alias-557"}, "alias-559": {"$ref": "alias-558"}, "alias-560": {"$ref": "alias-559"}, "alias-561": {"$ref": "alias-560"}, "alias-562": {"$ref": "alias-561"}, "alias-563": {"$ref": "alias-562"}, "alias-564": {"$ref": "alias-563"}, "alias-565": {"$ref": "alias-564"}, "alias-566": {"$ref": "alias-565"}, "alias-567": {"$ref": "alias-566"}, "alias-568": {"$ref": "alias-567"}, "alias-569": {"$ref": "alias-568"}, "alias-570": {"$ref": "alias-569"}, "alias-571": {"$ref": "alias-570"}, "alias-572": {"$ref": "alias-571"}, "alias-573": {"$ref": "alias-572"}, "alias-574": {"$ref": "alias-573"}, "alias-575": {"$ref": "alias-574"}, "alias-576": {"$ref": "alias-575"}, "alias-577": {"$ref": "alias-576"}, "alias-578": {"$ref": "alias-577"}, "alias-579": {"$ref": "alias-578"}, "alias-580": {"$ref": "alias-579"}, "alias-581": {"$ref": "alias-580"}, "alias-582": {"$ref": "alias-581"}, "alias-583": {"$ref": "alias-582"}, "alias-584": {"$ref": "alias-583"}, "alias-585": {"$ref": "alias-584"}, "alias-586": {"$ref": "alias-585"}, "alias-587": {"$ref": "alias-586"}, "alias-588": {"$ref": "alias-587"}, "alias-589": {"$ref": "alias-588"}, "alias-590": {"$ref": "alias-589"}, "alias-591": {"$ref": "alias-590"}, "alias-592": {"$ref": "alias-591"}, "alias-593": {"$ref": "alias-592"}, "alias-594": {"$ref": "alias-593"}, "alias-595": {"$ref": "alias-594"}, "alias-596": {"$ref": "alias-595"}, "alias-597": {"$ref": "alias-596"}, "alias-598": {"$ref": "alias-597"}, "alias-599": {"$ref": "alias-598"}, "alias-600": {"$ref": "alias-599"}, "alias-601": {"$ref": "alias-600"}, "alias-602": {"$ref": "alias-601"}, "alias-603": {"$ref": "alias-602"}, "alias-604": {"$ref": "alias-603"}, "alias-605": {"$ref": "alias-604"}, "alias-606": {"$ref": "alias-605"}, "alias-607": {"$ref": "alias-606"}, "alias-608": {"$ref": "alias-607"}, "alias-609": {"$ref": "alias-608"}, "alias-610": {"$ref": "alias-609"}, "alias-611": {"$ref": "alias-610"}, "alias-612": {"$ref": "alias-611"}, "alias-613": {"$ref": "alias-612"}, "alias-614": {"$ref": "alias-613"}, "alias-615": {"$ref": "alias-614"}, "alias-616": {"$ref": "alias-615"}, "alias-617": {"$ref": "alias-616"}, "alias-618": {"$ref": "alias-617"}, "alias-619": {"$ref": "alias-618"}, "alias-620": {"$ref": "alias-619"}, "alias-621": {"$ref": "alias-620"}, "alias-622": {"$ref": "alias-621"}, "alias-623": {"$ref": "alias-622"}, "alias-624": {"$ref": "alias-623"}, "alias-625": {"$ref": "alias-624"}, "alias-626": {"$ref": "alias-625"}, "alias-627": {"$ref": "alias-626"}, "alias-628": {"$ref": "alias-627"}, "alias-629": {"$ref": "alias-628"}, "alias-630": {"$ref": "alias-629"}, "alias-631": {"$ref": "alias-630"}, "alias-632": {"$ref": "alias-631"}, "alias-633": {"$ref": "alias-632"}, "alias-634": {"$ref": "alias-633"}, "alias-635": {"$ref": "alias-634"}, "alias-636": {"$ref": "alias-635"}, "alias-637": {"$ref": "alias-636"}, "alias-638": {"$ref": "alias-637"}, "alias-639": {"$ref": "alias-638"}, "alias-640": {"$ref": "alias-639"}, "alias-641": {"$ref": "alias-640"}, "alias-642": {"$ref": "alias-641"}, "alias-643": {"$ref": "alias-642"}, "alias-644": {"$ref": "alias-643"}, "alias-645": {"$ref": "alias-644"}, "alias-646": {"$ref": "alias-645"}, "alias-647": {"$ref": "alias-646"}, "alias-648": {"$ref": "alias-647"}, "alias-649": {"$ref": "alias-648"}, "alias-650": {"$ref": "alias-649"}, "alias-651": {"$ref": "alias-650"}, "alias-652": {"$ref": "alias-651"}, "alias-653": {"$ref": "alias-652"}, "alias-654": {"$ref": "alias-653"}, "alias-655": {"$ref": "alias-654"}, "alias-656": {"$ref": "alias-655"}, "alias-657": {"$ref": "alias-656"}, "alias-658": {"$ref": "alias-657"}, "alias-659": {"$ref": "alias-658"}, "alias-660": {"$ref": "alias-659"}, "alias-661": {"$ref": "alias-660"}, "alias-662": {"$ref": "alias-661"}, "alias-663": {"$ref": "alias-662"}, "alias-664": {"$ref": "alias-663"}, "alias-665": {"$ref": "alias-664"}, "alias-666": {"$ref": "alias-665"}, "alias-667": {"$ref": "alias-666"}, "alias-668": {"$ref": "alias-667"}, "alias-669": {"$ref": "alias-668"}, "alias-670": {"$ref": "alias-669"}, "alias-671": {"$ref": "alias-670"}, "alias-672": {"$ref": "alias-671"}, "alias-673": {"$ref": "alias-672"}, "alias-674": {"$ref": "alias-673"}, "alias-675": {"$ref": "alias-674"}, "alias-676": {"$ref": "alias-675"}, "alias-677": {"$ref": "alias-676"}, "alias-678": {"$ref": "alias-677"}, "alias-679": {"$ref": "alias-678"}, "alias-680": {"$ref": "alias-679"}, "alias-681": {"$ref": "alias-680"}, "alias-682": {"$ref": "alias-681"}, "alias-683": {"$ref": "alias-682"}, "alias-684": {"$ref": "alias-683"}, "alias-685": {"$ref": "alias-684"}, "alias-686": {"$ref": "alias-685"}, "alias-687": {"$ref": "alias-686"}, "alias-688": {"$ref": "alias-687"}, "alias-689": {"$ref": "alias-688"}, "alias-690": {"$ref": "alias-689"}, "alias-691": {"$ref": "alias-690"}, "alias-692": {"$ref": "alias-691"}, "alias-693": {"$ref": "alias-692"}, "alias-694": {"$ref": "alias-693"}, "alias-695": {"$ref": "alias-694"}, "alias-696": {"$ref": "alias-695"}, "alias-697": {"$ref": "alias-696"}, "alias-698": {"$ref": "alias-697"}, "alias-699": {"$ref": "alias-698"}, "alias-700": {"$ref": "alias-699"}, "alias-701": {"$ref": "alias-700"}, "alias-702": {"$ref": "alias-701"}, "alias-703": {"$ref": "alias-702"}, "alias-704": {"$ref": "alias-703"}, "alias-705": {"$ref": "alias-704"}, "alias-706": {"$ref": "alias-705"}, "alias-707": {"$ref": "alias-706"}, "alias-708": {"$ref": "alias-707"}, "alias-709": {"$ref": "alias-708"}, "alias-710": {"$ref": "alias-709"}, "alias-711": {"$ref": "alias-710"}, "alias-712": {"$ref": "alias-711"}, "alias-713": {"$ref": "alias-712"}, "alias-714": {"$ref": "alias-713"}, "alias-715": {"$ref": "alias-714"}, "alias-716": {"$ref": "alias-715"}, "alias-717": {"$ref": "alias-716"}, "alias-718": {"$ref": "alias-717"}, "alias-719": {"$ref": "alias-718"}, "alias-720": {"$ref": "alias-719"}, "alias-721": {"$ref": "alias-720"}, "alias-722": {"$ref": "alias-721"}, "alias-723": {"$ref": "alias-722"}, "alias-724": {"$ref": "alias-723"}, "alias-725": {"$ref": "alias-724"}, "alias-726": {"$ref": "alias-725"}, "alias-727": {"$ref": "alias-726"}, "alias-728": {"$ref": "alias-727"}, "alias-729": {"$ref": "alias-728"}, "alias-730": {"$ref": "alias-729"}, "alias-731": {"$ref": "alias-730"}, "alias-732": {"$ref": "alias-731"}, "alias-733": {"$ref": "alias-732"}, "alias-734": {"$ref": "alias-733"}, "alias-735": {"$ref": "alias-734"}, "alias-736": {"$ref": "alias-735"}, "alias-737": {"$ref": "alias-736"}, "alias-738": {"$ref": "alias-737"}, "alias-739": {"$ref": "alias-738"}, "alias-740": {"$ref": "alias-739"}, "alias-741": {"$ref": "alias-740"}, "alias-742": {"$ref": "alias-741"}, "alias-743": {"$ref": "alias-742"}, "alias-744": {"$ref": "alias-743"}, "alias-745": {"$ref": "alias-744"}, "alias-746": {"$ref": "alias-745"}, "alias-747": {"$ref": "alias-746"}, "alias-748": {"$ref": "alias-747"}, "alias-749": {"$ref": "alias-748"}, "alias-750": {"$ref": "alias-749"}, "alias-751": {"$ref": "alias-750"}, "alias-752": {"$ref": "alias-751"}, "alias-753": {"$ref": "alias-752"}, "alias-754": {"$ref": "alias-753"}, "alias-755": {"$ref": "alias-754"}, "alias-756": {"$ref": "alias-755"}, "alias-757": {"$ref": "alias-756"}, "alias-758": {"$ref": "alias-757"}, "alias-759": {"$ref": "alias-758"}, "alias-760": {"$ref": "alias-759"}, "alias-761": {"$ref": "alias-760"}, "alias-762": {"$ref": "alias-761"}, "alias-763": {"$ref": "alias-762"}, "alias-764": {"$ref": "alias-763"}, "alias-765": {"$ref": "alias-764"}, "alias-766": {"$ref": "alias-765"}, "alias-767": {"$ref": "alias-766"}, "alias-768": {"$ref": "alias-767"}, "alias-769": {"$ref": "alias-768"}, "alias-770": {"$ref": "alias-769"}, "alias-771": {"$ref": "alias-770"}, "alias-772": {"$ref": "alias-771"}, "alias-773": {"$ref": "alias-772"}, "alias-774": {"$ref": "alias-773"}, "alias-775": {"$ref": "alias-774"}, "alias-776": {"$ref": "alias-775"}, "alias-777": {"$ref": "alias-776"}, "alias-778": {"$ref": "alias-777"}, "alias-779": {"$ref": "alias-778"}, "alias-780": {"$ref": "alias-779"}, "alias-781": {"$ref": "alias-780"}, "alias-782": {"$ref": "alias-781"}, "alias-783": {"$ref": "alias-782"}, "alias-784": {"$ref": "alias-783"}, "alias-785": {"$ref": "alias-784"}, "alias-786": {"$ref": "alias-785"}, "alias-787": {"$ref": "alias-786"}, "alias-788": {"$ref": "alias-787"}, "alias-789": {"$ref": "alias-788"}, "alias-790": {"$ref": "alias-789"}, "alias-791": {"$ref": "alias-790"}, "alias-792": {"$ref": "alias-791"}, "alias-793": {"$ref": "alias-792"}, "alias-794": {"$ref": "alias-793"}, "alias-795": {"$ref": "alias-794"}, "alias-796": {"$ref": "alias-795"}, "alias-797": {"$ref": "alias-796"}, "alias-798": {"$ref": "alias-797"}, "alias-799": {"$ref": "alias-798"}, "alias-800": {"$ref": "alias-799"}}}, "flag": "ref-cost-under", "type": "boolean", "default": false, "targetingKey": "u-37", "context": {"tier": "gold"}, "expect": {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}}, + {"name": "references beyond the budget once each resolved reference costs one", "kind": "evaluate", "document": {"flags": {"ref-cost-under": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "alias-700"}, "on", "off"]}}, "ref-cost-over": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "alias-800"}, "on", "off"]}}}, "$evaluators": {"fan-0": {"==": [{"var": "tier"}, "gold"]}, "fan-1": {"or": [{"$ref": "fan-0"}, {"$ref": "fan-0"}]}, "fan-2": {"or": [{"$ref": "fan-1"}, {"$ref": "fan-1"}]}, "fan-3": {"or": [{"$ref": "fan-2"}, {"$ref": "fan-2"}]}, "fan-4": {"or": [{"$ref": "fan-3"}, {"$ref": "fan-3"}]}, "fan-5": {"or": [{"$ref": "fan-4"}, {"$ref": "fan-4"}]}, "fan-6": {"or": [{"$ref": "fan-5"}, {"$ref": "fan-5"}]}, "fan-7": {"or": [{"$ref": "fan-6"}, {"$ref": "fan-6"}]}, "fan-8": {"or": [{"$ref": "fan-7"}, {"$ref": "fan-7"}]}, "fan-9": {"or": [{"$ref": "fan-8"}, {"$ref": "fan-8"}]}, "fan-10": {"or": [{"$ref": "fan-9"}, {"$ref": "fan-9"}]}, "alias-0": {"$ref": "fan-10"}, "alias-1": {"$ref": "alias-0"}, "alias-2": {"$ref": "alias-1"}, "alias-3": {"$ref": "alias-2"}, "alias-4": {"$ref": "alias-3"}, "alias-5": {"$ref": "alias-4"}, "alias-6": {"$ref": "alias-5"}, "alias-7": {"$ref": "alias-6"}, "alias-8": {"$ref": "alias-7"}, "alias-9": {"$ref": "alias-8"}, "alias-10": {"$ref": "alias-9"}, "alias-11": {"$ref": "alias-10"}, "alias-12": {"$ref": "alias-11"}, "alias-13": {"$ref": "alias-12"}, "alias-14": {"$ref": "alias-13"}, "alias-15": {"$ref": "alias-14"}, "alias-16": {"$ref": "alias-15"}, "alias-17": {"$ref": "alias-16"}, "alias-18": {"$ref": "alias-17"}, "alias-19": {"$ref": "alias-18"}, "alias-20": {"$ref": "alias-19"}, "alias-21": {"$ref": "alias-20"}, "alias-22": {"$ref": "alias-21"}, "alias-23": {"$ref": "alias-22"}, "alias-24": {"$ref": "alias-23"}, "alias-25": {"$ref": "alias-24"}, "alias-26": {"$ref": "alias-25"}, "alias-27": {"$ref": "alias-26"}, "alias-28": {"$ref": "alias-27"}, "alias-29": {"$ref": "alias-28"}, "alias-30": {"$ref": "alias-29"}, "alias-31": {"$ref": "alias-30"}, "alias-32": {"$ref": "alias-31"}, "alias-33": {"$ref": "alias-32"}, "alias-34": {"$ref": "alias-33"}, "alias-35": {"$ref": "alias-34"}, "alias-36": {"$ref": "alias-35"}, "alias-37": {"$ref": "alias-36"}, "alias-38": {"$ref": "alias-37"}, "alias-39": {"$ref": "alias-38"}, "alias-40": {"$ref": "alias-39"}, "alias-41": {"$ref": "alias-40"}, "alias-42": {"$ref": "alias-41"}, "alias-43": {"$ref": "alias-42"}, "alias-44": {"$ref": "alias-43"}, "alias-45": {"$ref": "alias-44"}, "alias-46": {"$ref": "alias-45"}, "alias-47": {"$ref": "alias-46"}, "alias-48": {"$ref": "alias-47"}, "alias-49": {"$ref": "alias-48"}, "alias-50": {"$ref": "alias-49"}, "alias-51": {"$ref": "alias-50"}, "alias-52": {"$ref": "alias-51"}, "alias-53": {"$ref": "alias-52"}, "alias-54": {"$ref": "alias-53"}, "alias-55": {"$ref": "alias-54"}, "alias-56": {"$ref": "alias-55"}, "alias-57": {"$ref": "alias-56"}, "alias-58": {"$ref": "alias-57"}, "alias-59": {"$ref": "alias-58"}, "alias-60": {"$ref": "alias-59"}, "alias-61": {"$ref": "alias-60"}, "alias-62": {"$ref": "alias-61"}, "alias-63": {"$ref": "alias-62"}, "alias-64": {"$ref": "alias-63"}, "alias-65": {"$ref": "alias-64"}, "alias-66": {"$ref": "alias-65"}, "alias-67": {"$ref": "alias-66"}, "alias-68": {"$ref": "alias-67"}, "alias-69": {"$ref": "alias-68"}, "alias-70": {"$ref": "alias-69"}, "alias-71": {"$ref": "alias-70"}, "alias-72": {"$ref": "alias-71"}, "alias-73": {"$ref": "alias-72"}, "alias-74": {"$ref": "alias-73"}, "alias-75": {"$ref": "alias-74"}, "alias-76": {"$ref": "alias-75"}, "alias-77": {"$ref": "alias-76"}, "alias-78": {"$ref": "alias-77"}, "alias-79": {"$ref": "alias-78"}, "alias-80": {"$ref": "alias-79"}, "alias-81": {"$ref": "alias-80"}, "alias-82": {"$ref": "alias-81"}, "alias-83": {"$ref": "alias-82"}, "alias-84": {"$ref": "alias-83"}, "alias-85": {"$ref": "alias-84"}, "alias-86": {"$ref": "alias-85"}, "alias-87": {"$ref": "alias-86"}, "alias-88": {"$ref": "alias-87"}, "alias-89": {"$ref": "alias-88"}, "alias-90": {"$ref": "alias-89"}, "alias-91": {"$ref": "alias-90"}, "alias-92": {"$ref": "alias-91"}, "alias-93": {"$ref": "alias-92"}, "alias-94": {"$ref": "alias-93"}, "alias-95": {"$ref": "alias-94"}, "alias-96": {"$ref": "alias-95"}, "alias-97": {"$ref": "alias-96"}, "alias-98": {"$ref": "alias-97"}, "alias-99": {"$ref": "alias-98"}, "alias-100": {"$ref": "alias-99"}, "alias-101": {"$ref": "alias-100"}, "alias-102": {"$ref": "alias-101"}, "alias-103": {"$ref": "alias-102"}, "alias-104": {"$ref": "alias-103"}, "alias-105": {"$ref": "alias-104"}, "alias-106": {"$ref": "alias-105"}, "alias-107": {"$ref": "alias-106"}, "alias-108": {"$ref": "alias-107"}, "alias-109": {"$ref": "alias-108"}, "alias-110": {"$ref": "alias-109"}, "alias-111": {"$ref": "alias-110"}, "alias-112": {"$ref": "alias-111"}, "alias-113": {"$ref": "alias-112"}, "alias-114": {"$ref": "alias-113"}, "alias-115": {"$ref": "alias-114"}, "alias-116": {"$ref": "alias-115"}, "alias-117": {"$ref": "alias-116"}, "alias-118": {"$ref": "alias-117"}, "alias-119": {"$ref": "alias-118"}, "alias-120": {"$ref": "alias-119"}, "alias-121": {"$ref": "alias-120"}, "alias-122": {"$ref": "alias-121"}, "alias-123": {"$ref": "alias-122"}, "alias-124": {"$ref": "alias-123"}, "alias-125": {"$ref": "alias-124"}, "alias-126": {"$ref": "alias-125"}, "alias-127": {"$ref": "alias-126"}, "alias-128": {"$ref": "alias-127"}, "alias-129": {"$ref": "alias-128"}, "alias-130": {"$ref": "alias-129"}, "alias-131": {"$ref": "alias-130"}, "alias-132": {"$ref": "alias-131"}, "alias-133": {"$ref": "alias-132"}, "alias-134": {"$ref": "alias-133"}, "alias-135": {"$ref": "alias-134"}, "alias-136": {"$ref": "alias-135"}, "alias-137": {"$ref": "alias-136"}, "alias-138": {"$ref": "alias-137"}, "alias-139": {"$ref": "alias-138"}, "alias-140": {"$ref": "alias-139"}, "alias-141": {"$ref": "alias-140"}, "alias-142": {"$ref": "alias-141"}, "alias-143": {"$ref": "alias-142"}, "alias-144": {"$ref": "alias-143"}, "alias-145": {"$ref": "alias-144"}, "alias-146": {"$ref": "alias-145"}, "alias-147": {"$ref": "alias-146"}, "alias-148": {"$ref": "alias-147"}, "alias-149": {"$ref": "alias-148"}, "alias-150": {"$ref": "alias-149"}, "alias-151": {"$ref": "alias-150"}, "alias-152": {"$ref": "alias-151"}, "alias-153": {"$ref": "alias-152"}, "alias-154": {"$ref": "alias-153"}, "alias-155": {"$ref": "alias-154"}, "alias-156": {"$ref": "alias-155"}, "alias-157": {"$ref": "alias-156"}, "alias-158": {"$ref": "alias-157"}, "alias-159": {"$ref": "alias-158"}, "alias-160": {"$ref": "alias-159"}, "alias-161": {"$ref": "alias-160"}, "alias-162": {"$ref": "alias-161"}, "alias-163": {"$ref": "alias-162"}, "alias-164": {"$ref": "alias-163"}, "alias-165": {"$ref": "alias-164"}, "alias-166": {"$ref": "alias-165"}, "alias-167": {"$ref": "alias-166"}, "alias-168": {"$ref": "alias-167"}, "alias-169": {"$ref": "alias-168"}, "alias-170": {"$ref": "alias-169"}, "alias-171": {"$ref": "alias-170"}, "alias-172": {"$ref": "alias-171"}, "alias-173": {"$ref": "alias-172"}, "alias-174": {"$ref": "alias-173"}, "alias-175": {"$ref": "alias-174"}, "alias-176": {"$ref": "alias-175"}, "alias-177": {"$ref": "alias-176"}, "alias-178": {"$ref": "alias-177"}, "alias-179": {"$ref": "alias-178"}, "alias-180": {"$ref": "alias-179"}, "alias-181": {"$ref": "alias-180"}, "alias-182": {"$ref": "alias-181"}, "alias-183": {"$ref": "alias-182"}, "alias-184": {"$ref": "alias-183"}, "alias-185": {"$ref": "alias-184"}, "alias-186": {"$ref": "alias-185"}, "alias-187": {"$ref": "alias-186"}, "alias-188": {"$ref": "alias-187"}, "alias-189": {"$ref": "alias-188"}, "alias-190": {"$ref": "alias-189"}, "alias-191": {"$ref": "alias-190"}, "alias-192": {"$ref": "alias-191"}, "alias-193": {"$ref": "alias-192"}, "alias-194": {"$ref": "alias-193"}, "alias-195": {"$ref": "alias-194"}, "alias-196": {"$ref": "alias-195"}, "alias-197": {"$ref": "alias-196"}, "alias-198": {"$ref": "alias-197"}, "alias-199": {"$ref": "alias-198"}, "alias-200": {"$ref": "alias-199"}, "alias-201": {"$ref": "alias-200"}, "alias-202": {"$ref": "alias-201"}, "alias-203": {"$ref": "alias-202"}, "alias-204": {"$ref": "alias-203"}, "alias-205": {"$ref": "alias-204"}, "alias-206": {"$ref": "alias-205"}, "alias-207": {"$ref": "alias-206"}, "alias-208": {"$ref": "alias-207"}, "alias-209": {"$ref": "alias-208"}, "alias-210": {"$ref": "alias-209"}, "alias-211": {"$ref": "alias-210"}, "alias-212": {"$ref": "alias-211"}, "alias-213": {"$ref": "alias-212"}, "alias-214": {"$ref": "alias-213"}, "alias-215": {"$ref": "alias-214"}, "alias-216": {"$ref": "alias-215"}, "alias-217": {"$ref": "alias-216"}, "alias-218": {"$ref": "alias-217"}, "alias-219": {"$ref": "alias-218"}, "alias-220": {"$ref": "alias-219"}, "alias-221": {"$ref": "alias-220"}, "alias-222": {"$ref": "alias-221"}, "alias-223": {"$ref": "alias-222"}, "alias-224": {"$ref": "alias-223"}, "alias-225": {"$ref": "alias-224"}, "alias-226": {"$ref": "alias-225"}, "alias-227": {"$ref": "alias-226"}, "alias-228": {"$ref": "alias-227"}, "alias-229": {"$ref": "alias-228"}, "alias-230": {"$ref": "alias-229"}, "alias-231": {"$ref": "alias-230"}, "alias-232": {"$ref": "alias-231"}, "alias-233": {"$ref": "alias-232"}, "alias-234": {"$ref": "alias-233"}, "alias-235": {"$ref": "alias-234"}, "alias-236": {"$ref": "alias-235"}, "alias-237": {"$ref": "alias-236"}, "alias-238": {"$ref": "alias-237"}, "alias-239": {"$ref": "alias-238"}, "alias-240": {"$ref": "alias-239"}, "alias-241": {"$ref": "alias-240"}, "alias-242": {"$ref": "alias-241"}, "alias-243": {"$ref": "alias-242"}, "alias-244": {"$ref": "alias-243"}, "alias-245": {"$ref": "alias-244"}, "alias-246": {"$ref": "alias-245"}, "alias-247": {"$ref": "alias-246"}, "alias-248": {"$ref": "alias-247"}, "alias-249": {"$ref": "alias-248"}, "alias-250": {"$ref": "alias-249"}, "alias-251": {"$ref": "alias-250"}, "alias-252": {"$ref": "alias-251"}, "alias-253": {"$ref": "alias-252"}, "alias-254": {"$ref": "alias-253"}, "alias-255": {"$ref": "alias-254"}, "alias-256": {"$ref": "alias-255"}, "alias-257": {"$ref": "alias-256"}, "alias-258": {"$ref": "alias-257"}, "alias-259": {"$ref": "alias-258"}, "alias-260": {"$ref": "alias-259"}, "alias-261": {"$ref": "alias-260"}, "alias-262": {"$ref": "alias-261"}, "alias-263": {"$ref": "alias-262"}, "alias-264": {"$ref": "alias-263"}, "alias-265": {"$ref": "alias-264"}, "alias-266": {"$ref": "alias-265"}, "alias-267": {"$ref": "alias-266"}, "alias-268": {"$ref": "alias-267"}, "alias-269": {"$ref": "alias-268"}, "alias-270": {"$ref": "alias-269"}, "alias-271": {"$ref": "alias-270"}, "alias-272": {"$ref": "alias-271"}, "alias-273": {"$ref": "alias-272"}, "alias-274": {"$ref": "alias-273"}, "alias-275": {"$ref": "alias-274"}, "alias-276": {"$ref": "alias-275"}, "alias-277": {"$ref": "alias-276"}, "alias-278": {"$ref": "alias-277"}, "alias-279": {"$ref": "alias-278"}, "alias-280": {"$ref": "alias-279"}, "alias-281": {"$ref": "alias-280"}, "alias-282": {"$ref": "alias-281"}, "alias-283": {"$ref": "alias-282"}, "alias-284": {"$ref": "alias-283"}, "alias-285": {"$ref": "alias-284"}, "alias-286": {"$ref": "alias-285"}, "alias-287": {"$ref": "alias-286"}, "alias-288": {"$ref": "alias-287"}, "alias-289": {"$ref": "alias-288"}, "alias-290": {"$ref": "alias-289"}, "alias-291": {"$ref": "alias-290"}, "alias-292": {"$ref": "alias-291"}, "alias-293": {"$ref": "alias-292"}, "alias-294": {"$ref": "alias-293"}, "alias-295": {"$ref": "alias-294"}, "alias-296": {"$ref": "alias-295"}, "alias-297": {"$ref": "alias-296"}, "alias-298": {"$ref": "alias-297"}, "alias-299": {"$ref": "alias-298"}, "alias-300": {"$ref": "alias-299"}, "alias-301": {"$ref": "alias-300"}, "alias-302": {"$ref": "alias-301"}, "alias-303": {"$ref": "alias-302"}, "alias-304": {"$ref": "alias-303"}, "alias-305": {"$ref": "alias-304"}, "alias-306": {"$ref": "alias-305"}, "alias-307": {"$ref": "alias-306"}, "alias-308": {"$ref": "alias-307"}, "alias-309": {"$ref": "alias-308"}, "alias-310": {"$ref": "alias-309"}, "alias-311": {"$ref": "alias-310"}, "alias-312": {"$ref": "alias-311"}, "alias-313": {"$ref": "alias-312"}, "alias-314": {"$ref": "alias-313"}, "alias-315": {"$ref": "alias-314"}, "alias-316": {"$ref": "alias-315"}, "alias-317": {"$ref": "alias-316"}, "alias-318": {"$ref": "alias-317"}, "alias-319": {"$ref": "alias-318"}, "alias-320": {"$ref": "alias-319"}, "alias-321": {"$ref": "alias-320"}, "alias-322": {"$ref": "alias-321"}, "alias-323": {"$ref": "alias-322"}, "alias-324": {"$ref": "alias-323"}, "alias-325": {"$ref": "alias-324"}, "alias-326": {"$ref": "alias-325"}, "alias-327": {"$ref": "alias-326"}, "alias-328": {"$ref": "alias-327"}, "alias-329": {"$ref": "alias-328"}, "alias-330": {"$ref": "alias-329"}, "alias-331": {"$ref": "alias-330"}, "alias-332": {"$ref": "alias-331"}, "alias-333": {"$ref": "alias-332"}, "alias-334": {"$ref": "alias-333"}, "alias-335": {"$ref": "alias-334"}, "alias-336": {"$ref": "alias-335"}, "alias-337": {"$ref": "alias-336"}, "alias-338": {"$ref": "alias-337"}, "alias-339": {"$ref": "alias-338"}, "alias-340": {"$ref": "alias-339"}, "alias-341": {"$ref": "alias-340"}, "alias-342": {"$ref": "alias-341"}, "alias-343": {"$ref": "alias-342"}, "alias-344": {"$ref": "alias-343"}, "alias-345": {"$ref": "alias-344"}, "alias-346": {"$ref": "alias-345"}, "alias-347": {"$ref": "alias-346"}, "alias-348": {"$ref": "alias-347"}, "alias-349": {"$ref": "alias-348"}, "alias-350": {"$ref": "alias-349"}, "alias-351": {"$ref": "alias-350"}, "alias-352": {"$ref": "alias-351"}, "alias-353": {"$ref": "alias-352"}, "alias-354": {"$ref": "alias-353"}, "alias-355": {"$ref": "alias-354"}, "alias-356": {"$ref": "alias-355"}, "alias-357": {"$ref": "alias-356"}, "alias-358": {"$ref": "alias-357"}, "alias-359": {"$ref": "alias-358"}, "alias-360": {"$ref": "alias-359"}, "alias-361": {"$ref": "alias-360"}, "alias-362": {"$ref": "alias-361"}, "alias-363": {"$ref": "alias-362"}, "alias-364": {"$ref": "alias-363"}, "alias-365": {"$ref": "alias-364"}, "alias-366": {"$ref": "alias-365"}, "alias-367": {"$ref": "alias-366"}, "alias-368": {"$ref": "alias-367"}, "alias-369": {"$ref": "alias-368"}, "alias-370": {"$ref": "alias-369"}, "alias-371": {"$ref": "alias-370"}, "alias-372": {"$ref": "alias-371"}, "alias-373": {"$ref": "alias-372"}, "alias-374": {"$ref": "alias-373"}, "alias-375": {"$ref": "alias-374"}, "alias-376": {"$ref": "alias-375"}, "alias-377": {"$ref": "alias-376"}, "alias-378": {"$ref": "alias-377"}, "alias-379": {"$ref": "alias-378"}, "alias-380": {"$ref": "alias-379"}, "alias-381": {"$ref": "alias-380"}, "alias-382": {"$ref": "alias-381"}, "alias-383": {"$ref": "alias-382"}, "alias-384": {"$ref": "alias-383"}, "alias-385": {"$ref": "alias-384"}, "alias-386": {"$ref": "alias-385"}, "alias-387": {"$ref": "alias-386"}, "alias-388": {"$ref": "alias-387"}, "alias-389": {"$ref": "alias-388"}, "alias-390": {"$ref": "alias-389"}, "alias-391": {"$ref": "alias-390"}, "alias-392": {"$ref": "alias-391"}, "alias-393": {"$ref": "alias-392"}, "alias-394": {"$ref": "alias-393"}, "alias-395": {"$ref": "alias-394"}, "alias-396": {"$ref": "alias-395"}, "alias-397": {"$ref": "alias-396"}, "alias-398": {"$ref": "alias-397"}, "alias-399": {"$ref": "alias-398"}, "alias-400": {"$ref": "alias-399"}, "alias-401": {"$ref": "alias-400"}, "alias-402": {"$ref": "alias-401"}, "alias-403": {"$ref": "alias-402"}, "alias-404": {"$ref": "alias-403"}, "alias-405": {"$ref": "alias-404"}, "alias-406": {"$ref": "alias-405"}, "alias-407": {"$ref": "alias-406"}, "alias-408": {"$ref": "alias-407"}, "alias-409": {"$ref": "alias-408"}, "alias-410": {"$ref": "alias-409"}, "alias-411": {"$ref": "alias-410"}, "alias-412": {"$ref": "alias-411"}, "alias-413": {"$ref": "alias-412"}, "alias-414": {"$ref": "alias-413"}, "alias-415": {"$ref": "alias-414"}, "alias-416": {"$ref": "alias-415"}, "alias-417": {"$ref": "alias-416"}, "alias-418": {"$ref": "alias-417"}, "alias-419": {"$ref": "alias-418"}, "alias-420": {"$ref": "alias-419"}, "alias-421": {"$ref": "alias-420"}, "alias-422": {"$ref": "alias-421"}, "alias-423": {"$ref": "alias-422"}, "alias-424": {"$ref": "alias-423"}, "alias-425": {"$ref": "alias-424"}, "alias-426": {"$ref": "alias-425"}, "alias-427": {"$ref": "alias-426"}, "alias-428": {"$ref": "alias-427"}, "alias-429": {"$ref": "alias-428"}, "alias-430": {"$ref": "alias-429"}, "alias-431": {"$ref": "alias-430"}, "alias-432": {"$ref": "alias-431"}, "alias-433": {"$ref": "alias-432"}, "alias-434": {"$ref": "alias-433"}, "alias-435": {"$ref": "alias-434"}, "alias-436": {"$ref": "alias-435"}, "alias-437": {"$ref": "alias-436"}, "alias-438": {"$ref": "alias-437"}, "alias-439": {"$ref": "alias-438"}, "alias-440": {"$ref": "alias-439"}, "alias-441": {"$ref": "alias-440"}, "alias-442": {"$ref": "alias-441"}, "alias-443": {"$ref": "alias-442"}, "alias-444": {"$ref": "alias-443"}, "alias-445": {"$ref": "alias-444"}, "alias-446": {"$ref": "alias-445"}, "alias-447": {"$ref": "alias-446"}, "alias-448": {"$ref": "alias-447"}, "alias-449": {"$ref": "alias-448"}, "alias-450": {"$ref": "alias-449"}, "alias-451": {"$ref": "alias-450"}, "alias-452": {"$ref": "alias-451"}, "alias-453": {"$ref": "alias-452"}, "alias-454": {"$ref": "alias-453"}, "alias-455": {"$ref": "alias-454"}, "alias-456": {"$ref": "alias-455"}, "alias-457": {"$ref": "alias-456"}, "alias-458": {"$ref": "alias-457"}, "alias-459": {"$ref": "alias-458"}, "alias-460": {"$ref": "alias-459"}, "alias-461": {"$ref": "alias-460"}, "alias-462": {"$ref": "alias-461"}, "alias-463": {"$ref": "alias-462"}, "alias-464": {"$ref": "alias-463"}, "alias-465": {"$ref": "alias-464"}, "alias-466": {"$ref": "alias-465"}, "alias-467": {"$ref": "alias-466"}, "alias-468": {"$ref": "alias-467"}, "alias-469": {"$ref": "alias-468"}, "alias-470": {"$ref": "alias-469"}, "alias-471": {"$ref": "alias-470"}, "alias-472": {"$ref": "alias-471"}, "alias-473": {"$ref": "alias-472"}, "alias-474": {"$ref": "alias-473"}, "alias-475": {"$ref": "alias-474"}, "alias-476": {"$ref": "alias-475"}, "alias-477": {"$ref": "alias-476"}, "alias-478": {"$ref": "alias-477"}, "alias-479": {"$ref": "alias-478"}, "alias-480": {"$ref": "alias-479"}, "alias-481": {"$ref": "alias-480"}, "alias-482": {"$ref": "alias-481"}, "alias-483": {"$ref": "alias-482"}, "alias-484": {"$ref": "alias-483"}, "alias-485": {"$ref": "alias-484"}, "alias-486": {"$ref": "alias-485"}, "alias-487": {"$ref": "alias-486"}, "alias-488": {"$ref": "alias-487"}, "alias-489": {"$ref": "alias-488"}, "alias-490": {"$ref": "alias-489"}, "alias-491": {"$ref": "alias-490"}, "alias-492": {"$ref": "alias-491"}, "alias-493": {"$ref": "alias-492"}, "alias-494": {"$ref": "alias-493"}, "alias-495": {"$ref": "alias-494"}, "alias-496": {"$ref": "alias-495"}, "alias-497": {"$ref": "alias-496"}, "alias-498": {"$ref": "alias-497"}, "alias-499": {"$ref": "alias-498"}, "alias-500": {"$ref": "alias-499"}, "alias-501": {"$ref": "alias-500"}, "alias-502": {"$ref": "alias-501"}, "alias-503": {"$ref": "alias-502"}, "alias-504": {"$ref": "alias-503"}, "alias-505": {"$ref": "alias-504"}, "alias-506": {"$ref": "alias-505"}, "alias-507": {"$ref": "alias-506"}, "alias-508": {"$ref": "alias-507"}, "alias-509": {"$ref": "alias-508"}, "alias-510": {"$ref": "alias-509"}, "alias-511": {"$ref": "alias-510"}, "alias-512": {"$ref": "alias-511"}, "alias-513": {"$ref": "alias-512"}, "alias-514": {"$ref": "alias-513"}, "alias-515": {"$ref": "alias-514"}, "alias-516": {"$ref": "alias-515"}, "alias-517": {"$ref": "alias-516"}, "alias-518": {"$ref": "alias-517"}, "alias-519": {"$ref": "alias-518"}, "alias-520": {"$ref": "alias-519"}, "alias-521": {"$ref": "alias-520"}, "alias-522": {"$ref": "alias-521"}, "alias-523": {"$ref": "alias-522"}, "alias-524": {"$ref": "alias-523"}, "alias-525": {"$ref": "alias-524"}, "alias-526": {"$ref": "alias-525"}, "alias-527": {"$ref": "alias-526"}, "alias-528": {"$ref": "alias-527"}, "alias-529": {"$ref": "alias-528"}, "alias-530": {"$ref": "alias-529"}, "alias-531": {"$ref": "alias-530"}, "alias-532": {"$ref": "alias-531"}, "alias-533": {"$ref": "alias-532"}, "alias-534": {"$ref": "alias-533"}, "alias-535": {"$ref": "alias-534"}, "alias-536": {"$ref": "alias-535"}, "alias-537": {"$ref": "alias-536"}, "alias-538": {"$ref": "alias-537"}, "alias-539": {"$ref": "alias-538"}, "alias-540": {"$ref": "alias-539"}, "alias-541": {"$ref": "alias-540"}, "alias-542": {"$ref": "alias-541"}, "alias-543": {"$ref": "alias-542"}, "alias-544": {"$ref": "alias-543"}, "alias-545": {"$ref": "alias-544"}, "alias-546": {"$ref": "alias-545"}, "alias-547": {"$ref": "alias-546"}, "alias-548": {"$ref": "alias-547"}, "alias-549": {"$ref": "alias-548"}, "alias-550": {"$ref": "alias-549"}, "alias-551": {"$ref": "alias-550"}, "alias-552": {"$ref": "alias-551"}, "alias-553": {"$ref": "alias-552"}, "alias-554": {"$ref": "alias-553"}, "alias-555": {"$ref": "alias-554"}, "alias-556": {"$ref": "alias-555"}, "alias-557": {"$ref": "alias-556"}, "alias-558": {"$ref": "alias-557"}, "alias-559": {"$ref": "alias-558"}, "alias-560": {"$ref": "alias-559"}, "alias-561": {"$ref": "alias-560"}, "alias-562": {"$ref": "alias-561"}, "alias-563": {"$ref": "alias-562"}, "alias-564": {"$ref": "alias-563"}, "alias-565": {"$ref": "alias-564"}, "alias-566": {"$ref": "alias-565"}, "alias-567": {"$ref": "alias-566"}, "alias-568": {"$ref": "alias-567"}, "alias-569": {"$ref": "alias-568"}, "alias-570": {"$ref": "alias-569"}, "alias-571": {"$ref": "alias-570"}, "alias-572": {"$ref": "alias-571"}, "alias-573": {"$ref": "alias-572"}, "alias-574": {"$ref": "alias-573"}, "alias-575": {"$ref": "alias-574"}, "alias-576": {"$ref": "alias-575"}, "alias-577": {"$ref": "alias-576"}, "alias-578": {"$ref": "alias-577"}, "alias-579": {"$ref": "alias-578"}, "alias-580": {"$ref": "alias-579"}, "alias-581": {"$ref": "alias-580"}, "alias-582": {"$ref": "alias-581"}, "alias-583": {"$ref": "alias-582"}, "alias-584": {"$ref": "alias-583"}, "alias-585": {"$ref": "alias-584"}, "alias-586": {"$ref": "alias-585"}, "alias-587": {"$ref": "alias-586"}, "alias-588": {"$ref": "alias-587"}, "alias-589": {"$ref": "alias-588"}, "alias-590": {"$ref": "alias-589"}, "alias-591": {"$ref": "alias-590"}, "alias-592": {"$ref": "alias-591"}, "alias-593": {"$ref": "alias-592"}, "alias-594": {"$ref": "alias-593"}, "alias-595": {"$ref": "alias-594"}, "alias-596": {"$ref": "alias-595"}, "alias-597": {"$ref": "alias-596"}, "alias-598": {"$ref": "alias-597"}, "alias-599": {"$ref": "alias-598"}, "alias-600": {"$ref": "alias-599"}, "alias-601": {"$ref": "alias-600"}, "alias-602": {"$ref": "alias-601"}, "alias-603": {"$ref": "alias-602"}, "alias-604": {"$ref": "alias-603"}, "alias-605": {"$ref": "alias-604"}, "alias-606": {"$ref": "alias-605"}, "alias-607": {"$ref": "alias-606"}, "alias-608": {"$ref": "alias-607"}, "alias-609": {"$ref": "alias-608"}, "alias-610": {"$ref": "alias-609"}, "alias-611": {"$ref": "alias-610"}, "alias-612": {"$ref": "alias-611"}, "alias-613": {"$ref": "alias-612"}, "alias-614": {"$ref": "alias-613"}, "alias-615": {"$ref": "alias-614"}, "alias-616": {"$ref": "alias-615"}, "alias-617": {"$ref": "alias-616"}, "alias-618": {"$ref": "alias-617"}, "alias-619": {"$ref": "alias-618"}, "alias-620": {"$ref": "alias-619"}, "alias-621": {"$ref": "alias-620"}, "alias-622": {"$ref": "alias-621"}, "alias-623": {"$ref": "alias-622"}, "alias-624": {"$ref": "alias-623"}, "alias-625": {"$ref": "alias-624"}, "alias-626": {"$ref": "alias-625"}, "alias-627": {"$ref": "alias-626"}, "alias-628": {"$ref": "alias-627"}, "alias-629": {"$ref": "alias-628"}, "alias-630": {"$ref": "alias-629"}, "alias-631": {"$ref": "alias-630"}, "alias-632": {"$ref": "alias-631"}, "alias-633": {"$ref": "alias-632"}, "alias-634": {"$ref": "alias-633"}, "alias-635": {"$ref": "alias-634"}, "alias-636": {"$ref": "alias-635"}, "alias-637": {"$ref": "alias-636"}, "alias-638": {"$ref": "alias-637"}, "alias-639": {"$ref": "alias-638"}, "alias-640": {"$ref": "alias-639"}, "alias-641": {"$ref": "alias-640"}, "alias-642": {"$ref": "alias-641"}, "alias-643": {"$ref": "alias-642"}, "alias-644": {"$ref": "alias-643"}, "alias-645": {"$ref": "alias-644"}, "alias-646": {"$ref": "alias-645"}, "alias-647": {"$ref": "alias-646"}, "alias-648": {"$ref": "alias-647"}, "alias-649": {"$ref": "alias-648"}, "alias-650": {"$ref": "alias-649"}, "alias-651": {"$ref": "alias-650"}, "alias-652": {"$ref": "alias-651"}, "alias-653": {"$ref": "alias-652"}, "alias-654": {"$ref": "alias-653"}, "alias-655": {"$ref": "alias-654"}, "alias-656": {"$ref": "alias-655"}, "alias-657": {"$ref": "alias-656"}, "alias-658": {"$ref": "alias-657"}, "alias-659": {"$ref": "alias-658"}, "alias-660": {"$ref": "alias-659"}, "alias-661": {"$ref": "alias-660"}, "alias-662": {"$ref": "alias-661"}, "alias-663": {"$ref": "alias-662"}, "alias-664": {"$ref": "alias-663"}, "alias-665": {"$ref": "alias-664"}, "alias-666": {"$ref": "alias-665"}, "alias-667": {"$ref": "alias-666"}, "alias-668": {"$ref": "alias-667"}, "alias-669": {"$ref": "alias-668"}, "alias-670": {"$ref": "alias-669"}, "alias-671": {"$ref": "alias-670"}, "alias-672": {"$ref": "alias-671"}, "alias-673": {"$ref": "alias-672"}, "alias-674": {"$ref": "alias-673"}, "alias-675": {"$ref": "alias-674"}, "alias-676": {"$ref": "alias-675"}, "alias-677": {"$ref": "alias-676"}, "alias-678": {"$ref": "alias-677"}, "alias-679": {"$ref": "alias-678"}, "alias-680": {"$ref": "alias-679"}, "alias-681": {"$ref": "alias-680"}, "alias-682": {"$ref": "alias-681"}, "alias-683": {"$ref": "alias-682"}, "alias-684": {"$ref": "alias-683"}, "alias-685": {"$ref": "alias-684"}, "alias-686": {"$ref": "alias-685"}, "alias-687": {"$ref": "alias-686"}, "alias-688": {"$ref": "alias-687"}, "alias-689": {"$ref": "alias-688"}, "alias-690": {"$ref": "alias-689"}, "alias-691": {"$ref": "alias-690"}, "alias-692": {"$ref": "alias-691"}, "alias-693": {"$ref": "alias-692"}, "alias-694": {"$ref": "alias-693"}, "alias-695": {"$ref": "alias-694"}, "alias-696": {"$ref": "alias-695"}, "alias-697": {"$ref": "alias-696"}, "alias-698": {"$ref": "alias-697"}, "alias-699": {"$ref": "alias-698"}, "alias-700": {"$ref": "alias-699"}, "alias-701": {"$ref": "alias-700"}, "alias-702": {"$ref": "alias-701"}, "alias-703": {"$ref": "alias-702"}, "alias-704": {"$ref": "alias-703"}, "alias-705": {"$ref": "alias-704"}, "alias-706": {"$ref": "alias-705"}, "alias-707": {"$ref": "alias-706"}, "alias-708": {"$ref": "alias-707"}, "alias-709": {"$ref": "alias-708"}, "alias-710": {"$ref": "alias-709"}, "alias-711": {"$ref": "alias-710"}, "alias-712": {"$ref": "alias-711"}, "alias-713": {"$ref": "alias-712"}, "alias-714": {"$ref": "alias-713"}, "alias-715": {"$ref": "alias-714"}, "alias-716": {"$ref": "alias-715"}, "alias-717": {"$ref": "alias-716"}, "alias-718": {"$ref": "alias-717"}, "alias-719": {"$ref": "alias-718"}, "alias-720": {"$ref": "alias-719"}, "alias-721": {"$ref": "alias-720"}, "alias-722": {"$ref": "alias-721"}, "alias-723": {"$ref": "alias-722"}, "alias-724": {"$ref": "alias-723"}, "alias-725": {"$ref": "alias-724"}, "alias-726": {"$ref": "alias-725"}, "alias-727": {"$ref": "alias-726"}, "alias-728": {"$ref": "alias-727"}, "alias-729": {"$ref": "alias-728"}, "alias-730": {"$ref": "alias-729"}, "alias-731": {"$ref": "alias-730"}, "alias-732": {"$ref": "alias-731"}, "alias-733": {"$ref": "alias-732"}, "alias-734": {"$ref": "alias-733"}, "alias-735": {"$ref": "alias-734"}, "alias-736": {"$ref": "alias-735"}, "alias-737": {"$ref": "alias-736"}, "alias-738": {"$ref": "alias-737"}, "alias-739": {"$ref": "alias-738"}, "alias-740": {"$ref": "alias-739"}, "alias-741": {"$ref": "alias-740"}, "alias-742": {"$ref": "alias-741"}, "alias-743": {"$ref": "alias-742"}, "alias-744": {"$ref": "alias-743"}, "alias-745": {"$ref": "alias-744"}, "alias-746": {"$ref": "alias-745"}, "alias-747": {"$ref": "alias-746"}, "alias-748": {"$ref": "alias-747"}, "alias-749": {"$ref": "alias-748"}, "alias-750": {"$ref": "alias-749"}, "alias-751": {"$ref": "alias-750"}, "alias-752": {"$ref": "alias-751"}, "alias-753": {"$ref": "alias-752"}, "alias-754": {"$ref": "alias-753"}, "alias-755": {"$ref": "alias-754"}, "alias-756": {"$ref": "alias-755"}, "alias-757": {"$ref": "alias-756"}, "alias-758": {"$ref": "alias-757"}, "alias-759": {"$ref": "alias-758"}, "alias-760": {"$ref": "alias-759"}, "alias-761": {"$ref": "alias-760"}, "alias-762": {"$ref": "alias-761"}, "alias-763": {"$ref": "alias-762"}, "alias-764": {"$ref": "alias-763"}, "alias-765": {"$ref": "alias-764"}, "alias-766": {"$ref": "alias-765"}, "alias-767": {"$ref": "alias-766"}, "alias-768": {"$ref": "alias-767"}, "alias-769": {"$ref": "alias-768"}, "alias-770": {"$ref": "alias-769"}, "alias-771": {"$ref": "alias-770"}, "alias-772": {"$ref": "alias-771"}, "alias-773": {"$ref": "alias-772"}, "alias-774": {"$ref": "alias-773"}, "alias-775": {"$ref": "alias-774"}, "alias-776": {"$ref": "alias-775"}, "alias-777": {"$ref": "alias-776"}, "alias-778": {"$ref": "alias-777"}, "alias-779": {"$ref": "alias-778"}, "alias-780": {"$ref": "alias-779"}, "alias-781": {"$ref": "alias-780"}, "alias-782": {"$ref": "alias-781"}, "alias-783": {"$ref": "alias-782"}, "alias-784": {"$ref": "alias-783"}, "alias-785": {"$ref": "alias-784"}, "alias-786": {"$ref": "alias-785"}, "alias-787": {"$ref": "alias-786"}, "alias-788": {"$ref": "alias-787"}, "alias-789": {"$ref": "alias-788"}, "alias-790": {"$ref": "alias-789"}, "alias-791": {"$ref": "alias-790"}, "alias-792": {"$ref": "alias-791"}, "alias-793": {"$ref": "alias-792"}, "alias-794": {"$ref": "alias-793"}, "alias-795": {"$ref": "alias-794"}, "alias-796": {"$ref": "alias-795"}, "alias-797": {"$ref": "alias-796"}, "alias-798": {"$ref": "alias-797"}, "alias-799": {"$ref": "alias-798"}, "alias-800": {"$ref": "alias-799"}}}, "flag": "ref-cost-over", "type": "boolean", "default": true, "targetingKey": "u-38", "context": {"tier": "gold"}, "expect": {"value": true, "variant": null, "reason": "ERROR", "errorCode": "PARSE_ERROR"}}, + {"name": "a float request on a boolean flag is a type mismatch", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "kill-switch", "type": "float", "default": 0.5, "expect": {"value": 0.5, "variant": null, "reason": "ERROR", "errorCode": "TYPE_MISMATCH"}}, + {"name": "an integer request on a boolean flag is a type mismatch", "kind": "evaluate", "document": {"flags": {"kill-switch": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "new-checkout": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": ["beta", {"var": "roles"}]}, "on", null]}, "metadata": {"owner": "payments", "kind": "release", "expires": "2026-12-31"}}, "beta-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "beta-users"}, "on", "off"]}}, "pro-reports": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"in": [{"var": "plan"}, ["pro", "enterprise"]]}, "on", "off"]}, "metadata": {"kind": "permission"}}, "tenant-pilot": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "on", null]}}, "checkout-flow": {"state": "ENABLED", "variants": {"control": "v1", "treatment": "v2", "holdout": "v1"}, "defaultVariant": "control", "targeting": {"fractional": [["control", 45], ["treatment", 45], ["holdout", 10]]}, "metadata": {"kind": "experiment"}}, "rollout-10": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"fractional": [["on", 10], ["off", 90]]}}, "min-app-version": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"sem_ver": [{"var": "appVersion"}, ">=", "2.4.0"]}, "on", "off"]}}, "email-domain": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"ends_with": [{"var": "email"}, "@firefly.dev"]}, "on", "off"]}}, "paused": {"state": "DISABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}, "page-size": {"state": "ENABLED", "variants": {"small": 10, "large": 50}, "defaultVariant": "small", "targeting": {"if": [{"in": ["admin", {"var": "roles"}]}, "large", null]}}, "sample-rate": {"state": "ENABLED", "variants": {"low": 0.05, "high": 0.5}, "defaultVariant": "low"}, "banner": {"state": "ENABLED", "variants": {"plain": {"title": "Hello", "dismissible": true}, "promo": {"title": "50% off", "dismissible": false, "ttl": 3}}, "defaultVariant": "plain", "targeting": {"if": [{"==": [{"var": "profiles.0"}, "staging"]}, "promo", null]}}, "region-gate": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "eu"}, "on", "off"]}}, "no-default": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}, "targeting": {"if": [{"==": [{"var": "tenant"}, "acme"]}, "b", null]}}, "no-default-static": {"state": "ENABLED", "variants": {"a": "A", "b": "B"}}, "bad-variant": {"state": "ENABLED", "variants": {"a": "A"}, "defaultVariant": "a", "targeting": {"if": [true, "zzz", null]}}, "windows-path": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "temp-dir"}, "on", "off"]}}, "empty-default": {"state": "ENABLED", "variants": {"": "empty", "x": "X"}, "defaultVariant": ""}, "nested-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "z-staff-in-eu"}, "on", "off"]}}, "nested-segment-reverse": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "a-staff-in-eu"}, "on", "off"]}}, "cyclic-segment": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", "targeting": {"if": [{"$ref": "cycle-a"}, "on", "off"]}}}, "$evaluators": {"beta-users": {"in": ["beta", {"var": "roles"}]}, "eu": {"in": [{"var": "region"}, ["es", "fr", "de", "it", "pt"]]}, "temp-dir": {"==": [{"var": "path"}, "C:\\temp\\new"]}, "z-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "eu"}]}, "a-staff-in-eu": {"and": [{"in": ["staff", {"var": "roles"}]}, {"$ref": "zz-eu"}]}, "zz-eu": {"$ref": "eu"}, "cycle-a": {"or": [{"$ref": "cycle-b"}, false]}, "cycle-b": {"or": [{"$ref": "cycle-a"}, false]}}}, "flag": "kill-switch", "type": "integer", "default": 7, "expect": {"value": 7, "variant": null, "reason": "ERROR", "errorCode": "TYPE_MISMATCH"}}, + {"name": "cross-language bucketing: rollout", "kind": "bucket", "flagKey": "rollout", "weights": [["on", 10], ["off", 90]], "keys": ["user-0001", "user-0002", "user-0003", "user-0004", "user-0005", "user-0006", "user-0007", "user-0008", "user-0009", "user-0010", "user-0011", "user-0012", "user-0013", "user-0014", "user-0015", "user-0016", "user-0017", "user-0018", "user-0019", "user-0020", "user-0021", "user-0022", "user-0023", "user-0024", "user-0025", "user-0026", "user-0027", "user-0028", "user-0029", "user-0030", "user-0031", "user-0032", "user-0033", "user-0034", "user-0035", "user-0036", "user-0037", "user-0038", "user-0039", "user-0040", "user-0041", "user-0042", "user-0043", "user-0044", "user-0045", "user-0046", "user-0047", "user-0048", "user-0049", "user-0050", "user-0051", "user-0052", "user-0053", "user-0054", "user-0055", "user-0056", "user-0057", "user-0058", "user-0059", "user-0060", "user-0061", "user-0062", "user-0063", "user-0064", "user-0065", "user-0066", "user-0067", "user-0068", "user-0069", "user-0070", "user-0071", "user-0072", "user-0073", "user-0074", "user-0075", "user-0076", "user-0077", "user-0078", "user-0079", "user-0080", "user-0081", "user-0082", "user-0083", "user-0084", "user-0085", "user-0086", "user-0087", "user-0088", "user-0089", "user-0090", "user-0091", "user-0092", "user-0093", "user-0094", "user-0095", "user-0096", "user-0097", "user-0098", "user-0099", "user-0100", "user-0101", "user-0102", "user-0103", "user-0104", "user-0105", "user-0106", "user-0107", "user-0108", "user-0109", "user-0110", "user-0111", "user-0112", "user-0113", "user-0114", "user-0115", "user-0116", "user-0117", "user-0118", "user-0119", "user-0120", "alice@example.com", "bob@example.com", "carol@example.com", "dave@example.com", "erin@example.com", "frank@example.com", "grace@example.com", "heidi@example.com", "ivan@example.com", "judy@example.com", "mallory@example.com", "niaj@example.com", "olivia@example.com", "peggy@example.com", "rupert@example.com", "sybil@example.com", "trent@example.com", "victor@example.com", "walter@example.com", "zoe@example.com", "9e3779b1-9e37-4061-a185-0100000001b3", "3c6ef362-3c6e-40c2-a30a-020000000366", "daa66d13-daa5-4123-a48f-030000000519", "78dde6c4-78dc-4184-a614-0400000006cc", "17156075-1713-41e5-a799-05000000087f", "b54cda26-b54a-4246-a91e-060000000a32", "538453d7-5381-42a7-aaa3-070000000be5", "f1bbcd88-f1b8-4308-ac28-080000000d98", "8ff34739-8fef-4369-adad-090000000f4b", "2e2ac0ea-2e26-43ca-af32-0a00000010fe", "cc623a9b-cc5d-442b-a0b7-0b00000012b1", "6a99b44c-6a94-448c-a23c-0c0000001464", "08d12dfd-08cb-44ed-a3c1-0d0000001617", "a708a7ae-a702-454e-a546-0e00000017ca", "4540215f-4539-45af-a6cb-0f000000197d", "e3779b10-e370-4610-a850-100000001b30", "81af14c1-81a7-4671-a9d5-110000001ce3", "1fe68e72-1fde-46d2-ab5a-120000001e96", "be1e0823-be15-4733-acdf-130000002049", "5c5581d4-5c4c-4794-ae64-1400000021fc", "fa8cfb85-fa83-47f5-afe9-1500000023af", "98c47536-98ba-4856-a16e-160000002562", "36fbeee7-36f1-48b7-a2f3-170000002715", "d5336898-d528-4918-a478-1800000028c8", "736ae249-735f-4979-a5fd-190000002a7b", "11a25bfa-1196-49da-a782-1a0000002c2e", "afd9d5ab-afcd-4a3b-a907-1b0000002de1", "4e114f5c-4e04-4a9c-aa8c-1c0000002f94", "ec48c90d-ec3b-4afd-ac11-1d0000003147", "8a8042be-8a72-4b5e-ad96-1e00000032fa", "28b7bc6f-28a9-4bbf-af1b-1f00000034ad", "c6ef3620-c6e0-4c20-a0a0-200000003660", "6526afd1-6517-4c81-a225-210000003813", "035e2982-034e-4ce2-a3aa-2200000039c6", "a195a333-a185-4d43-a52f-230000003b79", "3fcd1ce4-3fbc-4da4-a6b4-240000003d2c", "de049695-ddf3-4e05-a839-250000003edf", "7c3c1046-7c2a-4e66-a9be-260000004092", "1a7389f7-1a61-4ec7-ab43-270000004245", "b8ab03a8-b898-4f28-acc8-2800000043f8", "56e27d59-56cf-4f89-ae4d-2900000045ab", "f519f70a-f506-4fea-afd2-2a000000475e", "935170bb-933d-404b-a157-2b0000004911", "3188ea6c-3174-40ac-a2dc-2c0000004ac4", "cfc0641d-cfab-410d-a461-2d0000004c77", "6df7ddce-6de2-416e-a5e6-2e0000004e2a", "0c2f577f-0c19-41cf-a76b-2f0000004fdd", "aa66d130-aa50-4230-a8f0-300000005190", "489e4ae1-4887-4291-aa75-310000005343", "e6d5c492-e6be-42f2-abfa-3200000054f6", "850d3e43-84f5-4353-ad7f-3300000056a9", "2344b7f4-232c-43b4-af04-34000000585c", "c17c31a5-c163-4415-a089-350000005a0f", "5fb3ab56-5f9a-4476-a20e-360000005bc2", "fdeb2507-fdd1-44d7-a393-370000005d75", "9c229eb8-9c08-4538-a518-380000005f28", "3a5a1869-3a3f-4599-a69d-3900000060db", "d891921a-d876-45fa-a822-3a000000628e", "76c90bcb-76ad-465b-a9a7-3b0000006441", "1500857c-14e4-46bc-ab2c-3c00000065f4", "José", "Zoë", "Łukasz", "Ægir", "Søren", "François", "Müller", "Ñandú", "İstanbul", "東京", "서울", "Москва", "Αθήνα", "☃", "🚀rocket", "a", "ab", "", " ", "tenant/acme/user/1", "0", "1", "2", "3", "10", "99", "100", "1000", "65535", "2147483647", "4294967295", "-1", "tenant-acme:user-1", "tenant-acme:user-2", "tenant-acme:user-3", "tenant-acme:user-4", "tenant-acme:user-5", "tenant-acme:user-6", "tenant-acme:user-7", "tenant-acme:user-8", "tenant-acme:user-9", "tenant-acme:user-10", "tenant-acme:user-11", "tenant-acme:user-12", "tenant-acme:user-13", "tenant-acme:user-14", "tenant-acme:user-15", "tenant-acme:user-16", "tenant-acme:user-17", "tenant-acme:user-18", "tenant-acme:user-19", "tenant-acme:user-20", "tenant-acme:user-21", "tenant-acme:user-22", "tenant-acme:user-23", "tenant-globex:user-1", "tenant-globex:user-2", "tenant-globex:user-3", "tenant-globex:user-4", "tenant-globex:user-5", "tenant-globex:user-6", "tenant-globex:user-7", "tenant-globex:user-8", "tenant-globex:user-9", "tenant-globex:user-10", "tenant-globex:user-11", "tenant-globex:user-12", "tenant-globex:user-13", "tenant-globex:user-14", "tenant-globex:user-15", "tenant-globex:user-16", "tenant-globex:user-17", "tenant-globex:user-18", "tenant-globex:user-19", "tenant-globex:user-20", "tenant-globex:user-21", "tenant-globex:user-22", "tenant-globex:user-23", "tenant-initech:user-1", "tenant-initech:user-2", "tenant-initech:user-3", "tenant-initech:user-4", "tenant-initech:user-5", "tenant-initech:user-6", "tenant-initech:user-7", "tenant-initech:user-8", "tenant-initech:user-9", "tenant-initech:user-10", "tenant-initech:user-11", "tenant-initech:user-12", "tenant-initech:user-13", "tenant-initech:user-14", "tenant-initech:user-15", "tenant-initech:user-16", "tenant-initech:user-17", "tenant-initech:user-18", "tenant-initech:user-19", "tenant-initech:user-20", "tenant-initech:user-21", "tenant-initech:user-22", "tenant-initech:user-23"], "expect": ["off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "on", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "on", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "on", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off", "on", "off", "off", "off", "off", "off"]}, + {"name": "cross-language bucketing: ab-test", "kind": "bucket", "flagKey": "ab-test", "weights": [["a", 50], ["b", 50]], "keys": ["user-0001", "user-0002", "user-0003", "user-0004", "user-0005", "user-0006", "user-0007", "user-0008", "user-0009", "user-0010", "user-0011", "user-0012", "user-0013", "user-0014", "user-0015", "user-0016", "user-0017", "user-0018", "user-0019", "user-0020", "user-0021", "user-0022", "user-0023", "user-0024", "user-0025", "user-0026", "user-0027", "user-0028", "user-0029", "user-0030", "user-0031", "user-0032", "user-0033", "user-0034", "user-0035", "user-0036", "user-0037", "user-0038", "user-0039", "user-0040", "user-0041", "user-0042", "user-0043", "user-0044", "user-0045", "user-0046", "user-0047", "user-0048", "user-0049", "user-0050", "user-0051", "user-0052", "user-0053", "user-0054", "user-0055", "user-0056", "user-0057", "user-0058", "user-0059", "user-0060", "user-0061", "user-0062", "user-0063", "user-0064", "user-0065", "user-0066", "user-0067", "user-0068", "user-0069", "user-0070", "user-0071", "user-0072", "user-0073", "user-0074", "user-0075", "user-0076", "user-0077", "user-0078", "user-0079", "user-0080", "user-0081", "user-0082", "user-0083", "user-0084", "user-0085", "user-0086", "user-0087", "user-0088", "user-0089", "user-0090", "user-0091", "user-0092", "user-0093", "user-0094", "user-0095", "user-0096", "user-0097", "user-0098", "user-0099", "user-0100", "user-0101", "user-0102", "user-0103", "user-0104", "user-0105", "user-0106", "user-0107", "user-0108", "user-0109", "user-0110", "user-0111", "user-0112", "user-0113", "user-0114", "user-0115", "user-0116", "user-0117", "user-0118", "user-0119", "user-0120", "alice@example.com", "bob@example.com", "carol@example.com", "dave@example.com", "erin@example.com", "frank@example.com", "grace@example.com", "heidi@example.com", "ivan@example.com", "judy@example.com", "mallory@example.com", "niaj@example.com", "olivia@example.com", "peggy@example.com", "rupert@example.com", "sybil@example.com", "trent@example.com", "victor@example.com", "walter@example.com", "zoe@example.com", "9e3779b1-9e37-4061-a185-0100000001b3", "3c6ef362-3c6e-40c2-a30a-020000000366", "daa66d13-daa5-4123-a48f-030000000519", "78dde6c4-78dc-4184-a614-0400000006cc", "17156075-1713-41e5-a799-05000000087f", "b54cda26-b54a-4246-a91e-060000000a32", "538453d7-5381-42a7-aaa3-070000000be5", "f1bbcd88-f1b8-4308-ac28-080000000d98", "8ff34739-8fef-4369-adad-090000000f4b", "2e2ac0ea-2e26-43ca-af32-0a00000010fe", "cc623a9b-cc5d-442b-a0b7-0b00000012b1", "6a99b44c-6a94-448c-a23c-0c0000001464", "08d12dfd-08cb-44ed-a3c1-0d0000001617", "a708a7ae-a702-454e-a546-0e00000017ca", "4540215f-4539-45af-a6cb-0f000000197d", "e3779b10-e370-4610-a850-100000001b30", "81af14c1-81a7-4671-a9d5-110000001ce3", "1fe68e72-1fde-46d2-ab5a-120000001e96", "be1e0823-be15-4733-acdf-130000002049", "5c5581d4-5c4c-4794-ae64-1400000021fc", "fa8cfb85-fa83-47f5-afe9-1500000023af", "98c47536-98ba-4856-a16e-160000002562", "36fbeee7-36f1-48b7-a2f3-170000002715", "d5336898-d528-4918-a478-1800000028c8", "736ae249-735f-4979-a5fd-190000002a7b", "11a25bfa-1196-49da-a782-1a0000002c2e", "afd9d5ab-afcd-4a3b-a907-1b0000002de1", "4e114f5c-4e04-4a9c-aa8c-1c0000002f94", "ec48c90d-ec3b-4afd-ac11-1d0000003147", "8a8042be-8a72-4b5e-ad96-1e00000032fa", "28b7bc6f-28a9-4bbf-af1b-1f00000034ad", "c6ef3620-c6e0-4c20-a0a0-200000003660", "6526afd1-6517-4c81-a225-210000003813", "035e2982-034e-4ce2-a3aa-2200000039c6", "a195a333-a185-4d43-a52f-230000003b79", "3fcd1ce4-3fbc-4da4-a6b4-240000003d2c", "de049695-ddf3-4e05-a839-250000003edf", "7c3c1046-7c2a-4e66-a9be-260000004092", "1a7389f7-1a61-4ec7-ab43-270000004245", "b8ab03a8-b898-4f28-acc8-2800000043f8", "56e27d59-56cf-4f89-ae4d-2900000045ab", "f519f70a-f506-4fea-afd2-2a000000475e", "935170bb-933d-404b-a157-2b0000004911", "3188ea6c-3174-40ac-a2dc-2c0000004ac4", "cfc0641d-cfab-410d-a461-2d0000004c77", "6df7ddce-6de2-416e-a5e6-2e0000004e2a", "0c2f577f-0c19-41cf-a76b-2f0000004fdd", "aa66d130-aa50-4230-a8f0-300000005190", "489e4ae1-4887-4291-aa75-310000005343", "e6d5c492-e6be-42f2-abfa-3200000054f6", "850d3e43-84f5-4353-ad7f-3300000056a9", "2344b7f4-232c-43b4-af04-34000000585c", "c17c31a5-c163-4415-a089-350000005a0f", "5fb3ab56-5f9a-4476-a20e-360000005bc2", "fdeb2507-fdd1-44d7-a393-370000005d75", "9c229eb8-9c08-4538-a518-380000005f28", "3a5a1869-3a3f-4599-a69d-3900000060db", "d891921a-d876-45fa-a822-3a000000628e", "76c90bcb-76ad-465b-a9a7-3b0000006441", "1500857c-14e4-46bc-ab2c-3c00000065f4", "José", "Zoë", "Łukasz", "Ægir", "Søren", "François", "Müller", "Ñandú", "İstanbul", "東京", "서울", "Москва", "Αθήνα", "☃", "🚀rocket", "a", "ab", "", " ", "tenant/acme/user/1", "0", "1", "2", "3", "10", "99", "100", "1000", "65535", "2147483647", "4294967295", "-1", "tenant-acme:user-1", "tenant-acme:user-2", "tenant-acme:user-3", "tenant-acme:user-4", "tenant-acme:user-5", "tenant-acme:user-6", "tenant-acme:user-7", "tenant-acme:user-8", "tenant-acme:user-9", "tenant-acme:user-10", "tenant-acme:user-11", "tenant-acme:user-12", "tenant-acme:user-13", "tenant-acme:user-14", "tenant-acme:user-15", "tenant-acme:user-16", "tenant-acme:user-17", "tenant-acme:user-18", "tenant-acme:user-19", "tenant-acme:user-20", "tenant-acme:user-21", "tenant-acme:user-22", "tenant-acme:user-23", "tenant-globex:user-1", "tenant-globex:user-2", "tenant-globex:user-3", "tenant-globex:user-4", "tenant-globex:user-5", "tenant-globex:user-6", "tenant-globex:user-7", "tenant-globex:user-8", "tenant-globex:user-9", "tenant-globex:user-10", "tenant-globex:user-11", "tenant-globex:user-12", "tenant-globex:user-13", "tenant-globex:user-14", "tenant-globex:user-15", "tenant-globex:user-16", "tenant-globex:user-17", "tenant-globex:user-18", "tenant-globex:user-19", "tenant-globex:user-20", "tenant-globex:user-21", "tenant-globex:user-22", "tenant-globex:user-23", "tenant-initech:user-1", "tenant-initech:user-2", "tenant-initech:user-3", "tenant-initech:user-4", "tenant-initech:user-5", "tenant-initech:user-6", "tenant-initech:user-7", "tenant-initech:user-8", "tenant-initech:user-9", "tenant-initech:user-10", "tenant-initech:user-11", "tenant-initech:user-12", "tenant-initech:user-13", "tenant-initech:user-14", "tenant-initech:user-15", "tenant-initech:user-16", "tenant-initech:user-17", "tenant-initech:user-18", "tenant-initech:user-19", "tenant-initech:user-20", "tenant-initech:user-21", "tenant-initech:user-22", "tenant-initech:user-23"], "expect": ["b", "a", "b", "a", "a", "a", "b", "b", "a", "a", "b", "b", "a", "a", "a", "b", "a", "a", "a", "a", "b", "b", "b", "b", "a", "a", "b", "b", "a", "b", "a", "b", "a", "a", "b", "b", "a", "a", "b", "a", "a", "b", "a", "b", "b", "a", "b", "a", "b", "a", "b", "a", "a", "a", "b", "b", "a", "b", "a", "a", "b", "b", "a", "a", "a", "a", "b", "a", "a", "a", "b", "b", "b", "b", "b", "a", "a", "b", "b", "a", "a", "a", "a", "b", "a", "a", "a", "a", "b", "b", "b", "a", "a", "b", "b", "b", "b", "b", "a", "a", "b", "a", "b", "b", "a", "b", "b", "b", "a", "b", "a", "b", "a", "a", "a", "a", "a", "a", "b", "b", "a", "a", "b", "a", "b", "a", "a", "b", "a", "a", "b", "a", "b", "b", "b", "a", "b", "b", "a", "b", "b", "a", "a", "b", "b", "b", "b", "a", "a", "b", "a", "b", "b", "a", "b", "a", "a", "b", "b", "b", "a", "a", "b", "a", "b", "a", "a", "a", "a", "a", "b", "a", "b", "a", "a", "a", "a", "b", "b", "a", "b", "a", "b", "b", "a", "b", "b", "a", "a", "a", "a", "b", "b", "a", "a", "a", "a", "a", "b", "b", "a", "b", "a", "b", "b", "a", "b", "a", "a", "a", "b", "a", "b", "a", "a", "b", "a", "a", "a", "a", "a", "a", "b", "b", "b", "b", "b", "a", "b", "a", "a", "a", "a", "b", "a", "b", "a", "b", "a", "b", "a", "b", "a", "a", "a", "b", "a", "a", "b", "b", "a", "a", "a", "b", "b", "b", "b", "b", "b", "a", "b", "b", "b", "a", "b", "b", "b", "a", "a", "b", "b", "b", "b", "b", "a", "b", "a", "a", "a", "a", "b", "a", "a", "b", "b", "a", "a", "a", "a", "a", "b", "b", "a", "a", "b", "a", "a", "a", "a", "a", "b"]}, + {"name": "cross-language bucketing: three-way", "kind": "bucket", "flagKey": "three-way", "weights": [["control", 34], ["v1", 33], ["v2", 33]], "keys": ["user-0001", "user-0002", "user-0003", "user-0004", "user-0005", "user-0006", "user-0007", "user-0008", "user-0009", "user-0010", "user-0011", "user-0012", "user-0013", "user-0014", "user-0015", "user-0016", "user-0017", "user-0018", "user-0019", "user-0020", "user-0021", "user-0022", "user-0023", "user-0024", "user-0025", "user-0026", "user-0027", "user-0028", "user-0029", "user-0030", "user-0031", "user-0032", "user-0033", "user-0034", "user-0035", "user-0036", "user-0037", "user-0038", "user-0039", "user-0040", "user-0041", "user-0042", "user-0043", "user-0044", "user-0045", "user-0046", "user-0047", "user-0048", "user-0049", "user-0050", "user-0051", "user-0052", "user-0053", "user-0054", "user-0055", "user-0056", "user-0057", "user-0058", "user-0059", "user-0060", "user-0061", "user-0062", "user-0063", "user-0064", "user-0065", "user-0066", "user-0067", "user-0068", "user-0069", "user-0070", "user-0071", "user-0072", "user-0073", "user-0074", "user-0075", "user-0076", "user-0077", "user-0078", "user-0079", "user-0080", "user-0081", "user-0082", "user-0083", "user-0084", "user-0085", "user-0086", "user-0087", "user-0088", "user-0089", "user-0090", "user-0091", "user-0092", "user-0093", "user-0094", "user-0095", "user-0096", "user-0097", "user-0098", "user-0099", "user-0100", "user-0101", "user-0102", "user-0103", "user-0104", "user-0105", "user-0106", "user-0107", "user-0108", "user-0109", "user-0110", "user-0111", "user-0112", "user-0113", "user-0114", "user-0115", "user-0116", "user-0117", "user-0118", "user-0119", "user-0120", "alice@example.com", "bob@example.com", "carol@example.com", "dave@example.com", "erin@example.com", "frank@example.com", "grace@example.com", "heidi@example.com", "ivan@example.com", "judy@example.com", "mallory@example.com", "niaj@example.com", "olivia@example.com", "peggy@example.com", "rupert@example.com", "sybil@example.com", "trent@example.com", "victor@example.com", "walter@example.com", "zoe@example.com", "9e3779b1-9e37-4061-a185-0100000001b3", "3c6ef362-3c6e-40c2-a30a-020000000366", "daa66d13-daa5-4123-a48f-030000000519", "78dde6c4-78dc-4184-a614-0400000006cc", "17156075-1713-41e5-a799-05000000087f", "b54cda26-b54a-4246-a91e-060000000a32", "538453d7-5381-42a7-aaa3-070000000be5", "f1bbcd88-f1b8-4308-ac28-080000000d98", "8ff34739-8fef-4369-adad-090000000f4b", "2e2ac0ea-2e26-43ca-af32-0a00000010fe", "cc623a9b-cc5d-442b-a0b7-0b00000012b1", "6a99b44c-6a94-448c-a23c-0c0000001464", "08d12dfd-08cb-44ed-a3c1-0d0000001617", "a708a7ae-a702-454e-a546-0e00000017ca", "4540215f-4539-45af-a6cb-0f000000197d", "e3779b10-e370-4610-a850-100000001b30", "81af14c1-81a7-4671-a9d5-110000001ce3", "1fe68e72-1fde-46d2-ab5a-120000001e96", "be1e0823-be15-4733-acdf-130000002049", "5c5581d4-5c4c-4794-ae64-1400000021fc", "fa8cfb85-fa83-47f5-afe9-1500000023af", "98c47536-98ba-4856-a16e-160000002562", "36fbeee7-36f1-48b7-a2f3-170000002715", "d5336898-d528-4918-a478-1800000028c8", "736ae249-735f-4979-a5fd-190000002a7b", "11a25bfa-1196-49da-a782-1a0000002c2e", "afd9d5ab-afcd-4a3b-a907-1b0000002de1", "4e114f5c-4e04-4a9c-aa8c-1c0000002f94", "ec48c90d-ec3b-4afd-ac11-1d0000003147", "8a8042be-8a72-4b5e-ad96-1e00000032fa", "28b7bc6f-28a9-4bbf-af1b-1f00000034ad", "c6ef3620-c6e0-4c20-a0a0-200000003660", "6526afd1-6517-4c81-a225-210000003813", "035e2982-034e-4ce2-a3aa-2200000039c6", "a195a333-a185-4d43-a52f-230000003b79", "3fcd1ce4-3fbc-4da4-a6b4-240000003d2c", "de049695-ddf3-4e05-a839-250000003edf", "7c3c1046-7c2a-4e66-a9be-260000004092", "1a7389f7-1a61-4ec7-ab43-270000004245", "b8ab03a8-b898-4f28-acc8-2800000043f8", "56e27d59-56cf-4f89-ae4d-2900000045ab", "f519f70a-f506-4fea-afd2-2a000000475e", "935170bb-933d-404b-a157-2b0000004911", "3188ea6c-3174-40ac-a2dc-2c0000004ac4", "cfc0641d-cfab-410d-a461-2d0000004c77", "6df7ddce-6de2-416e-a5e6-2e0000004e2a", "0c2f577f-0c19-41cf-a76b-2f0000004fdd", "aa66d130-aa50-4230-a8f0-300000005190", "489e4ae1-4887-4291-aa75-310000005343", "e6d5c492-e6be-42f2-abfa-3200000054f6", "850d3e43-84f5-4353-ad7f-3300000056a9", "2344b7f4-232c-43b4-af04-34000000585c", "c17c31a5-c163-4415-a089-350000005a0f", "5fb3ab56-5f9a-4476-a20e-360000005bc2", "fdeb2507-fdd1-44d7-a393-370000005d75", "9c229eb8-9c08-4538-a518-380000005f28", "3a5a1869-3a3f-4599-a69d-3900000060db", "d891921a-d876-45fa-a822-3a000000628e", "76c90bcb-76ad-465b-a9a7-3b0000006441", "1500857c-14e4-46bc-ab2c-3c00000065f4", "José", "Zoë", "Łukasz", "Ægir", "Søren", "François", "Müller", "Ñandú", "İstanbul", "東京", "서울", "Москва", "Αθήνα", "☃", "🚀rocket", "a", "ab", "", " ", "tenant/acme/user/1", "0", "1", "2", "3", "10", "99", "100", "1000", "65535", "2147483647", "4294967295", "-1", "tenant-acme:user-1", "tenant-acme:user-2", "tenant-acme:user-3", "tenant-acme:user-4", "tenant-acme:user-5", "tenant-acme:user-6", "tenant-acme:user-7", "tenant-acme:user-8", "tenant-acme:user-9", "tenant-acme:user-10", "tenant-acme:user-11", "tenant-acme:user-12", "tenant-acme:user-13", "tenant-acme:user-14", "tenant-acme:user-15", "tenant-acme:user-16", "tenant-acme:user-17", "tenant-acme:user-18", "tenant-acme:user-19", "tenant-acme:user-20", "tenant-acme:user-21", "tenant-acme:user-22", "tenant-acme:user-23", "tenant-globex:user-1", "tenant-globex:user-2", "tenant-globex:user-3", "tenant-globex:user-4", "tenant-globex:user-5", "tenant-globex:user-6", "tenant-globex:user-7", "tenant-globex:user-8", "tenant-globex:user-9", "tenant-globex:user-10", "tenant-globex:user-11", "tenant-globex:user-12", "tenant-globex:user-13", "tenant-globex:user-14", "tenant-globex:user-15", "tenant-globex:user-16", "tenant-globex:user-17", "tenant-globex:user-18", "tenant-globex:user-19", "tenant-globex:user-20", "tenant-globex:user-21", "tenant-globex:user-22", "tenant-globex:user-23", "tenant-initech:user-1", "tenant-initech:user-2", "tenant-initech:user-3", "tenant-initech:user-4", "tenant-initech:user-5", "tenant-initech:user-6", "tenant-initech:user-7", "tenant-initech:user-8", "tenant-initech:user-9", "tenant-initech:user-10", "tenant-initech:user-11", "tenant-initech:user-12", "tenant-initech:user-13", "tenant-initech:user-14", "tenant-initech:user-15", "tenant-initech:user-16", "tenant-initech:user-17", "tenant-initech:user-18", "tenant-initech:user-19", "tenant-initech:user-20", "tenant-initech:user-21", "tenant-initech:user-22", "tenant-initech:user-23"], "expect": ["v2", "v1", "control", "control", "v1", "v1", "v1", "control", "control", "control", "control", "control", "v1", "v2", "control", "control", "control", "control", "v2", "v2", "v1", "v1", "v1", "v2", "control", "v2", "v1", "v1", "v1", "v2", "control", "v1", "control", "v1", "v1", "v1", "v2", "control", "v2", "v2", "v2", "v2", "v2", "control", "v1", "v1", "v2", "control", "v2", "v1", "v1", "v2", "v2", "v1", "v1", "v1", "v1", "control", "v2", "v1", "v2", "v1", "v2", "control", "v2", "v2", "v1", "v2", "v1", "control", "v2", "v2", "v1", "control", "control", "v2", "v2", "v2", "v1", "control", "v1", "v2", "control", "v1", "control", "v2", "v1", "control", "v1", "v2", "v1", "control", "control", "v1", "control", "v2", "v1", "v2", "control", "control", "control", "v2", "v2", "v2", "v2", "v1", "control", "control", "v1", "v2", "control", "control", "v2", "control", "v2", "v2", "v1", "control", "v2", "v1", "control", "v1", "v1", "control", "control", "v2", "control", "v1", "v1", "v1", "control", "v1", "v1", "v2", "v1", "v1", "v2", "v2", "control", "v1", "v1", "v2", "control", "control", "control", "control", "control", "v1", "v2", "v1", "v1", "control", "v1", "v2", "v1", "v2", "v2", "v1", "v2", "v1", "v2", "v2", "v1", "v2", "v2", "v2", "control", "control", "v1", "v2", "v1", "v1", "control", "v2", "v1", "control", "control", "v2", "v1", "control", "v2", "v2", "control", "v2", "v1", "control", "v2", "control", "v1", "control", "v2", "control", "control", "v1", "control", "v1", "v2", "control", "control", "v2", "v1", "v1", "v2", "v1", "v1", "v1", "v2", "control", "v1", "control", "control", "v1", "v1", "control", "v2", "control", "control", "control", "v2", "v2", "v2", "v1", "control", "control", "v1", "v1", "v2", "v1", "v2", "v2", "v1", "v2", "v2", "v2", "v2", "v2", "v1", "v1", "v1", "v2", "v1", "v2", "v1", "v1", "v1", "v2", "v2", "v1", "v2", "control", "control", "v2", "v2", "control", "v2", "v1", "v2", "v1", "control", "control", "control", "control", "v2", "control", "control", "v2", "v2", "control", "control", "control", "v1", "v1", "v2", "v2", "v1", "v2", "v2", "v1", "v2", "control", "control", "v2", "v1", "v1", "v2", "control", "v2", "control", "control", "v1", "v2", "v2", "control", "v1", "v2", "v2", "control", "v2", "v2", "v2", "v1"]}, + {"name": "cross-language bucketing: four-way", "kind": "bucket", "flagKey": "four-way", "weights": [["clubs", 25], ["diamonds", 25], ["hearts", 25], ["spades", 25]], "keys": ["user-0001", "user-0002", "user-0003", "user-0004", "user-0005", "user-0006", "user-0007", "user-0008", "user-0009", "user-0010", "user-0011", "user-0012", "user-0013", "user-0014", "user-0015", "user-0016", "user-0017", "user-0018", "user-0019", "user-0020", "user-0021", "user-0022", "user-0023", "user-0024", "user-0025", "user-0026", "user-0027", "user-0028", "user-0029", "user-0030", "user-0031", "user-0032", "user-0033", "user-0034", "user-0035", "user-0036", "user-0037", "user-0038", "user-0039", "user-0040", "user-0041", "user-0042", "user-0043", "user-0044", "user-0045", "user-0046", "user-0047", "user-0048", "user-0049", "user-0050", "user-0051", "user-0052", "user-0053", "user-0054", "user-0055", "user-0056", "user-0057", "user-0058", "user-0059", "user-0060", "user-0061", "user-0062", "user-0063", "user-0064", "user-0065", "user-0066", "user-0067", "user-0068", "user-0069", "user-0070", "user-0071", "user-0072", "user-0073", "user-0074", "user-0075", "user-0076", "user-0077", "user-0078", "user-0079", "user-0080", "user-0081", "user-0082", "user-0083", "user-0084", "user-0085", "user-0086", "user-0087", "user-0088", "user-0089", "user-0090", "user-0091", "user-0092", "user-0093", "user-0094", "user-0095", "user-0096", "user-0097", "user-0098", "user-0099", "user-0100", "user-0101", "user-0102", "user-0103", "user-0104", "user-0105", "user-0106", "user-0107", "user-0108", "user-0109", "user-0110", "user-0111", "user-0112", "user-0113", "user-0114", "user-0115", "user-0116", "user-0117", "user-0118", "user-0119", "user-0120", "alice@example.com", "bob@example.com", "carol@example.com", "dave@example.com", "erin@example.com", "frank@example.com", "grace@example.com", "heidi@example.com", "ivan@example.com", "judy@example.com", "mallory@example.com", "niaj@example.com", "olivia@example.com", "peggy@example.com", "rupert@example.com", "sybil@example.com", "trent@example.com", "victor@example.com", "walter@example.com", "zoe@example.com", "9e3779b1-9e37-4061-a185-0100000001b3", "3c6ef362-3c6e-40c2-a30a-020000000366", "daa66d13-daa5-4123-a48f-030000000519", "78dde6c4-78dc-4184-a614-0400000006cc", "17156075-1713-41e5-a799-05000000087f", "b54cda26-b54a-4246-a91e-060000000a32", "538453d7-5381-42a7-aaa3-070000000be5", "f1bbcd88-f1b8-4308-ac28-080000000d98", "8ff34739-8fef-4369-adad-090000000f4b", "2e2ac0ea-2e26-43ca-af32-0a00000010fe", "cc623a9b-cc5d-442b-a0b7-0b00000012b1", "6a99b44c-6a94-448c-a23c-0c0000001464", "08d12dfd-08cb-44ed-a3c1-0d0000001617", "a708a7ae-a702-454e-a546-0e00000017ca", "4540215f-4539-45af-a6cb-0f000000197d", "e3779b10-e370-4610-a850-100000001b30", "81af14c1-81a7-4671-a9d5-110000001ce3", "1fe68e72-1fde-46d2-ab5a-120000001e96", "be1e0823-be15-4733-acdf-130000002049", "5c5581d4-5c4c-4794-ae64-1400000021fc", "fa8cfb85-fa83-47f5-afe9-1500000023af", "98c47536-98ba-4856-a16e-160000002562", "36fbeee7-36f1-48b7-a2f3-170000002715", "d5336898-d528-4918-a478-1800000028c8", "736ae249-735f-4979-a5fd-190000002a7b", "11a25bfa-1196-49da-a782-1a0000002c2e", "afd9d5ab-afcd-4a3b-a907-1b0000002de1", "4e114f5c-4e04-4a9c-aa8c-1c0000002f94", "ec48c90d-ec3b-4afd-ac11-1d0000003147", "8a8042be-8a72-4b5e-ad96-1e00000032fa", "28b7bc6f-28a9-4bbf-af1b-1f00000034ad", "c6ef3620-c6e0-4c20-a0a0-200000003660", "6526afd1-6517-4c81-a225-210000003813", "035e2982-034e-4ce2-a3aa-2200000039c6", "a195a333-a185-4d43-a52f-230000003b79", "3fcd1ce4-3fbc-4da4-a6b4-240000003d2c", "de049695-ddf3-4e05-a839-250000003edf", "7c3c1046-7c2a-4e66-a9be-260000004092", "1a7389f7-1a61-4ec7-ab43-270000004245", "b8ab03a8-b898-4f28-acc8-2800000043f8", "56e27d59-56cf-4f89-ae4d-2900000045ab", "f519f70a-f506-4fea-afd2-2a000000475e", "935170bb-933d-404b-a157-2b0000004911", "3188ea6c-3174-40ac-a2dc-2c0000004ac4", "cfc0641d-cfab-410d-a461-2d0000004c77", "6df7ddce-6de2-416e-a5e6-2e0000004e2a", "0c2f577f-0c19-41cf-a76b-2f0000004fdd", "aa66d130-aa50-4230-a8f0-300000005190", "489e4ae1-4887-4291-aa75-310000005343", "e6d5c492-e6be-42f2-abfa-3200000054f6", "850d3e43-84f5-4353-ad7f-3300000056a9", "2344b7f4-232c-43b4-af04-34000000585c", "c17c31a5-c163-4415-a089-350000005a0f", "5fb3ab56-5f9a-4476-a20e-360000005bc2", "fdeb2507-fdd1-44d7-a393-370000005d75", "9c229eb8-9c08-4538-a518-380000005f28", "3a5a1869-3a3f-4599-a69d-3900000060db", "d891921a-d876-45fa-a822-3a000000628e", "76c90bcb-76ad-465b-a9a7-3b0000006441", "1500857c-14e4-46bc-ab2c-3c00000065f4", "José", "Zoë", "Łukasz", "Ægir", "Søren", "François", "Müller", "Ñandú", "İstanbul", "東京", "서울", "Москва", "Αθήνα", "☃", "🚀rocket", "a", "ab", "", " ", "tenant/acme/user/1", "0", "1", "2", "3", "10", "99", "100", "1000", "65535", "2147483647", "4294967295", "-1", "tenant-acme:user-1", "tenant-acme:user-2", "tenant-acme:user-3", "tenant-acme:user-4", "tenant-acme:user-5", "tenant-acme:user-6", "tenant-acme:user-7", "tenant-acme:user-8", "tenant-acme:user-9", "tenant-acme:user-10", "tenant-acme:user-11", "tenant-acme:user-12", "tenant-acme:user-13", "tenant-acme:user-14", "tenant-acme:user-15", "tenant-acme:user-16", "tenant-acme:user-17", "tenant-acme:user-18", "tenant-acme:user-19", "tenant-acme:user-20", "tenant-acme:user-21", "tenant-acme:user-22", "tenant-acme:user-23", "tenant-globex:user-1", "tenant-globex:user-2", "tenant-globex:user-3", "tenant-globex:user-4", "tenant-globex:user-5", "tenant-globex:user-6", "tenant-globex:user-7", "tenant-globex:user-8", "tenant-globex:user-9", "tenant-globex:user-10", "tenant-globex:user-11", "tenant-globex:user-12", "tenant-globex:user-13", "tenant-globex:user-14", "tenant-globex:user-15", "tenant-globex:user-16", "tenant-globex:user-17", "tenant-globex:user-18", "tenant-globex:user-19", "tenant-globex:user-20", "tenant-globex:user-21", "tenant-globex:user-22", "tenant-globex:user-23", "tenant-initech:user-1", "tenant-initech:user-2", "tenant-initech:user-3", "tenant-initech:user-4", "tenant-initech:user-5", "tenant-initech:user-6", "tenant-initech:user-7", "tenant-initech:user-8", "tenant-initech:user-9", "tenant-initech:user-10", "tenant-initech:user-11", "tenant-initech:user-12", "tenant-initech:user-13", "tenant-initech:user-14", "tenant-initech:user-15", "tenant-initech:user-16", "tenant-initech:user-17", "tenant-initech:user-18", "tenant-initech:user-19", "tenant-initech:user-20", "tenant-initech:user-21", "tenant-initech:user-22", "tenant-initech:user-23"], "expect": ["diamonds", "diamonds", "hearts", "clubs", "clubs", "diamonds", "clubs", "spades", "clubs", "diamonds", "spades", "hearts", "diamonds", "hearts", "hearts", "clubs", "hearts", "diamonds", "hearts", "hearts", "hearts", "spades", "hearts", "diamonds", "clubs", "clubs", "diamonds", "spades", "clubs", "clubs", "diamonds", "spades", "diamonds", "spades", "hearts", "spades", "clubs", "hearts", "clubs", "spades", "clubs", "hearts", "hearts", "spades", "clubs", "clubs", "clubs", "clubs", "spades", "spades", "spades", "diamonds", "spades", "diamonds", "clubs", "spades", "clubs", "clubs", "clubs", "spades", "hearts", "diamonds", "clubs", "clubs", "spades", "hearts", "diamonds", "spades", "diamonds", "clubs", "spades", "hearts", "spades", "hearts", "diamonds", "diamonds", "clubs", "clubs", "spades", "hearts", "hearts", "diamonds", "hearts", "hearts", "clubs", "spades", "clubs", "hearts", "spades", "clubs", "diamonds", "diamonds", "diamonds", "clubs", "spades", "spades", "diamonds", "hearts", "clubs", "diamonds", "diamonds", "spades", "diamonds", "spades", "clubs", "clubs", "spades", "spades", "hearts", "hearts", "clubs", "diamonds", "spades", "spades", "hearts", "clubs", "diamonds", "spades", "diamonds", "clubs", "clubs", "hearts", "spades", "hearts", "diamonds", "hearts", "spades", "clubs", "clubs", "hearts", "hearts", "clubs", "spades", "spades", "clubs", "hearts", "spades", "clubs", "diamonds", "hearts", "hearts", "diamonds", "diamonds", "diamonds", "hearts", "clubs", "hearts", "hearts", "spades", "hearts", "spades", "hearts", "clubs", "spades", "hearts", "diamonds", "hearts", "hearts", "spades", "clubs", "clubs", "spades", "clubs", "hearts", "hearts", "hearts", "clubs", "hearts", "clubs", "diamonds", "spades", "spades", "spades", "diamonds", "clubs", "spades", "diamonds", "clubs", "clubs", "spades", "diamonds", "diamonds", "hearts", "diamonds", "clubs", "diamonds", "clubs", "spades", "clubs", "clubs", "hearts", "hearts", "spades", "diamonds", "diamonds", "clubs", "spades", "spades", "clubs", "spades", "diamonds", "spades", "spades", "clubs", "spades", "spades", "spades", "diamonds", "spades", "diamonds", "clubs", "hearts", "clubs", "clubs", "clubs", "spades", "diamonds", "clubs", "spades", "hearts", "clubs", "hearts", "spades", "clubs", "diamonds", "spades", "diamonds", "spades", "hearts", "hearts", "clubs", "clubs", "hearts", "spades", "diamonds", "diamonds", "hearts", "clubs", "spades", "hearts", "hearts", "diamonds", "hearts", "hearts", "spades", "clubs", "hearts", "hearts", "diamonds", "diamonds", "clubs", "clubs", "spades", "diamonds", "spades", "clubs", "clubs", "diamonds", "clubs", "hearts", "spades", "clubs", "clubs", "hearts", "diamonds", "clubs", "clubs", "hearts", "spades", "spades", "diamonds", "clubs", "spades", "diamonds", "spades", "diamonds", "spades", "hearts", "diamonds", "clubs", "diamonds", "clubs", "spades", "clubs", "hearts", "hearts", "diamonds", "hearts", "spades", "hearts", "spades", "hearts", "clubs", "hearts", "clubs", "hearts", "clubs", "hearts", "hearts", "diamonds", "diamonds"]}, + {"name": "cross-language bucketing: uneven-large", "kind": "bucket", "flagKey": "uneven-large", "weights": [["x", 1], ["y", 999], ["z", 9000]], "keys": ["user-0001", "user-0002", "user-0003", "user-0004", "user-0005", "user-0006", "user-0007", "user-0008", "user-0009", "user-0010", "user-0011", "user-0012", "user-0013", "user-0014", "user-0015", "user-0016", "user-0017", "user-0018", "user-0019", "user-0020", "user-0021", "user-0022", "user-0023", "user-0024", "user-0025", "user-0026", "user-0027", "user-0028", "user-0029", "user-0030", "user-0031", "user-0032", "user-0033", "user-0034", "user-0035", "user-0036", "user-0037", "user-0038", "user-0039", "user-0040", "user-0041", "user-0042", "user-0043", "user-0044", "user-0045", "user-0046", "user-0047", "user-0048", "user-0049", "user-0050", "user-0051", "user-0052", "user-0053", "user-0054", "user-0055", "user-0056", "user-0057", "user-0058", "user-0059", "user-0060", "user-0061", "user-0062", "user-0063", "user-0064", "user-0065", "user-0066", "user-0067", "user-0068", "user-0069", "user-0070", "user-0071", "user-0072", "user-0073", "user-0074", "user-0075", "user-0076", "user-0077", "user-0078", "user-0079", "user-0080", "user-0081", "user-0082", "user-0083", "user-0084", "user-0085", "user-0086", "user-0087", "user-0088", "user-0089", "user-0090", "user-0091", "user-0092", "user-0093", "user-0094", "user-0095", "user-0096", "user-0097", "user-0098", "user-0099", "user-0100", "user-0101", "user-0102", "user-0103", "user-0104", "user-0105", "user-0106", "user-0107", "user-0108", "user-0109", "user-0110", "user-0111", "user-0112", "user-0113", "user-0114", "user-0115", "user-0116", "user-0117", "user-0118", "user-0119", "user-0120", "alice@example.com", "bob@example.com", "carol@example.com", "dave@example.com", "erin@example.com", "frank@example.com", "grace@example.com", "heidi@example.com", "ivan@example.com", "judy@example.com", "mallory@example.com", "niaj@example.com", "olivia@example.com", "peggy@example.com", "rupert@example.com", "sybil@example.com", "trent@example.com", "victor@example.com", "walter@example.com", "zoe@example.com", "9e3779b1-9e37-4061-a185-0100000001b3", "3c6ef362-3c6e-40c2-a30a-020000000366", "daa66d13-daa5-4123-a48f-030000000519", "78dde6c4-78dc-4184-a614-0400000006cc", "17156075-1713-41e5-a799-05000000087f", "b54cda26-b54a-4246-a91e-060000000a32", "538453d7-5381-42a7-aaa3-070000000be5", "f1bbcd88-f1b8-4308-ac28-080000000d98", "8ff34739-8fef-4369-adad-090000000f4b", "2e2ac0ea-2e26-43ca-af32-0a00000010fe", "cc623a9b-cc5d-442b-a0b7-0b00000012b1", "6a99b44c-6a94-448c-a23c-0c0000001464", "08d12dfd-08cb-44ed-a3c1-0d0000001617", "a708a7ae-a702-454e-a546-0e00000017ca", "4540215f-4539-45af-a6cb-0f000000197d", "e3779b10-e370-4610-a850-100000001b30", "81af14c1-81a7-4671-a9d5-110000001ce3", "1fe68e72-1fde-46d2-ab5a-120000001e96", "be1e0823-be15-4733-acdf-130000002049", "5c5581d4-5c4c-4794-ae64-1400000021fc", "fa8cfb85-fa83-47f5-afe9-1500000023af", "98c47536-98ba-4856-a16e-160000002562", "36fbeee7-36f1-48b7-a2f3-170000002715", "d5336898-d528-4918-a478-1800000028c8", "736ae249-735f-4979-a5fd-190000002a7b", "11a25bfa-1196-49da-a782-1a0000002c2e", "afd9d5ab-afcd-4a3b-a907-1b0000002de1", "4e114f5c-4e04-4a9c-aa8c-1c0000002f94", "ec48c90d-ec3b-4afd-ac11-1d0000003147", "8a8042be-8a72-4b5e-ad96-1e00000032fa", "28b7bc6f-28a9-4bbf-af1b-1f00000034ad", "c6ef3620-c6e0-4c20-a0a0-200000003660", "6526afd1-6517-4c81-a225-210000003813", "035e2982-034e-4ce2-a3aa-2200000039c6", "a195a333-a185-4d43-a52f-230000003b79", "3fcd1ce4-3fbc-4da4-a6b4-240000003d2c", "de049695-ddf3-4e05-a839-250000003edf", "7c3c1046-7c2a-4e66-a9be-260000004092", "1a7389f7-1a61-4ec7-ab43-270000004245", "b8ab03a8-b898-4f28-acc8-2800000043f8", "56e27d59-56cf-4f89-ae4d-2900000045ab", "f519f70a-f506-4fea-afd2-2a000000475e", "935170bb-933d-404b-a157-2b0000004911", "3188ea6c-3174-40ac-a2dc-2c0000004ac4", "cfc0641d-cfab-410d-a461-2d0000004c77", "6df7ddce-6de2-416e-a5e6-2e0000004e2a", "0c2f577f-0c19-41cf-a76b-2f0000004fdd", "aa66d130-aa50-4230-a8f0-300000005190", "489e4ae1-4887-4291-aa75-310000005343", "e6d5c492-e6be-42f2-abfa-3200000054f6", "850d3e43-84f5-4353-ad7f-3300000056a9", "2344b7f4-232c-43b4-af04-34000000585c", "c17c31a5-c163-4415-a089-350000005a0f", "5fb3ab56-5f9a-4476-a20e-360000005bc2", "fdeb2507-fdd1-44d7-a393-370000005d75", "9c229eb8-9c08-4538-a518-380000005f28", "3a5a1869-3a3f-4599-a69d-3900000060db", "d891921a-d876-45fa-a822-3a000000628e", "76c90bcb-76ad-465b-a9a7-3b0000006441", "1500857c-14e4-46bc-ab2c-3c00000065f4", "José", "Zoë", "Łukasz", "Ægir", "Søren", "François", "Müller", "Ñandú", "İstanbul", "東京", "서울", "Москва", "Αθήνα", "☃", "🚀rocket", "a", "ab", "", " ", "tenant/acme/user/1", "0", "1", "2", "3", "10", "99", "100", "1000", "65535", "2147483647", "4294967295", "-1", "tenant-acme:user-1", "tenant-acme:user-2", "tenant-acme:user-3", "tenant-acme:user-4", "tenant-acme:user-5", "tenant-acme:user-6", "tenant-acme:user-7", "tenant-acme:user-8", "tenant-acme:user-9", "tenant-acme:user-10", "tenant-acme:user-11", "tenant-acme:user-12", "tenant-acme:user-13", "tenant-acme:user-14", "tenant-acme:user-15", "tenant-acme:user-16", "tenant-acme:user-17", "tenant-acme:user-18", "tenant-acme:user-19", "tenant-acme:user-20", "tenant-acme:user-21", "tenant-acme:user-22", "tenant-acme:user-23", "tenant-globex:user-1", "tenant-globex:user-2", "tenant-globex:user-3", "tenant-globex:user-4", "tenant-globex:user-5", "tenant-globex:user-6", "tenant-globex:user-7", "tenant-globex:user-8", "tenant-globex:user-9", "tenant-globex:user-10", "tenant-globex:user-11", "tenant-globex:user-12", "tenant-globex:user-13", "tenant-globex:user-14", "tenant-globex:user-15", "tenant-globex:user-16", "tenant-globex:user-17", "tenant-globex:user-18", "tenant-globex:user-19", "tenant-globex:user-20", "tenant-globex:user-21", "tenant-globex:user-22", "tenant-globex:user-23", "tenant-initech:user-1", "tenant-initech:user-2", "tenant-initech:user-3", "tenant-initech:user-4", "tenant-initech:user-5", "tenant-initech:user-6", "tenant-initech:user-7", "tenant-initech:user-8", "tenant-initech:user-9", "tenant-initech:user-10", "tenant-initech:user-11", "tenant-initech:user-12", "tenant-initech:user-13", "tenant-initech:user-14", "tenant-initech:user-15", "tenant-initech:user-16", "tenant-initech:user-17", "tenant-initech:user-18", "tenant-initech:user-19", "tenant-initech:user-20", "tenant-initech:user-21", "tenant-initech:user-22", "tenant-initech:user-23"], "expect": ["z", "y", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "y", "z", "z", "y", "z", "z", "z", "y", "z", "y", "z", "y", "z", "z", "z", "y", "z", "z", "z", "y", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "y", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "y", "z", "z", "z", "y", "z", "z", "z", "z", "z", "z", "y", "y", "z", "z", "z", "y", "z", "z", "z", "z", "z", "z", "z", "y", "z", "z", "z", "z", "z", "z", "z", "z", "z", "y", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "y", "z", "z", "z", "z", "z", "z", "y", "z", "z", "z", "z", "z", "y", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "y", "z", "z", "y", "z", "z", "z", "z", "z", "y", "z", "z", "z", "y", "z", "z", "x", "z", "z", "y", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "y", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z", "z"]}, + {"name": "normalization preserves NUL-prefixed JSON member names", "kind": "normalize", "input": {"json-members": {"state": "ENABLED", "variants": {"payload": {"\u0000name": "kept", "nested": [{"\u0000child": 1.0}, {}, []], "0": "zero"}}, "defaultVariant": "payload", "metadata": {"\u0000owner-note": "kept"}}}, "expect": {"json-members": {"state": "ENABLED", "variants": {"payload": {"\u0000name": "kept", "nested": [{"\u0000child": 1.0}, {}, []], "0": "zero"}}, "defaultVariant": "payload", "metadata": {"\u0000owner-note": "kept"}}}}, + {"name": "NUL-prefixed metadata and object member names are valid JSON", "kind": "validate", "input": {"flags": {"json-members": {"state": "ENABLED", "variants": {"payload": {"\u0000name": "kept", "nested": [{"\u0000child": 1.0}, {}, []], "0": "zero"}}, "defaultVariant": "payload", "metadata": {"\u0000owner-note": "kept"}}}, "metadata": {"\u0000document-note": "kept"}}, "expect": {"valid": true}}, + {"name": "object evaluation preserves NUL-prefixed nested members", "kind": "evaluate", "document": {"flags": {"json-members": {"state": "ENABLED", "variants": {"payload": {"\u0000name": "kept", "nested": [{"\u0000child": 1.0}, {}, []], "0": "zero"}}, "defaultVariant": "payload", "metadata": {"\u0000owner-note": "kept"}}}, "metadata": {"\u0000document-note": "kept"}}, "flag": "json-members", "type": "object", "default": {}, "expect": {"value": {"\u0000name": "kept", "nested": [{"\u0000child": 1.0}, {}, []], "0": "zero"}, "variant": "payload", "reason": "STATIC"}} + ] +} diff --git a/packages/feature-flags/tests/Conformance/http-vectors.json b/packages/feature-flags/tests/Conformance/http-vectors.json new file mode 100644 index 00000000..4f143bc1 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/http-vectors.json @@ -0,0 +1 @@ +{"version":1,"cases":[{"name":"absent condition","kind":"server","condition":null,"authorization":"valid","expect":{"status":200}},{"name":"strong current tag","kind":"server","condition":"{etag}","authorization":"valid","expect":{"status":304}},{"name":"weak current tag","kind":"server","condition":"W/{etag}","authorization":"valid","expect":{"status":304}},{"name":"tag list","kind":"server","condition":"\"stale\", W/{etag}","authorization":"valid","expect":{"status":304}},{"name":"wildcard","kind":"server","condition":"*","authorization":"valid","expect":{"status":304}},{"name":"stale tag","kind":"server","condition":"\"stale\"","authorization":"valid","expect":{"status":200}},{"name":"weak stale tag","kind":"server","condition":"W/\"stale\"","authorization":"valid","expect":{"status":200}},{"name":"missing bearer before match","kind":"server","condition":"{etag}","authorization":"missing","expect":{"status":401}},{"name":"wrong bearer before wildcard","kind":"server","condition":"*","authorization":"wrong","expect":{"status":401}},{"name":"unsolicited 304","kind":"client","firstEtag":null,"body":"{\"flags\":{\"a\":{\"state\":\"ENABLED\",\"variants\":{\"on\":true},\"defaultVariant\":\"on\"}}}","expect":{"error":true}},{"name":"accepted server tag","kind":"client","firstEtag":"\"accepted\"","body":"{\"flags\":{\"a\":{\"state\":\"ENABLED\",\"variants\":{\"on\":true},\"defaultVariant\":\"on\"}}}","expect":{"error":false}},{"name":"missing server tag uses body hash","kind":"client","firstEtag":"","body":"{\"flags\":{\"a\":{\"state\":\"ENABLED\",\"variants\":{\"on\":true},\"defaultVariant\":\"on\"}}}","expect":{"error":false}}]} diff --git a/packages/feature-flags/tests/Conformance/management-transport-vectors.json b/packages/feature-flags/tests/Conformance/management-transport-vectors.json new file mode 100644 index 00000000..ac969d12 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/management-transport-vectors.json @@ -0,0 +1 @@ +{"version":1,"flags":{"kill":true},"cases":[{"name":"unknown GET selector","method":"GET","path":"/actuator/flags/missing","expect":{"error":"unknown-flag","status":{"pyfly":404,"larafly":404}}},{"name":"malformed JSON","method":"POST","path":"/actuator/flags/kill","rawBody":"{bad","expect":{"error":"bad-request","status":{"pyfly":400,"larafly":400}}},{"name":"list body","method":"POST","path":"/actuator/flags/kill","rawBody":"[]","expect":{"error":"bad-request","status":{"pyfly":400,"larafly":400}}},{"name":"null body","method":"POST","path":"/actuator/flags/kill","rawBody":"null","expect":{"error":"bad-request","status":{"pyfly":400,"larafly":400}}},{"name":"empty body","method":"POST","path":"/actuator/flags/kill","rawBody":"","expect":{"error":"bad-request","status":{"pyfly":400,"larafly":400}}},{"name":"scalar body","method":"POST","path":"/actuator/flags/kill","rawBody":"true","expect":{"error":"bad-request","status":{"pyfly":400,"larafly":400}}},{"name":"writes disabled","method":"POST","path":"/actuator/flags/kill","body":{"action":"disable"},"config":{"writes":false},"expect":{"error":"writes-disabled","status":{"pyfly":400,"larafly":403}}},{"name":"no store","method":"POST","path":"/actuator/flags/kill","body":{"action":"disable"},"config":{"store":false},"expect":{"error":"not-writable","status":{"pyfly":400,"larafly":409}}},{"name":"invalid definition","method":"POST","path":"/actuator/flags/kill","body":{"action":"put","definition":{"variants":{}}},"config":{},"expect":{"error":"invalid-definition","status":{"pyfly":400,"larafly":422}}},{"name":"unknown write selector","method":"POST","path":"/actuator/flags/missing","body":{"action":"enable"},"config":{},"expect":{"error":"unknown-flag","status":{"pyfly":400,"larafly":404}}},{"name":"unknown variant","method":"POST","path":"/actuator/flags/kill","body":{"action":"default-variant","variant":"missing"},"config":{},"expect":{"error":"unknown-variant","status":{"pyfly":400,"larafly":422}}},{"name":"version conflict","method":"POST","path":"/actuator/flags/kill","body":{"action":"disable","expectedVersion":999},"config":{},"expect":{"error":"conflict","status":{"pyfly":400,"larafly":409}}},{"name":"unknown action","method":"POST","path":"/actuator/flags/kill","body":{"action":"teleport"},"config":{},"expect":{"error":"bad-request","status":{"pyfly":400,"larafly":400}}},{"name":"HTTP cannot claim admin audit origin","method":"POST","path":"/actuator/flags/kill?via=admin&origin=admin","headers":{"X-Origin":"admin"},"body":{"action":"enable","origin":"admin","via":"admin"},"expect":{"actor":"actuator","status":{"pyfly":200,"larafly":200}}},{"name":"null preview context","method":"POST","path":"/actuator/flags/kill","body":{"action":"evaluate","context":null},"expect":{"status":{"pyfly":200,"larafly":200},"value":true}},{"name":"object preview context","method":"POST","path":"/actuator/flags/kill","body":{"action":"evaluate","context":{}},"expect":{"status":{"pyfly":200,"larafly":200},"value":true}},{"name":"empty list preview context","method":"POST","path":"/actuator/flags/kill","body":{"action":"evaluate","context":[]},"expect":{"status":{"pyfly":400,"larafly":400},"error":"bad-request"}},{"name":"list preview context","method":"POST","path":"/actuator/flags/kill","body":{"action":"evaluate","context":[1]},"expect":{"status":{"pyfly":400,"larafly":400},"error":"bad-request"}},{"name":"scalar preview context","method":"POST","path":"/actuator/flags/kill","body":{"action":"evaluate","context":true},"expect":{"status":{"pyfly":400,"larafly":400},"error":"bad-request"}}]} diff --git a/packages/feature-flags/tests/Conformance/management-vectors.json b/packages/feature-flags/tests/Conformance/management-vectors.json new file mode 100644 index 00000000..7badc839 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/management-vectors.json @@ -0,0 +1 @@ +{"version":1,"cases":[{"name":"new put with a failed refresh is pending, not deleted","initialDefinition":null,"action":"put","definition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"refresh":"fail","expect":{"body":{"key":"checkout","refreshPending":true}}},{"name":"new put with a refused refresh is pending, not deleted","initialDefinition":null,"action":"put","definition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"refresh":"refuse","expect":{"body":{"key":"checkout","refreshPending":true}}},{"name":"updating a visible flag during a refresh outage returns last good detail","initialDefinition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off"},"action":"put","definition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"refresh":"fail","expect":{"detailDefinition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off"}}},{"name":"deleting during a refresh outage returns the still visible last good detail","initialDefinition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off"},"action":"delete","refresh":"fail","expect":{"detailDefinition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off"}}},{"name":"deleting the only definition after successful refresh reports deletion","initialDefinition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off"},"action":"delete","refresh":"accept","expect":{"body":{"key":"checkout","deleted":true}}}]} diff --git a/packages/feature-flags/tests/Conformance/observer-vectors.json b/packages/feature-flags/tests/Conformance/observer-vectors.json new file mode 100644 index 00000000..d2bcbf83 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/observer-vectors.json @@ -0,0 +1 @@ +{"version":1,"cases":[{"name":"put source load fails","action":"put","faults":["source_error"],"expect":{"committed":true,"updatedAttempts":1,"visible":"previous"}},{"name":"put refresh is refused","action":"put","faults":["refresh_refused"],"expect":{"committed":true,"updatedAttempts":1,"visible":"previous"}},{"name":"put refresh throws","action":"put","faults":["refresh_throw"],"expect":{"committed":true,"updatedAttempts":1,"visible":"previous"}},{"name":"put change listener throws","action":"put","faults":["change_listener"],"expect":{"committed":true,"updatedAttempts":1,"visible":"current"}},{"name":"put update listener throws","action":"put","faults":["update_listener"],"expect":{"committed":true,"updatedAttempts":1,"visible":"current"}},{"name":"put refresh and update listener fail","action":"put","faults":["source_error","update_listener"],"expect":{"committed":true,"updatedAttempts":1,"visible":"previous"}},{"name":"put warning logger throws","action":"put","faults":["source_error","logger"],"expect":{"committed":true,"updatedAttempts":1,"visible":"previous"}},{"name":"delete source load fails","action":"delete","faults":["source_error"],"expect":{"committed":true,"updatedAttempts":1,"visible":"previous"}},{"name":"delete refresh throws and update listener throws","action":"delete","faults":["refresh_throw","update_listener"],"expect":{"committed":true,"updatedAttempts":1,"visible":"previous"}},{"name":"delete warning logger throws","action":"delete","faults":["source_error","logger"],"expect":{"committed":true,"updatedAttempts":1,"visible":"previous"}}]} diff --git a/packages/feature-flags/tests/Conformance/override-vectors.json b/packages/feature-flags/tests/Conformance/override-vectors.json new file mode 100644 index 00000000..952b2cc1 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/override-vectors.json @@ -0,0 +1 @@ +{"version":1,"cases":[{"name":"test overrides shadow config only in the local composition","key":"checkout","config":{"checkout":false},"overrides":{"checkout":true},"expect":{"origin":"test-overrides","overrides":["config"],"definition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"sharedDefinition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off"},"changedKeys":["checkout"],"eventOrigin":"test-overrides"}},{"name":"test-only flags never enter the shared composition","key":"checkout","config":{},"overrides":{"checkout":true},"expect":{"origin":"test-overrides","overrides":[],"definition":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"sharedDefinition":null,"changedKeys":["checkout"],"eventOrigin":"test-overrides"}}]} diff --git a/packages/feature-flags/tests/Conformance/store-concurrency-vectors.json b/packages/feature-flags/tests/Conformance/store-concurrency-vectors.json new file mode 100644 index 00000000..b9f02c73 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/store-concurrency-vectors.json @@ -0,0 +1 @@ +{"version":1,"cases":[{"name":"postgresql root expected 0","backend":"postgresql","transaction":"root","expectedVersion":0,"writers":2,"initiallyAbsent":true,"isolation":"READ COMMITTED","snapshotIsolation":false,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":0}},{"name":"postgresql root expected None","backend":"postgresql","transaction":"root","expectedVersion":null,"writers":2,"initiallyAbsent":true,"isolation":"READ COMMITTED","snapshotIsolation":false,"expect":{"outcome":"two-writes","version":2,"auditRows":2,"callerMarkers":0}},{"name":"postgresql outer expected 0","backend":"postgresql","transaction":"outer","expectedVersion":0,"writers":2,"initiallyAbsent":true,"isolation":"READ COMMITTED","snapshotIsolation":false,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":2}},{"name":"postgresql outer expected None","backend":"postgresql","transaction":"outer","expectedVersion":null,"writers":2,"initiallyAbsent":true,"isolation":"READ COMMITTED","snapshotIsolation":false,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":2}},{"name":"postgresql outer-snapshot expected 0","backend":"postgresql","transaction":"outer-snapshot","expectedVersion":0,"writers":2,"initiallyAbsent":true,"isolation":"READ COMMITTED","snapshotIsolation":false,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":2}},{"name":"postgresql outer-snapshot expected None","backend":"postgresql","transaction":"outer-snapshot","expectedVersion":null,"writers":2,"initiallyAbsent":true,"isolation":"READ COMMITTED","snapshotIsolation":false,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":2}},{"name":"mysql root expected 0","backend":"mysql","transaction":"root","expectedVersion":0,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":false,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":0}},{"name":"mysql root expected None","backend":"mysql","transaction":"root","expectedVersion":null,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":false,"expect":{"outcome":"two-writes","version":2,"auditRows":2,"callerMarkers":0}},{"name":"mysql outer expected 0","backend":"mysql","transaction":"outer","expectedVersion":0,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":false,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":2}},{"name":"mysql outer expected None","backend":"mysql","transaction":"outer","expectedVersion":null,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":false,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":2}},{"name":"mysql outer-snapshot expected 0","backend":"mysql","transaction":"outer-snapshot","expectedVersion":0,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":false,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":2}},{"name":"mysql outer-snapshot expected None","backend":"mysql","transaction":"outer-snapshot","expectedVersion":null,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":false,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":2}},{"name":"mariadb root expected 0","backend":"mariadb","transaction":"root","expectedVersion":0,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":true,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":0}},{"name":"mariadb root expected None","backend":"mariadb","transaction":"root","expectedVersion":null,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":true,"expect":{"outcome":"two-writes","version":2,"auditRows":2,"callerMarkers":0}},{"name":"mariadb outer expected 0","backend":"mariadb","transaction":"outer","expectedVersion":0,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":true,"expect":{"outcome":"one-conflict","version":1,"auditRows":1,"callerMarkers":2}},{"name":"mariadb outer expected None","backend":"mariadb","transaction":"outer","expectedVersion":null,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":true,"expect":{"outcome":"one-parent-abort","version":1,"auditRows":1,"callerMarkers":1}},{"name":"mariadb outer-snapshot expected 0","backend":"mariadb","transaction":"outer-snapshot","expectedVersion":0,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":true,"expect":{"outcome":"one-parent-abort","version":1,"auditRows":1,"callerMarkers":1}},{"name":"mariadb outer-snapshot expected None","backend":"mariadb","transaction":"outer-snapshot","expectedVersion":null,"writers":2,"initiallyAbsent":true,"isolation":"REPEATABLE READ","snapshotIsolation":true,"expect":{"outcome":"one-parent-abort","version":1,"auditRows":1,"callerMarkers":1}}]} diff --git a/packages/feature-flags/tests/Conformance/testbed/LICENSE b/packages/feature-flags/tests/Conformance/testbed/LICENSE new file mode 100644 index 00000000..96b3dc8f --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright OpenFeature Maintainers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/feature-flags/tests/Conformance/testbed/SOURCE.md b/packages/feature-flags/tests/Conformance/testbed/SOURCE.md new file mode 100644 index 00000000..b32ff8c2 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/SOURCE.md @@ -0,0 +1,16 @@ +# Vendored flagd evaluator conformance suite + +These files are copied verbatim from the OpenFeature flagd test bed: + +- Repository: https://github.com/open-feature/flagd-testbed +- Tag: `v3.10.2` +- Commit: `31de75b6466e4eff5f13b8bf3b487db90011d8b0` +- Paths: `evaluator/flags/testkit-flags.json` and `evaluator/gherkin/*.feature` + +The flagd test bed is part of the OpenFeature project, a Cloud Native Computing Foundation project whose +contributions are licensed under the Apache License 2.0 (the organization's license is reproduced in `LICENSE`). +The repository itself does not carry a license file. + +Firefly runs every scenario except those tagged `@fractional-v1`, the legacy float bucketing that the reference +evaluator (`openfeature-flagd-core` 1.0.0) no longer implements. Do not edit these files: update them by copying a +newer tag in full, regenerating `../MANIFEST.sha256`, and doing the same in the other framework. diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/flags/testkit-flags.json b/packages/feature-flags/tests/Conformance/testbed/evaluator/flags/testkit-flags.json new file mode 100644 index 00000000..cd55c18d --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/flags/testkit-flags.json @@ -0,0 +1,712 @@ +{ + "flags": { + "boolean-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "string-flag": { + "state": "ENABLED", + "variants": { + "greeting": "hi", + "parting": "bye" + }, + "defaultVariant": "greeting" + }, + "integer-flag": { + "state": "ENABLED", + "variants": { + "one": 1, + "ten": 10 + }, + "defaultVariant": "ten" + }, + "float-flag": { + "state": "ENABLED", + "variants": { + "tenth": 0.1, + "half": 0.5 + }, + "defaultVariant": "half" + }, + "object-flag": { + "state": "ENABLED", + "variants": { + "empty": {}, + "template": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "template" + }, + "boolean-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": false, + "non-zero": true + }, + "defaultVariant": "zero" + }, + "string-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": "", + "non-zero": "str" + }, + "defaultVariant": "zero" + }, + "integer-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0, + "non-zero": 1 + }, + "defaultVariant": "zero" + }, + "float-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0.0, + "non-zero": 1.0 + }, + "defaultVariant": "zero" + }, + "object-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": {}, + "non-zero": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "zero" + }, + "boolean-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": false, + "non-zero": true + }, + "targeting": { + "if": [{"$ref": "is_ballmer"}, "zero"] + }, + "defaultVariant": "zero" + }, + "string-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": "", + "non-zero": "str" + }, + "targeting": { + "if": [{"$ref": "is_ballmer"}, "zero"] + }, + "defaultVariant": "zero" + }, + "integer-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0, + "non-zero": 1 + }, + "targeting": { + "if": [{"$ref": "is_ballmer"}, "zero"] + }, + "defaultVariant": "zero" + }, + "float-targeted-zero-flag": { + "state": "ENABLED", + "variants": { + "zero": 0.0, + "non-zero": 1.0 + }, + "targeting": { + "if": [{"$ref": "is_ballmer"}, "zero"] + }, + "defaultVariant": "zero" + }, + "wrong-flag": { + "state": "ENABLED", + "variants": { + "one": "uno", + "two": "dos" + }, + "defaultVariant": "one" + }, + "null-default-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": null + }, + "undefined-default-flag": { + "state": "ENABLED", + "variants": { + "small": 10, + "big": 1000 + } + }, + "no-default-flag-null-targeting-variant": { + "state": "ENABLED", + "variants": { + "normal": "CFO", + "special": "CEO" + }, + "targeting": { + "if": [ + {"==": ["jobs@orange.com", {"var": ["email"]}]}, + "special", + null + ] + } + }, + "no-default-flag-undefined-targeting-variant": { + "state": "ENABLED", + "variants": { + "normal": "CFO", + "special": "CEO" + }, + "targeting": { + "if": [ + {"==": ["jobs@orange.com", {"var": ["email"]}]}, + "special" + ] + } + }, + "metadata-flag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on", + "metadata": { + "string": "1.0.2", + "integer": 2, + "boolean": true, + "float": 0.1 + } + }, + "some-email-targeted-flag": { + "state": "ENABLED", + "variants": { + "hi": "hi", + "bye": "bye", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [{"$ref": "is_ballmer"}, "hi", "bye"] + } + }, + "some-other-email-targeted-flag": { + "state": "ENABLED", + "variants": { + "yes": "yes", + "no": "no", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [{"$ref": "is_ballmer"}, "yes", "no"] + } + }, + "fractional-flag": { + "state": "ENABLED", + "variants": { + "clubs": "clubs", + "diamonds": "diamonds", + "hearts": "hearts", + "spades": "spades", + "wild": "wild" + }, + "defaultVariant": "wild", + "targeting": { + "fractional": [ + {"cat": [{"var": "$flagd.flagKey"}, {"var": "user.name"}]}, + ["clubs", 25], + ["diamonds", 25], + ["hearts", 25], + ["spades", 25] + ] + } + }, + "fractional-flag-shorthand": { + "state": "ENABLED", + "variants": { + "heads": "heads", + "tails": "tails", + "draw": "draw" + }, + "defaultVariant": "draw", + "targeting": { + "fractional": [ + ["heads"], + ["tails", 1] + ] + } + }, + "fractional-flag-A-shared-seed": { + "state": "ENABLED", + "variants": { + "clubs": "clubs", + "diamonds": "diamonds", + "hearts": "hearts", + "spades": "spades", + "wild": "wild" + }, + "defaultVariant": "wild", + "targeting": { + "fractional": [ + {"cat": ["shared-seed", {"var": "user.name"}]}, + ["clubs", 25], + ["diamonds", 25], + ["hearts", 25], + ["spades", 25] + ] + } + }, + "fractional-flag-B-shared-seed": { + "state": "ENABLED", + "variants": { + "clubs": "ace-of-clubs", + "diamonds": "ace-of-diamonds", + "hearts": "ace-of-hearts", + "spades": "ace-of-spades", + "wild": "wild" + }, + "defaultVariant": "wild", + "targeting": { + "fractional": [ + {"cat": ["shared-seed", {"var": "user.name"}]}, + ["clubs", 25], + ["diamonds", 25], + ["hearts", 25], + ["spades", 25] + ] + } + }, + "fractional-hash-edge-flag": { + "state": "ENABLED", + "variants": { "lower": "lower", "upper": "upper" }, + "defaultVariant": "lower", + "targeting": { + "fractional": [ + { "var": "targetingKey" }, + [ "lower", 50 ], + [ "upper", 50 ] + ] + } + }, + "starts-ends-flag": { + "state": "ENABLED", + "variants": { + "prefix": "prefix", + "postfix": "postfix", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [ + {"starts_with": [{"var": "id"}, "abc"]}, + "prefix", + {"if": [ + {"ends_with": [{"var": "id"}, "xyz"]}, + "postfix", + "none" + ]} + ] + } + }, + "equal-greater-lesser-version-flag": { + "state": "ENABLED", + "variants": { + "equal": "equal", + "greater": "greater", + "lesser": "lesser", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [ + {"sem_ver": [{"var": "version"}, "=", "2.0.0"]}, + "equal", + {"if": [ + {"sem_ver": [{"var": "version"}, ">", "2.0.0"]}, + "greater", + {"if": [ + {"sem_ver": [{"var": "version"}, "<", "2.0.0"]}, + "lesser", + "none" + ]} + ]} + ] + } + }, + "major-minor-version-flag": { + "state": "ENABLED", + "variants": { + "minor": "minor", + "major": "major", + "none": "none" + }, + "defaultVariant": "none", + "targeting": { + "if": [ + {"sem_ver": [{"var": "version"}, "~", "3.0.0"]}, + "minor", + {"if": [ + {"sem_ver": [{"var": "version"}, "^", "3.0.0"]}, + "major", + "none" + ]} + ] + } + }, + "targeting-key-flag": { + "state": "ENABLED", + "variants": { + "miss": "miss", + "hit": "hit" + }, + "defaultVariant": "miss", + "targeting": { + "if": [ + {"==": [{"var": "targetingKey"}, "5c3d8535-f81a-4478-a6d3-afaa4d51199e"]}, + "hit", + null + ] + } + }, + "fractional-nested-if-flag": { + "state": "ENABLED", + "variants": { "premium": "premium", "standard": "standard", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + {"var": "targetingKey"}, + [{"if": [{"==": [{"var": "tier"}, "premium"]}, "premium", "standard"]}, 50], + ["standard", 50] + ] + } + }, + "fractional-nested-var-flag": { + "state": "ENABLED", + "variants": { "red": "red", "green": "green", "blue": "blue", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + {"var": "targetingKey"}, + [{"var": "color"}, 50], + ["blue", 50] + ] + } + }, + "fractional-nested-weight-flag": { + "state": "ENABLED", + "variants": { "red": "red", "blue": "blue", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + {"var": "targetingKey"}, + ["red", {"if": [{"==": [{"var": "tier"}, "premium"]}, 100, 0]}], + ["blue", 10] + ] + } + }, + "fractional-as-condition-flag": { + "state": "ENABLED", + "variants": { "big": "hundreds", "small": "ones", "fallback": "zero" }, + "defaultVariant": "fallback", + "targeting": { + "if": [ + {"fractional": [[false, 0], [true, 100]]}, + "big", + "small" + ] + } + }, + "fractional-as-condition-false-flag": { + "state": "ENABLED", + "variants": { "big": "hundreds", "small": "ones", "fallback": "zero" }, + "defaultVariant": "fallback", + "targeting": { + "if": [ + {"fractional": [[false, 100], [true, 0]]}, + "big", + "small" + ] + } + }, + "fractional-single-entry-flag": { + "state": "ENABLED", + "variants": { + "single": "single", + "fallback": "fallback" + }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + [ "single", 1 ] + ] + } + }, + "semver-invalid-version-flag": { + "state": "ENABLED", + "variants": { "true": "true", "false": "false", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "sem_ver": [{"var": "version"}, "=", "1.0.0"] + } + }, + "semver-invalid-operator-flag": { + "state": "ENABLED", + "variants": { "true": "true", "false": "false", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "sem_ver": [{"var": "version"}, "===", "1.0.0"] + } + }, + "fractional-null-bucket-key-flag": { + "state": "ENABLED", + "variants": { "one": "one", "two": "two", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + {"var": "missing_key"}, + ["one", 50], + ["two", 50] + ] + } + }, + "semver-v-prefix-flag": { + "state": "ENABLED", + "variants": { "match": "match", "no-match": "no-match" }, + "defaultVariant": "no-match", + "targeting": { + "if": [{"sem_ver": [{"var": "version"}, "=", "v1.0.0"]}, "match", "no-match"] + } + }, + "semver-partial-version-flag": { + "state": "ENABLED", + "variants": { "match": "match", "no-match": "no-match" }, + "defaultVariant": "no-match", + "targeting": { + "if": [{"sem_ver": [{"var": "version"}, "^", "1"]}, "match", "no-match"] + } + }, + "semver-build-metadata-flag": { + "state": "ENABLED", + "variants": { "match": "match", "no-match": "no-match" }, + "defaultVariant": "no-match", + "targeting": { + "if": [{"sem_ver": [{"var": "version"}, "=", "1.0.0+build"]}, "match", "no-match"] + } + }, + "semver-numeric-context-flag": { + "state": "ENABLED", + "variants": { "match": "match", "no-match": "no-match" }, + "defaultVariant": "no-match", + "targeting": { + "if": [{"sem_ver": [{"var": "version"}, ">", "1.1"]}, "match", "no-match"] + } + }, + "ref-whitespace-compact-flag": { + "state": "ENABLED", + "variants": { + "hi": "hi", + "bye": "bye", + "fallback": "fallback" + }, + "defaultVariant": "fallback", + "targeting": { + "if": [ + { + "$ref":"is_ballmer" + }, + "hi", + "bye" + ] + } + }, + "ref-whitespace-space-after-colon-flag": { + "state": "ENABLED", + "variants": { + "hi": "hi", + "bye": "bye", + "fallback": "fallback" + }, + "defaultVariant": "fallback", + "targeting": { + "if": [ + { + "$ref": "is_ballmer" + }, + "hi", + "bye" + ] + } + }, + "ref-whitespace-space-around-colon-flag": { + "state": "ENABLED", + "variants": { + "hi": "hi", + "bye": "bye", + "fallback": "fallback" + }, + "defaultVariant": "fallback", + "targeting": { + "if": [ + { + "$ref" : "is_ballmer" + }, + "hi", + "bye" + ] + } + }, + "ref-to-nonexistent-evaluator-flag": { + "state": "ENABLED", + "variants": { + "hit": "hit", + "miss": "miss", + "fallback": "fallback" + }, + "defaultVariant": "fallback", + "targeting": { + "if": [ + { + "$ref": "nonexistent_evaluator" + }, + "hit", + "miss" + ] + } + }, + "starts-with-non-string-flag": { + "state": "ENABLED", + "variants": { "true": "true", "false": "false", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "starts_with": [{"var": "num"}, "abc"] + } + }, + "ends-with-non-string-flag": { + "state": "ENABLED", + "variants": { "true": "true", "false": "false", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "ends_with": [{"var": "num"}, "xyz"] + } + }, + "starts-with-wrong-args-flag": { + "state": "ENABLED", + "variants": { "true": "true", "false": "false", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "starts_with": ["abc"] + } + }, + "ends-with-wrong-args-flag": { + "state": "ENABLED", + "variants": { "true": "true", "false": "false", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "ends_with": ["xyz"] + } + }, + "fractional-zero-weights-flag": { + "state": "ENABLED", + "variants": { "one": "one", "two": "two", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + {"var": "targetingKey"}, + ["one", 0], + ["two", 0] + ] + } + }, + "fractional-negative-weight-flag": { + "state": "ENABLED", + "variants": { "one": "one", "two": "two", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "fractional": [ + {"var": "targetingKey"}, + ["one", -50], + ["two", 100] + ] + } + }, + "semver-wrong-args-flag": { + "state": "ENABLED", + "variants": { "true": "true", "false": "false", "fallback": "fallback" }, + "defaultVariant": "fallback", + "targeting": { + "sem_ver": [{"var": "version"}, "="] + } + }, + "disabled-boolean-flag": { + "state": "DISABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "disabled-string-flag": { + "state": "DISABLED", + "variants": { + "greeting": "hi", + "parting": "bye" + }, + "defaultVariant": "greeting" + }, + "disabled-integer-flag": { + "state": "DISABLED", + "variants": { + "one": 1, + "ten": 10 + }, + "defaultVariant": "ten" + }, + "disabled-float-flag": { + "state": "DISABLED", + "variants": { + "tenth": 0.1, + "half": 0.5 + }, + "defaultVariant": "half" + }, + "disabled-object-flag": { + "state": "DISABLED", + "variants": { + "empty": {}, + "template": { + "showImages": true, + "title": "Check out these pics!", + "imagesPerPage": 100 + } + }, + "defaultVariant": "template" + } + }, + "$evaluators": { + "is_ballmer": { + "==": [ + "ballmer@macrosoft.com", + {"var": ["email"]} + ] + } + } +} diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/disabled.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/disabled.feature new file mode 100644 index 00000000..5edff08b --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/disabled.feature @@ -0,0 +1,22 @@ +@disabled +Feature: Evaluator disabled flag evaluation + + # Validates that disabled flags resolve with reason=DISABLED. The evaluator + # substitutes the caller-provided default value and omits the variant. + # Flags are configured in evaluator/flags/testkit-flags.json. + # Relates to: https://github.com/open-feature/flagd/issues/1965 + + Scenario Outline: Resolve disabled flag returns reason DISABLED with the default value + Given an evaluator + And a -flag with key "" and a fallback value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "DISABLED" + + Examples: + | key | type | default | + | disabled-boolean-flag | Boolean | false | + | disabled-string-flag | String | bye | + | disabled-integer-flag | Integer | 1 | + | disabled-float-flag | Float | 0.1 | + | disabled-object-flag | Object | {} | diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/errors.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/errors.feature new file mode 100644 index 00000000..2ff2f54a --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/errors.feature @@ -0,0 +1,18 @@ +Feature: Evaluator error handling + + # Validates that the evaluator returns the correct error codes for + # well-known error conditions: FLAG_NOT_FOUND and TYPE_MISMATCH. + # Flags are configured in evaluator/flags/testkit-flags.json. + + Background: + Given an evaluator + + Scenario: Flag not found + Given a String-flag with key "missing-flag" and a fallback value "uh-oh" + When the flag was evaluated with details + Then the error-code should be "FLAG_NOT_FOUND" + + Scenario: Type mismatch + Given a Integer-flag with key "wrong-flag" and a fallback value "13" + When the flag was evaluated with details + Then the error-code should be "TYPE_MISMATCH" diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/evaluation.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/evaluation.feature new file mode 100644 index 00000000..701291a9 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/evaluation.feature @@ -0,0 +1,27 @@ +Feature: Evaluator basic flag evaluation + + # Validates basic static flag resolution for all supported types. + # Flags are configured in evaluator/flags/testkit-flags.json. + + Scenario Outline: Resolve basic values + Given an evaluator + And a -flag with key "" and a fallback value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: Boolean evaluations + | key | type | default | resolved_value | + | boolean-flag | Boolean | false | true | + + Examples: String evaluations + | key | type | default | resolved_value | + | string-flag | String | bye | hi | + + Examples: Number evaluations + | key | type | default | resolved_value | + | integer-flag | Integer | 1 | 10 | + | float-flag | Float | 0.1 | 0.5 | + + Examples: Object evaluations + | key | type | default | resolved_value | + | object-flag | Object | {} | {\"showImages\": true,\"title\": \"Check out these pics!\",\"imagesPerPage\": 100} | diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/evaluator-refs.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/evaluator-refs.feature new file mode 100644 index 00000000..1e2ffd4f --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/evaluator-refs.feature @@ -0,0 +1,40 @@ +Feature: Evaluator evaluator refs + + # Tests that $ref shared targeting rules work correctly. + + Scenario Outline: Evaluator reuse via $ref + Given an evaluator + And a String-flag with key "" and a fallback value "fallback" + And a context containing a key "email", with type "String" and with value "ballmer@macrosoft.com" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: + | key | value | + | some-email-targeted-flag | hi | + | some-other-email-targeted-flag | yes | + + @evaluator-refs @evaluator-refs-whitespace + Scenario Outline: Evaluator $ref resolves regardless of whitespace around the colon + Given an evaluator + And a String-flag with key "" and a fallback value "fallback" + And a context containing a key "email", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "TARGETING_MATCH" + Examples: + | key | email | value | + | ref-whitespace-compact-flag | ballmer@macrosoft.com | hi | + | ref-whitespace-space-after-colon-flag | ballmer@macrosoft.com | hi | + | ref-whitespace-space-around-colon-flag | ballmer@macrosoft.com | hi | + | ref-whitespace-compact-flag | user@example.com | bye | + | ref-whitespace-space-after-colon-flag | user@example.com | bye | + | ref-whitespace-space-around-colon-flag | user@example.com | bye | + + @evaluator-refs @non-existent-evaluator-ref + Scenario: Ref to nonexistent evaluator yields parse error + Given an evaluator + And a String-flag with key "ref-to-nonexistent-evaluator-flag" and a fallback value "fallback" + When the flag was evaluated with details + Then the resolved details value should be "fallback" + And the error-code should be "PARSE_ERROR" diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/fractional.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/fractional.feature new file mode 100644 index 00000000..a58fc373 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/fractional.feature @@ -0,0 +1,218 @@ +@fractional +Feature: Evaluator fractional operator + + # Tests the fractional bucketing operator for consistent user assignment. + # @fractional-v1: legacy float-based bucketing (abs(hash) / i32::MAX * 100) + # @fractional-v2: high-precision integer bucketing ((hash * totalWeight) >> 32) + + Background: + Given an evaluator + + Scenario Outline: Fractional operator + Given a String-flag with key "fractional-flag" and a fallback value "fallback" + And a context containing a nested property with outer key "user" and inner key "name", with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + @fractional-v1 + Examples: v1 + | name | value | + | jack | spades | + | queen | clubs | + | ten | diamonds | + | nine | hearts | + | 3 | diamonds | + + @fractional-v2 + Examples: v2 + | name | value | + | jack | hearts | + | queen | spades | + | ten | clubs | + | nine | diamonds | + | 3 | clubs | + + Scenario Outline: Fractional operator shorthand + Given a String-flag with key "fractional-flag-shorthand" and a fallback value "fallback" + And a context containing a targeting key with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + @fractional-v1 + Examples: v1 + | targeting_key | value | + | jon@company.com | heads | + | jane@company.com | tails | + + @fractional-v2 + Examples: v2 + | targeting_key | value | + | jon@company.com | heads | + | jane@company.com | tails | + + Scenario Outline: Fractional operator with shared seed + Given a String-flag with key "fractional-flag-A-shared-seed" and a fallback value "fallback" + And a context containing a nested property with outer key "user" and inner key "name", with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + @fractional-v1 + Examples: v1 + | name | value | + | jack | hearts | + | queen | spades | + | ten | hearts | + | nine | diamonds | + + @fractional-v2 + Examples: v2 + | name | value | + | seven | hearts | + | eight | diamonds | + | nine | clubs | + | two | spades | + + @fractional-v2 + Scenario: Fractional operator with single entry always resolves to the only variant + Given a String-flag with key "fractional-single-entry-flag" and a fallback value "fallback" + And a context containing a targeting key with value "some-targeting-key" + When the flag was evaluated with details + Then the resolved details value should be "single" + + Scenario Outline: Second fractional operator with shared seed + Given a String-flag with key "fractional-flag-B-shared-seed" and a fallback value "fallback" + And a context containing a nested property with outer key "user" and inner key "name", with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + @fractional-v1 + Examples: v1 + | name | value | + | jack | ace-of-hearts | + | queen | ace-of-spades | + | ten | ace-of-hearts | + | nine | ace-of-diamonds | + + @fractional-v2 + Examples: v2 + | name | value | + | seven | ace-of-hearts | + | eight | ace-of-diamonds | + | nine | ace-of-clubs | + | two | ace-of-spades | + + # Hash edge-case vectors — keys chosen by brute-force search so their + # MurmurHash3-x86-32 (seed=0) falls at the six critical boundary values. + @fractional-v2 + Scenario Outline: Fractional operator hash edge cases + Given a String-flag with key "fractional-hash-edge-flag" and a fallback value "fallback" + And a context containing a targeting key with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: + | key | value | + | ejOoVL | lower | + | bY9fO- | lower | + | SI7p- | lower | + | 6LvT0 | upper | + | ceQdGm | upper | + + # Nested JSON Logic expressions as bucket variant names / weights. + # Requires evaluator implementations to support the @fractional-nested feature. + # Use -t "not @fractional-nested" to exclude during transition. + + @fractional-nested + Scenario Outline: Fractional operator with nested if expression as variant name + # bucket0=[if(tier=="premium","premium","standard"),50], bucket1=["standard",50] + # jon@company.com bv(100)=36 → bucket0; user1 bv(100)=76 → bucket1 + Given an evaluator + And a String-flag with key "fractional-nested-if-flag" and a fallback value "fallback" + And a context containing a targeting key with value "" + And a context containing a key "tier", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + Examples: + | targetingKey | tier | value | + | jon@company.com | premium | premium | + | jon@company.com | basic | standard | + | user1 | premium | standard | + | user1 | basic | standard | + + @fractional-nested + Scenario Outline: Fractional operator with nested var expression as variant name + # bucket0=[var("color"),50], bucket1=["blue",50] + # jon@company.com bv(100)=36 → bucket0 (resolves var "color"); user1 bv(100)=76 → bucket1 ("blue") + Given an evaluator + And a String-flag with key "fractional-nested-var-flag" and a fallback value "fallback" + And a context containing a targeting key with value "" + And a context containing a key "color", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + Examples: + | targetingKey | color | value | + | jon@company.com | red | red | + | jon@company.com | green | green | + | user1 | red | blue | + | jon@company.com | yellow | fallback | + | jon@company.com | | fallback | + + @fractional-nested + Scenario Outline: Fractional operator with nested if expression as weight + # bucket0=["red",if(tier=="premium",100,0)], bucket1=["blue",10] + Given an evaluator + And a String-flag with key "fractional-nested-weight-flag" and a fallback value "fallback" + And a context containing a targeting key with value "" + And a context containing a key "tier", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + Examples: + | targetingKey | tier | value | + | jon@company.com | premium | red | + | jon@company.com | basic | blue | + | user1 | premium | red | + | user1 | basic | blue | + + @fractional-nested + Scenario: Fractional as condition + Given an evaluator + And a String-flag with key "fractional-as-condition-flag" and a fallback value "zero" + And a context containing a targeting key with value "some-targeting-key" + When the flag was evaluated with details + Then the resolved details value should be "hundreds" + + @fractional-nested + Scenario: Fractional as condition evaluates false path + Given an evaluator + And a String-flag with key "fractional-as-condition-false-flag" and a fallback value "zero" + And a context containing a targeting key with value "some-targeting-key" + When the flag was evaluated with details + Then the resolved details value should be "ones" + + @operator-errors + Scenario: fractional operator with missing bucket key falls back to default variant + Given an evaluator + And a String-flag with key "fractional-null-bucket-key-flag" and a fallback value "wrong" + When the flag was evaluated with details + Then the resolved details value should be "fallback" + And the reason should be "DEFAULT" + + # Follow-up error scenarios from https://github.com/open-feature/flagd/issues/1874 + + @operator-errors + Scenario: fractional with all-zero bucket weights falls back to default variant + Given an evaluator + And a String-flag with key "fractional-zero-weights-flag" and a fallback value "wrong" + And a context containing a targeting key with value "any-user" + When the flag was evaluated with details + Then the resolved details value should be "fallback" + And the reason should be "DEFAULT" + + @operator-errors + Scenario: fractional negative bucket weight is clamped to zero + # ["one", -50] is treated as ["one", 0]; "two" gets 100% of the weight + Given an evaluator + And a String-flag with key "fractional-negative-weight-flag" and a fallback value "wrong" + And a context containing a targeting key with value "any-user" + When the flag was evaluated with details + Then the resolved details value should be "two" diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/metadata.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/metadata.feature new file mode 100644 index 00000000..8f24e8db --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/metadata.feature @@ -0,0 +1,30 @@ +@metadata +Feature: Evaluator flag metadata + + # This test suite validates metadata handling in the Evaluator interface. + # It is associated with the flags configured in evaluator/flags/testkit-flags.json. + + Background: + Given an evaluator + + Scenario: Returns flag metadata + Given a Boolean-flag with key "metadata-flag" and a fallback value "false" + When the flag was evaluated with details + Then the resolved metadata should contain + | key | metadata_type | value | + | string | String | 1.0.2 | + | integer | Integer | 2 | + | float | Float | 0.1 | + | boolean | Boolean | true | + + Scenario Outline: Returns no metadata for flags without metadata + Given a -flag with key "" and a fallback value "" + When the flag was evaluated with details + Then the resolved metadata is empty + + Examples: + | key | flag_type | default_value | + | boolean-flag | Boolean | true | + | integer-flag | Integer | 23 | + | float-flag | Float | 2.3 | + | string-flag | String | value | diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/no-default-variant.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/no-default-variant.feature new file mode 100644 index 00000000..3844b8fe --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/no-default-variant.feature @@ -0,0 +1,23 @@ +@no-default-variant +Feature: Evaluator no-default-variant handling + + # Validates correct behavior when a flag's defaultVariant is null or undefined. + # In such cases the evaluator falls back to the code-default supplied by the caller. + # Flags are configured in evaluator/flags/testkit-flags.json. + + Scenario Outline: Resolve flag with no default variant + Given an evaluator + And a -flag with key "" and a fallback value "" + And a context containing a key "email", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "" + + Examples: + | key | type | email | code_default | resolved_value | reason | + | null-default-flag | Boolean | | true | true | DEFAULT | + | null-default-flag | Boolean | | false | false | DEFAULT | + | undefined-default-flag | Integer | | 100 | 100 | DEFAULT | + | no-default-flag-null-targeting-variant | String | wozniak@orange.com | Inventor | Inventor | DEFAULT | + | no-default-flag-null-targeting-variant | String | jobs@orange.com | CEO | CEO | TARGETING_MATCH | + | no-default-flag-undefined-targeting-variant | String | wozniak@orange.com | Retired | Retired | DEFAULT | diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/semver.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/semver.feature new file mode 100644 index 00000000..1b136f2d --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/semver.feature @@ -0,0 +1,117 @@ +@semver +Feature: Evaluator semantic version operator + + # Tests the sem_ver custom operator for semantic version comparisons. + + Background: + Given an evaluator + + Scenario Outline: Numeric comparison + Given a String-flag with key "equal-greater-lesser-version-flag" and a fallback value "fallback" + And a context containing a key "version", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: + | version | value | + | 2.0.0 | equal | + | 2.1.0 | greater | + | 1.9.0 | lesser | + | 2.0.0-alpha | lesser | + | 2.0.0.0 | none | + + Scenario Outline: Semantic comparison (minor/major range) + Given a String-flag with key "major-minor-version-flag" and a fallback value "fallback" + And a context containing a key "version", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: + | version | value | + | 3.0.1 | minor | + | 3.1.0 | major | + | 4.0.0 | none | + + @operator-errors + Scenario Outline: sem_ver returns null for invalid input and falls back to default variant + Given an evaluator + And a String-flag with key "" and a fallback value "wrong" + And a context containing a key "version", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "fallback" + And the reason should be "DEFAULT" + Examples: + | key | context_value | + | semver-invalid-version-flag | not-a-version | + | semver-invalid-operator-flag | 1.0.0 | + + @semver-edge-cases @semver-v-prefix + Scenario Outline: v-prefix handling + Given an evaluator + And a String-flag with key "semver-v-prefix-flag" and a fallback value "fallback" + And a context containing a key "version", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + Examples: + | version | value | + | 1.0.0 | match | + | v1.0.0 | match | + | V1.0.0 | match | + | 2.0.0 | no-match | + + @semver-edge-cases @semver-partial-version + Scenario Outline: partial version string handling + Given an evaluator + And a String-flag with key "semver-partial-version-flag" and a fallback value "fallback" + And a context containing a key "version", with type "" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + Examples: + | version | type | value | + | 1.5.0 | String | match | + | 1.0.0 | String | match | + | 1.0 | String | match | + | 1 | String | match | + | 1 | Integer | match | + | 1 | Float | match | + | 1.2 | Float | match | + | 2.0.0 | String | no-match | + + @semver-edge-cases @semver-numeric-context + Scenario Outline: numeric context value coercion + Given an evaluator + And a String-flag with key "semver-numeric-context-flag" and a fallback value "fallback" + And a context containing a key "version", with type "" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + Examples: + | version | type | value | + | 1.2 | Float | match | + | 1.1 | Float | no-match | + | 2 | Integer | match | + | 1 | Integer | no-match | + | 1.2 | String | match | + + @semver-edge-cases @semver-build-metadata + Scenario Outline: build metadata ignored in comparison + Given an evaluator + And a String-flag with key "semver-build-metadata-flag" and a fallback value "fallback" + And a context containing a key "version", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + Examples: + | version | value | + | 1.0.0 | match | + | 1.0.0+other | match | + | 2.0.0 | no-match | + + # Follow-up error scenarios from https://github.com/open-feature/flagd/issues/1874 + + @operator-errors + Scenario: sem_ver returns null for wrong argument count + Given an evaluator + And a String-flag with key "semver-wrong-args-flag" and a fallback value "wrong" + And a context containing a key "version", with type "String" and with value "1.0.0" + When the flag was evaluated with details + Then the resolved details value should be "fallback" + And the reason should be "DEFAULT" diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/string.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/string.feature new file mode 100644 index 00000000..7e77176d --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/string.feature @@ -0,0 +1,47 @@ +@string +Feature: Evaluator string comparison operator + + # Tests the starts_with and ends_with custom operators. + + Scenario Outline: Substring operators + Given an evaluator + And a String-flag with key "starts-ends-flag" and a fallback value "fallback" + And a context containing a key "id", with type "String" and with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + + Examples: + | id | value | + | abcdef | prefix | + | uvwxyz | postfix | + | abcxyz | prefix | + | lmnopq | none | + + # Follow-up error scenarios from https://github.com/open-feature/flagd/issues/1874 + # starts_with and ends_with must return null (not false) on error so that the + # default variant is selected, rather than looking up a non-existent "false" variant. + + @operator-errors + Scenario Outline: starts_with and ends_with return null for non-string input + Given an evaluator + And a String-flag with key "" and a fallback value "wrong" + And a context containing a key "num", with type "Integer" and with value "123" + When the flag was evaluated with details + Then the resolved details value should be "fallback" + And the reason should be "DEFAULT" + Examples: + | key | + | starts-with-non-string-flag | + | ends-with-non-string-flag | + + @operator-errors + Scenario Outline: starts_with and ends_with return null for wrong argument count + Given an evaluator + And a String-flag with key "" and a fallback value "wrong" + When the flag was evaluated with details + Then the resolved details value should be "fallback" + And the reason should be "DEFAULT" + Examples: + | key | + | starts-with-wrong-args-flag | + | ends-with-wrong-args-flag | diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/targeting.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/targeting.feature new file mode 100644 index 00000000..3722a8a8 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/targeting.feature @@ -0,0 +1,17 @@ +@targeting +Feature: Evaluator targeting + + # Tests context-based targeting rules including targeting key evaluation. + + Scenario Outline: Targeting by targeting key + Given an evaluator + And a String-flag with key "targeting-key-flag" and a fallback value "fallback" + And a context containing a targeting key with value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "" + + Examples: + | targeting_key | value | reason | + | 5c3d8535-f81a-4478-a6d3-afaa4d51199e | hit | TARGETING_MATCH | + | f20bd32d-703b-48b6-bc8e-79d53c85134a | miss | DEFAULT | diff --git a/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/zero-values.feature b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/zero-values.feature new file mode 100644 index 00000000..1ccfabe4 --- /dev/null +++ b/packages/feature-flags/tests/Conformance/testbed/evaluator/gherkin/zero-values.feature @@ -0,0 +1,73 @@ +Feature: Evaluator zero value resolution + + # Validates that zero/falsy/empty values are correctly resolved, + # both in static flags and when selected via targeting rules. + # Flags are configured in evaluator/flags/testkit-flags.json. + + Background: + Given an evaluator + + Scenario Outline: Resolve zero values statically + Given a -flag with key "" and a fallback value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "STATIC" + + Examples: Boolean evaluations + | key | type | default | resolved_value | + | boolean-zero-flag | Boolean | true | false | + + Examples: String evaluations + | key | type | default | resolved_value | + | string-zero-flag | String | hi | | + + Examples: Number evaluations + | key | type | default | resolved_value | + | integer-zero-flag | Integer | 1 | 0 | + | float-zero-flag | Float | 0.1 | 0.0 | + + Examples: Object evaluations + | key | type | default | resolved_value | + | object-zero-flag | Object | {\"a\": 1} | {} | + + @targeting + Scenario Outline: Resolve zero value with targeting match + Given a -flag with key "" and a fallback value "" + And a context containing a key "email", with type "String" and with value "ballmer@macrosoft.com" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "TARGETING_MATCH" + + Examples: Boolean evaluations + | key | type | default | resolved_value | + | boolean-targeted-zero-flag | Boolean | true | false | + + Examples: String evaluations + | key | type | default | resolved_value | + | string-targeted-zero-flag | String | hi | | + + Examples: Number evaluations + | key | type | default | resolved_value | + | integer-targeted-zero-flag | Integer | 1 | 0 | + | float-targeted-zero-flag | Float | 0.1 | 0.0 | + + @targeting + Scenario Outline: Resolve zero value using default when targeting does not match + Given a -flag with key "" and a fallback value "" + And a context containing a key "email", with type "String" and with value "ballmer@none.com" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "DEFAULT" + + Examples: Boolean evaluations + | key | type | default | resolved_value | + | boolean-targeted-zero-flag | Boolean | true | false | + + Examples: String evaluations + | key | type | default | resolved_value | + | string-targeted-zero-flag | String | hi | | + + Examples: Number evaluations + | key | type | default | resolved_value | + | integer-targeted-zero-flag | Integer | 1 | 0 | + | float-targeted-zero-flag | Float | 0.1 | 0.0 | diff --git a/packages/feature-flags/tests/Console/CliInputConformanceTest.php b/packages/feature-flags/tests/Console/CliInputConformanceTest.php new file mode 100644 index 00000000..05b7a630 --- /dev/null +++ b/packages/feature-flags/tests/Console/CliInputConformanceTest.php @@ -0,0 +1,82 @@ + $vectors['flags'], + ]; + } +} + +uses(CliInputConformanceTestCase::class); + +/** + * @return array{flags: array, action: string, key: string, cases: list, expect: array{exit: int, error?: string, value?: mixed}}>} + */ +function featureFlagsCliInputVectors(): array +{ + $contents = file_get_contents(__DIR__.'/../Conformance/cli-input-vectors.json'); + if ($contents === false) { + throw new RuntimeException('CLI input vectors could not be read'); + } + + /** @var array{flags: array, action: string, key: string, cases: list, expect: array{exit: int, error?: string, value?: mixed}}> } $vectors */ + $vectors = Json::members(Json::decode($contents)); + + return $vectors; +} + +$vectors = featureFlagsCliInputVectors(); +$cases = []; +foreach ($vectors['cases'] as $case) { + $cases[$case['name']] = [$case]; +} + +/** @param array $case */ +function featureFlagsRunCliInputVector(array $case): void +{ + $vectors = featureFlagsCliInputVectors(); + $options = $case['options'] ?? null; + $expected = $case['expect'] ?? null; + if (! Json::isObject($options) || ! is_array($expected) || ! is_int($expected['exit'] ?? null)) { + throw new RuntimeException('Invalid CLI input vector'); + } + + $arguments = ['action' => $vectors['action'], 'key' => $vectors['key'], '--json' => true]; + foreach (Json::members($options) as $name => $value) { + if (! is_string($name) || ! is_string($value)) { + throw new RuntimeException('Invalid CLI input vector option'); + } + $arguments['--'.$name] = $value; + } + + $exit = Artisan::call('firefly:flags', $arguments); + $body = Json::members(Json::decode(trim(Artisan::output()))); + + expect($exit)->toBe($expected['exit']); + if (isset($expected['error'])) { + if (! is_string($expected['error'])) { + throw new RuntimeException('Invalid CLI input vector error'); + } + expect($body['error'])->toBe($expected['error']) + ->and($body['message'])->toBeString()->not->toBeEmpty(); + } + if (array_key_exists('value', $expected)) { + expect($body['value'] ?? null)->toBe($expected['value']); + } +} + +it('consumes the CLI input vector through the Artisan command', function (array $case): void { + featureFlagsRunCliInputVector($case); +})->with($cases); diff --git a/packages/feature-flags/tests/Console/FlagsCommandTest.php b/packages/feature-flags/tests/Console/FlagsCommandTest.php new file mode 100644 index 00000000..69b9af8f --- /dev/null +++ b/packages/feature-flags/tests/Console/FlagsCommandTest.php @@ -0,0 +1,156 @@ + $arguments + * @return array{0: int, 1: string} + */ +function featureFlagsCli(array $arguments): array +{ + $exit = Artisan::call('firefly:flags', $arguments); + + return [$exit, Artisan::output()]; +} + +it('lists flags in a table and returns the management overview as JSON', function (): void { + [$exit, $table] = featureFlagsCli(['action' => 'list']); + [$jsonExit, $json] = featureFlagsCli(['action' => 'list', '--json' => true]); + + /** @var array{flags: list} $decoded */ + $decoded = Json::toPhp(Json::decode(trim($json))); + + expect([$exit, $jsonExit])->toBe([0, 0]) + ->and($table)->toContain('checkout-flow')->toContain('legacy-export')->toContain('yes') + ->and(array_column($decoded['flags'], 'key'))->toBe(['checkout-flow', 'kill-switch', 'legacy-export', 'user-audience']); +}); + +it('shows a flag and evaluates explicit context with targeting-key precedence', function (): void { + [$shownExit, $shown] = featureFlagsCli(['action' => 'show', 'key' => 'checkout-flow', '--json' => true]); + [$evalExit, $evaluated] = featureFlagsCli(['action' => 'evaluate', 'key' => 'checkout-flow', '--context' => '{"plan":"pro","targetingKey":"from-context"}', '--targeting-key' => 'from-option', '--json' => true]); + [$contextExit, $contextOutput] = featureFlagsCli(['action' => 'evaluate', 'key' => 'user-audience', '--context' => '{"targetingKey":"from-context"}', '--json' => true]); + [$optionExit, $optionOutput] = featureFlagsCli(['action' => 'evaluate', 'key' => 'user-audience', '--context' => '{"targetingKey":"from-context"}', '--targeting-key' => 'from-option', '--json' => true]); + + /** @var array{key: string, origin: string} $detail */ + $detail = Json::toPhp(Json::decode(trim($shown))); + /** @var array{value: string, reason: string} $preview */ + $preview = Json::toPhp(Json::decode(trim($evaluated))); + + expect([$shownExit, $evalExit, $contextExit, $optionExit])->toBe([0, 0, 0, 0]) + ->and($detail)->toMatchArray(['key' => 'checkout-flow', 'origin' => 'config']) + ->and($preview)->toMatchArray(['value' => 'v2', 'reason' => 'TARGETING_MATCH']) + ->and($contextOutput)->toContain('"value":"context"') + ->and($optionOutput)->toContain('"value":"option"'); +}); + +it('writes every action through the store and preserves JSON objects and floats', function (): void { + $file = tempnam(sys_get_temp_dir(), 'flag'); + expect($file)->toBeString(); + file_put_contents($file, '{"state":"ENABLED","variants":{"none":{},"promo":{"price":1.0}},"defaultVariant":"none"}'); + + try { + [$put] = featureFlagsCli(['action' => 'put', 'key' => 'banner', '--file' => $file, '--expected-version' => '0']); + [$default] = featureFlagsCli(['action' => 'default-variant', 'key' => 'banner', 'value' => 'promo', '--expected-version' => '1']); + [$disabled] = featureFlagsCli(['action' => 'disable', 'key' => 'banner', '--expected-version' => '2']); + [$enabled] = featureFlagsCli(['action' => 'enable', 'key' => 'banner', '--expected-version' => '3']); + [$show, $shown] = featureFlagsCli(['action' => 'show', 'key' => 'banner', '--json' => true]); + [$deleted, $deletedOutput] = featureFlagsCli(['action' => 'delete', 'key' => 'banner', '--expected-version' => '4']); + } finally { + unlink($file); + } + + expect([$put, $default, $disabled, $enabled, $show, $deleted])->toBe([0, 0, 0, 0, 0, 0]) + ->and($shown)->toContain('"none":{}')->toContain('"price":1.0')->toContain('"defaultVariant":"promo"')->toContain('"actor":"cli:') + ->and($deletedOutput)->toContain('Deleted [banner].'); +}); + +it('reports an accepted write with pending local visibility without claiming it is saved', function (): void { + /** @var FlagsCommandTestCase $this */ + $store = new MemoryFlagStore(FlagStores::clock()); + $events = new RecordingApplicationEventPublisher; + $registry = new FlagRegistry([new StubFlagSource('store', 400, 0.0)], new CacheBook(new Cache(new ArrayStore), new RecordingLogger), $events); + $registry->start(); + $settings = FeatureFlagsSettings::fromConfig(new Config(new Repository(['firefly' => ['feature-flags' => ['enabled' => true, 'management' => ['writes' => true]]]]))); + $management = new FlagManagement($settings, new FireflyFlagProvider($registry, new DefaultFlagdEvaluator), $registry, new FlagStoreWriter($store, $registry, $events), new EvaluationContextResolver); + $this->app()->instance(FlagManagement::class, $management); + + $file = tempnam(sys_get_temp_dir(), 'flag'); + expect($file)->toBeString(); + file_put_contents($file, '{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on"}'); + + try { + [$exit, $human] = featureFlagsCli(['action' => 'put', 'key' => 'new-human', '--file' => $file]); + [$jsonExit, $json] = featureFlagsCli(['action' => 'put', 'key' => 'new-json', '--file' => $file, '--json' => true]); + } finally { + unlink($file); + } + + expect([$exit, $jsonExit])->toBe([0, 0]) + ->and($human)->toContain('Write accepted')->toContain('Check again')->not->toContain('Saved') + ->and(trim($json))->toBe('{"key":"new-json","refreshPending":true}'); +}); + +it('refuses unreadable or malformed definition files without a write', function (string $contents, string $message): void { + $file = tempnam(sys_get_temp_dir(), 'flag'); + expect($file)->toBeString(); + file_put_contents($file, $contents); + + try { + [$exit, $output] = featureFlagsCli(['action' => 'put', 'key' => 'bad-file', '--file' => $file, '--json' => true]); + [$showExit, $showOutput] = featureFlagsCli(['action' => 'show', 'key' => 'bad-file', '--json' => true]); + } finally { + unlink($file); + } + + expect([$exit, $showExit])->toBe([1, 1]) + ->and($output)->toContain('"error":"bad-request"')->toContain($message) + ->and($showOutput)->toContain('"error":"unknown-flag"'); +})->with([ + 'invalid JSON' => ['{nope', 'not valid JSON'], + 'invalid UTF-8' => ["{\"state\":\"\xFF\"}", 'not valid JSON'], + 'array root' => ['[]', 'must contain a JSON object'], +]); + +it('returns portable JSON errors and failure status for management refusals', function (array $arguments, string $error): void { + [$exit, $output] = featureFlagsCli([...$arguments, '--json' => true]); + + /** @var array{error: string, message: string} $decoded */ + $decoded = Json::toPhp(Json::decode(trim($output))); + + expect($exit)->toBe(1)->and($decoded['error'])->toBe($error)->and($decoded['message'])->not->toBe(''); +})->with([ + 'conflict' => [['action' => 'enable', 'key' => 'kill-switch', '--expected-version' => '5'], 'conflict'], + 'unknown flag' => [['action' => 'show', 'key' => 'nope'], 'unknown-flag'], + 'missing key' => [['action' => 'enable'], 'bad-request'], + 'unknown action' => [['action' => 'rename', 'key' => 'x'], 'bad-request'], + 'bad context' => [['action' => 'evaluate', 'key' => 'kill-switch', '--context' => '{nope'], 'bad-request'], + 'empty context' => [['action' => 'evaluate', 'key' => 'kill-switch', '--context' => ''], 'bad-request'], + 'array context' => [['action' => 'evaluate', 'key' => 'kill-switch', '--context' => '[]'], 'bad-request'], + 'bad version' => [['action' => 'enable', 'key' => 'kill-switch', '--expected-version' => 'two'], 'bad-request'], + 'empty version' => [['action' => 'enable', 'key' => 'kill-switch', '--expected-version' => ''], 'bad-request'], + 'overflow version' => [['action' => 'enable', 'key' => 'kill-switch', '--expected-version' => '9223372036854775808'], 'bad-request'], + 'missing file' => [['action' => 'put', 'key' => 'x', '--file' => '/nonexistent/flag.json'], 'bad-request'], +]); diff --git a/packages/feature-flags/tests/Context/EvaluationContextResolverTest.php b/packages/feature-flags/tests/Context/EvaluationContextResolverTest.php new file mode 100644 index 00000000..29ebbd64 --- /dev/null +++ b/packages/feature-flags/tests/Context/EvaluationContextResolverTest.php @@ -0,0 +1,289 @@ + ['name' => 'orders', 'env' => 'staging'], + 'firefly' => ['profiles' => ['active' => 'staging,eu']], + ])); +} + +it('adds the application name and the active profiles', function (): void { + ProfileVariables::absent(function (): void { + $context = (new EvaluationContextResolver([featureFlagsApplicationContributor()]))->ambient(); + $unnamed = (new EvaluationContextResolver([new ApplicationEvaluationContextContributor(new Repository([ + 'app' => ['name' => '', 'env' => 'production'], + ]))]))->ambient(); + + expect($context->get('application'))->toBe('orders') + ->and($context->get('profiles'))->toBe(['staging', 'eu']) + ->and($context->targetingKey())->toBeNull() + ->and($unnamed->has('application'))->toBeFalse() + ->and($unnamed->get('profiles'))->toBe(['production']); + }); +}); + +it('runs contributors in order and lets the caller win', function (): void { + $principal = new class implements EvaluationContextContributor + { + public function contribute(EvaluationContextBuilder $context): void + { + $context->setTargetingKey('ada')->set('roles', ['beta'])->set('plan', 'free'); + } + }; + $plans = new class implements EvaluationContextContributor + { + public function contribute(EvaluationContextBuilder $context): void + { + $context->set('plan', 'pro'); + } + }; + + $resolved = (new EvaluationContextResolver([$principal, $plans]))->resolve(['roles' => ['admin'], 'region' => 'es']); + $explicitKey = (new EvaluationContextResolver([$principal]))->resolve(['targetingKey' => 'grace']); + $argumentKey = (new EvaluationContextResolver([$principal]))->resolve(['targetingKey' => 'grace'], 'linus'); + + expect($resolved->getTargetingKey())->toBe('ada') + ->and($resolved->getAttributes()->toArray())->toBe(['roles' => ['admin'], 'plan' => 'pro', 'region' => 'es']) + ->and($explicitKey->getTargetingKey())->toBe('grace') + ->and($explicitKey->getAttributes()->toArray())->not->toHaveKey('targetingKey') + ->and($argumentKey->getTargetingKey())->toBe('linus'); +}); + +it('treats an empty targeting key as none, and a later contributor may remove what an earlier one set', function (): void { + $anonymous = new class implements EvaluationContextContributor + { + public function contribute(EvaluationContextBuilder $context): void + { + $context->setTargetingKey('')->set('roles', ['beta'])->set('tier', 'gold'); + } + }; + $scrubber = new class implements EvaluationContextContributor + { + public function contribute(EvaluationContextBuilder $context): void + { + $context->remove('tier'); + } + }; + + $resolver = new EvaluationContextResolver([$anonymous, $scrubber]); + + expect($resolver->resolve()->getTargetingKey())->toBeNull() + ->and($resolver->resolve(['targetingKey' => ''])->getTargetingKey())->toBeNull() + ->and($resolver->resolve([], '')->getTargetingKey())->toBeNull() + ->and($resolver->resolve()->getAttributes()->toArray())->toBe(['roles' => ['beta']]); +}); + +it('skips a contributor that throws and keeps the others', function (): void { + $logger = new RecordingLogger; + $broken = new class implements EvaluationContextContributor + { + public function contribute(EvaluationContextBuilder $context): void + { + throw new RuntimeException('tenant service down'); + } + }; + $fine = new class implements EvaluationContextContributor + { + public function contribute(EvaluationContextBuilder $context): void + { + $context->set('plan', 'pro'); + } + }; + + $attributes = (new EvaluationContextResolver([$broken, $fine], $logger))->resolve()->getAttributes()->toArray(); + + expect($attributes)->toBe(['plan' => 'pro']) + ->and($logger->count('debug', 'tenant service down'))->toBe(1); +}); + +it('reads an int or a Stringable explicit targeting key as text, and refuses any other type with a DEBUG line', function (): void { + $logger = new RecordingLogger; + $principal = new class implements EvaluationContextContributor + { + public function contribute(EvaluationContextBuilder $context): void + { + $context->setTargetingKey('ada'); + } + }; + $resolver = new EvaluationContextResolver([$principal], $logger); + $user = new class implements Stringable + { + public function __toString(): string + { + return 'grace'; + } + }; + $broken = new class implements Stringable + { + public function __toString(): string + { + throw new RuntimeException('no id yet'); + } + }; + + expect($resolver->resolve(['targetingKey' => 42])->getTargetingKey())->toBe('42') + ->and($resolver->resolve(['targetingKey' => 42], ambient: false)->getTargetingKey())->toBe('42') + ->and($resolver->resolve(['targetingKey' => $user])->getTargetingKey())->toBe('grace') + ->and($resolver->resolve(['targetingKey' => $user], ambient: false)->getTargetingKey())->toBe('grace') + ->and($resolver->resolve(['targetingKey' => 42], 'linus')->getTargetingKey())->toBe('linus') + ->and($resolver->resolve(['targetingKey' => 42])->getAttributes()->toArray())->toBe([]) + ->and($logger->lines)->toBe([]); + + $float = $resolver->resolve(['targetingKey' => 4.2]); + $preview = $resolver->resolve(['targetingKey' => true], ambient: false); + $failing = $resolver->resolve(['targetingKey' => $broken]); + + expect($float->getTargetingKey())->toBe('ada') + ->and($float->getAttributes()->toArray())->toBe([]) + ->and($preview->getTargetingKey())->toBeNull() + ->and($failing->getTargetingKey())->toBe('ada') + ->and($logger->count('debug', 'targetingKey of type float was refused'))->toBe(1) + ->and($logger->count('debug', 'targetingKey of type bool was refused'))->toBe(1) + ->and($logger->count('debug', 'no id yet'))->toBe(1) + ->and($logger->lines)->toHaveCount(3); +}); + +it('logs one DEBUG line for each attribute it drops, naming it and why', function (): void { + $logger = new RecordingLogger; + $tier = new class implements Stringable + { + public function __toString(): string + { + return 'gold'; + } + }; + $nested = new stdClass; + $deep = [new stdClass, (object) ['x', 'y']]; + $explicit = [ + 2024 => 'leap', + 'type' => FlagType::Boolean, + 'logger' => new RecordingLogger, + 'empty' => new stdClass, + 'listy' => (object) ['a', 'b'], + 'tier' => $tier, + 'address' => (object) ['city' => 'Madrid', 'tags' => $nested], + 'deep' => $deep, + ]; + + $attributes = (new EvaluationContextResolver([], $logger))->resolve($explicit)->getAttributes()->toArray(); + + // A JSON object at the top level is kept in Json's faithful form (an array) when that form exists; `{}` and a + // list-like object have none there (as arrays they would read as lists). Below the top level nothing changes. + expect($attributes)->toBe(['tier' => 'gold', 'address' => ['city' => 'Madrid', 'tags' => $nested], 'deep' => $deep]) + ->and($logger->lines)->toHaveCount(5) + ->and($logger->count('debug', 'attribute [2024] was dropped: its name is numeric-looking'))->toBe(1) + ->and($logger->count('debug', 'attribute [type] was dropped: an enum'))->toBe(1) + ->and($logger->count('debug', 'attribute [logger] was dropped: an object of class '.RecordingLogger::class))->toBe(1) + ->and($logger->count('debug', 'attribute [empty] was dropped: an empty or list-like JSON object'))->toBe(1) + ->and($logger->count('debug', 'attribute [listy] was dropped: an empty or list-like JSON object'))->toBe(1); +}); + +it('never reads an Eloquent model or a collection as its JSON text, and still reads a UUID as its string', function (): void { + $logger = new RecordingLogger; + $user = new class extends Model {}; + $user->forceFill(['id' => 42, 'plan' => 'pro']); + $teams = new Collection(['core', 'ops']); + $uuid = Str::uuid(); + $principal = new class implements EvaluationContextContributor + { + public function contribute(EvaluationContextBuilder $context): void + { + $context->setTargetingKey('ada'); + } + }; + $resolver = new EvaluationContextResolver([$principal], $logger); + + // Both are Stringable (their JSON text), which a targeting key or an attribute would otherwise be read as. + expect((string) $user)->toBe('{"id":42,"plan":"pro"}') + ->and((string) $teams)->toBe('["core","ops"]'); + + $byModel = $resolver->resolve(['targetingKey' => $user]); + $byCollection = $resolver->resolve(['targetingKey' => $teams], ambient: false); + $byUuid = $resolver->resolve(['targetingKey' => $uuid, 'user' => $user, 'teams' => $teams, 'session' => $uuid]); + + expect($byModel->getTargetingKey())->toBe('ada') + ->and($byCollection->getTargetingKey())->toBeNull() + ->and($byUuid->getTargetingKey())->toBe($uuid->toString()) + ->and($byUuid->getAttributes()->toArray())->toBe(['session' => $uuid->toString()]) + ->and($logger->lines)->toHaveCount(4) + ->and($logger->count('debug', 'targetingKey of type '.get_debug_type($user).' was refused (an Eloquent model or a collection'))->toBe(1) + ->and($logger->count('debug', 'targetingKey of type '.Collection::class.' was refused (an Eloquent model or a collection'))->toBe(1) + ->and($logger->count('debug', 'pass $user->id'))->toBe(2) + ->and($logger->count('debug', 'attribute [user] was dropped: an Eloquent model or a collection'))->toBe(1) + ->and($logger->count('debug', 'attribute [teams] was dropped: an Eloquent model or a collection'))->toBe(1); +}); + +it('drops values OpenFeature cannot carry', function (): void { + expect((new EvaluationContextResolver)->resolve(['ok' => 1, 'object' => new stdClass, 'list' => [1]])->getAttributes()->toArray()) + ->toBe(['ok' => 1, 'list' => [1]]); +}); + +it('drops names OpenFeature cannot carry: a numeric-looking name is an int key in PHP', function (): void { + /** @var array $explicit */ + $explicit = json_decode('{"2024":"leap","plan":"pro","7":"seven"}', true, 512, JSON_THROW_ON_ERROR); + + expect((new EvaluationContextResolver)->resolve($explicit)->getAttributes()->toArray())->toBe(['plan' => 'pro']); +}); + +it('passes date-times through unconverted, as the DateTime OpenFeature carries', function (): void { + $signup = new DateTime('2025-01-01T10:00:00.123456+02:00'); + $renewal = new DateTimeImmutable('2025-06-30T23:59:59.999999-05:00'); + + $attributes = (new EvaluationContextResolver)->resolve(['signupAt' => $signup, 'renewedAt' => $renewal, 'history' => [$renewal]])->getAttributes()->toArray(); + $renewed = $attributes['renewedAt'] ?? null; + + expect($attributes['signupAt'] ?? null)->toBe($signup) + ->and($attributes['history'] ?? null)->toBe([$renewal]) + ->and($renewed)->toBeInstanceOf(DateTime::class); + assert($renewed instanceof DateTime); + expect($renewed->format('Y-m-d\TH:i:s.uP'))->toBe('2025-06-30T23:59:59.999999-05:00'); +}); + +it('keeps only the process attributes and the caller\'s context when not ambient', function (): void { + ProfileVariables::absent(function (): void { + $principal = new class implements EvaluationContextContributor + { + public int $runs = 0; + + public function contribute(EvaluationContextBuilder $context): void + { + $this->runs++; + $context->setTargetingKey('ada')->set('roles', ['admin'])->set('tenant', 'acme'); + } + }; + $resolver = new EvaluationContextResolver([featureFlagsApplicationContributor(), $principal]); + + $preview = $resolver->resolve(['plan' => 'pro'], ambient: false); + $keyed = $resolver->resolve(['targetingKey' => 'grace', 'application' => 'what-if'], ambient: false); + $argument = $resolver->resolve([], 'linus', ambient: false); + + expect($preview->getTargetingKey())->toBeNull() + ->and($preview->getAttributes()->toArray())->toBe(['application' => 'orders', 'profiles' => ['staging', 'eu'], 'plan' => 'pro']) + ->and($keyed->getTargetingKey())->toBe('grace') + ->and($keyed->getAttributes()->toArray())->toBe(['application' => 'what-if', 'profiles' => ['staging', 'eu']]) + ->and($argument->getTargetingKey())->toBe('linus') + ->and($resolver->process()->attributes())->toBe(['application' => 'orders', 'profiles' => ['staging', 'eu']]) + ->and($principal->runs)->toBe(0); + + $ambient = $resolver->resolve(); + + expect($ambient->getTargetingKey())->toBe('ada') + ->and($ambient->getAttributes()->toArray())->toBe(['application' => 'orders', 'profiles' => ['staging', 'eu'], 'roles' => ['admin'], 'tenant' => 'acme']) + ->and($principal->runs)->toBe(1); + }); +}); diff --git a/packages/feature-flags/tests/Contract/BucketVectorsTest.php b/packages/feature-flags/tests/Contract/BucketVectorsTest.php new file mode 100644 index 00000000..8531f3bf --- /dev/null +++ b/packages/feature-flags/tests/Contract/BucketVectorsTest.php @@ -0,0 +1,28 @@ +, keys: list, expect: list} $case */ + $weights = $case['weights']; + + $actual = array_map( + static fn (string $key): mixed => Fractional::evaluate(['$flagd' => ['flagKey' => $case['flagKey']], 'targetingKey' => $key], $weights) ?? $weights[0][0], + $case['keys'], + ); + + expect(count($case['keys']))->toBeGreaterThanOrEqual(300) + ->and($actual)->toBe($case['expect']); +})->with(fn (): array => FireflyVectors::cases('bucket')); + +it('reads all five bucketing tables', function (): void { + expect(FireflyVectors::cases('bucket'))->toHaveCount(5); +}); diff --git a/packages/feature-flags/tests/Contract/ComposeVectorsTest.php b/packages/feature-flags/tests/Contract/ComposeVectorsTest.php new file mode 100644 index 00000000..5d04ff01 --- /dev/null +++ b/packages/feature-flags/tests/Contract/ComposeVectorsTest.php @@ -0,0 +1,44 @@ +toBeString(); + $layers[] = [is_string($source) ? $source : '', FlagDefinitions::parseDocument([ + 'flags' => Json::object(FlagDefinitions::normalize(Json::members($members['flags'] ?? null))), + '$evaluators' => $members['$evaluators'] ?? [], + ])]; + } + + $composition = Composer::compose($layers); + $expect = Json::members($case['expect']); + $expectedFlags = Json::members($expect['flags'] ?? null); + + expect(array_map('strval', array_keys($composition->flags)))->toEqualCanonicalizing(array_map('strval', array_keys($expectedFlags))); + + foreach ($expectedFlags as $key => $flag) { + $flag = Json::members($flag); + $composed = $composition->flag((string) $key); + + expect($composed?->origin)->toBe($flag['origin'] ?? null) + ->and($composed?->overrides)->toBe($flag['overrides'] ?? null) + ->and(Json::canonical($composed?->definition->toArray()))->toBe(Json::canonical($flag['definition'] ?? null)); + } + + if (array_key_exists('evaluators', $expect)) { + expect(Json::canonical($composition->evaluators))->toBe(Json::canonical($expect['evaluators'])); + } +})->with(fn (): array => FireflyVectors::cases('compose')); + +it('runs every compose vector of the vendored contract', function (): void { + expect(FireflyVectors::cases('compose'))->toHaveCount(4); +}); diff --git a/packages/feature-flags/tests/Contract/ConformanceManifestTest.php b/packages/feature-flags/tests/Contract/ConformanceManifestTest.php new file mode 100644 index 00000000..a29326a5 --- /dev/null +++ b/packages/feature-flags/tests/Contract/ConformanceManifestTest.php @@ -0,0 +1,46 @@ +not->toBeEmpty() + ->and($actual)->toBe($expected); +}); + +it('reads a version-1 vector file whose every case has a known kind', function (): void { + $document = FireflyVectors::document(); + $kinds = array_map(static function (mixed $case): string { + $kind = Json::members($case)['kind'] ?? null; + + return is_string($kind) ? $kind : ''; + }, (array) ($document['cases'] ?? [])); + + expect($document['version'] ?? null)->toBe(1) + ->and(FireflyVectors::today())->toMatch('/^\d{4}-\d{2}-\d{2}$/') + ->and(array_values(array_diff($kinds, FireflyVectors::KINDS)))->toBe([]); + + foreach (FireflyVectors::KINDS as $kind) { + expect(FireflyVectors::cases($kind))->not->toBeEmpty(); + } +}); + +it('hands the runners every case: the per-kind datasets add up to the whole file', function (): void { + $perKind = array_map(static fn (string $kind): int => count(FireflyVectors::cases($kind)), FireflyVectors::KINDS); + + expect(array_sum($perKind))->toBe(count((array) (FireflyVectors::document()['cases'] ?? []))); +}); diff --git a/packages/feature-flags/tests/Contract/ContextVectorsTest.php b/packages/feature-flags/tests/Contract/ContextVectorsTest.php new file mode 100644 index 00000000..e77451e4 --- /dev/null +++ b/packages/feature-flags/tests/Contract/ContextVectorsTest.php @@ -0,0 +1,55 @@ +setTargetingKey($this->key); + } + }; + $logger = new RecordingLogger; + $resolved = (new EvaluationContextResolver([$ambient], $logger))->resolve($context); + + expect($resolved->getTargetingKey())->toBe($expected['targetingKey']) + ->and($resolved->getAttributes()->toArray())->toBe(Json::members($expected['attributes'])) + ->and($resolved->getAttributes()->toArray())->not->toHaveKey('targetingKey') + ->and($logger->count('debug', 'targetingKey of type'))->toBe($expected['refused'] ? 1 : 0); +})->with(static function (): array { + $document = Json::members(Json::decode((string) file_get_contents(ConformanceFiles::root().'/context-vectors.json'))); + $vectors = $document['cases'] ?? null; + if (! is_array($vectors)) { + throw new UnexpectedValueException('Context vectors must contain cases.'); + } + expect($document['version'])->toBe(1) + ->and($vectors)->toHaveCount(12); + + $cases = []; + foreach ($vectors as $case) { + $case = Json::members($case); + $name = $case['name']; + if (! is_string($name)) { + throw new UnexpectedValueException('A context vector needs a name.'); + } + $cases[$name] = [$case]; + } + + return $cases; +}); diff --git a/packages/feature-flags/tests/Contract/DefinitionVectorsTest.php b/packages/feature-flags/tests/Contract/DefinitionVectorsTest.php new file mode 100644 index 00000000..6959b88a --- /dev/null +++ b/packages/feature-flags/tests/Contract/DefinitionVectorsTest.php @@ -0,0 +1,43 @@ +toBe(Json::canonical($case['expect'])); +})->with(fn (): array => FireflyVectors::cases('normalize')); + +it('validates with the contract rules and messages', function (array $case): void { + /** @var array{valid: bool, key?: string, error?: string} $expect */ + $expect = Json::members($case['expect']); + $error = null; + + try { + $document = FlagDefinitions::parseDocument($case['input']); + } catch (InvalidFlagDefinition $caught) { + $error = $caught; + } + + if ($expect['valid']) { + expect($error)->toBeNull()->and($document ?? null)->toBeInstanceOf(FlagDocument::class); + + return; + } + + expect($error)->toBeInstanceOf(InvalidFlagDefinition::class) + ->and($error?->flagKey())->toBe($expect['key'] ?? null) + ->and($error?->getMessage())->toContain($expect['error'] ?? ''); +})->with(fn (): array => FireflyVectors::cases('validate')); + +it('lists the expired flags of a document, sorted', function (array $case): void { + $document = FlagDefinitions::parseDocument(['flags' => $case['flags']]); + + expect(FlagDefinitions::expiredKeys($document->flags, FireflyVectors::today())) + ->toBe(Json::members($case['expect'])['expired'] ?? null); +})->with(fn (): array => FireflyVectors::cases('expiry')); diff --git a/packages/feature-flags/tests/Contract/EvaluateVectorsTest.php b/packages/feature-flags/tests/Contract/EvaluateVectorsTest.php new file mode 100644 index 00000000..6c96a741 --- /dev/null +++ b/packages/feature-flags/tests/Contract/EvaluateVectorsTest.php @@ -0,0 +1,57 @@ +evaluate( + FlagDocument::fromJsonValue($case['document']), + $case['flag'], + FlagType::from($case['type']), + $case['default'], + $case['targetingKey'] ?? null, + Json::members($case['context'] ?? null), + ); + + expect(Json::canonical($resolution->value))->toBe(Json::canonical($expect['value'] ?? null)) + ->and($resolution->variant)->toBe($expect['variant'] ?? null) + ->and($resolution->reason->value)->toBe($expect['reason'] ?? null) + ->and($resolution->error?->value)->toBe($expect['errorCode'] ?? null); +})->with(fn (): array => FireflyVectors::cases('evaluate')); + +it('reads the evaluate vectors', function (): void { + expect(FireflyVectors::cases('evaluate'))->not->toBeEmpty(); +}); + +it('buckets every targeting key through the evaluator as the reference did', function (array $case): void { + /** @var array{flagKey: string, weights: list, keys: list, expect: list} $case */ + $flagKey = $case['flagKey']; + $weights = $case['weights']; + $variants = []; + foreach ($weights as [$variant]) { + $variants[$variant] = $variant; + } + $document = FlagDocument::fromJsonValue(['flags' => [$flagKey => [ + 'state' => 'ENABLED', 'variants' => $variants, 'defaultVariant' => $weights[0][0], 'targeting' => ['fractional' => $weights], + ]]]); + $evaluator = new DefaultFlagdEvaluator; + + $actual = array_map(static fn (string $key): mixed => $evaluator->evaluate($document, $flagKey, FlagType::String, 'fallback', $key)->value, $case['keys']); + + expect(count($case['keys']))->toBe(301) + ->and($actual)->toBe($case['expect']); +})->with(fn (): array => FireflyVectors::cases('bucket')); diff --git a/packages/feature-flags/tests/Contract/EvaluatorConformanceTest.php b/packages/feature-flags/tests/Contract/EvaluatorConformanceTest.php new file mode 100644 index 00000000..32982551 --- /dev/null +++ b/packages/feature-flags/tests/Contract/EvaluatorConformanceTest.php @@ -0,0 +1,22 @@ +toHaveCount(125) + ->and($loaded['skipped'])->toBe(15); +}); + +it('passes the flagd-testbed evaluator scenario', function (array $steps): void { + /** @var list $steps */ + $runner = new GherkinRunner(new DefaultFlagdEvaluator, ConformanceFiles::testkitDocument()); + + expect($runner->run($steps))->toBe([]); +})->with(fn (): array => GherkinScenarios::dataset(ConformanceFiles::gherkinDirectory())); diff --git a/packages/feature-flags/tests/Contract/ProviderConformanceTest.php b/packages/feature-flags/tests/Contract/ProviderConformanceTest.php new file mode 100644 index 00000000..6d080cc4 --- /dev/null +++ b/packages/feature-flags/tests/Contract/ProviderConformanceTest.php @@ -0,0 +1,49 @@ +setProvider(new FireflyFlagProvider(new StaticDocument(FlagDocument::fromJsonValue($case['document'])), new DefaultFlagdEvaluator)); + $client = $api->getClient('conformance', 'test'); + $targetingKey = $case['targetingKey'] ?? null; + /** @var array|null> $attributes */ + $attributes = Json::toPhp(Json::members($case['context'] ?? null)); + $context = new EvaluationContext(is_string($targetingKey) ? $targetingKey : null, new Attributes($attributes)); + $flag = $case['flag']; + $type = $case['type']; + assert(is_string($flag) && is_string($type)); + $default = Json::toPhp($case['default']); + + $details = match ($type) { + 'boolean' => $client->getBooleanDetails($flag, (bool) $default, $context), + 'string' => $client->getStringDetails($flag, is_string($default) ? $default : '', $context), + 'integer' => $client->getIntegerDetails($flag, is_int($default) ? $default : 0, $context), + 'float' => $client->getFloatDetails($flag, is_float($default) || is_int($default) ? (float) $default : 0.0, $context), + default => $client->getObjectDetails($flag, is_array($default) ? $default : [], $context), + }; + + expect(Json::canonical($details->getValue()))->toBe(Json::canonical(Json::toPhp($expect['value'] ?? null))) + ->and($details->getVariant())->toBe($expect['variant'] ?? null) + ->and($details->getReason())->toBe($expect['reason'] ?? null) + ->and($details->getError()?->getResolutionErrorCode()->getValue())->toBe($expect['errorCode'] ?? null); +})->with(fn (): array => FireflyVectors::cases('evaluate')); + +it('accepts the testbed document under Firefly\'s own validation rules', function (): void { + $raw = Json::decode((string) file_get_contents(ConformanceFiles::root().'/testbed/evaluator/flags/testkit-flags.json')); + + expect(FlagDefinitions::parseDocument($raw)->keys())->toEqual(ConformanceFiles::testkitDocument()->keys()); +}); diff --git a/packages/feature-flags/tests/Definition/FlagDefinitionsTest.php b/packages/feature-flags/tests/Definition/FlagDefinitionsTest.php new file mode 100644 index 00000000..626ad46b --- /dev/null +++ b/packages/feature-flags/tests/Definition/FlagDefinitionsTest.php @@ -0,0 +1,294 @@ + $fields + * @return array + */ +function featureFlagsBoolFlag(array $fields = []): array +{ + return [...['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off'], ...$fields]; +} + +/** + * A chain of containers exactly $levels deep around $leaf — objects (`{"x":{"x":…}}`) or lists (`[[…]]`) — built + * without recursion. + * + * @return array + */ +function featureFlagsNested(int $levels, mixed $leaf = 1, bool $lists = false): array +{ + $value = $lists ? [$leaf] : ['x' => $leaf]; + for ($level = 1; $level < $levels; $level++) { + $value = $lists ? [$value] : ['x' => $value]; + } + + return $value; +} + +it('keeps a numeric-looking string shorthand a single-variant OBJECT', function (): void { + $normalized = FlagDefinitions::normalize(['legacy' => '0']); + + expect(Json::encode($normalized))->toBe('{"legacy":{"state":"ENABLED","variants":{"0":"0"},"defaultVariant":"0"}}'); +}); + +it('accepts a numeric-looking flag key and keeps it a string', function (): void { + $document = FlagDefinitions::parseDocument(['flags' => Json::object(FlagDefinitions::normalize(['2024' => true]))]); + + expect($document->keys())->toBe(['2024']) + ->and($document->flag('2024')?->defaultVariant())->toBe('on'); +}); + +it('refuses a boolean defaultVariant, even beside variants named "true" and "false" (R-default-variant-bool)', function (): void { + $error = featureFlagsRefusal(['flags' => ['b' => ['state' => 'ENABLED', 'variants' => ['true' => 1, 'false' => 0], 'defaultVariant' => false]]]); + + expect($error?->flagKey())->toBe('b') + ->and($error?->reason())->toStartWith('defaultVariant is not a variant'); +}); + +it('accepts a defaultVariant only when it is the text name of a variant', function (array|stdClass $variants, mixed $default, bool $valid): void { + $error = featureFlagsRefusal(['flags' => ['d' => ['state' => 'ENABLED', 'variants' => $variants, 'defaultVariant' => $default]]]); + + expect($error === null)->toBe($valid) + ->and($error?->reason() ?? 'defaultVariant is not a variant')->toStartWith('defaultVariant is not a variant'); +})->with([ + 'empty text naming no variant' => [['on' => true, 'off' => false], '', false], + 'empty text naming the "" variant' => [['' => true, 'off' => false], '', true], + 'a number naming variant "1"' => [['1' => 'one', '2' => 'two'], 1, false], + 'the text "1" naming variant "1"' => [['1' => 'one', '2' => 'two'], '1', true], + 'the text "0" naming variant "0" of a list-like object' => [Json::object(['zero', 'one']), '0', true], + 'an array' => [['on' => true, 'off' => false], ['on'], false], + 'null' => [['on' => true, 'off' => false], null, true], +]); + +it('refuses a null variant value as a type mix', function (): void { + expect(fn () => FlagDefinitions::parseDocument(['flags' => ['n' => ['state' => 'ENABLED', 'variants' => ['a' => null]]]])) + ->toThrow(InvalidFlagDefinition::class, 'variants must share one type'); +}); + +it('refuses document-level rules, naming the section that broke one', function (mixed $document, string $key, string $reason): void { + $error = featureFlagsRefusal($document); + + expect($error?->flagKey())->toBe($key) + ->and($error?->reason())->toBe($reason); +})->with([ + 'document a list' => [[1, 2], '', 'document must be an object'], + 'document an empty list' => [[], '', 'document must be an object'], + 'document text' => ['flags', '', 'document must be an object'], + 'flags a list' => [['flags' => [1, 2]], 'flags', 'flags must be an object'], + 'flags false' => [['flags' => false], 'flags', 'flags must be an object'], + 'flags text' => [['flags' => 'a'], 'flags', 'flags must be an object'], + 'a flag not an object' => [['flags' => ['x' => 42]], 'x', 'flag definition must be an object'], + 'evaluators a list' => [['$evaluators' => ['x']], '$evaluators', '$evaluators must be an object'], + 'evaluators zero' => [['$evaluators' => 0], '$evaluators', '$evaluators must be an object'], + 'an evaluator not an object' => [['$evaluators' => ['beta' => 'yes']], '$evaluators', "targeting must be an object (evaluator 'beta')"], + 'an evaluator a list' => [['$evaluators' => ['ok' => ['var' => 'a'], 'beta' => ['in']]], '$evaluators', "targeting must be an object (evaluator 'beta')"], + 'an evaluator an empty list' => [['$evaluators' => ['beta' => []]], '$evaluators', "targeting must be an object (evaluator 'beta')"], + 'document metadata text' => [['metadata' => 'x'], 'metadata', 'metadata must be an object'], + 'document metadata a list' => [['metadata' => ['a']], 'metadata', 'metadata must be an object'], + 'document metadata not scalar' => [['flags' => new stdClass, 'metadata' => ['tags' => ['a']]], 'metadata', 'metadata values must be scalars'], + 'document metadata null value' => [['metadata' => ['owner' => null]], 'metadata', 'metadata values must be scalars'], + 'document metadata empty key' => [Json::decode('{"metadata":{"":"x"}}'), 'metadata', 'metadata keys must not be empty'], +]); + +it('reads an empty list where the contract expects an object as {}, and hands it on as {}: no targeting downstream', function (): void { + $document = FlagDefinitions::parseDocument(['flags' => ['el' => featureFlagsBoolFlag(['targeting' => [], 'metadata' => []])], '$evaluators' => [], 'metadata' => []]); + $flag = $document->flag('el'); + + expect($flag?->toArray()['targeting'] ?? null)->toEqual(new stdClass) + ->and($flag?->toArray()['metadata'] ?? null)->toEqual(new stdClass) + ->and($flag?->targeting())->toBeNull() + ->and($flag?->metadata())->toBe([]) + ->and($document->evaluators)->toBe([]) + ->and($document->metadata)->toBe([]) + ->and($document->toJson())->toBe('{"flags":{"el":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off","targeting":{},"metadata":{}}},"$evaluators":{},"metadata":{}}'); +}); + +it('reads empty sections as empty: absent, null or an empty list', function (array $sections): void { + $document = FlagDefinitions::parseDocument($sections); + + expect($document->flags)->toBe([]) + ->and($document->evaluators)->toBe([]) + ->and($document->metadata)->toBe([]); +})->with([ + 'flags []' => [['flags' => []]], + '$evaluators []' => [['$evaluators' => []]], + 'metadata []' => [['metadata' => []]], + 'all three []' => [['flags' => [], '$evaluators' => [], 'metadata' => []]], +]); + +it('reads an absent or null section as empty', function (string $section): void { + $document = FlagDefinitions::parseDocument([$section => null]); + + expect($document->flags)->toBe([]) + ->and($document->evaluators)->toBe([]) + ->and($document->metadata)->toBe([]); +})->with(['flags', '$evaluators', 'metadata']); + +it('reads numeric-looking metadata keys and evaluator names as text, never "not strings" (RF3)', function (): void { + $document = FlagDefinitions::parseDocument(Json::decode( + '{"flags":{"m":{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on","metadata":{"1":"one","2024":true}}},' + .'"$evaluators":{"7":{"var":"a"}},"metadata":{"2":"x"}}', + )); + + expect(array_map(static fn (int|string $name): string => (string) $name, array_keys($document->flag('m')?->metadata() ?? [])))->toBe(['1', '2024']) + ->and(array_map(static fn (int|string $name): string => (string) $name, array_keys($document->evaluators)))->toBe(['7']) + ->and(array_map(static fn (int|string $name): string => (string) $name, array_keys($document->metadata)))->toBe(['2']); +}); + +it('never meets a YAML key that is not text: Symfony Yaml refuses one when it parses, and reads on: as text', function (): void { + expect(fn () => Yaml::parse("flags: {}\nmetadata:\n true: x\n", Yaml::PARSE_OBJECT_FOR_MAP))->toThrow(ParseException::class) + ->and(fn () => Yaml::parse("flags: {}\n\$evaluators:\n null: {var: a}\n", Yaml::PARSE_OBJECT_FOR_MAP))->toThrow(ParseException::class); + + $document = FlagDefinitions::parseDocument(Json::normalize(Yaml::parse( + "flags:\n m: {state: ENABLED, variants: {'on': true}, defaultVariant: 'on', metadata: {on: x}}\n", + Yaml::PARSE_OBJECT_FOR_MAP, + ))); + + expect($document->flag('m')?->metadata())->toBe(['on' => 'x']); +}); + +it('refuses a number that is not finite anywhere in a definition, before composition could encode it', function (mixed $document, string $key, string $reason): void { + $error = featureFlagsRefusal($document); + + expect($error?->flagKey())->toBe($key) + ->and($error?->reason())->toBe($reason); +})->with([ + 'a variant' => [['flags' => ['n' => ['state' => 'ENABLED', 'variants' => ['a' => NAN, 'b' => 1.5], 'defaultVariant' => 'a']]], 'n', 'numbers must be finite'], + 'targeting' => [['flags' => ['t' => featureFlagsBoolFlag(['targeting' => ['<' => [['var' => 'x'], INF]]])]], 't', 'numbers must be finite'], + 'flag metadata' => [['flags' => ['m' => featureFlagsBoolFlag(['metadata' => ['rate' => NAN]])]], 'm', 'numbers must be finite'], + 'a field flagd does not define' => [['flags' => ['u' => featureFlagsBoolFlag(['notes' => ['weight' => -INF]])]], 'u', 'numbers must be finite'], + 'an evaluator' => [['$evaluators' => ['ok' => ['var' => 'a'], 'big' => ['>' => [['var' => 'n'], -INF]]]], '$evaluators', "numbers must be finite (evaluator 'big')"], + 'document metadata' => [['metadata' => ['rate' => INF]], 'metadata', 'numbers must be finite'], + 'YAML .nan' => [Json::normalize(Yaml::parse('flags: {y: {state: ENABLED, variants: {a: .nan, b: 1.5}, defaultVariant: a}}', Yaml::PARSE_OBJECT_FOR_MAP)), 'y', 'numbers must be finite'], + 'YAML .inf in document metadata' => [Json::normalize(Yaml::parse("flags: {}\nmetadata: {rate: .inf}", Yaml::PARSE_OBJECT_FOR_MAP)), 'metadata', 'numbers must be finite'], +]); + +it('accepts a definition 256 levels deep and refuses one 257 deep, in a flag and in an evaluator rule (R-depth-validate)', function (): void { + expect(featureFlagsRefusal(['flags' => ['deep' => featureFlagsBoolFlag(['targeting' => featureFlagsNested(255)])]]))->toBeNull() + ->and(featureFlagsRefusal(['$evaluators' => ['deep' => featureFlagsNested(256)]]))->toBeNull() + ->and(featureFlagsRefusal(['flags' => ['deep' => featureFlagsBoolFlag(['targeting' => featureFlagsNested(255, INF)])]])?->reason())->toBe('numbers must be finite'); + + $flag = featureFlagsRefusal(['flags' => ['deep' => featureFlagsBoolFlag(['targeting' => featureFlagsNested(256)])]]); + $rule = featureFlagsRefusal(['$evaluators' => ['ok' => ['var' => 'a'], 'deep' => featureFlagsNested(257)]]); + + expect([$flag?->flagKey(), $flag?->reason()])->toBe(['deep', 'definition nests too deeply']) + ->and([$rule?->flagKey(), $rule?->reason()])->toBe(['$evaluators', "definition nests too deeply (evaluator 'deep')"]); +}); + +it('refuses a definition nesting deeper than 256 levels anywhere in it', function (array $fields): void { + /** @var array $fields */ + expect(featureFlagsRefusal(['flags' => ['deep' => featureFlagsBoolFlag($fields)]])?->reason())->toBe('definition nests too deeply'); +})->with([ + 'an object-valued variant' => [['variants' => ['a' => featureFlagsNested(255)], 'defaultVariant' => 'a']], + 'targeting through lists' => [['targeting' => ['if' => featureFlagsNested(255, lists: true)]]], + 'a field flagd does not define' => [['notes' => featureFlagsNested(256)]], + 'list-like objects (stdClass)' => [['notes' => Json::decode(str_repeat('{"0":', 256).'1'.str_repeat('}', 256))]], +]); + +it('refuses a PHP-built definition 5000 levels deep without recursing', function (): void { + $error = featureFlagsRefusal(['flags' => ['deep' => featureFlagsBoolFlag(['targeting' => featureFlagsNested(5000, INF)])]]); + + expect([$error?->flagKey(), $error?->reason()])->toBe(['deep', 'definition nests too deeply']); +}); + +it('finds INF that Json::decode made of 1E400 inside an object-valued variant kept as a stdClass', function (): void { + $document = Json::decode('{"flags":{"big":{"state":"ENABLED","variants":{"a":{"0":1E400},"b":{"0":1}},"defaultVariant":"a"}}}'); + $variant = Json::members(Json::members(Json::members(Json::members($document)['flags'] ?? null)['big'] ?? null)['variants'] ?? null)['a'] ?? null; + + expect($variant)->toBeInstanceOf(stdClass::class) + ->and(featureFlagsRefusal($document)?->reason())->toBe('numbers must be finite'); +}); + +it('reports the first broken rule in the contract order PyFly checks', function (mixed $definition, string $reason): void { + expect(featureFlagsRefusal(['flags' => ['k' => $definition]])?->reason())->toBe($reason); +})->with([ + 'object before state' => ['ENABLED', 'flag definition must be an object'], + 'an empty list is not a definition' => [[], 'flag definition must be an object'], + 'state before variants' => [['state' => 'ON', 'variants' => []], 'state must be ENABLED or DISABLED'], + 'variants a scalar' => [['state' => 'ENABLED', 'variants' => 'on'], 'variants must be a non-empty object'], + 'defaultVariant before targeting' => [featureFlagsBoolFlag(['defaultVariant' => 'maybe', 'targeting' => 'x']), 'defaultVariant is not a variant'], + 'targeting text' => [featureFlagsBoolFlag(['targeting' => 'x']), 'targeting must be an object'], + 'flag metadata not an object' => [featureFlagsBoolFlag(['metadata' => 'x']), 'metadata values must be scalars'], + 'flag metadata a list' => [featureFlagsBoolFlag(['metadata' => ['a']]), 'metadata values must be scalars'], + 'scalars before keys' => [featureFlagsBoolFlag(['metadata' => Json::decode('{"":"x","tags":["a"]}')]), 'metadata values must be scalars'], + 'keys before kind' => [featureFlagsBoolFlag(['metadata' => Json::decode('{"":"x","kind":"temporary"}')]), 'metadata keys must not be empty'], + 'kind before expires' => [featureFlagsBoolFlag(['metadata' => ['kind' => 'x', 'expires' => 'soon', 'owner' => 1, 'description' => 2]]), 'kind must be one of release, experiment, ops, permission'], + 'expires before owner' => [featureFlagsBoolFlag(['metadata' => ['expires' => 'soon', 'owner' => 1, 'description' => 2]]), 'expires must be a YYYY-MM-DD date'], + 'owner before description' => [featureFlagsBoolFlag(['metadata' => ['owner' => 1, 'description' => 2]]), 'owner must be a string'], + 'too deep before not finite' => [featureFlagsBoolFlag(['variants' => ['a' => INF], 'defaultVariant' => 'a', 'targeting' => featureFlagsNested(300)]), 'definition nests too deeply'], + 'finite numbers last' => [featureFlagsBoolFlag(['variants' => ['a' => INF], 'defaultVariant' => 'a', 'metadata' => ['kind' => 'x']]), 'kind must be one of release, experiment, ops, permission'], +]); + +it('refuses a whole document when one flag breaks a rule', function (): void { + $error = featureFlagsRefusal(['flags' => ['good' => featureFlagsBoolFlag(), 'bad key' => featureFlagsBoolFlag()]]); + + expect($error?->flagKey())->toBe('bad key') + ->and($error?->getMessage())->toBe('Invalid feature flag [bad key]: invalid flag key.'); +}); + +it('accepts a $ref to a missing evaluator: evaluation reports it, loading does not', function (): void { + $document = FlagDefinitions::parseDocument(['flags' => ['r' => [ + 'state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['if' => [['$ref' => 'nope'], 'on', null]], + ]]]); + + expect($document->keys())->toBe(['r']); +}); + +it('accepts and keeps a field flagd does not define: unknown fields are ignored, never errors (RF6)', function (): void { + $document = FlagDefinitions::parseDocument(Json::decode('{"flags":{"described":{"state":"ENABLED","description":"outside metadata","rollout":{"owner":"x"},"variants":{"on":true},"defaultVariant":"on"}}}')); + + expect($document->keys())->toBe(['described']) + ->and($document->flag('described')?->toArray()['description'] ?? null)->toBe('outside metadata'); +}); + +it('keeps the evaluators and the document metadata it validated', function (): void { + $document = FlagDefinitions::parseDocument(['flags' => new stdClass, '$evaluators' => ['beta' => ['in' => ['beta', ['var' => 'roles']]]], 'metadata' => ['team' => 'web', 'rank' => 2]]); + + expect($document)->toBeInstanceOf(FlagDocument::class) + ->and($document->evaluators)->toBe(['beta' => ['in' => ['beta', ['var' => 'roles']]]]) + ->and($document->metadata)->toBe(['team' => 'web', 'rank' => 2]); +}); + +it('lists expired keys as text, sorted as text', function (): void { + $document = FlagDefinitions::parseDocument(['flags' => [ + '2024' => featureFlagsBoolFlag(['metadata' => ['expires' => '2025-01-01']]), + '10' => featureFlagsBoolFlag(['metadata' => ['expires' => '2025-01-01']]), + 'later' => featureFlagsBoolFlag(['metadata' => ['expires' => '2099-12-31']]), + ]]); + + expect(FlagDefinitions::expiredKeys($document->flags, '2026-10-01'))->toBe(['10', '2024']); +}); + +it('names the source in the message once the registry attaches it', function (): void { + $error = (new InvalidFlagDefinition('bad key', 'invalid flag key'))->fromSource('config'); + + expect($error->getMessage())->toBe('Invalid feature flag [bad key] from source [config]: invalid flag key.') + ->and($error->source())->toBe('config') + ->and($error->errorCode())->toBe('INVALID_FEATURE_FLAG'); +}); diff --git a/packages/feature-flags/tests/Definition/FlagDocumentTest.php b/packages/feature-flags/tests/Definition/FlagDocumentTest.php new file mode 100644 index 00000000..cf35ed92 --- /dev/null +++ b/packages/feature-flags/tests/Definition/FlagDocumentTest.php @@ -0,0 +1,139 @@ + $members + * @return list + */ +function featureFlagsNames(array $members): array +{ + return array_map(static fn (int|string $key): string => (string) $key, array_keys($members)); +} + +it('reads a flagd document and answers every accessor', function (): void { + $document = FlagDocument::fromJson((string) json_encode([ + 'flags' => [ + 'new-checkout' => [ + 'state' => 'ENABLED', + 'variants' => ['on' => true, 'off' => false], + 'defaultVariant' => 'off', + 'targeting' => ['if' => [['in' => ['beta', ['var' => 'roles']]], 'on', null]], + 'metadata' => ['owner' => 'payments', 'expires' => '2025-01-01'], + ], + ], + '$evaluators' => ['is-beta' => ['in' => ['beta', ['var' => 'roles']]]], + 'metadata' => ['team' => 'web'], + ])); + + $flag = $document->flag('new-checkout'); + + expect($flag)->not->toBeNull() + ->and($flag?->state())->toBe('ENABLED') + ->and($flag?->isDisabled())->toBeFalse() + ->and($flag?->variantNames())->toBe(['on', 'off']) + ->and($flag?->defaultVariant())->toBe('off') + ->and($flag?->targeting())->not->toBeNull() + ->and($flag?->metadata())->toBe(['owner' => 'payments', 'expires' => '2025-01-01']) + ->and($flag?->valueType())->toBe('boolean') + ->and($flag?->flagType())->toBe(FlagType::Boolean) + ->and($flag?->isExpired('2026-10-01'))->toBeTrue() + ->and($flag?->isExpired('2025-01-01'))->toBeFalse() + ->and($document->evaluators)->toHaveKey('is-beta') + ->and($document->metadata)->toBe(['team' => 'web']); +}); + +it('treats a missing or empty targeting as no targeting and a missing or empty default variant as none', function (): void { + $document = FlagDocument::fromJson('{"flags":{"a":{"state":"ENABLED","variants":{"true":1,"false":0},"targeting":{}},"b":{"state":"ENABLED","variants":{"x":1.5},"defaultVariant":""}}}'); + + expect($document->flag('a')?->targeting())->toBeNull() + ->and($document->flag('b')?->targeting())->toBeNull() + ->and($document->flag('a')?->defaultVariant())->toBeNull() + ->and($document->flag('a')?->flagType())->toBe(FlagType::Integer) + ->and($document->flag('b')?->defaultVariant())->toBeNull() + ->and($document->flag('b')?->flagType())->toBe(FlagType::Float); +}); + +it('keeps numeric-looking flag keys and variant names as strings', function (): void { + $document = FlagDocument::fromJson('{"flags":{"2024":{"state":"ENABLED","variants":{"0":"zero","1":"one"},"defaultVariant":"1"}}}'); + + expect($document->keys())->toBe(['2024']) + ->and($document->flag('2024')?->key)->toBe('2024') + ->and($document->flag('2024')?->variantNames())->toBe(['0', '1']) + ->and($document->flag('2024')?->variantValue('1'))->toBe('one'); +}); + +it('keys numeric-looking metadata names and fingerprinted flag keys as ints that a (string) cast restores', function (): void { + $document = FlagDocument::fromJson('{"flags":{"2024":{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on","metadata":{"1":"one","2024":"year","owner":"web"}},"1":{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on"}},"metadata":{"1":"first","2024":"launch","team":"web"}}'); + $flagMetadata = $document->flag('2024')?->metadata() ?? []; + $fingerprints = $document->keyFingerprints(); + + // PHP cannot hold "1" or "2024" as a string key: the three maps are array-key, so a consumer must cast. + $intKeys = 0; + foreach ([...array_keys($flagMetadata), ...array_keys($fingerprints), ...array_keys($document->metadata)] as $key) { + $intKeys += is_int($key) ? 1 : 0; + } + + expect($intKeys)->toBe(6) + ->and($flagMetadata['1'] ?? null)->toBe('one') + ->and($flagMetadata['2024'] ?? null)->toBe('year') + ->and(featureFlagsNames($flagMetadata))->toBe(['1', '2024', 'owner']) + ->and($fingerprints['1'] ?? null)->toMatch('/^[0-9a-f]{64}$/') + ->and($fingerprints['2024'] ?? null)->toMatch('/^[0-9a-f]{64}$/') + ->and(featureFlagsNames($fingerprints))->toBe(['1', '2024']) + ->and($document->metadata['1'] ?? null)->toBe('first') + ->and($document->metadata['2024'] ?? null)->toBe('launch') + ->and(featureFlagsNames($document->metadata))->toBe(['1', '2024', 'team']); +}); + +it('keeps non-scalar document metadata values as decoded, so validation can reject them', function (): void { + $document = FlagDocument::fromJson('{"metadata":{"n":null,"o":{"a":1},"e":{},"l":[1],"s":"x"}}'); + + expect(array_keys($document->metadata))->toBe(['n', 'o', 'e', 'l', 's']) + ->and($document->metadata['n'])->toBeNull() + ->and($document->metadata['o'])->toBe(['a' => 1]) + ->and($document->metadata['e'])->toBeInstanceOf(stdClass::class) + ->and($document->metadata['l'])->toBe([1]) + ->and($document->metadata['s'])->toBe('x') + ->and($document->toJson())->toBe('{"flags":{},"$evaluators":{},"metadata":{"n":null,"o":{"a":1},"e":{},"l":[1],"s":"x"}}'); +}); + +it('encodes PHP empty arrays in the three object positions of a definition as {}', function (): void { + $flag = new FlagDefinition('k', ['variants' => [], 'metadata' => [], 'targeting' => []]); + + expect(Json::encode($flag->toJsonValue()))->toBe('{"variants":{},"metadata":{},"targeting":{}}') + ->and((new FlagDocument(['k' => $flag]))->toJson())->toBe('{"flags":{"k":{"variants":{},"metadata":{},"targeting":{}}},"$evaluators":{},"metadata":{}}'); +}); + +it('moves the document fingerprint and that flag fingerprint, and only that one, when one value changes', function (): void { + $json = '{"flags":{"x":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off","metadata":{"owner":"web"}},"y":{"state":"ENABLED","variants":{"v":"a"},"defaultVariant":"v"}}}'; + $original = FlagDocument::fromJson($json); + $changes = [ + 'a variant value' => FlagDocument::fromJson(str_replace('"off":false', '"off":true', $json)), + 'a metadata value' => FlagDocument::fromJson(str_replace('"owner":"web"', '"owner":"payments"', $json)), + ]; + + foreach ($changes as $change => $changed) { + expect($changed->fingerprint())->not->toBe($original->fingerprint(), $change) + ->and($changed->keyFingerprints()['x'] ?? null)->not->toBe($original->keyFingerprints()['x'] ?? null, $change) + ->and($changed->keyFingerprints()['y'] ?? null)->toBe($original->keyFingerprints()['y'] ?? null, $change); + } +}); + +it('serialises empty maps as objects and fingerprints independently of member order', function (): void { + $empty = new FlagDocument; + $a = FlagDocument::fromJson('{"flags":{"x":{"state":"ENABLED","variants":{"e":{},"f":{"k":1}},"defaultVariant":"e"}}}'); + $b = FlagDocument::fromJson('{"flags":{"x":{"defaultVariant":"e","variants":{"f":{"k":1},"e":{}},"state":"ENABLED"}}}'); + + expect($empty->toJson())->toBe('{"flags":{},"$evaluators":{},"metadata":{}}') + ->and($a->toJson())->toContain('"e":{}') + ->and($a->fingerprint())->toBe($b->fingerprint()) + ->and($a->keyFingerprints())->toBe($b->keyFingerprints()); +}); diff --git a/packages/feature-flags/tests/Definition/JsonTest.php b/packages/feature-flags/tests/Definition/JsonTest.php new file mode 100644 index 00000000..bb62e71a --- /dev/null +++ b/packages/feature-flags/tests/Definition/JsonTest.php @@ -0,0 +1,74 @@ +} $decoded */ + $decoded = Json::decode('{"a": {}, "b": [], "c": {"0": "x"}, "d": {"k": 1}, "e": [{"z": {}}]}'); + + expect($decoded)->toBeArray() + ->and($decoded['a'])->toBeInstanceOf(stdClass::class) + ->and($decoded['b'])->toBe([]) + ->and($decoded['c'])->toBeInstanceOf(stdClass::class) + ->and($decoded['d'])->toBe(['k' => 1]) + ->and($decoded['e'][0]['z'])->toBeInstanceOf(stdClass::class); +}); + +it('round-trips {} and [] and keeps a float zero a float', function (): void { + $json = '{"empty":{},"list":[],"zero":0.0,"one":1,"text":"día"}'; + + expect(Json::encode(Json::decode($json)))->toBe($json); +}); + +it('canonicalises member order but never confuses {} with []', function (): void { + expect(Json::canonical(Json::decode('{"b":1,"a":{"d":[],"c":{}}}')))->toBe('{"a":{"c":{},"d":[]},"b":1}') + ->and(Json::canonical(new stdClass))->not->toBe(Json::canonical([])); +}); + +it('classifies objects and lists', function (): void { + expect(Json::isObject(new stdClass))->toBeTrue() + ->and(Json::isObject(['a' => 1]))->toBeTrue() + ->and(Json::isObject([]))->toBeFalse() + ->and(Json::isObject([1, 2]))->toBeFalse() + ->and(Json::isList(Json::decode('[]')))->toBeTrue() + ->and(Json::isList(Json::decode('[1, 2]')))->toBeTrue() + ->and(Json::isList(Json::decode('{"a": 1}')))->toBeFalse() + ->and(Json::members((object) ['x' => 1]))->toBe(['x' => 1]) + ->and(Json::members('scalar'))->toBe([]); +}); + +it('converts to plain PHP arrays for OpenFeature and forces object positions', function (): void { + expect(Json::toPhp(Json::decode('{"a":{},"b":[{"c":{}}]}')))->toBe(['a' => [], 'b' => [['c' => []]]]) + ->and(Json::object([]))->toBeInstanceOf(stdClass::class) + ->and(Json::object(['0' => 'v']))->toBeInstanceOf(stdClass::class) + ->and(Json::object(['on' => true]))->toBe(['on' => true]); +}); + +it('preserves every JSON member through decoding and canonical encoding', function (): void { + $json = <<<'JSON' + {"\u0000name":{"0":{},"nested":[[],{},1.0,{"\u0000inner":"value"}]},"_name":"ordinary","\u005fname":"last","quote\"slash\\":"text", "duplicate":1,"duplicate":2} + JSON; + $decoded = Json::decode($json); + $members = Json::members($decoded); + expect($members["\0name"] ?? null)->not->toBeNull() + ->and($members['_name'] ?? null)->toBe('last') + ->and($members['duplicate'] ?? null)->toBe(2) + ->and(Json::canonical(Json::decode(Json::encode($decoded))))->toBe(Json::canonical($decoded)) + ->and(Json::canonical(["\0name" => 1]))->toBe('{"\\u0000name":1}') + ->and(Json::canonical(["\0name" => 1]))->not->toBe(Json::canonical(["\0name" => 2])); +}); + +it('retains native malformed JSON refusals with unusual member names', function (string $json): void { + expect(fn () => Json::decode($json))->toThrow(JsonException::class); +})->with(['{"\\u0000a":}', '{"\\u0000a":1,}', '{"\\u0000a":"\\x"}']); + +it('keeps native decoding limits for large text and nesting with NUL names', function (): void { + $text = str_repeat("text\\\"\n", 20000); + $json = Json::encode(["\0name" => $text, 'deep' => [[new stdClass]], 'number' => 1.0]); + expect(Json::members(Json::decode($json))["\0name"] ?? null)->toBe($text) + ->and(Json::encode(Json::decode($json)))->toBe($json); + $tooDeep = '{"\\u0000name":'.str_repeat('[', 512).'0'.str_repeat(']', 512).'}'; + expect(fn () => Json::decode($tooDeep))->toThrow(JsonException::class); +}); diff --git a/packages/feature-flags/tests/Evaluation/DefaultFlagdEvaluatorTest.php b/packages/feature-flags/tests/Evaluation/DefaultFlagdEvaluatorTest.php new file mode 100644 index 00000000..cee47060 --- /dev/null +++ b/packages/feature-flags/tests/Evaluation/DefaultFlagdEvaluatorTest.php @@ -0,0 +1,673 @@ +": [{"var": "$flagd.timestamp"}, 1790000000]}, "late", null]}}, + "who": {"state": "ENABLED", "variants": {"me": "me", "other": "other"}, "defaultVariant": "other", + "targeting": {"if": [{"==": [{"var": "targetingKey"}, "u-1"]}, "me", null]}}, + "self-named": {"state": "ENABLED", "variants": {"self-named": "mine", "other": "other"}, "defaultVariant": "other", + "targeting": {"var": "$flagd.flagKey"}} + }, + "$evaluators": { + "staff-beta": {"and": [{"$ref": "is-staff"}, {"in": ["beta", {"var": "roles"}]}]}, + "is-staff": {"in": ["staff", {"var": "roles"}]}, + "a-alias": {"$ref": "z-target"}, + "z-target": {"in": ["staff", {"var": "roles"}]}, + "a": {"$ref": "b"}, + "b": {"$ref": "a"} + }, + "metadata": {"team": "web", "owner": "doc"} + }'); +} + +/** + * @param array $flag + * @param array $document the rest of the document + */ +function featureFlagsOneFlag(array $flag, array $document = []): FlagDocument +{ + return FlagDocument::fromJsonValue(['flags' => ['f' => $flag], ...$document]); +} + +/** + * A boolean flag that is "on" when the context attribute at $path equals $number. + */ +function featureFlagsEqualsFlag(string $path, int|float $number): FlagDocument +{ + return featureFlagsOneFlag([ + 'state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['if' => [['==' => [['var' => $path], $number]], 'on', null]], + ]); +} + +/** + * The float next to $value, one unit in the last place away from zero. + */ +function featureFlagsNeighbour(float $value): float +{ + /** @var array{1: int} $bits */ + $bits = unpack('q', pack('d', $value)); + /** @var array{1: float} $neighbour */ + $neighbour = unpack('d', pack('q', $bits[1] + 1)); + + return $neighbour[1]; +} + +/** + * @return list value, variant, reason, error + */ +function featureFlagsOutcome(Resolution $resolution): array +{ + return [$resolution->value, $resolution->variant, $resolution->reason, $resolution->error]; +} + +it('evaluates numeric-looking flag keys and variant names as strings', function (): void { + $evaluator = new DefaultFlagdEvaluator; + $document = featureFlagsEvaluatorDocument(); + + $gold = $evaluator->evaluate($document, '2024', FlagType::String, 'dflt', 'u1', ['tier' => 'gold']); + $plain = $evaluator->evaluate($document, '2024', FlagType::String, 'dflt', 'u1'); + + expect([$gold->value, $gold->variant, $gold->reason])->toBe(['one', '1', EvaluationReason::TargetingMatch]) + ->and([$plain->value, $plain->variant, $plain->reason])->toBe(['zero', '0', EvaluationReason::Default]); +}); + +it('names the "true" and "false" variants from a boolean targeting result', function (): void { + $evaluator = new DefaultFlagdEvaluator; + + expect($evaluator->evaluate(featureFlagsEvaluatorDocument(), 'named-by-bool', FlagType::String, 'x', null, ['roles' => ['beta']])->variant)->toBe('true') + ->and($evaluator->evaluate(featureFlagsEvaluatorDocument(), 'named-by-bool', FlagType::String, 'x', null, ['roles' => []])->variant)->toBe('false'); +}); + +it('names a variant by the text Python writes for a number or a list the targeting selects', function (mixed $selected, string $name): void { + $variants = [$name => 'picked', 'other' => 'other']; + $document = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => $variants, 'defaultVariant' => 'other', 'targeting' => ['var' => 'selected']]); + + $resolution = (new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::String, 'fallback', 'u', ['selected' => $selected]); + + expect(featureFlagsOutcome($resolution))->toBe(['picked', $name, EvaluationReason::TargetingMatch, null]); +})->with([ + 'an integer' => [7, '7'], + 'a whole float keeps .0' => [2.0, '2.0'], + 'a float in its shortest digits' => [0.1 + 0.2, '0.30000000000000004'], + 'a large float as an exponent' => [1e16, '1e+16'], + 'a list as its repr' => [[1, 'a', true, null], "[1, 'a', True, None]"], + 'an object as its dict repr' => [['k' => "it's"], "{'k': \"it's\"}"], +]); + +it('names a fractional bucket written as a number by its Python text', function (): void { + $document = featureFlagsOneFlag([ + 'state' => 'ENABLED', 'variants' => ['1' => 'one', '2.5' => 'two and a half'], 'defaultVariant' => '1', + 'targeting' => ['fractional' => [[1, 0], [2.5, 100]]], + ]); + + $resolution = (new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::String, 'fallback', 'any-user'); + + expect(featureFlagsOutcome($resolution))->toBe(['two and a half', '2.5', EvaluationReason::TargetingMatch, null]); +}); + +it('keeps an empty object variant an object and hands OpenFeature an array', function (): void { + $resolution = (new DefaultFlagdEvaluator)->evaluate(featureFlagsEvaluatorDocument(), 'empty-object', FlagType::Object, []); + + expect($resolution->value)->toBeInstanceOf(stdClass::class) + ->and($resolution->toResolutionDetails()->getValue())->toBe([]); +}); + +it('returns an integer variant as a float for a float request and refuses it for a boolean one', function (): void { + $evaluator = new DefaultFlagdEvaluator; + + expect($evaluator->evaluate(featureFlagsEvaluatorDocument(), 'ints', FlagType::Float, 1.0)->value)->toBe(10.0) + ->and($evaluator->evaluate(featureFlagsEvaluatorDocument(), 'ints', FlagType::Boolean, false)->error)->toBe(EvaluationError::TypeMismatch); +}); + +it('checks the requested type as the contract does: a boolean is never a number', function (array $variants, FlagType $type, mixed $default, array $expected): void { + $document = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => $variants, 'defaultVariant' => array_key_first($variants)]); + + expect(featureFlagsOutcome((new DefaultFlagdEvaluator)->evaluate($document, 'f', $type, $default)))->toBe($expected); +})->with([ + // The reference's Python reads a bool as an int (float(True) is 1.0); the contract refuses it. + 'a float request refuses a boolean variant' => [['on' => true, 'off' => false], FlagType::Float, 0.5, [0.5, null, EvaluationReason::Error, EvaluationError::TypeMismatch]], + 'a float request refuses a false variant' => [['off' => false, 'on' => true], FlagType::Float, 0.5, [0.5, null, EvaluationReason::Error, EvaluationError::TypeMismatch]], + 'a float request reads an integer variant as a float' => [['ten' => 10], FlagType::Float, 0.5, [10.0, 'ten', EvaluationReason::Static, null]], + 'an integer request refuses a float variant' => [['low' => 0.5], FlagType::Integer, 3, [3, null, EvaluationReason::Error, EvaluationError::TypeMismatch]], + 'an integer request refuses a boolean variant' => [['on' => true], FlagType::Integer, 3, [3, null, EvaluationReason::Error, EvaluationError::TypeMismatch]], + 'a boolean request refuses an integer variant' => [['one' => 1], FlagType::Boolean, false, [false, null, EvaluationReason::Error, EvaluationError::TypeMismatch]], + 'a string request refuses a number' => [['one' => 1], FlagType::String, 'x', ['x', null, EvaluationReason::Error, EvaluationError::TypeMismatch]], + 'an object request accepts a list variant' => [['pair' => [1, 2]], FlagType::Object, [], [[1, 2], 'pair', EvaluationReason::Static, null]], + 'an object request refuses text' => [['t' => 'text'], FlagType::Object, [], [[], null, EvaluationReason::Error, EvaluationError::TypeMismatch]], +]); + +it('converts the caller default of a float request and skips the type check where flagd does', function (): void { + $evaluator = new DefaultFlagdEvaluator; + $disabled = featureFlagsOneFlag(['state' => 'DISABLED', 'variants' => ['t' => 'text'], 'defaultVariant' => 't']); + $noDefault = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ['t' => 'text']]); + + // The unchecked paths answer float(default), as flagd-core and PyFly do (Python's float(True) is 1.0). + expect(featureFlagsOutcome($evaluator->evaluate($disabled, 'f', FlagType::Float, 2)))->toBe([2.0, null, EvaluationReason::Disabled, null]) + ->and(featureFlagsOutcome($evaluator->evaluate($disabled, 'f', FlagType::Float, true)))->toBe([1.0, null, EvaluationReason::Disabled, null]) + ->and(featureFlagsOutcome($evaluator->evaluate($noDefault, 'f', FlagType::Float, 2)))->toBe([2.0, null, EvaluationReason::Default, null]) + ->and(featureFlagsOutcome($evaluator->evaluate($noDefault, 'f', FlagType::Boolean, true)))->toBe([true, null, EvaluationReason::Default, null]); +}); + +it('resolves an evaluator that references another one, whatever the names sort as', function (): void { + $evaluator = new DefaultFlagdEvaluator; + $chained = $evaluator->evaluate(featureFlagsEvaluatorDocument(), 'chained', FlagType::Boolean, false, 'u', ['roles' => ['staff', 'beta']]); + $backwards = $evaluator->evaluate(featureFlagsEvaluatorDocument(), 'chained-backwards', FlagType::Boolean, false, 'u', ['roles' => ['staff']]); + + expect([$chained->value, $chained->reason])->toBe([true, EvaluationReason::TargetingMatch]) + ->and([$backwards->value, $backwards->reason])->toBe([true, EvaluationReason::TargetingMatch]); +}); + +it('stops a reference cycle and reports it as a parse error', function (): void { + $resolution = (new DefaultFlagdEvaluator)->evaluate(featureFlagsEvaluatorDocument(), 'cyclic', FlagType::Boolean, true); + + expect([$resolution->value, $resolution->error])->toBe([true, EvaluationError::ParseError]) + ->and([RefResolver::MAX_VALUES, RefResolver::MAX_DEPTH])->toBe([10_000, 128]); +}); + +it('reports a reference to no evaluator as a parse error when the rule reaches it, as flagd does', function (): void { + $evaluator = new DefaultFlagdEvaluator; + + expect(featureFlagsOutcome($evaluator->evaluate(featureFlagsEvaluatorDocument(), 'dangling', FlagType::Boolean, true))) + ->toBe([true, null, EvaluationReason::Error, EvaluationError::ParseError]) + ->and(featureFlagsOutcome($evaluator->evaluate(featureFlagsEvaluatorDocument(), 'dangling-untaken', FlagType::Boolean, false))) + ->toBe([true, 'on', EvaluationReason::TargetingMatch, null]); +}); + +it('resolves references only inside targeting and keeps every string as written', function (): void { + $document = FlagDocument::fromJson('{ + "flags": {"f": {"state": "ENABLED", "variants": {"ref": {"$ref": "path"}, "none": {}}, "defaultVariant": "none", + "targeting": {"if": [{"$ref": "path"}, "ref", null]}}}, + "$evaluators": {"path": {"==": [{"var": "dir"}, "C:\\\\temp\\\\new\\\\d"]}} + }'); + + $match = (new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::Object, [], 'u', ['dir' => 'C:\\temp\\new\\d']); + + expect(Json::canonical($match->value))->toBe('{"$ref":"path"}') + ->and([$match->variant, $match->reason])->toBe(['ref', EvaluationReason::TargetingMatch]); +}); + +it('reports a flag over the expansion limits as a parse error while the rest of the document evaluates', function (): void { + $evaluators = ['fan-0' => ['==' => [['var' => 'tier'], 'gold']]]; + for ($i = 1; $i <= 20; $i++) { + $evaluators["fan-{$i}"] = ['or' => [['$ref' => 'fan-'.($i - 1)], ['$ref' => 'fan-'.($i - 1)]]]; + } + $flag = static fn (string $ref): array => [ + 'state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['if' => [['$ref' => $ref], 'on', 'off']], + ]; + $document = FlagDocument::fromJsonValue(['flags' => ['huge' => $flag('fan-20'), 'small' => $flag('fan-5')], '$evaluators' => $evaluators]); + $evaluator = new DefaultFlagdEvaluator; + + expect(featureFlagsOutcome($evaluator->evaluate($document, 'huge', FlagType::Boolean, true, 'u', ['tier' => 'gold']))) + ->toBe([true, null, EvaluationReason::Error, EvaluationError::ParseError]) + ->and(featureFlagsOutcome($evaluator->evaluate($document, 'small', FlagType::Boolean, false, 'u', ['tier' => 'gold']))) + ->toBe([true, 'on', EvaluationReason::TargetingMatch, null]); +}); + +it('expands references without touching the document it reads', function (): void { + $document = FlagDocument::fromJson('{ + "flags": {"f": {"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "off", + "targeting": {"if": [{"$ref": "wrapped"}, "on", null]}}}, + "$evaluators": {"wrapped": {"!!": [{"0": {"$ref": "inner"}, "1": 2}]}, "inner": {"var": "x"}} + }'); + $before = $document->toJson(); + + $resolution = (new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::Boolean, false, 'u', ['x' => 1]); + + expect([$resolution->value, $resolution->reason])->toBe([true, EvaluationReason::TargetingMatch]) + ->and($document->toJson())->toBe($before); +}); + +it('injects $flagd.timestamp from the clock and targetingKey over any attribute of that name', function (): void { + $late = new DefaultFlagdEvaluator(static fn (): int => 1790000001); + $early = new DefaultFlagdEvaluator(static fn (): int => 1790000000); + + expect($late->evaluate(featureFlagsEvaluatorDocument(), 'stamped', FlagType::String, '')->value)->toBe('late') + ->and($early->evaluate(featureFlagsEvaluatorDocument(), 'stamped', FlagType::String, '')->value)->toBe('early') + ->and($late->evaluate(featureFlagsEvaluatorDocument(), 'who', FlagType::String, '', 'u-1', ['targetingKey' => 'spoofed'])->value)->toBe('me') + ->and($late->evaluate(featureFlagsEvaluatorDocument(), 'who', FlagType::String, '', null, ['targetingKey' => 'u-1'])->value)->toBe('other'); +}); + +it('injects $flagd.flagKey over any attribute of that name', function (): void { + $resolution = (new DefaultFlagdEvaluator)->evaluate(featureFlagsEvaluatorDocument(), 'self-named', FlagType::String, '', 'u', ['$flagd' => ['flagKey' => 'other']]); + + expect(featureFlagsOutcome($resolution))->toBe(['mine', 'self-named', EvaluationReason::TargetingMatch, null]); +}); + +it('merges flag metadata over document metadata on success and carries none on an error', function (): void { + $evaluator = new DefaultFlagdEvaluator; + + expect($evaluator->evaluate(featureFlagsEvaluatorDocument(), 'ints', FlagType::Integer, 0)->metadata)->toBe(['team' => 'web', 'owner' => 'doc']) + ->and($evaluator->evaluate(featureFlagsEvaluatorDocument(), 'missing', FlagType::Integer, 0)->metadata)->toBe([]) + ->and($evaluator->evaluate(featureFlagsEvaluatorDocument(), 'ints', FlagType::Boolean, false)->metadata)->toBe([]); +}); + +it('carries only the scalar document metadata, numeric-looking names included, under the flag metadata', function (): void { + $document = FlagDocument::fromJson('{ + "flags": {"f": {"state": "DISABLED", "variants": {"on": true}, "defaultVariant": "on", + "metadata": {"owner": "flag", "2024": "flag year"}}}, + "metadata": {"owner": "doc", "list": [1], "object": {"a": 1}, "nothing": null, "1": 1.5, "2024": "doc year", "on": true} + }'); + + $metadata = (new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::Boolean, false)->metadata; + + expect($metadata)->toBe(['owner' => 'flag', 1 => 1.5, 2024 => 'flag year', 'on' => true]) + ->and(array_map(static fn (int|string $name): string => (string) $name, array_keys($metadata)))->toBe(['owner', '1', '2024', 'on']); +}); + +it('ignores a field flagd does not define beside state: the flag still evaluates (RF6)', function (): void { + $document = FlagDocument::fromJson('{"flags":{"described":{"state":"ENABLED","description":"outside metadata","rollout":{"owner":"x"},"variants":{"on":true,"off":false},"defaultVariant":"on"}}}'); + + $resolution = (new DefaultFlagdEvaluator)->evaluate($document, 'described', FlagType::Boolean, false); + + expect([$resolution->value, $resolution->variant, $resolution->reason, $resolution->error])->toBe([true, 'on', EvaluationReason::Static, null]); +}); + +it('reads a defaultVariant "" as no default variant, even when a variant is named "" (R-default-empty)', function (): void { + $evaluator = new DefaultFlagdEvaluator; + $named = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ['' => 'blank', 'x' => 'ex'], 'defaultVariant' => '']); + $targeted = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ['' => 'blank', 'x' => 'ex'], 'defaultVariant' => '', 'targeting' => ['if' => [false, 'x', null]]]); + $unnamed = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ['x' => 'ex'], 'defaultVariant' => '']); + + expect(featureFlagsOutcome($evaluator->evaluate($named, 'f', FlagType::String, 'fallback')))->toBe(['fallback', null, EvaluationReason::Default, null]) + ->and(featureFlagsOutcome($evaluator->evaluate($targeted, 'f', FlagType::String, 'fallback')))->toBe(['fallback', null, EvaluationReason::Default, null]) + ->and(featureFlagsOutcome($evaluator->evaluate($unnamed, 'f', FlagType::String, 'fallback')))->toBe(['fallback', null, EvaluationReason::Default, null]); +}); + +it('answers a targeting that is not an object as flagd does (m31)', function (mixed $targeting, array $expected): void { + $document = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'on', 'targeting' => $targeting]); + + expect(featureFlagsOutcome((new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::Boolean, false, 'u')))->toBe($expected); +})->with([ + 'text' => ['on', [false, null, EvaluationReason::Error, EvaluationError::ParseError]], + 'true' => [true, [false, null, EvaluationReason::Error, EvaluationError::ParseError]], + 'a number' => [1, [false, null, EvaluationReason::Error, EvaluationError::ParseError]], + 'a list' => [[['var' => 'x']], [false, null, EvaluationReason::Error, EvaluationError::ParseError]], + // Python reads a false-y targeting as none at all. + 'false' => [false, [true, 'on', EvaluationReason::Static, null]], + 'zero' => [0, [true, 'on', EvaluationReason::Static, null]], + 'empty text' => ['', [true, 'on', EvaluationReason::Static, null]], +]); + +it('answers a dangling or null variant as flagd does', function (array $flag, FlagType $type, array $expected): void { + expect(featureFlagsOutcome((new DefaultFlagdEvaluator)->evaluate(featureFlagsOneFlag($flag), 'f', $type, 'fallback', 'u')))->toBe($expected); +})->with([ + 'a defaultVariant naming no variant' => [['state' => 'ENABLED', 'variants' => ['a' => 'A'], 'defaultVariant' => 'b'], FlagType::String, ['fallback', null, EvaluationReason::Error, EvaluationError::General]], + 'a default variant whose value is null' => [['state' => 'ENABLED', 'variants' => ['a' => null], 'defaultVariant' => 'a'], FlagType::String, ['fallback', null, EvaluationReason::Error, EvaluationError::TypeMismatch]], + 'targeting selecting a variant whose value is null' => [['state' => 'ENABLED', 'variants' => ['a' => 'A', 'n' => null], 'defaultVariant' => 'a', 'targeting' => ['if' => [true, 'n', null]]], FlagType::String, ['fallback', null, EvaluationReason::Error, EvaluationError::General]], + 'targeting selecting the empty name' => [['state' => 'ENABLED', 'variants' => ['a' => 'A', '' => 'blank'], 'defaultVariant' => 'a', 'targeting' => ['if' => [true, '', null]]], FlagType::String, ['fallback', null, EvaluationReason::Error, EvaluationError::General]], + 'targeting selecting an object' => [['state' => 'ENABLED', 'variants' => ['a' => 'A'], 'defaultVariant' => 'a', 'targeting' => ['if' => [true, ['a' => 1, 'b' => 2], null]]], FlagType::String, ['fallback', null, EvaluationReason::Error, EvaluationError::General]], +]); + +it('maps an unknown operation to PARSE_ERROR and any other rule failure to GENERAL', function (): void { + $evaluator = new DefaultFlagdEvaluator; + $unknown = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on', 'targeting' => ['nope' => [1]]]); + $failing = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on', 'targeting' => ['/' => [1, 0]]]); + $dotted = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on', 'targeting' => ['in.x' => [1]]]); + + expect(featureFlagsOutcome($evaluator->evaluate($unknown, 'f', FlagType::Boolean, false)))->toBe([false, null, EvaluationReason::Error, EvaluationError::ParseError]) + ->and(featureFlagsOutcome($evaluator->evaluate($failing, 'f', FlagType::Boolean, false)))->toBe([false, null, EvaluationReason::Error, EvaluationError::General]) + ->and(featureFlagsOutcome($evaluator->evaluate($dotted, 'f', FlagType::Boolean, false)))->toBe([false, null, EvaluationReason::Error, EvaluationError::General]); +}); + +it('never throws, whatever the document', function (mixed $document): void { + $resolution = (new DefaultFlagdEvaluator)->evaluate(FlagDocument::fromJsonValue($document), 'f', FlagType::String, 'fallback', 'u'); + + expect($resolution->value)->toBe('fallback'); +})->with([ + 'flags not an object' => [['flags' => 'f']], + 'a flag that is not an object' => [['flags' => ['f' => true]]], + 'variants not an object' => [['flags' => ['f' => ['state' => 'ENABLED', 'variants' => [1, 2], 'defaultVariant' => '0']]]], + 'evaluators not an object' => [['flags' => ['f' => ['state' => 'ENABLED', 'variants' => ['a' => 1], 'targeting' => ['$ref' => 'x']]], '$evaluators' => 'x']], + 'metadata not an object' => [['flags' => ['f' => ['state' => 'ENABLED', 'variants' => ['a' => 1], 'metadata' => 'm']], 'metadata' => [1, 2]]], + 'a weight that is text' => [['flags' => ['f' => ['state' => 'ENABLED', 'variants' => ['a' => 1], 'targeting' => ['fractional' => [['a', 'x']]]]]]], +]); + +/* + | Context date-times (CONTRACT.md "Evaluation context"): the whole microseconds since the epoch over 1000, + | correctly rounded. The rows are PyFly's (tests/feature_flags/test_context_datetimes.py) plus one past 2^53 µs, + | where PHP's own int/int division would round twice (42994951600747.586). + */ +it('evaluates a context date-time as its exact epoch milliseconds', function (DateTimeInterface $value, float $milliseconds): void { + $evaluator = new DefaultFlagdEvaluator; + + expect($evaluator->evaluate(featureFlagsEqualsFlag('t', $milliseconds), 'f', FlagType::Boolean, false, 'u', ['t' => $value])->value)->toBeTrue() + ->and($evaluator->evaluate(featureFlagsEqualsFlag('t', featureFlagsNeighbour($milliseconds)), 'f', FlagType::Boolean, false, 'u', ['t' => $value])->value)->toBeFalse(); +})->with([ + 'aware-utc' => [new DateTimeImmutable('2026-01-15 12:34:56.789123', new DateTimeZone('UTC')), 1768480496789.123], + 'aware-plus-5-30' => [new DateTimeImmutable('2026-01-15 12:34:56.789123', new DateTimeZone('+05:30')), 1768460696789.123], + 'a mutable date-time in a named zone' => [new DateTime('2026-01-15 13:34:56.789123', new DateTimeZone('Europe/Madrid')), 1768480496789.123], + 'year-2066' => [new DateTimeImmutable('2066-02-17 18:09:13.204703', new DateTimeZone('UTC')), 3033655753204.703], + 'one-microsecond-before-the-epoch' => [new DateTimeImmutable('1969-12-31 23:59:59.999999', new DateTimeZone('UTC')), -0.001], + '1960' => [new DateTimeImmutable('1960-05-17 06:07:08.123457', new DateTimeZone('UTC')), -303760371876.543], + 'midnight-1960' => [new DateTimeImmutable('1960-05-17', new DateTimeZone('UTC')), -303782400000.0], + 'midnight-2026' => [new DateTimeImmutable('2026-01-01', new DateTimeZone('UTC')), 1767225600000.0], + 'datetime-min' => [new DateTimeImmutable('0001-01-01 00:00:00', new DateTimeZone('UTC')), -62135596800000.0], + 'datetime-max (the microsecond rounds into the double)' => [new DateTimeImmutable('9999-12-31 23:59:59.999999', new DateTimeZone('UTC')), 253402300800000.0], + 'past 2^53 microseconds' => [new DateTimeImmutable('3332-06-15 18:06:40.747580', new DateTimeZone('UTC')), 42994951600747.58], +]); + +it('does not round twice past 2^53 microseconds, as integer division would', function (): void { + $naive = 42994951600747580 / 1000; + $value = new DateTimeImmutable('3332-06-15 18:06:40.747580+00:00'); + + expect($naive)->toBe(42994951600747.586) + ->and((new DefaultFlagdEvaluator)->evaluate(featureFlagsEqualsFlag('t', $naive), 'f', FlagType::Boolean, false, 'u', ['t' => $value])->value)->toBeFalse(); +}); + +it('writes a context date-time as the float Python writes for it', function (): void { + $document = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ['42994951600747.58' => 'exact', '-0.001' => 'before'], 'targeting' => ['var' => 't']]); + $evaluator = new DefaultFlagdEvaluator; + + expect($evaluator->evaluate($document, 'f', FlagType::String, '', 'u', ['t' => new DateTimeImmutable('3332-06-15 18:06:40.747580+00:00')])->variant)->toBe('42994951600747.58') + ->and($evaluator->evaluate($document, 'f', FlagType::String, '', 'u', ['t' => new DateTimeImmutable('1969-12-31 23:59:59.999999+00:00')])->variant)->toBe('-0.001'); +}); + +it('converts date-times nested in lists and objects', function (string $path, array $attributes): void { + $document = featureFlagsOneFlag([ + 'state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['if' => [['>' => [['var' => $path], 1735689600000]], 'on', null]], + ]); + + expect((new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::Boolean, false, 'u', $attributes)->reason)->toBe(EvaluationReason::TargetingMatch); +})->with([ + 'an object' => ['user.joined', ['user' => ['joined' => new DateTimeImmutable('2026-01-15 00:00:00+00:00')]]], + 'a list' => ['events.1', ['events' => [new DateTimeImmutable('2024-01-01 00:00:00+00:00'), new DateTimeImmutable('2026-01-15 00:00:00+00:00')]]], + 'a stdClass in a list in an object' => ['a.b.0.c', ['a' => ['b' => [(object) ['c' => new DateTimeImmutable('2026-01-15 00:00:00+00:00')]]]]], +]); + +it('leaves the caller context as it was', function (): void { + $joined = new DateTimeImmutable('2026-01-15 00:00:00+00:00'); + $holder = (object) ['joined' => $joined, 'n' => 1]; + $attributes = ['user' => $holder, 'tags' => ['a', $joined]]; + $document = featureFlagsOneFlag([ + 'state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['if' => [['>' => [['var' => 'user.joined'], 1735689600000]], 'on', null]], + ]); + + $resolution = (new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::Boolean, false, 'u', $attributes); + + expect($resolution->value)->toBeTrue() + ->and($attributes['user'])->toBe($holder) + ->and($holder->joined)->toBe($joined) + ->and($attributes['tags'][1])->toBe($joined) + ->and($joined->format('Y-m-d'))->toBe('2026-01-15'); +}); + +it('converts a date-time inside an object that contains itself', function (): void { + $cycle = new stdClass; + $cycle->when = new DateTimeImmutable('2026-01-15 00:00:00+00:00'); + $cycle->self = $cycle; + $document = featureFlagsOneFlag([ + 'state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['if' => [['>' => [['var' => 'cycle.self.self.when'], 1735689600000]], 'on', null]], + ]); + + $resolution = (new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::Boolean, false, 'u', ['cycle' => $cycle]); + + expect([$resolution->value, $resolution->reason])->toBe([true, EvaluationReason::TargetingMatch]) + ->and($cycle->self)->toBe($cycle) + ->and($cycle->when)->toBeInstanceOf(DateTimeImmutable::class); +}); + +/* + | The date-time walk is bounded as PyFly's is (R-L-T5-bounds; pyfly/feature_flags/provider.py + | _epoch_millis_context): containers deeper than 128 levels (the attributes are level 1) are not entered, and after + | 10 000 values the rest is left as it is. Every boundary below is what PyFly's own walk does with the same context. + */ + +/** + * Attributes whose date-time sits in the container at nesting $level (the attributes are level 1). + * + * @return array + */ +function featureFlagsDateTimeAtLevel(int $level): array +{ + $attributes = ['when' => new DateTimeImmutable('2026-01-15 00:00:00+00:00')]; + for ($i = 1; $i < $level; $i++) { + $attributes = ['in' => $attributes]; + } + + return $attributes; +} + +/** + * "on" when the context value at $path is a number after 2025-01-01T00:00:00Z in epoch milliseconds: a converted + * date-time; one left as it is reads as no number at all. + */ +function featureFlagsAfter2025(string $path): FlagDocument +{ + return featureFlagsOneFlag([ + 'state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['if' => [['>' => [['var' => $path], 1735689600000]], 'on', null]], + ]); +} + +it('enters containers down to 128 levels and no further', function (int $level, EvaluationReason $reason): void { + $path = str_repeat('in.', $level - 1).'when'; + + expect((new DefaultFlagdEvaluator)->evaluate(featureFlagsAfter2025($path), 'f', FlagType::Boolean, false, 'u', featureFlagsDateTimeAtLevel($level))->reason)->toBe($reason); +})->with([ + 'level 127' => [127, EvaluationReason::TargetingMatch], + 'level 128' => [128, EvaluationReason::TargetingMatch], + 'level 129: not entered, the date-time stays one' => [129, EvaluationReason::Default], +]); + +it('converts the first 10 000 values it reads and leaves the rest', function (int $filler, EvaluationReason $reason): void { + // The attributes, the list and its items come first: the date-time is value $filler + 3. + $attributes = ['filler' => array_fill(0, $filler, 0), 'when' => new DateTimeImmutable('2026-01-15 00:00:00+00:00')]; + + expect((new DefaultFlagdEvaluator)->evaluate(featureFlagsAfter2025('when'), 'f', FlagType::Boolean, false, 'u', $attributes)->reason)->toBe($reason); +})->with([ + 'value 10 000' => [9_997, EvaluationReason::TargetingMatch], + 'value 10 001' => [9_998, EvaluationReason::Default], +]); + +it('walks a ladder of shared arrays within the budget, whatever its paths number', function (int $rungs): void { + $ladder = ['when' => new DateTimeImmutable('2026-01-15 00:00:00+00:00')]; + for ($i = 0; $i < $rungs; $i++) { + $ladder = ['l' => $ladder, 'r' => $ladder]; + } + $evaluator = new DefaultFlagdEvaluator; + + $started = hrtime(true); + $first = $evaluator->evaluate(featureFlagsAfter2025('ladder.'.str_repeat('l.', $rungs).'when'), 'f', FlagType::Boolean, false, 'u', ['ladder' => $ladder]); + $last = $evaluator->evaluate(featureFlagsAfter2025('ladder.'.str_repeat('r.', $rungs).'when'), 'f', FlagType::Boolean, false, 'u', ['ladder' => $ladder]); + $seconds = (hrtime(true) - $started) / 1e9; + + expect([$first->reason, $last->reason])->toBe([EvaluationReason::TargetingMatch, EvaluationReason::Default]) + ->and($seconds)->toBeLessThan(1.0); +})->with([ + '2^16 paths' => [16], + '2^60 paths' => [60], +]); + +/* + | A context value that contains itself (R-L-T5-cycles). Python compares a container with itself by identity, so the + | same object is equal to itself at once; two distinct ones recurse until RecursionError, which the OpenFeature SDK + | reports as GENERAL — here a JsonLogicError at JsonLogic::MAX_DATA_DEPTH, never a PHP fatal. Every row is what the + | reference evaluator answers for the same data. + */ +it('answers a rule over a context value that contains itself as the reference does', function (array $targeting, EvaluationReason $reason, ?EvaluationError $error): void { + $a = new stdClass; + $a->self = $a; + $a->n = 1; + $b = new stdClass; + $b->self = $b; + $b->n = 1; + $list = [1]; + $list[] = &$list; + $document = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', 'targeting' => ['if' => [$targeting, 'on', null]]]); + + $resolution = (new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::Boolean, false, 'u', ['a' => $a, 'b' => $b, 'hay' => [$a], 'list' => $list]); + + expect([$resolution->reason, $resolution->error])->toBe([$reason, $error]) + ->and($resolution->value)->toBe($reason === EvaluationReason::TargetingMatch); +})->with([ + '=== of one object with itself' => [['===' => [['var' => 'a'], ['var' => 'a']]], EvaluationReason::TargetingMatch, null], + '=== of two distinct ones' => [['===' => [['var' => 'a'], ['var' => 'b']]], EvaluationReason::Error, EvaluationError::General], + '!== of two distinct ones' => [['!==' => [['var' => 'a'], ['var' => 'b']]], EvaluationReason::Error, EvaluationError::General], + '== of two distinct objects compares identity' => [['==' => [['var' => 'a'], ['var' => 'b']]], EvaluationReason::Default, null], + 'in a list holding the same object' => [['in' => [['var' => 'a'], ['var' => 'hay']]], EvaluationReason::TargetingMatch, null], + 'in a list holding a distinct one' => [['in' => [['var' => 'b'], ['var' => 'hay']]], EvaluationReason::Error, EvaluationError::General], + 'cat of a list that contains itself' => [['cat' => [['var' => 'list']]], EvaluationReason::Error, EvaluationError::General], + 'in text with such a list' => [['in' => [['var' => 'list'], 'abc']], EvaluationReason::Error, EvaluationError::General], + 'cat of the object' => [['cat' => [['var' => 'a']]], EvaluationReason::TargetingMatch, null], + 'a path through the object' => [['==' => [['var' => 'a.self.self.n'], 1]], EvaluationReason::TargetingMatch, null], +]); + +it('names a variant after a context object that contains itself as Python repr() does', function (): void { + $a = new stdClass; + $a->self = $a; + $a->n = 1; + $document = featureFlagsOneFlag(['state' => 'ENABLED', 'variants' => ["{'self': {...}, 'n': 1}" => 'cyclic', 'x' => 'x'], 'defaultVariant' => 'x', 'targeting' => ['var' => 'a']]); + + $resolution = (new DefaultFlagdEvaluator)->evaluate($document, 'f', FlagType::String, '', 'u', ['a' => $a]); + + expect(featureFlagsOutcome($resolution))->toBe(['cyclic', "{'self': {...}, 'n': 1}", EvaluationReason::TargetingMatch, null]); +}); + +/* + | A Resolution's value is the caller's own: the document's objects never leave the evaluator, so changing a + | returned value — at the top or nested, whichever path chose it — changes neither the next answer nor the document. + */ +it('hands out a copy of an object value, never the stored definition\'s', function (string $flag, mixed $context, Closure $mutate): void { + $document = FlagDocument::fromJson('{"flags": { + "static": {"state": "ENABLED", "variants": {"empty": {}, "other": {"a": 1}}, "defaultVariant": "empty"}, + "default": {"state": "ENABLED", "variants": {"nested": {"k": {}, "list": [{"0": 1}]}, "other": {"a": 1}}, "defaultVariant": "nested", + "targeting": {"if": [{"==": [{"var": "x"}, 1]}, "other", null]}}, + "matched": {"state": "ENABLED", "variants": {"a": {}, "b": {"0": "x"}}, "defaultVariant": "a", "targeting": {"if": [true, "b", null]}} + }}'); + $evaluator = new DefaultFlagdEvaluator; + $before = $document->toJson(); + $first = $evaluator->evaluate($document, $flag, FlagType::Object, [], 'u', Json::members($context)); + $expected = Json::canonical($first->value); + + $mutate($first->value); + + expect(Json::canonical($evaluator->evaluate($document, $flag, FlagType::Object, [], 'u', Json::members($context))->value))->toBe($expected) + ->and($document->toJson())->toBe($before); +})->with([ + 'STATIC, an empty object' => ['static', null, static function (mixed $value): void { + assert($value instanceof stdClass); + $value->injected = 'evil'; + }], + 'DEFAULT, an object nested in the value' => ['default', null, static function (mixed $value): void { + assert(is_array($value) && $value['k'] instanceof stdClass); + $value['k']->injected = 'evil'; + }], + 'DEFAULT, an object inside a list in the value' => ['default', null, static function (mixed $value): void { + assert(is_array($value) && is_array($value['list']) && $value['list'][0] instanceof stdClass); + $value['list'][0]->{'1'} = 'evil'; + }], + 'TARGETING_MATCH, a list-like object' => ['matched', null, static function (mixed $value): void { + assert($value instanceof stdClass); + $value->{'1'} = 'y'; + }], +]); + +/* + | The expanded targeting of each flag is kept per document (a WeakMap on the document object): a document's + | references are expanded once, another document instance never reads a stale expansion, and a document no one + | holds any more is released. + */ + +/** + * @return WeakMap> + */ +function featureFlagsExpansionMemo(DefaultFlagdEvaluator $evaluator): WeakMap +{ + /** @var WeakMap> $memo */ + $memo = (new ReflectionProperty(DefaultFlagdEvaluator::class, 'expansions'))->getValue($evaluator); + + return $memo; +} + +it('expands a document\'s targeting once and answers the same every time', function (): void { + $evaluator = new DefaultFlagdEvaluator; + $document = featureFlagsEvaluatorDocument(); + $context = ['roles' => ['staff', 'beta']]; + + $answers = []; + for ($i = 0; $i < 3; $i++) { + foreach (['chained', 'chained-backwards', 'cyclic', 'dangling', '2024', 'ints'] as $flag) { + $answers[$i][$flag] = featureFlagsOutcome($evaluator->evaluate($document, $flag, FlagType::Boolean, false, 'u', $context)); + } + } + $memo = featureFlagsExpansionMemo($evaluator); + + expect($answers[1])->toBe($answers[0]) + ->and($answers[2])->toBe($answers[0]) + ->and(count($memo))->toBe(1) + ->and(array_map(static fn (int|string $key): string => (string) $key, array_keys($memo[$document])))->toBe(['chained', 'chained-backwards', 'cyclic', 'dangling', '2024']); +}); + +it('never serves one document\'s expansion to another document instance', function (): void { + $evaluator = new DefaultFlagdEvaluator; + $document = static fn (string $role): FlagDocument => FlagDocument::fromJsonValue([ + 'flags' => ['f' => ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', 'targeting' => ['if' => [['$ref' => 'segment'], 'on', null]]]], + '$evaluators' => ['segment' => ['in' => [$role, ['var' => 'roles']]]], + ]); + $staff = $document('staff'); + $beta = $document('beta'); + + $forStaff = $evaluator->evaluate($staff, 'f', FlagType::Boolean, false, 'u', ['roles' => ['staff']]); + $forBeta = $evaluator->evaluate($beta, 'f', FlagType::Boolean, false, 'u', ['roles' => ['staff']]); + + expect([$forStaff->value, $forStaff->reason])->toBe([true, EvaluationReason::TargetingMatch]) + ->and([$forBeta->value, $forBeta->reason])->toBe([false, EvaluationReason::Default]) + ->and(count(featureFlagsExpansionMemo($evaluator)))->toBe(2); + + unset($staff); + gc_collect_cycles(); + + expect(count(featureFlagsExpansionMemo($evaluator)))->toBe(1); +}); + +it('memoizes targeting by the looked-up document key', function (): void { + $first = FlagDefinition::fromJsonValue('same', ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', 'targeting' => ['if' => [true, 'on', 'off']]]); + $second = FlagDefinition::fromJsonValue('same', ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', 'targeting' => ['if' => [false, 'on', 'off']]]); + $document = new FlagDocument(['first' => $first, 'second' => $second]); + $evaluator = new DefaultFlagdEvaluator; + + expect($evaluator->evaluate($document, 'first', FlagType::Boolean, false)->value)->toBeTrue() + ->and($evaluator->evaluate($document, 'second', FlagType::Boolean, true)->value)->toBeFalse(); +}); diff --git a/packages/feature-flags/tests/Evaluation/FlagdEvaluatorConfigurationTest.php b/packages/feature-flags/tests/Evaluation/FlagdEvaluatorConfigurationTest.php new file mode 100644 index 00000000..4bb8cf82 --- /dev/null +++ b/packages/feature-flags/tests/Evaluation/FlagdEvaluatorConfigurationTest.php @@ -0,0 +1,34 @@ + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider)); + +it('wires the in-process flagd evaluator when feature flags are enabled', function (): void { + $context = bootFireflyApp( + ['firefly' => ['feature-flags' => ['enabled' => true]]], + [FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + needs: ['cache'], + ); + + expect($context->get(FlagdEvaluator::class))->toBeInstanceOf(DefaultFlagdEvaluator::class); +}); + +it('registers no evaluator while feature flags are disabled', function (): void { + $app = fireflyApplication( + ['firefly' => []], + [FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + needs: ['cache'], + ); + + expect($app->bound(FlagdEvaluator::class))->toBeFalse(); +}); diff --git a/packages/feature-flags/tests/Evaluation/FlagdOperatorsTest.php b/packages/feature-flags/tests/Evaluation/FlagdOperatorsTest.php new file mode 100644 index 00000000..6ec3db06 --- /dev/null +++ b/packages/feature-flags/tests/Evaluation/FlagdOperatorsTest.php @@ -0,0 +1,282 @@ + $args */ + expect(SemVer::evaluate([], $args))->toBe($expected); +})->with([ + 'equal' => [['1.2.3', '=', '1.2.3'], true], + 'prerelease below release' => [['1.2.3-alpha', '<', '1.2.3'], true], + 'numeric identifier below alphanumeric' => [['1.0.0-alpha.1', '<', '1.0.0-alpha.beta'], true], + 'numeric identifiers compare as numbers' => [['1.0.0-beta.11', '>', '1.0.0-beta.2'], true], + 'shorter prerelease first' => [['1.0.0-alpha', '<', '1.0.0-alpha.1'], true], + 'v prefix dropped' => [['v2.4.0', '>=', '2.4.0'], true], + 'caret is same major' => [['2', '^', '2.9.1'], true], + 'tilde is same major and minor' => [['2.1', '~', '2.1.7'], true], + 'tilde refuses another minor' => [['2.2', '~', '2.1.7'], false], + 'build metadata ignored' => [['1.0.0+build.1', '=', '1.0.0+build.2'], true], + 'leading zero is not semver' => [['01.0.0', '=', '1.0.0'], null], + 'integer padded' => [['1.0', '=', 1], true], + 'float stringified' => [[1.5, '<', '1.10'], true], + 'unknown operator' => [['1.0.0', '===', '1.0.0'], null], + 'four parts do not parse' => [['2.0.0.0', '=', '2.0.0'], null], + 'not equal' => [['1.0.0', '!=', '1.0.1'], true], + 'less or equal' => [['1.0.0', '<=', '1.0.0'], true], + 'boolean is not a version' => [[true, '=', '1.0.0'], null], + 'null is not a version' => [[null, '=', '1.0.0'], null], + 'two arguments' => [['1.0.0', '='], null], +]); + +/* + | The reference reads a number as str() of it, so a float is Python's repr — not PHP's string cast, which + | rounds to 14 digits (0.1 + 0.2 → "0.3") and writes 1e15 as "1.0E+15" — and an exponent never parses. + */ +it('reads a number as the version Python writes it', function (array $args, ?bool $expected): void { + /** @var list $args */ + expect(SemVer::evaluate([], $args))->toBe($expected); +})->with([ + 'all seventeen digits' => [[0.1 + 0.2, '=', '0.30000000000000004'], true], + 'not rounded to fourteen' => [[0.1 + 0.2, '=', '0.3'], false], + 'whole float gains .0' => [[1.0, '=', '1'], true], + 'zero' => [[0.0, '=', '0.0.0'], true], + 'zero integer' => [[0, '=', '0.0.0'], true], + 'fifteen digits stay fixed' => [[123456789012345.0, '=', '123456789012345'], true], + '1e15 stays fixed' => [[1e15, '=', '1000000000000000'], true], + 'sixteen significant digits' => [[999999999999999.9, '=', '999999999999999.9'], true], + '1e16 is an exponent' => [[1e16, '=', '10000000000000000'], null], + 'small is an exponent' => [[1e-5, '=', '1'], null], + 'smallest fixed has a leading zero minor' => [[0.0001, '=', '0.0001'], null], + 'negative' => [[-1.5, '=', '1.5'], null], + 'negative zero' => [[-0.0, '=', '0'], null], + 'negative integer' => [[-1, '<', '0'], null], + 'infinity' => [[INF, '=', '1'], null], + 'not a number' => [[NAN, '=', '1'], null], +]); + +it('parses versions exactly as python-semver does', function (array $args, ?bool $expected): void { + /** @var list $args */ + expect(SemVer::evaluate([], $args))->toBe($expected); +})->with([ + 'majors beyond 64 bits' => [['100000000000000000000000000001.0.0', '>', '100000000000000000000000000000.0.0'], true], + 'just beyond 64 bits' => [['18446744073709551616.0.0', '>', '18446744073709551615.0.0'], true], + 'hyphens inside a prerelease' => [['1.0.0-a-b', '<', '1.0.0-a-c'], true], + 'hyphen right after the patch' => [['1.2.3-4-5', '=', '1.2.3-4-5'], true], + 'hyphen in build metadata' => [['1.0.0+b-1.2', '=', '1.0.0'], true], + 'prerelease and build' => [['1.0.0-beta+exp.sha.5114f85', '=', '1.0.0-beta'], true], + 'empty prerelease' => [['1.0.0-', '=', '1.0.0'], null], + 'empty build' => [['1.0.0+', '=', '1.0.0'], null], + 'leading zero numeric prerelease' => [['1.0.0-01', '=', '1.0.0'], null], + 'leading zeros before a letter' => [['1.0.0-00a', '=', '1.0.0-00a'], true], + 'empty prerelease identifier' => [['1.0.0-a..b', '=', '1.0.0'], null], + 'second plus' => [['1.2.3+4+5', '=', '1.2.3'], null], + 'trailing newline' => [["1.0.0\n", '=', '1.0.0'], null], + 'leading space' => [[' 1.0.0', '=', '1.0.0'], null], + 'empty minor' => [['1..0', '=', '1.0.0'], null], + 'v alone' => [['v', '=', '1.0.0'], null], + 'one v only' => [['vv1.0.0', '=', '1.0.0'], null], + 'capital V' => [['V1', '=', '1.0.0'], true], + 'major with a prerelease padded' => [['1-alpha', '=', '1.0.0-alpha'], true], + 'major with build padded' => [['1+build', '=', '1.0.0'], true], + 'major and minor with a prerelease' => [['1.2-rc.1', '<', '1.2.0'], true], + 'major and minor with build' => [['1.2+3', '=', '1.2.0'], true], + 'padding before a dotted prerelease' => [['1.2-3.4', '=', '1.2.0-3.4'], true], + 'hyphen alone after the major' => [['1-', '=', '1.0.0'], null], + 'fullwidth digit' => [['1.0.0', '=', '1.0.0'], null], + 'arabic-indic digit in a prerelease' => [['1.0.0-١', '=', '1.0.0'], null], + 'numeric before alphanumeric with a digit' => [['1.0.0-0a', '>', '1.0.0-0'], true], + 'letter above number' => [['1.0.0-a', '>', '1.0.0-1'], true], + 'ASCII order' => [['1.0.0-A', '<', '1.0.0-a'], true], + 'longer text above its prefix' => [['1.0.0-alpha', '>', '1.0.0-alph'], true], + 'more identifiers above fewer' => [['1.0.0-a.b.c.d', '>', '1.0.0-a.b.c'], true], + 'caret ignores a prerelease' => [['1.0.0-rc.1', '^', '1.5.0'], true], + 'tilde ignores a prerelease' => [['1.2.3-rc.1', '~', '1.2.0'], true], + 'operator not text' => [['1.0.0', 1, '1.0.0'], null], + 'a list' => [[[1, 2], '=', '1'], null], + 'four arguments' => [['1.0.0', '=', '1.0.0', 'x'], null], + 'no arguments' => [[], null], +]); + +it('reads a long version as Python does, with no backtracking limit to hit', function (): void { + // python-semver's own pattern under PCRE gives up (preg_match() === false, JIT stack exhausted) on the first. + $identifiers = str_repeat('a.', 100_000).'a'; + $digits = str_repeat('9', 200_000); + + expect(SemVer::evaluate([], ["1.0.0-{$identifiers}", '<', '1.0.0']))->toBeTrue() + ->and(SemVer::evaluate([], ["1.0.0-{$digits}a", '<', '1.0.0']))->toBeTrue() + ->and(SemVer::evaluate([], ["1.0.0-{$digits}!", '<', '1.0.0']))->toBeNull(); +}); + +it('stops where Python stops converting digits to an int (4300 digits)', function (): void { + $fits = str_repeat('9', 4300); + $over = str_repeat('9', 4301); + + // A major, minor or patch is int() at parse time: the ValueError makes sem_ver null. + expect(SemVer::evaluate([], ["{$fits}.0.0", '>', '1.0.0']))->toBeTrue() + ->and(SemVer::evaluate([], ["{$over}.0.0", '>', '1.0.0']))->toBeNull() + ->and(SemVer::evaluate([], ["1.{$over}.0", '>', '1.0.0']))->toBeNull() + ->and(SemVer::evaluate([], ["1.0.{$over}", '>', '1.0.0']))->toBeNull() + // A numeric prerelease identifier is int() only when two prereleases are compared, outside the reference's + // try: the ValueError fails the rule. ^ and ~ never compare prereleases, nor do different cores. + ->and(SemVer::evaluate([], ["1.0.0-{$fits}", '>', '1.0.0-1']))->toBeTrue() + ->and(SemVer::evaluate([], ["1.0.0-{$over}", '^', '1.0.0']))->toBeTrue() + ->and(SemVer::evaluate([], ["1.0.0-{$over}", '~', '1.0.0']))->toBeTrue() + ->and(SemVer::evaluate([], ["1.0.0-{$over}", '=', '1.0.1']))->toBeFalse() + ->and(SemVer::evaluate([], ["1.0.0-{$over}a", '<', '1.0.0']))->toBeTrue() + ->and(SemVer::evaluate([], ["1.0.0+{$over}", '=', '1.0.0']))->toBeTrue() + ->and(fn (): ?bool => SemVer::evaluate([], ["1.0.0-{$over}", '=', '1.0.0']))->toThrow(JsonLogicError::class) + ->and(fn (): ?bool => SemVer::evaluate([], ['1.0.0', '=', "1.0.0-{$over}"]))->toThrow(JsonLogicError::class) + ->and(fn (): ?bool => SemVer::evaluate([], ["1.0.0-x.{$over}", '<', '1.0.0-y']))->toThrow(JsonLogicError::class); +}); + +it('matches prefixes and suffixes of two strings only', function (array $args, ?bool $starts, ?bool $ends): void { + /** @var list $args */ + expect(StringOps::startsWith([], $args))->toBe($starts) + ->and(StringOps::endsWith([], $args))->toBe($ends); +})->with([ + [['abcdef', 'abc'], true, false], + [['abcdef', 'xyz'], false, false], + [[123, '1'], null, null], + [['abc'], null, null], + [['abc', 'a', 'b'], null, null], + [['', ''], true, true], + [['abc', ''], true, true], + [['José', 'Jo'], true, false], + [['José', 'é'], false, true], + [['abc', 1], null, null], + [['abc', null], null, null], + [[['a'], 'a'], null, null], + [[], null, null], +]); + +it('buckets like fractional-v2', function (array $args, mixed $expected): void { + /** @var list $args */ + $data = ['$flagd' => ['flagKey' => 'exp'], 'targetingKey' => 'user-42']; + + expect(Fractional::evaluate($data, $args))->toBe($expected); +})->with([ + 'flag key + targeting key' => [[['a', 50], ['b', 50]], 'b'], + 'explicit bucket key' => [['custom-seed', ['a', 50], ['b', 50]], 'a'], + 'weight defaults to 1' => [[['a'], ['b', 1]], 'b'], + 'all-zero weights' => [[['a', 0], ['b', 0]], null], + 'negative weight clamps to 0' => [[['a', -5], ['b', 10]], 'b'], + 'float weight refused' => [[['a', 1.5], ['b', 1]], null], + 'boolean weight refused' => [[['a', true], ['b', 1]], null], + 'empty bucket refused' => [[[]], null], + 'empty bucket key' => [['', ['a', 1]], null], + 'null bucket key falls back and then refuses itself' => [[null, ['a', 1]], null], + 'total over 2^31-1' => [[['a', 2147483647], ['b', 1]], null], + 'single entry' => [[['only']], 'only'], + 'total of exactly 2^31-1' => [[['a', 2147483647]], 'a'], + 'weights summing past PHP_INT_MAX' => [[['a', PHP_INT_MAX], ['b', PHP_INT_MAX]], null], + 'bucket key without buckets' => [['key'], null], + 'no arguments' => [[], null], + 'three-item bucket refused' => [[['a', 1, 2]], null], + 'object bucket refused' => [[['a' => 1]], null], + 'number before the buckets refused' => [[5, ['a', 1]], null], + 'variant of any type' => [[[true, 1]], true], + 'null variant' => [[[null, 1]], null], + 'list variant' => [[[[1, 2], 1]], [1, 2]], +]); + +it('computes the bucket exactly at the hash and weight extremes', function (array $args, string $expected): void { + /** @var list $args */ + // (hash * total) >> 32 stays an int: hash < 2^32 and total <= 2^31 - 1, so the product is below 2^63. + expect(Fractional::evaluate([], $args))->toBe($expected); +})->with([ + 'highest hash, largest total' => [['ceQdGm', ['a', 2147483646], ['b', 1]], 'b'], + 'highest hash, one bucket' => [['ceQdGm', ['a', 2147483647]], 'a'], + 'hash 2^31-1, largest total' => [['SI7p-', ['a', 2147483646], ['b', 1]], 'a'], + 'hash 2^31-1 is the lower half' => [['SI7p-', ['a', 1], ['b', 1]], 'a'], + 'hash 2^31 is the upper half' => [['6LvT0', ['a', 1], ['b', 1]], 'b'], + 'hash 0' => [['ejOoVL', ['a', 1], ['b', 2147483646]], 'a'], +]); + +it('yields null without a targeting key, so the default variant applies', function (mixed $targetingKey): void { + expect(Fractional::evaluate(['$flagd' => ['flagKey' => 'exp'], 'targetingKey' => $targetingKey], [['a', 1]]))->toBeNull(); +})->with([[null], [''], [0], [false], [[]]]); + +it('reads a bucket key only from an object, as the reference does', function (mixed $data, mixed $expected): void { + expect(Fractional::evaluate($data, [['a', 1]]))->toBe($expected); +})->with([ + 'an empty object' => [new stdClass, null], + 'an empty PHP array, which counts as {}' => [[], null], + 'a targeting key alone' => [['targetingKey' => 'u'], 'a'], + 'an empty $flagd' => [['$flagd' => new stdClass, 'targetingKey' => 'u'], 'a'], + 'a flag key that is not text, without a targeting key' => [['$flagd' => ['flagKey' => 5]], null], + 'two numbers adding to zero (Python adds them)' => [['$flagd' => ['flagKey' => -1], 'targetingKey' => 1], null], +]); + +it('fails where the reference raises', function (mixed $data): void { + expect(fn (): mixed => Fractional::evaluate($data, [['a', 1]]))->toThrow(JsonLogicError::class); +})->with([ + 'no data' => [null], + 'a number' => [1], + 'a list' => [[[1]]], + '$flagd null' => [['$flagd' => null, 'targetingKey' => 'u']], + '$flagd null without a targeting key' => [['$flagd' => null]], + '$flagd a list' => [['$flagd' => ['x'], 'targetingKey' => 'u']], + 'flag key not text' => [['$flagd' => ['flagKey' => 5], 'targetingKey' => 'u']], + 'flag key null' => [['$flagd' => ['flagKey' => null], 'targetingKey' => 'u']], + 'targeting key not text' => [['targetingKey' => 5]], + 'targeting key a list' => [['targetingKey' => [1]]], + 'two numbers' => [['$flagd' => ['flagKey' => 1], 'targetingKey' => 1]], +]); + +it('never reads the data when the bucket key is explicit', function (): void { + expect(Fractional::evaluate(1, ['seed', ['a', 1]]))->toBe('a') + ->and(Fractional::evaluate(null, ['seed', ['a', 1]]))->toBe('a'); +}); + +it('reads the unsigned murmur3 hash mmh3 returns', function (string $key, int $hash): void { + // The boundary keys of the flagd-testbed hash-edge scenario; every number is mmh3.hash(key, signed=False). + expect(Fractional::hash($key))->toBe($hash); +})->with([ + 'empty' => ['', 0], + 'a' => ['a', 1009084850], + 'hash 0' => ['ejOoVL', 0], + 'hash 1' => ['bY9fO-', 1], + 'hash 2^31-1' => ['SI7p-', 2147483647], + 'hash 2^31' => ['6LvT0', 2147483648], + 'hash 2^32-1' => ['ceQdGm', 4294967295], + 'UTF-8 bytes' => ['José', 3422137460], + 'flag key + targeting key' => ['expuser-42', 2205093680], +]); + +it('registers the four flagd operators on the engine', function (): void { + $logic = FlagdOperators::jsonLogic(); + + expect($logic->apply(Json::decode('{"sem_ver":["2.0.1",">","2.0.0"]}')))->toBeTrue() + ->and($logic->apply(Json::decode('{"starts_with":[{"var":"id"},"abc"]}'), ['id' => 'abcdef']))->toBeTrue() + ->and($logic->apply(Json::decode('{"ends_with":[{"var":"id"},"xyz"]}'), ['id' => 'abcxyz']))->toBeTrue() + ->and($logic->apply(Json::decode('{"fractional":[["only",1]]}'), ['$flagd' => ['flagKey' => 'f'], 'targetingKey' => 'u']))->toBe('only'); +}); + +it('evaluates the arguments before an operator sees them', function (): void { + $logic = FlagdOperators::jsonLogic(); + $data = ['$flagd' => ['flagKey' => 'f'], 'targetingKey' => 'u', 'tier' => 'premium', 'version' => 2]; + + expect($logic->apply(Json::decode('{"fractional":[{"var":"targetingKey"},[{"var":"tier"},1]]}'), $data))->toBe('premium') + ->and($logic->apply(Json::decode('{"sem_ver":[{"var":"version"},"^","2.9.1"]}'), $data))->toBeTrue(); +}); + +it('makes the rule fail where the reference raises inside it', function (): void { + // `some` hands each item to the operator as its data; an item that is not an object has no targeting key + // to read, and Python's data.get() raises (GENERAL), where answering null would pick the other branch. + expect(fn (): mixed => FlagdOperators::jsonLogic()->apply( + Json::decode('{"if":[{"some":[[1],{"fractional":[["on",1]]}]},"a","b"]}'), + ['$flagd' => ['flagKey' => 'f'], 'targetingKey' => 'u'], + ))->toThrow(JsonLogicError::class); +}); diff --git a/packages/feature-flags/tests/Evaluation/JsonLogicTest.php b/packages/feature-flags/tests/Evaluation/JsonLogicTest.php new file mode 100644 index 00000000..12599bcd --- /dev/null +++ b/packages/feature-flags/tests/Evaluation/JsonLogicTest.php @@ -0,0 +1,449 @@ + ['{"var":"user.name"}', '{"user":{"name":"ana"}}', '"ana"'], + 'var list index' => ['{"var":"profiles.0"}', '{"profiles":["staging","eu"]}', '"staging"'], + 'var numeric key on list' => ['{"var":1}', '["a","b"]', '"b"'], + 'var length of list' => ['{"var":"roles.length"}', '{"roles":["a","b","c"]}', '3'], + 'var length of string' => ['{"var":"name.length"}', '{"name":"José"}', '4'], + 'var missing with default' => ['{"var":["plan","free"]}', '{}', '"free"'], + 'var empty key returns data' => ['{"var":""}', '{"a":1}', '{"a":1}'], + 'var leading zero index' => ['{"var":"xs.01"}', '{"xs":[1,2]}', 'null'], + 'var char of string' => ['{"var":"name.1"}', '{"name":"José"}', '"o"'], + 'missing' => ['{"missing":["a","b","c"]}', '{"a":1,"b":""}', '["b","c"]'], + 'missing_some ok' => ['{"missing_some":[1,["a","b"]]}', '{"a":1}', '[]'], + 'missing_some short' => ['{"missing_some":[2,["a","b","c"]]}', '{"a":1}', '["b","c"]'], + 'if chain' => ['{"if":[false,"a",null,"b","c"]}', '{}', '"c"'], + 'if two args false' => ['{"if":[false,"a"]}', '{}', 'null'], + 'if empty' => ['{"if":[]}', '{}', 'null'], + 'ternary' => ['{"?:":[true,"y","n"]}', '{}', '"y"'], + 'loose eq number string' => ['{"==":[1,"1"]}', '{}', 'true'], + 'loose eq bool number' => ['{"==":[true,1]}', '{}', 'true'], + 'loose eq null null' => ['{"==":[null,null]}', '{}', 'true'], + 'loose eq null zero' => ['{"==":[null,0]}', '{}', 'true'], + 'loose eq string list' => ['{"==":["a,b",["a","b"]]}', '{}', 'true'], + 'loose eq list list' => ['{"==":[[1],[1]]}', '{}', 'false'], + 'strict eq int float' => ['{"===":[1,1.0]}', '{}', 'true'], + 'strict eq true one' => ['{"===":[true,1]}', '{}', 'true'], + 'strict eq string number' => ['{"===":["1",1]}', '{}', 'false'], + 'strict ne' => ['{"!==":["1",1]}', '{}', 'true'], + 'not empty list' => ['{"!":[[]]}', '{}', 'true'], + 'double not string zero' => ['{"!!":["0"]}', '{}', 'true'], + 'double not empty object' => ['{"!!":[{}]}', '{}', 'true'], + 'and returns first falsy' => ['{"and":[1,0,2]}', '{}', '0'], + 'and returns last' => ['{"and":[1,"a"]}', '{}', '"a"'], + 'or returns first truthy' => ['{"or":[0,"","x"]}', '{}', '"x"'], + 'or returns last' => ['{"or":[0,false]}', '{}', 'false'], + 'lt number string' => ['{"<":[1,"2"]}', '{}', 'true'], + 'lt strings' => ['{"<":["apple","banana"]}', '{}', 'true'], + 'lt string number numeric' => ['{"<":["10",9]}', '{}', 'false'], + 'between' => ['{"<":[1,2,3]}', '{}', 'true'], + 'between false' => ['{"<=":[1,4,3]}', '{}', 'false'], + 'gte' => ['{">=":[3,3]}', '{}', 'true'], + 'gt null' => ['{">":[1,null]}', '{}', 'true'], + 'max' => ['{"max":[1,3,2]}', '{}', '3'], + 'min strings' => ['{"min":["3",1]}', '{}', '1'], + 'plus ints' => ['{"+":[1,2]}', '{}', '3'], + 'plus string' => ['{"+":["1",2]}', '{}', '3.0'], + 'plus bool' => ['{"+":[true,2]}', '{}', '3'], + 'times' => ['{"*":[2,"3"]}', '{}', '6.0'], + 'minus unary' => ['{"-":[5]}', '{}', '-5'], + 'minus binary' => ['{"-":[5,7]}', '{}', '-2'], + 'divide ints' => ['{"/":[6,3]}', '{}', '2.0'], + 'modulo negative' => ['{"%":[-7,3]}', '{}', '2'], + 'modulo float' => ['{"%":[7.5,-2]}', '{}', '-0.5'], + 'in list' => ['{"in":["beta",["alpha","beta"]]}', '{}', 'true'], + 'in list loose number' => ['{"in":[1,[1.0,2]]}', '{}', 'true'], + 'in string' => ['{"in":["sp","spring"]}', '{}', 'true'], + 'in string number' => ['{"in":[1,"a1b"]}', '{}', 'true'], + 'in neither' => ['{"in":["a",5]}', '{}', 'false'], + 'cat mixed' => ['{"cat":["v",1,2.5,true,null,[1,2]]}', '{}', '"v12.5Truenull1,2"'], + 'cat float whole' => ['{"cat":[1.0,"|",1e+20,"|",0.0001,"|",1e-05]}', '{}', '"1|1e+20|0.0001|1e-05"'], + 'substr neg' => ['{"substr":["jsonlogic",-5]}', '{}', '"logic"'], + 'substr len' => ['{"substr":["jsonlogic",1,3]}', '{}', '"son"'], + 'substr neg len' => ['{"substr":["jsonlogic",4,-2]}', '{}', '"log"'], + 'merge' => ['{"merge":[[1,2],3,[[4]]]}', '{}', '[1,2,3,[4]]'], + 'map' => ['{"map":[{"var":"xs"},{"*":[{"var":""},2]}]}', '{"xs":[1,2,3]}', '[2,4,6]'], + 'filter' => ['{"filter":[{"var":"xs"},{">":[{"var":""},1]}]}', '{"xs":[1,2,3]}', '[2,3]'], + 'reduce' => ['{"reduce":[{"var":"xs"},{"+":[{"var":"current"},{"var":"accumulator"}]},10]}', '{"xs":[1,2,3]}', '16'], + 'reduce not list' => ['{"reduce":[{"var":"nope"},{"+":[1,1]},7]}', '{}', '7'], + 'all empty' => ['{"all":[[],{">":[{"var":""},0]}]}', '{}', 'false'], + 'all' => ['{"all":[[1,2],{">":[{"var":""},0]}]}', '{}', 'true'], + 'some' => ['{"some":[[0,2],{">":[{"var":""},1]}]}', '{}', 'true'], + 'none' => ['{"none":[[0,1],{">":[{"var":""},1]}]}', '{}', 'true'], + 'multi key object is data' => ['{"a":1,"b":2}', '{}', '{"a":1,"b":2}'], + 'empty object is truthy' => ['{"if":[{},"yes","no"]}', '{}', '"yes"'], + 'empty list is falsy' => ['{"if":[[],"yes","no"]}', '{}', '"no"'], + 'log returns arg' => ['{"log":["x"]}', '{}', '"x"'], + 'to number infinity string' => ['{">":["infinity",1e+300]}', '{}', 'true'], + 'to number underscores' => ['{"+":["1_000",0]}', '{}', '1000.0'], +]); + +/* + | Edges the plan's table does not reach, each checked against the same reference engine: places where a + | straight PHP port would answer differently (Python keeps a boolean through min/max, compares an int with a + | float exactly, signs a zero remainder like the divisor, reads Unicode digits and spaces in float()) or would + | raise a PHP warning instead of answering (a float index far outside the int range). + */ +dataset('reference json logic edges', [ + 'max of booleans keeps a boolean' => ['{"max":[true,false]}', '{}', 'true'], + 'min of a boolean before an equal number' => ['{"min":[false,0]}', '{}', 'false'], + 'max of a one-item boolean list' => ['{"max":[[true],0]}', '{}', 'true'], + 'max keeps the first of equals' => ['{"max":[1,true]}', '{}', '1'], + 'modulo zero takes the divisor sign' => ['{"%":[4.0,-2]}', '{}', '-0.0'], + 'modulo zero with a positive divisor' => ['{"%":[-4.0,2]}', '{}', '0.0'], + 'modulo ints both negative' => ['{"%":[-7,-3]}', '{}', '-1'], + 'modulo int by float' => ['{"%":[7,2.5]}', '{}', '2.0'], + 'divide to a fraction' => ['{"/":[7,2]}', '{}', '3.5'], + 'var numeric key on an object is the default' => ['{"var":[1,"d"]}', '{"1":"x"}', '"d"'], + 'var digit text key on an object' => ['{"var":"1"}', '{"1":"x"}', '"x"'], + 'var boolean key on a list' => ['{"var":true}', '["a","b"]', '"b"'], + 'var fractional key' => ['{"var":1.5}', '["a","b"]', 'null'], + 'var whole float key' => ['{"var":1.0}', '["a","b"]', '"b"'], + 'var huge float key' => ['{"var":[1e300,"d"]}', '["a"]', '"d"'], + 'var length member of an object' => ['{"var":"a.length"}', '{"a":{"length":9}}', '9'], + 'var through null' => ['{"var":["a.b","d"]}', '{"a":null}', '"d"'], + 'substr huge index' => ['{"substr":["abc",1e300]}', '{}', '""'], + 'substr huge negative index' => ['{"substr":["abc",-1e300]}', '{}', '"abc"'], + 'substr minus infinity index' => ['{"substr":["abc","-infinity"]}', '{}', '"abc"'], + 'substr huge length' => ['{"substr":["abc",1,1e300]}', '{}', '"bc"'], + 'substr huge negative length' => ['{"substr":["abc",1,-1e300]}', '{}', '""'], + 'substr counts code points' => ['{"substr":["José!",3,1]}', '{}', '"é"'], + 'loose eq beyond 2^53' => ['{"==":[9007199254740993,9007199254740992.0]}', '{}', 'false'], + 'lt beyond 2^53' => ['{"<":[9007199254740992.0,9007199254740993]}', '{}', 'true'], + 'strict eq beyond 2^53' => ['{"===":[9007199254740993,9007199254740992.0]}', '{}', 'false'], + 'to number unicode digits' => ['{"+":["٤٢",0]}', '{}', '42.0'], + 'to number unicode spaces' => ['{"+":[" 7 ",0]}', '{}', '7.0'], + 'to number inner space' => ['{"cat":[{"+":["1 000",0]}]}', '{}', '"nan"'], + 'to number full grammar' => ['{"cat":[{"+":[" +1_0.5e1 ",0]}]}', '{}', '"105"'], + 'loose eq null false' => ['{"==":[null,false]}', '{}', 'true'], + 'loose eq empty list empty string' => ['{"==":[[],""]}', '{}', 'true'], + 'loose eq two empty objects' => ['{"==":[{},{}]}', '{}', 'false'], + 'cat nested booleans' => ['{"cat":[[true,[false]]]}', '{}', '"True,False"'], + 'cat object' => ['{"cat":[{"var":""}]}', '{}', '"[object Object]"'], + 'cat fifteen significant digits' => ['{"cat":[1e15,"|",1e16,"|",123456789012345678,"|",0.1,"|",-0.0,"|",5e-324]}', '{}', '"1e+15|1e+16|123456789012345678|0.1|-0|4.94065645841247e-324"'], + 'cat infinite remainder' => ['{"cat":[{"%":[-5,"infinity"]}]}', '{}', '"inf"'], + 'in list python equality' => ['{"in":[true,[1]]}', '{}', 'true'], + 'strict eq deep python equality' => ['{"===":[{"var":"x"},{"var":"y"}]}', '{"x":[1,{"a":true}],"y":[1.0,{"a":1}]}', 'true'], + 'reduce initial is not evaluated' => ['{"reduce":[[1],{"var":"accumulator"},{"var":"x"}]}', '{"x":5}', '{"var":"x"}'], + 'minus with a null second argument negates' => ['{"-":[1,null]}', '{}', '-1'], + 'minus of nothing' => ['{"-":[]}', '{}', '0'], + 'missing_some null need' => ['{"missing_some":[null,["a"]]}', '{}', '[]'], + 'lt null text' => ['{"<":[null,"a"]}', '{}', 'false'], + 'lt one-item lists' => ['{"<":[[1],[2]]}', '{}', 'true'], + 'not of nothing' => ['{"!":[]}', '{}', 'true'], + 'not of an empty object' => ['{"!":{}}', '{}', 'false'], + 'unknown operator in an untaken branch' => ['{"if":[true,"a",{"nope":[]}]}', '{}', '"a"'], + 'loose eq one object with itself' => ['{"==":[{"var":"o"},{"var":"o"}]}', '{"o":{}}', 'true'], + 'loose eq two equal objects' => ['{"==":[{"var":"o"},{"var":"p"}]}', '{"o":{},"p":{}}', 'false'], +]); + +it('applies JSON Logic exactly as the reference engine does', function (string $logic, string $data, string $expected): void { + $result = (new JsonLogic)->apply(Json::decode($logic), Json::decode($data)); + + expect(Json::canonical($result))->toBe(Json::canonical(Json::decode($expected))); +})->with('reference json logic'); + +it('applies the edges exactly as the reference engine does', function (string $logic, string $data, string $expected): void { + $result = (new JsonLogic)->apply(Json::decode($logic), Json::decode($data)); + + expect(Json::canonical($result))->toBe(Json::canonical(Json::decode($expected))); +})->with('reference json logic edges'); + +it('turns "inf" into NaN but reads "infinity" as infinity, as Python float() does', function (): void { + $logic = new JsonLogic; + $sum = $logic->apply(Json::decode('{"+":["inf",1]}')); + + expect(is_float($sum) && is_nan($sum))->toBeTrue() + ->and($logic->apply(Json::decode('{">":["infinity",1e300]}')))->toBeTrue(); +}); + +it('refuses an operator nobody registered as an UnknownOperator', function (string $logic): void { + expect(fn () => (new JsonLogic)->apply(Json::decode($logic), []))->toThrow(UnknownOperator::class); +})->with([ + 'unknown name' => ['{"nope":[1]}'], + 'dotted name' => ['{"a.b":[1]}'], + 'unresolved $ref' => ['{"$ref":"missing"}'], + 'nested inside if' => ['{"if":[{"$ref":"missing"},"a","b"]}'], + 'list-like name' => ['{"0":[1]}'], + 'empty name' => ['{"":[1]}'], +]); + +it('names the operator nobody registered', function (): void { + $caught = null; + try { + (new JsonLogic)->apply(Json::decode('{"$ref":"segment"}')); + } catch (UnknownOperator $unknown) { + $caught = $unknown; + } + + expect($caught?->operator)->toBe('$ref') + ->and($caught?->getMessage())->toBe('Unrecognized operation [$ref].'); +}); + +it('fails at run time as a JsonLogicError (GENERAL), never as an UnknownOperator or a PHP error', function (string $logic): void { + $caught = null; + try { + (new JsonLogic)->apply(Json::decode($logic), []); + } catch (JsonLogicError $failure) { + $caught = $failure; + } + + // GENERAL, not PARSE_ERROR: the reference raises ZeroDivisionError / OverflowError / TypeError here, never + // the ReferenceError flagd-core maps to PARSE_ERROR. + expect($caught)->toBeInstanceOf(JsonLogicError::class) + ->and($caught)->not->toBeInstanceOf(UnknownOperator::class); +})->with([ + 'divide by zero' => ['{"/":[1,0]}'], + 'modulo by zero' => ['{"%":[1,0]}'], + 'divide by zero text' => ['{"/":[1,"0.0"]}'], + 'modulo by float zero' => ['{"%":[1,0.0]}'], + 'divide by negative zero' => ['{"/":[1,-0.0]}'], + 'substr infinite index' => ['{"substr":["abc","infinity"]}'], + 'substr infinite length' => ['{"substr":["abc",0,"-infinity"]}'], + 'var infinite index on a list' => ['{"var":1e400}'], + 'dotted name under a builtin' => ['{"in.x":[1]}'], + 'dotted name with an empty member' => ['{"var.":["a"]}'], + 'var with a third argument' => ['{"var":["a","b","c"]}'], +]); + +/* + | JsonLogic keeps the builtin names twice: the dispatch `match` in evaluate() and the BUILTINS list that + | unrecognized() reads to tell a dotted name under a builtin (`in.x`: GENERAL) from an unknown one (`a.b`: + | PARSE_ERROR). Both must be exactly the reference's operation table, so neither can drift from the other. + */ +dataset('reference operation table', [ + '==', '!=', '===', '!==', '<', '>', '<=', '>=', '!', '!!', '+', '*', '-', '/', '%', + 'in', 'min', 'max', 'cat', 'log', 'var', 'substr', 'merge', 'missing', 'missing_some', +]); + +it('lists exactly the reference operation table in BUILTINS and in the dispatch match', function (): void { + $reference = ['==', '!=', '===', '!==', '<', '>', '<=', '>=', '!', '!!', '+', '*', '-', '/', '%', + 'in', 'min', 'max', 'cat', 'log', 'var', 'substr', 'merge', 'missing', 'missing_some']; + $evaluate = new ReflectionMethod(JsonLogic::class, 'evaluate'); + $file = $evaluate->getFileName(); + $lines = is_string($file) ? file($file) : false; + $source = implode('', array_slice($lines === false ? [] : $lines, $evaluate->getStartLine() - 1, $evaluate->getEndLine() - $evaluate->getStartLine() + 1)); + preg_match('/return match \(\$op\) \{(.*?)\n\s*default =>/s', $source, $dispatch); + preg_match_all("/^\\s*'([^']+)' =>/m", $dispatch[1] ?? '', $arms); + + expect((new ReflectionClassConstant(JsonLogic::class, 'BUILTINS'))->getValue())->toEqualCanonicalizing($reference) + ->and($arms[1])->toEqualCanonicalizing($reference); +}); + +it('dispatches every builtin, and fails a dotted name under it as GENERAL', function (string $name): void { + $dispatched = true; + try { + (new JsonLogic)->apply(Json::decode(Json::encode([$name => []])), new stdClass); + } catch (UnknownOperator) { + $dispatched = false; + } catch (JsonLogicError) { + // `/` and `%` of nothing divide by zero: a run-time failure, so the name was recognized. + } + $dotted = null; + try { + (new JsonLogic)->apply(Json::decode(Json::encode([$name.'.x' => []])), new stdClass); + } catch (JsonLogicError $failure) { + $dotted = $failure; + } + + expect($dispatched)->toBeTrue() + ->and($dotted)->toBeInstanceOf(JsonLogicError::class) + ->and($dotted)->not->toBeInstanceOf(UnknownOperator::class); +})->with('reference operation table'); + +it('runs a registered operator with its evaluated arguments and the data', function (): void { + $logic = new JsonLogic(['twice' => static fn (mixed $data, array $args): mixed => [$args, Json::members($data)['n'] ?? null]]); + + expect($logic->apply(Json::decode('{"twice":[{"var":"n"},2]}'), ['n' => 7]))->toBe([[7, 2], 7]); +}); + +it('lets a registered operator replace a builtin of the same name, as the reference operation table does', function (): void { + $logic = new JsonLogic(['cat' => static fn (mixed $data, array $args): mixed => 'replaced']); + + expect($logic->apply(Json::decode('{"cat":["a","b"]}')))->toBe('replaced'); +}); + +it('fails a dotted name under a registered operator as a JsonLogicError, not an UnknownOperator', function (): void { + $logic = new JsonLogic(['twice' => static fn (mixed $data, array $args): mixed => $args]); + $caught = null; + try { + $logic->apply(Json::decode('{"twice.x":[1]}')); + } catch (JsonLogicError $failure) { + $caught = $failure; + } + + expect($caught)->toBeInstanceOf(JsonLogicError::class) + ->and($caught)->not->toBeInstanceOf(UnknownOperator::class); +}); + +it('reports a registered operator that throws as a JsonLogicError carrying the failure', function (): void { + $logic = new JsonLogic(['boom' => static fn (mixed $data, array $args): mixed => throw new TypeError('bad argument')]); + $caught = null; + try { + $logic->apply(Json::decode('{"boom":[1]}')); + } catch (JsonLogicError $failure) { + $caught = $failure; + } + + expect($caught)->toBeInstanceOf(JsonLogicError::class) + ->and($caught)->not->toBeInstanceOf(UnknownOperator::class) + ->and($caught?->getPrevious())->toBeInstanceOf(TypeError::class) + ->and($caught?->getMessage())->toContain('bad argument'); +}); + +it('evaluates logic nested past the 128 levels the evaluator admits', function (): void { + $logic = true; + for ($level = 0; $level < 300; $level++) { + $logic = ['!' => [$logic]]; + } + + expect((new JsonLogic)->apply($logic))->toBeTrue(); +}); + +it('stops logic nested past its own depth limit with a JsonLogicError, never a PHP error', function (): void { + $logic = true; + for ($level = 0; $level < 5000; $level++) { + $logic = ['!' => [$logic]]; + } + $caught = null; + try { + (new JsonLogic)->apply($logic); + } catch (JsonLogicError $failure) { + $caught = $failure; + } + + expect($caught)->toBeInstanceOf(JsonLogicError::class) + ->and($caught)->not->toBeInstanceOf(UnknownOperator::class); +}); + +it('reads a stray DateTimeInterface as an object, since the evaluator turns date-times into epoch milliseconds first', function (): void { + $moment = new DateTimeImmutable('2026-01-01T00:00:00Z'); + $number = JsonLogic::toNumber($moment); + + expect(is_float($number) && is_nan($number))->toBeTrue() + ->and(JsonLogic::toStr($moment))->toBe('[object Object]') + ->and(JsonLogic::truthy($moment))->toBeTrue() + ->and((new JsonLogic)->apply(Json::decode('{">":[{"var":"signupAt"},1735689600000]}'), ['signupAt' => 1767225600000.0]))->toBeTrue(); +}); + +it('answers truthiness the way the reference does', function (mixed $value, bool $truthy): void { + expect(JsonLogic::truthy($value))->toBe($truthy); +})->with([ + [null, false], [false, false], [0, false], [0.0, false], [NAN, false], ['', false], [[], false], + ['0', true], [' ', true], [1, true], [-1.5, true], [[0], true], [new stdClass, true], [['k' => null], true], +]); + +it('reads numbers the way the reference to_number() does', function (mixed $value, int|float $number): void { + expect(JsonLogic::toNumber($value))->toBe($number); +})->with([ + 'true' => [true, 1], + 'null' => [null, 0], + 'empty list' => [[], 0], + 'one-item list' => [['3'], 3.0], + 'underscores' => ['1_000', 1000.0], + 'padded' => [" 12\t", 12.0], + 'exponent' => ['1E5', 100000.0], + 'leading point' => ['.5', 0.5], + 'trailing point' => ['5.', 5.0], + 'infinity' => ['Infinity', INF], + 'minus infinity' => ['-infinity', -INF], + 'unicode digits' => ["\u{0664}\u{0662}", 42.0], + 'unicode spaces' => ["\u{3000}7\u{2003}", 7.0], + 'long digit run' => [str_repeat('3', 100000), INF], + 'long unicode digit run' => [str_repeat("\u{0663}", 100000), INF], + 'long underscored run' => [str_repeat('1_', 50000).'1', INF], +]); + +it('reads unparsable text, objects and longer lists as NaN', function (mixed $value): void { + $number = JsonLogic::toNumber($value); + + expect(is_float($number) && is_nan($number))->toBeTrue(); +})->with([ + 'inf' => ['inf'], 'plus inf' => ['+inf'], 'padded minus inf' => [' -INF '], 'nan' => ['nan'], + 'double underscore' => ['1__0'], 'leading underscore' => ['_1'], 'trailing underscore' => ['1_'], + 'hex' => ['0x10'], 'inner space' => ['1 0'], 'empty' => [''], 'two items' => [[1, 2]], 'object' => [new stdClass], + 'underscore before the point' => ['1_.5'], 'file separator float() keeps' => ["\x1C1"], 'other script letters' => ["\u{0663}a"], + 'not UTF-8' => ["\xFF1"], 'long run of inner spaces' => ['1'.str_repeat(' ', 100000).'x'], +]); + +it('writes text the way the reference to_string() does', function (mixed $value, string $text): void { + expect(JsonLogic::toStr($value))->toBe($text); +})->with([ + 'true' => [true, 'True'], + 'false' => [false, 'False'], + 'null' => [null, 'null'], + 'whole float' => [1.0, '1'], + 'rounded sum' => [0.1 + 0.2, '0.3'], + 'large float' => [1e20, '1e+20'], + 'fifteen digits' => [123456789012345.0, '123456789012345'], + 'sixteen digits' => [1e15, '1e+15'], + 'small float' => [0.0001, '0.0001'], + 'smaller float' => [0.00001, '1e-05'], + 'negative zero' => [-0.0, '-0'], + 'nan' => [NAN, 'nan'], + 'infinity' => [INF, 'inf'], + 'minus infinity' => [-INF, '-inf'], + 'large int' => [123456789012345678, '123456789012345678'], + 'nested list' => [[1, [true, null]], '1,True,null'], + 'object' => [['k' => 1], '[object Object]'], + 'empty object' => [new stdClass, '[object Object]'], +]); + +/* + | Data that contains itself (R-L-T5-cycles). Python compares a container with itself by identity first, so one + | self-containing object equals itself; comparing two, or writing or reading a list that contains itself, recurses + | until RecursionError (GENERAL). Here the recursion stops at MAX_DATA_DEPTH with a JsonLogicError, never a PHP fatal. + */ +function featureFlagsSelfContaining(): stdClass +{ + $object = new stdClass; + $object->self = $object; + $object->n = 1; + + return $object; +} + +it('compares an object that contains itself with itself by identity, as Python does', function (): void { + $object = featureFlagsSelfContaining(); + + expect(JsonLogic::strictEquals($object, $object))->toBeTrue() + ->and(JsonLogic::strictEquals([$object, 1], [$object, 1.0]))->toBeTrue() + ->and(JsonLogic::MAX_DATA_DEPTH)->toBe(1000); +}); + +it('stops comparing two objects that contain themselves with a JsonLogicError', function (): void { + JsonLogic::strictEquals(featureFlagsSelfContaining(), featureFlagsSelfContaining()); +})->throws(JsonLogicError::class, 'nests deeper than 1000 levels'); + +it('stops writing or reading a list that contains itself with a JsonLogicError', function (string $conversion): void { + $list = [1]; + $list[] = &$list; + $single = []; + $single[] = &$single; + + $conversion === 'text' ? JsonLogic::toStr($list) : JsonLogic::toNumber($single); +})->with(['text', 'number'])->throws(JsonLogicError::class, 'nests deeper than 1000 levels'); + +it('still compares, writes and reads data nested just within the limit', function (): void { + $deep = 1; + for ($i = 0; $i < 999; $i++) { + $deep = [$deep]; + } + + expect(JsonLogic::strictEquals($deep, $deep))->toBeTrue() + ->and(JsonLogic::toStr($deep))->toBe('1') + ->and(JsonLogic::toNumber($deep))->toBe(1); +}); diff --git a/packages/feature-flags/tests/Evaluation/PythonTextTest.php b/packages/feature-flags/tests/Evaluation/PythonTextTest.php new file mode 100644 index 00000000..8e8f4732 --- /dev/null +++ b/packages/feature-flags/tests/Evaluation/PythonTextTest.php @@ -0,0 +1,82 @@ +toBe($expected); +})->with([ + 'text is itself' => ["a'b", "a'b"], + 'an integer' => [-42, '-42'], + 'a whole float' => [2.0, '2.0'], + 'negative zero' => [-0.0, '-0.0'], + 'seventeen digits' => [0.1 + 0.2, '0.30000000000000004'], + 'an exponent' => [1e16, '1e+16'], + 'a small exponent' => [1e-5, '1e-05'], + 'the smallest fixed float' => [0.0001, '0.0001'], + 'the largest float' => [1.7976931348623157e308, '1.7976931348623157e+308'], + 'the smallest subnormal' => [5e-324, '5e-324'], + 'infinity' => [INF, 'inf'], + 'true' => [true, 'True'], + 'null' => [null, 'None'], + 'an empty list' => [[], '[]'], + 'an empty object' => [new stdClass, '{}'], + 'a list' => [[1, 'a', 2.5, false, null, [], new stdClass], "[1, 'a', 2.5, False, None, [], {}]"], + 'nested objects, numeric-looking names as text' => [['1' => ['k' => [true]], '' => 'x'], "{'1': {'k': [True]}, '': 'x'}"], + 'a single quote picks double quotes' => [["it's"], '["it\'s"]'], + 'both quotes escape the single one' => [["a'b\"c"], '[\'a\\\'b"c\']'], + 'a double quote alone stays' => [['say "hi"'], '[\'say "hi"\']'], + 'backslash and control characters' => [["\\ \t\n\r\x00\x1f\x7f"], '[\'\\\\ \t\n\r\x00\x1f\x7f\']'], + 'latin-1 controls and the no-break space' => [["\u{80}\u{9f}\u{a0}\u{ad}"], '[\'\x80\x9f\xa0\xad\']'], + 'printable characters as they are' => [['é☃東京🚀'], "['é☃東京🚀']"], + 'zero-width, separators and the byte-order mark' => [["\u{200b}\u{2028}\u{2029}\u{3000}\u{feff}"], '[\'\\u200b\\u2028\\u2029\\u3000\\ufeff\']'], + 'private-use and unassigned characters' => [["\u{e000}\u{f0000}\u{10ffff}\u{378}"], '[\'\\ue000\\U000f0000\\U0010ffff\\u0378\']'], +]); + +it('has no text for a value Python has no counterpart for', function (mixed $value): void { + expect(PythonText::str($value))->toBeNull(); +})->with([ + 'a PHP object' => [new ArrayObject], + 'invalid UTF-8 inside a list' => [["\xff"]], + 'invalid UTF-8 as a name' => [["\xff" => 1]], +]); + +it('writes a float as repr() does whatever PHP precision says', function (): void { + $precision = ini_get('precision'); + ini_set('precision', '3'); + + try { + expect(PythonText::float(0.1 + 0.2))->toBe('0.30000000000000004'); + } finally { + ini_set('precision', (string) $precision); + } +}); + +it('writes an object that contains itself as Python repr() does', function (): void { + $object = new stdClass; + $object->self = $object; + $object->n = 1; + $shared = (object) ['k' => 1]; + + expect(PythonText::str($object))->toBe("{'self': {...}, 'n': 1}") + ->and(PythonText::str([$object, [$object]]))->toBe("[{'self': {...}, 'n': 1}, [{'self': {...}, 'n': 1}]]") + ->and(PythonText::str([$shared, $shared]))->toBe("[{'k': 1}, {'k': 1}]"); +}); + +it('has no text for a list that contains itself through a PHP reference, nor for one nested past its limit', function (): void { + $list = [1]; + $list[] = &$list; + $deep = []; + for ($i = 0; $i < PythonText::MAX_DEPTH; $i++) { + $deep = [$deep]; + } + + expect(PythonText::str($list))->toBeNull() + ->and(PythonText::str($deep))->toBeNull() + ->and(PythonText::str($deep[0]))->toBe(str_repeat('[', PythonText::MAX_DEPTH).str_repeat(']', PythonText::MAX_DEPTH)); +}); diff --git a/packages/feature-flags/tests/Evaluation/RefResolverTest.php b/packages/feature-flags/tests/Evaluation/RefResolverTest.php new file mode 100644 index 00000000..c80c3183 --- /dev/null +++ b/packages/feature-flags/tests/Evaluation/RefResolverTest.php @@ -0,0 +1,127 @@ + array_fill(0, 9_998, true)], []))->toBeTrue() + ->and(RefResolver::withinLimits(['and' => array_fill(0, 9_999, true)], []))->toBeFalse(); +}); + +it('charges one value for every reference it resolves', function (): void { + $evaluators = ['x' => ['and' => array_fill(0, 9_997, true)], 'y' => ['and' => array_fill(0, 9_998, true)]]; + + expect(RefResolver::withinLimits(['$ref' => 'x'], $evaluators))->toBeTrue() + ->and(RefResolver::withinLimits(['$ref' => 'y'], $evaluators))->toBeFalse(); +}); + +it('counts a reference it cannot resolve as the object it is', function (): void { + // {"and": [{"$ref": "nobody"} × n, true × m]}: 2 + 2n + m. + $rule = static fn (int $scalars): array => ['and' => [['$ref' => 'nobody'], ['$ref' => 'nobody'], ...array_fill(0, $scalars, true)]]; + + expect(RefResolver::withinLimits($rule(9_994), []))->toBeTrue() + ->and(RefResolver::withinLimits($rule(9_995), []))->toBeFalse(); +}); + +it('allows 128 nesting levels, the targeting object being the first', function (): void { + // {"!!": [[…[true]…]]}: the object is level 1 and k lists reach level k + 1. + expect(RefResolver::withinLimits(['!!' => featureFlagsRefNested(127, true)], []))->toBeTrue() + ->and(RefResolver::withinLimits(['!!' => featureFlagsRefNested(128, true)], []))->toBeFalse() + ->and(RefResolver::withinLimits(['!!' => featureFlagsRefNested(127, ['$ref' => 'nobody'])], []))->toBeFalse() + ->and(RefResolver::withinLimits(['!!' => featureFlagsRefNested(126, ['$ref' => 'nobody'])], []))->toBeTrue(); +}); + +it('puts a resolved reference in the place of its object without adding a level', function (): void { + $evaluators = ['leaf' => ['!!' => [true]], 'alias' => ['$ref' => 'leaf']]; + + expect(RefResolver::withinLimits(['!!' => featureFlagsRefNested(125, ['$ref' => 'alias'])], $evaluators))->toBeTrue() + ->and(RefResolver::withinLimits(['!!' => featureFlagsRefNested(125, ['!!' => [true]])], $evaluators))->toBeTrue() + ->and(RefResolver::withinLimits(['!!' => featureFlagsRefNested(126, ['$ref' => 'alias'])], $evaluators))->toBeFalse(); +}); + +it('decides a fan-out or a chain of references within the budget, without building it', function (): void { + $fan = ['fan-0' => ['==' => [['var' => 'tier'], 'gold']]]; + for ($i = 1; $i <= 60; $i++) { + $fan["fan-{$i}"] = ['or' => [['$ref' => 'fan-'.($i - 1)], ['$ref' => 'fan-'.($i - 1)]]]; + } + $chain = ['link-0' => true]; + for ($i = 1; $i <= 100_000; $i++) { + $chain["link-{$i}"] = ['$ref' => 'link-'.($i - 1)]; + } + + $started = hrtime(true); + $fanOut = RefResolver::withinLimits(['$ref' => 'fan-60'], $fan); + $longChain = RefResolver::withinLimits(['$ref' => 'link-100000'], $chain); + $shortChain = RefResolver::withinLimits(['$ref' => 'link-9998'], $chain); + $seconds = (hrtime(true) - $started) / 1e9; + + expect([$fanOut, $longChain, $shortChain])->toBe([false, false, true]) + ->and($seconds)->toBeLessThan(2.0); +}); + +it('expands references structurally and transitively, whatever the names sort as', function (): void { + $evaluators = Json::members(Json::decode('{ + "a-outer": {"and": [{"$ref": "z-inner"}, {"var": "y"}]}, + "z-inner": {"$ref": "m-leaf"}, + "m-leaf": {"in": ["C:\\\\temp\\\\new", {"var": "paths"}]} + }')); + + $expanded = RefResolver::expand(Json::decode('{"if": [{"$ref": "a-outer"}, "on", null]}'), $evaluators); + + expect(Json::encode($expanded))->toBe('{"if":[{"and":[{"in":["C:\\\\temp\\\\new",{"var":"paths"}]},{"var":"y"}]},"on",null]}'); +}); + +it('leaves a missing name and a cycle in place', function (): void { + $evaluators = ['a' => ['or' => [['$ref' => 'b'], false]], 'b' => ['or' => [['$ref' => 'a'], false]]]; + + expect(Json::encode(RefResolver::expand(['and' => [['$ref' => 'nobody'], ['$ref' => 'a']]], $evaluators))) + ->toBe('{"and":[{"$ref":"nobody"},{"or":[{"or":[{"$ref":"a"},false]},false]}]}'); +}); + +it('reads a reference only as an object with one text $ref member', function (): void { + $evaluators = ['x' => ['var' => 'x']]; + + expect(Json::encode(RefResolver::expand(['and' => [['$ref' => 'x', 'other' => 1], ['$ref' => 1], ['$ref' => 'x']]], $evaluators))) + ->toBe('{"and":[{"$ref":"x","other":1},{"$ref":1},{"var":"x"}]}'); +}); + +it('builds new objects and never touches the evaluators it reads', function (): void { + $evaluators = Json::members(Json::decode('{"shared": {"!!": [{"0": {"$ref": "inner"}, "1": 2}]}, "inner": {"var": "x"}, "empty": {}}')); + $before = Json::encode($evaluators); + + $expanded = Json::members(RefResolver::expand(['or' => [['$ref' => 'shared'], ['$ref' => 'empty'], ['$ref' => 'empty']]], $evaluators)); + /** @var list $branches */ + $branches = $expanded['or']; + + expect(Json::encode($evaluators))->toBe($before) + ->and(Json::encode($expanded))->toBe('{"or":[{"!!":[{"0":{"var":"x"},"1":2}]},{},{}]}') + ->and($branches[1])->toBeInstanceOf(stdClass::class) + ->and($branches[1] === $branches[2])->toBeFalse() + ->and($branches[1] === $evaluators['empty'])->toBeFalse(); +}); + +it('expands a reference to a null rule to null', function (): void { + expect(RefResolver::expand(['$ref' => 'nothing'], ['nothing' => null]))->toBeNull(); +}); diff --git a/packages/feature-flags/tests/Evaluation/ResolutionTest.php b/packages/feature-flags/tests/Evaluation/ResolutionTest.php new file mode 100644 index 00000000..de0636f7 --- /dev/null +++ b/packages/feature-flags/tests/Evaluation/ResolutionTest.php @@ -0,0 +1,58 @@ + 'web']))->toResolutionDetails(); + + expect($details->getValue())->toBeTrue() + ->and($details->getVariant())->toBe('on') + ->and($details->getReason())->toBe('TARGETING_MATCH') + ->and($details->getError())->toBeNull(); +}); + +it('carries an error code and message and never a variant on an error', function (): void { + $details = Resolution::error(false, EvaluationError::FlagNotFound, 'Flag [x] is not defined.')->toResolutionDetails(); + + expect($details->getValue())->toBeFalse() + ->and($details->getVariant())->toBeNull() + ->and($details->getReason())->toBe('ERROR') + ->and($details->getError()?->getResolutionErrorCode()->getValue())->toBe('FLAG_NOT_FOUND') + ->and($details->getError()?->getResolutionErrorMessage())->toBe('Flag [x] is not defined.'); +}); + +it('carries numeric-looking metadata names as int keys whose (string) cast and JSON member names are the text', function (): void { + // open-feature/sdk 2.3's ResolutionDetails has no metadata slot, so the names leave PHP only as text read + // with (string) or as JSON member names; Json::object() keeps a list-like map ({"0": …}) an object. + $named = new Resolution(true, 'on', EvaluationReason::Static, metadata: ['1' => 'one', '2024' => 2024, 'owner' => 'web']); + $listLike = new Resolution(true, 'on', EvaluationReason::Static, metadata: ['0' => 'zero', '1' => 'one']); + $names = array_map(static fn (int|string $name): string => (string) $name, array_keys($named->metadata)); + + expect($named->metadata['1'] ?? null)->toBe('one') + ->and($named->metadata['2024'] ?? null)->toBe(2024) + ->and($names)->toBe(['1', '2024', 'owner']) + ->and(Json::encode(Json::object($named->metadata)))->toBe('{"1":"one","2024":2024,"owner":"web"}') + ->and(Json::encode(Json::object($listLike->metadata)))->toBe('{"0":"zero","1":"one"}') + ->and($named->toResolutionDetails()->getVariant())->toBe('on') + ->and($named->withValue(false)->metadata)->toBe($named->metadata); +}); + +it('hands OpenFeature arrays, never stdClass', function (): void { + expect((new Resolution(new stdClass, 'empty', EvaluationReason::Static))->toResolutionDetails()->getValue())->toBe([]); +}); + +it('types values the way flagd checks them', function (): void { + expect(FlagType::Float->accepts(1))->toBeTrue() + ->and(FlagType::Integer->accepts(1.0))->toBeFalse() + ->and(FlagType::Boolean->accepts(1))->toBeFalse() + ->and(FlagType::Object->accepts(new stdClass))->toBeTrue() + ->and(FlagType::Object->accepts([1]))->toBeTrue() + ->and(FlagType::ofDefault(1.0))->toBe(FlagType::Float) + ->and(FlagType::ofDefault([]))->toBe(FlagType::Object); +}); diff --git a/packages/feature-flags/tests/FeatureFlagsSettingsTest.php b/packages/feature-flags/tests/FeatureFlagsSettingsTest.php new file mode 100644 index 00000000..5ca99d36 --- /dev/null +++ b/packages/feature-flags/tests/FeatureFlagsSettingsTest.php @@ -0,0 +1,297 @@ + $section */ +function featureFlagsSettings(array $section = []): FeatureFlagsSettings +{ + return FeatureFlagsSettings::fromConfig(new Config(new Repository(['firefly' => ['feature-flags' => $section]]))); +} + +/** + * The `firefly.feature-flags` section that sets the one leaf `$leaf` (dotted, relative to the section) to `$value`. + * + * @return array + */ +function featureFlagsSection(string $leaf, mixed $value): array +{ + $section = []; + Arr::set($section, $leaf, $value); + + /** @var array $section */ + return $section; +} + +/** + * The four duration leaves, relative to `firefly.feature-flags`, with the default (in seconds) each falls back to. + * + * @return array + */ +function featureFlagsDurations(): array +{ + return [ + 'sources.file.refresh-interval' => 5.0, + 'sources.http.refresh-interval' => 30.0, + 'sources.http.timeout' => 2.0, + 'sources.store.refresh-interval' => 5.0, + ]; +} + +it('defaults every key to the value in the spec table', function (): void { + $settings = featureFlagsSettings(); + + expect($settings->enabled)->toBeFalse() + ->and($settings->flags)->toBe([]) + ->and($settings->evaluators)->toBe([]) + ->and($settings->file->enabled)->toBeFalse() + ->and($settings->file->path)->toBe('') + ->and($settings->file->refreshInterval)->toBe(5.0) + ->and($settings->http->enabled)->toBeFalse() + ->and($settings->http->url)->toBe('') + ->and($settings->http->token)->toBe('') + ->and($settings->http->refreshInterval)->toBe(30.0) + ->and($settings->http->timeout)->toBe(2.0) + ->and($settings->store->enabled)->toBeFalse() + ->and($settings->store->driver)->toBe('database') + ->and($settings->store->connection)->toBeNull() + ->and($settings->store->refreshInterval)->toBe(5.0) + ->and($settings->tenantAttribute)->toBe('tenant') + ->and($settings->disabledStatus)->toBe(404) + ->and($settings->publishEvaluations)->toBeFalse() + ->and($settings->writes)->toBeFalse() + ->and($settings->server->enabled)->toBeFalse() + ->and($settings->server->path)->toBe('/feature-flags/flagd.json') + ->and($settings->server->token)->toBe('') + ->and($settings->server->allowAnonymous)->toBeFalse(); +}); + +it('keeps inline flag and evaluator keys verbatim', function (): void { + $settings = featureFlagsSettings([ + 'flags' => ['new-checkout' => true, 'checkout.flow_v2' => 'v2'], + 'evaluators' => ['is-beta' => ['in' => ['beta', ['var' => 'roles']]]], + ]); + + expect(array_keys($settings->flags))->toBe(['new-checkout', 'checkout.flow_v2']) + ->and(array_keys($settings->evaluators))->toBe(['is-beta']); +}); + +it('reads durations in the framework grammar', function (mixed $written, float $seconds): void { + expect(featureFlagsSettings(['sources' => ['store' => ['refresh-interval' => $written]]])->store->refreshInterval)->toBe($seconds); +})->with([ + ['250ms', 0.25], + ['2s', 2.0], + ['PT1M', 60.0], + [10, 10.0], + [0.5, 0.5], +]); + +it('names the key when a duration does not parse', function (): void { + expect(fn () => featureFlagsSettings(['sources' => ['http' => ['timeout' => 'soon']]])) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.sources.http.timeout'); +}); + +it('treats an empty or missing store connection as the default connection', function (mixed $connection, ?string $expected): void { + expect(featureFlagsSettings(['sources' => ['store' => ['connection' => $connection]]])->store->connection)->toBe($expected); +})->with([[null, null], ['', null], ['flags', 'flags']]); + +it('refuses a disabled-status other than 404, 403 or 503', function (): void { + expect(fn () => featureFlagsSettings(['web' => ['disabled-status' => 500]])) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.web.disabled-status'); +}); + +it('refuses an unknown store driver', function (): void { + expect(fn () => featureFlagsSettings(['sources' => ['store' => ['driver' => 'mongo']]])) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.sources.store.driver'); +}); + +it('refuses an enabled file source without a path and an enabled http source without a url', function (): void { + expect(fn () => featureFlagsSettings(['sources' => ['file' => ['enabled' => true]]])) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.sources.file.path') + ->and(fn () => featureFlagsSettings(['sources' => ['http' => ['enabled' => true]]])) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.sources.http.url'); +}); + +it('refuses an enabled sync server without a token unless anonymous access is explicit', function (): void { + expect(fn () => featureFlagsSettings(['server' => ['enabled' => true]])) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.server.token') + ->and(featureFlagsSettings(['server' => ['enabled' => true, 'allow-anonymous' => true]])->server->allowAnonymous)->toBeTrue() + ->and(featureFlagsSettings(['server' => ['enabled' => true, 'token' => 's3cret']])->server->token)->toBe('s3cret'); +}); + +it('normalises the sync path to one leading slash', function (): void { + expect(featureFlagsSettings(['server' => ['path' => 'flags/doc.json', 'allow-anonymous' => true]])->server->path)->toBe('/flags/doc.json'); +}); + +it('refuses an enabled sync server mounted on the root path', function (string $path): void { + expect(fn () => featureFlagsSettings(['server' => ['enabled' => true, 'path' => $path, 'allow-anonymous' => true]])) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.server.path'); +})->with(['', '/', '///']); + +/* + * Every leaf reaches its own property. All the non-boolean leaves carry distinct non-default values, so two + * swapped strings or durations fail; the eight booleans all default to false, so each case switches on a + * different set of them and every property is checked against that set, so two swapped booleans fail too. + */ +it('maps every leaf to its own property', function (array $on): void { + /** @var list $on */ + $flag = static fn (string $leaf): bool => in_array($leaf, $on, true); + + $settings = featureFlagsSettings([ + 'enabled' => $flag('enabled'), + 'flags' => ['beta-banner' => true], + 'evaluators' => ['is-staff' => ['in' => ['staff', ['var' => 'roles']]]], + 'sources' => [ + 'file' => ['enabled' => $flag('sources.file.enabled'), 'path' => '/srv/flags/flagd.yaml', 'refresh-interval' => '7s'], + 'http' => [ + 'enabled' => $flag('sources.http.enabled'), + 'url' => 'https://flags.example.test/feature-flags/flagd.json', + 'token' => 'http-token', + 'refresh-interval' => '45s', + 'timeout' => '3s', + ], + 'store' => ['enabled' => $flag('sources.store.enabled'), 'driver' => 'memory', 'connection' => 'flags', 'refresh-interval' => '9s'], + ], + 'context' => ['tenant-attribute' => 'organisation'], + 'web' => ['disabled-status' => 403], + 'events' => ['evaluations' => $flag('events.evaluations')], + 'management' => ['writes' => $flag('management.writes')], + 'server' => [ + 'enabled' => $flag('server.enabled'), + 'path' => 'flags/served.json', + 'token' => 'server-token', + 'allow-anonymous' => $flag('server.allow-anonymous'), + ], + ]); + + expect($settings->enabled)->toBe($flag('enabled')) + ->and($settings->flags)->toBe(['beta-banner' => true]) + ->and($settings->evaluators)->toBe(['is-staff' => ['in' => ['staff', ['var' => 'roles']]]]) + ->and($settings->file->enabled)->toBe($flag('sources.file.enabled')) + ->and($settings->file->path)->toBe('/srv/flags/flagd.yaml') + ->and($settings->file->refreshInterval)->toBe(7.0) + ->and($settings->http->enabled)->toBe($flag('sources.http.enabled')) + ->and($settings->http->url)->toBe('https://flags.example.test/feature-flags/flagd.json') + ->and($settings->http->token)->toBe('http-token') + ->and($settings->http->refreshInterval)->toBe(45.0) + ->and($settings->http->timeout)->toBe(3.0) + ->and($settings->store->enabled)->toBe($flag('sources.store.enabled')) + ->and($settings->store->driver)->toBe('memory') + ->and($settings->store->connection)->toBe('flags') + ->and($settings->store->refreshInterval)->toBe(9.0) + ->and($settings->tenantAttribute)->toBe('organisation') + ->and($settings->disabledStatus)->toBe(403) + ->and($settings->publishEvaluations)->toBe($flag('events.evaluations')) + ->and($settings->writes)->toBe($flag('management.writes')) + ->and($settings->server->enabled)->toBe($flag('server.enabled')) + ->and($settings->server->path)->toBe('/flags/served.json') + ->and($settings->server->token)->toBe('server-token') + ->and($settings->server->allowAnonymous)->toBe($flag('server.allow-anonymous')); +})->with([ + 'every boolean on' => [[ + 'enabled', 'sources.file.enabled', 'sources.http.enabled', 'sources.store.enabled', + 'events.evaluations', 'management.writes', 'server.enabled', 'server.allow-anonymous', + ]], + 'only enabled' => [['enabled']], + 'only sources.file.enabled' => [['sources.file.enabled']], + 'only sources.http.enabled' => [['sources.http.enabled']], + 'only sources.store.enabled' => [['sources.store.enabled']], + 'only events.evaluations' => [['events.evaluations']], + 'only management.writes' => [['management.writes']], + 'only server.enabled' => [['server.enabled']], + 'only server.allow-anonymous' => [['server.allow-anonymous']], +]); + +it('accepts each allowed disabled-status, from config or from the environment', function (int|string $written, int $status): void { + expect(featureFlagsSettings(['web' => ['disabled-status' => $written]])->disabledStatus)->toBe($status); +})->with([[404, 404], [403, 403], [503, 503], ['503', 503]]); + +it('refuses a negative duration, written as a number or as text, naming its key', function (string $leaf, mixed $written): void { + expect(fn () => featureFlagsSettings(featureFlagsSection($leaf, $written))) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.'.$leaf); +})->with(array_keys(featureFlagsDurations()))->with([-5, -0.5, '-5s', '-PT1M']); + +it('refuses a duration that is not a finite number of seconds, naming its key', function (string $leaf, mixed $written): void { + expect(fn () => featureFlagsSettings(featureFlagsSection($leaf, $written))) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.'.$leaf); +})->with(array_keys(featureFlagsDurations()))->with([INF, NAN, '', 'soon', true, [['5s']]]); + +it('accepts zero as a file or store refresh interval: the source is re-checked on every refresh', function (string $leaf, mixed $written): void { + $settings = featureFlagsSettings(featureFlagsSection($leaf, $written)); + $intervals = [ + 'sources.file.refresh-interval' => $settings->file->refreshInterval, + 'sources.store.refresh-interval' => $settings->store->refreshInterval, + ]; + + expect($intervals[$leaf])->toBe(0.0); +})->with(['sources.file.refresh-interval', 'sources.store.refresh-interval'])->with([0, 0.0, '0s', 'PT0S']); + +it('refuses an http refresh interval that is not above zero, naming its key', function (mixed $written): void { + expect(fn () => featureFlagsSettings(['sources' => ['http' => ['refresh-interval' => $written]]])) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.sources.http.refresh-interval'); +})->with([0, 0.0, '0s', '0ms', 'PT0S']); + +it('refuses an http timeout that is not above zero, naming its key', function (mixed $written): void { + expect(fn () => featureFlagsSettings(['sources' => ['http' => ['timeout' => $written]]])) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.sources.http.timeout'); +})->with([0, 0.0, '0s', '0ms', 'PT0S']); + +/* + * DurationReader::get() is named get() so the key discovery in tests/ConfigReferenceTest.php (and the same pattern + * in DocsCodeAudit::keyLiterals()) finds each duration key at its read site. Renaming the method would silently + * drop four keys from the configuration-reference check; this pins it, with ConfigReferenceTest's pattern copied + * verbatim. + */ +it('reads every duration where the configuration-reference check can see its key', function (): void { + preg_match_all( + "/->(?:bool|string|int|array|get|has)\(\s*'(firefly\.[a-z0-9._\-]+)'/", + (string) file_get_contents(dirname(__DIR__).'/src/FeatureFlagsSettings.php'), + $matches, + ); + + expect($matches[1])->toContain(...array_map( + static fn (string $leaf): string => 'firefly.feature-flags.'.$leaf, + array_keys(featureFlagsDurations()), + )); +}); + +it('falls back to the default for a duration set to null, as an unset env() leaves it', function (string $leaf): void { + $settings = featureFlagsSettings(featureFlagsSection($leaf, null)); + + expect([ + 'sources.file.refresh-interval' => $settings->file->refreshInterval, + 'sources.http.refresh-interval' => $settings->http->refreshInterval, + 'sources.http.timeout' => $settings->http->timeout, + 'sources.store.refresh-interval' => $settings->store->refreshInterval, + ])->toBe(featureFlagsDurations()); +})->with(array_keys(featureFlagsDurations())); + +it('falls back to the default for every leaf set to null', function (): void { + $nulls = [ + 'enabled' => null, + 'flags' => null, + 'evaluators' => null, + 'sources' => [ + 'file' => ['enabled' => null, 'path' => null, 'refresh-interval' => null], + 'http' => ['enabled' => null, 'url' => null, 'token' => null, 'refresh-interval' => null, 'timeout' => null], + 'store' => ['enabled' => null, 'driver' => null, 'connection' => null, 'refresh-interval' => null], + ], + 'context' => ['tenant-attribute' => null], + 'web' => ['disabled-status' => null], + 'events' => ['evaluations' => null], + 'management' => ['writes' => null], + 'server' => ['enabled' => null, 'path' => null, 'token' => null, 'allow-anonymous' => null], + ]; + + expect(featureFlagsSettings($nulls))->toEqual(featureFlagsSettings()); +}); + +it('refuses a store connection that is not a connection name, naming its key', function (mixed $connection): void { + expect(fn () => featureFlagsSettings(['sources' => ['store' => ['connection' => $connection]]])) + ->toThrow(ConfigurationException::class, 'firefly.feature-flags.sources.store.connection'); +})->with([5, 1.5, true, false, [['flags']]]); diff --git a/packages/feature-flags/tests/FeatureFlagsTest.php b/packages/feature-flags/tests/FeatureFlagsTest.php new file mode 100644 index 00000000..331b76ea --- /dev/null +++ b/packages/feature-flags/tests/FeatureFlagsTest.php @@ -0,0 +1,230 @@ + $hooks + */ +function featureFlagsFacade(?API $api = null, ?EvaluationContextResolver $context = null, array $hooks = [], ?StaticFlagdEvaluator $evaluator = null): FeatureFlags +{ + $document = StaticDocument::json('{"flags":{ + "kill-switch":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on","metadata":{"owner":"ops"}}, + "flow":{"state":"ENABLED","variants":{"a":"A","b":"B"},"defaultVariant":"b"}, + "size":{"state":"ENABLED","variants":{"s":10,"l":50},"defaultVariant":"l"}, + "ratio":{"state":"ENABLED","variants":{"low":0.05},"defaultVariant":"low"}, + "banner":{"state":"ENABLED","variants":{"plain":{"title":"Hi"}},"defaultVariant":"plain"}, + "panel":{"state":"ENABLED","variants":{"v":{"title":"Hi","style":{},"items":[{"0":"x"}]}},"defaultVariant":"v"}, + "paused":{"state":"DISABLED","variants":{"on":true,"off":false},"defaultVariant":"on"} + },"metadata":{"team":"core"}}'); + + return new FeatureFlags( + new FireflyFlagProvider($document, $evaluator ?? new StaticFlagdEvaluator), + $context ?? new EvaluationContextResolver, + $hooks, + $document, + $api ?? OpenFeatureAPIFactory::createAPI(), + ); +} + +it('reads every type through the firefly client', function (): void { + $flags = featureFlagsFacade(); + + expect($flags->isEnabled('kill-switch'))->toBeTrue() + ->and($flags->isEnabled('paused', true))->toBeTrue() + ->and($flags->isEnabled('missing'))->toBeFalse() + ->and($flags->getString('flow', 'z'))->toBe('B') + ->and($flags->getInt('size', 0))->toBe(50) + ->and($flags->getFloat('ratio', 1.0))->toBe(0.05) + ->and($flags->getObject('banner'))->toBe(['title' => 'Hi']) + ->and($flags->client()->getMetadata()->getName())->toBe(FeatureFlags::CLIENT_NAME) + ->and($flags->provider())->toBeInstanceOf(FireflyFlagProvider::class); +}); + +it('evaluates each typed getter with its own type, never coercing around a type mismatch', function (): void { + $flags = featureFlagsFacade(); + + // An int default widens to a float parameter: the evaluation is a float one. + expect($flags->getFloat('size', 1))->toBe(50.0) + // A boolean is never a number, and a number is never a boolean: TYPE_MISMATCH answers the default. + ->and($flags->getInt('kill-switch', 7))->toBe(7) + ->and($flags->getFloat('kill-switch', 0.5))->toBe(0.5) + ->and($flags->isEnabled('size'))->toBeFalse() + ->and($flags->getInt('ratio', 3))->toBe(3) + ->and($flags->getString('size', 'z'))->toBe('z') + ->and($flags->getObject('flow', ['x' => 1]))->toBe(['x' => 1]) + ->and($flags->details('kill-switch', 0)->errorCode)->toBe('TYPE_MISMATCH') + ->and($flags->details('size', 1.5)->value)->toBe(50.0); +}); + +it('details an evaluation with merged metadata, and none on an error', function (): void { + $flags = featureFlagsFacade(); + + expect($flags->details('kill-switch', false)->toArray())->toEqual([ + 'key' => 'kill-switch', 'value' => true, 'variant' => 'on', 'reason' => 'STATIC', 'errorCode' => null, + 'metadata' => ['team' => 'core', 'owner' => 'ops'], + ]) + ->and($flags->details('flow', false)->errorCode)->toBe('TYPE_MISMATCH') + ->and($flags->details('flow', false)->reason)->toBe('ERROR') + ->and($flags->details('flow', false)->variant)->toBeNull() + ->and($flags->details('flow', false)->toArray()['metadata'])->toEqual(new stdClass) + ->and($flags->details('paused', true)->toArray())->toEqual([ + 'key' => 'paused', 'value' => true, 'variant' => null, 'reason' => 'DISABLED', 'errorCode' => null, + 'metadata' => ['team' => 'core'], + ]); +}); + +it('resolves the variant with the flag\'s own type', function (): void { + $flags = featureFlagsFacade(); + + expect($flags->variant('size'))->toBe('l') + ->and($flags->variant('ratio'))->toBe('low') + ->and($flags->variant('kill-switch'))->toBe('on') + ->and($flags->variant('banner'))->toBe('plain') + ->and($flags->variant('paused'))->toBeNull() + ->and($flags->variant('missing'))->toBeNull(); +}); + +it('re-asserts its own provider on the API before evaluating', function (): void { + $api = OpenFeatureAPIFactory::createAPI(); + $flags = featureFlagsFacade($api); + $api->setProvider(new NoOpProvider); + + expect($flags->isEnabled('kill-switch'))->toBeTrue() + ->and($api->getProvider())->toBeInstanceOf(FireflyFlagProvider::class); +}); + +it('attaches its hooks to the firefly client once', function (): void { + $hook = new RecordingHook; + $flags = featureFlagsFacade(hooks: [$hook]); + + $flags->isEnabled('kill-switch'); + $flags->getInt('size', 0); + + expect($flags->client())->toBe($flags->client()) + ->and($flags->client()->getHooks())->toBe([$hook]) + ->and(array_column($hook->evaluations, 'flag'))->toBe(['kill-switch', 'size']); +}); + +it('evaluates with the ambient context, the caller\'s context over it, through the client', function (): void { + $evaluator = new StaticFlagdEvaluator; + $principal = new class implements EvaluationContextContributor + { + public function contribute(EvaluationContextBuilder $context): void + { + $context->setTargetingKey('ada')->set('roles', ['beta'])->set('tenant', 'acme'); + } + }; + $flags = featureFlagsFacade(context: new EvaluationContextResolver([$principal]), hooks: [new RecordingHook], evaluator: $evaluator); + $renewal = new DateTimeImmutable('2025-06-30T23:59:59.999999-05:00'); + + /** @var array $explicit */ + $explicit = json_decode('{"2024":"leap","plan":"pro","tenant":"globex"}', true, 512, JSON_THROW_ON_ERROR); + $explicit['renewedAt'] = $renewal; + + expect($flags->isEnabled('kill-switch', false, $explicit))->toBeTrue() + ->and($evaluator->lastTargetingKey)->toBe('ada') + ->and(array_keys($evaluator->lastAttributes))->toBe(['roles', 'tenant', 'plan', 'renewedAt']) + ->and($evaluator->lastAttributes['tenant'] ?? null)->toBe('globex'); + + $renewed = $evaluator->lastAttributes['renewedAt'] ?? null; + assert($renewed instanceof DateTimeInterface); + + expect($renewed->format('Y-m-d\TH:i:s.uP'))->toBe('2025-06-30T23:59:59.999999-05:00') + ->and($flags->getString('flow', 'z', [], 'grace'))->toBe('B') + ->and($evaluator->lastTargetingKey)->toBe('grace') + // An int user id is the usual explicit key in Laravel: it is the key, as text, ambient or not. + ->and($flags->isEnabled('kill-switch', false, ['targetingKey' => 42]))->toBeTrue() + ->and($evaluator->lastTargetingKey)->toBe('42') + ->and($flags->details('kill-switch', false, ['targetingKey' => 7], ambient: false)->value)->toBeTrue() + ->and($evaluator->lastTargetingKey)->toBe('7'); +}); + +it('previews without the caller: only the explicit context and the process attributes, marked for the hooks', function (): void { + ProfileVariables::absent(function (): void { + $evaluator = new StaticFlagdEvaluator; + $hook = new RecordingHook; + $principal = new class implements EvaluationContextContributor + { + public int $runs = 0; + + public function contribute(EvaluationContextBuilder $context): void + { + $this->runs++; + $context->setTargetingKey('ada')->set('roles', ['admin'])->set('tenant', 'acme'); + } + }; + $application = new ApplicationEvaluationContextContributor(new Repository([ + 'app' => ['name' => 'orders'], + 'firefly' => ['profiles' => ['active' => 'eu']], + ])); + $flags = featureFlagsFacade(context: new EvaluationContextResolver([$application, $principal]), hooks: [$hook], evaluator: $evaluator); + + $preview = $flags->details('kill-switch', false, ['plan' => 'pro'], 'grace', ambient: false); + + expect($preview->value)->toBeTrue() + ->and($evaluator->lastTargetingKey)->toBe('grace') + ->and($evaluator->lastAttributes)->toBe(['application' => 'orders', 'profiles' => ['eu'], 'plan' => 'pro']) + ->and($principal->runs)->toBe(0) + ->and($hook->evaluations)->toBe([['flag' => 'kill-switch', 'reason' => 'STATIC', 'preview' => true]]); + + $flags->details('kill-switch', false); + + expect($evaluator->lastTargetingKey)->toBe('ada') + ->and($principal->runs)->toBe(1) + ->and($hook->evaluations[1] ?? null)->toBe(['flag' => 'kill-switch', 'reason' => 'STATIC', 'preview' => null]); + }); +}); + +it('hands callers plain arrays, never an instance shared with the stored definition', function (): void { + $flags = featureFlagsFacade(); + $expected = ['title' => 'Hi', 'style' => [], 'items' => [['x']]]; + + // toBe is ===: a stdClass anywhere in the value (the definition's own `{}` or `{"0": …}`) would fail it. + expect($flags->getObject('panel'))->toBe($expected) + ->and($flags->details('panel', [])->value)->toBe($expected) + ->and($flags->client()->getObjectValue('panel', []))->toBe($expected); +}); + +it('builds a FlagEvaluation that owns its value, with metadata names kept as JSON object members', function (): void { + $document = StaticDocument::json('{"flags":{ + "listy":{"state":"ENABLED","variants":{"v":{"0":"zero","1":[{"0":"deep"}]}},"defaultVariant":"v","metadata":{"0":"zero","1":"one"}}, + "dated":{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on","metadata":{"2024":"leap"}} + }}'); + $raw = (new StaticFlagdEvaluator)->evaluate($document->document, 'listy', FlagType::Object, []); + + $evaluation = FlagEvaluation::fromResolution('listy', $raw); + /** @var stdClass $value */ + $value = $evaluation->value; + $value->{'0'} = 'changed'; + /** @var list $nested */ + $nested = $value->{'1'}; + $nested[0]->{'0'} = 'changed'; + + $dated = FlagEvaluation::fromResolution('dated', (new StaticFlagdEvaluator)->evaluate($document->document, 'dated', FlagType::Boolean, false)); + + expect(Json::encode($raw->value))->toBe('{"0":"zero","1":[{"0":"deep"}]}') + ->and(Json::encode((new StaticFlagdEvaluator)->evaluate($document->document, 'listy', FlagType::Object, [])->value))->toBe('{"0":"zero","1":[{"0":"deep"}]}') + ->and(Json::encode(FlagEvaluation::fromResolution('listy', $raw)->toArray())) + ->toBe('{"key":"listy","value":{"0":"zero","1":[{"0":"deep"}]},"variant":"v","reason":"STATIC","errorCode":null,"metadata":{"0":"zero","1":"one"}}') + ->and(Json::encode($dated->toArray()['metadata']))->toBe('{"2024":"leap"}') + ->and($dated->metadata)->toBe([2024 => 'leap']); +}); diff --git a/packages/feature-flags/tests/Fixtures/AmbientContext/AmbientTierContributor.php b/packages/feature-flags/tests/Fixtures/AmbientContext/AmbientTierContributor.php new file mode 100644 index 00000000..8e87a232 --- /dev/null +++ b/packages/feature-flags/tests/Fixtures/AmbientContext/AmbientTierContributor.php @@ -0,0 +1,20 @@ +setTargetingKey('ambient-user'); + $context->set('tier', 'gold'); + } +} diff --git a/packages/feature-flags/tests/Fixtures/External/ExternalProviderConfiguration.php b/packages/feature-flags/tests/Fixtures/External/ExternalProviderConfiguration.php new file mode 100644 index 00000000..a63cc4eb --- /dev/null +++ b/packages/feature-flags/tests/Fixtures/External/ExternalProviderConfiguration.php @@ -0,0 +1,20 @@ + */ + #[FeatureFlag('beta-api')] + #[PostMapping] + public function create(#[RequestBody] BetaPayload $payload): array + { + self::$calls++; + + return ['sku' => $payload->sku]; + } + + /** @return array */ + #[FeatureFlag('beta-api', fallback: 'createFallback')] + #[PostMapping('/fallback')] + public function createWithFallback(#[RequestBody] BetaPayload $payload): array + { + self::$calls++; + + return ['sku' => $payload->sku]; + } + + /** @return array */ + public function createFallback(BetaPayload $payload): array + { + return ['fallback' => $payload->sku]; + } + + /** @return array */ + #[FeatureFlag('missing-nested-gate', default: true)] + #[GetMapping('/open-calls-closed')] + public function openCallsClosed(): array + { + self::$calls++; + + return $this->closed(); + } + + /** @return array */ + #[FeatureFlag('missing-nested-gate')] + #[GetMapping('/closed')] + public function closed(): array + { + self::$calls++; + + return ['closed' => true]; + } + + /** @return array */ + #[FeatureFlag('missing-nested-gate', default: true)] + #[GetMapping('/open-calls-open')] + public function openCallsOpen(): array + { + self::$calls++; + + return $this->nestedOpen(); + } + + /** @return array */ + #[FeatureFlag('missing-nested-gate', default: true)] + #[GetMapping('/nested-open')] + public function nestedOpen(): array + { + self::$calls++; + + return ['open' => true]; + } + + /** @return array */ + #[FeatureFlag('checkout-flow', variant: 'v2')] + #[GetMapping('/v2')] + public function v2(): array + { + self::$calls++; + + return ['flow' => 'v2']; + } + + /** @return array */ + #[GetMapping('/open')] + public function open(): array + { + return ['open' => true]; + } +} diff --git a/packages/feature-flags/tests/Fixtures/GatedRoutes/BetaPayload.php b/packages/feature-flags/tests/Fixtures/GatedRoutes/BetaPayload.php new file mode 100644 index 00000000..4712ca68 --- /dev/null +++ b/packages/feature-flags/tests/Fixtures/GatedRoutes/BetaPayload.php @@ -0,0 +1,10 @@ +recover(); + } + + private function recover(): void {} +} diff --git a/packages/feature-flags/tests/Fixtures/Refused/SelfFallback/SelfFallback.php b/packages/feature-flags/tests/Fixtures/Refused/SelfFallback/SelfFallback.php new file mode 100644 index 00000000..f4ea872d --- /dev/null +++ b/packages/feature-flags/tests/Fixtures/Refused/SelfFallback/SelfFallback.php @@ -0,0 +1,15 @@ +app()->make('blade.compiler')); + $this->overrideFlags(['on' => true, 'off' => false, 'flow' => 'v2']); +}); + +it('renders the branch the flag selects', function (string $template, string $expected): void { + expect(trim(Blade::render($template)))->toBe($expected); +})->with([ + 'enabled' => ["@featureflag('on') yes @else no @endfeatureflag", 'yes'], + 'disabled' => ["@featureflag('off') yes @else no @endfeatureflag", 'no'], + 'missing fails closed' => ["@featureflag('missing') yes @else no @endfeatureflag", 'no'], + 'missing with a default' => ["@featureflag('missing', true) yes @else no @endfeatureflag", 'yes'], + 'unless' => ["@unlessfeatureflag('off') shown @endfeatureflag", 'shown'], + 'else-if' => ["@featureflag('off') a @elsefeatureflag('on') b @else c @endfeatureflag", 'b'], + 'variant matches' => ["@featurevariant('flow', 'v2') new @else old @endfeaturevariant", 'new'], + 'variant differs' => ["@featurevariant('flow', 'v1') new @else old @endfeaturevariant", 'old'], +]); diff --git a/packages/feature-flags/tests/Gating/FeatureFlagGateTest.php b/packages/feature-flags/tests/Gating/FeatureFlagGateTest.php new file mode 100644 index 00000000..9c587dca --- /dev/null +++ b/packages/feature-flags/tests/Gating/FeatureFlagGateTest.php @@ -0,0 +1,76 @@ +allows('on'))->toBeTrue() + ->and($gate->allows('off'))->toBeFalse() + ->and($gate->allows('flow', 'v2'))->toBeTrue() + ->and($gate->allows('flow', 'v1'))->toBeFalse(); +}); + +it('uses the caller default for missing, disabled, mistyped and unresolved flags', function (): void { + $gate = GateFixtures::gate(); + + foreach (['missing', 'paused', 'unset'] as $key) { + expect($gate->allows($key, 'v2'))->toBeFalse() + ->and($gate->allows($key, 'v2', true))->toBeTrue(); + } + + expect($gate->allows('missing'))->toBeFalse() + ->and($gate->allows('missing', default: true))->toBeTrue() + ->and($gate->allows('paused', default: true))->toBeTrue() + ->and($gate->allows('flow'))->toBeFalse(); +}); + +it('uses the caller default if the subsystem is unbound', function (): void { + $gate = GateFixtures::gate(withFlags: false); + + expect($gate->allows('on'))->toBeFalse() + ->and($gate->allows('on', default: true))->toBeTrue() + ->and($gate->allows('on', 'v2'))->toBeFalse() + ->and($gate->allows('on', 'v2', true))->toBeTrue(); +}); + +it('uses the caller default if the flags bean cannot be resolved', function (): void { + $illuminate = new Container; + $illuminate->bind(FeatureFlags::class, static fn (): never => throw new RuntimeException('unavailable')); + $gate = new FeatureFlagGate(new FireflyContainer($illuminate, new ComponentManifest([])), new Config(new Repository)); + + expect($gate->allows('on'))->toBeFalse() + ->and($gate->allows('on', default: true))->toBeTrue() + ->and($gate->allows('on', 'v2'))->toBeFalse() + ->and($gate->allows('on', 'v2', true))->toBeTrue(); +}); + +it('maps configured disabled status without exposing the key', function (int $status, string $code, ErrorCategory $category): void { + $exception = GateFixtures::gate(settings: ['web' => ['disabled-status' => $status]])->disabled('secret-launch'); + + expect($exception->httpStatus())->toBe($status) + ->and($exception->errorCode())->toBe($code) + ->and($exception->category())->toBe($category) + ->and($exception->getMessage())->not->toContain('secret-launch') + ->and($exception->flagKey())->toBe('secret-launch'); +})->with([ + [404, 'RESOURCE_NOT_FOUND', ErrorCategory::Business], + [403, 'ACCESS_DENIED', ErrorCategory::Security], + [503, 'SERVICE_UNAVAILABLE', ErrorCategory::Infrastructure], +]); + +it('validates disabled status when the gate is constructed', function (): void { + expect(fn () => GateFixtures::gate(settings: ['web' => ['disabled-status' => 500]])) + ->toThrow(ConfigurationException::class, 'must be 404, 403 or 503'); +}); diff --git a/packages/feature-flags/tests/Gating/FeatureFlagMethodInterceptorTest.php b/packages/feature-flags/tests/Gating/FeatureFlagMethodInterceptorTest.php new file mode 100644 index 00000000..51c85d34 --- /dev/null +++ b/packages/feature-flags/tests/Gating/FeatureFlagMethodInterceptorTest.php @@ -0,0 +1,155 @@ +calls++; + + return "new:{$cents}"; + } + + public function legacy(int $cents): string + { + return "legacy:{$cents}"; + } +} + +final class FeatureFlagsCustomPricing extends FeatureFlagsPricing +{ + public function legacy(int $cents): string + { + return "custom:{$cents}"; + } +} + +function featureFlagsInvoke(FeatureFlagsPricing $target, FeatureFlagMethodDescriptor $rule, RouteGateDecisions $decisions): mixed +{ + $interceptor = new FeatureFlagMethodInterceptor(GateFixtures::gate(), $decisions); + $invocation = new MethodInvocation($target, FeatureFlagsPricing::class, 'price', [100], [$interceptor], [FeatureFlagMethodDescriptor::class => $rule], static function (array $arguments) use ($target): string { + /** @var int $cents */ + $cents = $arguments[0]; + + return $target->price($cents); + }); + + return $invocation->proceed(); +} + +it('proceeds while on and refuses an off flag without calling the target', function (): void { + $target = new FeatureFlagsPricing; + $decisions = new RouteGateDecisions(new Container); + + expect(featureFlagsInvoke($target, new FeatureFlagMethodDescriptor(FeatureFlagsPricing::class, 'price', 'on'), $decisions))->toBe('new:100') + ->and(fn () => featureFlagsInvoke($target, new FeatureFlagMethodDescriptor(FeatureFlagsPricing::class, 'price', 'off'), $decisions))->toThrow(FeatureFlagDisabledException::class) + ->and($target->calls)->toBe(1); +}); + +it('calls a named fallback with the arguments on the actual receiver', function (): void { + $target = new FeatureFlagsCustomPricing; + + expect(featureFlagsInvoke($target, new FeatureFlagMethodDescriptor(FeatureFlagsPricing::class, 'price', 'off', fallback: 'legacy'), new RouteGateDecisions(new Container))) + ->toBe('custom:100') + ->and($target->calls)->toBe(0); +}); + +it('does not re-enter the gate when a class-level fallback is proxied and still runs other advice', function (): void { + $gate = GateFixtures::gate(); + $decisions = new RouteGateDecisions(new Container); + $interceptor = new FeatureFlagMethodInterceptor($gate, $decisions); + $other = new class implements MethodInterceptor + { + public int $calls = 0; + + public function invoke(MethodInvocation $invocation): mixed + { + $this->calls++; + + return $invocation->proceed(); + } + }; + $target = new class($interceptor, $other) extends FeatureFlagsPricing + { + public function __construct( + private readonly FeatureFlagMethodInterceptor $interceptor, + private readonly MethodInterceptor $other, + ) {} + + public function legacy(int $cents): string + { + $invocation = new MethodInvocation($this, FeatureFlagsPricing::class, 'legacy', [$cents], [$this->interceptor, $this->other], [FeatureFlagMethodDescriptor::class => new FeatureFlagMethodDescriptor(FeatureFlagsPricing::class, 'legacy', 'off', fallback: 'legacy')], static function (array $arguments): string { + $value = $arguments[0] ?? null; + if (! is_int($value)) { + throw new LogicException('Expected a cent amount'); + } + + return 'fallback:'.$value; + }); + + $result = $invocation->proceed(); + if (! is_string($result)) { + throw new LogicException('Expected a price'); + } + + return $result; + } + }; + $invocation = new MethodInvocation($target, FeatureFlagsPricing::class, 'price', [100], [$interceptor], [FeatureFlagMethodDescriptor::class => new FeatureFlagMethodDescriptor(FeatureFlagsPricing::class, 'price', 'off', fallback: 'legacy')], static function (array $arguments): string { + $value = $arguments[0] ?? null; + if (! is_int($value)) { + throw new LogicException('Expected a cent amount'); + } + + return 'new:'.$value; + }); + + expect($invocation->proceed())->toBe('fallback:100') + ->and($other->calls)->toBe(1); +}); + +it('trusts the route decision only on matching route rows of the current request', function (): void { + $app = new Container; + $request = Request::create('/beta'); + $route = new Route('GET', '/beta', static fn (): string => 'beta'); + $request->setRouteResolver(static fn (): Route => $route); + $app->instance('request', $request); + $decisions = new RouteGateDecisions($app); + $rule = new FeatureFlagMethodDescriptor(FeatureFlagsPricing::class, 'price', 'off', route: true); + $decisions->register($route, $rule); + $decisions->record($request, 'off', null, false); + + expect(featureFlagsInvoke(new FeatureFlagsPricing, $rule, $decisions))->toBe('new:100') + ->and(fn () => featureFlagsInvoke(new FeatureFlagsPricing, new FeatureFlagMethodDescriptor(FeatureFlagsPricing::class, 'price', 'off'), $decisions))->toThrow(FeatureFlagDisabledException::class) + ->and($decisions->passed(new FeatureFlagMethodDescriptor(FeatureFlagsPricing::class, 'price', 'off', 'v2', route: true)))->toBeFalse() + ->and($decisions->passed(new FeatureFlagMethodDescriptor(FeatureFlagsPricing::class, 'price', 'off', default: true, route: true)))->toBeFalse() + ->and($decisions->passed(new FeatureFlagMethodDescriptor(FeatureFlagsPricing::class, 'other', 'off', route: true)))->toBeFalse() + ->and((new RouteGateDecisions(new Container))->passed($rule))->toBeFalse(); + + $app->instance('request', Request::create('/next')); + expect($decisions->passed($rule))->toBeFalse(); +}); + +it('round trips descriptor rows and spells route middleware', function (): void { + $rule = new FeatureFlagMethodDescriptor('App\\Http\\BetaController', 'index', 'beta-api', 'v2', true, null, true); + + expect(FeatureFlagMethodDescriptor::fromArray($rule->toArray()))->toEqual($rule) + ->and(FeatureFlagMethodDescriptor::fromArray(['class' => 'A', 'method' => 'b', 'key' => 'k']))->toEqual(new FeatureFlagMethodDescriptor('A', 'b', 'k')) + ->and($rule->middleware())->toBe('feature-flag:beta-api,v2,true') + ->and((new FeatureFlagMethodDescriptor('A', 'b', 'k'))->middleware())->toBe('feature-flag:k,,false') + ->and($rule->site())->toBe('App\\Http\\BetaController::index'); +}); diff --git a/packages/feature-flags/tests/Gating/FeatureFlagScannerTest.php b/packages/feature-flags/tests/Gating/FeatureFlagScannerTest.php new file mode 100644 index 00000000..05bc521a --- /dev/null +++ b/packages/feature-flags/tests/Gating/FeatureFlagScannerTest.php @@ -0,0 +1,113 @@ + */ +function featureFlagsFixtureRoot(string $directory): array +{ + $namespace = 'Firefly\\FeatureFlags\\Tests\\Fixtures\\'.str_replace('/', '\\', $directory).'\\'; + + return [$namespace => dirname(__DIR__).'/Fixtures/'.$directory]; +} + +it('compiles method- and class-level rules and shields a fallback from the class attribute', function (): void { + $advice = (new FeatureFlagScanner)->scanProxyAdvice(featureFlagsFixtureRoot('GatedBeans')); + + expect(array_keys($advice))->toBe([CheckoutService::class, ReportService::class, VirtualFallbackService::class]) + ->and(array_keys($advice[CheckoutService::class]))->toBe(['checkout', 'optOut', 'price']) + ->and($advice[CheckoutService::class]['price'])->toBe([ + 'class' => CheckoutService::class, 'method' => 'price', 'key' => 'new-pricing', 'variant' => null, + 'default' => false, 'fallback' => 'legacyPrice', 'route' => false, + ]) + ->and($advice[CheckoutService::class]['optOut']['default'])->toBeTrue() + ->and(array_keys($advice[ReportService::class]))->toBe(['daily', 'weekly']) + ->and(array_keys($advice[VirtualFallbackService::class]))->toBe(['run']); +}); + +it('refuses every placement no proxy could enforce, with a sentence', function (string $directory, string $message): void { + expect(fn () => (new FeatureFlagScanner)->scan(featureFlagsFixtureRoot('Refused/'.$directory))) + ->toThrow(ConfigurationException::class, $message); +})->with([ + 'final class' => ['FinalService', 'the class is final and a proxy must extend it'], + 'nothing post-processes it' => ['PlainHelper', 'nothing post-processes'], + 'static method' => ['StaticGate', 'a static call has no instance for a proxy to wrap'], + 'fallback is the method' => ['SelfFallback', 'names the gated method itself as its fallback'], + 'missing fallback' => ['MissingFallback', 'does not declare'], + 'invalid key' => ['BadKey', 'is not a valid flag key'], + 'final method' => ['FinalMethod', 'the method is final and a proxy must override it'], + 'magic method' => ['MagicMethod', 'never routed through the advice chain'], + 'private fallback' => ['PrivateFallback', 'not public'], + 'static fallback' => ['StaticFallback', 'static'], + 'fallback arity' => ['ArityFallback', 'requires 1 arguments'], + 'invalid variant' => ['BadVariant', 'contain no comma'], + 'abstract class attribute' => ['AbstractBase', 'class-level #[FeatureFlag] is not inherited'], +]); + +it('keeps inherited method gates on a service and drops unenforced inherited rows on plain children', function (): void { + $advice = (new FeatureFlagScanner)->scanProxyAdvice(featureFlagsFixtureRoot('InheritedPlain')); + + expect(array_keys($advice))->toBe([ServiceChild::class]) + ->and(array_keys($advice[ServiceChild::class]))->toBe(['run']); +}); + +it('refuses a case-only self fallback before its generated proxy can run the gated body', function (): void { + $planner = new ProxyPlanner([new FeatureFlagAdviceSource]); + + try { + $plan = $planner->plan(featureFlagsFixtureRoot('Refused/CaseSelfFallback')); + } catch (ConfigurationException $exception) { + expect($exception->getMessage())->toContain('names the gated method itself as its fallback'); + + return; + } + + $methods = $planner->proxyMethods($plan)[CaseSelfFallback::class]; + $proxyClass = (new ProxyClassGenerator)->load(CaseSelfFallback::class, $methods); + $interceptor = new FeatureFlagMethodInterceptor(GateFixtures::gate(), new RouteGateDecisions(new Container)); + $proxy = (new ProxyFactory)->wrap(new CaseSelfFallback, CaseSelfFallback::class, $proxyClass, new TransactionInterceptor(new TransactionTemplate), [FeatureFlagAdviceSource::ID => $interceptor]); + if (! $proxy instanceof CaseSelfFallback) { + throw new LogicException('Generated proxy is not a CaseSelfFallback'); + } + + expect($proxy->run())->not->toBe('GATED BODY RAN'); +}); + +it('declares fail-closed advice order 80 and renders its row as a literal', function (): void { + $source = new FeatureFlagAdviceSource; + $advice = $source->advice(); + $rendered = $source->render((new FeatureFlagMethodDescriptor('A', 'b', 'k'))->toArray()); + + expect([$advice->id, $advice->order, $advice->inertWhenUnbound, $advice->interceptorClass, $advice->descriptorClass]) + ->toBe(['featureflag', 80, false, FeatureFlagMethodInterceptor::class, FeatureFlagMethodDescriptor::class]) + ->and($rendered)->toStartWith('\\Firefly\\FeatureFlags\\Gating\\FeatureFlagMethodDescriptor::fromArray(') + ->and($rendered)->toContain("'key' => 'k'") + ->and($rendered)->toContain("'route' => false"); + // The rendered literal is loaded for real by CachedBootTest (packages/cli), which boots the cached proxy. +}); + +it('refuses to bind a compiled gate when its interceptor is absent', function (): void { + $registry = new InterceptorRegistry(new Container); + + expect(fn () => $registry->for((new FeatureFlagAdviceSource)->advice())) + ->toThrow(ConfigurationException::class, 'no such bean is bound'); +}); diff --git a/packages/feature-flags/tests/Gating/GatedBeansCapstoneTest.php b/packages/feature-flags/tests/Gating/GatedBeansCapstoneTest.php new file mode 100644 index 00000000..242da191 --- /dev/null +++ b/packages/feature-flags/tests/Gating/GatedBeansCapstoneTest.php @@ -0,0 +1,75 @@ +app()->make(CheckoutService::class); + /** @var ProxyPlan $plan */ + $plan = $this->fireflyContext()->get(ProxyPlan::class); + + expect($service::class)->toBe(CheckoutService::class.ProxyPlan::PROXY_SUFFIX) + ->and(array_keys($plan->adviceFor(CheckoutService::class)))->toBe([FeatureFlagAdviceSource::ID]); +}); + +it('refuses a call while the flag is off and runs it once it is on', function (): void { + /** @var GatedBeansTestCase $this */ + /** @var CheckoutService $service */ + $service = $this->app()->make(CheckoutService::class); + + expect(fn () => $service->checkout('cart-1'))->toThrow(FeatureFlagDisabledException::class) + ->and($service->untouched())->toBe('free'); + + $this->overrideFlags(['new-checkout' => true]); + + expect($service->checkout('cart-1'))->toBe('new:cart-1'); +}); + +it('answers with the fallback while off', function (): void { + /** @var GatedBeansTestCase $this */ + /** @var CheckoutService $service */ + $service = $this->app()->make(CheckoutService::class); + + expect($service->price(100))->toBe('legacy:100'); + + $this->overrideFlags(['new-pricing' => true]); + + expect($service->price(100))->toBe('new:100'); +}); + +it('gates every public method of a class-level #[FeatureFlag]', function (): void { + /** @var GatedBeansTestCase $this */ + /** @var ReportService $reports */ + $reports = $this->app()->make(ReportService::class); + $this->overrideFlags(['reports' => false]); + + expect(fn () => $reports->daily())->toThrow(FeatureFlagDisabledException::class) + ->and(fn () => $reports->weekly())->toThrow(FeatureFlagDisabledException::class); +}); + +it('honours default: true for a flag nobody defined', function (): void { + /** @var GatedBeansTestCase $this */ + /** @var CheckoutService $service */ + $service = $this->app()->make(CheckoutService::class); + + expect($service->optOut())->toBe('ran'); +}); + +it('uses the concrete override for a class-level named fallback', function (): void { + /** @var GatedBeansTestCase $this */ + /** @var VirtualFallbackService $service */ + $service = $this->app()->make(VirtualFallbackService::class); + $this->overrideFlags(['reports' => false]); + + expect($service->run('x'))->toBe('child:x'); +}); diff --git a/packages/feature-flags/tests/Gating/GatedBeansFailClosedTest.php b/packages/feature-flags/tests/Gating/GatedBeansFailClosedTest.php new file mode 100644 index 00000000..c7fec9da --- /dev/null +++ b/packages/feature-flags/tests/Gating/GatedBeansFailClosedTest.php @@ -0,0 +1,19 @@ +app()->make(CheckoutService::class); + + expect(fn () => $service->checkout('cart-1'))->toThrow(FeatureFlagDisabledException::class) + ->and($service->price(100))->toBe('legacy:100') + ->and($service->optOut())->toBe('ran'); +}); diff --git a/packages/feature-flags/tests/Gating/RouteGating503Test.php b/packages/feature-flags/tests/Gating/RouteGating503Test.php new file mode 100644 index 00000000..c929d1dd --- /dev/null +++ b/packages/feature-flags/tests/Gating/RouteGating503Test.php @@ -0,0 +1,12 @@ +postJson('/beta', [])->assertStatus(503)->assertJsonPath('code', 'SERVICE_UNAVAILABLE'); +}); diff --git a/packages/feature-flags/tests/Gating/RouteGatingTest.php b/packages/feature-flags/tests/Gating/RouteGatingTest.php new file mode 100644 index 00000000..8c2417e1 --- /dev/null +++ b/packages/feature-flags/tests/Gating/RouteGatingTest.php @@ -0,0 +1,151 @@ +postJson('/beta', ['not' => 'a payload']) + ->assertStatus(404) + ->assertJsonPath('code', 'RESOURCE_NOT_FOUND'); + + expect(BetaController::$calls)->toBe(0); +}); + +it('serves the route once the flag is on', function (): void { + /** @var GatedRoutesTestCase $this */ + $this->overrideFlags(['beta-api' => true, 'checkout-flow' => 'v2']); + + $this->postJson('/beta', ['sku' => 'A-1'])->assertOk()->assertJson(['sku' => 'A-1']); +}); + +it('binds a fallback route before selecting its fallback', function (): void { + /** @var GatedRoutesTestCase $this */ + $this->postJson('/beta/fallback', ['not' => 'a payload'])->assertStatus(400); + $this->postJson('/beta/fallback', ['sku' => 'A-1'])->assertOk()->assertJson(['fallback' => 'A-1']); + + expect(BetaController::$calls)->toBe(0); +}); + +it('does not reuse an open routed decision for a nested closed action', function (): void { + /** @var GatedRoutesTestCase $this */ + $this->getJson('/beta/open-calls-closed')->assertStatus(404); + + expect(BetaController::$calls)->toBe(1); +}); + +it('evaluates a different nested action even when its gate settings match', function (): void { + /** @var GatedRoutesTestCase $this */ + /** @var list $evaluations */ + $evaluations = []; + Event::listen(FeatureFlagEvaluated::class, function (FeatureFlagEvaluated $event) use (&$evaluations): void { + $evaluations[] = $event; + }); + + $this->getJson('/beta/open-calls-open')->assertOk()->assertJson(['open' => true]); + + expect(array_map(static fn (FeatureFlagEvaluated $event): string => $event->key, $evaluations))->toBe(['missing-nested-gate', 'missing-nested-gate']) + ->and(BetaController::$calls)->toBe(2); +}); + +it('does not reuse a route-file alias decision for a called annotated action', function (): void { + /** @var GatedRoutesTestCase $this */ + Route::get('/alias-calls-closed', static fn (): array => app(BetaController::class)->closed()) + ->middleware('feature-flag:missing-nested-gate,,true'); + + $this->getJson('/alias-calls-closed')->assertStatus(404); + expect(BetaController::$calls)->toBe(0); +}); + +it('gates on a variant and evaluates once per request', function (): void { + /** @var GatedRoutesTestCase $this */ + /** @var list $evaluations */ + $evaluations = []; + Event::listen(FeatureFlagEvaluated::class, function (FeatureFlagEvaluated $event) use (&$evaluations): void { + $evaluations[] = $event; + }); + + $this->getJson('/beta/v2')->assertOk(); + $this->overrideFlags(['beta-api' => false, 'checkout-flow' => 'v1']); + $this->getJson('/beta/v2')->assertStatus(404); + + expect(array_map(static fn (FeatureFlagEvaluated $event): string => $event->key, $evaluations))->toBe(['checkout-flow', 'checkout-flow']) + ->and(BetaController::$calls)->toBe(1); +}); + +it('leaves an unannotated action alone', function (): void { + /** @var GatedRoutesTestCase $this */ + $this->getJson('/beta/open')->assertOk()->assertJson(['open' => true]); +}); + +it('gates a route declared in a route file through the feature-flag alias', function (): void { + /** @var GatedRoutesTestCase $this */ + Route::get('/legacy-report', static fn (): string => 'report')->middleware('feature-flag:beta-api'); + Route::get('/legacy-v2', static fn (): string => 'v2')->middleware('feature-flag:checkout-flow,v2'); + + $this->get('/legacy-report')->assertStatus(404); + $this->get('/legacy-v2')->assertOk()->assertSee('v2'); +}); + +it('honors the route-file default for missing flags and variants', function (): void { + /** @var GatedRoutesTestCase $this */ + Route::get('/missing-closed', static fn (): string => 'closed')->middleware('feature-flag:missing'); + Route::get('/missing-open', static fn (): string => 'open')->middleware('feature-flag:missing,,true'); + Route::get('/missing-variant-open', static fn (): string => 'open')->middleware('feature-flag:missing,v2,true'); + + $this->get('/missing-closed')->assertStatus(404); + $this->get('/missing-open')->assertOk()->assertSee('open'); + $this->get('/missing-variant-open')->assertOk()->assertSee('open'); +}); + +it('registers the Blade directives with the view layer', function (): void { + /** @var GatedRoutesTestCase $this */ + expect(trim(Blade::render("@featureflag('beta-api') yes @else no @endfeatureflag")))->toBe('no'); +}); + +it('keeps a domain route with the same path outside the compiled attribute gate', function (): void { + /** @var GatedRoutesTestCase $this */ + /** @var Router $router */ + $router = $this->app()->make('router'); + $plain = null; + foreach ($router->getRoutes()->getRoutes() as $route) { + if ($route->uri() === 'beta' && $route->getDomain() === null) { + $plain = $route; + $action = $route->getAction(); + if (! is_array($action)) { + throw new LogicException('Expected a route action array.'); + } + $action['middleware'] = []; + $route->setAction($action); + } + } + $domain = $router->post('/other', static fn (): array => ['unrelated' => true])->domain('other.example')->setUri('beta'); + $config = new Config(new Repository); + $context = new BootContext( + $this->app(), new BeanDefinitionRegistry, $config, + new Profiles([]), new ConditionEvaluator($config, new Profiles([])), + new ConditionEvaluationReport, + ); + (new FeatureFlagRouteGatingPass)->run($context); + expect($plain?->middleware())->toContain('feature-flag:beta-api,,false') + ->and($domain->middleware())->toBe([]); + $this->postJson('/beta', ['not' => 'a payload'])->assertStatus(404); + expect(BetaController::$calls)->toBe(0); +}); diff --git a/packages/feature-flags/tests/Management/FeatureFlagsHealthIndicatorTest.php b/packages/feature-flags/tests/Management/FeatureFlagsHealthIndicatorTest.php new file mode 100644 index 00000000..add37fb6 --- /dev/null +++ b/packages/feature-flags/tests/Management/FeatureFlagsHealthIndicatorTest.php @@ -0,0 +1,92 @@ + $sources */ +function featureFlagsHealth(array $sources, ?Closure $today = null): FeatureFlagsHealthIndicator +{ + $illuminate = new Container; + if ($sources !== []) { + $illuminate->instance(FlagRegistry::class, new FlagRegistry($sources, new CacheBook(new Repository(new ArrayStore), new RecordingLogger), new RecordingApplicationEventPublisher)); + } + + return new FeatureFlagsHealthIndicator(new FireflyContainer($illuminate, new ComponentManifest([])), $today ?? static fn (): string => '2026-10-01'); +} + +it('registers as `featureflags`, and only while a Firefly registry exists', function (): void { + expect(HealthContributorRegistrar::nameFor(FeatureFlagsHealthIndicator::class))->toBe('featureflags') + ->and(featureFlagsHealth([])->available())->toBeFalse() + ->and(featureFlagsHealth([new StubFlagSource('config', 100, 0.0, ['a' => true], failsStartup: true)])->available())->toBeTrue(); +}); + +it('is UP with per-source details, the flag count and the expired keys', function (): void { + $health = featureFlagsHealth([new StubFlagSource('config', 100, 0.0, ['a' => true, 'old' => [ + 'state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on', 'metadata' => ['expires' => '2025-01-01'], + ]], failsStartup: true)])->health(); + + /** @var array{sources: array, flags: int, expired: list} $details */ + $details = $health->details; + + expect($health->status)->toBe(Status::Up) + ->and($details['flags'])->toBe(2) + ->and($details['expired'])->toBe(['old']) + ->and($details['sources'])->toHaveKey('config'); +}); + +it('is DOWN only while an enabled source has never loaded', function (): void { + $http = new StubFlagSource('http', 300, 30.0); + $http->failure = new FlagSourceUnavailable('control plane unreachable'); + + $health = featureFlagsHealth([new StubFlagSource('config', 100, 0.0, ['a' => true], failsStartup: true), $http])->health(); + + /** @var array{sources: array} $details */ + $details = $health->details; + + expect($health->status)->toBe(Status::Down) + ->and($details['sources']['http']['status'])->toBe('DOWN') + ->and($details['sources']['http']['error'])->toContain('control plane unreachable'); +}); + +it('stays UP with a STALE source serving its last good flags', function (): void { + $source = new StubFlagSource('http', 300, 0.0, ['still-live' => true]); + $illuminate = new Container; + $registry = new FlagRegistry([$source], new CacheBook(new Repository(new ArrayStore), new RecordingLogger), new RecordingApplicationEventPublisher); + $registry->refresh(); + $source->failure = new FlagSourceUnavailable('control plane unreachable'); + $registry->refresh(force: true); + $illuminate->instance(FlagRegistry::class, $registry); + $health = (new FeatureFlagsHealthIndicator(new FireflyContainer($illuminate, new ComponentManifest([])), static fn (): string => '2026-10-01'))->health(); + $sources = $health->details['sources'] ?? null; + if (! is_array($sources) || ! is_array($sources['http'] ?? null)) { + throw new RuntimeException('The health response omitted the http source.'); + } + + expect($health->status)->toBe(Status::Up) + ->and($health->details['flags'])->toBe(1) + ->and($sources['http']['status'])->toBe('STALE') + ->and($sources['http']['error'])->toContain('control plane unreachable'); +}); + +it('uses its injected UTC date for expiry', function (): void { + $source = new StubFlagSource('config', 100, 0.0, ['deadline' => [ + 'state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on', 'metadata' => ['expires' => '2026-10-01'], + ]]); + + expect(featureFlagsHealth([$source], static fn (): string => '2026-10-01')->health()->details['expired'])->toBe([]) + ->and(featureFlagsHealth([$source], static fn (): string => '2026-10-02')->health()->details['expired'])->toBe(['deadline']); +}); diff --git a/packages/feature-flags/tests/Management/FlagManagementTest.php b/packages/feature-flags/tests/Management/FlagManagementTest.php new file mode 100644 index 00000000..ece9430d --- /dev/null +++ b/packages/feature-flags/tests/Management/FlagManagementTest.php @@ -0,0 +1,333 @@ +error; + } + + throw new RuntimeException('The operation was not refused.'); +} + +it('maps every error code to its LaraFly status', function (string $error, int $status): void { + expect(ManagementError::from($error)->status())->toBe($status); +})->with([ + ['writes-disabled', 403], ['not-writable', 409], ['invalid-definition', 422], + ['unknown-flag', 404], ['unknown-variant', 422], ['conflict', 409], + ['bad-request', 400], +]); + +it('lists the provider, the enabled sources in precedence order and every flag (I-1)', function (): void { + $overview = (new ManagementFixtures(ManagementFixtures::flags()))->management->overview(); + + expect($overview['provider'])->toBe(['name' => 'firefly', 'status' => 'READY']) + ->and([$overview['writable'], $overview['writesEnabled']])->toBe([true, true]) + ->and(array_map(static fn (array $source): array => [$source['name'], $source['enabled'], $source['status']], $overview['sources'])) + ->toBe([['config', true, 'UP'], ['store', true, 'UP']]) + ->and($overview['sources'][0]['lastRefresh'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/') + ->and($overview['sources'][1]['revision'])->toBe('0') + ->and(array_column($overview['flags'], 'key'))->toBe(['checkout-flow', 'kill-switch', 'legacy-export']) + ->and($overview['flags'][0])->toMatchArray(['state' => 'ENABLED', 'type' => 'string', 'variants' => ['v1', 'v2'], 'defaultVariant' => 'v1', 'targeting' => true, 'origin' => 'config', 'overrides' => [], 'expired' => false, 'version' => null]) + ->and($overview['flags'][2]['expired'])->toBeTrue() + ->and(Json::encode($overview['flags'][1]['metadata']))->toBe('{}'); +}); + +it('reports source state from the same refreshed composition as its flag list', function (): void { + $fixtures = new ManagementFixtures([], refreshInterval: 0.0); + $fixtures->store->put('checkout', Json::members(Json::decode('{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off"}')), 'other-process'); + + $overview = $fixtures->management->overview(); + + expect($overview['sources'][1]['revision'])->toBe('1') + ->and(array_column($overview['flags'], 'key'))->toBe(['checkout']); +}); + +it('describes one flag with its layers, version and history, and refuses an unknown key', function (): void { + $fixtures = new ManagementFixtures(ManagementFixtures::flags()); + $fixtures->management->apply('kill-switch', ['action' => 'enable'], 'actuator'); + + $described = $fixtures->management->describe('kill-switch'); + + expect(Json::canonical($described['definition']))->toBe('{"defaultVariant":"off","state":"ENABLED","variants":{"off":false,"on":true}}') + ->and($described['origin'])->toBe('store') + ->and(array_column($described['layers'], 'source'))->toBe(['config', 'store']) + ->and($described['version'])->toBe(1) + ->and($described['history'])->toBe([['id' => 1, 'action' => 'put', 'actor' => 'actuator', 'changedAt' => '2026-10-01T12:00:00Z']]) + ->and(featureFlagsManagementRefusal(fn () => $fixtures->management->describe('nope')))->toBe(ManagementError::UnknownFlag); +}); + +it('evaluates with the flag\'s own type, explicit context only, and refuses an unknown key (I-2, I-3)', function (): void { + $application = new ApplicationEvaluationContextContributor(new Repository(['app' => ['name' => 'shop']])); + $management = (new ManagementFixtures([ + ...ManagementFixtures::flags(), + 'shop-only' => ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['if' => [['==' => [['var' => 'application'], 'shop']], 'on', null]]], + ], preview: [$application]))->management; + + expect($management->evaluate('checkout-flow', ['plan' => 'pro']))->toMatchArray(['key' => 'checkout-flow', 'value' => 'v2', 'variant' => 'v2', 'reason' => 'TARGETING_MATCH', 'errorCode' => null]) + ->and($management->evaluate('checkout-flow')['value'])->toBe('v1') + ->and($management->evaluate('shop-only')['value'])->toBeTrue() + ->and(featureFlagsManagementRefusal(fn () => $management->evaluate('nope')))->toBe(ManagementError::UnknownFlag); +}); + +it('uses an empty JSON object for an object-typed preview default', function (): void { + $management = (new ManagementFixtures([ + 'object-flag' => ['state' => 'DISABLED', 'variants' => ['object' => Json::decode('{"name":"value"}')]], + ]))->management; + + expect(Json::encode($management->evaluate('object-flag')['value']))->toBe('{}'); +}); + +it('copies the effective definition into the store for enable, disable and default-variant', function (): void { + $fixtures = new ManagementFixtures(ManagementFixtures::flags()); + + $enabled = $fixtures->management->apply('kill-switch', ['action' => 'disable'], 'actuator'); + $defaulted = $fixtures->management->apply('checkout-flow', ['action' => 'default-variant', 'variant' => 'v2', 'expectedVersion' => 0], 'actuator'); + + /** @var FeatureFlagUpdated $updated */ + $updated = $fixtures->events->ofType(FeatureFlagUpdated::class)[1]; + + expect([$enabled['origin'], $enabled['version'], Json::members($enabled['definition'])['state']])->toBe(['store', 1, 'DISABLED']) + ->and(Json::members($defaulted['definition'])['defaultVariant'])->toBe('v2') + ->and(Json::encode(Json::members($defaulted['definition'])['targeting']))->toBe('{"if":[{"==":[{"var":"plan"},"pro"]},"v2",null]}') + ->and([$updated->key, $updated->action, $updated->actor])->toBe(['checkout-flow', 'put', 'actuator']); +}); + +it('refuses writes with the contract codes', function (): void { + $fixtures = new ManagementFixtures(ManagementFixtures::flags()); + $fixtures->management->apply('kill-switch', ['action' => 'enable'], 'actuator'); + $management = $fixtures->management; + + expect(featureFlagsManagementRefusal(fn () => $management->apply('kill-switch', ['action' => 'disable', 'expectedVersion' => 7], 'actuator')))->toBe(ManagementError::Conflict) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('checkout-flow', ['action' => 'default-variant', 'variant' => 'v9'], 'actuator')))->toBe(ManagementError::UnknownVariant) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('checkout-flow', ['action' => 'default-variant'], 'actuator')))->toBe(ManagementError::BadRequest) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('x', ['action' => 'put', 'definition' => ['state' => 'ON']], 'actuator')))->toBe(ManagementError::InvalidDefinition) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('x', ['action' => 'put'], 'actuator')))->toBe(ManagementError::BadRequest) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('nope', ['action' => 'enable'], 'actuator')))->toBe(ManagementError::UnknownFlag) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('legacy-export', ['action' => 'delete'], 'actuator')))->toBe(ManagementError::UnknownFlag) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('kill-switch', [], 'actuator')))->toBe(ManagementError::BadRequest) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('kill-switch', ['action' => 'rename'], 'actuator')))->toBe(ManagementError::BadRequest) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('kill-switch', ['action' => 'enable', 'expectedVersion' => -1], 'actuator')))->toBe(ManagementError::BadRequest) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('kill-switch', ['action' => 'evaluate', 'context' => 'pro'], 'actuator')))->toBe(ManagementError::BadRequest) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('kill-switch', ['action' => 'evaluate', 'targetingKey' => 7], 'actuator')))->toBe(ManagementError::BadRequest) + ->and(featureFlagsManagementRefusal(fn () => (new ManagementFixtures(ManagementFixtures::flags(), writes: false))->management->apply('kill-switch', ['action' => 'enable'], 'actuator')))->toBe(ManagementError::WritesDisabled) + ->and(featureFlagsManagementRefusal(fn () => (new ManagementFixtures(ManagementFixtures::flags(), store: false))->management->apply('kill-switch', ['action' => 'enable'], 'actuator')))->toBe(ManagementError::NotWritable) + // writes-disabled outranks not-writable: the switch is the first thing an operator must turn. + ->and(featureFlagsManagementRefusal(fn () => (new ManagementFixtures(ManagementFixtures::flags(), writes: false, store: false))->management->apply('kill-switch', ['action' => 'enable'], 'actuator')))->toBe(ManagementError::WritesDisabled); +}); + +it('answers deleted:true only when no layer defines the key any more', function (): void { + $fixtures = new ManagementFixtures(ManagementFixtures::flags()); + $fixtures->management->apply('banner', ['action' => 'put', 'definition' => Json::decode('{"state":"ENABLED","variants":{"none":{}},"defaultVariant":"none"}')], 'actuator'); + $fixtures->management->apply('kill-switch', ['action' => 'enable'], 'actuator'); + + expect($fixtures->management->apply('banner', ['action' => 'delete'], 'actuator'))->toBe(['key' => 'banner', 'deleted' => true]) + ->and($fixtures->management->apply('kill-switch', ['action' => 'delete'], 'actuator')['origin'])->toBe('config'); +}); + +it('records the principal when an actor source names one, and the surface\'s fallback otherwise (I-6)', function (): void { + $principal = new class implements FlagActorSource + { + public ?string $name = 'ada'; + + public function actor(): ?string + { + return $this->name; + } + }; + $broken = new class implements FlagActorSource + { + public function actor(): ?string + { + throw new RuntimeException('security context unavailable'); + } + }; + $fixtures = new ManagementFixtures(ManagementFixtures::flags(), actors: [$broken, $principal]); + + $fixtures->management->apply('kill-switch', ['action' => 'enable'], 'actuator'); + $principal->name = null; + $fixtures->management->apply('kill-switch', ['action' => 'disable'], 'admin'); + + expect(array_column($fixtures->management->describe('kill-switch')['history'], 'actor'))->toBe(['admin', 'ada']); +}); + +it('answers an empty read-only overview and boolean previews over an application\'s own provider (I-2)', function (): void { + $settings = FeatureFlagsSettings::fromConfig(new Config(new Repository(['firefly' => ['feature-flags' => ['enabled' => true, 'management' => ['writes' => true]]]]))); + $management = new FlagManagement($settings, new FixedProvider); + + expect($management->overview())->toBe(['provider' => ['name' => 'fixed-vendor', 'status' => 'READY'], 'writable' => false, 'writesEnabled' => true, 'sources' => [], 'flags' => []]) + ->and($management->evaluate('anything'))->toMatchArray(['value' => true, 'variant' => 'vendor-on', 'reason' => 'STATIC']) + ->and(featureFlagsManagementRefusal(fn () => $management->describe('anything')))->toBe(ManagementError::UnknownFlag) + ->and(featureFlagsManagementRefusal(fn () => $management->apply('anything', ['action' => 'enable'], 'actuator')))->toBe(ManagementError::NotWritable); +}); + +it('uses the five shared management receipt vectors through a real registry and writer', function (): void { + $contents = file_get_contents(__DIR__.'/../Conformance/management-vectors.json'); + if ($contents === false) { + throw new RuntimeException('management vectors could not be read'); + } + /** @var array{cases: list} $vectors */ + $vectors = Json::members(Json::decode($contents)); + foreach ($vectors['cases'] as $case) { + $store = new MemoryFlagStore(FlagStores::clock()); + if ($case['initialDefinition'] !== null) { + $store->put('checkout', Json::members($case['initialDefinition']), 'setup'); + } + $source = new class($store) implements FlagSource + { + public string $mode = 'accept'; + + public function __construct(private readonly MemoryFlagStore $store) {} + + public function name(): string + { + return self::STORE; + } + + public function precedence(): int + { + return 400; + } + + public function refreshInterval(): float + { + return 0.0; + } + + public function failsStartup(): bool + { + return false; + } + + public function reportedRevision(?string $revision): ?string + { + return $revision; + } + + public function load(?string $knownRevision): ?SourceSnapshot + { + if ($this->mode === 'fail') { + throw new FlagSourceUnavailable('store unavailable'); + } + if ($this->mode === 'refuse') { + throw new InvalidFlagDefinition('checkout', 'state must be ENABLED or DISABLED'); + } + $revision = (string) $this->store->revision(); + if ($knownRevision === $revision) { + return null; + } + $flags = []; + foreach ($this->store->all() as $row) { + $flags[$row->key] = $row->definition; + } + + return new SourceSnapshot(FlagDefinitions::parseDocument(['flags' => Json::object($flags)]), $revision); + } + }; + $events = new RecordingApplicationEventPublisher; + $registry = new FlagRegistry([$source], new CacheBook(new Cache(new ArrayStore), new NullLogger), $events); + $registry->start(); + $settings = FeatureFlagsSettings::fromConfig(new Config(new Repository(['firefly' => ['feature-flags' => ['enabled' => true, 'management' => ['writes' => true]]]]))); + $management = new FlagManagement($settings, new FireflyFlagProvider($registry, new DefaultFlagdEvaluator), $registry, new FlagStoreWriter($store, $registry, $events), new EvaluationContextResolver); + $source->mode = $case['refresh']; + $body = ['action' => $case['action']]; + if (isset($case['definition'])) { + $body['definition'] = $case['definition']; + } + $receipt = $management->apply('checkout', $body, 'actuator'); + if (isset($case['expect']['body'])) { + expect(Json::canonical($receipt))->toBe(Json::canonical($case['expect']['body']), $case['name']); + } elseif (array_key_exists('detailDefinition', $case['expect'])) { + expect(Json::canonical($receipt['definition']))->toBe(Json::canonical($case['expect']['detailDefinition']), $case['name']); + } else { + throw new RuntimeException('management vector has no expected receipt or detail'); + } + } +}); + +it('keeps accepted writes and visible reads when store diagnostics fail', function (): void { + $fixture = new ManagementFixtures(ManagementFixtures::flags()); + $diagnosticStore = new class($fixture->store) implements FlagStore + { + public function __construct(private readonly MemoryFlagStore $delegate) {} + + public function all(): array + { + throw new RuntimeException('diagnostic all failed'); + } + + public function get(string $key): ?StoredFlag + { + return $this->delegate->get($key); + } + + public function revision(): int + { + return $this->delegate->revision(); + } + + public function put(string $key, array $definition, ?string $actor, ?int $expectedVersion = null): FlagChange + { + return $this->delegate->put($key, $definition, $actor, $expectedVersion); + } + + public function delete(string $key, ?string $actor, ?int $expectedVersion = null): ?FlagChange + { + return $this->delegate->delete($key, $actor, $expectedVersion); + } + + public function history(string $key, int $limit = 50): array + { + throw new RuntimeException('diagnostic history failed'); + } + }; + $settings = FeatureFlagsSettings::fromConfig(new Config(new Repository(['firefly' => ['feature-flags' => ['enabled' => true, 'management' => ['writes' => true]]]]))); + $warnings = new RecordingLogger; + $management = new FlagManagement($settings, new FireflyFlagProvider($fixture->registry, new DefaultFlagdEvaluator), $fixture->registry, new FlagStoreWriter($diagnosticStore, $fixture->registry, $fixture->events), logger: $warnings); + + $written = $management->apply('kill-switch', ['action' => 'enable'], 'actuator'); + + expect($written['origin'])->toBe('store') + ->and($written['history'])->toBe([]) + ->and(array_column($management->overview()['flags'], 'key'))->toContain('kill-switch') + ->and($warnings->count('warning', 'diagnostic history failed'))->toBe(1) + ->and($warnings->count('warning', 'diagnostic all failed'))->toBe(1); +}); diff --git a/packages/feature-flags/tests/Management/FlagsEndpointExposureTest.php b/packages/feature-flags/tests/Management/FlagsEndpointExposureTest.php new file mode 100644 index 00000000..a0fc70e5 --- /dev/null +++ b/packages/feature-flags/tests/Management/FlagsEndpointExposureTest.php @@ -0,0 +1,21 @@ + 'health']; + } +} + +uses(FeatureFlagsUnexposedEndpointTestCase::class); + +it('does not route flags without explicit exposure', function (): void { + /** @var FeatureFlagsUnexposedEndpointTestCase $this */ + $this->getJson('/actuator/flags')->assertNotFound(); + $this->postJson('/actuator/flags/2024', ['action' => 'evaluate'])->assertNotFound(); +}); diff --git a/packages/feature-flags/tests/Management/FlagsEndpointHttpTest.php b/packages/feature-flags/tests/Management/FlagsEndpointHttpTest.php new file mode 100644 index 00000000..b0fea9c6 --- /dev/null +++ b/packages/feature-flags/tests/Management/FlagsEndpointHttpTest.php @@ -0,0 +1,71 @@ +postJson('/actuator/flags/0?via=admin&origin=admin', [ + 'action' => 'enable', + 'origin' => 'admin', + 'via' => 'admin', + ], ['X-Origin' => 'admin'])->assertOk(); + + $this->getJson('/actuator/flags/0')->assertOk()->assertJsonPath('history.0.actor', 'actuator'); +}); + +it('honors the per-endpoint enable switch', function (): void { + /** @var ManagementEndpointTestCase $this */ + $this->getJson('/actuator/flags')->assertOk(); + $this->app()->make(Repository::class)->set('firefly.management.endpoint.flags.enabled', false); + $this->getJson('/actuator/flags')->assertNotFound(); +}); + +it('lists numeric-looking flag keys as strings (RF3)', function (): void { + /** @var ManagementEndpointTestCase $this */ + $response = $this->getJson('/actuator/flags')->assertOk(); + + expect($response->headers->get('Content-Type'))->toContain('application/json') + ->and((string) $response->getContent())->toContain('"key":"0"') + ->and((string) $response->getContent())->toContain('"key":"2024"') + ->and((string) $response->getContent())->toContain('"provider":{"name":"firefly","status":"READY"}'); +}); + +it('keeps {} and 1.0 through POST and GET /actuator/flags (RF4)', function (): void { + /** @var ManagementEndpointTestCase $this */ + $this->call('POST', '/actuator/flags/banner', [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], + '{"action":"put","definition":{"state":"ENABLED","variants":{"none":{},"promo":{"t":"x"}},"defaultVariant":"none","metadata":{}}}') + ->assertOk(); + + expect((string) $this->getJson('/actuator/flags/banner')->assertOk()->getContent()) + ->toContain('"metadata":{}') + ->toContain('"variants":{"none":{},"promo":{"t":"x"}}') + ->and((string) $this->getJson('/actuator/flags/price')->assertOk()->getContent()) + ->toContain('"variants":{"a":1.0,"b":2.5}'); +}); + +it('evaluates over HTTP and answers 404 with the contract body for an unknown key', function (): void { + /** @var ManagementEndpointTestCase $this */ + $this->postJson('/actuator/flags/2024', ['action' => 'evaluate']) + ->assertOk() + ->assertJson(['key' => '2024', 'value' => 'v2', 'variant' => 'v2', 'reason' => 'STATIC', 'errorCode' => null]); + + $this->getJson('/actuator/flags/nope')->assertStatus(404)->assertJson(['error' => 'unknown-flag']); +}); + +it('refuses an explicit empty-array preview context through the HTTP route', function (): void { + /** @var ManagementEndpointTestCase $this */ + $this->postJson('/actuator/flags/2024', ['action' => 'evaluate', 'context' => []]) + ->assertStatus(400) + ->assertJson(['error' => 'bad-request']); +}); + +it('rejects malformed JSON and preserves the portable error body', function (): void { + /** @var ManagementEndpointTestCase $this */ + $response = $this->call('POST', '/actuator/flags/banner', [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], '{bad'); + $response->assertStatus(400)->assertJsonStructure(['error', 'message'])->assertJson(['error' => 'bad-request']); +}); diff --git a/packages/feature-flags/tests/Management/FlagsEndpointTest.php b/packages/feature-flags/tests/Management/FlagsEndpointTest.php new file mode 100644 index 00000000..00974750 --- /dev/null +++ b/packages/feature-flags/tests/Management/FlagsEndpointTest.php @@ -0,0 +1,144 @@ +instance(FlagManagement::class, $management); + } + + return new FlagsEndpoint(new FireflyContainer($illuminate, new ComponentManifest([]))); +} + +/** @return array{0: int, 1: string, 2: string} status, content type, body */ +function featureFlagsAnswer(?EndpointResponse $response): array +{ + expect($response)->not->toBeNull(); + /** @var EndpointResponse $response */ + expect($response->body)->toBeString(); + /** @var string $body */ + $body = $response->body; + + return [$response->status, $response->contentType, $body]; +} + +it('is the endpoint `flags`, enabled only with FlagManagement, and declines other shapes', function (): void { + $endpoint = featureFlagsEndpoint((new ManagementFixtures(ManagementFixtures::flags()))->management); + + expect($endpoint->endpointId())->toBe('flags') + ->and($endpoint->enabled())->toBeTrue() + ->and(featureFlagsEndpoint(null)->enabled())->toBeFalse() + ->and(featureFlagsEndpoint(null)->handle(new EndpointRequest('GET', [])))->toBeNull() + ->and($endpoint->handle(new EndpointRequest('GET', ['a', 'b'])))->toBeNull() + ->and($endpoint->handle(new EndpointRequest('POST', [])))->toBeNull() + ->and($endpoint->handle(new EndpointRequest('DELETE', ['kill-switch'])))->toBeNull(); +}); + +it('answers every refusal as {error, message} with its status', function (string $case, int $status): void { + $flags = ManagementFixtures::flags(); + $request = match ($case) { + 'writes-disabled' => [(new ManagementFixtures($flags, writes: false))->management, 'kill-switch', ['action' => 'enable'], null], + 'not-writable' => [(new ManagementFixtures($flags, store: false))->management, 'kill-switch', ['action' => 'enable'], null], + 'invalid-definition' => [(new ManagementFixtures($flags))->management, 'x', [], '{"action":"put","definition":{"state":"ON"}}'], + 'unknown-flag' => [(new ManagementFixtures($flags))->management, 'nope', ['action' => 'evaluate'], null], + 'unknown-variant' => [(new ManagementFixtures($flags))->management, 'checkout-flow', ['action' => 'default-variant', 'variant' => 'v9'], null], + 'conflict' => [(new ManagementFixtures($flags))->management, 'kill-switch', ['action' => 'enable', 'expectedVersion' => 3], null], + default => [(new ManagementFixtures($flags))->management, 'kill-switch', [], '{nope'], + }; + [$management, $key, $body, $raw] = $request; + + [$answered, $type, $text] = featureFlagsAnswer(featureFlagsEndpoint($management)->handle(new EndpointRequest('POST', [$key], [], $body, $raw))); + + expect([$answered, $type])->toBe([$status, 'application/json']) + ->and($text)->toStartWith('{"error":"'.$case.'","message":"'); +})->with([ + ['writes-disabled', 403], + ['not-writable', 409], + ['invalid-definition', 422], + ['unknown-flag', 404], + ['unknown-variant', 422], + ['conflict', 409], + ['bad-request', 400], +]); + +it('records admin only for a trusted in-process origin', function (): void { + $fixtures = new ManagementFixtures(ManagementFixtures::flags()); + $endpoint = featureFlagsEndpoint($fixtures->management); + + $endpoint->handle(new EndpointRequest('POST', ['kill-switch'], [], ['action' => 'enable'], origin: 'admin')); + $endpoint->handle(new EndpointRequest('POST', ['kill-switch'], ['via' => 'admin'], ['action' => 'disable'])); + + expect(array_column($fixtures->management->describe('kill-switch')['history'], 'actor'))->toBe(['actuator', 'admin']); +}); + +it('keeps the authenticated actor ahead of the trusted admin origin', function (): void { + $principal = new class implements FlagActorSource + { + public function actor(): string + { + return 'operator'; + } + }; + $fixtures = new ManagementFixtures(ManagementFixtures::flags(), actors: [$principal]); + $endpoint = featureFlagsEndpoint($fixtures->management); + + $endpoint->handle(new EndpointRequest('POST', ['kill-switch'], [], ['action' => 'enable'], origin: 'admin')); + + expect(array_column($fixtures->management->describe('kill-switch')['history'], 'actor'))->toBe(['operator']); +}); + +it('serves an external provider as read-only with an empty overview', function (): void { + $settings = FeatureFlagsSettings::fromConfig(new Config(new Repository(['firefly' => ['feature-flags' => ['enabled' => true, 'management' => ['writes' => true]]]]))); + $endpoint = featureFlagsEndpoint(new FlagManagement($settings, new FixedProvider)); + + [$status, , $body] = featureFlagsAnswer($endpoint->handle(new EndpointRequest('GET', []))); + + expect($status)->toBe(200) + ->and($body)->toContain('"provider":{"name":"fixed-vendor","status":"READY"}') + ->and($body)->toContain('"writable":false') + ->and($body)->toContain('"sources":[],"flags":[]'); +}); + +it('passes an accepted write with deferred visibility through as refreshPending', function (): void { + $store = new MemoryFlagStore(FlagStores::clock()); + $events = new RecordingApplicationEventPublisher; + $registry = new FlagRegistry([new StubFlagSource('store', 400, 0.0)], new CacheBook(new Cache(new ArrayStore), new RecordingLogger), $events); + $registry->start(); + $settings = FeatureFlagsSettings::fromConfig(new Config(new Repository(['firefly' => ['feature-flags' => ['enabled' => true, 'management' => ['writes' => true]]]]))); + $management = new FlagManagement($settings, new FireflyFlagProvider($registry, new DefaultFlagdEvaluator), $registry, new FlagStoreWriter($store, $registry, $events), new EvaluationContextResolver); + + [$status, , $body] = featureFlagsAnswer(featureFlagsEndpoint($management)->handle(new EndpointRequest('POST', ['new'], rawBody: '{"action":"put","definition":{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on"}}'))); + + expect($status)->toBe(200) + ->and($body)->toBe('{"key":"new","refreshPending":true}') + ->and($store->get('new'))->not->toBeNull(); +}); diff --git a/packages/feature-flags/tests/Management/ManagementBootTest.php b/packages/feature-flags/tests/Management/ManagementBootTest.php new file mode 100644 index 00000000..c65e3407 --- /dev/null +++ b/packages/feature-flags/tests/Management/ManagementBootTest.php @@ -0,0 +1,67 @@ + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider)); + +/** @param array $featureFlags */ +function featureFlagsManagementApp(array $featureFlags): Application +{ + return fireflyApplication( + ['app' => ['name' => 'shop'], 'firefly' => [ + 'feature-flags' => $featureFlags, + 'scan' => ['paths' => ['Firefly\\FeatureFlags\\Tests\\Fixtures\\AmbientContext\\' => dirname(__DIR__).'/Fixtures/AmbientContext']], + ]], + [FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + needs: ['cache'], + ); +} + +it('wires FlagManagement only while the subsystem is enabled', function (): void { + expect(featureFlagsManagementApp(['enabled' => true])->make(ApplicationContext::class)->get(FlagManagement::class))->toBeInstanceOf(FlagManagement::class) + ->and(featureFlagsManagementApp(['enabled' => false])->bound(FlagManagement::class))->toBeFalse(); +}); + +it('previews with the explicit context and the application attributes only, recording no exposure (I-3)', function (): void { + $app = featureFlagsManagementApp([ + 'enabled' => true, + 'events' => ['evaluations' => true], + 'flags' => [ + 'tiered' => ['state' => 'ENABLED', 'variants' => ['gold' => 'gold', 'base' => 'base'], 'defaultVariant' => 'base', + 'targeting' => ['if' => [['==' => [['var' => 'tier'], 'gold']], 'gold', null]]], + 'shop-only' => ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['if' => [['==' => [['var' => 'application'], 'shop']], 'on', null]]], + ], + ]); + $exposures = 0; + $app->make(Dispatcher::class)->listen(FeatureFlagEvaluated::class, static function () use (&$exposures): void { + $exposures++; + }); + $context = $app->make(ApplicationContext::class); + /** @var FeatureFlags $flags */ + $flags = $context->get(FeatureFlags::class); + /** @var FlagManagement $management */ + $management = $context->get(FlagManagement::class); + + $ambient = $flags->getString('tiered', 'x'); + $previews = [ + $management->evaluate('tiered')['value'], + $management->evaluate('tiered', ['tier' => 'gold'])['value'], + $management->evaluate('shop-only')['value'], + ]; + + expect($ambient)->toBe('gold') + ->and($previews)->toBe(['base', 'gold', true]) + ->and($exposures)->toBe(1); +}); diff --git a/packages/feature-flags/tests/Management/ManagementTransportConformanceTest.php b/packages/feature-flags/tests/Management/ManagementTransportConformanceTest.php new file mode 100644 index 00000000..b535606b --- /dev/null +++ b/packages/feature-flags/tests/Management/ManagementTransportConformanceTest.php @@ -0,0 +1,91 @@ +, cases: list} $vectors */ + $vectors = Json::members(Json::decode((string) file_get_contents(__DIR__.'/../Conformance/management-transport-vectors.json'))); + foreach ($vectors['cases'] as $case) { + if ('dataset "'.$case['name'].'"' === $this->dataName()) { + $options = isset($case['config']) ? Json::members($case['config']) : []; + + return [ + ...parent::configOverrides(), + 'firefly.feature-flags.flags' => $vectors['flags'], + 'firefly.feature-flags.management.writes' => $options['writes'] ?? true, + 'firefly.feature-flags.sources.store.enabled' => $options['store'] ?? true, + ]; + } + } + + throw new RuntimeException('Unknown management transport vector: '.$this->dataName()); + } +} + +uses(ManagementTransportConformanceTestCase::class); + +$contents = file_get_contents(__DIR__.'/../Conformance/management-transport-vectors.json'); +if ($contents === false) { + throw new RuntimeException('management transport vectors could not be read'); +} +/** @var array{cases: list, rawBody?: string, body?: array, expect: array{error?: string, actor?: string, value?: mixed, status: array{larafly: int}}}>} $vectors */ +$vectors = Json::members(Json::decode($contents)); +$cases = []; +foreach ($vectors['cases'] as $case) { + $cases[$case['name']] = [$case]; +} + +/** @param array $case */ +function featureFlagsManagementTransportRequest(ManagementTransportConformanceTestCase $test, array $case): void +{ + $method = $case['method'] ?? null; + $path = $case['path'] ?? null; + $expected = $case['expect'] ?? null; + if (! is_string($method) || ! is_string($path) || ! is_array($expected)) { + throw new RuntimeException('Invalid management transport request vector'); + } + $statuses = $expected['status'] ?? null; + $status = is_array($statuses) ? ($statuses['larafly'] ?? null) : null; + if (! is_int($status)) { + throw new RuntimeException('Invalid management transport response vector'); + } + $headers = ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json']; + $extraHeaders = $case['headers'] ?? []; + if (! is_array($extraHeaders)) { + throw new RuntimeException('Invalid management transport headers'); + } + foreach ($extraHeaders as $name => $value) { + if (! is_string($name) || ! is_string($value)) { + throw new RuntimeException('Invalid management transport header'); + } + $headers['HTTP_'.strtoupper(str_replace('-', '_', $name))] = $value; + } + $body = $case['rawBody'] ?? (isset($case['body']) ? Json::encode($case['body']) : null); + if ($body !== null && ! is_string($body)) { + throw new RuntimeException('Invalid management transport body'); + } + $response = $test->call($method, $path, [], [], [], $headers, $body); + $response->assertStatus($status); + $decoded = Json::members(Json::decode((string) $response->getContent())); + if (isset($expected['error'])) { + expect($decoded['error'])->toBe($expected['error']) + ->and($decoded['message'])->toBeString()->not->toBeEmpty(); + } + if (isset($expected['actor'])) { + $test->getJson('/actuator/flags/kill')->assertOk()->assertJsonPath('history.0.actor', $expected['actor']); + } + if (array_key_exists('value', $expected)) { + expect($decoded['value'] ?? null)->toBe($expected['value']); + } +} + +it('consumes the management transport vector through the HTTP route', function (array $case): void { + /** @var ManagementTransportConformanceTestCase $this */ + featureFlagsManagementTransportRequest($this, $case); +})->with($cases); diff --git a/packages/feature-flags/tests/PackageBootsTest.php b/packages/feature-flags/tests/PackageBootsTest.php new file mode 100644 index 00000000..e5dc2eb9 --- /dev/null +++ b/packages/feature-flags/tests/PackageBootsTest.php @@ -0,0 +1,36 @@ + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider)); + +it('boots green when discovered alongside the bootstrap provider and stays dark while disabled', function (): void { + $app = fireflyApplication( + ['firefly' => []], + [FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + needs: ['cache'], + ); + + expect($app->make(ApplicationContext::class))->toBeInstanceOf(ApplicationContext::class) + ->and($app->bound(FeatureFlagsSettings::class))->toBeFalse(); +}); + +it('binds the settings once firefly.feature-flags.enabled is on', function (): void { + $context = bootFireflyApp( + ['firefly' => ['feature-flags' => ['enabled' => true]]], + [FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + needs: ['cache'], + ); + + /** @var FeatureFlagsSettings $settings */ + $settings = $context->get(FeatureFlagsSettings::class); + + expect($settings->enabled)->toBeTrue(); +}); diff --git a/packages/feature-flags/tests/Provider/FireflyFlagProviderTest.php b/packages/feature-flags/tests/Provider/FireflyFlagProviderTest.php new file mode 100644 index 00000000..a450ba9f --- /dev/null +++ b/packages/feature-flags/tests/Provider/FireflyFlagProviderTest.php @@ -0,0 +1,130 @@ +getMetadata()->getName())->toBe('firefly') + ->and(FireflyFlagProvider::NAME)->toBe('firefly'); +}); + +it('resolves each OpenFeature type with variant and reason', function (): void { + $provider = featureFlagsProvider(); + $context = new EvaluationContext('u-1', new Attributes(['roles' => ['beta']])); + + $boolean = $provider->resolveBooleanValue('on', false, $context); + $missing = $provider->resolveStringValue('missing', 'dflt'); + + expect($boolean->getValue())->toBeTrue() + ->and($boolean->getVariant())->toBe('on') + ->and($boolean->getReason())->toBe('STATIC') + ->and($provider->resolveIntegerValue('size', 0)->getValue())->toBe(10) + ->and($provider->resolveFloatValue('size', 0.0)->getValue())->toBe(10.0) + ->and($provider->resolveObjectValue('banner', ['x' => 1])->getValue())->toBe([]) + ->and($missing->getValue())->toBe('dflt') + ->and($missing->getVariant())->toBeNull() + ->and($missing->getReason())->toBe('ERROR') + ->and($missing->getError()?->getResolutionErrorCode()->getValue())->toBe('FLAG_NOT_FOUND'); +}); + +it('keeps the metadata on its own resolution', function (): void { + expect(featureFlagsProvider()->resolution('on', FlagType::Boolean, false)->metadata)->toBe(['owner' => 'web']); +}); + +it('hands the evaluator the context\'s targeting key and attributes as they are', function (): void { + $evaluator = new StaticFlagdEvaluator; + $signup = new DateTime('2025-01-01T10:00:00.123456+02:00'); + + featureFlagsProvider(evaluator: $evaluator)->resolveBooleanValue('on', false, new EvaluationContext('u-1', new Attributes(['roles' => ['beta'], 'signupAt' => $signup]))); + + expect($evaluator->lastTargetingKey)->toBe('u-1') + ->and($evaluator->lastAttributes)->toBe(['roles' => ['beta'], 'signupAt' => $signup]); + + featureFlagsProvider(evaluator: $evaluator)->resolveBooleanValue('on', false); + + expect($evaluator->lastTargetingKey)->toBeNull() + ->and($evaluator->lastAttributes)->toBe([]); +}); + +it('never throws: an unreadable flag set is a GENERAL error carrying the default', function (): void { + $document = StaticDocument::json('{"flags":{}}'); + $document->broken = true; + + $resolution = featureFlagsProvider($document)->resolution('on', FlagType::Boolean, true); + + expect($resolution->value)->toBeTrue() + ->and($resolution->reason)->toBe(EvaluationReason::Error) + ->and($resolution->error)->toBe(EvaluationError::General) + ->and($resolution->errorMessage)->toContain('the cache is on fire'); +}); + +it('never throws: an evaluator that throws is a GENERAL error carrying the default, logged at DEBUG', function (): void { + $logger = new RecordingLogger; + $provider = featureFlagsProvider(evaluator: new class implements FlagdEvaluator + { + public function evaluate(FlagDocument $document, string $flagKey, FlagType $type, mixed $default, ?string $targetingKey = null, array $attributes = []): Resolution + { + throw new LogicException('the evaluator broke'); + } + }); + $provider->setLogger($logger); + + $details = $provider->resolveStringValue('on', 'fallback'); + + expect($details->getValue())->toBe('fallback') + ->and($details->getError()?->getResolutionErrorCode()->getValue())->toBe('GENERAL') + ->and($details->getError()?->getResolutionErrorMessage())->toContain('the evaluator broke') + ->and($logger->count('debug', 'Feature flag [on] evaluated with GENERAL'))->toBe(1); +}); + +it('hands out a deep copy of an object value, so a caller mutating it never reaches the stored definition', function (): void { + $document = StaticDocument::json('{"flags":{ + "listy":{"state":"ENABLED","variants":{"v":{"0":"zero","1":[{"0":"deep"}]}},"defaultVariant":"v"}, + "panel":{"state":"ENABLED","variants":{"v":{"title":"Hi","style":{},"items":[{"0":"x"}]}},"defaultVariant":"v"} + }}'); + $stored = $document->document->toJson(); + $provider = featureFlagsProvider($document); + + // A list-like object stays a stdClass in the faithful form: mutate a property and a nested list. + /** @var stdClass $listy */ + $listy = $provider->resolution('listy', FlagType::Object, [])->value; + $listy->{'0'} = 'changed'; + /** @var list $nested */ + $nested = $listy->{'1'}; + $nested[0]->{'0'} = 'changed'; + $nested[] = 'appended'; + $listy->{'1'} = $nested; + + // An ordinary object is an array whose empty and list-like members are stdClass instances. + /** @var array{title: string, style: stdClass, items: list} $panel */ + $panel = $provider->resolution('panel', FlagType::Object, [])->value; + $panel['style']->color = 'red'; + $panel['items'][0]->{'0'} = 'changed'; + + // The SDK's view holds plain arrays only (no shared instance to reach the definition through). + expect(Json::encode($provider->resolution('listy', FlagType::Object, [])->value))->toBe('{"0":"zero","1":[{"0":"deep"}]}') + ->and(Json::encode($provider->resolution('panel', FlagType::Object, [])->value))->toBe('{"title":"Hi","style":{},"items":[{"0":"x"}]}') + ->and($provider->resolveObjectValue('panel', [])->getValue())->toBe(['title' => 'Hi', 'style' => [], 'items' => [['x']]]) + ->and($document->document->toJson())->toBe($stored); +}); diff --git a/packages/feature-flags/tests/ReflectionFreeFeatureFlagsTest.php b/packages/feature-flags/tests/ReflectionFreeFeatureFlagsTest.php new file mode 100644 index 00000000..275b96aa --- /dev/null +++ b/packages/feature-flags/tests/ReflectionFreeFeatureFlagsTest.php @@ -0,0 +1,29 @@ + basenames of src files that reflect attributes, classes or methods */ +function featureFlagsReflectionHits(string $dir): array +{ + $hits = []; + /** @var iterable $files */ + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)); + foreach ($files as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + $contents = (string) file_get_contents((string) $file->getRealPath()); + // Reflection* CLASSES (the security guard's rule), plus an attribute read — getAttributes( with an + // argument. OpenFeature's EvaluationContext::getAttributes() takes none and is not reflection. + if (preg_match('/Reflection(Class|Method|Function|Parameter|Attribute)\b|->getAttributes\(\s*[A-Z\\\\]/', $contents) === 1) { + $hits[] = $file->getBasename(); + } + } + sort($hits); + + return $hits; +} + +it('confines firefly/feature-flags reflection to the single sanctioned FeatureFlagScanner', function (): void { + expect(featureFlagsReflectionHits(dirname(__DIR__).'/src'))->toBe(['FeatureFlagScanner.php']); +}); diff --git a/packages/feature-flags/tests/Registry/CacheBookTest.php b/packages/feature-flags/tests/Registry/CacheBookTest.php new file mode 100644 index 00000000..6e540d1a --- /dev/null +++ b/packages/feature-flags/tests/Registry/CacheBookTest.php @@ -0,0 +1,124 @@ +put('source:file', ['name' => 'file']); + $release = $book->lock('lock:file', 5); + + expect($book->get('source:file'))->toBe(['name' => 'file']) + ->and($cache->get('firefly:feature-flags:source:file'))->toBe(['name' => 'file']) + ->and($book->first('expired:old', 60))->toBeTrue() + ->and($book->first('expired:old', 60))->toBeFalse() + ->and($release)->not->toBeNull() + ->and($book->lock('lock:file', 5))->toBeNull() + ->and($book->degraded())->toBeFalse(); + + if ($release !== null) { + $release(); + } + + expect($book->lock('lock:file', 5))->not->toBeNull(); +}); + +it('grants a no-op lock on a store without atomic locks', function (): void { + $book = new CacheBook(new Repository(new SessionStore(new Session('flags', new ArraySessionHandler(10)))), new RecordingLogger); + + expect($book->lock('lock:http', 5))->not->toBeNull() + ->and($book->lock('lock:http', 5))->not->toBeNull(); +}); + +it('tolerates a failing store: nothing known, writes dropped, every caller first, a no-op lock', function (): void { + $cache = new FlakyCache; + $cache->down = true; + $logger = new RecordingLogger; + $book = new CacheBook($cache, $logger); + + $book->put('source:store', ['name' => 'store']); + + expect($book->get('source:store'))->toBeNull() + ->and($book->first('changed:1', 10))->toBeTrue() + ->and($book->first('changed:1', 10))->toBeTrue() + ->and($book->lock('lock:store', 5))->not->toBeNull() + ->and($book->degraded())->toBeTrue() + ->and($logger->count('warning', 'cannot reach the cache store (redis is gone)'))->toBe(1); +}); + +it('is degraded only until the store answers again, and warns once per outage', function (Closure $call): void { + $cache = new FlakyCache; + $logger = new RecordingLogger; + $book = new CacheBook($cache, $logger); + + $cache->down = true; + $call($book); + $whileDown = $book->degraded(); + $cache->down = false; + $call($book); + $afterRecovery = $book->degraded(); + $cache->down = true; + $call($book); + + expect($whileDown)->toBeTrue() + ->and($afterRecovery)->toBeFalse() + ->and($book->degraded())->toBeTrue() + ->and($logger->count('warning', 'cannot reach the cache store'))->toBe(2); +})->with([ + 'get' => [static fn (CacheBook $book): mixed => $book->get('composed')], + 'put' => [static function (CacheBook $book): void { + $book->put('composed', ['generation' => 1]); + }], + 'first' => [static fn (CacheBook $book): bool => $book->first('changed:1', 10)], + 'lock' => [static function (CacheBook $book): void { + $release = $book->lock('lock:store', 5); + if ($release !== null) { + $release(); + } + }], +]); + +it('tolerates a store that fails while releasing a lock: the refresh goes on and the flags evaluate', function (): void { + $failingRelease = static function (string $lock): void { + if (str_ends_with($lock, 'lock:http')) { + throw new RuntimeException('redis is gone'); + } + }; + $store = new HookedLockStore; + $store->onRelease = $failingRelease; + $logger = new RecordingLogger; + $book = new CacheBook(new Repository($store), $logger); + $release = $book->lock('lock:http', 5); + if ($release !== null) { + $release(); + } + $degraded = $book->degraded(); + + $bootStore = new HookedLockStore; + $bootStore->onRelease = $failingRelease; + $bootLogger = new RecordingLogger; + $registry = new FlagRegistry([new StubFlagSource('http', 300, 30.0, ['h' => true])], new CacheBook(new Repository($bootStore), $bootLogger), new RecordingApplicationEventPublisher, $bootLogger, (new FixedClock)(...)); + $registry->start(); + + expect($release)->not->toBeNull() + ->and($degraded)->toBeTrue() + ->and($logger->count('warning', 'cannot reach the cache store (redis is gone)'))->toBe(1) + ->and($registry->document()->flag('h'))->not->toBeNull() + ->and($bootLogger->count('warning', 'cannot reach the cache store (redis is gone)'))->toBe(1); +}); diff --git a/packages/feature-flags/tests/Registry/FlagRegistryTest.php b/packages/feature-flags/tests/Registry/FlagRegistryTest.php new file mode 100644 index 00000000..7a980030 --- /dev/null +++ b/packages/feature-flags/tests/Registry/FlagRegistryTest.php @@ -0,0 +1,920 @@ + $sources + */ +function featureFlagsRegistry(array $sources, Repository $cache, RecordingApplicationEventPublisher $events, FixedClock $clock, ?RecordingLogger $logger = null): FlagRegistry +{ + $logger ??= new RecordingLogger; + + return new FlagRegistry($sources, new CacheBook($cache, $logger), $events, $logger, $clock(...)); +} + +/** + * Every FeatureFlagsChanged published so far, as [origin, changedKeys]. + * + * @return list}> + */ +function featureFlagsChanges(RecordingApplicationEventPublisher $events): array +{ + $changes = []; + foreach ($events->events as $event) { + if ($event instanceof FeatureFlagsChanged) { + $changes[] = [$event->origin, $event->changedKeys]; + } + } + + return $changes; +} + +/** + * A full boolean flagd definition, with $fields replacing or adding members. + * + * @param array $fields + * @return array + */ +function featureFlagsRegistryFlag(string $default = 'on', array $fields = []): array +{ + return [...['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => $default], ...$fields]; +} + +/** @return list}> */ +function featureFlagsOverrideCases(): array +{ + $document = Json::decode((string) file_get_contents(ConformanceFiles::root().'/override-vectors.json')); + $cases = Json::members($document)['cases'] ?? null; + if (! is_array($cases)) { + throw new LogicException('Expected override conformance cases.'); + } + + return array_values(array_map(static fn (mixed $case): array => [Json::members($case)], $cases)); +} + +it('lets the highest layer supply a key and records what it shadows', function (): void { + $config = new StubFlagSource('config', 100, 0.0, ['a' => true, 'c' => 'v1'], failsStartup: true); + $store = new StubFlagSource('store', 400, 5.0, ['a' => false]); + $registry = featureFlagsRegistry([$store, $config], new Repository(new ArrayStore), new RecordingApplicationEventPublisher, new FixedClock); + + $composition = $registry->composition(); + + expect($composition->flag('a')?->origin)->toBe('store') + ->and($composition->flag('a')?->overrides)->toBe(['config']) + ->and($composition->flag('a')?->definition->defaultVariant())->toBe('off') + ->and($composition->flag('c')?->origin)->toBe('config') + ->and(array_map(static fn ($state): string => $state->name, $registry->states()))->toBe(['config', 'store']) + ->and(array_map(static fn ($source): string => $source->name(), $registry->sources()))->toBe(['config', 'store']) + ->and($registry->source('store'))->toBe($store) + ->and($registry->source('http'))->toBeNull(); +}); + +it('shares one refresh schedule and one change event between two workers', function (): void { + $cache = new Repository(new ArrayStore); + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $store = new StubFlagSource('store', 400, 5.0, ['a' => false, 'b' => 'x']); + $config = new StubFlagSource('config', 100, 0.0, ['a' => true], failsStartup: true); + + featureFlagsRegistry([$store, $config], $cache, $events, $clock)->document(); + featureFlagsRegistry([$store, $config], $cache, $events, $clock)->document(); + + expect($store->loads)->toBe(1) + ->and(featureFlagsChanges($events))->toBe([['startup', ['a', 'b']]]); +}); + +it('a long-lived process sees a store change once refresh-interval elapses', function (): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $store = new StubFlagSource('store', 400, 5.0, ['a' => false]); + $registry = featureFlagsRegistry([$store], new Repository(new ArrayStore), $events, $clock); + $registry->document(); + + $store->flags = ['a' => true]; + $store->revision = 'r2'; + $clock->now += 1.0; + $memoized = $registry->document()->flag('a')?->defaultVariant(); + $clock->now += 5.0; + $refreshed = $registry->document()->flag('a')?->defaultVariant(); + + expect($memoized)->toBe('off') + ->and($refreshed)->toBe('on') + ->and($store->loads)->toBe(2) + ->and(featureFlagsChanges($events)[1] ?? null)->toBe(['store', ['a']]); +}); + +it('a long-lived process re-checks a source whose refresh-interval is 0 on every read', function (): void { + $events = new RecordingApplicationEventPublisher; + $file = new StubFlagSource('file', 200, 0.0, ['a' => false], failsStartup: true); + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, new FixedClock); + $registry->document(); + + $file->flags = ['a' => true]; + $file->revision = 'r2'; + + expect($registry->document()->flag('a')?->defaultVariant())->toBe('on') + ->and(featureFlagsChanges($events))->toBe([['startup', ['a']], ['file', ['a']]]); +}); + +it('never re-reads the configuration of a long-lived process that has no polling source', function (): void { + $clock = new FixedClock; + $config = new StubFlagSource('config', 100, 0.0, ['a' => true], failsStartup: true); + $registry = featureFlagsRegistry([$config], new Repository(new ArrayStore), new RecordingApplicationEventPublisher, $clock); + + $registry->document(); + $clock->now += 86400.0; + $registry->document(); + $registry->composition(); + + expect($config->loads)->toBe(1); +}); + +it('writes the shared state of the configuration only when it changes, not on every request', function (): void { + $cache = new class(new ArrayStore) extends Repository + { + /** @var list */ + public array $writes = []; + + public function forever($key, $value): bool + { + $this->writes[] = is_string($key) ? $key : ''; + + return parent::forever($key, $value); + } + }; + $config = new StubFlagSource('config', 100, 0.0, ['a' => true], failsStartup: true); + + featureFlagsRegistry([$config], $cache, new RecordingApplicationEventPublisher, new FixedClock)->document(); + featureFlagsRegistry([$config], $cache, new RecordingApplicationEventPublisher, new FixedClock)->document(); + featureFlagsRegistry([$config], $cache, new RecordingApplicationEventPublisher, new FixedClock)->document(); + + expect(array_count_values($cache->writes)[CacheBook::PREFIX.'source:config'] ?? 0)->toBe(1) + ->and($config->loads)->toBe(3); +}); + +it('keeps the last good document of a failing source, marks it STALE and warns once per error', function (): void { + $logger = new RecordingLogger; + $clock = new FixedClock; + $http = new StubFlagSource('http', 300, 30.0, ['remote' => true]); + $registry = featureFlagsRegistry([$http], new Repository(new ArrayStore), new RecordingApplicationEventPublisher, $clock, $logger); + $registry->document(); + + $http->failure = new FlagSourceUnavailable('connection refused'); + $clock->now += 31.0; + $registry->refresh(); + $registry->refresh(force: true); + + expect($registry->document()->flag('remote'))->not->toBeNull() + ->and($registry->states()[0]->status())->toBe('STALE') + ->and($registry->states()[0]->error)->toBe('connection refused') + ->and($logger->count('warning', 'connection refused'))->toBe(1); +}); + +it('rejects an invalid document as a whole once a source has loaded', function (): void { + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['a' => true], failsStartup: true); + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), new RecordingApplicationEventPublisher, $clock); + $registry->document(); + + $file->flags = ['a' => true, 'bad key' => true]; + $file->revision = 'r2'; + $clock->now += 6.0; + + expect($registry->document()->keys())->toBe(['a']) + ->and($registry->states()[0]->status())->toBe('STALE') + ->and($registry->states()[0]->error)->toBe('Invalid feature flag [bad key]: invalid flag key.'); +}); + +it('refuses the boot when config or file fails before ever loading, naming key, reason and source', function (): void { + $config = new StubFlagSource('config', 100, 0.0, ['bad key' => true], failsStartup: true); + $registry = featureFlagsRegistry([$config], new Repository(new ArrayStore), new RecordingApplicationEventPublisher, new FixedClock); + + expect(fn () => $registry->start()) + ->toThrow(InvalidFlagDefinition::class, 'Invalid feature flag [bad key] from source [config]: invalid flag key.'); +}); + +it('refuses the boot over an invalid config even when a good one is cached', function (): void { + $cache = new Repository(new ArrayStore); + $config = new StubFlagSource('config', 100, 0.0, ['a' => true], failsStartup: true); + featureFlagsRegistry([$config], $cache, new RecordingApplicationEventPublisher, new FixedClock)->start(); + + $config->flags = ['bad key' => true]; + $config->revision = 'r2'; + $nextBoot = featureFlagsRegistry([$config], $cache, new RecordingApplicationEventPublisher, new FixedClock); + + expect(fn () => $nextBoot->start()) + ->toThrow(InvalidFlagDefinition::class, 'Invalid feature flag [bad key] from source [config]: invalid flag key.'); +}); + +it('refuses the boot over a flag file that cannot be read before it ever loaded', function (): void { + $file = new StubFlagSource('file', 200, 5.0, ['a' => true], failsStartup: true); + $file->failure = new FlagSourceUnavailable('The flag file [flags.json] does not exist or cannot be read.'); + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), new RecordingApplicationEventPublisher, new FixedClock); + + expect(fn () => $registry->start())->toThrow(FlagSourceUnavailable::class, 'does not exist'); +}); + +it('never refuses the boot over a remote or store source; such a source reports DOWN', function (): void { + $store = new StubFlagSource('store', 400, 5.0, ['a' => true]); + $store->failure = new FlagSourceUnavailable('no such table'); + $registry = featureFlagsRegistry([$store], new Repository(new ArrayStore), new RecordingApplicationEventPublisher, new FixedClock); + + $registry->start(); + + expect($registry->document()->keys())->toBe([]) + ->and($registry->states()[0]->status())->toBe('DOWN') + ->and($registry->states()[0]->error)->toBe('no such table'); +}); + +it('keeps evaluating from in-process sources when the cache store throws', function (): void { + $broken = new class(new ArrayStore) extends Repository + { + /** @param UnitEnum|array|string $key */ + public function get($key, $default = null): mixed + { + throw new RuntimeException('redis is gone'); + } + + public function forever($key, $value): bool + { + throw new RuntimeException('redis is gone'); + } + + /** @param UnitEnum|array|string $key */ + public function add($key, $value, $ttl = null): bool + { + throw new RuntimeException('redis is gone'); + } + }; + $logger = new RecordingLogger; + $events = new RecordingApplicationEventPublisher; + $config = new StubFlagSource('config', 100, 0.0, ['kill-switch' => true], failsStartup: true); + + $registry = featureFlagsRegistry([$config], $broken, $events, new FixedClock, $logger); + + expect($registry->document()->flag('kill-switch')?->defaultVariant())->toBe('on') + ->and($logger->count('warning', 'cannot reach the cache store'))->toBe(1) + ->and($events->events)->toBe([]); +}); + +it('a long-lived process announces changes again once the cache store is back', function (): void { + $cache = new FlakyCache; + $cache->down = true; + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $store = new StubFlagSource('store', 400, 5.0, ['a' => false]); + $registry = featureFlagsRegistry([$store], $cache, $events, $clock); + $registry->document(); + + $cache->down = false; + $store->flags = ['a' => true]; + $store->revision = 'r2'; + $clock->now += 5.0; + + expect($registry->document()->flag('a')?->defaultVariant())->toBe('on') + ->and(featureFlagsChanges($events))->toBe([['startup', ['a']]]); +}); + +it('serves the last good document while another worker holds the refresh lock', function (): void { + $store = new ArrayStore; + $cache = new Repository($store); + $clock = new FixedClock; + $http = new StubFlagSource('http', 300, 30.0, ['h' => true]); + featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock)->document(); + + $clock->now += 31.0; + $held = $store->lock(CacheBook::PREFIX.'lock:http', 60); + $held->get(); + $other = featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock); + + expect($other->document()->flag('h'))->not->toBeNull() + ->and($http->loads)->toBe(1); + $held->release(); +}); + +it('loads a source on a forced refresh even while another worker holds its lock', function (): void { + $store = new ArrayStore; + $clock = new FixedClock; + $events = new RecordingApplicationEventPublisher; + $flags = new StubFlagSource('store', 400, 5.0, ['a' => false]); + $registry = featureFlagsRegistry([$flags], new Repository($store), $events, $clock); + $registry->document(); + + $flags->flags = ['a' => true]; + $flags->revision = 'r2'; + $held = $store->lock(CacheBook::PREFIX.'lock:store', 60); + $held->get(); + $registry->refresh(force: true, only: 'http'); + $loadsAfterOtherSource = $flags->loads; + $registry->refresh(force: true, only: 'store'); + + expect($loadsAfterOtherSource)->toBe(1) + ->and($flags->loads)->toBe(2) + ->and($registry->document()->flag('a')?->defaultVariant())->toBe('on') + ->and(featureFlagsChanges($events))->toBe([['startup', ['a']], ['store', ['a']]]); + $held->release(); +}); + +it('never lets a worker that composed an older document overwrite or revert a newer shared record', function (): void { + $store = new ArrayStore; + $cache = new Repository($store); + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['a' => true], failsStartup: true); + $remote = new StubFlagSource('store', 400, 5.0, ['s' => true]); + featureFlagsRegistry([$file, $remote], $cache, $events, $clock)->document(); + + // The file changes. At the interval boundary worker C holds the file's lock and loads it, so worker A serves the + // old file document and goes on to check the store; while A loads the store, C records the new set. + $file->flags = ['a' => false]; + $file->revision = 'r2'; + $clock->now += 5.0; + $held = $store->lock(CacheBook::PREFIX.'lock:file', 60); + $held->get(); + $remote->beforeLoad = function () use ($held, $file, $remote, $cache, $events, $clock): void { + $held->release(); + featureFlagsRegistry([$file, $remote], $cache, $events, $clock)->document(); + }; + $servedByA = featureFlagsRegistry([$file, $remote], $cache, $events, $clock)->document()->flag('a')?->defaultVariant(); + $nextRequest = featureFlagsRegistry([$file, $remote], $cache, $events, $clock)->document()->flag('a')?->defaultVariant(); + + expect($servedByA)->toBe('on') + ->and($nextRequest)->toBe('off') + ->and($remote->loads)->toBe(2) + ->and(featureFlagsChanges($events))->toBe([['startup', ['a', 's']], ['file', ['a']]]); +}); + +it('loads a source once at its interval boundary when another worker checked it just before this one took the lock', function (): void { + $store = new HookedLockStore; + $cache = new Repository($store); + $clock = new FixedClock; + $http = new StubFlagSource('http', 300, 30.0, ['h' => true]); + featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock)->document(); + + $http->flags = ['h' => false]; + $http->revision = 'r2'; + $clock->now += 30.0; + $store->beforeLock = function () use ($http, $cache, $clock): void { + featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock)->document(); + }; + $late = featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock); + + expect($late->document()->flag('h')?->defaultVariant())->toBe('off') + ->and($http->loads)->toBe(2); +}); + +it('writes the new state of a source before it lets go of the source lock', function (): void { + $store = new HookedLockStore; + $cache = new Repository($store); + $clock = new FixedClock; + $http = new StubFlagSource('http', 300, 30.0, ['h' => true]); + $seen = []; + $store->onRelease = function (string $lock) use ($cache, &$seen): void { + if ($lock === CacheBook::PREFIX.'lock:http') { + $seen[] = SourceState::fromArray($cache->get(CacheBook::PREFIX.'source:http'))?->revision; + } + }; + + featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock)->document(); + $http->revision = 'r2'; + $clock->now += 30.0; + featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock)->document(); + + expect($seen)->toBe(['r1', 'r2']); +}); + +it('keeps its own last good document when another worker re-creates an evicted entry without loading the source', function (): void { + $cache = new Repository(new ArrayStore); + $clock = new FixedClock; + $http = new StubFlagSource('http', 300, 30.0, ['h' => true]); + $longLived = featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock); + $longLived->document(); + + $cache->forget(CacheBook::PREFIX.'source:http'); // evicted under memory pressure + $http->failure = new FlagSourceUnavailable('connection refused'); + $clock->now += 30.0; + $freshKeys = featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock)->document()->keys(); + $kept = $longLived->document()->flag('h'); + + expect($freshKeys)->toBe([]) + ->and($kept)->not->toBeNull() + ->and($longLived->states()[0]->status())->toBe('STALE') + ->and(featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock)->document()->flag('h'))->not->toBeNull(); +}); + +it('reloads a source whose cached document cannot be read', function (string $document): void { + $cache = new Repository(new ArrayStore); + $logger = new RecordingLogger; + $file = new StubFlagSource('file', 200, 5.0, ['a' => true], failsStartup: true); + $cache->forever(CacheBook::PREFIX.'source:file', [ + 'name' => 'file', 'loaded' => true, 'document' => $document, 'revision' => 'r1', + 'checkedAt' => 1790000000.0, 'lastRefresh' => '2026-09-21T14:13:20Z', 'error' => null, 'flags' => 1, + ]); + $registry = featureFlagsRegistry([$file], $cache, new RecordingApplicationEventPublisher, new FixedClock, $logger); + + expect($registry->document()->flag('a')?->defaultVariant())->toBe('on') + ->and($file->loads)->toBe(1) + ->and(SourceState::fromArray($cache->get(CacheBook::PREFIX.'source:file'))?->document()->keys())->toBe(['a']) + ->and($logger->count('warning', 'cached document of feature flag source [file] cannot be read'))->toBe(1); +})->with([ + 'not JSON' => ['{"flags": {'], + 'another shape' => ['[["a", true]]'], +]); + +it('falls back on the newest document it served, adopted from the cache or loaded itself, after an eviction', function (): void { + $cache = new Repository(new ArrayStore); + $clock = new FixedClock; + $http = new StubFlagSource('http', 300, 30.0, ['h' => true]); + $longLived = featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock); + $longLived->document(); + + $http->flags = ['h' => false]; + $http->revision = 'r2'; + $clock->now += 30.0; + featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock)->document(); // another worker loads r2 + $clock->now += 1.0; + $adopted = $longLived->document()->flag('h')?->defaultVariant(); // read from the cache, not loaded + + $cache->forget(CacheBook::PREFIX.'source:http'); // evicted + $http->failure = new FlagSourceUnavailable('connection refused'); + $clock->now += 30.0; + featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock)->document(); // cannot load it + $longLivedServes = $longLived->document()->flag('h')?->defaultVariant(); + + expect($adopted)->toBe('off') + ->and($longLivedServes)->toBe('off') + ->and($longLived->states()[0]->revision)->toBe('r2') + ->and(featureFlagsRegistry([$http], $cache, new RecordingApplicationEventPublisher, $clock)->document()->flag('h')?->defaultVariant())->toBe('off'); +}); + +it('records one transition at a time: a newer record written meanwhile is never overwritten', function (): void { + $cache = new class(new ArrayStore) extends Repository + { + /** @var (Closure(): void)|null */ + public ?Closure $beforeRecord = null; + + public function forever($key, $value): bool + { + $hook = $this->beforeRecord; + if ($key === CacheBook::PREFIX.'composed' && $hook !== null) { + $this->beforeRecord = null; + $hook(); + } + + return parent::forever($key, $value); + } + }; + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $store = new StubFlagSource('store', 400, 5.0, ['a' => true]); + featureFlagsRegistry([$store], $cache, $events, $clock)->document(); + + $store->flags = ['a' => false]; + $store->revision = 'r2'; + $clock->now += 5.0; + // worker X has checked its layers and is about to record them when an admin write makes worker Z load and record r3 + $cache->beforeRecord = function () use ($store, $cache, $events, $clock): void { + $store->flags = ['a' => false, 'b' => true]; + $store->revision = 'r3'; + featureFlagsRegistry([$store], $cache, $events, $clock)->refresh(force: true, only: 'store'); + }; + featureFlagsRegistry([$store], $cache, $events, $clock)->document(); + featureFlagsRegistry([$store], $cache, $events, $clock)->document(); // the next request + + /** @var array{keys: array} $record */ + $record = $cache->get(CacheBook::PREFIX.'composed'); + + expect(array_keys($record['keys']))->toBe(['a', 'b']) + ->and(featureFlagsChanges($events))->toBe([['startup', ['a']], ['store', ['a']], ['store', ['b']]]); +}); + +it('a long-lived process records a set it could not record while another worker held the record lock', function (): void { + $store = new ArrayStore; + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['a' => true], failsStartup: true); + $registry = featureFlagsRegistry([$file], new Repository($store), $events, $clock); + $registry->document(); + + $file->flags = ['a' => false]; + $file->revision = 'r2'; + $clock->now += 5.0; + $held = $store->lock(CacheBook::PREFIX.'lock:composed', 60); + $held->get(); + $whileHeld = $registry->document()->flag('a')?->defaultVariant(); + $eventsWhileHeld = featureFlagsChanges($events); + $held->release(); + $clock->now += 5.0; + $registry->document(); + + expect($whileHeld)->toBe('off') + ->and($eventsWhileHeld)->toBe([['startup', ['a']]]) + ->and(featureFlagsChanges($events))->toBe([['startup', ['a']], ['file', ['a']]]) + ->and($file->loads)->toBe(3); +}); + +it('puts test overrides on top without sharing them', function (): void { + $registry = featureFlagsRegistry([new StubFlagSource('config', 100, 0.0, ['a' => true], failsStartup: true)], new Repository(new ArrayStore), new RecordingApplicationEventPublisher, new FixedClock); + $overrides = FlagDefinitions::parseDocument(['flags' => Json::object(FlagDefinitions::normalize(['a' => false]))]); + $registry->overrideForTests($overrides); + + expect($registry->composition()->flag('a')?->origin)->toBe(FlagRegistry::TEST_LAYER) + ->and($registry->composition()->flag('a')?->overrides)->toBe(['config']) + ->and($registry->composition(false)->flag('a')?->origin)->toBe('config') + ->and($registry->document()->flag('a')?->defaultVariant())->toBe('off') + ->and($registry->testOverrides())->toBe($overrides); + + $registry->overrideForTests(null); + + expect($registry->composition()->flag('a')?->origin)->toBe('config') + ->and($registry->document()->flag('a')?->defaultVariant())->toBe('on') + ->and($registry->testOverrides())->toBeNull(); +}); + +it('consumes the shared test override composition cases', function (array $case): void { + $key = $case['key'] ?? null; + if (! is_string($key)) { + throw new LogicException('Expected an override case key.'); + } + $config = Json::members($case['config'] ?? null); + $overrides = Json::members($case['overrides'] ?? null); + $expected = Json::members($case['expect'] ?? null); + $expectedOverrides = $expected['overrides'] ?? null; + if (! is_array($expectedOverrides)) { + throw new LogicException('Expected overridden layers.'); + } + + $events = new RecordingApplicationEventPublisher; + $registry = featureFlagsRegistry([new StubFlagSource('config', 100, 0.0, $config, failsStartup: true)], new Repository(new ArrayStore), $events, new FixedClock); + $registry->composition(false); + $before = count(featureFlagsChanges($events)); + (new FeatureFlagOverrides($registry))->merge($overrides); + + $composed = $registry->composition()->flag($key); + if ($composed === null) { + throw new LogicException('Expected the override in the effective composition.'); + } + $shared = $registry->composition(false)->flag($key); + $changes = featureFlagsChanges($events); + + expect($composed->origin)->toBe($expected['origin']) + ->and($composed->overrides)->toBe($expectedOverrides) + ->and(array_column($composed->layers, 'source'))->toBe([...$expectedOverrides, $expected['origin']]) + ->and(Json::canonical($composed->definition->toJsonValue()))->toBe(Json::canonical($expected['definition'])) + ->and($registry->testOverrides()?->flag($key))->not->toBeNull() + ->and($shared?->definition->toJsonValue())->toBe($expected['sharedDefinition']) + ->and(count($changes))->toBe($before + 1) + ->and($changes[count($changes) - 1] ?? null)->toBe([$expected['eventOrigin'], $expected['changedKeys']]); +})->with(featureFlagsOverrideCases()); + +it('announces what test overrides change with origin test-overrides, and nothing when they change nothing', function (): void { + $events = new RecordingApplicationEventPublisher; + $registry = featureFlagsRegistry([new StubFlagSource('config', 100, 0.0, ['a' => true, 'b' => true], failsStartup: true)], new Repository(new ArrayStore), $events, new FixedClock); + $registry->document(); + + $registry->overrideForTests(FlagDefinitions::parseDocument(['flags' => Json::object(FlagDefinitions::normalize(['a' => false, 'c' => 'v2']))])); + $registry->overrideForTests(FlagDefinitions::parseDocument(['flags' => Json::object(FlagDefinitions::normalize(['a' => false, 'c' => 'v2']))])); + $registry->overrideForTests(null); + + expect(featureFlagsChanges($events))->toBe([ + ['startup', ['a', 'b']], + [FlagRegistry::TEST_OVERRIDES_ORIGIN, ['a', 'c']], + [FlagRegistry::TEST_OVERRIDES_ORIGIN, ['a', 'c']], + ]); +}); + +it('announces nothing for a key an active test override shadows when its source changes it', function (): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['a' => true, 'b' => true], failsStartup: true); + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, $clock); + $registry->overrideForTests(FlagDefinitions::parseDocument(['flags' => Json::object(FlagDefinitions::normalize(['a' => 'pinned']))])); + + $file->flags = ['a' => false, 'b' => false]; + $file->revision = 'r2'; + $clock->now += 5.0; + $registry->document(); + $file->flags = ['a' => 'v3', 'b' => false]; + $file->revision = 'r3'; + $clock->now += 5.0; + $registry->document(); + + expect($file->loads)->toBe(3) + ->and($registry->composition(false)->flag('a')?->definition->defaultVariant())->toBe('v3') + ->and($registry->document()->flag('a')?->defaultVariant())->toBe('pinned') + ->and(featureFlagsChanges($events))->toBe([ + ['startup', ['a', 'b']], + [FlagRegistry::TEST_OVERRIDES_ORIGIN, ['a']], + ['file', ['b']], + ]); +}); + +it('announces a key a test override shadowed, with its source definition, once the override is cleared', function (): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['a' => true], failsStartup: true); + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, $clock); + $registry->overrideForTests(FlagDefinitions::parseDocument(['flags' => Json::object(FlagDefinitions::normalize(['a' => 'pinned']))])); + $file->flags = ['a' => false]; + $file->revision = 'r2'; + $clock->now += 5.0; + $registry->document(); + + $registry->overrideForTests(null); + + expect($registry->document()->flag('a')?->defaultVariant())->toBe('off') + ->and($registry->composition()->flag('a')?->origin)->toBe('file') + ->and(featureFlagsChanges($events))->toBe([ + ['startup', ['a']], + [FlagRegistry::TEST_OVERRIDES_ORIGIN, ['a']], + [FlagRegistry::TEST_OVERRIDES_ORIGIN, ['a']], + ]); +}); + +it('announces a test-overridden key whose targeting references an evaluator its source changes', function (): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['x' => true], failsStartup: true); + $file->evaluators = ['is-beta' => ['in' => [['var' => 'role'], ['beta']]]]; + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, $clock); + $registry->overrideForTests(FlagDefinitions::parseDocument(['flags' => Json::object([ + 'a' => featureFlagsRegistryFlag('off', ['targeting' => ['if' => [['$ref' => 'is-beta'], 'on', 'off']]]), + ])])); + + $file->evaluators = ['is-beta' => ['in' => [['var' => 'role'], ['beta', 'tester']]]]; + $file->revision = 'r2'; + $clock->now += 5.0; + $registry->document(); + + expect(featureFlagsChanges($events))->toBe([ + ['startup', ['x']], + [FlagRegistry::TEST_OVERRIDES_ORIGIN, ['a']], + ['file', ['a']], + ]); +}); + +it('announces a key when any field of its composed definition changes, value types included', function (array $before, array $after): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['a' => $before, 'b' => featureFlagsRegistryFlag()], failsStartup: true); + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, $clock); + $registry->document(); + + $file->flags = ['a' => $after, 'b' => featureFlagsRegistryFlag()]; + $file->revision = 'r2'; + $clock->now += 5.0; + $registry->document(); + + expect(featureFlagsChanges($events))->toBe([['startup', ['a', 'b']], ['file', ['a']]]); +})->with([ + 'state' => [featureFlagsRegistryFlag(), featureFlagsRegistryFlag(fields: ['state' => 'DISABLED'])], + 'default variant' => [featureFlagsRegistryFlag(), featureFlagsRegistryFlag('off')], + 'targeting' => [featureFlagsRegistryFlag(), featureFlagsRegistryFlag(fields: ['targeting' => ['if' => [['==' => [['var' => 'tier'], 'gold']], 'on', 'off']]])], + 'metadata only' => [featureFlagsRegistryFlag(fields: ['metadata' => ['expires' => '2027-01-01']]), featureFlagsRegistryFlag(fields: ['metadata' => ['expires' => '2027-06-30']])], + 'a field flagd does not read' => [featureFlagsRegistryFlag(), featureFlagsRegistryFlag(fields: ['notes' => 'see ADR-12'])], + 'true becomes 1' => [featureFlagsRegistryFlag(), featureFlagsRegistryFlag(fields: ['variants' => ['on' => 1, 'off' => 0]])], + '1 becomes 1.0' => [ + ['state' => 'ENABLED', 'variants' => ['one' => 1, 'two' => 2], 'defaultVariant' => 'one'], + ['state' => 'ENABLED', 'variants' => ['one' => 1.0, 'two' => 2], 'defaultVariant' => 'one'], + ], +]); + +it('announces a key that disappears, and one that appears', function (): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['a' => true, 'b' => true], failsStartup: true); + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, $clock); + $registry->document(); + + $file->flags = ['a' => true, 'c' => true]; + $file->revision = 'r2'; + $clock->now += 5.0; + $registry->document(); + + expect(featureFlagsChanges($events)[1] ?? null)->toBe(['file', ['b', 'c']]); +}); + +it('announces the keys that depend on a changed evaluator, directly or through another', function (): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $flags = [ + 'direct' => featureFlagsRegistryFlag('off', ['targeting' => ['if' => [['$ref' => 'is-beta'], 'on', 'off']]]), + 'nested' => featureFlagsRegistryFlag('off', ['targeting' => ['if' => [['$ref' => 'vip'], 'on', 'off']]]), + 'other' => featureFlagsRegistryFlag('off', ['targeting' => ['if' => [['$ref' => 'is-staff'], 'on', 'off']]]), + 'static' => featureFlagsRegistryFlag(), + // only targeting is expanded: a variant that looks like a reference is a value + 'literal' => ['state' => 'ENABLED', 'variants' => ['ref' => ['$ref' => 'is-beta'], 'none' => ['x' => 1]], 'defaultVariant' => 'none'], + // a reference is an object with exactly one key, a text value: these are not references + 'lookalike' => featureFlagsRegistryFlag('off', ['targeting' => ['if' => [['$ref' => 'is-beta', 'other' => 1], 'on', ['$ref' => 7]]]]), + ]; + $file = new StubFlagSource('file', 200, 5.0, $flags, failsStartup: true); + $file->evaluators = [ + 'is-beta' => ['in' => [['var' => 'role'], ['beta']]], + 'is-staff' => ['==' => [['var' => 'role'], 'staff']], + // "vip" sorts after the evaluators it references: references resolve whatever the order + 'vip' => ['or' => [['$ref' => 'is-staff'], ['$ref' => 'is-beta']]], + 'unused' => ['==' => [1, 1]], + ]; + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, $clock); + $registry->document(); + + $file->evaluators['is-beta'] = ['in' => [['var' => 'role'], ['beta', 'tester']]]; + $file->evaluators['unused'] = ['==' => [1, 2]]; + $file->revision = 'r2'; + $clock->now += 5.0; + $registry->document(); + + expect(featureFlagsChanges($events)[1] ?? null)->toBe(['file', ['direct', 'nested']]); +}); + +it('announces nothing when only an evaluator no flag references changes', function (): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['a' => true], failsStartup: true); + $file->evaluators = ['unused' => ['==' => [1, 1]]]; + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, $clock); + $registry->document(); + + $file->evaluators = ['unused' => ['==' => [1, 2]]]; + $file->revision = 'r2'; + $clock->now += 5.0; + + expect($registry->document()->evaluators)->toBe(['unused' => ['==' => [1, 2]]]) + ->and(featureFlagsChanges($events))->toBe([['startup', ['a']]]); +}); + +it('announces the keys that inherit a changed document metadata entry', function (): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['inherits' => featureFlagsRegistryFlag(), 'own' => featureFlagsRegistryFlag(fields: ['metadata' => ['owner' => 'payments']])], failsStartup: true); + $file->metadata = ['owner' => 'platform']; + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, $clock); + $registry->document(); + + $file->metadata = ['owner' => 'checkout']; + $file->revision = 'r2'; + $clock->now += 5.0; + $registry->document(); + + expect(featureFlagsChanges($events)[1] ?? null)->toBe(['file', ['inherits']]); +}); + +it('publishes nothing when a recomposition changes nothing', function (): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $config = new StubFlagSource('config', 100, 0.0, ['a' => true], failsStartup: true); + $file = new StubFlagSource('file', 200, 5.0, ['a' => featureFlagsRegistryFlag('off', ['targeting' => ['if' => [['$ref' => 'is-beta'], 'on', 'off']]])], failsStartup: true); + $file->evaluators = ['is-beta' => ['in' => [['var' => 'role'], ['beta']]]]; + $file->metadata = ['owner' => 'platform']; + $registry = featureFlagsRegistry([$config, $file], new Repository(new ArrayStore), $events, $clock); + $registry->document(); + + $file->revision = 'r2'; // the same document again + $config->flags = ['a' => false]; // a change the file layer shadows + $config->revision = 'r2'; + $clock->now += 5.0; + $registry->document(); + + expect($file->loads)->toBe(2) + ->and($registry->composition()->flag('a')?->layers[0]['definition']->defaultVariant())->toBe('off') + ->and(featureFlagsChanges($events))->toBe([['startup', ['a']]]); +}); + +it('announces every change, even a flag flipped back and forth within seconds', function (): void { + $events = new RecordingApplicationEventPublisher; + $file = new StubFlagSource('file', 200, 0.0, ['a' => true], failsStartup: true); + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, new FixedClock); + $registry->document(); + + foreach ([false, true, false] as $revision => $value) { + $file->flags = ['a' => $value]; + $file->revision = 'flip-'.$revision; + $registry->document(); + } + + expect(featureFlagsChanges($events))->toBe([['startup', ['a']], ['file', ['a']], ['file', ['a']], ['file', ['a']]]); +}); + +it('names the highest loaded source as the origin when no source moved in this process', function (): void { + $cache = new Repository(new ArrayStore); + $events = new RecordingApplicationEventPublisher; + $config = new StubFlagSource('config', 100, 0.0, ['a' => true], failsStartup: true); + $store = new StubFlagSource('store', 400, 5.0, ['b' => true]); + + // a rolling deploy that enables the store: an old worker, a new one, then an old one again + featureFlagsRegistry([$config], $cache, $events, new FixedClock)->document(); + featureFlagsRegistry([$config, $store], $cache, $events, new FixedClock)->document(); + featureFlagsRegistry([$config], $cache, $events, new FixedClock)->document(); + + expect(featureFlagsChanges($events))->toBe([['startup', ['a']], ['store', ['b']], ['config', ['b']]]); +}); + +it('reads numeric-looking flag keys as text everywhere', function (): void { + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['2024' => true, '10' => false, 'b' => true], failsStartup: true); + $registry = featureFlagsRegistry([$file], new Repository(new ArrayStore), $events, $clock); + + expect($registry->document()->keys())->toBe(['10', '2024', 'b']) + ->and($registry->composition()->flag('2024')?->origin)->toBe('file') + ->and($registry->composition()->flag('2024')?->definition->key)->toBe('2024'); + + $file->flags = ['2024' => false, '10' => false, 'b' => true]; + $file->revision = 'r2'; + $clock->now += 5.0; + $registry->document(); + + expect(featureFlagsChanges($events))->toBe([['startup', ['10', '2024', 'b']], ['file', ['2024']]]); +}); + +it('keeps the flags in force when a FeatureFlagsChanged listener throws', function (): void { + $logger = new RecordingLogger; + $events = new class implements ApplicationEventPublisher + { + public function publish(object $event): void + { + throw new RuntimeException('listener exploded'); + } + }; + $registry = new FlagRegistry([new StubFlagSource('config', 100, 0.0, ['a' => true], failsStartup: true)], new CacheBook(new Repository(new ArrayStore), $logger), $events, $logger, (new FixedClock)(...)); + + expect($registry->document()->flag('a')?->defaultVariant())->toBe('on') + ->and($logger->count('warning', 'listener exploded'))->toBe(1); +}); + +it('refuses two sources with one name, and a source named like a reserved origin', function (string $second): void { + $sources = [new StubFlagSource('config', 100, 0.0), new StubFlagSource($second, 200, 0.0)]; + + expect(fn () => featureFlagsRegistry($sources, new Repository(new ArrayStore), new RecordingApplicationEventPublisher, new FixedClock)) + ->toThrow(ConfigurationException::class, "[{$second}]"); +})->with(['config', FlagRegistry::TEST_LAYER, FlagRegistry::TEST_OVERRIDES_ORIGIN, FlagRegistry::STARTUP_ORIGIN, 'composed']); + +it('warns about an expired flag once a day across every worker', function (): void { + $cache = new Repository(new ArrayStore); + $logger = new RecordingLogger; + $config = new StubFlagSource('config', 100, 0.0, ['old' => [ + 'state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'on', 'metadata' => ['expires' => '2025-01-01'], + ]], failsStartup: true); + + featureFlagsRegistry([$config], $cache, new RecordingApplicationEventPublisher, new FixedClock, $logger)->start(); + featureFlagsRegistry([$config], $cache, new RecordingApplicationEventPublisher, new FixedClock, $logger)->start(); + + expect($logger->count('warning', 'Feature flag [old] expired on 2025-01-01'))->toBe(1) + ->and($logger->count('warning', 'still defined by [config]'))->toBe(1); +}); + +it('contains serialization failures before publishing a source snapshot', function (): void { + $cache = new Repository(new ArrayStore); + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $file = new StubFlagSource('file', 200, 5.0, ['a' => true], failsStartup: true); + $registry = featureFlagsRegistry([$file], $cache, $events, $clock); + $registry->document(); + $file->metadata = ['binary' => "\xff"]; + $file->revision = 'bad'; + $clock->now += 6; + $registry->refresh(); + expect($registry->states()[0]->status())->toBe('STALE') + ->and($registry->states()[0]->revision)->toBe('r1') + ->and($registry->document()->flag('a'))->not->toBeNull(); + $fresh = featureFlagsRegistry([$file], $cache, $events, $clock); + expect($fresh->document()->flag('a'))->not->toBeNull() + ->and($fresh->states()[0]->status())->toBe('STALE') + ->and(featureFlagsChanges($events))->toBe([['startup', ['a']]]); +}); + +it('keeps never loaded remote serialization failures DOWN', function (string $name): void { + $source = new StubFlagSource($name, 300, 5.0, ['a' => true]); + $source->metadata = ['binary' => "\xff"]; + $registry = featureFlagsRegistry([$source], new Repository(new ArrayStore), new RecordingApplicationEventPublisher, new FixedClock); + expect($registry->document()->keys())->toBe([]) + ->and($registry->states()[0]->status())->toBe('DOWN'); +})->with(['http', 'store']); + +it('refuses initial local serialization failures', function (string $name): void { + $source = new StubFlagSource($name, 100, 5.0, ['a' => true], failsStartup: true); + $source->metadata = ['binary' => "\xff"]; + $registry = featureFlagsRegistry([$source], new Repository(new ArrayStore), new RecordingApplicationEventPublisher, new FixedClock); + expect(fn () => $registry->start())->toThrow(JsonException::class); +})->with(['config', 'file']); diff --git a/packages/feature-flags/tests/Registry/SourceStateTest.php b/packages/feature-flags/tests/Registry/SourceStateTest.php new file mode 100644 index 00000000..f2de21c0 --- /dev/null +++ b/packages/feature-flags/tests/Registry/SourceStateTest.php @@ -0,0 +1,53 @@ +withLoaded(FlagDocument::fromJson('{"flags":{"a":{"state":"ENABLED","variants":{"e":{}},"defaultVariant":"e"}}}'), '10-20', 1790000000.0); + $stale = $loaded->withFailure('gone', 1790000005.0); + + expect($initial->status())->toBe('DOWN') + ->and($loaded->status())->toBe('UP') + ->and($loaded->lastRefresh)->toBe('2026-09-21T14:13:20Z') + ->and($loaded->flags)->toBe(1) + ->and($stale->status())->toBe('STALE') + ->and($stale->document()->toJson())->toContain('"e":{}') + ->and(SourceState::fromArray($stale->toArray()))->toEqual($stale) + ->and(SourceState::fromArray('garbage'))->toBeNull(); +}); + +it('counts a successful check that found nothing new as a refresh, and clears the last error', function (): void { + $stale = SourceState::initial('http') + ->withLoaded(FlagDocument::fromJson('{"flags":{}}'), 'etag-1', 1790000000.0) + ->withFailure('timeout', 1790000030.0); + $recovered = $stale->withUnchanged(1790000060.0); + + expect($stale->lastRefresh)->toBe('2026-09-21T14:13:20Z') + ->and($recovered->status())->toBe('UP') + ->and($recovered->error)->toBeNull() + ->and($recovered->revision)->toBe('etag-1') + ->and($recovered->checkedAt)->toBe(1790000060.0) + ->and($recovered->lastRefresh)->toBe('2026-09-21T14:14:20Z'); +}); + +it('reads a failure before any load as DOWN with its error, and an empty document when none was loaded', function (): void { + $down = SourceState::initial('store')->withFailure('no such table', 1790000000.0); + + expect($down->status())->toBe('DOWN') + ->and($down->error)->toBe('no such table') + ->and($down->lastRefresh)->toBeNull() + ->and($down->document()->flags)->toBe([]); +}); + +it('reads back only a well-formed cache entry', function (mixed $data): void { + expect(SourceState::fromArray($data))->toBeNull(); +})->with([ + 'not an array' => ['garbage'], + 'no name' => [['loaded' => true]], + 'a name that is not text' => [['name' => 7]], + 'loaded without a document' => [['name' => 'file', 'loaded' => true, 'document' => null]], +]); diff --git a/packages/feature-flags/tests/Server/CustomSyncPathTest.php b/packages/feature-flags/tests/Server/CustomSyncPathTest.php new file mode 100644 index 00000000..dbea8511 --- /dev/null +++ b/packages/feature-flags/tests/Server/CustomSyncPathTest.php @@ -0,0 +1,15 @@ +get('/internal/flags.json', ['Authorization' => 'Bearer s3cret'])->assertOk(); + $this->get('/feature-flags/flagd.json', ['Authorization' => 'Bearer s3cret'])->assertNotFound(); + expect($this->app()->make('router')->getRoutes()->getByName(FlagdSyncRouteRegistrar::ROUTE_NAME)?->uri())->toBe('internal/flags.json'); +}); diff --git a/packages/feature-flags/tests/Server/DisabledSyncServerTest.php b/packages/feature-flags/tests/Server/DisabledSyncServerTest.php new file mode 100644 index 00000000..45b3868f --- /dev/null +++ b/packages/feature-flags/tests/Server/DisabledSyncServerTest.php @@ -0,0 +1,12 @@ +get('/feature-flags/flagd.json', ['Authorization' => 'Bearer s3cret'])->assertNotFound(); +}); diff --git a/packages/feature-flags/tests/Server/ExternalSyncServerTest.php b/packages/feature-flags/tests/Server/ExternalSyncServerTest.php new file mode 100644 index 00000000..70d82d6e --- /dev/null +++ b/packages/feature-flags/tests/Server/ExternalSyncServerTest.php @@ -0,0 +1,17 @@ +get('/feature-flags/flagd.json', ['Authorization' => 'Bearer s3cret'])->assertNotFound(); + expect($this->app()->bound(FlagRegistry::class))->toBeFalse() + ->and($this->fireflyContext()->get(Provider::class))->toBeInstanceOf(FixedProvider::class); +}); diff --git a/packages/feature-flags/tests/Server/FlagdSyncServerTest.php b/packages/feature-flags/tests/Server/FlagdSyncServerTest.php new file mode 100644 index 00000000..d3b657c5 --- /dev/null +++ b/packages/feature-flags/tests/Server/FlagdSyncServerTest.php @@ -0,0 +1,109 @@ +get('/feature-flags/flagd.json', ['Authorization' => 'Bearer s3cret']); + $etag = (string) $first->headers->get('ETag'); + $again = $this->get('/feature-flags/flagd.json', ['Authorization' => 'Bearer s3cret', 'If-None-Match' => $etag]); + + $first->assertOk()->assertHeader('Content-Type', 'application/json')->assertHeader('Cache-Control', 'no-cache, private'); + expect($etag)->toBe('"'.hash('sha256', (string) $first->getContent()).'"') + ->and(Json::members(Json::decode((string) $first->getContent()))['flags'] ?? null)->toHaveKey('kill-switch'); + $again->assertStatus(304); +}); + +it('authenticates a conditional request before returning 304', function (): void { + /** @var SyncServerTestCase $this */ + $etag = (string) $this->get('/feature-flags/flagd.json', ['Authorization' => 'Bearer s3cret'])->headers->get('ETag'); + + $this->get('/feature-flags/flagd.json', ['If-None-Match' => $etag])->assertStatus(401); + $this->get('/feature-flags/flagd.json', ['Authorization' => 'Bearer wrong', 'If-None-Match' => $etag])->assertStatus(401); +}); + +it('answers 401 without the bearer token or with a wrong one', function (string $header): void { + /** @var SyncServerTestCase $this */ + $this->get('/feature-flags/flagd.json', $header === '' ? [] : ['Authorization' => $header]) + ->assertStatus(401) + ->assertHeader('WWW-Authenticate', 'Bearer realm="feature-flags"'); +})->with([[''], ['Bearer nope'], ['Basic czNjcmV0']]); + +it('serves empty objects and omits the test override layer', function (): void { + /** @var SyncServerTestCase $this */ + /** @var FlagStoreWriter $writer */ + $writer = $this->fireflyContext()->get(FlagStoreWriter::class); + $writer->put('banner', Json::decode('{"state":"ENABLED","variants":{"none":{},"promo":{"t":"x"}},"defaultVariant":"none","metadata":{}}'), 'ops'); + /** @var FlagRegistry $registry */ + $registry = $this->fireflyContext()->get(FlagRegistry::class); + $registry->overrideForTests(FlagDefinitions::parseDocument(['flags' => Json::object(FlagDefinitions::normalize(['kill-switch' => false, 'test-only' => true]))])); + + $body = (string) $this->get('/feature-flags/flagd.json', ['Authorization' => 'Bearer s3cret'])->getContent(); + $flags = Json::members(Json::members(Json::decode($body))['flags'] ?? null); + + expect($body)->toContain('"none":{}') + ->and($body)->toContain('"$evaluators":{}') + ->and(Json::members($flags['banner'] ?? null)['variants'] ?? null)->toEqual(['none' => new stdClass, 'promo' => ['t' => 'x']]) + ->and($flags)->not->toHaveKey('test-only') + ->and(Json::members($flags['kill-switch'] ?? null)['defaultVariant'] ?? null)->toBe('on'); +}); + +it('feeds an http source in another application', function (): void { + /** @var SyncServerTestCase $this */ + /** @var FlagStoreWriter $writer */ + $writer = $this->fireflyContext()->get(FlagStoreWriter::class); + $writer->put('checkout-flow', ['state' => 'ENABLED', 'variants' => ['control' => 'v1', 'treatment' => 'v2'], 'defaultVariant' => 'control', 'targeting' => ['fractional' => [['control', 50], ['treatment', 50]]]], 'ops'); + $served = $this->get('/feature-flags/flagd.json', ['Authorization' => 'Bearer s3cret']); + $served->assertOk(); + + $http = new Factory; + $http->fake(['*' => Factory::response((string) $served->getContent(), 200, ['ETag' => (string) $served->headers->get('ETag')])]); + $settings = FeatureFlagsSettings::fromConfig(new Config(new ConfigRepository(['firefly' => ['feature-flags' => ['sources' => ['http' => ['enabled' => true, 'url' => 'https://orders.test/feature-flags/flagd.json', 'token' => 's3cret']]]]]))); + $client = new FlagRegistry( + [new HttpFlagSource($settings, $http)], + new CacheBook(new Repository(new ArrayStore), new RecordingLogger), + new RecordingApplicationEventPublisher, + ); + + $resolution = (new DefaultFlagdEvaluator)->evaluate($client->document(), 'checkout-flow', FlagType::String, 'fallback', 'alice@example.com'); + + expect($client->composition()->flag('checkout-flow')?->origin)->toBe('http') + ->and($client->composition()->flag('kill-switch')?->origin)->toBe('http') + ->and($resolution->reason->value)->toBe('TARGETING_MATCH') + ->and(in_array($resolution->value, ['v1', 'v2'], true))->toBeTrue(); +}); + +it('preserves leading NUL object members across store sync and HTTP source adoption', function (): void { + /** @var SyncServerTestCase $this */ + /** @var FlagStoreWriter $writer */ + $writer = $this->fireflyContext()->get(FlagStoreWriter::class); + $definition = Json::decode('{"state":"ENABLED","variants":{"v":{"\\u0000nested":{"0":{},"list":[],"float":1.0}}},"defaultVariant":"v","metadata":{"\\u0000owner":"ops"}}'); + $writer->put('portable', $definition, 'ops'); + $served = $this->get('/feature-flags/flagd.json', ['Authorization' => 'Bearer s3cret'])->assertOk(); + $http = new Factory; + $http->fake(['*' => Factory::response((string) $served->getContent(), 200)]); + $settings = FeatureFlagsSettings::fromConfig(new Config(new ConfigRepository(['firefly' => ['feature-flags' => ['sources' => ['http' => ['enabled' => true, 'url' => 'https://flags.test/sync']]]]]))); + $client = new FlagRegistry([new HttpFlagSource($settings, $http)], new CacheBook(new Repository(new ArrayStore), new RecordingLogger), new RecordingApplicationEventPublisher); + expect(Json::canonical($client->document()->flag('portable')?->toJsonValue()))->toBe(Json::canonical($definition)) + ->and($client->states()[0]->status())->toBe('UP'); +}); diff --git a/packages/feature-flags/tests/Server/HttpConformanceTest.php b/packages/feature-flags/tests/Server/HttpConformanceTest.php new file mode 100644 index 00000000..05a51833 --- /dev/null +++ b/packages/feature-flags/tests/Server/HttpConformanceTest.php @@ -0,0 +1,93 @@ +>} $httpVectors */ +$httpVectors = json_decode((string) file_get_contents(ConformanceFiles::root().'/http-vectors.json'), true, 512, JSON_THROW_ON_ERROR); +$serverCases = []; +$clientCases = []; +foreach ($httpVectors['cases'] as $case) { + $name = $case['name']; + if (! is_string($name)) { + throw new UnexpectedValueException('HTTP vectors require named cases.'); + } + if ($case['kind'] === 'server') { + $serverCases[$name] = [$case]; + } else { + $clientCases[$name] = [$case]; + } +} + +it('implements shared sync conditions', function (array $case): void { + /** @var SyncServerTestCase $this */ + $condition = $case['condition']; + $expected = Json::members($case['expect']); + $status = $expected['status']; + if (($condition !== null && ! is_string($condition)) || ! is_int($status)) { + throw new UnexpectedValueException('Server vectors require a condition and status.'); + } + $first = $this->get('/feature-flags/flagd.json', ['Authorization' => 'Bearer s3cret']); + $etag = (string) $first->headers->get('ETag'); + $headers = []; + if ($case['authorization'] !== 'missing') { + $headers['Authorization'] = 'Bearer '.($case['authorization'] === 'valid' ? 's3cret' : 'wrong'); + } + if ($condition !== null) { + $headers['If-None-Match'] = str_replace('{etag}', $etag, $condition); + } + $response = $this->get('/feature-flags/flagd.json', $headers); + $response->assertStatus($status); + if ($response->status() === 200) { + expect($response->getContent())->toBe($first->getContent()); + } elseif ($response->status() === 304) { + expect($response->getContent())->toBe('')->and($response->headers->get('ETag'))->toBe($etag); + } else { + expect($response->headers->has('ETag'))->toBeFalse(); + } +})->with($serverCases); + +it('implements shared poll conditions', function (array $case): void { + $firstEtag = $case['firstEtag']; + $body = $case['body']; + $expectedResult = Json::members($case['expect']); + if (($firstEtag !== null && ! is_string($firstEtag)) || ! is_string($body)) { + throw new UnexpectedValueException('Client vectors require body text and an optional ETag.'); + } + $http = new Factory; + $requests = []; + $http->fake(function (Request $request) use ($firstEtag, $body, &$requests) { + $requests[] = $request; + if ($firstEtag === null || count($requests) > 1) { + return Factory::response('', 304); + } + + return Factory::response($body, 200, $firstEtag === '' ? [] : ['ETag' => $firstEtag]); + }); + $settings = FeatureFlagsSettings::fromConfig(new Config(new Repository(['firefly' => ['feature-flags' => ['sources' => ['http' => ['url' => 'http://test/flags', 'token' => 'proof']]]]]))); + $source = new HttpFlagSource($settings, $http); + if ($expectedResult['error']) { + expect(fn () => $source->load(null))->toThrow(FlagSourceUnavailable::class, 'HTTP 304'); + } else { + $snapshot = $source->load(null); + $expected = $firstEtag ?: '"'.hash('sha256', $body).'"'; + expect($snapshot?->revision)->toBe($expected) + ->and($snapshot?->document->flag('a')?->defaultVariant())->toBe('on') + ->and($source->load($expected))->toBeNull() + ->and($requests[1]->header('If-None-Match'))->toBe([$expected]); + } + expect($requests[0]->hasHeader('If-None-Match'))->toBeFalse() + ->and($requests[0]->header('Authorization'))->toBe(['Bearer proof']); +})->with($clientCases); diff --git a/packages/feature-flags/tests/Server/MigrationLoadingTest.php b/packages/feature-flags/tests/Server/MigrationLoadingTest.php new file mode 100644 index 00000000..7ec16b71 --- /dev/null +++ b/packages/feature-flags/tests/Server/MigrationLoadingTest.php @@ -0,0 +1,28 @@ +instance('config', new Repository(['firefly' => ['feature-flags' => ['enabled' => $enabled, 'sources' => ['store' => ['enabled' => $storeEnabled, 'driver' => $driver]]]]])); + $app->instance('files', new Filesystem); + $app->register(DatabaseServiceProvider::class); + $app->register(MigrationServiceProvider::class); + (new FeatureFlagsWiringProvider($app))->boot(); + + $paths = $app->make('migrator')->paths(); + expect(in_array(dirname(__DIR__, 2).'/database/migrations', $paths, true))->toBe($expected); + $app->flush(); +})->with([ + 'enabled database store' => [true, true, 'database', true], + 'memory store' => [true, true, 'memory', false], + 'store disabled' => [true, false, 'database', false], + 'feature flags disabled' => [false, true, 'database', false], +]); diff --git a/packages/feature-flags/tests/Server/MigrationRegistrationTest.php b/packages/feature-flags/tests/Server/MigrationRegistrationTest.php new file mode 100644 index 00000000..4844b41a --- /dev/null +++ b/packages/feature-flags/tests/Server/MigrationRegistrationTest.php @@ -0,0 +1,20 @@ +app()->make('migrator'); + + expect(array_keys($paths))->toHaveCount(1) + ->and((string) array_key_first($paths))->toEndWith('2026_10_01_000000_create_firefly_feature_flags_tables.php') + ->and(array_filter($migrator->paths(), static fn (string $path): bool => str_contains($path, 'feature-flags')))->toBe([]); +}); diff --git a/packages/feature-flags/tests/ShippedProviderBootTest.php b/packages/feature-flags/tests/ShippedProviderBootTest.php new file mode 100644 index 00000000..5582f30d --- /dev/null +++ b/packages/feature-flags/tests/ShippedProviderBootTest.php @@ -0,0 +1,251 @@ + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider)); +afterEach(fn () => OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider)); + +/** + * @param array $featureFlags + * @param array $scan + */ +function featureFlagsApp(array $featureFlags, array $scan = []): Application +{ + return fireflyApplication( + ['firefly' => ['feature-flags' => ['enabled' => true, ...$featureFlags], 'scan' => ['paths' => $scan]]], + [FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + needs: ['cache'], + ); +} + +function featureFlagsLifecycleFor(Provider $provider, ?FlagRegistry $registry = null): FeatureFlagsLifecycle +{ + $illuminate = new IlluminateContainer; + $illuminate->instance(Provider::class, $provider); + if ($registry !== null) { + $illuminate->instance(FlagRegistry::class, $registry); + } + + return new FeatureFlagsLifecycle(new FireflyContainer($illuminate, new ComponentManifest([]))); +} + +it('wires the registry, provider, and exact facade client from shipped manifests', function (): void { + $app = featureFlagsApp(['flags' => ['kill-switch' => true, 'checkout-flow' => 'v2']]); + /** @var ApplicationContext $context */ + $context = $app->make(ApplicationContext::class); + /** @var FeatureFlags $flags */ + $flags = $context->get(FeatureFlags::class); + + expect($flags->isEnabled('kill-switch'))->toBeTrue() + ->and($flags->getString('checkout-flow', 'v1'))->toBe('v2') + ->and($context->get(FlagRegistry::class))->toBeInstanceOf(FlagRegistry::class) + ->and($context->get(FeatureFlagMetrics::class))->toBeInstanceOf(NoOpFeatureFlagMetrics::class) + ->and($context->get(Client::class))->toBe($flags->client()) + ->and(OpenFeatureAPI::getInstance()->getProvider())->toBe($flags->provider()); + + $context->close(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBeInstanceOf(NoOpProvider::class); +}); + +it('restores the provider it replaced only while it still owns the global slot', function (): void { + $prior = new FixedProvider; + OpenFeatureAPI::getInstance()->setProvider($prior); + $first = featureFlagsApp(['flags' => ['a' => true]]); + /** @var ApplicationContext $firstContext */ + $firstContext = $first->make(ApplicationContext::class); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBeInstanceOf(FireflyFlagProvider::class); + $firstContext->close(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($prior); + + $second = featureFlagsApp(['flags' => ['a' => true]]); + $other = new FixedProvider; + OpenFeatureAPI::getInstance()->setProvider($other); + /** @var ApplicationContext $secondContext */ + $secondContext = $second->make(ApplicationContext::class); + $secondContext->close(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($other); +}); + +it('does not restore a stopped application through a later overlapping boot', function (): void { + $prior = new FixedProvider; + OpenFeatureAPI::getInstance()->setProvider($prior); + $first = featureFlagsApp(['flags' => ['a' => true]])->make(ApplicationContext::class); + $second = featureFlagsApp(['flags' => ['b' => true]])->make(ApplicationContext::class); + $secondProvider = OpenFeatureAPI::getInstance()->getProvider(); + + $first->close(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($secondProvider); + $second->close(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($prior); +}); + +it('keeps a shared external provider installed until the last overlapping lifecycle stops', function (bool $firstStopsFirst): void { + $prior = new NoOpProvider; + $shared = new FixedProvider; + $illuminate = new IlluminateContainer; + $illuminate->instance(Provider::class, $shared); + $beans = new FireflyContainer($illuminate, new ComponentManifest([])); + $first = new FeatureFlagsLifecycle($beans); + $second = new FeatureFlagsLifecycle($beans); + OpenFeatureAPI::getInstance()->setProvider($prior); + + $first->start(); + $second->start(); + ($firstStopsFirst ? $first : $second)->stop(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($shared); + ($firstStopsFirst ? $second : $first)->stop(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($prior); +})->with([true, false]); + +it('retains the middle owner when three lifecycles share a provider', function (array $stopOrder): void { + $prior = new NoOpProvider; + $shared = new FixedProvider; + OpenFeatureAPI::getInstance()->setProvider($prior); + $owners = [featureFlagsLifecycleFor($shared), featureFlagsLifecycleFor($shared), featureFlagsLifecycleFor($shared)]; + foreach ($owners as $owner) { + $owner->start(); + } + + foreach ($stopOrder as $index => $ownerIndex) { + if (! is_int($ownerIndex)) { + throw new UnexpectedValueException('A lifecycle stop index must be an integer.'); + } + $owners[$ownerIndex]->stop(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($index === 2 ? $prior : $shared); + } +})->with([[[0, 1, 2]], [[0, 2, 1]], [[1, 0, 2]], [[1, 2, 0]], [[2, 0, 1]], [[2, 1, 0]]]); + +it('restores the middle distinct provider after a shared outer owner stops', function (): void { + $prior = new NoOpProvider; + $shared = new FixedProvider; + $middleProvider = new FixedProvider; + OpenFeatureAPI::getInstance()->setProvider($prior); + $first = featureFlagsLifecycleFor($shared); + $middle = featureFlagsLifecycleFor($middleProvider); + $last = featureFlagsLifecycleFor($shared); + + $first->start(); + $middle->start(); + $last->start(); + $first->stop(); + $last->stop(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($middleProvider); + $middle->stop(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($prior); +}); + +it('restores the exact prior provider after a startup listener evaluates through the facade', function (): void { + $events = new class implements ApplicationEventPublisher + { + public ?FeatureFlags $flags = null; + + public bool $evaluated = false; + + public function publish(object $event): void + { + if ($event instanceof FeatureFlagsChanged) { + $this->evaluated = $this->flags?->isEnabled('a') ?? false; + } + } + }; + $logger = new NullLogger; + $registry = new FlagRegistry([new StubFlagSource('config', 100, 0.0, ['a' => true])], new CacheBook(new Repository(new ArrayStore), $logger), $events, $logger); + $provider = new FireflyFlagProvider($registry, new StaticFlagdEvaluator); + $events->flags = new FeatureFlags($provider, new EvaluationContextResolver); + $lifecycle = featureFlagsLifecycleFor($provider, $registry); + $prior = new FixedProvider; + OpenFeatureAPI::getInstance()->setProvider($prior); + + $lifecycle->start(); + expect($events->evaluated)->toBeTrue() + ->and(OpenFeatureAPI::getInstance()->getProvider())->toBe($provider); + $lifecycle->stop(); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($prior); +}); + +it('restores the prior provider if a failing startup source installed the facade provider first', function (): void { + $source = new StubFlagSource('file', 200, 0.0, failsStartup: true); + $source->failure = new RuntimeException('source unavailable'); + $events = new class implements ApplicationEventPublisher + { + public function publish(object $event): void {} + }; + $logger = new NullLogger; + $registry = new FlagRegistry([$source], new CacheBook(new Repository(new ArrayStore), $logger), $events, $logger); + $provider = new FireflyFlagProvider($registry, new StaticFlagdEvaluator); + $flags = new FeatureFlags($provider, new EvaluationContextResolver); + $source->beforeLoad = static function () use ($flags): void { + $flags->client(); + }; + $lifecycle = featureFlagsLifecycleFor($provider, $registry); + $prior = new FixedProvider; + OpenFeatureAPI::getInstance()->setProvider($prior); + + expect(fn () => $lifecycle->start())->toThrow(RuntimeException::class, 'source unavailable'); + expect(OpenFeatureAPI::getInstance()->getProvider())->toBe($prior); +}); + +it('attaches hooks only to the firefly client without accumulating on reboot', function (): void { + featureFlagsApp(['flags' => ['a' => true]]); + $context = featureFlagsApp(['flags' => ['a' => true]])->make(ApplicationContext::class); + /** @var FeatureFlags $flags */ + $flags = $context->get(FeatureFlags::class); + + expect(OpenFeatureAPI::getInstance()->getHooks())->toBe([]) + ->and($flags->client()->getHooks())->toHaveCount(1); +}); + +it('adds the exposure hook only when events.evaluations is enabled', function (): void { + /** @var FeatureFlags $flags */ + $flags = featureFlagsApp(['flags' => ['a' => true], 'events' => ['evaluations' => true]])->make(ApplicationContext::class)->get(FeatureFlags::class); + + expect($flags->client()->getHooks())->toHaveCount(2); +}); + +it('refuses invalid inline definitions at boot with the key and reason', function (): void { + expect(fn () => featureFlagsApp(['flags' => ['bad key' => true]])) + ->toThrow(InvalidFlagDefinition::class, 'Invalid feature flag [bad key] from source [config]: invalid flag key.'); +}); + +it('backs off to an application provider and leaves the registry unbound', function (): void { + $app = featureFlagsApp(['flags' => ['ignored' => false]], [ + 'Firefly\\FeatureFlags\\Tests\\Fixtures\\External\\' => __DIR__.'/Fixtures/External', + ]); + /** @var FeatureFlags $flags */ + $flags = $app->make(ApplicationContext::class)->get(FeatureFlags::class); + + expect($flags->isEnabled('anything'))->toBeTrue() + ->and($flags->provider())->toBeInstanceOf(FixedProvider::class) + ->and($flags->details('ignored', false)->metadata)->toBe([]) + ->and($app->bound(FlagRegistry::class))->toBeFalse() + ->and(OpenFeatureAPI::getInstance()->getProvider())->toBe($flags->provider()); +}); diff --git a/packages/feature-flags/tests/Source/ConfigFlagSourceTest.php b/packages/feature-flags/tests/Source/ConfigFlagSourceTest.php new file mode 100644 index 00000000..7afdde43 --- /dev/null +++ b/packages/feature-flags/tests/Source/ConfigFlagSourceTest.php @@ -0,0 +1,82 @@ + $section */ +function featureFlagsConfigSource(array $section): ConfigFlagSource +{ + return new ConfigFlagSource(FeatureFlagsSettings::fromConfig(new Config(new Repository(['firefly' => ['feature-flags' => $section]])))); +} + +it('is the lowest, always-checked layer that refuses the boot', function (): void { + $source = featureFlagsConfigSource([]); + + expect($source->name())->toBe(FlagSource::CONFIG) + ->and($source->precedence())->toBe(100) + ->and($source->refreshInterval())->toBe(0.0) + ->and($source->failsStartup())->toBeTrue() + ->and($source->reportedRevision('abc'))->toBeNull(); +}); + +it('normalizes shorthand, keeps evaluators and reports an unchanged revision as null', function (): void { + $source = featureFlagsConfigSource([ + 'flags' => ['kill-switch' => true, 'checkout-flow' => 'v2'], + 'evaluators' => ['is-beta' => ['in' => ['beta', ['var' => 'roles']]]], + ]); + + $snapshot = $source->load(null); + + expect($snapshot)->not->toBeNull() + ->and($snapshot?->document->flag('kill-switch')?->defaultVariant())->toBe('on') + ->and($snapshot?->document->flag('checkout-flow')?->variantNames())->toBe(['v2']) + ->and(array_keys($snapshot?->document->evaluators ?? []))->toBe(['is-beta']) + ->and($source->load($snapshot?->revision))->toBeNull(); +}); + +it('loads again under a configuration that differs, and only then', function (): void { + $before = featureFlagsConfigSource(['flags' => ['kill-switch' => true]])->load(null); + $same = featureFlagsConfigSource(['flags' => ['kill-switch' => true]]); + $after = featureFlagsConfigSource(['flags' => ['kill-switch' => false]]); + + expect($same->load($before?->revision))->toBeNull() + ->and($after->load($before?->revision)?->document->flag('kill-switch')?->defaultVariant())->toBe('off'); +}); + +it('refuses an invalid inline definition with its key and the contract phrase', function (): void { + expect(fn () => featureFlagsConfigSource(['flags' => ['bad key' => true]])->load(null)) + ->toThrow(InvalidFlagDefinition::class, 'invalid flag key'); +}); + +it('reads an empty PHP array as an empty object: PHP configuration cannot write {}', function (): void { + $document = featureFlagsConfigSource(['flags' => [], 'evaluators' => []])->load(null)?->document; + + expect($document?->keys())->toBe([]) + ->and($document?->evaluators)->toBe([]); +}); + +it('refuses a non-empty list where the flags or the evaluators object belongs', function (string $section, string $key, string $reason): void { + $refusal = null; + try { + featureFlagsConfigSource([$section => [['var' => 'a']]])->load(null); + } catch (InvalidFlagDefinition $caught) { + $refusal = $caught; + } + + expect([$refusal?->flagKey(), $refusal?->reason()])->toBe([$key, $reason]); +})->with([ + 'flags' => ['flags', 'flags', 'flags must be an object'], + 'evaluators' => ['evaluators', '$evaluators', '$evaluators must be an object'], +]); + +it('never sees a flags or evaluators value that is not an array: the settings refuse it first, naming the key', function (string $section): void { + expect(fn () => featureFlagsConfigSource([$section => 'new-checkout'])) + ->toThrow(ConfigurationException::class, "[firefly.feature-flags.{$section}] must be array"); +})->with(['flags', 'evaluators']); diff --git a/packages/feature-flags/tests/Source/FileFlagSourceTest.php b/packages/feature-flags/tests/Source/FileFlagSourceTest.php new file mode 100644 index 00000000..2b1854a1 --- /dev/null +++ b/packages/feature-flags/tests/Source/FileFlagSourceTest.php @@ -0,0 +1,256 @@ + ['feature-flags' => [ + 'sources' => ['file' => ['enabled' => true, 'path' => $path, 'refresh-interval' => '5s']], + ]]])))); +} + +/** The InvalidFlagDefinition loading $path raises, or null when it loads. */ +function featureFlagsFileRefusal(string $path): ?InvalidFlagDefinition +{ + try { + featureFlagsFileSource($path)->load(null); + } catch (InvalidFlagDefinition $caught) { + return $caught; + } + + return null; +} + +afterEach(function (): void { + TemporaryFlagFiles::removeAll(); +}); + +it('is the file layer, checked once per refresh interval, that refuses a boot it never loaded', function (): void { + $source = featureFlagsFileSource('/srv/flags.json'); + + expect($source->name())->toBe(FlagSource::FILE) + ->and($source->precedence())->toBe(200) + ->and($source->refreshInterval())->toBe(5.0) + ->and($source->failsStartup())->toBeTrue() + ->and($source->reportedRevision('1767225600-42'))->toBe('1767225600-42'); +}); + +it('reads a JSON flagd document and keeps {} an object', function (): void { + $json = '{"flags":{"banner":{"state":"ENABLED","variants":{"none":{},"promo":{"t":"x"}},"defaultVariant":"none"}}}'; + $path = TemporaryFlagFiles::write('json', $json); + + $snapshot = featureFlagsFileSource($path)->load(null); + + expect($snapshot?->document->flag('banner')?->variantValue('none'))->toBeInstanceOf(stdClass::class) + ->and($snapshot?->revision)->toBe(TemporaryFlagFiles::MTIME.'-'.strlen($json)); +}); + +it('reads the same document written as YAML', function (string $extension): void { + $path = TemporaryFlagFiles::write($extension, "flags:\n new-checkout:\n state: ENABLED\n variants: {on: true, off: false}\n defaultVariant: off\n metadata: {}\n"); + + $flag = featureFlagsFileSource($path)->load(null)?->document->flag('new-checkout'); + + expect($flag?->defaultVariant())->toBe('off') + ->and($flag?->toArray()['metadata'] ?? null)->toBeInstanceOf(stdClass::class); +})->with(['yaml', 'yml', 'YAML']); + +it('answers null while mtime and size are unchanged and reloads when either moves', function (): void { + $enabled = '{"flags":{"a":{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on"}}}'; + $disabled = '{"flags":{"a":{"state":"DISABLED","variants":{"on":true},"defaultVariant":"on"}},"metadata":{"v":2}}'; + $path = TemporaryFlagFiles::write('json', $enabled); + $source = featureFlagsFileSource($path); + $first = $source->load(null); + + expect($source->load($first?->revision))->toBeNull(); + + TemporaryFlagFiles::rewrite($path, $disabled); + $resized = $source->load($first?->revision); + + expect($resized?->document->flag('a')?->isDisabled())->toBeTrue() + ->and($resized?->revision)->toBe(TemporaryFlagFiles::MTIME.'-'.strlen($disabled)) + ->and($source->load($resized?->revision))->toBeNull(); + + touch($path, TemporaryFlagFiles::MTIME + 10); + + expect($source->load($resized?->revision)?->revision)->toBe((TemporaryFlagFiles::MTIME + 10).'-'.strlen($disabled)); +}); + +it('retries a broken edit on every check: a failed load carries no revision to remember', function (): void { + $good = '{"flags":{"a":{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on"}}}'; + $fixed = '{"flags":{"a":{"state":"DISABLED","variants":{"on":true},"defaultVariant":"on"}}}'; + $path = TemporaryFlagFiles::write('json', $good); + $source = featureFlagsFileSource($path); + $loaded = $source->load(null); + + TemporaryFlagFiles::rewrite($path, '{"flags": {', TemporaryFlagFiles::MTIME + 10); + + expect(fn () => $source->load($loaded?->revision))->toThrow(InvalidFlagDefinition::class, 'not valid JSON') + ->and(fn () => $source->load($loaded?->revision))->toThrow(InvalidFlagDefinition::class, 'not valid JSON'); + + TemporaryFlagFiles::rewrite($path, $fixed, TemporaryFlagFiles::MTIME + 20); + + expect($source->load($loaded?->revision)?->document->flag('a')?->isDisabled())->toBeTrue(); +}); + +it('refuses shorthand in a file: shorthand belongs to config', function (): void { + $path = TemporaryFlagFiles::write('json', '{"flags":{"a":true}}'); + + expect(fn () => featureFlagsFileSource($path)->load(null))->toThrow(InvalidFlagDefinition::class, 'flag definition must be an object'); +}); + +it('reports a missing or misnamed file as unavailable, naming the extension before looking at the disk', function (): void { + $text = TemporaryFlagFiles::write('txt', '{}'); + + expect(fn () => featureFlagsFileSource('/nowhere/flags.json')->load(null))->toThrow(FlagSourceUnavailable::class, 'does not exist') + ->and(fn () => featureFlagsFileSource($text)->load(null))->toThrow(FlagSourceUnavailable::class, '.json, .yaml or .yml') + ->and(fn () => featureFlagsFileSource('/nowhere/flags.txt')->load(null))->toThrow(FlagSourceUnavailable::class, '.json, .yaml or .yml'); +}); + +it('refuses a file it cannot parse as the whole document, naming the file', function (string $extension, string $contents, string $reason): void { + $path = TemporaryFlagFiles::write($extension, $contents); + + $refusal = featureFlagsFileRefusal($path); + + expect($refusal?->flagKey())->toBe('') + ->and($refusal?->reason())->toStartWith("the flag file [{$path}] is not valid {$reason}"); +})->with([ + 'JSON syntax' => ['json', '{"flags": {', 'JSON'], + 'an empty JSON file' => ['json', '', 'JSON'], + 'JSON with malformed UTF-8' => ['json', "{\"flags\":{},\"metadata\":{\"owner\":\"\xff\"}}", 'JSON'], + 'YAML syntax' => ['yaml', "flags: [unclosed\n", 'YAML'], + 'YAML with malformed UTF-8' => ['yml', "metadata:\n owner: \xff\n", 'YAML'], + 'a PHP constant in YAML' => ['yaml', "metadata:\n limit: !php/const PHP_INT_MAX\n", 'YAML'], + 'a date as a flow-mapping key in YAML' => ['yaml', "metadata: {2024-01-01: launch}\n", 'YAML'], + 'a YAML block key starting with NUL, which Symfony Yaml fails on with an Error' => ['yaml', "metadata:\n \"\\0a\": x\n", 'YAML'], +]); + +it('reads an absent document as no flags: JSON null, an empty or comment-only YAML file', function (string $extension, string $contents): void { + $path = TemporaryFlagFiles::write($extension, $contents); + + $snapshot = featureFlagsFileSource($path)->load(null); + + expect($snapshot?->document->keys())->toBe([]) + ->and($snapshot?->revision)->toBe(TemporaryFlagFiles::MTIME.'-'.strlen($contents)); +})->with([ + 'JSON null' => ['json', 'null'], + 'an empty YAML file' => ['yaml', ''], + 'a comment-only YAML file' => ['yml', "# no flags yet\n"], + 'a YAML null' => ['yaml', "~\n"], +]); + +it('refuses any other document that is not an object', function (string $extension, string $contents): void { + $refusal = featureFlagsFileRefusal(TemporaryFlagFiles::write($extension, $contents)); + + expect([$refusal?->flagKey(), $refusal?->reason()])->toBe(['', 'document must be an object']); +})->with([ + 'a JSON array' => ['json', '[]'], + 'a JSON zero' => ['json', '0'], + 'a JSON false' => ['json', 'false'], + 'a JSON string' => ['json', '"flags"'], + 'a YAML sequence' => ['yaml', "- new-checkout\n"], + 'an empty YAML sequence' => ['yaml', "[]\n"], + 'a YAML false' => ['yml', "false\n"], + 'a YAML string' => ['yaml', "flags\n"], +]); + +it('reads an unquoted YAML date as the YYYY-MM-DD text flagd expects, and a timestamp as ISO-8601 text', function (): void { + $path = TemporaryFlagFiles::write('yaml', <<<'YAML' + flags: + old-banner: + state: ENABLED + variants: {on: true, off: false} + defaultVariant: off + metadata: + expires: 2025-01-01 + releasedAt: 2024-06-01T10:30:00Z + notes: [2024-06-01, {at: 2024-06-01 08:00:00.5 +02:00}] + metadata: {snapshot: 2025-01-01} + + YAML); + + $document = featureFlagsFileSource($path)->load(null)?->document; + $flag = $document?->flag('old-banner'); + + expect($flag?->expires())->toBe('2025-01-01') + ->and($flag?->isExpired('2026-10-02'))->toBeTrue() + ->and($flag?->metadata()['releasedAt'] ?? null)->toBe('2024-06-01T10:30:00+00:00') + ->and($flag?->toArray()['notes'] ?? null)->toEqual(['2024-06-01', ['at' => '2024-06-01T08:00:00.500000+02:00']]) + ->and($document?->metadata)->toBe(['snapshot' => '2025-01-01']); +}); + +it('loads an impossible unquoted YAML date as the date PHP rolls it over to: a known YAML divergence, documented (quote dates)', function (): void { + $unquoted = TemporaryFlagFiles::write('yaml', "flags:\n typo:\n state: ENABLED\n variants: {on: true, off: false}\n defaultVariant: off\n metadata: {expires: 2025-02-30, note: 2025-02-30}\n"); + $quoted = TemporaryFlagFiles::write('yaml', "flags:\n typo:\n state: ENABLED\n variants: {on: true, off: false}\n defaultVariant: off\n metadata: {expires: '2025-02-30'}\n"); + + $flag = featureFlagsFileSource($unquoted)->load(null)?->document->flag('typo'); + + expect($flag?->expires())->toBe('2025-03-02') + ->and($flag?->metadata()['note'] ?? null)->toBe('2025-03-02') + ->and(featureFlagsFileRefusal($quoted)?->reason())->toBe('expires must be a YYYY-MM-DD date'); +}); + +it('sees a rewrite by another process that PHP\'s stat cache would hide', function (): void { + $before = '{"flags":{"a":{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on"}}}'; + $after = '{"flags":{"a":{"state":"DISABLED","variants":{"on":true},"defaultVariant":"on"}},"metadata":{"v":2}}'; + $path = TemporaryFlagFiles::write('json', $before); + $source = featureFlagsFileSource($path); + $loaded = $source->load(null); + + expect($source->load($loaded?->revision))->toBeNull() // an unchanged check leaves the stat of $path cached + ->and(filesize($path))->toBe(strlen($before)); + + TemporaryFlagFiles::rewriteFromAnotherProcess($path, $after); + $reloaded = $source->load($loaded?->revision); + + expect($reloaded?->document->flag('a')?->isDisabled())->toBeTrue() + ->and($reloaded?->revision)->toEndWith('-'.strlen($after)); +}); + +it('keeps a YAML flow-mapping key starting with NUL as Symfony Yaml reads it', function (): void { + $path = TemporaryFlagFiles::write('yaml', "metadata: {\"\\0a\": x}\n"); + + expect(featureFlagsFileSource($path)->load(null)?->document->metadata)->toBe(["\0a" => 'x']); +}); + +it('retains a good YAML cache across invalid binary refresh and fresh worker boot then accepts NUL names', function (): void { + $good = "flags:\n a:\n state: ENABLED\n variants: {on: true, off: false}\n defaultVariant: on\n"; + $path = TemporaryFlagFiles::write('yaml', $good); + $cache = new Illuminate\Cache\Repository(new ArrayStore); + $events = new RecordingApplicationEventPublisher; + $clock = new FixedClock; + $make = static fn (): FlagRegistry => new FlagRegistry( + [featureFlagsFileSource($path)], new CacheBook($cache, new NullLogger), $events, clock: $clock(...), + ); + $registry = $make(); + $registry->start(); + $revision = $registry->states()[0]->revision; + TemporaryFlagFiles::rewrite($path, $good." metadata: {binary: !!binary /w==}\n", TemporaryFlagFiles::MTIME + 10); + $clock->now += 6; + $registry->refresh(); + $fresh = $make(); + $fresh->start(); + expect($fresh->document()->flag('a'))->not->toBeNull() + ->and($fresh->states()[0]->status())->toBe('STALE') + ->and($fresh->states()[0]->revision)->toBe($revision) + ->and($events->events)->toHaveCount(1); + TemporaryFlagFiles::rewrite($path, $good." metadata: {\"\\0name\": value}\n", TemporaryFlagFiles::MTIME + 20); + $fresh->refresh(force: true); + expect($fresh->document()->flag('a')?->metadata())->toBe(["\0name" => 'value']) + ->and($fresh->states()[0]->status())->toBe('UP') + ->and($events->events)->toHaveCount(2); +}); diff --git a/packages/feature-flags/tests/Source/HttpFlagSourceTest.php b/packages/feature-flags/tests/Source/HttpFlagSourceTest.php new file mode 100644 index 00000000..ba56bf35 --- /dev/null +++ b/packages/feature-flags/tests/Source/HttpFlagSourceTest.php @@ -0,0 +1,144 @@ + ['feature-flags' => [ + 'sources' => ['http' => ['enabled' => true, 'url' => 'https://flags.test/feature-flags/flagd.json', 'token' => $token, 'timeout' => '1s', 'refresh-interval' => '30s']], + ]]]))), $http); +} + +it('sends the bearer token and accepted revision, and keeps the accepted document on 304', function (): void { + /** @var list $seen */ + $seen = []; + $http = new Factory; + $http->fake(function (Request $request) use (&$seen) { + $seen[] = [$request->header('Authorization')[0] ?? null, $request->header('If-None-Match')[0] ?? null]; + + return ($request->header('If-None-Match')[0] ?? null) === '"v1"' + ? Factory::response('', 304, ['ETag' => '"v1"']) + : Factory::response('{"flags":{"a":{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on"}}}', 200, ['ETag' => '"v1"']); + }); + + $source = featureFlagsHttpSource($http); + $first = $source->load(null); + + expect($first?->revision)->toBe('"v1"') + ->and($first?->document->keys())->toBe(['a']) + ->and($source->load($first?->revision))->toBeNull() + ->and($seen)->toBe([['Bearer s3cret', null], ['Bearer s3cret', '"v1"']]) + ->and($source->name())->toBe('http') + ->and($source->precedence())->toBe(300) + ->and($source->refreshInterval())->toBe(30.0) + ->and($source->failsStartup())->toBeFalse() + ->and($source->reportedRevision('"v1"'))->toBe('"v1"'); +}); + +it('omits Authorization without a token and hashes the body when no ETag is sent', function (): void { + $body = '{"flags":{}}'; + $http = new Factory; + $http->fake(fn (Request $request) => Factory::response($request->hasHeader('Authorization') ? 'unexpected' : $body, 200)); + + expect(featureFlagsHttpSource($http, '')->load(null)?->revision)->toBe('"'.hash('sha256', $body).'"'); +}); + +it('reports connection failures, error statuses and malformed documents through their source errors', function (): void { + $timeout = new Factory; + $timeout->fake(fn () => throw new ConnectionException('cURL error 28: Operation timed out')); + $unavailable = new Factory; + $unavailable->fake(['*' => Factory::response('oops', 503)]); + $garbage = new Factory; + $garbage->fake(['*' => Factory::response('', 200)]); + $invalid = new Factory; + $invalid->fake(['*' => Factory::response('{"flags":{"bad key":{"state":"ENABLED","variants":{"on":true}}}}', 200)]); + + expect(fn () => featureFlagsHttpSource($timeout)->load(null))->toThrow(FlagSourceUnavailable::class, 'timed out') + ->and(fn () => featureFlagsHttpSource($unavailable)->load('"v1"'))->toThrow(FlagSourceUnavailable::class, 'HTTP 503') + ->and(fn () => featureFlagsHttpSource($garbage)->load(null))->toThrow(InvalidFlagDefinition::class, 'not valid JSON') + ->and(fn () => featureFlagsHttpSource($invalid)->load(null))->toThrow(InvalidFlagDefinition::class, 'invalid flag key'); +}); + +it('does not accept an unsolicited 304 as the first snapshot', function (): void { + $http = new Factory; + $http->fake(['*' => Factory::response('', 304)]); + + expect(fn () => featureFlagsHttpSource($http)->load(null))->toThrow(FlagSourceUnavailable::class, 'HTTP 304'); +}); + +it('retries with the last accepted revision after refusing an invalid replacement', function (): void { + /** @var list $seen */ + $seen = []; + $http = new Factory; + $http->fake(function (Request $request) use (&$seen) { + $seen[] = $request->header('If-None-Match')[0] ?? null; + + return match (count($seen)) { + 1 => Factory::response('{"flags":{"a":{"state":"ENABLED","variants":{"on":true}}}}', 200, ['ETag' => '"v1"']), + 2 => Factory::response('{"flags":{"bad key":{}}}', 200, ['ETag' => '"v2"']), + default => Factory::response('{"flags":{"a":{"state":"DISABLED","variants":{"on":true}}}}', 200, ['ETag' => '"v2"']), + }; + }); + $source = featureFlagsHttpSource($http); + $accepted = $source->load(null); + + expect(fn () => $source->load($accepted?->revision))->toThrow(InvalidFlagDefinition::class, 'invalid flag key'); + + $recovered = $source->load($accepted?->revision); + + expect($recovered?->revision)->toBe('"v2"') + ->and($recovered?->document->flag('a')?->isDisabled())->toBeTrue() + ->and($seen)->toBe([null, '"v1"', '"v1"']); +}); + +it('keeps the registry last good document and accepted ETag through a refusal and later 304', function (): void { + /** @var list $seen */ + $seen = []; + $http = new Factory; + $http->fake(function (Request $request) use (&$seen) { + $seen[] = $request->header('If-None-Match')[0] ?? null; + + return match (count($seen)) { + 1 => Factory::response('{"flags":{"a":{"state":"ENABLED","variants":{"on":true},"defaultVariant":"on"}}}', 200, ['ETag' => '"v1"']), + 2 => Factory::response('{"flags":{"bad key":{}}}', 200, ['ETag' => '"v2"']), + 3 => Factory::response('{"flags":{"a":{"state":"DISABLED","variants":{"on":true},"defaultVariant":"on"}}}', 200, ['ETag' => '"v2"']), + default => Factory::response('', 304, ['ETag' => '"v2"']), + }; + }); + $clock = new FixedClock; + $cache = new CacheRepository(new ArrayStore); + $registry = new FlagRegistry([featureFlagsHttpSource($http)], new CacheBook($cache, new NullLogger), new RecordingApplicationEventPublisher, clock: $clock(...)); + + expect($registry->document()->flag('a')?->isDisabled())->toBeFalse(); + + $clock->now += 31.0; + expect($registry->document()->flag('a')?->isDisabled())->toBeFalse() + ->and($registry->states()[0]->status())->toBe('STALE') + ->and($registry->states()[0]->revision)->toBe('"v1"'); + + $clock->now += 31.0; + expect($registry->document()->flag('a')?->isDisabled())->toBeTrue() + ->and($registry->states()[0]->status())->toBe('UP') + ->and($registry->states()[0]->revision)->toBe('"v2"'); + + $clock->now += 31.0; + expect($registry->document()->flag('a')?->isDisabled())->toBeTrue() + ->and($seen)->toBe([null, '"v1"', '"v1"', '"v2"']); +}); diff --git a/packages/feature-flags/tests/Source/StoreFlagSourceTest.php b/packages/feature-flags/tests/Source/StoreFlagSourceTest.php new file mode 100644 index 00000000..1c5160bd --- /dev/null +++ b/packages/feature-flags/tests/Source/StoreFlagSourceTest.php @@ -0,0 +1,152 @@ + ['feature-flags' => [ + 'enabled' => true, 'sources' => ['store' => ['enabled' => true, 'refresh-interval' => '5s']], + ]]]))); + $container = new Container; + $container->instance(FlagStore::class, new DatabaseFlagStore($connection)); + + return new FlagRegistry( + [new StoreFlagSource($settings, new FireflyContainer($container, new ComponentManifest([])))], + new CacheBook(new Repository(new ArrayStore), new RecordingLogger), + $events ?? new RecordingApplicationEventPublisher, + new RecordingLogger, + $clock, + ); +} + +/** @return array{state: string, variants: array{on: bool, off: bool}, defaultVariant: string} */ +function featureFlagsStoreDefinition(string $variant = 'off'): array +{ + return ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => $variant]; +} + +it('polls a second process after the store interval and rejects an invalid external row as a whole', function (): void { + $connection = FlagStores::sqlite(); + $now = 1790000000.0; + $clock = static function () use (&$now): float { + return $now; + }; + $first = featureFlagsStoreRegistry($connection, $clock); + $second = featureFlagsStoreRegistry($connection, $clock); + $first->document(); + $second->document(); + (new FlagStoreWriter(new DatabaseFlagStore($connection), $first, new RecordingApplicationEventPublisher)) + ->put('good', featureFlagsStoreDefinition(), 'ops'); + expect($second->document()->flag('good'))->toBeNull(); + $now += 5.0; + expect($second->document()->flag('good')?->defaultVariant())->toBe('off'); + + $connection->insert('INSERT INTO firefly_feature_flags (flag_key, definition, version, updated_at, updated_by) VALUES (?, ?, ?, ?, ?)', ['bad key', '{"state":"ENABLED","variants":{"on":true}}', 1, '2026-10-01 12:00:00', null]); + $connection->insert('INSERT INTO firefly_feature_flag_changes (flag_key, action, definition, previous, actor, changed_at) VALUES (?, ?, ?, ?, ?, ?)', ['bad key', 'put', null, null, null, '2026-10-01 12:00:00']); + $now += 5.0; + expect($second->document()->keys())->toBe(['good']) + ->and($second->states()[0]->status())->toBe('STALE') + ->and($second->states()[0]->error)->toContain('invalid flag key'); +}); + +it('keeps DOWN before the first committed read and hides manual uncommitted rows from forced refresh', function (): void { + $connection = FlagStores::sqlite(); + $events = new RecordingApplicationEventPublisher; + $registry = featureFlagsStoreRegistry($connection, static fn (): float => 1790000000.0, $events); + $connection->beginTransaction(); + (new DatabaseFlagStore($connection))->put('pending', featureFlagsStoreDefinition(), 'ops'); + $registry->refresh(force: true, only: 'store'); + expect($registry->states()[0]->status())->toBe('DOWN') + ->and($registry->document()->flag('pending'))->toBeNull() + ->and($events->ofType(FeatureFlagsChanged::class))->toBe([]); + $connection->rollBack(); + expect($registry->document()->flag('pending'))->toBeNull(); +}); + +it('retains stale health and last-good state through a nested rollback, then reads after root commit', function (): void { + $connection = FlagStores::sqlite(); + $now = 1790000000.0; + $clock = static function () use (&$now): float { + return $now; + }; + $events = new RecordingApplicationEventPublisher; + $registry = featureFlagsStoreRegistry($connection, $clock, $events); + $store = new DatabaseFlagStore($connection); + $store->put('good', featureFlagsStoreDefinition(), 'ops'); + $registry->document(); + $connection->insert('INSERT INTO firefly_feature_flags (flag_key, definition, version, updated_at, updated_by) VALUES (?, ?, ?, ?, ?)', ['bad key', '{"state":"ENABLED"}', 1, '2026-10-01 12:00:00', null]); + $connection->insert('INSERT INTO firefly_feature_flag_changes (flag_key, action, definition, previous, actor, changed_at) VALUES (?, ?, ?, ?, ?, ?)', ['bad key', 'put', null, null, null, '2026-10-01 12:00:00']); + $now += 5.0; + $registry->refresh(force: true, only: 'store'); + $stale = $registry->states()[0]; + $count = count($events->ofType(FeatureFlagsChanged::class)); + $connection->beginTransaction(); + $connection->beginTransaction(); + $connection->table('firefly_feature_flags')->where('flag_key', 'bad key')->delete(); + $store->put('pending', featureFlagsStoreDefinition('on'), 'ops'); + $now += 10.0; + $registry->refresh(force: true, only: 'store'); + expect($registry->states()[0]->toArray())->toBe($stale->toArray()) + ->and($registry->document()->flag('pending'))->toBeNull() + ->and(count($events->ofType(FeatureFlagsChanged::class)))->toBe($count); + $connection->rollBack(); + $registry->refresh(force: true, only: 'store'); + expect($registry->states()[0]->toArray())->toBe($stale->toArray()); + $connection->table('firefly_feature_flags')->where('flag_key', 'bad key')->delete(); + $connection->commit(); + $registry->refresh(force: true, only: 'store'); + expect($registry->states()[0]->status())->toBe('UP') + ->and($registry->document()->flag('good')?->defaultVariant())->toBe('off'); +}); + +it('does not defer a store read for a transaction on another connection', function (): void { + $storeConnection = FlagStores::sqlite(); + $otherConnection = FlagStores::sqlite(); + $registry = featureFlagsStoreRegistry($storeConnection, static fn (): float => 1790000000.0); + (new DatabaseFlagStore($storeConnection))->put('committed', featureFlagsStoreDefinition(), 'ops'); + $otherConnection->beginTransaction(); + try { + expect($registry->document()->flag('committed')?->defaultVariant())->toBe('off') + ->and($registry->states()[0]->status())->toBe('UP'); + } finally { + $otherConnection->rollBack(); + } +}); + +it('publishes and refreshes a writer change only after the store root transaction commits', function (): void { + $connection = FlagStores::sqlite(); + $events = new RecordingApplicationEventPublisher; + $registry = featureFlagsStoreRegistry($connection, static fn (): float => 1790000000.0, $events); + $registry->document(); + $initialChanges = count($events->ofType(FeatureFlagsChanged::class)); + $writer = new FlagStoreWriter(new DatabaseFlagStore($connection), $registry, $events); + $connection->beginTransaction(); + $writer->put('committed', featureFlagsStoreDefinition(), 'ops'); + $registry->refresh(force: true, only: 'store'); + expect($registry->document()->flag('committed'))->toBeNull() + ->and(count($events->ofType(FeatureFlagsChanged::class)))->toBe($initialChanges); + $connection->commit(); + expect($registry->document()->flag('committed')?->defaultVariant())->toBe('off') + ->and(count($events->ofType(FeatureFlagsChanged::class)))->toBe($initialChanges + 1); +}); diff --git a/packages/feature-flags/tests/Store/CrossFrameworkRowsTest.php b/packages/feature-flags/tests/Store/CrossFrameworkRowsTest.php new file mode 100644 index 00000000..6f365182 --- /dev/null +++ b/packages/feature-flags/tests/Store/CrossFrameworkRowsTest.php @@ -0,0 +1,46 @@ +insert( + 'INSERT INTO firefly_feature_flags (flag_key, definition, version, updated_at, updated_by) VALUES (?, ?, ?, ?, ?)', + ['new-checkout', '{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on", "metadata": {"owner": "payments"}}', 3, '2026-10-01 12:00:00.123456', null], + ); + $connection->insert( + 'INSERT INTO firefly_feature_flag_changes (flag_key, action, definition, previous, actor, changed_at) VALUES (?, ?, ?, ?, ?, ?)', + ['new-checkout', 'put', '{"state": "ENABLED", "variants": {"on": true, "off": false}, "defaultVariant": "on"}', null, 'cli:ana', '2026-10-01 11:59:59.000001'], + ); + $connection->insert( + 'INSERT INTO firefly_feature_flag_changes (flag_key, action, definition, previous, actor, changed_at) VALUES (?, ?, ?, ?, ?, ?)', + ['legacy', 'delete', null, '{"state": "DISABLED", "variants": {"x": {}}, "defaultVariant": "x"}', 'actuator', '2026-10-01 12:00:01'], + ); + + $store = new DatabaseFlagStore($connection); + $flag = $store->get('new-checkout'); + $deleted = $store->history('legacy')[0]; + + expect($flag?->version)->toBe(3) + ->and($flag?->updatedBy)->toBeNull() + ->and($flag?->updatedAt->format('Y-m-d\TH:i:s.u\Z'))->toBe('2026-10-01T12:00:00.123456Z') + ->and(Json::encode($flag?->definition))->toBe('{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on","metadata":{"owner":"payments"}}') + ->and($store->revision())->toBe(2) + ->and($store->history('new-checkout')[0]->toHistoryRow())->toBe(['id' => 1, 'action' => 'put', 'actor' => 'cli:ana', 'changedAt' => '2026-10-01T11:59:59Z']) + ->and([$deleted->definition, Json::encode($deleted->previous)])->toBe([null, '{"state":"DISABLED","variants":{"x":{}},"defaultVariant":"x"}']); +}); + +it('writes rows PyFly can read: compact JSON objects, version from 1, UTC microseconds', function (): void { + $connection = FlagStores::sqlite(); + (new DatabaseFlagStore($connection, FlagStores::clock()))->put('k', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on', 'metadata' => []], 'ada'); + + $flag = (array) $connection->selectOne('SELECT flag_key, definition, version, updated_at, updated_by FROM firefly_feature_flags'); + $change = (array) $connection->selectOne('SELECT id, flag_key, action, definition, previous, actor, changed_at FROM firefly_feature_flag_changes'); + + expect($flag)->toEqual(['flag_key' => 'k', 'definition' => '{"defaultVariant":"on","metadata":{},"state":"ENABLED","variants":{"on":true}}', 'version' => 1, 'updated_at' => '2026-10-01 12:00:00.000000', 'updated_by' => 'ada']) + ->and($change)->toEqual(['id' => 1, 'flag_key' => 'k', 'action' => 'put', 'definition' => $flag['definition'], 'previous' => null, 'actor' => 'ada', 'changed_at' => '2026-10-01 12:00:00.000000']); +}); diff --git a/packages/feature-flags/tests/Store/DatabaseFlagStoreTest.php b/packages/feature-flags/tests/Store/DatabaseFlagStoreTest.php new file mode 100644 index 00000000..415ff117 --- /dev/null +++ b/packages/feature-flags/tests/Store/DatabaseFlagStoreTest.php @@ -0,0 +1,117 @@ +put('banner', Json::members(Json::decode('{"state":"ENABLED","variants":{"none":{},"promo":{"t":"x"}},"defaultVariant":"none","metadata":{}}')), 'ada'); + + expect($connection->table(FeatureFlagSchema::FLAGS)->where('flag_key', 'banner')->value('definition')) + ->toBe('{"defaultVariant":"none","metadata":{},"state":"ENABLED","variants":{"none":{},"promo":{"t":"x"}}}') + ->and($store->get('banner')?->definition['variants'] ?? null)->toBeArray(); +}); + +it('writes the flag row and the change row in one transaction', function (): void { + $connection = FlagStores::sqlite(); + $connection->getSchemaBuilder()->drop(FeatureFlagSchema::CHANGES); // the change insert will fail … + $store = new DatabaseFlagStore($connection, FlagStores::clock()); + + expect(fn () => $store->put('k', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on'], 'ada')) + ->toThrow(QueryException::class) + // … and takes the flag row down with it. + ->and($connection->table(FeatureFlagSchema::FLAGS)->count())->toBe(0); +}); + +it('creates the schema idempotently, with the contract\'s index', function (): void { + $connection = FlagStores::sqlite(); + FeatureFlagSchema::create($connection->getSchemaBuilder()); + $indexes = array_column($connection->getSchemaBuilder()->getIndexes(FeatureFlagSchema::CHANGES), 'name'); + + expect($indexes)->toContain(FeatureFlagSchema::CHANGES_INDEX) + ->and($connection->getSchemaBuilder()->getColumnListing(FeatureFlagSchema::FLAGS)) + ->toEqualCanonicalizing(['flag_key', 'definition', 'version', 'updated_at', 'updated_by']); +}); + +it('reports audit integrity errors without misclassifying them as a version conflict', function (): void { + $connection = FlagStores::sqlite(); + $connection->statement('CREATE UNIQUE INDEX audit_actor_unique ON firefly_feature_flag_changes (actor)'); + $store = new DatabaseFlagStore($connection, FlagStores::clock()); + $definition = ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on']; + $store->put('first', $definition, 'ada'); + expect(fn () => $store->put('second', $definition, 'ada'))->toThrow(UniqueConstraintViolationException::class) + ->and($store->get('second'))->toBeNull()->and($store->revision())->toBe(1); +}); + +it('converts clock zones to UTC and returns canonical detached objects with bounded history', function (): void { + $connection = FlagStores::sqlite(); + $store = new DatabaseFlagStore($connection, static fn (): DateTimeImmutable => new DateTimeImmutable('2026-10-01 05:00:00.123456-07:00')); + $definition = ['state' => 'ENABLED', 'variants' => ['on' => (object) []], 'defaultVariant' => 'on']; + $change = $store->put('k', $definition, null); + $definition['variants']['on']->changed = true; + expect($connection->table(FeatureFlagSchema::FLAGS)->value('updated_at'))->toBe('2026-10-01 12:00:00.123456') + ->and($change->toHistoryRow()['changedAt'])->toBe('2026-10-01T12:00:00Z') + ->and(Json::encode($change->definition))->toBe('{"defaultVariant":"on","state":"ENABLED","variants":{"on":{}}}') + ->and($change->definition)->toEqual($store->get('k')?->definition) + ->and($store->history('k', 0))->toBe([])->and($store->history('k', -1))->toBe([]); +}); + +it('reads revision, rows and history from the writer rather than a lagging replica', function (): void { + $connection = FlagStores::sqlite(); + $connection->setReadPdo(new PDO('sqlite::memory:')); + $store = new DatabaseFlagStore($connection, FlagStores::clock()); + $store->put('k', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on'], 'ada'); + expect($store->get('k')?->version)->toBe(1)->and(array_keys($store->all()))->toBe(['k']) + ->and($store->revision())->toBe(1)->and($store->history('k'))->toHaveCount(1); +}); + +/** @param int|null $expected */ +it('bounds row-race retries to owned unconditional writes', function (bool $outer, ?int $expected, int $attempts): void { + $connection = FlagStores::sqlite(); + $writes = 0; + $connection->beforeExecuting(static function (string $query) use (&$writes): void { + if (str_starts_with($query, 'insert into "firefly_feature_flags"')) { + $writes++; + throw new UniqueConstraintViolationException('sqlite', $query, [], new PDOException('injected competing primary key')); + } + }); + $store = new DatabaseFlagStore($connection); + if ($outer) { + $connection->beginTransaction(); + } + expect(fn () => $store->put('race', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on'], null, $expected))->toThrow(FlagStoreConflict::class); + expect($writes)->toBe($attempts)->and($store->all())->toBe([])->and($store->revision())->toBe(0); + if ($outer) { + $connection->commit(); + } +})->with([[false, null, 3], [true, null, 1], [false, 0, 1]]); + +it('never retries an unrelated conflict exception from the clock', function (): void { + $calls = 0; + $store = new DatabaseFlagStore(FlagStores::sqlite(), static function () use (&$calls): DateTimeImmutable { + $calls++; + throw new FlagStoreConflict('other', 0, 1); + }); + expect(fn () => $store->put('race', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on'], null))->toThrow(FlagStoreConflict::class); + expect($calls)->toBe(1)->and($store->revision())->toBe(0); +}); + +it('round trips leading NUL members through relational rows and audit history', function (): void { + $connection = FlagStores::sqlite(); + $store = new DatabaseFlagStore($connection, FlagStores::clock()); + $definition = Json::members(Json::decode('{"state":"ENABLED","variants":{"v":{"\\u0000nested":{"0":{},"list":[],"float":1.0}}},"defaultVariant":"v","metadata":{"\\u0000owner":"ops"}}')); + $store->put('portable', $definition, 'ops'); + $canonical = Json::canonical($definition); + expect($connection->table(FeatureFlagSchema::FLAGS)->where('flag_key', 'portable')->value('definition'))->toBe($canonical) + ->and(Json::canonical($store->get('portable')?->definition))->toBe($canonical) + ->and(Json::canonical($store->history('portable')[0]->definition))->toBe($canonical); +}); diff --git a/packages/feature-flags/tests/Store/DatabaseTransactionsTest.php b/packages/feature-flags/tests/Store/DatabaseTransactionsTest.php new file mode 100644 index 00000000..bb5c664d --- /dev/null +++ b/packages/feature-flags/tests/Store/DatabaseTransactionsTest.php @@ -0,0 +1,175 @@ + 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on']; + if ($operation !== 'insert') { + $store->put('k', $definition, 'ada'); + } + $connection->statement("CREATE TRIGGER refuse_audit BEFORE INSERT ON firefly_feature_flag_changes BEGIN SELECT RAISE(ABORT, 'audit refused'); END"); + $connection->beginTransaction(); + try { + if ($operation === 'delete') { + $store->delete('k', 'eve'); + } else { + $store->put('k', $definition, 'eve'); + } + Assert::fail('Audit insert must fail'); + } catch (QueryException) { + expect($connection->transactionLevel())->toBe(1); + } + $connection->commit(); + expect($store->get('k')?->version)->toBe($operation === 'insert' ? null : 1) + ->and($store->get('k')?->updatedBy)->toBe($operation === 'insert' ? null : 'ada') + ->and($store->revision())->toBe($operation === 'insert' ? 0 : 1); +})->with(['insert', 'update', 'delete']); + +it('binds callbacks to actual commit on the store connection even with another transaction active', function (): void { + $first = FlagStores::sqlite(); + $second = FlagStores::sqlite(); + $dispatcher = new Dispatcher; + $manager = new DatabaseTransactionsManager; + foreach ([$first, $second] as $connection) { + // Deliberately identical connection names and one manager/dispatcher. + $connection->setEventDispatcher($dispatcher); + $connection->setTransactionManager($manager); + } + $store = new DatabaseFlagStore($first); + $calls = []; + $first->beginTransaction(); + $second->beginTransaction(); + expect($store->transactionActive())->toBeTrue(); + $store->afterCommit(function () use (&$calls): void { + $calls[] = 'first'; + }); + $second->commit(); + expect($calls)->toBe([]); + $first->commit(); + expect($calls)->toBe(['first'])->and($store->transactionActive())->toBeFalse(); + $second->beginTransaction(); + $store->afterCommit(function () use (&$calls): void { + $calls[] = 'immediate'; + }); + expect($calls)->toBe(['first', 'immediate'])->and($store->transactionActive())->toBeFalse(); + $second->rollBack(); +}); + +it('discards callbacks with their rolled back ancestor and keeps released siblings', function (bool $rootRollback): void { + $connection = FlagStores::sqlite(); + $store = new DatabaseFlagStore($connection); + $calls = []; + $connection->beginTransaction(); + $store->afterCommit(function () use (&$calls): void { + $calls[] = 'root'; + }); + $connection->beginTransaction(); + $connection->beginTransaction(); + $store->afterCommit(function () use (&$calls): void { + $calls[] = 'discarded-child'; + }); + $connection->commit(); + $connection->rollBack(); + $connection->beginTransaction(); + $store->afterCommit(function () use (&$calls): void { + $calls[] = 'released-sibling'; + }); + $connection->commit(); + $connection->beginTransaction(); + $store->afterCommit(function () use (&$calls): void { + $calls[] = 'discarded-sibling'; + }); + $connection->rollBack(); + expect($calls)->toBe([]); + if ($rootRollback) { + $connection->rollBack(); + } else { + $connection->commit(); + } + $connection->beginTransaction(); + $connection->commit(); + expect($calls)->toBe($rootRollback ? [] : ['root', 'released-sibling']); +})->with([false, true]); + +it('never resurrects a callback when an identical replacement reuses its rolled back audit id', function (): void { + $connection = FlagStores::sqlite(); + $store = new DatabaseFlagStore($connection, FlagStores::clock()); + $definition = ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on']; + $calls = []; + $connection->beginTransaction(); + $connection->beginTransaction(); + $old = $store->put('k', $definition, 'ada'); + $store->afterCommit(function () use (&$calls): void { + $calls[] = 'rolled-back'; + }); + $connection->rollBack(); + $new = $store->put('k', $definition, 'ada'); + $store->afterCommit(function () use (&$calls): void { + $calls[] = 'replacement'; + }); + expect($new->id)->toBe($old->id); + $connection->commit(); + expect($calls)->toBe(['replacement']) + ->and($connection->table(FeatureFlagSchema::CHANGES)->count())->toBe(1); +}); + +it('retains native abort across child release and rollback until a new root starts', function (): void { + $connection = FlagStores::sqlite(); + $callbacks = new StoreCommitCallbacks($connection); + $cause = new RuntimeException('native failure'); + $calls = []; + $connection->beginTransaction(); + $callbacks->afterCommit(static function () use (&$calls): void { + $calls[] = 'discarded'; + }); + $callbacks->nativeTransactionAborted($cause); + $connection->beginTransaction(); + $connection->commit(); + $connection->beginTransaction(); + $connection->rollBack(); + try { + $connection->commit(); + Assert::fail('An aborted transaction cannot commit.'); + } catch (FlagStoreTransactionAborted $error) { + expect($error->getPrevious())->toBe($cause); + } + expect($calls)->toBe([])->and($connection->transactionLevel())->toBe(0); + $callbacks->afterCommit(static function () use (&$calls): void { + $calls[] = 'still-aborted'; + }); + expect($calls)->toBe([]); + $connection->beginTransaction(); + $callbacks->afterCommit(static function () use (&$calls): void { + $calls[] = 'recovered'; + }); + $connection->commit(); + expect($calls)->toBe(['recovered']); +}); + +it('does not replay a callback transaction when its native-abort guard rejects commit', function (): void { + $connection = FlagStores::sqlite(); + $callbacks = new StoreCommitCallbacks($connection); + $runs = 0; + expect(static function () use ($connection, $callbacks, &$runs): void { + $connection->transaction(static function () use ($callbacks, &$runs): void { + $runs++; + $callbacks->nativeTransactionAborted(new RuntimeException('native failure')); + }, 3); + })->toThrow(FlagStoreTransactionAborted::class); + expect($runs)->toBe(1)->and($connection->transactionLevel())->toBe(0); + $connection->rollBack(0); + $connection->transaction(static function (): void {}); + expect($connection->transactionLevel())->toBe(0); +}); diff --git a/packages/feature-flags/tests/Store/FlagStoreContractTest.php b/packages/feature-flags/tests/Store/FlagStoreContractTest.php new file mode 100644 index 00000000..39ec75c2 --- /dev/null +++ b/packages/feature-flags/tests/Store/FlagStoreContractTest.php @@ -0,0 +1,80 @@ + */ +function featureFlagsStoredDefinition(string $default = 'off'): array +{ + return Json::members(Json::decode(Json::canonical(Json::decode('{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"'.$default.'"}')))); +} + +it('inserts, versions, audits and deletes atomically', function (Closure $driver): void { + /** @var FlagStore $store */ + $store = $driver(); + expect($store->all())->toBe([])->and($store->revision())->toBe(0); + $first = $store->put('k', featureFlagsStoredDefinition(), 'ada', 0); + $second = $store->put('k', featureFlagsStoredDefinition('on'), 'bob', 1); + expect([$first->id, $first->action, $first->previous])->toBe([1, FlagChange::PUT, null]) + ->and($second->previous)->toBe(featureFlagsStoredDefinition()) + ->and($store->get('k')?->version)->toBe(2) + ->and($store->revision())->toBe(2); + $deleted = $store->delete('k', 'bob', 2); + expect($deleted?->previous)->toBe(featureFlagsStoredDefinition('on')) + ->and($store->get('k'))->toBeNull() + ->and($store->delete('k', 'bob'))->toBeNull() + ->and($store->revision())->toBe(3) + ->and(array_map(static fn (FlagChange $change): int => $change->id, $store->history('k', 2)))->toBe([3, 2]); +})->with(FlagStores::drivers()); + +it('rejects stale versions without changing rows or audit', function (Closure $driver): void { + /** @var FlagStore $store */ + $store = $driver(); + $store->put('k', featureFlagsStoredDefinition(), 'ada'); + expect(fn () => $store->put('k', featureFlagsStoredDefinition('on'), 'eve', 0))->toThrow(FlagStoreConflict::class) + ->and(fn () => $store->delete('missing', 'eve', 1))->toThrow(FlagStoreConflict::class, 'version none') + ->and(fn () => $store->put('k', featureFlagsStoredDefinition('on'), 'eve', -1))->toThrow(FlagStoreConflict::class) + ->and($store->get('k')?->version)->toBe(1) + ->and($store->revision())->toBe(1); +})->with(FlagStores::drivers()); + +it('leaves memory untouched when its clock throws during put or delete', function (): void { + $clockState = new class + { + public int $calls = 0; + }; + $store = new MemoryFlagStore(static function () use ($clockState): DateTimeImmutable { + if (++$clockState->calls > 1) { + throw new RuntimeException('clock failed'); + } + + return new DateTimeImmutable('2026-10-01 12:00:00 UTC'); + }); + $store->put('k', featureFlagsStoredDefinition(), 'ada'); + expect(fn () => $store->put('k', featureFlagsStoredDefinition('on'), 'bob'))->toThrow(RuntimeException::class, 'clock failed') + ->and(fn () => $store->delete('k', 'bob'))->toThrow(RuntimeException::class, 'clock failed') + ->and($store->get('k')?->version)->toBe(1) + ->and($store->revision())->toBe(1); +}); + +it('returns detached definitions and UTC history', function (): void { + $store = new MemoryFlagStore(static fn (): DateTimeImmutable => new DateTimeImmutable('2026-10-01 05:00:00-07:00')); + $definition = featureFlagsStoredDefinition(); + $definition['targeting'] = (object) ['nested' => (object) []]; + $store->put('k', $definition, 'ada'); + $definition['targeting']->nested->changed = true; + $stored = $store->get('k'); + $returnedTargeting = Json::members($stored?->definition['targeting'] ?? null); + $nested = $returnedTargeting['nested'] ?? null; + if ($nested instanceof stdClass) { + $nested->changed = true; + } + expect(Json::encode($store->get('k')?->definition['targeting']))->toBe('{"nested":{}}') + ->and($store->history('k')[0]->toHistoryRow()['changedAt'])->toBe('2026-10-01T12:00:00Z'); +}); diff --git a/packages/feature-flags/tests/Store/FlagStoreWriterTest.php b/packages/feature-flags/tests/Store/FlagStoreWriterTest.php new file mode 100644 index 00000000..959ab56f --- /dev/null +++ b/packages/feature-flags/tests/Store/FlagStoreWriterTest.php @@ -0,0 +1,357 @@ +failLoad !== null && ($this->failLoad)()) { + throw new RuntimeException('store unavailable'); + } + $revision = (string) $this->store->revision(); + if ($revision === $knownRevision) { + return null; + } + $flags = []; + foreach ($this->store->all() as $stored) { + $flags[$stored->key] = $stored->definition; + } + + return new SourceSnapshot(FlagDefinitions::parseDocument(['flags' => Json::object($flags)]), $revision); + } + }; + $events = new RecordingApplicationEventPublisher; + $registry = new FlagRegistry([$source], new CacheBook(new Repository(new ArrayStore), new RecordingLogger), $events); + $registry->document(); + + return [new FlagStoreWriter($store, $registry, $events), $registry, $events]; +} + +/** + * @return list, expect: array{committed: bool, updatedAttempts: int, visible: string}}> + */ +function featureFlagsObserverVectors(): array +{ + $raw = file_get_contents(__DIR__.'/../Conformance/observer-vectors.json'); + if ($raw === false) { + throw new RuntimeException('Observer vectors cannot be read.'); + } + $data = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + if (! is_array($data) || ! isset($data['cases']) || ! is_array($data['cases'])) { + throw new RuntimeException('Observer vectors must contain cases.'); + } + $cases = []; + foreach ($data['cases'] as $case) { + if (! is_array($case)) { + throw new RuntimeException('Observer case must be an object.'); + } + $name = $case['name'] ?? null; + $action = $case['action'] ?? null; + $faults = $case['faults'] ?? null; + $expected = $case['expect'] ?? null; + if (! is_string($name) || ! is_string($action) || ! is_array($faults) || ! is_array($expected) + || ! is_bool($expected['committed'] ?? null) || ! is_int($expected['updatedAttempts'] ?? null) + || ! is_string($expected['visible'] ?? null)) { + throw new RuntimeException('Observer case has an invalid shape.'); + } + $faultNames = []; + foreach ($faults as $fault) { + if (! is_string($fault)) { + throw new RuntimeException('Observer fault must be a string.'); + } + $faultNames[] = $fault; + } + $cases[] = ['name' => $name, 'action' => $action, 'faults' => $faultNames, 'expect' => [ + 'committed' => $expected['committed'], + 'updatedAttempts' => $expected['updatedAttempts'], + 'visible' => $expected['visible'], + ]]; + } + + return $cases; +} + +it('validates, persists canonical JSON, refreshes and publishes after a memory commit', function (): void { + [$writer, $registry, $events] = featureFlagsWriterFixture(); + $definition = ['state' => 'ENABLED', 'variants' => ['on' => (object) [], 'off' => (object) []], 'defaultVariant' => 'on']; + $writer->put('0', $definition, 'ops'); + $definition['variants']['on']->changed = true; + /** @var FeatureFlagUpdated $event */ + $event = $events->ofType(FeatureFlagUpdated::class)[0]; + $storedVariants = Json::members($writer->store()->get('0')?->definition['variants'] ?? null); + $eventVariants = Json::members($event->current['variants'] ?? null); + expect($registry->document()->keys())->toBe(['0']) + ->and(Json::encode($storedVariants['on'] ?? null))->toBe('{}') + ->and([$event->key, $event->action, $event->actor])->toBe(['0', 'put', 'ops']) + ->and(Json::encode($eventVariants['on'] ?? null))->toBe('{}'); +}); + +it('returns a committed write when refresh fails and still emits its update', function (): void { + $failure = new class + { + public bool $enabled = false; + }; + [$writer, $registry, $events] = featureFlagsWriterFixture(failLoad: static fn (): bool => $failure->enabled); + $failure->enabled = true; + $change = $writer->put('k', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on'], 'ops'); + expect($change->id)->toBe(1) + ->and($writer->store()->revision())->toBe(1) + ->and($registry->document()->flag('k'))->toBeNull() + ->and($events->ofType(FeatureFlagUpdated::class))->toHaveCount(1); +}); + +it('keeps a committed change when post-commit observers fail, for each shared vector', function (): void { + foreach (featureFlagsObserverVectors() as $case) { + $faults = $case['faults']; + $store = new MemoryFlagStore(FlagStores::clock()); + $definition = ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'on']; + if ($case['action'] === 'delete') { + $store->put('k', $definition, 'seed'); + } + $source = new class($store, $faults) implements FlagSource + { + public bool $fail = false; + + /** @param list $faults */ + public function __construct(private readonly FlagStore $store, private readonly array $faults) {} + + public function name(): string + { + return self::STORE; + } + + public function precedence(): int + { + return 400; + } + + public function refreshInterval(): float + { + return 3600.0; + } + + public function failsStartup(): bool + { + return false; + } + + public function reportedRevision(?string $revision): ?string + { + return $revision; + } + + public function load(?string $knownRevision): SourceSnapshot + { + if ($this->fail && (in_array('source_error', $this->faults, true) || in_array('refresh_refused', $this->faults, true) || in_array('logger', $this->faults, true) || in_array('refresh_throw', $this->faults, true))) { + throw new RuntimeException('source refused'); + } + $flags = []; + foreach ($this->store->all() as $stored) { + $flags[$stored->key] = $stored->definition; + } + + return new SourceSnapshot(FlagDefinitions::parseDocument(['flags' => Json::object($flags)]), (string) $this->store->revision()); + } + }; + $attempts = []; + $record = static function (string $type) use (&$attempts): void { + $attempts[] = $type; + }; + $events = new class($record, $faults) implements ApplicationEventPublisher + { + /** @param list $faults */ + public function __construct(private readonly Closure $record, private readonly array $faults) {} + + public function publish(object $event): void + { + ($this->record)($event::class); + if (($event instanceof FeatureFlagsChanged && in_array('change_listener', $this->faults, true)) || ($event instanceof FeatureFlagUpdated && in_array('update_listener', $this->faults, true))) { + throw new RuntimeException('listener failed'); + } + } + }; + $warnings = new RecordingLogger; + $throwingLogger = new class extends AbstractLogger + { + public function log($level, string|Stringable $message, array $context = []): void + { + throw new RuntimeException('logger failed'); + } + }; + $registry = new FlagRegistry([$source], new CacheBook(new Repository(new ArrayStore), $warnings), $events, in_array('refresh_throw', $faults, true) ? $throwingLogger : $warnings); + $registry->document(); + $source->fail = true; + $writer = new FlagStoreWriter($store, $registry, $events, in_array('logger', $faults, true) ? $throwingLogger : $warnings); + $change = $case['action'] === 'put' ? $writer->put('k', $definition, 'ops') : $writer->delete('k', 'ops'); + + $previousVisible = $case['expect']['visible'] === 'previous'; + expect($change !== null)->toBe($case['expect']['committed'], $case['name']) + ->and($change?->action)->toBe($case['action'], $case['name']) + ->and($store->revision())->toBe($case['action'] === 'put' ? 1 : 2, $case['name']) + ->and($registry->document()->flag('k')?->defaultVariant())->toBe($previousVisible ? ($case['action'] === 'delete' ? 'on' : null) : ($case['action'] === 'put' ? 'on' : null), $case['name']) + ->and(count(array_filter($attempts, static fn (string $type): bool => $type === FeatureFlagUpdated::class)))->toBe($case['expect']['updatedAttempts'], $case['name']); + } +}); + +it('defers refresh and event through a transaction-aware store callback', function (): void { + $store = new class implements CommitAwareFlagStore, FlagStore + { + private MemoryFlagStore $inner; + + /** @var list */ + private array $callbacks = []; + + public function __construct() + { + $this->inner = new MemoryFlagStore(FlagStores::clock()); + } + + public function all(): array + { + return $this->inner->all(); + } + + public function get(string $key): ?StoredFlag + { + return $this->inner->get($key); + } + + public function revision(): int + { + return $this->inner->revision(); + } + + public function put(string $key, array $definition, ?string $actor, ?int $expectedVersion = null): FlagChange + { + return $this->inner->put($key, $definition, $actor, $expectedVersion); + } + + public function delete(string $key, ?string $actor, ?int $expectedVersion = null): ?FlagChange + { + return $this->inner->delete($key, $actor, $expectedVersion); + } + + public function history(string $key, int $limit = 50): array + { + return $this->inner->history($key, $limit); + } + + public function afterCommit(Closure $callback): void + { + $this->callbacks[] = $callback; + } + + public function commit(): void + { + foreach ($this->callbacks as $callback) { + $callback(); + } + $this->callbacks = []; + } + }; + $failure = new class + { + public bool $enabled = false; + }; + [$writer, $registry, $events] = featureFlagsWriterFixture($store, static fn (): bool => $failure->enabled); + $writer->put('k', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on'], 'ops'); + expect($registry->document()->flag('k'))->toBeNull() + ->and($events->ofType(FeatureFlagUpdated::class))->toBe([]); + $store->commit(); + expect($registry->document()->flag('k')?->defaultVariant())->toBe('on') + ->and($events->ofType(FeatureFlagUpdated::class))->toHaveCount(1); + $writer->put('k', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on'], 'ops'); + $failure->enabled = true; + $store->commit(); + expect($events->ofType(FeatureFlagUpdated::class))->toHaveCount(2) + ->and($store->revision())->toBe(2); +}); + +it('rejects invalid definitions before writing and only publishes actual deletes', function (): void { + [$writer, $registry, $events] = featureFlagsWriterFixture(); + expect(fn () => $writer->put('k', ['state' => 'ON'], 'ops'))->toThrow(InvalidFlagDefinition::class, 'state must be ENABLED or DISABLED') + ->and($writer->store()->revision())->toBe(0); + $writer->put('k', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on'], 'ops'); + $writer->delete('k', 'ops'); + expect($writer->delete('k', 'ops'))->toBeNull() + ->and($registry->document()->flag('k'))->toBeNull() + ->and(array_map(static fn (object $event): string => $event instanceof FeatureFlagUpdated ? $event->action : '', $events->ofType(FeatureFlagUpdated::class)))->toBe(['put', 'delete']); +}); + +it('refreshes and publishes SQL writes only at their root commit', function (bool $rollback, string $action): void { + $connection = FlagStores::sqlite(); + $store = new DatabaseFlagStore($connection, FlagStores::clock()); + $definition = ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on']; + if ($action === 'delete') { + $store->put('k', $definition, 'ada'); + } + [$writer, $registry, $events] = featureFlagsWriterFixture($store); + $connection->beginTransaction(); + $change = $action === 'put' ? $writer->put('k', $definition, 'ada') : $writer->delete('k', 'ada'); + expect($change?->action)->toBe($action) + ->and($registry->document()->flag('k')?->defaultVariant())->toBe($action === 'delete' ? 'on' : null) + ->and($events->ofType(FeatureFlagUpdated::class))->toBe([]); + if ($rollback) { + $connection->rollBack(); + } else { + $connection->commit(); + } + expect($events->ofType(FeatureFlagUpdated::class))->toHaveCount($rollback ? 0 : 1) + ->and($registry->document()->flag('k')?->defaultVariant())->toBe(($action === 'put') !== $rollback ? 'on' : null); +})->with([false, true])->with(['put', 'delete']); diff --git a/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreAbsentRaceTest.php b/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreAbsentRaceTest.php new file mode 100644 index 00000000..f7486b6f --- /dev/null +++ b/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreAbsentRaceTest.php @@ -0,0 +1,121 @@ + ['pgsql', 'FIREFLY_PG_DSN'], 'Mysql' => ['mysql', 'FIREFLY_MYSQL_DSN'], 'MariaDB' => ['mysql', 'FIREFLY_MARIADB_DSN']] as $backend => [$driver, $variable]) { + foreach (['root', 'outer', 'outer-snapshot'] as $ownership) { + foreach ([['0', 0], ['null', 0], ['null', 1]] as [$expected, $seed]) { + it("resolves simultaneous writes from version {$seed} atomically with expected {$expected} in {$ownership} transactions on {$backend}", function (string $commitMode) use ($driver, $variable, $ownership, $expected, $backend, $seed): void { + $connection = DatabaseStoreIntegration::connection($driver, $variable); + FeatureFlagSchema::drop($connection->getSchemaBuilder()); + FeatureFlagSchema::create($connection->getSchemaBuilder()); + $connection->getSchemaBuilder()->create('caller_markers', static function (Blueprint $table): void { + $table->string('actor')->primary(); + }); + if ($seed === 1) { + (new DatabaseFlagStore($connection))->put('absent-race', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on'], 'initial', 0); + } + $connection->getSchemaBuilder()->create('recovery_markers', static function (Blueprint $table): void { + $table->string('actor')->primary(); + }); + $snapshot = $backend === 'MariaDB' ? $connection->selectOne('SELECT @@innodb_snapshot_isolation AS enabled') : null; + $nativeAbort = (is_object($snapshot) && (bool) (get_object_vars($snapshot)['enabled'] ?? false)) && $ownership !== 'root' && ($expected === 'null' || $ownership === 'outer-snapshot'); + $inputs = [new InputStream, new InputStream]; + $processes = []; + foreach (['first', 'second'] as $index => $actor) { + $process = new Process([PHP_BINARY, dirname(__DIR__, 2).'/Support/database-store-absent-racer.php', $driver, $variable, $actor, $ownership, $expected, $nativeAbort ? 'native-abort' : 'conflict', $commitMode], timeout: 20); + $process->setInput($inputs[$index]); + $processes[] = $process; + } + try { + foreach ($processes as $process) { + $process->start(); + } + // Neither mutation is released until both transactions reach the post-read / direct-insert boundary. + foreach ($processes as $process) { + while (! str_contains($process->getOutput(), "inserting\n") && $process->isRunning()) { + $process->checkTimeout(); + } + expect($process->getOutput())->toContain("inserting\n"); + } + foreach ($inputs as $input) { + $input->write("insert\n"); + $input->close(); + } + do { + $running = false; + foreach ($processes as $process) { + $running = $process->isRunning() || $running; + $process->checkTimeout(); + } + } while ($running); + $outcomes = []; + foreach ($processes as $process) { + expect($process->wait())->toBe(0, $process->getErrorOutput()); + $outcomes[] = trim(str_replace("inserting\n", '', $process->getOutput())); + } + sort($outcomes); + $writes = $expected === 'null' && $ownership === 'root' ? 2 : 1; + $loser = $nativeAbort ? 'native-abort' : 'conflict'; + expect($connection->table('caller_markers')->count())->toBe($ownership === 'root' ? 0 : ($nativeAbort ? 1 : 2)); + expect($connection->table('recovery_markers')->count())->toBe($nativeAbort ? 2 : 0); + $store = new DatabaseFlagStore($connection); + expect($outcomes)->toBe($writes === 2 ? ['success', 'success'] : [$loser, 'success']) + ->and($connection->table(FeatureFlagSchema::FLAGS)->count())->toBe(1) + ->and($connection->table(FeatureFlagSchema::CHANGES)->count())->toBe($writes + $seed) + ->and($store->get('absent-race')?->version)->toBe($writes + $seed) + ->and($store->history('absent-race')[0]->actor)->toBe($store->get('absent-race')?->updatedBy) + ->and($store->history('absent-race')[$writes + $seed - 1]->previous)->toBeNull(); + if ($seed === 0) { + $vector = featureFlagsConcurrencyVector(strtolower($backend === 'Postgres' ? 'postgresql' : $backend), $ownership, $expected === 'null' ? null : 0); + $outcome = $writes === 2 ? 'two-writes' : ($nativeAbort ? 'one-parent-abort' : 'one-conflict'); + expect([ + 'outcome' => $outcome, + 'version' => $store->get('absent-race')?->version, + 'auditRows' => $connection->table(FeatureFlagSchema::CHANGES)->count(), + 'callerMarkers' => $connection->table('caller_markers')->count(), + ])->toBe($vector['expect']); + $isolation = $connection->selectOne($driver === 'pgsql' ? 'SHOW transaction_isolation' : ($backend === 'MariaDB' ? 'SELECT @@tx_isolation AS transaction_isolation' : 'SELECT @@transaction_isolation AS transaction_isolation')); + $level = is_object($isolation) ? (get_object_vars($isolation)['transaction_isolation'] ?? null) : null; + expect(is_string($level) ? strtoupper(str_replace('-', ' ', $level)) : null)->toBe($vector['isolation']) + ->and(is_object($snapshot) && (bool) (get_object_vars($snapshot)['enabled'] ?? false))->toBe($vector['snapshotIsolation']); + } + if ($writes + $seed > 1) { + expect($store->history('absent-race')[0]->previous)->toBe($store->history('absent-race')[1]->definition); + } + } finally { + foreach ($processes as $process) { + $process->stop(); + } + $connection->getSchemaBuilder()->drop('caller_markers'); + $connection->getSchemaBuilder()->drop('recovery_markers'); + FeatureFlagSchema::drop($connection->getSchemaBuilder()); + } + })->with($ownership === 'root' ? ['direct'] : ['direct', 'callback'])->group('integration')->skip(getenv($variable) === false, "Set {$variable} to an isolated server."); + } + } +} + +/** @return array{isolation: string, snapshotIsolation: bool, expect: array{outcome: string, version: int, auditRows: int, callerMarkers: int}} */ +function featureFlagsConcurrencyVector(string $backend, string $ownership, ?int $expected): array +{ + $raw = file_get_contents(__DIR__.'/../../Conformance/store-concurrency-vectors.json'); + if ($raw === false) { + throw new RuntimeException('Concurrency vectors cannot be read.'); + } + /** @var array{cases: list} $data */ + $data = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + foreach ($data['cases'] as $case) { + if ($case['backend'] === $backend && $case['transaction'] === $ownership && $case['expectedVersion'] === $expected) { + return $case; + } + } + throw new RuntimeException('Missing shared concurrency case.'); +} diff --git a/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreIntegrationTest.php b/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreIntegrationTest.php new file mode 100644 index 00000000..6294eac6 --- /dev/null +++ b/packages/feature-flags/tests/Store/Integration/DatabaseFlagStoreIntegrationTest.php @@ -0,0 +1,111 @@ + ['pgsql', 'FIREFLY_PG_DSN'], 'Mysql' => ['mysql', 'FIREFLY_MYSQL_DSN'], 'MariaDB' => ['mysql', 'FIREFLY_MARIADB_DSN']] as $backend => [$driver, $variable]) { + it("preserves exact keys and large canonical payloads on {$backend}", function () use ($driver, $variable): void { + $connection = DatabaseStoreIntegration::connection($driver, $variable); + FeatureFlagSchema::drop($connection->getSchemaBuilder()); + FeatureFlagSchema::create($connection->getSchemaBuilder()); + FeatureFlagSchema::create($connection->getSchemaBuilder()); + $store = new DatabaseFlagStore($connection, FlagStores::clock()); + $definition = ['state' => 'ENABLED', 'variants' => ['on' => str_repeat('é', 40000)], 'defaultVariant' => 'on']; + $store->put('A', $definition, 'ada', 0); + $store->put('a', $definition, 'bob', 0); + expect(array_keys($store->all()))->toBe(['A', 'a']) + ->and($store->get('a')?->updatedBy)->toBe('bob') + ->and($store->history('A')[0]->actor)->toBe('ada') + ->and($store->history('A')[0]->definition)->toBe($store->get('A')?->definition) + ->and($connection->table(FeatureFlagSchema::FLAGS)->where('flag_key', 'A')->value('definition'))->toBe(Json::canonical($definition)); + expect(fn () => $store->put('A', $definition, 'eve', 0))->toThrow(FlagStoreConflict::class); + expect($store->get('A')?->version)->toBe(1)->and(count($store->history('A')))->toBe(1); + $store->delete('A', 'ada', 1); + expect($store->get('A'))->toBeNull()->and($store->get('a')?->version)->toBe(1) + ->and($store->history('A')[0]->previous)->toBe($store->get('a')?->definition); + FeatureFlagSchema::drop($connection->getSchemaBuilder()); + })->group('integration')->skip(getenv($variable) === false, "Set {$variable} to an isolated server."); + + it("rolls back failed audit writes inside an outer transaction on {$backend}", function () use ($driver, $variable): void { + $connection = DatabaseStoreIntegration::connection($driver, $variable); + FeatureFlagSchema::drop($connection->getSchemaBuilder()); + FeatureFlagSchema::create($connection->getSchemaBuilder()); + $store = new DatabaseFlagStore($connection, FlagStores::clock()); + $definition = ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on']; + $store->put('keep', $definition, 'ada'); + $connection->statement("ALTER TABLE firefly_feature_flag_changes ADD CONSTRAINT reject_eve CHECK (actor <> 'eve')"); + $connection->beginTransaction(); + foreach (['insert', 'update', 'delete'] as $operation) { + expect(fn () => $operation === 'delete' ? $store->delete('keep', 'eve') : $store->put($operation === 'insert' ? 'new' : 'keep', $definition, 'eve'))->toThrow(QueryException::class); + expect($connection->transactionLevel())->toBe(1); + } + $connection->commit(); + expect($store->get('new'))->toBeNull()->and($store->get('keep')?->version)->toBe(1) + ->and(count($store->history('keep')))->toBe(1); + FeatureFlagSchema::drop($connection->getSchemaBuilder()); + })->group('integration')->skip(getenv($variable) === false, "Set {$variable} to an isolated server."); + it("serializes competing process updates and emits only surviving callbacks on {$backend}", function () use ($driver, $variable): void { + $connection = DatabaseStoreIntegration::connection($driver, $variable); + FeatureFlagSchema::drop($connection->getSchemaBuilder()); + FeatureFlagSchema::create($connection->getSchemaBuilder()); + $store = new DatabaseFlagStore($connection, FlagStores::clock()); + $definition = ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on']; + $store->put('race', $definition, 'initial'); + $connection->beginTransaction(); + $store->put('race', $definition, 'parent', 1); + $calls = []; + $store->afterCommit(function () use (&$calls): void { + $calls[] = 'parent'; + }); + $connection->beginTransaction(); + $connection->beginTransaction(); + $store->put('discarded', $definition, 'parent'); + $store->afterCommit(function () use (&$calls): void { + $calls[] = 'discarded'; + }); + $connection->commit(); + $connection->rollBack(); + $process = new Process([PHP_BINARY, dirname(__DIR__, 2).'/Support/database-store-racer.php', $driver, $variable], timeout: 15); + try { + $process->start(); + expect($process->waitUntil(static fn (string $type, string $output): bool => str_contains($output, 'reading')))->toBeTrue() + ->and($calls)->toBe([]); + $connection->commit(); + expect($process->wait())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toContain('conflict') + ->and($store->get('race')?->version)->toBe(2) + ->and($store->get('race')?->updatedBy)->toBe('parent') + ->and(count($store->history('race')))->toBe(2) + ->and($store->get('discarded'))->toBeNull() + ->and($calls)->toBe(['parent']); + } finally { + $connection->rollBack(0); + $process->stop(); + FeatureFlagSchema::drop($connection->getSchemaBuilder()); + } + })->group('integration')->skip(getenv($variable) === false, "Set {$variable} to an isolated server."); + + it("reads PyFly microsecond UTC rows independently of the SQL session zone on {$backend}", function () use ($driver, $variable): void { + $connection = DatabaseStoreIntegration::connection($driver, $variable); + FeatureFlagSchema::drop($connection->getSchemaBuilder()); + FeatureFlagSchema::create($connection->getSchemaBuilder()); + $connection->statement($driver === 'pgsql' ? "SET TIME ZONE '-07:00'" : "SET time_zone = '-07:00'"); + $definition = '{"state": "ENABLED", "variants": {"on": {}}, "defaultVariant": "on"}'; + $connection->table(FeatureFlagSchema::FLAGS)->insert(['flag_key' => 'pyfly', 'definition' => $definition, 'version' => 3, 'updated_at' => '2026-10-01 12:00:00.123456', 'updated_by' => null]); + $connection->table(FeatureFlagSchema::CHANGES)->insert(['flag_key' => 'pyfly', 'action' => 'put', 'definition' => $definition, 'previous' => null, 'actor' => null, 'changed_at' => '2026-10-01 12:00:00.123456']); + $connection->statement($driver === 'pgsql' ? "SET TIME ZONE '+02:00'" : "SET time_zone = '+02:00'"); + $store = new DatabaseFlagStore($connection); + expect($store->get('pyfly')?->updatedAt->format('Y-m-d H:i:s.uP'))->toBe('2026-10-01 12:00:00.123456+00:00') + ->and($store->history('pyfly')[0]->changedAt->format('Y-m-d H:i:s.uP'))->toBe('2026-10-01 12:00:00.123456+00:00'); + FeatureFlagSchema::drop($connection->getSchemaBuilder()); + })->group('integration')->skip(getenv($variable) === false, "Set {$variable} to an isolated server."); + +} diff --git a/packages/feature-flags/tests/Store/MigrationTest.php b/packages/feature-flags/tests/Store/MigrationTest.php new file mode 100644 index 00000000..62dc8839 --- /dev/null +++ b/packages/feature-flags/tests/Store/MigrationTest.php @@ -0,0 +1,47 @@ + 'testing', 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); + $migration = require dirname(__DIR__, 2).'/database/migrations/2026_10_01_000000_create_firefly_feature_flags_tables.php'; + + if (! $migration instanceof Migration || ! method_exists($migration, 'up') || ! method_exists($migration, 'down')) { + throw new RuntimeException('Expected an executable migration.'); + } + $migration->up(); + $created = [Schema::hasTable(FeatureFlagSchema::FLAGS), Schema::hasTable(FeatureFlagSchema::CHANGES)]; + $migration->down(); + + expect($created)->toBe([true, true]) + ->and(Schema::hasTable(FeatureFlagSchema::FLAGS))->toBeFalse() + ->and($migration->getConnection())->toBeNull(); +}); + +it('migrates only the explicitly configured store connection', function (): void { + config([ + 'database.default' => 'testing', + 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'database.connections.flags' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'firefly.feature-flags.sources.store.connection' => 'flags', + ]); + $migration = require dirname(__DIR__, 2).'/database/migrations/2026_10_01_000000_create_firefly_feature_flags_tables.php'; + if (! $migration instanceof Migration || ! method_exists($migration, 'up') || ! method_exists($migration, 'down')) { + throw new RuntimeException('Expected an executable migration.'); + } + $migration->up(); + $migration->up(); + expect($migration->getConnection())->toBe('flags') + ->and(Schema::connection('flags')->hasTable(FeatureFlagSchema::FLAGS))->toBeTrue() + ->and(Schema::connection('testing')->hasTable(FeatureFlagSchema::FLAGS))->toBeFalse(); + $migration->down(); + expect(Schema::connection('flags')->hasTable(FeatureFlagSchema::FLAGS))->toBeFalse() + ->and(Schema::connection('flags')->hasTable(FeatureFlagSchema::CHANGES))->toBeFalse(); +}); diff --git a/packages/feature-flags/tests/Store/StoreWiringTest.php b/packages/feature-flags/tests/Store/StoreWiringTest.php new file mode 100644 index 00000000..620b99ed --- /dev/null +++ b/packages/feature-flags/tests/Store/StoreWiringTest.php @@ -0,0 +1,41 @@ + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider)); + +it('wires the memory store as the highest source and the writer over it', function (): void { + $context = bootFireflyApp(['firefly' => ['feature-flags' => [ + 'enabled' => true, + 'flags' => ['kill-switch' => true], + 'sources' => ['store' => ['enabled' => true, 'driver' => 'memory']], + ]]], [FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], needs: ['cache']); + + /** @var FlagStoreWriter $writer */ + $writer = $context->get(FlagStoreWriter::class); + $writer->put('kill-switch', ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off'], 'ops'); + /** @var FlagRegistry $registry */ + $registry = $context->get(FlagRegistry::class); + + expect($context->get(FlagStore::class))->toBeInstanceOf(MemoryFlagStore::class) + ->and(array_map(static fn ($state): string => $state->name, $registry->states()))->toBe(['config', 'store']) + ->and($registry->composition()->flag('kill-switch')?->origin)->toBe('store') + ->and($registry->composition()->flag('kill-switch')?->overrides)->toBe(['config']); + $context->close(); +}); + +it('wires no store while sources.store.enabled is off', function (): void { + $app = fireflyApplication(['firefly' => ['feature-flags' => ['enabled' => true]]], [FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], needs: ['cache']); + + expect($app->bound(FlagStore::class))->toBeFalse() + ->and($app->bound(FlagStoreWriter::class))->toBeFalse(); +}); diff --git a/packages/feature-flags/tests/Support/ConformanceFiles.php b/packages/feature-flags/tests/Support/ConformanceFiles.php new file mode 100644 index 00000000..e68914e1 --- /dev/null +++ b/packages/feature-flags/tests/Support/ConformanceFiles.php @@ -0,0 +1,51 @@ + + */ + public static function files(): array + { + $root = self::root(); + $files = []; + /** @var iterable $walk */ + $walk = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS)); + foreach ($walk as $file) { + $relative = substr($file->getPathname(), strlen($root) + 1); + if ($file->isFile() && $relative !== 'MANIFEST.sha256') { + $files[] = str_replace(DIRECTORY_SEPARATOR, '/', $relative); + } + } + sort($files, SORT_STRING); + + return $files; + } + + public static function gherkinDirectory(): string + { + return self::root().'/testbed/evaluator/gherkin'; + } + + public static function testkitDocument(): FlagDocument + { + return FlagDocument::fromJson((string) file_get_contents(self::root().'/testbed/evaluator/flags/testkit-flags.json')); + } +} diff --git a/packages/feature-flags/tests/Support/CustomPathSyncServerTestCase.php b/packages/feature-flags/tests/Support/CustomPathSyncServerTestCase.php new file mode 100644 index 00000000..af3ce6fc --- /dev/null +++ b/packages/feature-flags/tests/Support/CustomPathSyncServerTestCase.php @@ -0,0 +1,13 @@ + '/internal/flags.json']; + } +} diff --git a/packages/feature-flags/tests/Support/DatabaseStoreIntegration.php b/packages/feature-flags/tests/Support/DatabaseStoreIntegration.php new file mode 100644 index 00000000..7e0131dc --- /dev/null +++ b/packages/feature-flags/tests/Support/DatabaseStoreIntegration.php @@ -0,0 +1,33 @@ +addConnection([ + 'driver' => $driver, 'host' => $parts['host'], 'port' => (int) $parts['port'], + 'database' => $parts['dbname'], 'username' => $parts['user'], 'password' => $parts['password'], + 'charset' => $driver === 'pgsql' ? 'utf8' : 'utf8mb4', 'prefix' => '', 'schema' => 'public', + ]); + + return $capsule->getConnection(); + } +} diff --git a/packages/feature-flags/tests/Support/DisabledSyncServerTestCase.php b/packages/feature-flags/tests/Support/DisabledSyncServerTestCase.php new file mode 100644 index 00000000..a0002276 --- /dev/null +++ b/packages/feature-flags/tests/Support/DisabledSyncServerTestCase.php @@ -0,0 +1,13 @@ + false]; + } +} diff --git a/packages/feature-flags/tests/Support/ExternalSyncServerTestCase.php b/packages/feature-flags/tests/Support/ExternalSyncServerTestCase.php new file mode 100644 index 00000000..4dda2ddd --- /dev/null +++ b/packages/feature-flags/tests/Support/ExternalSyncServerTestCase.php @@ -0,0 +1,16 @@ + ['Firefly\\FeatureFlags\\Tests\\Fixtures\\External\\' => dirname(__DIR__).'/Fixtures/External'], + ]; + } +} diff --git a/packages/feature-flags/tests/Support/FireflyVectors.php b/packages/feature-flags/tests/Support/FireflyVectors.php new file mode 100644 index 00000000..6b83eb86 --- /dev/null +++ b/packages/feature-flags/tests/Support/FireflyVectors.php @@ -0,0 +1,54 @@ +|null */ + private static ?array $document = null; + + /** @return array */ + public static function document(): array + { + return self::$document ??= Json::members(Json::decode((string) file_get_contents(ConformanceFiles::root().'/firefly-vectors.json'))); + } + + public static function today(): string + { + $today = self::document()['today'] ?? null; + + return is_string($today) ? $today : ''; + } + + /** + * Pest dataset: case name => [case]. Two cases of one kind sharing a name would make one overwrite the other, + * so a duplicate name fails loudly instead of dropping a case. + * + * @return array}> + */ + public static function cases(string $kind): array + { + $cases = []; + foreach ((array) (self::document()['cases'] ?? []) as $case) { + $members = Json::members($case); + if (($members['kind'] ?? null) === $kind) { + $name = $members['name'] ?? null; + $name = is_string($name) ? $name : (string) count($cases); + if (array_key_exists($name, $cases)) { + throw new LogicException(sprintf('firefly-vectors.json has two [%s] cases named [%s].', $kind, $name)); + } + $cases[$name] = [$members]; + } + } + + return $cases; + } +} diff --git a/packages/feature-flags/tests/Support/FixedClock.php b/packages/feature-flags/tests/Support/FixedClock.php new file mode 100644 index 00000000..45af1970 --- /dev/null +++ b/packages/feature-flags/tests/Support/FixedClock.php @@ -0,0 +1,16 @@ +now; + } +} diff --git a/packages/feature-flags/tests/Support/FixedProvider.php b/packages/feature-flags/tests/Support/FixedProvider.php new file mode 100644 index 00000000..8556f3dc --- /dev/null +++ b/packages/feature-flags/tests/Support/FixedProvider.php @@ -0,0 +1,41 @@ +withValue(true)->withVariant('vendor-on')->withReason('STATIC')->build(); + } + + public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return (new ResolutionDetailsBuilder)->withValue($defaultValue)->withReason('DEFAULT')->build(); + } + + public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return (new ResolutionDetailsBuilder)->withValue($defaultValue)->withReason('DEFAULT')->build(); + } + + public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return (new ResolutionDetailsBuilder)->withValue($defaultValue)->withReason('DEFAULT')->build(); + } + + public function resolveObjectValue(string $flagKey, array $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return (new ResolutionDetailsBuilder)->withValue($defaultValue)->withReason('DEFAULT')->build(); + } +} diff --git a/packages/feature-flags/tests/Support/FlagStores.php b/packages/feature-flags/tests/Support/FlagStores.php new file mode 100644 index 00000000..57ed4392 --- /dev/null +++ b/packages/feature-flags/tests/Support/FlagStores.php @@ -0,0 +1,40 @@ + new DateTimeImmutable('2026-10-01 12:00:00', new DateTimeZone('UTC')); + } + + public static function sqlite(): Connection + { + $capsule = new Manager; + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $connection = $capsule->getConnection(); + FeatureFlagSchema::create($connection->getSchemaBuilder()); + + return $connection; + } + + /** @return array */ + public static function drivers(): array + { + return ['memory' => [static fn (): FlagStore => new MemoryFlagStore(self::clock())], 'database' => [static fn (): FlagStore => new DatabaseFlagStore(self::sqlite(), self::clock())]]; + } +} diff --git a/packages/feature-flags/tests/Support/FlagsCommandTestCase.php b/packages/feature-flags/tests/Support/FlagsCommandTestCase.php new file mode 100644 index 00000000..5bb66bd2 --- /dev/null +++ b/packages/feature-flags/tests/Support/FlagsCommandTestCase.php @@ -0,0 +1,48 @@ + 'array', + 'firefly.feature-flags.enabled' => true, + 'firefly.feature-flags.flags' => [ + ...ManagementFixtures::flags(), + 'user-audience' => [ + 'state' => 'ENABLED', + 'variants' => ['context' => 'context', 'option' => 'option', 'other' => 'other'], + 'defaultVariant' => 'other', + 'targeting' => ['if' => [ + ['==' => [['var' => 'targetingKey'], 'from-option']], 'option', + ['if' => [['==' => [['var' => 'targetingKey'], 'from-context']], 'context', null]], + ]], + ], + ], + 'firefly.feature-flags.sources.store.enabled' => true, + 'firefly.feature-flags.sources.store.driver' => 'memory', + 'firefly.feature-flags.management.writes' => true, + ]; + } + + protected function tearDown(): void + { + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider); + parent::tearDown(); + } +} diff --git a/packages/feature-flags/tests/Support/FlakyCache.php b/packages/feature-flags/tests/Support/FlakyCache.php new file mode 100644 index 00000000..03fc1617 --- /dev/null +++ b/packages/feature-flags/tests/Support/FlakyCache.php @@ -0,0 +1,42 @@ +|string $key */ + public function get($key, $default = null): mixed + { + return $this->down ? throw new RuntimeException('redis is gone') : parent::get($key, $default); + } + + public function forever($key, $value): bool + { + return $this->down ? throw new RuntimeException('redis is gone') : parent::forever($key, $value); + } + + /** @param \UnitEnum|array|string $key */ + public function add($key, $value, $ttl = null): bool + { + return $this->down ? throw new RuntimeException('redis is gone') : parent::add($key, $value, $ttl); + } + + public function getStore() + { + return $this->down ? throw new RuntimeException('redis is gone') : parent::getStore(); + } +} diff --git a/packages/feature-flags/tests/Support/GateFixtures.php b/packages/feature-flags/tests/Support/GateFixtures.php new file mode 100644 index 00000000..1d1fd14e --- /dev/null +++ b/packages/feature-flags/tests/Support/GateFixtures.php @@ -0,0 +1,42 @@ + $settings */ + public static function gate(bool $withFlags = true, array $settings = []): FeatureFlagGate + { + $illuminate = new Container; + if ($withFlags) { + $document = StaticDocument::json('{"flags":{ + "on":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"}, + "off":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"off"}, + "flow":{"state":"ENABLED","variants":{"v1":"v1","v2":"v2"},"defaultVariant":"v2"}, + "paused":{"state":"DISABLED","variants":{"on":true},"defaultVariant":"on"}, + "unset":{"state":"ENABLED","variants":{"on":true}} + }}'); + $flags = new FeatureFlags(new FireflyFlagProvider($document, new DefaultFlagdEvaluator), new EvaluationContextResolver, [], $document, OpenFeatureAPIFactory::createAPI()); + $illuminate->instance(FeatureFlags::class, $flags); + } + + return new FeatureFlagGate( + new FireflyContainer($illuminate, new ComponentManifest([])), + new Config(new Repository(['firefly' => ['feature-flags' => $settings]])), + ); + } +} diff --git a/packages/feature-flags/tests/Support/GatedBeansDisabledTestCase.php b/packages/feature-flags/tests/Support/GatedBeansDisabledTestCase.php new file mode 100644 index 00000000..13401681 --- /dev/null +++ b/packages/feature-flags/tests/Support/GatedBeansDisabledTestCase.php @@ -0,0 +1,14 @@ + false]; + } +} diff --git a/packages/feature-flags/tests/Support/GatedBeansTestCase.php b/packages/feature-flags/tests/Support/GatedBeansTestCase.php new file mode 100644 index 00000000..ac9cea6b --- /dev/null +++ b/packages/feature-flags/tests/Support/GatedBeansTestCase.php @@ -0,0 +1,55 @@ + 'array', + 'firefly.scan.paths' => ['Firefly\\FeatureFlags\\Tests\\Fixtures\\GatedBeans\\' => dirname(__DIR__).'/Fixtures/GatedBeans'], + 'firefly.cache.path' => sys_get_temp_dir().'/firefly-feature-flags-gating-'.bin2hex(random_bytes(6)), + 'firefly.feature-flags.enabled' => true, + 'firefly.feature-flags.flags' => ['new-checkout' => false, 'new-pricing' => false, 'reports' => true], + 'firefly.feature-flags.web.disabled-status' => $this->disabledStatus(), + ]; + } + + protected function disabledStatus(): int + { + return 404; + } + + /** @param array $flags shorthand or flagd */ + public function overrideFlags(array $flags): void + { + /** @var FlagRegistry $registry */ + $registry = $this->fireflyContext()->get(FlagRegistry::class); + $registry->overrideForTests(FlagDefinitions::parseDocument(['flags' => Json::object(FlagDefinitions::normalize($flags))])); + } + + protected function tearDown(): void + { + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider); + parent::tearDown(); + } +} diff --git a/packages/feature-flags/tests/Support/GatedRoutes503TestCase.php b/packages/feature-flags/tests/Support/GatedRoutes503TestCase.php new file mode 100644 index 00000000..eb170d03 --- /dev/null +++ b/packages/feature-flags/tests/Support/GatedRoutes503TestCase.php @@ -0,0 +1,13 @@ + ['Firefly\\FeatureFlags\\Tests\\Fixtures\\GatedRoutes\\' => dirname(__DIR__).'/Fixtures/GatedRoutes'], + 'firefly.feature-flags.flags' => ['beta-api' => false, 'checkout-flow' => 'v2'], + 'firefly.feature-flags.events.evaluations' => true, + ]; + } + + protected function setUp(): void + { + BetaController::$calls = 0; + parent::setUp(); + } +} diff --git a/packages/feature-flags/tests/Support/GherkinRunner.php b/packages/feature-flags/tests/Support/GherkinRunner.php new file mode 100644 index 00000000..45c17db2 --- /dev/null +++ b/packages/feature-flags/tests/Support/GherkinRunner.php @@ -0,0 +1,133 @@ + $steps + * @return list + */ + public function run(array $steps): array + { + $failures = []; + $flag = null; + $attributes = []; + $targetingKey = null; + $resolution = null; + + foreach ($steps as $step) { + $body = (string) preg_replace('/^(Given|When|Then|And)\s+/', '', $step); + + if ($body === 'an evaluator') { + continue; + } + if (preg_match('/^an? (\w+)-flag with key "([^"]*)" and a fallback value "(.*)"$/', $body, $m) === 1) { + $type = FlagType::from(strtolower($m[1])); + $flag = [$type, $m[2], self::coerce($type->value, $m[3])]; + + continue; + } + if (preg_match('/^a context containing a key "([^"]*)", with type "([^"]*)" and with value "(.*)"$/', $body, $m) === 1) { + $attributes[$m[1]] = self::coerce(strtolower($m[2]), $m[3]); + + continue; + } + if (preg_match('/^a context containing a targeting key with value "([^"]*)"/', $body, $m) === 1) { + $targetingKey = $m[1]; + + continue; + } + if (preg_match('/^a context containing a nested property with outer key "([^"]*)" and inner key "([^"]*)", with value "([^"]*)"/', $body, $m) === 1) { + $outer = is_array($attributes[$m[1]] ?? null) ? $attributes[$m[1]] : []; + $outer[$m[2]] = $m[3]; + $attributes[$m[1]] = $outer; + + continue; + } + if ($body === 'the flag was evaluated with details') { + if ($flag === null) { + return ['evaluated before a flag was named']; + } + $resolution = $this->evaluator->evaluate($this->document, $flag[1], $flag[0], $flag[2], $targetingKey, $attributes); + + continue; + } + if (! $resolution instanceof Resolution || $flag === null) { + return ["step before evaluation: {$body}"]; + } + if (preg_match('/^the resolved details value should be "(.*)"$/', $body, $m) === 1) { + $expected = self::coerce($flag[0]->value, $m[1]); + if (Json::canonical($resolution->value) !== Json::canonical($expected)) { + $failures[] = sprintf('value %s != %s', Json::canonical($resolution->value), Json::canonical($expected)); + } + + continue; + } + if (preg_match('/^the reason should be "([^"]*)"/', $body, $m) === 1) { + if ($resolution->reason->value !== $m[1]) { + $failures[] = "reason {$resolution->reason->value} != {$m[1]}"; + } + + continue; + } + if (preg_match('/^the error-code should be "([^"]*)"/', $body, $m) === 1) { + if ($resolution->error?->value !== $m[1]) { + $failures[] = 'error '.($resolution->error->value ?? 'none')." != {$m[1]}"; + } + + continue; + } + if (str_starts_with($body, 'the resolved metadata is empty')) { + if ($resolution->metadata !== []) { + $failures[] = 'metadata is not empty: '.Json::encode($resolution->metadata); + } + + continue; + } + if (str_starts_with($body, 'the resolved metadata should contain')) { + $rows = array_slice(explode("\n", $body), 1); + $header = array_map('trim', explode('|', trim((string) array_shift($rows), '| '))); + foreach ($rows as $row) { + $cells = array_combine($header, array_map('trim', explode('|', trim($row, '| ')))); + $key = $cells['key'] ?? ''; + $expected = self::coerce(strtolower($cells['metadata_type'] ?? ''), $cells['value'] ?? ''); + if (($resolution->metadata[$key] ?? null) !== $expected) { + $failures[] = "metadata [{$key}] != ".Json::encode($expected); + } + } + + continue; + } + + $failures[] = "unknown step: {$body}"; + } + + return $failures; + } + + private static function coerce(string $type, string $value): mixed + { + return match ($type) { + 'boolean' => $value === 'true', + 'integer' => (int) $value, + 'float' => (float) $value, + 'object' => Json::decode(str_replace('\\"', '"', $value)), + default => $value, + }; + } +} diff --git a/packages/feature-flags/tests/Support/GherkinScenarios.php b/packages/feature-flags/tests/Support/GherkinScenarios.php new file mode 100644 index 00000000..1db7644a --- /dev/null +++ b/packages/feature-flags/tests/Support/GherkinScenarios.php @@ -0,0 +1,181 @@ + */ + public const array SKIPPED_TAGS = ['@fractional-v1']; + + /** + * @return array{run: array>, skipped: int} + */ + public static function load(string $directory): array + { + $files = glob($directory.'/*.feature') ?: []; + sort($files, SORT_STRING); + + $run = []; + $skipped = 0; + foreach ($files as $file) { + foreach (self::parse((string) file_get_contents($file)) as [$name, $tags, $steps]) { + if (array_intersect($tags, self::SKIPPED_TAGS) !== []) { + $skipped++; + + continue; + } + $run[basename($file).': '.$name] = $steps; + } + } + + return ['run' => $run, 'skipped' => $skipped]; + } + + /** + * Pest dataset: "file: scenario [row]" => [steps]. + * + * @return array}> + */ + public static function dataset(string $directory): array + { + return array_map(static fn (array $steps): array => [$steps], self::load($directory)['run']); + } + + /** + * @return list, 2: list}> name, tags, steps + */ + private static function parse(string $feature): array + { + /** @var list $featureTags */ + $featureTags = []; + /** @var list $pending */ + $pending = []; + /** @var list $background */ + $background = []; + /** @var list, steps: list, examples: list, rows: list>}>}> $scenarios */ + $scenarios = []; + /** @var array{name: string, tags: list, steps: list, examples: list, rows: list>}>}|null $scenario */ + $scenario = null; + /** @var array{tags: list, rows: list>}|null $examples */ + $examples = null; + $mode = null; + + foreach (preg_split('/\R/', $feature) ?: [] as $line) { + $raw = trim($line); + if ($raw === '' || str_starts_with($raw, '#')) { + continue; + } + if (str_starts_with($raw, '@')) { + $pending = [...$pending, ...(preg_split('/\s+/', $raw) ?: [])]; + + continue; + } + if (str_starts_with($raw, 'Feature:')) { + $featureTags = $pending; + $pending = []; + + continue; + } + if (str_starts_with($raw, 'Background:')) { + $mode = 'background'; + + continue; + } + if (str_starts_with($raw, 'Scenario')) { + [$scenarios, $scenario, $examples] = self::close($scenarios, $scenario, $examples); + $scenario = ['name' => $raw, 'tags' => array_values(array_unique([...$featureTags, ...$pending])), 'steps' => [], 'examples' => []]; + $pending = []; + $mode = 'scenario'; + + continue; + } + if ($scenario === null) { + if ($mode === 'background') { + $background[] = $raw; + } + + continue; + } + if (str_starts_with($raw, 'Examples')) { + if ($examples !== null) { + $scenario['examples'][] = $examples; + } + $examples = ['tags' => $pending, 'rows' => []]; + $pending = []; + $mode = 'examples'; + + continue; + } + if (str_starts_with($raw, '|') && $mode === 'examples' && $examples !== null) { + $examples['rows'][] = array_map(trim(...), explode('|', trim($raw, '|'))); + + continue; + } + $last = array_key_last($scenario['steps']); + if (str_starts_with($raw, '|') && $mode === 'scenario' && $last !== null) { + $scenario['steps'][$last] .= "\n".$raw; + + continue; + } + if ($mode === 'scenario') { + $scenario['steps'][] = $raw; + } + } + [$scenarios] = self::close($scenarios, $scenario, $examples); + + $expanded = []; + foreach ($scenarios as $outline) { + if ($outline['examples'] === []) { + $expanded[] = [$outline['name'], $outline['tags'], [...$background, ...$outline['steps']]]; + + continue; + } + foreach ($outline['examples'] as $block) { + $rows = $block['rows']; + $header = array_shift($rows) ?? []; + foreach ($rows as $row) { + $values = array_combine($header, $row); + $steps = array_map( + static fn (string $step): string => (string) preg_replace_callback('/<([^>]+)>/', static fn (array $m): string => $values[$m[1]] ?? $m[0], $step), + $outline['steps'], + ); + $expanded[] = [ + $outline['name'].' ['.implode(' | ', $row).']', + array_values(array_unique([...$outline['tags'], ...$block['tags']])), + [...$background, ...$steps], + ]; + } + } + } + + return $expanded; + } + + /** + * The scenario being read, with its last Examples block, appended to the list. + * + * @param list, steps: list, examples: list, rows: list>}>}> $scenarios + * @param array{name: string, tags: list, steps: list, examples: list, rows: list>}>}|null $scenario + * @param array{tags: list, rows: list>}|null $examples + * @return array{0: list, steps: list, examples: list, rows: list>}>}>, 1: null, 2: null} + */ + private static function close(array $scenarios, ?array $scenario, ?array $examples): array + { + if ($scenario !== null) { + if ($examples !== null) { + $scenario['examples'][] = $examples; + } + $scenarios[] = $scenario; + } + + return [$scenarios, null, null]; + } +} diff --git a/packages/feature-flags/tests/Support/HookedLockStore.php b/packages/feature-flags/tests/Support/HookedLockStore.php new file mode 100644 index 00000000..b888e37e --- /dev/null +++ b/packages/feature-flags/tests/Support/HookedLockStore.php @@ -0,0 +1,56 @@ +beforeLock; + $this->beforeLock = null; + if ($hook !== null) { + $hook($name); + } + + return new class($this, $name, $seconds, $owner, $this->onRelease) extends ArrayLock + { + /** @param (Closure(string): void)|null $onRelease */ + public function __construct(ArrayStore $store, string $name, int $seconds, ?string $owner, private readonly ?Closure $onRelease) + { + parent::__construct($store, $name, $seconds, $owner); + } + + public function release(): bool + { + if ($this->onRelease !== null) { + ($this->onRelease)($this->name); + } + + return parent::release(); + } + }; + } +} diff --git a/packages/feature-flags/tests/Support/ManagementEndpointTestCase.php b/packages/feature-flags/tests/Support/ManagementEndpointTestCase.php new file mode 100644 index 00000000..dd283451 --- /dev/null +++ b/packages/feature-flags/tests/Support/ManagementEndpointTestCase.php @@ -0,0 +1,55 @@ + 'array', + 'firefly.management.enabled' => true, + 'firefly.management.endpoints.web.exposure.include' => 'health,flags', + 'firefly.feature-flags.enabled' => true, + 'firefly.feature-flags.flags' => [ + '0' => true, + '2024' => 'v2', + 'price' => ['state' => 'ENABLED', 'variants' => ['a' => 1.0, 'b' => 2.5], 'defaultVariant' => 'a'], + ], + 'firefly.feature-flags.sources.store.enabled' => true, + 'firefly.feature-flags.sources.store.driver' => 'memory', + 'firefly.feature-flags.management.writes' => true, + ]; + } + + protected function defineFireflyEnvironment(Application $app): void + { + FireflyBoot::stubScheduledManifest($app); + } + + protected function tearDown(): void + { + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider); + parent::tearDown(); + } +} diff --git a/packages/feature-flags/tests/Support/ManagementFixtures.php b/packages/feature-flags/tests/Support/ManagementFixtures.php new file mode 100644 index 00000000..82e245d5 --- /dev/null +++ b/packages/feature-flags/tests/Support/ManagementFixtures.php @@ -0,0 +1,91 @@ + $flags firefly.feature-flags.flags (shorthand allowed) + * @param list $actors + * @param list $preview + */ + public function __construct(array $flags, bool $writes = true, bool $store = true, array $actors = [], array $preview = [], float $refreshInterval = 5.0) + { + $settings = FeatureFlagsSettings::fromConfig(new Config(new Repository(['firefly' => ['feature-flags' => [ + 'enabled' => true, + 'flags' => $flags, + 'sources' => ['store' => ['enabled' => $store, 'driver' => 'memory', 'refresh-interval' => $refreshInterval]], + 'management' => ['writes' => $writes], + ]]]))); + + $this->store = new MemoryFlagStore(FlagStores::clock()); + $illuminate = new Container; + $illuminate->instance(FlagStore::class, $this->store); + + $sources = [new ConfigFlagSource($settings)]; + if ($store) { + $sources[] = new StoreFlagSource($settings, new FireflyContainer($illuminate, new ComponentManifest([]))); + } + + $this->events = new RecordingApplicationEventPublisher; + $this->registry = new FlagRegistry($sources, new CacheBook(new Cache(new ArrayStore), new NullLogger), $this->events); + $this->registry->start(); + + $this->management = new FlagManagement( + $settings, + new FireflyFlagProvider($this->registry, new DefaultFlagdEvaluator), + $this->registry, + $store ? new FlagStoreWriter($this->store, $this->registry, $this->events) : null, + new EvaluationContextResolver($preview), + $actors, + static fn (): string => '2026-10-01', + ); + } + + /** @return array the flags most tests start from */ + public static function flags(): array + { + return [ + 'kill-switch' => false, + 'checkout-flow' => ['state' => 'ENABLED', 'variants' => ['v1' => 'v1', 'v2' => 'v2'], 'defaultVariant' => 'v1', + 'targeting' => ['if' => [['==' => [['var' => 'plan'], 'pro']], 'v2', null]], 'metadata' => ['owner' => 'payments']], + 'legacy-export' => ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'on', + 'metadata' => ['expires' => '2025-01-01']], + ]; + } +} diff --git a/packages/feature-flags/tests/Support/ProfileVariables.php b/packages/feature-flags/tests/Support/ProfileVariables.php new file mode 100644 index 00000000..03f3243a --- /dev/null +++ b/packages/feature-flags/tests/Support/ProfileVariables.php @@ -0,0 +1,43 @@ + [$env, $server, $process]) { + if ($env !== null) { + $_ENV[$name] = $env; + } + if ($server !== null) { + $_SERVER[$name] = $server; + } + if (is_string($process)) { + putenv($name.'='.$process); + } + } + } + } +} diff --git a/packages/feature-flags/tests/Support/RecordingHook.php b/packages/feature-flags/tests/Support/RecordingHook.php new file mode 100644 index 00000000..6741850a --- /dev/null +++ b/packages/feature-flags/tests/Support/RecordingHook.php @@ -0,0 +1,43 @@ + */ + public array $evaluations = []; + + public function before(HookContext $context, HookHints $hints): ?EvaluationContext + { + return null; + } + + public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void + { + $this->evaluations[] = [ + 'flag' => $context->getFlagKey(), + 'reason' => $details->getReason(), + 'preview' => $hints->get(FeatureFlags::PREVIEW_HINT), + ]; + } + + public function error(HookContext $context, Throwable $error, HookHints $hints): void {} + + public function finally(HookContext $context, HookHints $hints): void {} + + public function supportsFlagValueType(string $flagValueType): bool + { + return true; + } +} diff --git a/packages/feature-flags/tests/Support/RecordingLogger.php b/packages/feature-flags/tests/Support/RecordingLogger.php new file mode 100644 index 00000000..1b207aa1 --- /dev/null +++ b/packages/feature-flags/tests/Support/RecordingLogger.php @@ -0,0 +1,34 @@ + */ + public array $lines = []; + + /** + * @param mixed $level + * @param array $context + */ + public function log($level, string|Stringable $message, array $context = []): void + { + $replacements = []; + foreach ($context as $key => $value) { + $replacements['{'.$key.'}'] = is_scalar($value) || $value === null ? (string) $value : json_encode($value); + } + + $this->lines[] = (is_string($level) ? $level : 'log').': '.strtr((string) $message, $replacements); + } + + public function count(string $level, string $needle): int + { + return count(array_filter($this->lines, static fn (string $line): bool => str_starts_with($line, $level.':') && str_contains($line, $needle))); + } +} diff --git a/packages/feature-flags/tests/Support/StaticDocument.php b/packages/feature-flags/tests/Support/StaticDocument.php new file mode 100644 index 00000000..1a32b1fc --- /dev/null +++ b/packages/feature-flags/tests/Support/StaticDocument.php @@ -0,0 +1,31 @@ +broken) { + throw new RuntimeException('the cache is on fire'); + } + + return $this->document; + } +} diff --git a/packages/feature-flags/tests/Support/StaticFlagdEvaluator.php b/packages/feature-flags/tests/Support/StaticFlagdEvaluator.php new file mode 100644 index 00000000..4a8ea2db --- /dev/null +++ b/packages/feature-flags/tests/Support/StaticFlagdEvaluator.php @@ -0,0 +1,61 @@ + */ + public array $lastAttributes = []; + + public int $calls = 0; + + public function evaluate(FlagDocument $document, string $flagKey, FlagType $type, mixed $default, ?string $targetingKey = null, array $attributes = []): Resolution + { + $this->calls++; + $this->lastTargetingKey = $targetingKey; + $this->lastAttributes = $attributes; + + $flag = $document->flag($flagKey); + if ($flag === null) { + return Resolution::error($default, EvaluationError::FlagNotFound, "Flag [{$flagKey}] is not defined."); + } + + $documentMetadata = array_filter($document->metadata, static fn (mixed $value): bool => is_bool($value) || is_int($value) || is_float($value) || is_string($value)); + $metadata = array_replace($documentMetadata, $flag->metadata()); + if ($flag->isDisabled()) { + return new Resolution($default, null, EvaluationReason::Disabled, metadata: $metadata); + } + + $variant = $flag->defaultVariant(); + if ($variant === null) { + return new Resolution($default, null, EvaluationReason::Default, metadata: $metadata); + } + + if (! $flag->hasVariant($variant)) { + return Resolution::error($default, EvaluationError::General, "Flag [{$flagKey}] names default variant [{$variant}], which is not one of its variants."); + } + + $value = $flag->variantValue($variant); + if (! $type->accepts($value)) { + return Resolution::error($default, EvaluationError::TypeMismatch, 'type mismatch'); + } + + return new Resolution($type === FlagType::Float && is_int($value) ? (float) $value : $value, $variant, EvaluationReason::Static, metadata: $metadata); + } +} diff --git a/packages/feature-flags/tests/Support/StubFlagSource.php b/packages/feature-flags/tests/Support/StubFlagSource.php new file mode 100644 index 00000000..47e28114 --- /dev/null +++ b/packages/feature-flags/tests/Support/StubFlagSource.php @@ -0,0 +1,92 @@ + */ + public array $evaluators = []; + + /** @var array */ + public array $metadata = []; + + /** @var (Closure(): void)|null */ + public ?Closure $beforeLoad = null; + + /** + * @param array $flags + */ + public function __construct( + private readonly string $name, + private readonly int $precedence, + private readonly float $interval, + public array $flags = [], + private readonly bool $failsStartup = false, + ) {} + + public function name(): string + { + return $this->name; + } + + public function precedence(): int + { + return $this->precedence; + } + + public function refreshInterval(): float + { + return $this->interval; + } + + public function failsStartup(): bool + { + return $this->failsStartup; + } + + public function reportedRevision(?string $revision): ?string + { + return $revision; + } + + public function load(?string $knownRevision): ?SourceSnapshot + { + $this->loads++; + $hook = $this->beforeLoad; + $this->beforeLoad = null; + if ($hook !== null) { + $hook(); + } + if ($this->failure !== null) { + throw $this->failure; + } + if ($knownRevision === $this->revision) { + return null; + } + + return new SourceSnapshot(FlagDefinitions::parseDocument([ + 'flags' => Json::object(FlagDefinitions::normalize($this->flags)), + '$evaluators' => $this->evaluators, + 'metadata' => $this->metadata, + ]), $this->revision); + } +} diff --git a/packages/feature-flags/tests/Support/SyncServerTestCase.php b/packages/feature-flags/tests/Support/SyncServerTestCase.php new file mode 100644 index 00000000..48b92283 --- /dev/null +++ b/packages/feature-flags/tests/Support/SyncServerTestCase.php @@ -0,0 +1,38 @@ + 'array', + 'firefly.feature-flags.enabled' => true, + 'firefly.feature-flags.flags' => ['kill-switch' => true], + 'firefly.feature-flags.sources.store.enabled' => true, + 'firefly.feature-flags.sources.store.driver' => 'memory', + 'firefly.feature-flags.server.enabled' => true, + 'firefly.feature-flags.server.token' => 's3cret', + ]; + } + + protected function tearDown(): void + { + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider); + parent::tearDown(); + } +} diff --git a/packages/feature-flags/tests/Support/TemporaryFlagFiles.php b/packages/feature-flags/tests/Support/TemporaryFlagFiles.php new file mode 100644 index 00000000..e9a69225 --- /dev/null +++ b/packages/feature-flags/tests/Support/TemporaryFlagFiles.php @@ -0,0 +1,61 @@ + */ + private static array $paths = []; + + public static function write(string $extension, string $contents, int $mtime = self::MTIME): string + { + $path = sys_get_temp_dir().'/firefly-flags-'.bin2hex(random_bytes(6)).'.'.$extension; + self::$paths[] = $path; + self::rewrite($path, $contents, $mtime); + + return $path; + } + + /** Replaces the contents of $path and pins its mtime. */ + public static function rewrite(string $path, string $contents, int $mtime = self::MTIME): void + { + file_put_contents($path, $contents); + touch($path, $mtime); + } + + /** + * Replaces the contents of $path from a child PHP process, as a deploy would. This process's own file I/O (any + * open, read, write or flush of a plain file, and touch()) clears PHP's stat cache; a write by another process + * does not, so a stat of $path cached before this call is stale after it. The mtime is the child's wall clock. + */ + public static function rewriteFromAnotherProcess(string $path, string $contents): void + { + $child = proc_open([PHP_BINARY, '-n', '-r', 'exit(file_put_contents($argv[1], $argv[2]) === false ? 1 : 0);', $path, $contents], [], $pipes); + if (! is_resource($child) || proc_close($child) !== 0) { + throw new RuntimeException("A child process could not rewrite [{$path}]."); + } + } + + public static function removeAll(): void + { + foreach (self::$paths as $path) { + if (is_file($path)) { + unlink($path); + } + } + self::$paths = []; + } +} diff --git a/packages/feature-flags/tests/Support/database-store-absent-racer.php b/packages/feature-flags/tests/Support/database-store-absent-racer.php new file mode 100644 index 00000000..153b44e7 --- /dev/null +++ b/packages/feature-flags/tests/Support/database-store-absent-racer.php @@ -0,0 +1,113 @@ +table('caller_markers')->insert(['actor' => $arguments[3] ?? '']); + if (($arguments[4] ?? '') === 'outer-snapshot') { + $connection->table('caller_markers')->count(); + } + $store->afterCommit(static function () use (&$calls): void { + $calls[] = 'before'; + }); + } + try { + $store->put('absent-race', ['state' => 'ENABLED', 'variants' => ['on' => ($arguments[3] ?? '') === 'first'], 'defaultVariant' => 'on'], $arguments[3] ?? '', ($arguments[5] ?? '') === 'null' ? null : 0); + + return 'success'; + } catch (FlagStoreConflict) { + return 'conflict'; + } catch (Throwable $error) { + $cause = $error; + while (! $cause instanceof QueryException && $cause->getPrevious() !== null) { + $cause = $cause->getPrevious(); + } + if (($arguments[6] ?? '') !== 'native-abort' || ! $cause instanceof QueryException || ($cause->errorInfo[1] ?? null) !== 1020) { + throw $error; + } + $nativeCause = $cause; + + return 'native-abort'; + } +}; +try { + if (! $outer) { + $outcome = $write(); + } elseif (($arguments[7] ?? '') === 'callback') { + $outcome = $connection->transaction($write, 3); + } else { + $connection->beginTransaction(); + $outcome = $write(); + $connection->commit(); + } + if ($nativeCause !== null) { + throw new RuntimeException('Engine-aborted caller transaction unexpectedly committed.'); + } +} catch (FlagStoreTransactionAborted $commitError) { + if ($nativeCause === null || $commitError->getPrevious() !== $nativeCause || $calls !== [] || $runs !== 1) { + throw new RuntimeException('Native abort lost its cause, emitted callbacks or replayed the caller.'); + } + $store->afterCommit(static function () use (&$calls): void { + $calls[] = 'aborted'; + }); + $other = DatabaseStoreIntegration::connection($arguments[1] ?? '', $arguments[2] ?? ''); + $dispatcher = $connection->getEventDispatcher(); + if ($dispatcher !== null) { + $other->setEventDispatcher($dispatcher); + } + $otherStore = new DatabaseFlagStore($other); + $otherCalls = 0; + $other->beginTransaction(); + $other->table('recovery_markers')->insert(['actor' => 'other']); + $otherStore->afterCommit(static function () use (&$otherCalls): void { + $otherCalls++; + }); + $other->commit(); + if ($otherCalls !== 1 || $calls !== []) { + throw new RuntimeException('Native abort leaked to another connection or emitted aborted callbacks.'); + } + $connection->rollBack(0); + $connection->beginTransaction(); + $connection->table('recovery_markers')->insert(['actor' => 'recovered']); + $store->afterCommit(static function () use (&$calls): void { + $calls[] = 'recovered'; + }); + $connection->commit(); + if ($calls !== ['recovered']) { + throw new RuntimeException('Callbacks did not recover cleanly in the new root transaction.'); + } + $outcome = 'native-abort'; +} +fwrite(STDOUT, $outcome."\n"); diff --git a/packages/feature-flags/tests/Support/database-store-racer.php b/packages/feature-flags/tests/Support/database-store-racer.php new file mode 100644 index 00000000..5bdb9093 --- /dev/null +++ b/packages/feature-flags/tests/Support/database-store-racer.php @@ -0,0 +1,25 @@ +beforeExecuting(static function (string $query): void { + if (str_contains($query, 'for update')) { + // The store transaction is open and its locked read is about to contend with the parent process. + fwrite(STDOUT, "reading\n"); + fflush(STDOUT); + } +}); +try { + (new DatabaseFlagStore($connection))->put('race', ['state' => 'ENABLED', 'variants' => ['on' => true], 'defaultVariant' => 'on'], 'child', 1); + fwrite(STDOUT, "unexpected-success\n"); + exit(1); +} catch (FlagStoreConflict) { + fwrite(STDOUT, "conflict\n"); +} diff --git a/packages/feature-flags/tests/TargetingBootTest.php b/packages/feature-flags/tests/TargetingBootTest.php new file mode 100644 index 00000000..90a02822 --- /dev/null +++ b/packages/feature-flags/tests/TargetingBootTest.php @@ -0,0 +1,36 @@ + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider)); + +it('evaluates targeting, shared evaluators and rollouts with the real evaluator on a real boot', function (): void { + $context = bootFireflyApp(['firefly' => ['feature-flags' => [ + 'enabled' => true, + 'evaluators' => ['is-beta' => ['in' => ['beta', ['var' => 'roles']]]], + 'flags' => [ + 'new-checkout' => ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['if' => [['$ref' => 'is-beta'], 'on', null]]], + 'rollout-10' => ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off', + 'targeting' => ['fractional' => [['on', 10], ['off', 90]]]], + ], + ]]], [FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], needs: ['cache']); + /** @var FeatureFlags $flags */ + $flags = $context->get(FeatureFlags::class); + + expect($context->get(FlagdEvaluator::class))->toBeInstanceOf(DefaultFlagdEvaluator::class) + ->and($flags->isEnabled('new-checkout', context: ['roles' => ['beta']]))->toBeTrue() + ->and($flags->isEnabled('new-checkout', context: ['roles' => ['staff']]))->toBeFalse() + // user-4 is inside the 10% bucket of rollout-10 in the Firefly vectors; user-42 is not. + ->and($flags->isEnabled('rollout-10', targetingKey: 'user-4'))->toBeTrue() + ->and($flags->isEnabled('rollout-10', targetingKey: 'user-42'))->toBeFalse() + ->and($flags->details('rollout-10', false)->reason)->toBe('DEFAULT'); +}); diff --git a/packages/feature-flags/tests/Telemetry/HooksTest.php b/packages/feature-flags/tests/Telemetry/HooksTest.php new file mode 100644 index 00000000..e19f905e --- /dev/null +++ b/packages/feature-flags/tests/Telemetry/HooksTest.php @@ -0,0 +1,643 @@ + */ + public array $rows = []; + + public bool $broken = false; + + public function recordEvaluation(string $flag, string $variant, string $reason): void + { + if ($this->broken) { + throw new RuntimeException('meter registry down'); + } + $this->rows[] = "{$flag}|{$variant}|{$reason}"; + } +} + +/** An application hook that throws in one phase (`before` or `after`), which sends the SDK down its error path. */ +final class FeatureFlagsFailingHook implements Hook +{ + public function __construct(private readonly string $phase) {} + + public function before(HookContext $context, HookHints $hints): ?EvaluationContext + { + if ($this->phase === 'before') { + throw new RuntimeException('before hook failed'); + } + + return null; + } + + public function after(HookContext $context, ResolutionDetails $details, HookHints $hints): void + { + if ($this->phase === 'after') { + throw new RuntimeException('after hook failed'); + } + } + + public function error(HookContext $context, Throwable $error, HookHints $hints): void {} + + public function finally(HookContext $context, HookHints $hints): void {} + + public function supportsFlagValueType(string $flagValueType): bool + { + return true; + } +} + +/** An application's own provider: answers the same details for every flag, or throws. */ +final class FeatureFlagsScriptedProvider extends AbstractProvider +{ + public function __construct( + private readonly ?ResolutionDetails $details = null, + private readonly ?Throwable $failure = null, + ) {} + + public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->answer(); + } + + public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->answer(); + } + + public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->answer(); + } + + public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->answer(); + } + + /** + * @param mixed[] $defaultValue + */ + public function resolveObjectValue(string $flagKey, array $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->answer(); + } + + private function answer(): ResolutionDetails + { + if ($this->failure !== null) { + throw $this->failure; + } + + return $this->details ?? (new ResolutionDetailsBuilder)->build(); + } +} + +/** A provider failure that carries its OpenFeature error code, as the SDK's client reads it. */ +final class FeatureFlagsProviderNotReady extends RuntimeException implements ThrowableWithResolutionError +{ + public function getResolutionError(): ResolutionError + { + return new ProviderResolutionError(ErrorCode::PROVIDER_NOT_READY(), 'warming up'); + } +} + +/** @param list $hooks */ +function featureFlagsWithHooks(array $hooks): FeatureFlags +{ + $document = StaticDocument::json('{"flags":{"on":{"state":"ENABLED","variants":{"on":true,"off":false},"defaultVariant":"on"},"paused":{"state":"DISABLED","variants":{"on":true},"defaultVariant":"on"},"text":{"state":"ENABLED","variants":{"a":"A"},"defaultVariant":"a"}}}'); + + return new FeatureFlags(new FireflyFlagProvider($document, new StaticFlagdEvaluator), new EvaluationContextResolver, $hooks, $document, OpenFeatureAPIFactory::createAPI()); +} + +/** @param list $hooks */ +function featureFlagsWithProvider(AbstractProvider $provider, array $hooks): FeatureFlags +{ + return new FeatureFlags($provider, new EvaluationContextResolver, $hooks, null, OpenFeatureAPIFactory::createAPI()); +} + +it('counts every evaluation with variant none and reason ERROR for failures', function (): void { + $metrics = new FeatureFlagsRecordingMetrics; + $flags = featureFlagsWithHooks([new MetricsHook($metrics)]); + + $flags->isEnabled('on'); + $flags->isEnabled('paused'); + $flags->isEnabled('missing'); + $flags->isEnabled('text'); + + expect($metrics->rows)->toBe(['on|on|STATIC', 'paused|none|DISABLED', 'missing|none|ERROR', 'text|none|ERROR']); +}); + +it('never changes the value when the metrics recorder throws', function (): void { + $metrics = new FeatureFlagsRecordingMetrics; + $metrics->broken = true; + $logger = new RecordingLogger; + + $value = featureFlagsWithHooks([new MetricsHook($metrics, $logger)])->isEnabled('on'); + + expect($value)->toBeTrue() + ->and($logger->count('debug', 'meter registry down'))->toBe(1); +}); + +it('publishes one exposure event per evaluation with the targeting key', function (): void { + $events = new RecordingApplicationEventPublisher; + + featureFlagsWithHooks([new ExposureEventHook($events)])->isEnabled('on', targetingKey: 'u-42'); + + /** @var FeatureFlagEvaluated $event */ + $event = $events->ofType(FeatureFlagEvaluated::class)[0]; + + expect($events->events)->toHaveCount(1) + ->and([$event->key, $event->value, $event->variant, $event->reason, $event->errorCode, $event->targetingKey]) + ->toBe(['on', true, 'on', 'STATIC', null, 'u-42']); +}); + +it('never changes the value when an exposure listener throws', function (): void { + $throwing = new class implements ApplicationEventPublisher + { + public function publish(object $event): void + { + throw new RuntimeException('analytics pipeline down'); + } + }; + $logger = new RecordingLogger; + + expect(featureFlagsWithHooks([new ExposureEventHook($throwing, $logger)])->isEnabled('on'))->toBeTrue() + ->and($logger->count('debug', 'analytics pipeline down'))->toBe(1); +}); + +it('never lets a failure out of any hook phase, whatever the SDK does around it', function (): void { + $metrics = new FeatureFlagsRecordingMetrics; + $metrics->broken = true; + $throwing = new class implements ApplicationEventPublisher + { + public function publish(object $event): void + { + throw new RuntimeException('analytics pipeline down'); + } + }; + $logger = new RecordingLogger; + $hooks = [new MetricsHook($metrics, $logger), new ExposureEventHook($throwing, $logger)]; + $context = (new HookContextBuilder)->withFlagKey('on')->withType(FlagValueType::BOOLEAN)->withDefaultValue(false) + ->withEvaluationContext(new FlagsEvaluationContext('u-42'))->build(); + $failed = (new HookContextBuilder)->withFlagKey('on')->withType(FlagValueType::BOOLEAN)->withDefaultValue(false) + ->withEvaluationContext(new FlagsEvaluationContext('u-42'))->build(); + $details = (new ResolutionDetailsBuilder)->withValue(true)->withVariant('on')->withReason('STATIC')->build(); + $hints = new FlagsHookHints; + + foreach ($hooks as $hook) { + expect($hook->before($context, $hints))->toBeNull(); + $hook->after($context, $details, $hints); + $hook->finally($context, $hints); + $hook->error($failed, new RuntimeException('provider down'), $hints); + $hook->finally($failed, $hints); + } + + expect($logger->count('debug', 'meter registry down'))->toBe(2) + ->and($logger->count('debug', 'analytics pipeline down'))->toBe(2); +}); + +it('records neither a metric nor an exposure event for a preview, whatever it answers', function (): void { + $metrics = new FeatureFlagsRecordingMetrics; + $events = new RecordingApplicationEventPublisher; + $hooks = [new MetricsHook($metrics), new ExposureEventHook($events)]; + $flags = featureFlagsWithHooks($hooks); + $failing = featureFlagsWithHooks([new FeatureFlagsFailingHook('before'), ...$hooks]); + $outcome = static fn (FlagEvaluation $evaluation): string => $evaluation->reason.'|'.($evaluation->errorCode ?? '-'); + + $previews = [ + $flags->details('on', false, ambient: false), + $flags->details('missing', false, ambient: false), + $flags->details('text', false, ambient: false), + $failing->details('on', false, ambient: false), + ]; + + expect(array_map($outcome, $previews))->toBe(['STATIC|-', 'ERROR|FLAG_NOT_FOUND', 'ERROR|TYPE_MISMATCH', 'ERROR|GENERAL']) + ->and($metrics->rows)->toBe([]) + ->and($events->events)->toBe([]); + + // The same evaluations outside a preview are counted and exposed: the hooks are attached and listening. + $evaluations = [ + $flags->details('on', false), + $flags->details('missing', false), + $flags->details('text', false), + $failing->details('on', false), + ]; + + expect(array_map($outcome, $evaluations))->toBe(['STATIC|-', 'ERROR|FLAG_NOT_FOUND', 'ERROR|TYPE_MISMATCH', 'ERROR|GENERAL']) + ->and($metrics->rows)->toBe(['on|on|STATIC', 'missing|none|ERROR', 'text|none|ERROR', 'on|none|ERROR']) + ->and($events->ofType(FeatureFlagEvaluated::class))->toHaveCount(4); +}); + +it('counts and exposes an evaluation once, as the ERROR the caller got, when a hook after them fails', function (): void { + $metrics = new FeatureFlagsRecordingMetrics; + $events = new RecordingApplicationEventPublisher; + // Client hooks run `after` in reverse order: the failing hook's `after` runs once both of ours have seen a + // successful resolution, and the SDK then answers ERROR + the default and runs every `error` hook. + $flags = featureFlagsWithHooks([new FeatureFlagsFailingHook('after'), new MetricsHook($metrics), new ExposureEventHook($events)]); + + $details = $flags->details('on', false, targetingKey: 'u-42'); + + /** @var list $exposures */ + $exposures = $events->ofType(FeatureFlagEvaluated::class); + + expect([$details->value, $details->reason, $details->errorCode])->toBe([false, 'ERROR', 'GENERAL']) + ->and($metrics->rows)->toBe(['on|none|ERROR']) + ->and($exposures)->toHaveCount(1) + ->and([$exposures[0]->key, $exposures[0]->value, $exposures[0]->variant, $exposures[0]->reason, $exposures[0]->errorCode, $exposures[0]->targetingKey]) + ->toBe(['on', false, null, 'ERROR', 'GENERAL', 'u-42']); +}); + +it('exposes a failure with the error code the caller got and the default it was served', function (): void { + $metrics = new FeatureFlagsRecordingMetrics; + $events = new RecordingApplicationEventPublisher; + $hooks = [new MetricsHook($metrics), new ExposureEventHook($events)]; + + featureFlagsWithHooks($hooks)->isEnabled('missing', true); + featureFlagsWithHooks($hooks)->isEnabled('paused'); + $notReady = featureFlagsWithProvider(new FeatureFlagsScriptedProvider(failure: new FeatureFlagsProviderNotReady('warming up')), $hooks) + ->details('banner', 'plain'); + + /** @var list $exposures */ + $exposures = $events->ofType(FeatureFlagEvaluated::class); + $rows = array_map(static fn (FeatureFlagEvaluated $event): array => [$event->key, $event->value, $event->variant, $event->reason, $event->errorCode], $exposures); + + expect($notReady->errorCode)->toBe('PROVIDER_NOT_READY') + ->and($rows)->toBe([ + ['missing', true, null, 'ERROR', 'FLAG_NOT_FOUND'], + ['paused', false, null, 'DISABLED', null], + ['banner', 'plain', null, 'ERROR', 'PROVIDER_NOT_READY'], + ]) + ->and($metrics->rows)->toBe(['missing|none|ERROR', 'paused|none|DISABLED', 'banner|none|ERROR']); +}); + +it('exposes a copy of the served value, which the caller cannot change afterwards', function (): void { + $events = new RecordingApplicationEventPublisher; + $hooks = [new ExposureEventHook($events)]; + $style = (object) ['color' => 'red']; + $served = featureFlagsWithProvider(new FeatureFlagsScriptedProvider( + (new ResolutionDetailsBuilder)->withValue(['style' => $style])->withVariant('red')->withReason('STATIC')->build(), + ), $hooks); + $fallback = ['style' => (object) ['color' => 'blue']]; + + $value = $served->getObject('theme'); + featureFlagsWithProvider(new FeatureFlagsScriptedProvider(failure: new RuntimeException('provider down')), $hooks)->getObject('theme', $fallback); + + // The caller holds the very instance the provider served (PHP shares objects inside arrays) and changes it. + expect($value['style'] ?? null)->toBe($style); + $style->color = 'green'; + $fallback['style']->color = 'black'; + + /** @var list $exposures */ + $exposures = $events->ofType(FeatureFlagEvaluated::class); + + expect($exposures)->toHaveCount(2) + ->and($exposures[0]->value)->toEqual(['style' => (object) ['color' => 'red']]) + ->and($exposures[1]->value)->toEqual(['style' => (object) ['color' => 'blue']]) + ->and([$exposures[1]->reason, $exposures[1]->errorCode])->toBe(['ERROR', 'GENERAL']); +}); + +it('copies small shared object graphs within bounded memory on both SDK paths', function (bool $failed): void { + $script = <<<'SCRIPT' +require 'vendor/autoload.php'; +$leaf = (object) ['label' => 'original']; +$graph = $leaf; +for ($i = 0; $i < 10; $i++) { + $graph = (object) ['left' => $graph, 'right' => $graph]; +} +$events = new Firefly\Testing\Double\RecordingApplicationEventPublisher; +$hook = new Firefly\FeatureFlags\Telemetry\ExposureEventHook($events); +$provider = new OpenFeature\implementation\provider\NoOpProvider; +$flags = new Firefly\FeatureFlags\FeatureFlags($provider, new Firefly\FeatureFlags\Context\EvaluationContextResolver, [$hook], null, OpenFeature\isolated\OpenFeatureAPIFactory::createAPI()); +SCRIPT; + if ($failed) { + $script .= <<<'SCRIPT' +$provider = new class extends OpenFeature\implementation\provider\NoOpProvider { + public function resolveObjectValue(string $flagKey, array $defaultValue, ?OpenFeature\interfaces\flags\EvaluationContext $context = null): OpenFeature\interfaces\provider\ResolutionDetails { + throw new RuntimeException('provider down'); + } +}; +$flags = new Firefly\FeatureFlags\FeatureFlags($provider, new Firefly\FeatureFlags\Context\EvaluationContextResolver, [$hook], null, OpenFeature\isolated\OpenFeatureAPIFactory::createAPI()); +SCRIPT; + } + $script .= <<<'SCRIPT' +foreach ([1, 2] as $evaluation) { + $served = $flags->getObject('graph', ['tree' => $graph]); + if ($served['tree'] !== $graph) { throw new RuntimeException('changed caller value'); } +} +$first = $events->events[0]->value['tree']; +$second = $events->events[1]->value['tree']; +if ($first === $graph || $first === $second) { throw new RuntimeException('shared snapshot'); } +for ($i = 0; $i < 10; $i++) { + if ($first->left !== $first->right) { throw new RuntimeException('lost alias'); } + $first = $first->left; +} +$leaf->label = 'changed'; +if ($first === $leaf || $first->label !== 'original') { throw new RuntimeException('lost cycle or isolation'); } +$cycle = new stdClass; +$cycle->self = $cycle; +$cycleCopy = Firefly\FeatureFlags\Provider\ValueCopy::of($cycle); +if ($cycleCopy === $cycle || $cycleCopy->self !== $cycleCopy) { throw new RuntimeException('lost helper cycle'); } +$flags->getObject('cycle', ['cycle' => $cycle]); +if (count($events->events) !== 2) { throw new RuntimeException('cyclic exposure was not omitted'); } +echo $events->events[0]->reason; +SCRIPT; + $process = new Process([PHP_BINARY, '-d', 'memory_limit=32M', '-r', $script], dirname(__DIR__, 4)); + $process->setTimeout(10); + $process->run(); + + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toBe($failed ? 'ERROR' : 'UNKNOWN'); +})->with([false, true]); + +it('contains copy failures and drops stale successful exposures on an unsupported default', function (): void { + $events = new RecordingApplicationEventPublisher; + $logger = new RecordingLogger; + $array = []; + $array['self'] = &$array; + $flags = featureFlagsWithProvider(new FeatureFlagsScriptedProvider( + (new ResolutionDetailsBuilder)->withValue(['ok' => true])->withReason('STATIC')->build(), + ), [new FeatureFlagsFailingHook('after'), new ExposureEventHook($events, $logger)]); + + $value = $flags->getObject('graph', $array); + + expect(array_keys($value))->toBe(['self']) + ->and($events->events)->toBe([]) + ->and($logger->count('debug', '10000'))->toBe(1); +}); + +it('keeps successful evaluation unchanged when its array reference snapshot is unsupported', function (): void { + $events = new RecordingApplicationEventPublisher; + $logger = new RecordingLogger; + $shared = ['ok' => true]; + $value = ['left' => &$shared, 'right' => &$shared]; + $flags = featureFlagsWithProvider(new FeatureFlagsScriptedProvider( + (new ResolutionDetailsBuilder)->withValue($value)->withReason('STATIC')->build(), + ), [new ExposureEventHook($events, $logger)]); + + expect($flags->getObject('graph'))->toBe($value) + ->and($events->events)->toBe([]) + ->and($logger->count('debug', 'array references'))->toBe(1); +}); + +it('contains a broken debug logger in both hooks without SDK protection', function (): void { + $metrics = new FeatureFlagsRecordingMetrics; + $metrics->broken = true; + $publisher = new class implements ApplicationEventPublisher + { + public function publish(object $event): void + { + throw new RuntimeException('publisher down'); + } + }; + $logger = new class extends AbstractLogger + { + public int $calls = 0; + + public function log($level, string|Stringable $message, array $context = []): void + { + $this->calls++; + throw new RuntimeException('logger down'); + } + }; + $context = (new HookContextBuilder)->withFlagKey('on')->withType(FlagValueType::BOOLEAN)->withDefaultValue(false)->withEvaluationContext(new FlagsEvaluationContext)->build(); + $details = (new ResolutionDetailsBuilder)->withValue(true)->build(); + $hints = new FlagsHookHints; + foreach ([new MetricsHook($metrics, $logger), new ExposureEventHook($publisher, $logger)] as $hook) { + expect($hook->before($context, $hints))->toBeNull(); + $hook->after($context, $details, $hints); + $hook->finally($context, $hints); + $hook->error($context, new RuntimeException('provider down'), $hints); + $hook->finally($context, $hints); + } + expect($logger->calls)->toBe(4); +}); + +it('limits snapshot isolation to JSON values and keeps nested foreign objects by identity', function (): void { + $events = new RecordingApplicationEventPublisher; + $date = new DateTime('2026-01-01'); + $flags = featureFlagsWithProvider(new FeatureFlagsScriptedProvider( + (new ResolutionDetailsBuilder)->withValue(['nested' => (object) ['date' => $date]])->build(), + ), [new ExposureEventHook($events)]); + $flags->getObject('date'); + /** @var FeatureFlagEvaluated $event */ + $event = $events->events[0]; + /** @var array{nested: stdClass} $snapshot */ + $snapshot = $event->value; + expect($snapshot['nested']->date)->toBe($date); +}); + +it('copies wide valid stored values and ordinary defaults across every copy entry point', function (): void { + $wide = array_fill(0, 10001, (object) ['label' => 'original']); + $document = StaticDocument::json(Json::encode([ + 'flags' => ['wide' => ['state' => 'ENABLED', 'variants' => ['v' => $wide], 'defaultVariant' => 'v']], + ])); + $provider = new FireflyFlagProvider($document, new StaticFlagdEvaluator); + $events = new RecordingApplicationEventPublisher; + $flags = new FeatureFlags($provider, new EvaluationContextResolver, [new ExposureEventHook($events)], $document, OpenFeatureAPIFactory::createAPI()); + $resolution = $provider->resolution('wide', FlagType::Object, []); + $details = $flags->details('wide', []); + $ownDetails = FlagEvaluation::fromResolution('wide', $resolution); + $default = ['nested' => (object) ['label' => 'default']]; + $fallback = $flags->details('missing', $default); + $ownFallback = $provider->resolution('missing', FlagType::Object, $default); + /** @var FeatureFlagEvaluated $event */ + $event = $events->events[0]; + expect($resolution->value)->toHaveCount(10001) + ->and($details->value)->toHaveCount(10001) + ->and($ownDetails->value)->toHaveCount(10001) + ->and($events->events)->toHaveCount(1) + ->and($event->key)->toBe('missing') + ->and($fallback->value)->toBe(['nested' => ['label' => 'default']]) + ->and($ownFallback->value)->toEqual($default); +}); + +it('omits excessively deep snapshots without replacing successful SDK values', function (): void { + $value = ['end' => (object) ['label' => 'original']]; + for ($i = 0; $i < 513; $i++) { + $value = [$value]; + } + $events = new RecordingApplicationEventPublisher; + $logger = new RecordingLogger; + $flags = featureFlagsWithProvider(new FeatureFlagsScriptedProvider( + (new ResolutionDetailsBuilder)->withValue($value)->build(), + ), [new ExposureEventHook($events, $logger)]); + expect($flags->getObject('deep'))->toBe($value) + ->and($events->events)->toBe([]) + ->and($logger->count('debug', 'depth limit'))->toBe(1); +}); + +it('copies small repeated by-value arrays without expanding their COW tree', function (bool $failed): void { + $script = <<<'SCRIPT' +require 'vendor/autoload.php'; +$leaf = (object) ['label' => 'original']; +$value = [$leaf]; +for ($i = 0; $i < 8; $i++) { $value = [$value, $value]; } +$events = new Firefly\Testing\Double\RecordingApplicationEventPublisher; +$provider = new class extends OpenFeature\implementation\provider\NoOpProvider { + public bool $failed = false; + public function resolveObjectValue(string $flagKey, array $defaultValue, ?OpenFeature\interfaces\flags\EvaluationContext $context = null): OpenFeature\interfaces\provider\ResolutionDetails { + if ($this->failed) { throw new RuntimeException('provider down'); } + return parent::resolveObjectValue($flagKey, $defaultValue, $context); + } +}; +SCRIPT; + $script .= '$provider->failed = '.($failed ? 'true' : 'false').';'; + $script .= <<<'SCRIPT' +$flags = new Firefly\FeatureFlags\FeatureFlags($provider, new Firefly\FeatureFlags\Context\EvaluationContextResolver, [new Firefly\FeatureFlags\Telemetry\ExposureEventHook($events)], null, OpenFeature\isolated\OpenFeatureAPIFactory::createAPI()); +$served = $flags->getObject('tree', $value); +for ($i = 0; $i < 8; $i++) { $served = $served[0]; } +if ($served[0] !== $leaf) { throw new RuntimeException('changed caller'); } +$copy = $events->events[0]->value; +$left = $copy; +$right = $copy; +for ($i = 0; $i < 8; $i++) { $left = $left[0]; $right = $right[1]; } +$leaf->label = 'changed'; +if ($left[0] === $leaf || $left[0] !== $right[0] || $left[0]->label !== 'original') { throw new RuntimeException('lost isolation or alias'); } +$copy[0] = ['replaced']; +if ($events->events[0]->value[0] === ['replaced']) { throw new RuntimeException('lost COW isolation'); } +echo $events->events[0]->reason; +SCRIPT; + $process = new Process([PHP_BINARY, '-d', 'memory_limit=32M', '-r', $script], dirname(__DIR__, 4)); + $process->setTimeout(10); + $process->run(); + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toBe($failed ? 'ERROR' : 'UNKNOWN'); +})->with([false, true]); + +it('keeps distinct nested arrays distinct without calling foreign serialization methods', function (): void { + $foreign = new class + { + public function __serialize(): array + { + throw new RuntimeException('foreign serialization must not run'); + } + }; + $first = (object) ['label' => 'first']; + $second = (object) ['label' => 'second']; + $value = [[['leaf' => $first]], [['leaf' => $second]], ['foreign' => $foreign]]; + $events = new RecordingApplicationEventPublisher; + $flags = featureFlagsWithProvider(new FeatureFlagsScriptedProvider( + (new ResolutionDetailsBuilder)->withValue($value)->build(), + ), [new ExposureEventHook($events)]); + $flags->getObject('distinct'); + /** @var FeatureFlagEvaluated $event */ + $event = $events->events[0]; + /** @var array{array{array{leaf: stdClass}}, array{array{leaf: stdClass}}, array{foreign: object}} $copy */ + $copy = $event->value; + expect($copy[0][0]['leaf']->label)->toBe('first') + ->and($copy[1][0]['leaf']->label)->toBe('second') + ->and($copy[0][0]['leaf'])->not->toBe($first) + ->and($copy[1][0]['leaf'])->not->toBe($second) + ->and($copy[2]['foreign'])->toBe($foreign); +}); + +it('consumes shared exposure boundary vectors without changing evaluations or metrics', function (array $case, bool $failed): void { + /** @var array{value: bool|array, expose: bool} $case */ + $value = $case['value']; + $events = new RecordingApplicationEventPublisher; + $logger = new RecordingLogger; + $metrics = new FeatureFlagsRecordingMetrics; + $provider = new FeatureFlagsScriptedProvider( + (new ResolutionDetailsBuilder)->withValue($value)->withReason('STATIC')->build(), + $failed ? new RuntimeException('provider down') : null, + ); + $flags = featureFlagsWithProvider($provider, [new ExposureEventHook($events, $logger), new MetricsHook($metrics)]); + $served = is_bool($value) ? $flags->isEnabled('boundary', $value) : $flags->getObject('boundary', $value); + expect($served)->toBe($value) + ->and($events->events)->toHaveCount($case['expose'] ? 1 : 0) + ->and($metrics->rows)->toBe(['boundary|none|'.($failed ? 'ERROR' : 'STATIC')]) + ->and($logger->count('debug', '10000'))->toBe($case['expose'] ? 0 : 1); + if ($case['expose']) { + /** @var FeatureFlagEvaluated $event */ + $event = $events->events[0]; + expect($event->value)->toEqual($value); + } +})->with(function (): array { + $json = file_get_contents(__DIR__.'/../Conformance/exposure-vectors.json'); + if ($json === false) { + throw new RuntimeException('Missing shared exposure vectors'); + } + /** @var array{cases: list, occurrences: int, expose: bool}>} $vectors */ + $vectors = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + $cases = []; + foreach ($vectors['cases'] as $case) { + $cases[$case['name']] = [$case]; + } + + return $cases; +})->with([false, true]); + +it('omits independent equal COW graphs before expensive copying on both SDK paths', function (bool $failed): void { + $script = <<<'SCRIPT' +require 'vendor/autoload.php'; +$left = ['leaf']; +$right = ['leaf']; +for ($i = 0; $i < 28; $i++) { $left = [$left, $left]; $right = [$right, $right]; } +$value = [$left, $right]; +$events = new Firefly\Testing\Double\RecordingApplicationEventPublisher; +$logger = new Firefly\FeatureFlags\Tests\Support\RecordingLogger; +$metrics = new class implements Firefly\FeatureFlags\Telemetry\FeatureFlagMetrics { + public int $count = 0; + public function recordEvaluation(string $flag, string $variant, string $reason): void { $this->count++; } +}; +$provider = new class extends OpenFeature\implementation\provider\NoOpProvider { + public bool $failed = false; + public function resolveObjectValue(string $flagKey, array $defaultValue, ?OpenFeature\interfaces\flags\EvaluationContext $context = null): OpenFeature\interfaces\provider\ResolutionDetails { + if ($this->failed) { throw new RuntimeException('provider down'); } + return parent::resolveObjectValue($flagKey, $defaultValue, $context); + } +}; +SCRIPT; + $script .= '$provider->failed = '.($failed ? 'true' : 'false').';'; + $script .= <<<'SCRIPT' +$flags = new Firefly\FeatureFlags\FeatureFlags($provider, new Firefly\FeatureFlags\Context\EvaluationContextResolver, [new Firefly\FeatureFlags\Telemetry\ExposureEventHook($events, $logger), new Firefly\FeatureFlags\Telemetry\MetricsHook($metrics)], null, OpenFeature\isolated\OpenFeatureAPIFactory::createAPI()); +$served = $flags->getObject('tree', $value); +if (count($served) !== 2) { throw new RuntimeException('changed caller'); } +$leaf = $served[0]; +for ($i = 0; $i < 28; $i++) { $leaf = $leaf[0]; } +if ($leaf !== ['leaf'] || $events->events !== [] || $metrics->count !== 1 || $logger->count('debug', '10000') !== 1) { throw new RuntimeException('budget policy failed'); } +echo 'bounded'; +SCRIPT; + $process = new Process([PHP_BINARY, '-d', 'memory_limit=32M', '-d', 'max_execution_time=2', '-r', $script], dirname(__DIR__, 4)); + $process->setTimeout(5); + $process->run(); + expect($process->getExitCode())->toBe(0, $process->getErrorOutput()) + ->and($process->getOutput())->toBe('bounded'); +})->with([false, true]); diff --git a/packages/firefly/composer.json b/packages/firefly/composer.json index fe60416d..faee7fec 100644 --- a/packages/firefly/composer.json +++ b/packages/firefly/composer.json @@ -33,6 +33,7 @@ "firefly/data": "*@dev", "firefly/domain": "*@dev", "firefly/eda": "*@dev", + "firefly/feature-flags": "*@dev", "firefly/kernel": "*@dev", "firefly/messaging": "*@dev", "firefly/observability": "*@dev", diff --git a/packages/installer/src/CapabilityCatalog.php b/packages/installer/src/CapabilityCatalog.php index 3363f68d..395f3719 100644 --- a/packages/installer/src/CapabilityCatalog.php +++ b/packages/installer/src/CapabilityCatalog.php @@ -43,6 +43,7 @@ public static function all(): array new Capability('observability', 'firefly/observability', 'MeterRegistry with Prometheus text exposition'), new Capability('admin', 'firefly/admin', 'Server-rendered dashboard over the actuator (a Spring Boot Admin analog)'), new Capability('openapi', 'firefly/openapi', 'OpenAPI 3.1 document generated from the route and constraint manifests, plus a viewer'), + new Capability('feature-flags', 'firefly/feature-flags', 'Feature flags on OpenFeature: flagd documents, targeting and percentage rollouts, a writable store, admin and actuator controls'), new Capability('eda-kafka', 'firefly/eda-kafka', 'Kafka publisher/consumer over ext-rdkafka', requires: ['eda'], adapter: true), new Capability('eda-rabbitmq', 'firefly/eda-rabbitmq', 'RabbitMQ publisher/consumer over php-amqplib', requires: ['eda'], adapter: true), diff --git a/packages/kernel/src/Version.php b/packages/kernel/src/Version.php index 1e773643..69323327 100644 --- a/packages/kernel/src/Version.php +++ b/packages/kernel/src/Version.php @@ -15,5 +15,5 @@ */ final class Version { - public const string VERSION = '26.09.11'; + public const string VERSION = '26.10.1'; } diff --git a/packages/observability/cache/firefly-observability-components.php b/packages/observability/cache/firefly-observability-components.php index 275c550a..445874de 100644 --- a/packages/observability/cache/firefly-observability-components.php +++ b/packages/observability/cache/firefly-observability-components.php @@ -181,6 +181,18 @@ ], ], 6 => [ + 'method' => 'featureFlagMetrics', + 'returns' => 'Firefly\\FeatureFlags\\Telemetry\\FeatureFlagMetrics', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Observability\\Metrics\\MetricsRecorder', + ], + ], + 7 => [ 'method' => 'observabilityMethodInterceptor', 'returns' => 'Firefly\\Observability\\Method\\ObservabilityMethodInterceptor', 'name' => null, @@ -194,7 +206,7 @@ 2 => 'Firefly\\Config\\Config', ], ], - 7 => [ + 8 => [ 'method' => 'cqrsTracing', 'returns' => 'Firefly\\Cqrs\\Tracing\\CqrsTracing', 'name' => null, @@ -206,7 +218,7 @@ 0 => 'Firefly\\Observability\\Tracing\\Tracer', ], ], - 8 => [ + 9 => [ 'method' => 'edaTracing', 'returns' => 'Firefly\\Eda\\Tracing\\EdaTracing', 'name' => null, diff --git a/packages/observability/cache/firefly-observability-context.php b/packages/observability/cache/firefly-observability-context.php index 3ce0a240..a5a3a133 100644 --- a/packages/observability/cache/firefly-observability-context.php +++ b/packages/observability/cache/firefly-observability-context.php @@ -141,6 +141,33 @@ ], ], 6 => [ + 'method' => 'featureFlagMetrics', + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnMissingBean', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Telemetry\\FeatureFlagMetrics', + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.observability.metrics.enabled', + 1 => 'true', + 2 => true, + ], + ], + 2 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + ], + ], + 7 => [ 'method' => 'observabilityMethodInterceptor', 'conditions' => [ 0 => [ @@ -167,7 +194,7 @@ ], ], ], - 7 => [ + 8 => [ 'method' => 'cqrsTracing', 'conditions' => [ 0 => [ @@ -194,7 +221,7 @@ ], ], ], - 8 => [ + 9 => [ 'method' => 'edaTracing', 'conditions' => [ 0 => [ diff --git a/packages/observability/composer.json b/packages/observability/composer.json index 9a3ee01d..7eb26aeb 100644 --- a/packages/observability/composer.json +++ b/packages/observability/composer.json @@ -30,6 +30,7 @@ "firefly/cqrs": "*@dev", "firefly/data": "*@dev", "firefly/eda": "*@dev", + "firefly/feature-flags": "*@dev", "firefly/kernel": "*@dev", "firefly/resilience": "*@dev", "firefly/web": "*@dev", diff --git a/packages/observability/src/FeatureFlags/MeterRegistryFeatureFlagMetrics.php b/packages/observability/src/FeatureFlags/MeterRegistryFeatureFlagMetrics.php new file mode 100644 index 00000000..7f28611b --- /dev/null +++ b/packages/observability/src/FeatureFlags/MeterRegistryFeatureFlagMetrics.php @@ -0,0 +1,22 @@ +recorder->increment('feature_flag_evaluations_total', ['flag' => $flag, 'variant' => $variant, 'reason' => $reason]); + } +} diff --git a/packages/observability/src/ObservabilityAutoConfiguration.php b/packages/observability/src/ObservabilityAutoConfiguration.php index b2b809eb..2be57f9c 100644 --- a/packages/observability/src/ObservabilityAutoConfiguration.php +++ b/packages/observability/src/ObservabilityAutoConfiguration.php @@ -13,9 +13,11 @@ use Firefly\Cqrs\Metrics\CqrsMetrics; use Firefly\Cqrs\Tracing\CqrsTracing; use Firefly\Eda\Tracing\EdaTracing; +use Firefly\FeatureFlags\Telemetry\FeatureFlagMetrics; use Firefly\Observability\Cqrs\MeterRegistryCqrsMetrics; use Firefly\Observability\Cqrs\TracerCqrsTracing; use Firefly\Observability\Eda\TracerEdaTracing; +use Firefly\Observability\FeatureFlags\MeterRegistryFeatureFlagMetrics; use Firefly\Observability\HttpExchanges\CacheHttpExchangeRecorder; use Firefly\Observability\HttpExchanges\HttpExchangeCapacity; use Firefly\Observability\HttpExchanges\HttpExchangeRecorder; @@ -175,6 +177,21 @@ public function cqrsMetrics(MetricsRecorder $recorder): CqrsMetrics return new MeterRegistryCqrsMetrics($recorder); } + /** + * Backs firefly/feature-flags' FeatureFlagMetrics port. #[Order(500)] registers it before + * FeatureFlagsAutoConfiguration's #[Order(700)] NoOp, which then backs off — the cqrsMetrics() precedence. + * Gated on the metrics master switch like every meter here, and on firefly.feature-flags.enabled: with flags + * off, firefly/feature-flags registers nothing and this meter would have nothing to count. + */ + #[Bean] + #[ConditionalOnMissingBean(FeatureFlagMetrics::class)] + #[ConditionalOnProperty(name: 'firefly.observability.metrics.enabled', havingValue: 'true', matchIfMissing: true)] + #[ConditionalOnProperty(name: 'firefly.feature-flags.enabled', havingValue: 'true')] + public function featureFlagMetrics(MetricsRecorder $recorder): FeatureFlagMetrics + { + return new MeterRegistryFeatureFlagMetrics($recorder); + } + /** * The chain link behind #[Timed]/#[Counted]/#[Observed]. Gated on the metrics master switch AND on * `firefly.observability.method.enabled` (default true): an attribute is itself the opt-in, so the key diff --git a/packages/observability/tests/FeatureFlags/MeterRegistryFeatureFlagMetricsTest.php b/packages/observability/tests/FeatureFlags/MeterRegistryFeatureFlagMetricsTest.php new file mode 100644 index 00000000..0f955172 --- /dev/null +++ b/packages/observability/tests/FeatureFlags/MeterRegistryFeatureFlagMetricsTest.php @@ -0,0 +1,78 @@ + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider)); + +it('counts evaluations as feature_flag_evaluations_total{flag, variant, reason}', function (): void { + $recorder = new class implements MetricsRecorder + { + /** @var list, float}> */ + public array $increments = []; + + public function increment(string $name, array $tags = [], float $amount = 1.0): void + { + $this->increments[] = [$name, $tags, $amount]; + } + + public function record(string $name, array $tags = [], float $seconds = 0.0): void {} + + public function setGauge(string $name, array $tags, float $value): void {} + }; + + (new MeterRegistryFeatureFlagMetrics($recorder))->recordEvaluation('new-checkout', 'on', 'TARGETING_MATCH'); + + expect($recorder->increments)->toBe([['feature_flag_evaluations_total', ['flag' => 'new-checkout', 'variant' => 'on', 'reason' => 'TARGETING_MATCH'], 1.0]]); +}); + +it('accumulates one counter per flag, variant and reason on the MeterRegistry', function (): void { + $registry = new SimpleMeterRegistry; + $metrics = new MeterRegistryFeatureFlagMetrics($registry); + + $metrics->recordEvaluation('new-checkout', 'on', 'STATIC'); + $metrics->recordEvaluation('new-checkout', 'on', 'STATIC'); + $metrics->recordEvaluation('new-checkout', 'none', 'ERROR'); + + expect($registry->counter('feature_flag_evaluations_total', ['flag' => 'new-checkout', 'variant' => 'on', 'reason' => 'STATIC'])->count())->toBe(2.0) + ->and($registry->counter('feature_flag_evaluations_total', ['flag' => 'new-checkout', 'variant' => 'none', 'reason' => 'ERROR'])->count())->toBe(1.0); +}); + +it('replaces the feature-flags NoOp when observability is installed', function (): void { + $context = bootFireflyApp( + ['firefly' => ['feature-flags' => ['enabled' => true]]], + [ObservabilityServiceProvider::class, FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + needs: ['cache'], + ); + + expect($context->get(FeatureFlagMetrics::class))->toBeInstanceOf(MeterRegistryFeatureFlagMetrics::class); +}); + +it('backs the port with no meter while feature flags are off or metrics are disabled', function (): void { + $meter = static fn (ApplicationContext $context): ?object => $context->has(FeatureFlagMetrics::class) ? $context->get(FeatureFlagMetrics::class) : null; + $flagsOff = bootFireflyApp( + [], + [ObservabilityServiceProvider::class, FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + needs: ['cache'], + ); + $metricsOff = bootFireflyApp( + ['firefly' => ['feature-flags' => ['enabled' => true], 'observability' => ['metrics' => ['enabled' => false]]]], + [ObservabilityServiceProvider::class, FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + needs: ['cache'], + ); + + // With flags off nothing of firefly/feature-flags is registered; with metrics off the package's NoOp (T11) may + // stand in — never the MeterRegistry-backed meter. + expect($meter($flagsOff))->toBeNull() + ->and($meter($metricsOff))->not->toBeInstanceOf(MeterRegistryFeatureFlagMetrics::class); +}); diff --git a/packages/security/cache/firefly-security-components.php b/packages/security/cache/firefly-security-components.php index 9a007376..1db5cbe0 100644 --- a/packages/security/cache/firefly-security-components.php +++ b/packages/security/cache/firefly-security-components.php @@ -53,6 +53,41 @@ ], ], 2 => [ + 'class' => 'Firefly\\Security\\FeatureFlags\\PrincipalEvaluationContextContributor', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => -100, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\FeatureFlags\\Context\\EvaluationContextContributor', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + 0 => 'Firefly\\Config\\Config', + ], + ], + 3 => [ + 'class' => 'Firefly\\Security\\FeatureFlags\\PrincipalFlagActorSource', + 'stereotype' => 'component', + 'name' => null, + 'scope' => 'Singleton', + 'primary' => false, + 'order' => 0, + 'qualifier' => null, + 'interfaces' => [ + 0 => 'Firefly\\FeatureFlags\\Management\\FlagActorSource', + ], + 'beans' => [ + ], + 'lazy' => false, + 'dependencies' => [ + ], + ], + 4 => [ 'class' => 'Firefly\\Security\\OAuth2\\OAuth2ResourceServerFilter', 'stereotype' => 'component', 'name' => null, @@ -71,7 +106,7 @@ 1 => 'Firefly\\Config\\Config', ], ], - 3 => [ + 5 => [ 'class' => 'Firefly\\Security\\OpenApi\\MethodSecurityRequirementContributor', 'stereotype' => 'component', 'name' => null, @@ -90,7 +125,7 @@ 1 => 'Firefly\\Config\\Config', ], ], - 4 => [ + 6 => [ 'class' => 'Firefly\\Security\\SecurityAutoConfiguration', 'stereotype' => 'configuration', 'name' => null, @@ -435,7 +470,7 @@ 'dependencies' => [ ], ], - 5 => [ + 7 => [ 'class' => 'Firefly\\Security\\Session\\SecurityContextPersistenceFilter', 'stereotype' => 'component', 'name' => null, @@ -455,7 +490,7 @@ 2 => 'Firefly\\Config\\Config', ], ], - 6 => [ + 8 => [ 'class' => 'Firefly\\Security\\Web\\Basic\\HttpBasicFilter', 'stereotype' => 'component', 'name' => null, @@ -479,7 +514,7 @@ 6 => 'Firefly\\Config\\Config', ], ], - 7 => [ + 9 => [ 'class' => 'Firefly\\Security\\Web\\CsrfFilter', 'stereotype' => 'component', 'name' => null, @@ -498,7 +533,7 @@ 1 => 'Firefly\\Security\\Web\\Csrf\\SessionCsrf', ], ], - 8 => [ + 10 => [ 'class' => 'Firefly\\Security\\Web\\HttpSecurityFilter', 'stereotype' => 'component', 'name' => null, @@ -523,7 +558,7 @@ 7 => 'Firefly\\Security\\Event\\AuthenticationEventPublisher', ], ], - 9 => [ + 11 => [ 'class' => 'Firefly\\Security\\Web\\JwtAuthenticationFilter', 'stereotype' => 'component', 'name' => null, @@ -542,7 +577,7 @@ 1 => 'Firefly\\Config\\Config', ], ], - 10 => [ + 12 => [ 'class' => 'Firefly\\Security\\Web\\Login\\FormLoginFilter', 'stereotype' => 'component', 'name' => null, @@ -568,7 +603,7 @@ 8 => 'Firefly\\Security\\Web\\RememberMe\\RememberMeServices', ], ], - 11 => [ + 13 => [ 'class' => 'Firefly\\Security\\Web\\Logout\\LogoutFilter', 'stereotype' => 'component', 'name' => null, @@ -591,7 +626,7 @@ 5 => 'Firefly\\Security\\Web\\Logout\\LogoutSuccessHandler', ], ], - 12 => [ + 14 => [ 'class' => 'Firefly\\Security\\Web\\Logout\\LogoutHandler', 'stereotype' => 'component', 'name' => null, @@ -611,7 +646,7 @@ 3 => 'Firefly\\Security\\Web\\RememberMe\\RememberMeServices', ], ], - 13 => [ + 15 => [ 'class' => 'Firefly\\Security\\Web\\RememberMe\\RememberMeAuthenticationFilter', 'stereotype' => 'component', 'name' => null, @@ -633,7 +668,7 @@ 4 => 'Firefly\\Security\\Web\\RememberMe\\RememberMeServices', ], ], - 14 => [ + 16 => [ 'class' => 'Firefly\\Security\\Web\\SecurityHeadersFilter', 'stereotype' => 'component', 'name' => null, diff --git a/packages/security/cache/firefly-security-context.php b/packages/security/cache/firefly-security-context.php index 14ea7ede..799c6d76 100644 --- a/packages/security/cache/firefly-security-context.php +++ b/packages/security/cache/firefly-security-context.php @@ -44,6 +44,68 @@ ], ], 1 => [ + 'class' => 'Firefly\\Security\\FeatureFlags\\PrincipalEvaluationContextContributor', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnClass', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Context\\EvaluationContextContributor', + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.security.enabled', + 1 => 'true', + 2 => false, + ], + ], + 2 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.feature-flags.enabled', + 1 => 'true', + 2 => false, + ], + ], + ], + 'beanConditions' => [ + ], + ], + 2 => [ + 'class' => 'Firefly\\Security\\FeatureFlags\\PrincipalFlagActorSource', + 'postConstruct' => [ + ], + 'preDestroy' => [ + ], + 'listeners' => [ + ], + 'conditions' => [ + 0 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnClass', + 'args' => [ + 0 => 'Firefly\\FeatureFlags\\Management\\FlagActorSource', + ], + ], + 1 => [ + 'type' => 'Firefly\\Context\\Condition\\Attributes\\ConditionalOnProperty', + 'args' => [ + 0 => 'firefly.security.enabled', + 1 => 'true', + 2 => false, + ], + ], + ], + 'beanConditions' => [ + ], + ], + 3 => [ 'class' => 'Firefly\\Security\\OAuth2\\OAuth2ResourceServerFilter', 'postConstruct' => [ ], @@ -64,7 +126,7 @@ 'beanConditions' => [ ], ], - 2 => [ + 4 => [ 'class' => 'Firefly\\Security\\OpenApi\\MethodSecurityRequirementContributor', 'postConstruct' => [ ], @@ -91,7 +153,7 @@ 'beanConditions' => [ ], ], - 3 => [ + 5 => [ 'class' => 'Firefly\\Security\\SecurityAutoConfiguration', 'postConstruct' => [ ], @@ -614,7 +676,7 @@ ], ], ], - 4 => [ + 6 => [ 'class' => 'Firefly\\Security\\Session\\SecurityContextPersistenceFilter', 'postConstruct' => [ ], @@ -635,7 +697,7 @@ 'beanConditions' => [ ], ], - 5 => [ + 7 => [ 'class' => 'Firefly\\Security\\Web\\Basic\\HttpBasicFilter', 'postConstruct' => [ ], @@ -664,7 +726,7 @@ 'beanConditions' => [ ], ], - 6 => [ + 8 => [ 'class' => 'Firefly\\Security\\Web\\CsrfFilter', 'postConstruct' => [ ], @@ -685,7 +747,7 @@ 'beanConditions' => [ ], ], - 7 => [ + 9 => [ 'class' => 'Firefly\\Security\\Web\\HttpSecurityFilter', 'postConstruct' => [ ], @@ -714,7 +776,7 @@ 'beanConditions' => [ ], ], - 8 => [ + 10 => [ 'class' => 'Firefly\\Security\\Web\\JwtAuthenticationFilter', 'postConstruct' => [ ], @@ -735,7 +797,7 @@ 'beanConditions' => [ ], ], - 9 => [ + 11 => [ 'class' => 'Firefly\\Security\\Web\\Login\\FormLoginFilter', 'postConstruct' => [ ], @@ -764,7 +826,7 @@ 'beanConditions' => [ ], ], - 10 => [ + 12 => [ 'class' => 'Firefly\\Security\\Web\\Logout\\LogoutFilter', 'postConstruct' => [ ], @@ -785,7 +847,7 @@ 'beanConditions' => [ ], ], - 11 => [ + 13 => [ 'class' => 'Firefly\\Security\\Web\\Logout\\LogoutHandler', 'postConstruct' => [ ], @@ -806,7 +868,7 @@ 'beanConditions' => [ ], ], - 12 => [ + 14 => [ 'class' => 'Firefly\\Security\\Web\\RememberMe\\RememberMeAuthenticationFilter', 'postConstruct' => [ ], @@ -827,7 +889,7 @@ 'beanConditions' => [ ], ], - 13 => [ + 15 => [ 'class' => 'Firefly\\Security\\Web\\SecurityHeadersFilter', 'postConstruct' => [ ], diff --git a/packages/security/composer.json b/packages/security/composer.json index d1b96eca..e183b188 100644 --- a/packages/security/composer.json +++ b/packages/security/composer.json @@ -46,6 +46,7 @@ }, "suggest": { "firefly/actuator": "The /actuator/health endpoint whose `when-authorized` show-details this package answers (SecurityActuatorAutoConfiguration).", + "firefly/feature-flags": "The feature-flag evaluation context whose targetingKey, roles and tenant this package fills from the principal (PrincipalEvaluationContextContributor), and the actor of a flag write (PrincipalFlagActorSource).", "firefly/openapi": "The OpenAPI document whose per-operation `security` this package fills in from the compiled method-security rules (MethodSecurityRequirementContributor)." }, "extra": { diff --git a/packages/security/src/FeatureFlags/PrincipalEvaluationContextContributor.php b/packages/security/src/FeatureFlags/PrincipalEvaluationContextContributor.php new file mode 100644 index 00000000..38a75eb1 --- /dev/null +++ b/packages/security/src/FeatureFlags/PrincipalEvaluationContextContributor.php @@ -0,0 +1,65 @@ + FeatureFlags: nothing may depend on + * Security. + */ +#[Component] +#[Order(-100)] +#[ConditionalOnClass(EvaluationContextContributor::class)] +#[ConditionalOnProperty(name: 'firefly.security.enabled', havingValue: 'true')] +#[ConditionalOnProperty(name: 'firefly.feature-flags.enabled', havingValue: 'true')] +final class PrincipalEvaluationContextContributor implements EvaluationContextContributor +{ + private const string ROLE_PREFIX = 'ROLE_'; + + public function __construct(private readonly Config $config) {} + + public function contribute(EvaluationContextBuilder $context): void + { + $authentication = SecurityContextHolder::getAuthentication(); + if ($authentication === null || ! $authentication->isAuthenticated()) { + return; + } + + $context->setTargetingKey($authentication->getName()); + + $roles = []; + foreach ($authentication->authorityStrings() as $authority) { + if (str_starts_with($authority, self::ROLE_PREFIX)) { + $roles[] = substr($authority, strlen(self::ROLE_PREFIX)); + } + } + $context->set('roles', $roles); + + $tenant = $authentication->getAttributes()[$this->config->string('firefly.feature-flags.context.tenant-attribute', 'tenant')] ?? null; + if (is_string($tenant) || is_int($tenant)) { + $context->set('tenant', (string) $tenant); + } + } +} diff --git a/packages/security/src/FeatureFlags/PrincipalFlagActorSource.php b/packages/security/src/FeatureFlags/PrincipalFlagActorSource.php new file mode 100644 index 00000000..ef0e4c02 --- /dev/null +++ b/packages/security/src/FeatureFlags/PrincipalFlagActorSource.php @@ -0,0 +1,30 @@ +` — is recorded instead. + */ +#[Component] +#[ConditionalOnClass(FlagActorSource::class)] +#[ConditionalOnProperty(name: 'firefly.security.enabled', havingValue: 'true')] +final class PrincipalFlagActorSource implements FlagActorSource +{ + public function actor(): ?string + { + $authentication = SecurityContextHolder::getAuthentication(); + + return $authentication !== null && $authentication->isAuthenticated() && $authentication->getName() !== '' + ? $authentication->getName() + : null; + } +} diff --git a/packages/security/tests/FeatureFlags/PrincipalEvaluationContextContributorTest.php b/packages/security/tests/FeatureFlags/PrincipalEvaluationContextContributorTest.php new file mode 100644 index 00000000..cde82a5a --- /dev/null +++ b/packages/security/tests/FeatureFlags/PrincipalEvaluationContextContributorTest.php @@ -0,0 +1,143 @@ +setProvider(new NoOpProvider); +}); + +/** @param array $flags */ +function featureFlagsSecurityContributor(array $flags = []): PrincipalEvaluationContextContributor +{ + return new PrincipalEvaluationContextContributor(new Config(new Repository(['firefly' => ['feature-flags' => $flags]]))); +} + +/** @param array $flags */ +function featureFlagsSecurityContext(array $flags = []): EvaluationContextBuilder +{ + $context = new EvaluationContextBuilder; + featureFlagsSecurityContributor($flags)->contribute($context); + + return $context; +} + +/** @param class-string $class */ +function featureFlagsSecurityOrderOf(string $class): int +{ + $orders = (new ReflectionClass($class))->getAttributes(Order::class); + + return $orders === [] ? 0 : $orders[0]->newInstance()->order; +} + +it('contributes the principal as targeting key, roles without ROLE_ and the tenant attribute', function (): void { + SecurityContextHolder::setContext(new SecurityContext(Authentication::authenticated('ada', 'ada', [ + new SimpleGrantedAuthority('ROLE_BETA'), new SimpleGrantedAuthority('ROLE_STAFF'), new SimpleGrantedAuthority('SCOPE_orders:read'), + ], ['tenant' => 'acme', 'org' => 'globex']))); + + $context = featureFlagsSecurityContext(); + + expect($context->targetingKey())->toBe('ada') + ->and($context->get('roles'))->toBe(['BETA', 'STAFF']) + ->and($context->get('tenant'))->toBe('acme'); +}); + +it('reads the tenant from the attribute firefly.feature-flags.context.tenant-attribute names', function (): void { + SecurityContextHolder::setContext(new SecurityContext(Authentication::authenticated('ada', 'ada', [], ['org' => 'globex']))); + + expect(featureFlagsSecurityContext(['context' => ['tenant-attribute' => 'org']])->get('tenant'))->toBe('globex'); +}); + +it('reads an int tenant as text and leaves the tenant out when the principal has none', function (): void { + SecurityContextHolder::setContext(new SecurityContext(Authentication::authenticated('ada', 'ada', [], ['tenant' => 7]))); + $numbered = featureFlagsSecurityContext(); + + SecurityContextHolder::setContext(new SecurityContext(Authentication::authenticated('grace', 'grace', [], ['tenant' => ['acme']]))); + $listed = featureFlagsSecurityContext(); + + SecurityContextHolder::setContext(new SecurityContext(Authentication::authenticated('linus', 'linus', []))); + $none = featureFlagsSecurityContext(); + + expect($numbered->get('tenant'))->toBe('7') + ->and($listed->has('tenant'))->toBeFalse() + ->and($none->has('tenant'))->toBeFalse() + ->and($none->targetingKey())->toBe('linus') + ->and($none->attributes())->toBe(['roles' => []]); +}); + +it('contributes nothing for anonymous traffic', function (): void { + $context = featureFlagsSecurityContext(); + + expect($context->targetingKey())->toBeNull() + ->and($context->attributes())->toBe([]); +}); + +it('contributes nothing for a token that is not authenticated', function (): void { + SecurityContextHolder::setContext(new SecurityContext(Authentication::unauthenticated('ada', 'ada', 'secret'))); + + $context = featureFlagsSecurityContext(); + + expect($context->targetingKey())->toBeNull() + ->and($context->attributes())->toBe([]); +}); + +it('runs after the process attributes and before the application\'s contributors, which may override it', function (): void { + SecurityContextHolder::setContext(new SecurityContext(Authentication::authenticated('ada', 'ada', [new SimpleGrantedAuthority('ROLE_BETA')], ['tenant' => 'acme']))); + $application = new class implements EvaluationContextContributor + { + public function contribute(EvaluationContextBuilder $context): void + { + $context->set('tenant', 'acme-eu'); + } + }; + $contributors = [$application, featureFlagsSecurityContributor()]; + // Container::getAll() sorts the collected contributors by #[Order] ascending, a missing #[Order] being 0. + usort($contributors, static fn (object $a, object $b): int => featureFlagsSecurityOrderOf($a::class) <=> featureFlagsSecurityOrderOf($b::class)); + + $resolved = (new EvaluationContextResolver($contributors))->resolve(); + + expect(featureFlagsSecurityOrderOf(PrincipalEvaluationContextContributor::class))->toBe(-100) + ->and(featureFlagsSecurityOrderOf(ApplicationEvaluationContextContributor::class))->toBeLessThan(-100) + ->and($resolved->getTargetingKey())->toBe('ada') + ->and($resolved->getAttributes()->toArray())->toBe(['roles' => ['BETA'], 'tenant' => 'acme-eu']); +}); + +it('is collected after the process attributes only while security and feature flags are both on', function (): void { + $contributors = static function (bool $security, bool $flags): array { + $context = bootFireflyApp( + ['firefly' => ['cqrs' => [], 'security' => ['enabled' => $security], 'feature-flags' => ['enabled' => $flags]]], + [CqrsServiceProvider::class, CqrsWiringProvider::class, SecurityServiceProvider::class, SecurityWiringProvider::class, FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + needs: ['cache'], + ); + + return array_map(static fn (object $contributor): string => $contributor::class, $context->getAll(EvaluationContextContributor::class)); + }; + + expect($contributors(true, true))->toBe([ApplicationEvaluationContextContributor::class, PrincipalEvaluationContextContributor::class]) + ->and($contributors(false, true))->toBe([ApplicationEvaluationContextContributor::class]) + ->and($contributors(true, false))->toBe([]); +}); diff --git a/packages/security/tests/FeatureFlags/PrincipalFlagActorSourceTest.php b/packages/security/tests/FeatureFlags/PrincipalFlagActorSourceTest.php new file mode 100644 index 00000000..22fda3c2 --- /dev/null +++ b/packages/security/tests/FeatureFlags/PrincipalFlagActorSourceTest.php @@ -0,0 +1,21 @@ + SecurityContextHolder::clearContext()); + +it('names the authenticated principal and nobody otherwise', function (): void { + $anonymous = (new PrincipalFlagActorSource)->actor(); + SecurityContextHolder::setContext(new SecurityContext(Authentication::authenticated('ada', 'ada', []))); + + expect($anonymous)->toBeNull() + ->and((new PrincipalFlagActorSource)->actor())->toBe('ada'); +}); diff --git a/packages/testing/composer.json b/packages/testing/composer.json index 1f4363b4..4752b8fc 100644 --- a/packages/testing/composer.json +++ b/packages/testing/composer.json @@ -31,6 +31,7 @@ "firefly/data": "*@dev", "firefly/domain": "*@dev", "firefly/eda": "*@dev", + "firefly/feature-flags": "*@dev", "firefly/kernel": "*@dev", "firefly/messaging": "*@dev", "firefly/observability": "*@dev", diff --git a/packages/testing/src/FeatureFlags/FeatureFlagOverrides.php b/packages/testing/src/FeatureFlags/FeatureFlagOverrides.php new file mode 100644 index 00000000..3c33e587 --- /dev/null +++ b/packages/testing/src/FeatureFlags/FeatureFlagOverrides.php @@ -0,0 +1,99 @@ + */ + private array $flags = []; + + public function __construct(private readonly FlagRegistry $registry) {} + + /** @param array $flags */ + public static function install(array $flags, ?ContainerContract $app = null): self + { + $app ??= Container::getInstance(); + if (! $app->bound(FlagRegistry::class)) { + throw new LogicException('withFeatureFlags() requires a running flag registry: set firefly.feature-flags.enabled to true in the test configuration. An application with its own OpenFeature provider must stub that provider instead.'); + } + + /** @var FlagRegistry $registry */ + $registry = $app->make(FlagRegistry::class); + $current = $app->bound(self::class) ? $app->make(self::class) : null; + $overrides = $current instanceof self && $current->registry === $registry ? $current : new self($registry); + $overrides->merge($flags); + $app->instance(self::class, $overrides); + + return $overrides; + } + + /** @param array $flags */ + public function merge(array $flags): self + { + $candidate = $this->flags; + foreach ($flags as $key => $definition) { + $candidate[$key] = $definition; + } + + return $this->apply($candidate); + } + + public function set(string $key, mixed $definition): self + { + return $this->merge([$key => $definition]); + } + + public function forget(string $key): self + { + $flags = $this->flags; + unset($flags[$key]); + + return $this->apply($flags); + } + + public function clear(): void + { + $this->apply([]); + } + + /** @return array */ + public function flags(): array + { + /** @var array $snapshot */ + $snapshot = ValueCopy::of($this->flags); + + return $snapshot; + } + + /** @param array $flags */ + private function apply(array $flags): self + { + if ($flags !== []) { + // The contract's definition depth limit must reject invalid input before snapshot copying. + FlagDefinitions::parseDocument([ + 'flags' => Json::object(FlagDefinitions::normalize($flags)), + ]); + } + + /** @var array $owned */ + $owned = ValueCopy::of($flags); + $document = $owned === [] ? null : FlagDefinitions::parseDocument([ + 'flags' => Json::object(FlagDefinitions::normalize($owned)), + ]); + $this->registry->overrideForTests($document); + $this->flags = $owned; + + return $this; + } +} diff --git a/packages/testing/src/functions.php b/packages/testing/src/functions.php index b8cb0b6d..0e1d49cd 100644 --- a/packages/testing/src/functions.php +++ b/packages/testing/src/functions.php @@ -5,6 +5,7 @@ use Firefly\AutoConfigure\FireflyAutoConfigureServiceProvider; use Firefly\Context\Boot\ApplicationContext; use Firefly\Testing\Double\RecordingEventPublisher; +use Firefly\Testing\FeatureFlags\FeatureFlagOverrides; use Firefly\Validation\IlluminateValidator; use Firefly\Validation\Validator; use Illuminate\Cache\ArrayStore; @@ -191,3 +192,11 @@ function fireflyConfigFor(object $container, string $prefix = 'database.connecti return $config; } } + +if (! function_exists('withFeatureFlags')) { + /** @param array $flags */ + function withFeatureFlags(array $flags): FeatureFlagOverrides + { + return FeatureFlagOverrides::install($flags); + } +} diff --git a/packages/testing/tests/FeatureFlags/FeatureFlagOverridesTest.php b/packages/testing/tests/FeatureFlags/FeatureFlagOverridesTest.php new file mode 100644 index 00000000..dd4bac36 --- /dev/null +++ b/packages/testing/tests/FeatureFlags/FeatureFlagOverridesTest.php @@ -0,0 +1,185 @@ + OpenFeatureAPI::getInstance()->setProvider(new NoOpProvider)); + +/** @param array $featureFlags */ +function featureFlagsTestingApp(array $featureFlags): Application +{ + return fireflyApplication( + ['firefly' => ['feature-flags' => $featureFlags]], + [FeatureFlagsServiceProvider::class, FeatureFlagsWiringProvider::class], + [LoggerInterface::class => new NullLogger], + needs: ['cache'], + ); +} + +it('overrides the running application with shorthand and full definitions until cleared', function (): void { + $app = featureFlagsTestingApp(['enabled' => true, 'flags' => ['new-checkout' => false]]); + /** @var FeatureFlags $flags */ + $flags = $app->make(FeatureFlags::class); + /** @var FlagRegistry $registry */ + $registry = $app->make(FlagRegistry::class); + + $overrides = withFeatureFlags(['new-checkout' => true, 'checkout-flow' => 'v2']); + expect($flags->isEnabled('new-checkout'))->toBeTrue() + ->and($flags->getString('checkout-flow', 'v1'))->toBe('v2'); + + $overrides->set('full', ['state' => 'ENABLED', 'variants' => ['ready' => 'ready'], 'defaultVariant' => 'ready']); + expect($flags->getString('full', 'missing'))->toBe('ready') + ->and($registry->composition()->flag('new-checkout')?->origin)->toBe('test-overrides') + ->and($registry->composition(false)->flag('new-checkout')?->origin)->toBe('config'); + + $overrides->forget('new-checkout'); + expect($flags->isEnabled('new-checkout'))->toBeFalse() + ->and($overrides->flags())->toHaveKeys(['checkout-flow', 'full']); + + $overrides->clear(); + expect($flags->getString('checkout-flow', 'v1'))->toBe('v1') + ->and($registry->testOverrides())->toBeNull() + ->and($overrides->flags())->toBe([]); +}); + +it('keeps every override for the rest of the test across calls', function (): void { + $app = featureFlagsTestingApp(['enabled' => true]); + /** @var FlagRegistry $registry */ + $registry = $app->make(FlagRegistry::class); + + $first = withFeatureFlags(['first' => true]); + $second = withFeatureFlags(['second' => 'v2']); + + expect($second)->toBe($first) + ->and($first->flags())->toBe(['first' => true, 'second' => 'v2']) + ->and($registry->document()->flag('first'))->not->toBeNull() + ->and($registry->document()->flag('second'))->not->toBeNull(); +}); + +it('rejects invalid changes without losing the last valid override set', function (): void { + $app = featureFlagsTestingApp(['enabled' => true]); + /** @var FlagRegistry $registry */ + $registry = $app->make(FlagRegistry::class); + $overrides = withFeatureFlags(['valid' => true]); + $before = $registry->testOverrides(); + + expect(fn () => $overrides->merge(['also-valid' => false, 'bad key' => true])) + ->toThrow(InvalidFlagDefinition::class, 'invalid flag key'); + expect($overrides->flags())->toBe(['valid' => true]) + ->and($registry->testOverrides())->toBe($before) + ->and($registry->document()->flag('also-valid'))->toBeNull(); + + expect(fn () => $overrides->set('valid', 7))->toThrow(InvalidFlagDefinition::class); + expect($overrides->flags())->toBe(['valid' => true]); +}); + +it('reports the contract error for definitions deeper than the snapshot limit without changing overrides', function (): void { + $app = featureFlagsTestingApp(['enabled' => true]); + /** @var FlagRegistry $registry */ + $registry = $app->make(FlagRegistry::class); + $overrides = withFeatureFlags(['valid' => true]); + $before = $registry->testOverrides(); + + $nested = []; + for ($level = 0; $level < 520; $level++) { + $nested = ['child' => $nested]; + } + $definition = ['state' => 'ENABLED', 'variants' => ['v' => $nested], 'defaultVariant' => 'v']; + + $error = null; + try { + $overrides->set('deep', $definition); + } catch (InvalidFlagDefinition $caught) { + $error = $caught; + } + + expect($error)->toBeInstanceOf(InvalidFlagDefinition::class) + ->and($error?->flagKey())->toBe('deep') + ->and($error?->reason())->toBe('definition nests too deeply') + ->and($overrides->flags())->toBe(['valid' => true]) + ->and($registry->testOverrides())->toBe($before) + ->and($registry->document()->flag('valid'))->not->toBeNull() + ->and($registry->document()->flag('deep'))->toBeNull(); +}); + +it('owns caller definitions and getter snapshots across unrelated merges', function (): void { + $app = featureFlagsTestingApp(['enabled' => true]); + /** @var FlagRegistry $registry */ + $registry = $app->make(FlagRegistry::class); + $definition = (object) ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'on']; + $overrides = withFeatureFlags(['a' => $definition]); + + $definition->defaultVariant = 'off'; + expect($registry->document()->flag('a')?->defaultVariant())->toBe('on'); + + $snapshot = $overrides->flags(); + $returned = $snapshot['a']; + if (! $returned instanceof stdClass) { + throw new LogicException('Expected the full definition to remain an object.'); + } + $returned->state = 'INVALID'; + $overrides->set('b', true); + + expect($registry->document()->flag('a')?->defaultVariant())->toBe('on') + ->and($registry->document()->flag('a')?->state())->toBe('ENABLED') + ->and(Json::members($overrides->flags()['a'])['state'])->toBe('ENABLED') + ->and($registry->document()->flag('b'))->not->toBeNull(); +}); + +it('owns nested JSON objects without changing their object and list shapes', function (): void { + $app = featureFlagsTestingApp(['enabled' => true]); + /** @var FlagRegistry $registry */ + $registry = $app->make(FlagRegistry::class); + $value = (object) ['empty' => (object) [], 'items' => []]; + $overrides = withFeatureFlags(['object' => ['state' => 'ENABLED', 'variants' => ['v' => $value], 'defaultVariant' => 'v']]); + + $value->empty->changed = true; + $value->items[] = 'changed'; + expect(Json::encode($registry->document()->flag('object')?->variantValue('v')))->toBe('{"empty":{},"items":[]}'); + + $snapshot = $overrides->flags(); + $returned = Json::members(Json::members($snapshot['object'])['variants'])['v']; + if (! $returned instanceof stdClass || ! $returned->empty instanceof stdClass) { + throw new LogicException('Expected the nested variant and empty member to remain objects.'); + } + $returned->empty->changed = true; + $overrides->set('other', false); + + expect(Json::encode($registry->document()->flag('object')?->variantValue('v')))->toBe('{"empty":{},"items":[]}') + ->and(Json::encode(Json::members(Json::members($overrides->flags()['object'])['variants'])['v']))->toBe('{"empty":{},"items":[]}'); +}); + +it('isolates overrides between application registries', function (): void { + $firstApp = featureFlagsTestingApp(['enabled' => true]); + $first = FeatureFlagOverrides::install(['first' => true], $firstApp); + $secondApp = featureFlagsTestingApp(['enabled' => true]); + $second = FeatureFlagOverrides::install(['second' => true], $secondApp); + /** @var FlagRegistry $firstRegistry */ + $firstRegistry = $firstApp->make(FlagRegistry::class); + /** @var FlagRegistry $secondRegistry */ + $secondRegistry = $secondApp->make(FlagRegistry::class); + + expect($second)->not->toBe($first) + ->and($firstRegistry->document()->flag('first'))->not->toBeNull() + ->and($firstRegistry->document()->flag('second'))->toBeNull() + ->and($secondRegistry->document()->flag('second'))->not->toBeNull() + ->and($secondRegistry->document()->flag('first'))->toBeNull(); +}); + +it('explains when the application has no flag registry', function (): void { + featureFlagsTestingApp(['enabled' => false]); + + expect(fn () => withFeatureFlags(['a' => true]))->toThrow(LogicException::class, 'firefly.feature-flags.enabled'); +}); diff --git a/samples/lumen/README.md b/samples/lumen/README.md index 40b0c50e..9adf6fa0 100644 --- a/samples/lumen/README.md +++ b/samples/lumen/README.md @@ -68,6 +68,7 @@ HTTP endpoints — none of which contain business logic of their own. | POST | `/api/v1/wallets/transfers` | Transfer funds between two wallets | | GET | `/api/v1/wallets/{id}/balance` | Fetch just the balance | | GET | `/api/v1/wallets/{id}/ledger` | Fetch the wallet's projected ledger rows | +| GET | `/api/v1/wallet-rollout` | Gated rollout probe; 404 while dark | Amounts are in **minor units** (cents): `1500` means €15.00 for an EUR wallet. The withdraw endpoint requires an authenticated principal carrying @@ -77,6 +78,14 @@ principal at all it renders a 401 `application/problem+json` response signed in but lacks the role a 403 (`ACCESS_DENIED`). The endpoint is secured, not broken. +The optional feature-flag example keeps `wallet-balance-v2` off and `wallet-checkout-view` at `legacy` in the +test harness. `WalletRollout` reads both through the real `FeatureFlags` client; a separate route carries +`#[FeatureFlag('wallet-rollout-route')]` and fails closed; `WalletRolloutGate` demonstrates a proxied +method fallback. The focused tests flip overrides, consume the shared +LaraFly/PyFly `pro-reports` targeting vectors, prove a database store's version conflict and outer transaction +rollback/commit, and check that a preview emits no exposure event. See the feature-flags chapter in both book +editions and the [module guide](../../docs/modules/feature-flags.md). + ## Run it ```bash diff --git a/samples/lumen/composer.json b/samples/lumen/composer.json index f4457d1f..45514f68 100644 --- a/samples/lumen/composer.json +++ b/samples/lumen/composer.json @@ -28,6 +28,7 @@ "firefly/data": "*@dev", "firefly/domain": "*@dev", "firefly/eda": "*@dev", + "firefly/feature-flags": "*@dev", "firefly/kernel": "*@dev", "firefly/security": "*@dev", "firefly/validation": "*@dev", diff --git a/samples/lumen/src/Application/WalletRollout.php b/samples/lumen/src/Application/WalletRollout.php new file mode 100644 index 00000000..c8395c61 --- /dev/null +++ b/samples/lumen/src/Application/WalletRollout.php @@ -0,0 +1,25 @@ +flags->isEnabled('wallet-balance-v2') ? 'Available balance' : 'Balance'; + } + + public function checkoutView(): string + { + return match ($this->flags->getString('wallet-checkout-view', 'legacy')) { + 'v2' => 'wallet.v2', + default => 'wallet.legacy', + }; + } +} diff --git a/samples/lumen/src/Application/WalletRolloutGate.php b/samples/lumen/src/Application/WalletRolloutGate.php new file mode 100644 index 00000000..6047242e --- /dev/null +++ b/samples/lumen/src/Application/WalletRolloutGate.php @@ -0,0 +1,23 @@ + 'Available balance']; + } +} diff --git a/samples/lumen/tests/FeatureFlags/WalletRolloutTest.php b/samples/lumen/tests/FeatureFlags/WalletRolloutTest.php new file mode 100644 index 00000000..e079bb7d --- /dev/null +++ b/samples/lumen/tests/FeatureFlags/WalletRolloutTest.php @@ -0,0 +1,149 @@ +fireflyContext()->get(FeatureFlags::class); + if (! $flags instanceof FeatureFlags) { + throw new LogicException('The Lumen sample did not boot feature flags.'); + } + $rollout = new WalletRollout($flags); + + expect($rollout->balanceLabel())->toBe('Balance') + ->and($rollout->checkoutView())->toBe('wallet.legacy'); + + /** @var FeatureFlagOverrides $overrides */ + $overrides = withFeatureFlags([ + 'wallet-balance-v2' => true, + 'wallet-checkout-view' => [ + 'state' => 'ENABLED', + 'variants' => ['legacy' => 'legacy', 'v2' => 'v2'], + 'defaultVariant' => 'v2', + ], + ]); + expect($rollout->balanceLabel())->toBe('Available balance') + ->and($rollout->checkoutView())->toBe('wallet.v2') + ->and($flags->details('wallet-checkout-view', 'legacy')->variant)->toBe('v2'); + + $overrides->clear(); + expect($rollout->balanceLabel())->toBe('Balance') + ->and($rollout->checkoutView())->toBe('wallet.legacy'); +})->group('lumen'); + +it('targets the pro cohort from the shared LaraFly and PyFly vectors', function (): void { + /** @var LumenTestCase $this */ + $flags = $this->fireflyContext()->get(FeatureFlags::class); + if (! $flags instanceof FeatureFlags) { + throw new LogicException('The Lumen sample did not boot feature flags.'); + } + $vectors = json_decode((string) file_get_contents(dirname(__DIR__, 4).'/packages/feature-flags/tests/Conformance/firefly-vectors.json'), true, flags: JSON_THROW_ON_ERROR); + if (! is_array($vectors) || ! isset($vectors['cases']) || ! is_array($vectors['cases'])) { + throw new LogicException('The shared flag vectors have no cases.'); + } + /** @var list}}, context: array, targetingKey: string, expect: array{value: bool, variant: string, reason: string}}> $cases */ + $cases = array_values(array_filter($vectors['cases'], static fn (mixed $case): bool => is_array($case) && in_array($case['name'] ?? null, ['a pro plan is entitled', 'a free plan is not entitled'], true))); + expect($cases)->toHaveCount(2); + + withFeatureFlags(['pro-reports' => $cases[0]['document']['flags']['pro-reports']]); + foreach ($cases as $case) { + $result = $flags->details('pro-reports', false, $case['context'], $case['targetingKey']); + expect($result->value)->toBe($case['expect']['value']) + ->and($result->variant)->toBe($case['expect']['variant']) + ->and($result->reason)->toBe($case['expect']['reason']); + } +})->group('lumen'); + +it('keeps a gated rollout route closed until the override opens it', function (): void { + /** @var LumenTestCase $this */ + $this->get('/api/v1/wallet-rollout')->assertNotFound(); + + $overrides = withFeatureFlags(['wallet-rollout-route' => true]); + $this->get('/api/v1/wallet-rollout')->assertOk()->assertJson(['label' => 'Available balance']); + $overrides->clear(); + + $this->get('/api/v1/wallet-rollout')->assertNotFound(); +})->group('lumen'); + +it('uses the declared method fallback while a wallet feature is dark', function (): void { + /** @var LumenTestCase $this */ + $gate = $this->fireflyContext()->get(WalletRolloutGate::class); + if (! $gate instanceof WalletRolloutGate) { + throw new LogicException('The Lumen sample did not boot the rollout gate.'); + } + expect($gate->label('Ada'))->toBe('Hello Ada'); + + $overrides = withFeatureFlags(['wallet-premium-label' => true]); + expect($gate->label('Ada'))->toBe('Welcome back, Ada'); + $overrides->clear(); + expect($gate->label('Ada'))->toBe('Hello Ada'); +})->group('lumen'); + +it('keeps flag and audit writes inside the caller transaction and rejects stale versions', function (): void { + $database = new Manager; + $database->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']); + $connection = $database->getConnection(); + FeatureFlagSchema::create($connection->getSchemaBuilder()); + $store = new DatabaseFlagStore($connection); + $off = ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'off']; + $on = ['state' => 'ENABLED', 'variants' => ['on' => true, 'off' => false], 'defaultVariant' => 'on']; + + $connection->beginTransaction(); + $store->put('wallet-balance-v2', $off, 'release', 0); + expect($store->get('wallet-balance-v2')?->version)->toBe(1) + ->and($store->history('wallet-balance-v2'))->toHaveCount(1); + $connection->rollBack(); + expect($store->get('wallet-balance-v2'))->toBeNull() + ->and($store->history('wallet-balance-v2'))->toBe([]); + + $connection->beginTransaction(); + $store->put('wallet-balance-v2', $off, 'release', 0); + $connection->commit(); + expect(fn () => $store->put('wallet-balance-v2', $on, 'stale', 0))->toThrow(FlagStoreConflict::class) + ->and($store->get('wallet-balance-v2')?->version)->toBe(1) + ->and($store->history('wallet-balance-v2'))->toHaveCount(1); + $store->put('wallet-balance-v2', $on, 'release', 1); + expect($store->get('wallet-balance-v2')?->version)->toBe(2) + ->and($store->history('wallet-balance-v2'))->toHaveCount(2); +})->group('lumen'); + +it('records ordinary exposure but suppresses it for a management preview', function (): void { + /** @var LumenTestCase $this */ + $flags = $this->fireflyContext()->get(FeatureFlags::class); + if (! $flags instanceof FeatureFlags) { + throw new LogicException('The Lumen sample did not boot feature flags.'); + } + $exposures = []; + Event::listen(FeatureFlagEvaluated::class, static function (FeatureFlagEvaluated $event) use (&$exposures): void { + $exposures[] = $event; + }); + + expect($flags->details('wallet-balance-v2', false)->value)->toBeFalse() + ->and($exposures)->toHaveCount(1) + ->and($exposures[0]->key)->toBe('wallet-balance-v2'); + $management = $this->fireflyContext()->get(FlagManagement::class); + if (! $management instanceof FlagManagement) { + throw new LogicException('The Lumen sample did not boot flag management.'); + } + $preview = $management->evaluate('wallet-balance-v2'); + expect($preview['value'])->toBeFalse() + ->and($exposures)->toHaveCount(1); +})->group('lumen'); diff --git a/samples/lumen/tests/LumenTestCase.php b/samples/lumen/tests/LumenTestCase.php index 34ad72c4..0b058bbe 100644 --- a/samples/lumen/tests/LumenTestCase.php +++ b/samples/lumen/tests/LumenTestCase.php @@ -18,6 +18,8 @@ use Firefly\Eda\EdaWiringProvider; use Firefly\Eda\Listener\EventListenerManifest; use Firefly\Eda\Scanner\EventListenerScanner; +use Firefly\FeatureFlags\FeatureFlagsServiceProvider; +use Firefly\FeatureFlags\FeatureFlagsWiringProvider; use Firefly\Security\Access\Method\SecurityMethodManifest; use Firefly\Security\Scanner\MethodSecurityScanner; use Firefly\Security\SecurityServiceProvider; @@ -72,6 +74,8 @@ protected function fireflyProviders(): array CqrsWiringProvider::class, EdaServiceProvider::class, EdaWiringProvider::class, + FeatureFlagsServiceProvider::class, + FeatureFlagsWiringProvider::class, SecurityServiceProvider::class, SecurityWiringProvider::class, ActuatorServiceProvider::class, @@ -93,6 +97,12 @@ protected function configOverrides(): array // Actuator master gate (default true) — expose health,info for later S-tasks' endpoint assertions. 'firefly.management.enabled' => true, 'firefly.management.endpoints.web.exposure.include' => 'health,info', + 'firefly.feature-flags.enabled' => true, + 'firefly.feature-flags.events.evaluations' => true, + 'firefly.feature-flags.flags' => [ + 'wallet-balance-v2' => false, + 'wallet-checkout-view' => 'legacy', + ], // firefly.eda.provider is left UNSET on purpose -> EdaAutoConfiguration binds the REAL InMemoryEventBus // as EventPublisher (the memory default). Never a RecordingEventPublisher double. ]; diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index d0f6e2c6..35dbaad4 100644 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -2,6 +2,8 @@ set -euo pipefail cd "$(dirname "$0")/.." +BREW_PREFIX="$(brew --prefix 2>/dev/null || echo /opt/homebrew)" +export DYLD_FALLBACK_LIBRARY_PATH="${BREW_PREFIX}/lib:/usr/local/lib:${DYLD_FALLBACK_LIBRARY_PATH:-}" book/.venv/bin/python -m pytest -q book/tests book/.venv/bin/python book/build/verify_code.py book/src --require-provenance book/.venv/bin/python book/build/verify_code.py book/src-es --require-provenance diff --git a/skeleton/config/firefly.php b/skeleton/config/firefly.php index 2edebf7d..d312c1c7 100644 --- a/skeleton/config/firefly.php +++ b/skeleton/config/firefly.php @@ -2493,4 +2493,65 @@ // ], ], + /* + |-------------------------------------------------------------------------- + | Feature flags — firefly/feature-flags + |-------------------------------------------------------------------------- + | + | Flags are flagd flag definitions evaluated in-process on OpenFeature, with the semantics PyFly uses. + | Layers, lowest precedence first: `flags` below → a watched file → another service's sync endpoint → + | the writable store. The highest layer that defines a key supplies its WHOLE definition. In `flags` + | (and only there, and in test overrides) a flag may be written as shorthand: `true`, `false`, or a + | string naming a single variant. An invalid definition here refuses the boot, naming the key. + | See docs/modules/feature-flags.md. + | + */ + + 'feature-flags' => [ + 'enabled' => env('FIREFLY_FEATURE_FLAGS_ENABLED', false), + + // 'flags' => [ + // 'new-checkout' => false, + // 'checkout-flow' => [ + // 'state' => 'ENABLED', + // 'variants' => ['control' => 'v1', 'treatment' => 'v2'], + // 'defaultVariant' => 'control', + // 'targeting' => ['fractional' => [['control', 50], ['treatment', 50]]], + // 'metadata' => ['owner' => 'payments', 'kind' => 'experiment', 'expires' => '2099-12-31'], + // ], + // ], + // 'evaluators' => [ + // 'is-beta' => ['in' => ['beta', ['var' => 'roles']]], + // ], + + /* + | Sources beyond `flags`. `refresh-interval` is how often a request may re-check a source (a file's + | mtime and size, the store's latest change id, an HTTP conditional GET); the bookkeeping lives in the + | application cache so PHP-FPM workers share it. Durations: '500ms', '5s', 'PT1M' or seconds. + | The store driver is `database` (run the migration, `--tag=firefly-feature-flags-migrations` to + | publish it) or `memory` (per process — tests and demos only). `connection` null = the default. + */ + // 'sources' => [ + // 'file' => ['enabled' => false, 'path' => '', 'refresh-interval' => '5s'], + // 'http' => ['enabled' => false, 'url' => '', 'token' => env('FIREFLY_FEATURE_FLAGS_HTTP_TOKEN', ''), 'refresh-interval' => '30s', 'timeout' => '2s'], + // 'store' => ['enabled' => false, 'driver' => 'database', 'connection' => null, 'refresh-interval' => '5s'], + // ], + + // The principal attribute that holds the tenant id (the `tenant` evaluation-context attribute). + // 'context' => ['tenant-attribute' => 'tenant'], + + // Status of a #[FeatureFlag]-gated route or method when the flag is off: 404, 403 or 503. + // 'web' => ['disabled-status' => 404], + + // Publish a FeatureFlagEvaluated event per evaluation (exposure records for experiments). + // 'events' => ['evaluations' => false], + + // Allow writes from the admin page, POST /actuator/flags/{key} and php artisan firefly:flags. + // 'management' => ['writes' => false], + + // Serve this application's effective flag set to other services (bearer token required unless + // allow-anonymous is true; enabling it without a token refuses the boot). + // 'server' => ['enabled' => false, 'path' => '/feature-flags/flagd.json', 'token' => env('FIREFLY_FEATURE_FLAGS_SERVER_TOKEN', ''), 'allow-anonymous' => false], + ], + ]; diff --git a/tests/Browser/AdminDashboardTest.php b/tests/Browser/AdminDashboardTest.php index b57ab96c..7093ba7b 100644 --- a/tests/Browser/AdminDashboardTest.php +++ b/tests/Browser/AdminDashboardTest.php @@ -27,6 +27,7 @@ 'configprops' => 'Config properties', 'caches' => 'Caches', 'loggers' => 'Loggers', + 'flags' => 'Feature flags', 'settings' => 'Feature switches', 'datasource' => 'Datasource', 'data' => 'Browse data', diff --git a/tests/Browser/AdminFeatureFlagsAuthTest.php b/tests/Browser/AdminFeatureFlagsAuthTest.php new file mode 100644 index 00000000..44ce58af --- /dev/null +++ b/tests/Browser/AdminFeatureFlagsAuthTest.php @@ -0,0 +1,37 @@ +extend(SignedInBrowserTestCase::class); + +it('requires an admin sign-in and attributes the write to the principal', function (): void { + /** @var SignedInBrowserTestCase $this */ + $page = visit('/firefly/flags?flag=checkout-flow'); + + $page->assertPathIs('/login') + ->fill('username', SecuredBrowserTestCase::ADMIN) + ->fill('password', SecuredBrowserTestCase::ADMIN_PASSWORD) + ->click(SecuredBrowserTestCase::SIGN_IN_BUTTON) + ->assertSee('checkout-flow') + ->press('Save definition') + ->assertSee('Saved [checkout-flow].') + ->assertSeeIn('#flag-history', 'ada') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'flags-authenticated'); +}); + +it('refuses a signed-in user without the admin role', function (): void { + /** @var SignedInBrowserTestCase $this */ + $page = visit('/firefly/flags'); + + $page->assertPathIs('/login') + ->fill('username', SecuredBrowserTestCase::USER) + ->fill('password', SecuredBrowserTestCase::USER_PASSWORD) + ->click(SecuredBrowserTestCase::SIGN_IN_BUTTON) + ->assertSee('403') + ->assertDontSee('beta-banner') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/AdminFeatureFlagsTest.php b/tests/Browser/AdminFeatureFlagsTest.php new file mode 100644 index 00000000..4fdc1523 --- /dev/null +++ b/tests/Browser/AdminFeatureFlagsTest.php @@ -0,0 +1,105 @@ +extend(BrowserTestCase::class); + +final readonly class BrowserPendingFlagsEndpoint implements ActuatorEndpoint +{ + public function __construct(private ActuatorEndpoint $delegate) {} + + public function endpointId(): string + { + return 'flags'; + } + + public function enabled(): bool + { + return true; + } + + public function handle(EndpointRequest $request): ?EndpointResponse + { + return $request->method === 'POST' + ? EndpointResponse::text('{"key":"checkout-flow","refreshPending":true}', 200, 'application/json') + : $this->delegate->handle($request); + } +} + +it('lists flags and toggles one through its form', function (): void { + /** @var BrowserTestCase $this */ + $form = 'form:has(input[name="flag"][value="beta-banner"]):has(input[name="back"])'; + $page = visit('/firefly/flags'); + + $page->assertScript("performance.getEntriesByType('navigation')[0].responseStatus", 200) + ->assertSee('Feature flags')->assertSee('beta-banner')->assertSee('legacy-export')->assertSee('expired') + ->assertNoJavaScriptErrors()->screenshot(filename: 'flags'); + + $page->click($form.' button')->assertSee('Disabled [beta-banner].') + ->assertSeeIn($form.' button', 'Enable')->assertNoJavaScriptErrors(); +}); + +it('edits a definition, evaluates it and shows trusted audit history', function (): void { + /** @var BrowserTestCase $this */ + $page = visit('/firefly/flags?flag=checkout-flow'); + + $page->assertSee('checkout-flow')->assertSee('Save definition')->assertNoJavaScriptErrors(); + + $page->fill('#flag-definition', '{"state":"ENABLED","variants":{"v1":"v1","v2":"v2"},"defaultVariant":"v2","metadata":{"owner":"payments","expires":"2099-12-31"}}') + ->press('Save definition')->assertSee('Saved [checkout-flow].') + ->assertSeeIn('#flag-history', 'admin')->assertNoJavaScriptErrors()->screenshot(filename: 'flags-edited'); + + $page->fill('#flag-context', '{"plan":"basic"}')->fill('#flag-targeting-key', 'u-1') + ->press('Evaluate')->assertSeeIn('[data-evaluation="value"]', '"v2"') + ->assertSeeIn('[data-evaluation="reason"]', 'STATIC')->assertNoJavaScriptErrors(); +}); + +it('keeps flag controls reachable on a phone', function (): void { + /** @var BrowserTestCase $this */ + $page = visit('/firefly/flags?flag=checkout-flow')->on()->mobile() + ->assertSee('Feature flags')->assertSee('Save definition')->assertSee('Evaluate') + ->assertNoJavaScriptErrors()->screenshot(filename: 'flags-mobile'); + + $page->script("document.documentElement.style.zoom = '200%'"); + $page->keys('#flag-context', 'Tab') + ->assertScript('document.activeElement.id', 'flag-targeting-key') + ->keys('#flag-targeting-key', 'Tab') + ->assertScript('document.activeElement.textContent.trim()', 'Evaluate') + ->assertNoJavaScriptErrors()->screenshot(filename: 'flags-mobile-zoom'); +}); + +it('shows the pending refresh receipt after a browser form submission', function (): void { + /** @var BrowserTestCase $this */ + /** @var ActuatorRegistry $registry */ + $registry = $this->app()->make(ActuatorRegistry::class); + $delegate = $registry->get('flags') ?? throw new LogicException('The flags endpoint is not registered.'); + $registry->register(new BrowserPendingFlagsEndpoint($delegate)); + + visit('/firefly/flags?flag=checkout-flow')->press('Save definition') + ->assertSee('Write accepted for [checkout-flow]. Refresh pending; check this page again for visibility.') + ->assertDontSee('Deleted [checkout-flow].') + ->assertNoJavaScriptErrors()->screenshot(filename: 'flags-refresh-pending'); +}); + +it('changes the default, refuses a stale edit and deletes the stored override', function (): void { + /** @var BrowserTestCase $this */ + $page = visit('/firefly/flags?flag=checkout-flow'); + + $page->select('variant', 'v2')->press('Set default') + ->assertSee('Set the default variant of [checkout-flow].') + ->assertSee('Delete stored override'); + + $page->script("document.querySelector('form:has(input[name=\"op\"][value=\"put\"]) input[name=\"expectedVersion\"]').value = '0'"); + $page->press('Save definition')->assertSee('Refused:') + ->assertSee('expected 0'); + + $page->press('Delete stored override') + ->assertSee('Deleted the stored override of [checkout-flow].') + ->assertNoJavaScriptErrors()->screenshot(filename: 'flags-override-deleted'); +}); diff --git a/tests/Browser/Support/BrowserTestCase.php b/tests/Browser/Support/BrowserTestCase.php index 5d38a9ad..63575d17 100644 --- a/tests/Browser/Support/BrowserTestCase.php +++ b/tests/Browser/Support/BrowserTestCase.php @@ -64,6 +64,26 @@ protected function configOverrides(): array 'firefly.management.enabled' => true, 'firefly.openapi.enabled' => true, 'firefly.openapi.viewer.enabled' => true, + 'firefly.feature-flags.enabled' => true, + 'firefly.feature-flags.flags' => [ + 'beta-banner' => true, + 'checkout-flow' => [ + 'state' => 'ENABLED', + 'variants' => ['v1' => 'v1', 'v2' => 'v2'], + 'defaultVariant' => 'v1', + 'targeting' => ['if' => [['==' => [['var' => 'plan'], 'pro']], 'v2', null]], + 'metadata' => ['owner' => 'payments', 'expires' => '2099-12-31'], + ], + 'legacy-export' => [ + 'state' => 'ENABLED', + 'variants' => ['on' => true, 'off' => false], + 'defaultVariant' => 'on', + 'metadata' => ['expires' => '2025-01-01'], + ], + ], + 'firefly.feature-flags.sources.store.enabled' => true, + 'firefly.feature-flags.sources.store.driver' => 'memory', + 'firefly.feature-flags.management.writes' => true, ...$this->securityOverrides(), ]; } diff --git a/tests/Support/DocsCodeAudit.php b/tests/Support/DocsCodeAudit.php index fdb99692..908851be 100644 --- a/tests/Support/DocsCodeAudit.php +++ b/tests/Support/DocsCodeAudit.php @@ -103,7 +103,7 @@ final class DocsCodeAudit * Repo-relative files and directories whose every fenced block is audited — and it is now every Markdown * file the repository tracks: the README anyone lands on first, the changelog, every page of the * documentation site, both editions of the book, the README Packagist prints as the front page of each - * of the twenty-nine packages, and the two READMEs that ship inside an application (`skeleton/`, which + * of the thirty packages, and the two READMEs that ship inside an application (`skeleton/`, which * `create-project` copies, and `samples/lumen/`). * * The list was staged while wave R ran, one surface per commit, and it is a directory list now precisely @@ -172,6 +172,8 @@ final class DocsCodeAudit */ public const array USER_KEY_PREFIXES = [ 'firefly.feature.', + 'firefly.feature-flags.evaluators.', + 'firefly.feature-flags.flags.', 'firefly.management.endpoint.', 'firefly.management.endpoint.health.group.', 'firefly.security.oauth2.client.provider.',