From c226555d22a5f40157741fdb5ccfd520ecf55d1f Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 16:49:24 -0400 Subject: [PATCH 01/22] docs: add plan for provider.setConfiguration offline init (FFL-2666) Adds provider_set_configuration.md describing the plan to expose a JS setConfiguration path so a customer-supplied precomputed ConfigurationWire loads and evaluates exactly like CDN-fetched assignments. Planning only, no implementation. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 123 ++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 provider_set_configuration.md diff --git a/provider_set_configuration.md b/provider_set_configuration.md new file mode 100644 index 000000000..15ed07994 --- /dev/null +++ b/provider_set_configuration.md @@ -0,0 +1,123 @@ +# `provider.setConfiguration` — Offline Init for React Native Feature Flags + +**Jira:** [FFL-2666](https://datadoghq.atlassian.net/browse/FFL-2666) +**Status:** Planning (no implementation yet) + +## Goal + +Let a customer who has fetched a flag **configuration themselves** load it into the +React Native SDK and have flag evaluation behave **exactly** as it does today when the +SDK fetches precomputed assignments from the edge CDN. The core operation is: + +``` +configurationFromString(wire) -> provider.setConfiguration(configuration) -> evaluate(...) +``` + +Scope for this task: **precomputed (static context)** configuration only. The API is +designed to leave room for a future **rules-based (UFC / dynamic)** branch without +reshaping it. + +## Background: how flags work today + +1. `DdFlags.enable(config)` → native `enable`. +2. `DdFlags.getClient(name)` → a JS `FlagsClient` (`packages/core/src/flags/FlagsClient.ts`). +3. `flagsClient.setEvaluationContext(ctx)` → native `setEvaluationContext(...)`. **Setting + the context is what triggers the CDN fetch** of precomputed assignments. Native + fetches + parses and returns a **serialized snapshot** (`Record`). +4. JS caches the snapshot in `flagsCache`. **All evaluation already happens in JS** against + that cache. +5. `trackEvaluation` (exposure / RUM logging) is a per-flag, stateless call back to native. + +So the native read path only does *fetch → parse → return a snapshot*, and native exposure +tracking reconstructs what it needs from the per-flag data JS passes it. This means offline +init can be done **entirely in JS with no native changes**: parse a supplied configuration +into the same `FlagCacheEntry` map and populate `flagsCache`. + +## Approach (decisions) + +- **Parse the `ConfigurationWire` string in JS**, populating the existing `flagsCache`. +- **Input** is a `ConfigurationWire` string, consumed via `configurationFromString(wire)`. +- **No Swift/Kotlin changes** — evaluation and per-flag exposure tracking already run off + JS-provided data. +- **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. +- The customer still calls `setEvaluationContext` themselves; `setConfiguration` must + **verify the precomputed config matches the active context** (see below). + +## Wire format (confirmed) + +**ConfigurationWire v2** ([ConfigurationWire](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141725646/ConfigurationWire)): + +```ts +type ConfigurationWire = string // JSON-serialized +type Configuration = { + version: 2 + precomputed?: { + response: string // JSON-encoded PrecomputedConfiguration + context?: EvaluationContext // the context the assignments were computed for + fetchedAt?: number + etag?: string + } + // no `server`/rules branch defined yet — keep the type extensible for it +} +``` + +**Inner `precomputed.response` → PrecomputedConfiguration** +([PrecomputedConfiguration format](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141791092/PrecomputedConfiguration+format)): + +```ts +{ + id: string + data: { attributes: { + createdAt: number + expiresAt?: number + flags: Record + }} +} +``` + +Key facts that de-risk the JS approach: + +- **Obfuscation is not supported** in the DD precomputed format — parsing is plain + JSON → object mapping (no key hashing, no base64/salt decoding). +- `context` and the active context are both plain (`targetingKey` + attributes) — matching + is a normalized deep-equality. +- `PrecomputedFlag` → `FlagCacheEntry` is nearly 1:1 for the evaluation path. Two + tracking-only fields (`doLog`, `variationValue`; new format exposes + `flagMetadata.experiment`) need a mapping decision, confirmed with the flags backend team. + +## Context matching (order-independent) + +Customers may call `setConfiguration` and `setEvaluationContext` in either order. The +`FlagsClient` holds the **loaded configuration** (carrying its embedded `context`) and the +**active evaluation context** independently. The servable `flagsCache` is only populated +when the two **match**; the match is re-validated on **both** calls. On mismatch, values are +not served. + +## Work breakdown + +| Subtask | Summary | +| :------ | :------ | +| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v2, validate, fail predictably; extensible for rules) | +| [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode `PrecomputedConfiguration` → `FlagCacheEntry` map (plain JSON) | +| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching | +| [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | +| [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | +| [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Tests (parse/round-trip, decode, context match/mismatch, events) | + +## Open items (non-blocking) + +- `doLog` / `variationValue` / `experiment` mapping for native exposure tracking (FFL-2687). +- Precomputed context-mismatch behavior: default value vs `PROVIDER_NOT_READY` vs error + (RFC open question). +- `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise + keeps parity with `setEvaluationContext` and forward-compat for rules). From 7ecd40fbc3e19ebc2665e23dbaf014dc0acdfc2d Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 18:56:19 -0400 Subject: [PATCH 02/22] docs(plan): correct wire format to version 1 and add server branch ConfigurationWire is version 1 (per updated spec and shipped wire.ts), and the envelope now reserves a server/rules branch alongside precomputed. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 15ed07994..2e80404f0 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -45,19 +45,23 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. ## Wire format (confirmed) -**ConfigurationWire v2** ([ConfigurationWire](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141725646/ConfigurationWire)): +**ConfigurationWire v1** ([ConfigurationWire](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141725646/ConfigurationWire)): ```ts type ConfigurationWire = string // JSON-serialized type Configuration = { - version: 2 + version: 1 precomputed?: { response: string // JSON-encoded PrecomputedConfiguration context?: EvaluationContext // the context the assignments were computed for fetchedAt?: number etag?: string } - // no `server`/rules branch defined yet — keep the type extensible for it + server?: { // rules-based (UFC) config — out of scope for MVP + response: string // JSON-encoded server configuration + fetchedAt?: number + etag?: string + } } ``` @@ -107,7 +111,7 @@ not served. | Subtask | Summary | | :------ | :------ | -| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v2, validate, fail predictably; extensible for rules) | +| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1, validate, fail predictably; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode `PrecomputedConfiguration` → `FlagCacheEntry` map (plain JSON) | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | From b838fb95555bd738a987aebf17fb588c7658a05c Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 18:56:49 -0400 Subject: [PATCH 03/22] docs(plan): make configurationFromString lenient, not throwing Parser returns an empty config on invalid input or unknown version, matching the shipped wire.ts. Predictable failure surfaces at the provider layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 2e80404f0..5d1b6d27a 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -95,6 +95,11 @@ Key facts that de-risk the JS approach: JSON → object mapping (no key hashing, no base64/salt decoding). - `context` and the active context are both plain (`targetingKey` + attributes) — matching is a normalized deep-equality. +- **`configurationFromString` is lenient** — it returns an empty config (`{}`) on a parse + error or unknown `version` rather than throwing, matching the shipped + `openfeature-js-client` `wire.ts`. Predictable failure surfaces at the + `setConfiguration`/provider layer (empty/absent precomputed → provider stays not-ready / + emits `PROVIDER_ERROR`), not as a thrown parse error. - `PrecomputedFlag` → `FlagCacheEntry` is nearly 1:1 for the evaluation path. Two tracking-only fields (`doLog`, `variationValue`; new format exposes `flagMetadata.experiment`) need a mapping decision, confirmed with the flags backend team. @@ -111,7 +116,7 @@ not served. | Subtask | Summary | | :------ | :------ | -| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1, validate, fail predictably; extensible for `server`/rules) | +| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode `PrecomputedConfiguration` → `FlagCacheEntry` map (plain JSON) | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | From 27c38f312b0542c453c5b4952f4bdcda1c225e03 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 18:57:29 -0400 Subject: [PATCH 04/22] docs(plan): assume shipped PrecomputedFlag shape, note format discrepancy Adopt the openfeature-js-client PrecomputedFlag shape (matches RN's FlagCacheEntry, decoder ~1:1) instead of the OpenFeature-aligned proposal page. Flags the two-format discrepancy to confirm with the flags team. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 47 +++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 5d1b6d27a..9f4bc4df0 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -65,30 +65,36 @@ type Configuration = { } ``` -**Inner `precomputed.response` → PrecomputedConfiguration** -([PrecomputedConfiguration format](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141791092/PrecomputedConfiguration+format)): +**Inner `precomputed.response` → PrecomputedConfiguration** — **ASSUMED**: the shipped +`openfeature-js-client` `PrecomputedFlag` shape +(`packages/core/src/configuration/configuration.ts`), which matches RN's existing +`FlagCacheEntry`: ```ts { - id: string data: { attributes: { - createdAt: number - expiresAt?: number + createdAt: string flags: Record }> }} } ``` +> ⚠️ **Two documented formats exist.** The Confluence *PrecomputedConfiguration format* +> page ([5141791092](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141791092/PrecomputedConfiguration+format)) +> describes an OpenFeature-aligned shape (`type` + `resolution.flagMetadata.experiment`). +> The **shipped** `openfeature-js-client` uses the `PrecomputedFlag` shape above, which +> matches RN's `FlagCacheEntry`. **We assume the shipped `PrecomputedFlag` shape.** Confirm +> with the flags backend team which the CDN actually returns, and whether `variationValue` +> is the typed value or a string. + Key facts that de-risk the JS approach: - **Obfuscation is not supported** in the DD precomputed format — parsing is plain @@ -100,9 +106,10 @@ Key facts that de-risk the JS approach: `openfeature-js-client` `wire.ts`. Predictable failure surfaces at the `setConfiguration`/provider layer (empty/absent precomputed → provider stays not-ready / emits `PROVIDER_ERROR`), not as a thrown parse error. -- `PrecomputedFlag` → `FlagCacheEntry` is nearly 1:1 for the evaluation path. Two - tracking-only fields (`doLog`, `variationValue`; new format exposes - `flagMetadata.experiment`) need a mapping decision, confirmed with the flags backend team. +- The assumed `PrecomputedFlag` shape maps **~1:1** onto RN's existing `FlagCacheEntry` + (`allocationKey`, `variationKey`, `variationType`, `variationValue`, `reason`, `doLog`, + `extraLogging`), so the decoder is near-trivial and the earlier `doLog`/`variationValue` + uncertainty is resolved by the shipped format. ## Context matching (order-independent) @@ -117,7 +124,7 @@ not served. | Subtask | Summary | | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | -| [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode `PrecomputedConfiguration` → `FlagCacheEntry` map (plain JSON) | +| [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | @@ -125,7 +132,9 @@ not served. ## Open items (non-blocking) -- `doLog` / `variationValue` / `experiment` mapping for native exposure tracking (FFL-2687). +- Confirm the CDN's actual precomputed shape against the two documented formats, and whether + `variationValue` is the typed value or a string (FFL-2687). Plan assumes the shipped + `PrecomputedFlag` shape. - Precomputed context-mismatch behavior: default value vs `PROVIDER_NOT_READY` vs error (RFC open question). - `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise From cfd6f59ac9e5775390623be65cc3db8edd7a718f Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 18:58:09 -0400 Subject: [PATCH 05/22] docs(plan): port reference helpers (wire + configMatchesContext) vs depend openfeature-js-client is not a dependency here, so port its pure wire.ts and configMatchesContext helpers into RN core to preserve the native RUM/exposure path. Documents the context-agnostic matching nuance. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 9f4bc4df0..e66e7c16b 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -42,6 +42,11 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. - **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. - The customer still calls `setEvaluationContext` themselves; `setConfiguration` must **verify the precomputed config matches the active context** (see below). +- **Port, don't depend.** `openfeature-js-client` is not a dependency here (only upstream + `@openfeature/core` + `@openfeature/web-sdk` are). Port its small pure helpers — + `wire.ts` (`configurationFromString`/`configurationToString`) and `configMatchesContext` — + into RN core rather than depending on the package, whose provider/exposure-logging would + bypass RN's native RUM/exposure path. ## Wire format (confirmed) @@ -119,13 +124,17 @@ Customers may call `setConfiguration` and `setEvaluationContext` in either order when the two **match**; the match is re-validated on **both** calls. On mismatch, values are not served. +This is a port of the reference `configMatchesContext` (deep-equality on `targetingKey` + +attributes), including its nuance: **a config with no embedded `context` is context-agnostic +and matches any evaluation context; a stored context must match exactly.** + ## Work breakdown | Subtask | Summary | | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | -| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching | +| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`) | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | | [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Tests (parse/round-trip, decode, context match/mismatch, events) | From 88529ff53340baf1286ed56b3dc48501d2b941f7 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 19:07:49 -0400 Subject: [PATCH 06/22] docs(plan): drop certainty-asserting section headings Nothing here is confirmed or decided yet, so rename the Approach and Wire format headings to avoid implying finality. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index e66e7c16b..028c50f70 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -33,7 +33,7 @@ tracking reconstructs what it needs from the per-flag data JS passes it. This me init can be done **entirely in JS with no native changes**: parse a supplied configuration into the same `FlagCacheEntry` map and populate `flagsCache`. -## Approach (decisions) +## Approach - **Parse the `ConfigurationWire` string in JS**, populating the existing `flagsCache`. - **Input** is a `ConfigurationWire` string, consumed via `configurationFromString(wire)`. @@ -48,7 +48,7 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. into RN core rather than depending on the package, whose provider/exposure-logging would bypass RN's native RUM/exposure path. -## Wire format (confirmed) +## Wire format **ConfigurationWire v1** ([ConfigurationWire](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141725646/ConfigurationWire)): From cfcb27e19560434f2c61b0605cbe355a15fa1586 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 19:08:52 -0400 Subject: [PATCH 07/22] docs(plan): ground precomputed shape in a sample CDN response A staging /precompute-assignments sample (example.json) shows the PrecomputedFlag-style shape (typed variationValue, RFC3339 createdAt, obfuscated:false), matching RN's FlagCacheEntry. Update the format block and notes to reference it and soften remaining certainty wording. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 68 ++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 028c50f70..f8cf5a7a4 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -70,40 +70,51 @@ type Configuration = { } ``` -**Inner `precomputed.response` → PrecomputedConfiguration** — **ASSUMED**: the shipped -`openfeature-js-client` `PrecomputedFlag` shape -(`packages/core/src/configuration/configuration.ts`), which matches RN's existing -`FlagCacheEntry`: +**Inner `precomputed.response` → PrecomputedConfiguration.** A sample staging CDN response +(`POST /precompute-assignments?dd_env=staging`, saved locally as `example.json`) shows the +`PrecomputedFlag`-style shape used by the shipped `openfeature-js-client`, which lines up +with RN's existing `FlagCacheEntry`: ```ts { - data: { attributes: { - createdAt: string - flags: Record - }> - }} + data: { + id: string + type: 'precomputed-assignments' + attributes: { + obfuscated: boolean // false in the sample + createdAt: string // RFC3339 timestamp (string, not a number) + format: 'PRECOMPUTED' + environment: { name: string } + flags: Record + serialId?: number + }> + } + } } ``` > ⚠️ **Two documented formats exist.** The Confluence *PrecomputedConfiguration format* > page ([5141791092](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141791092/PrecomputedConfiguration+format)) > describes an OpenFeature-aligned shape (`type` + `resolution.flagMetadata.experiment`). -> The **shipped** `openfeature-js-client` uses the `PrecomputedFlag` shape above, which -> matches RN's `FlagCacheEntry`. **We assume the shipped `PrecomputedFlag` shape.** Confirm -> with the flags backend team which the CDN actually returns, and whether `variationValue` -> is the typed value or a string. +> The sample CDN response and the shipped `openfeature-js-client` instead use the +> `PrecomputedFlag`-style shape above, which matches RN's `FlagCacheEntry`. **This plan +> assumes the `PrecomputedFlag`-style shape.** Since `variationValue` is the **typed** value, +> the decoder maps it to RN's `FlagCacheEntry.value` and derives the string +> `variationValue`/`variationType` that native Android exposure tracking expects. Still worth +> checking with the flags team whether the shape is stable across environments and versions. Key facts that de-risk the JS approach: -- **Obfuscation is not supported** in the DD precomputed format — parsing is plain - JSON → object mapping (no key hashing, no base64/salt decoding). +- **Obfuscation is not supported** in the DD precomputed format (the sample response carries + `obfuscated: false`) — parsing is plain JSON → object mapping (no key hashing, no + base64/salt decoding). - `context` and the active context are both plain (`targetingKey` + attributes) — matching is a normalized deep-equality. - **`configurationFromString` is lenient** — it returns an empty config (`{}`) on a parse @@ -111,10 +122,11 @@ Key facts that de-risk the JS approach: `openfeature-js-client` `wire.ts`. Predictable failure surfaces at the `setConfiguration`/provider layer (empty/absent precomputed → provider stays not-ready / emits `PROVIDER_ERROR`), not as a thrown parse error. -- The assumed `PrecomputedFlag` shape maps **~1:1** onto RN's existing `FlagCacheEntry` +- The `PrecomputedFlag`-style shape maps **~1:1** onto RN's existing `FlagCacheEntry` (`allocationKey`, `variationKey`, `variationType`, `variationValue`, `reason`, `doLog`, - `extraLogging`), so the decoder is near-trivial and the earlier `doLog`/`variationValue` - uncertainty is resolved by the shipped format. + `extraLogging`), so the decoder is near-trivial — the sample shows `doLog` and the per-flag + fields present directly. The one transform is the typed `variationValue` → RN's typed + `value` plus a stringified `variationValue`. ## Context matching (order-independent) @@ -141,9 +153,9 @@ and matches any evaluation context; a stored context must match exactly.** ## Open items (non-blocking) -- Confirm the CDN's actual precomputed shape against the two documented formats, and whether - `variationValue` is the typed value or a string (FFL-2687). Plan assumes the shipped - `PrecomputedFlag` shape. +- Check with the flags team whether the `PrecomputedFlag`-style shape seen in the sample + (`example.json`) is stable across environments/versions, vs the OpenFeature-aligned proposal + page (FFL-2687). - Precomputed context-mismatch behavior: default value vs `PROVIDER_NOT_READY` vs error (RFC open question). - `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise From ccd7287a233d14e87e97b23c09c3309f2cffc921 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 6 Jul 2026 21:07:44 -0400 Subject: [PATCH 08/22] docs(plan): document native tracking path and setEvaluationContext fetch Explains that native exposure/RUM tracking is per-flag and parameter-driven (no dependency on a prior CDN fetch), so offline init stays in JS. Notes that FlagsClient.setEvaluationContext currently triggers a native CDN fetch and overwrites the cache, so it must branch when an offline config is loaded (FFL-2688). Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index f8cf5a7a4..e61cb1799 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -41,7 +41,7 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. JS-provided data. - **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. - The customer still calls `setEvaluationContext` themselves; `setConfiguration` must - **verify the precomputed config matches the active context** (see below). + **check that the precomputed config matches the active context** (see below). - **Port, don't depend.** `openfeature-js-client` is not a dependency here (only upstream `@openfeature/core` + `@openfeature/web-sdk` are). Port its small pure helpers — `wire.ts` (`configurationFromString`/`configurationToString`) and `configMatchesContext` — @@ -140,13 +140,45 @@ This is a port of the reference `configMatchesContext` (deep-equality on `target attributes), including its nuance: **a config with no embedded `context` is context-agnostic and matches any evaluation context; a stored context must match exactly.** +## Native tracking path and `setEvaluationContext` + +Two native code paths shape how much of this can stay in JS. + +**Exposure / RUM tracking is per-flag and parameter-driven.** RN's `trackEvaluation` bridges to +the native SDK's internal tracking entry point (iOS +`FlagsClient.sendFlagEvaluation(key:assignment:context:)`, Android +`_FlagsInternalProxy.trackFlagSnapshotEvaluation(key, flag, context)`). On iOS that calls +`exposureLogger.logExposure`, `evaluationLogger.logEvaluation`, and +`rumFlagEvaluationReporter.sendFlagEvaluation` — each takes the `key`, `assignment`, and +`context` as arguments and none reads the native repository's stored context or fetched +assignments (`ExposureLogger` gates on `assignment.doLog`, dedups in memory, and writes through +the core feature scope). Android's `ExposureEventsProcessor` is constructed from a record writer +plus a time provider and builds each event from the passed flag + context. So tracking runs off +the flag data and context that JS supplies, as long as `DdFlags.enable()` and `getClient()` have +run to create the client. That is what keeps offline init in JS with no Swift/Kotlin changes. + +**`setEvaluationContext` currently triggers a native CDN fetch.** RN's JS +`FlagsClient.setEvaluationContext` calls native `setEvaluationContext`, which fetches precomputed +assignments from the CDN and then overwrites `flagsCache` with the result: + +```ts +const result = await this.nativeFlags.setEvaluationContext(...); // CDN fetch +this.evaluationContext = processedContext; +this.flagsCache = result; // overwrites loaded config +``` + +In the offline flow the customer still calls `setEvaluationContext` (needed for context matching +and to hand a context to `trackEvaluation`), so this path has to branch when an offline +configuration is present: record the context without invoking the native fetch and without +overwriting the config-populated cache. This is a JS-only change and lives in FFL-2688. + ## Work breakdown | Subtask | Summary | | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | -| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`) | +| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`); branch `setEvaluationContext` so it does not trigger the native CDN fetch / overwrite the loaded cache when an offline config is present | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | | [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Tests (parse/round-trip, decode, context match/mismatch, events) | From 106ed16bdb5dcbc3066de050fdc11536a8b55e1d Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 08:45:24 -0400 Subject: [PATCH 09/22] docs(plan): address review feedback Add a Non-goals section (fetchPrecomputedConfiguration, precomputeConfiguration, rules-based eval out of scope), note the fetch source could be the Datadog/Fastly edge CDN or a customer proxy, and specify context mismatch as PROVIDER_ERROR plus an INVALID_CONTEXT evaluation error code. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 39 +++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index e61cb1799..be6483aac 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -13,10 +13,27 @@ SDK fetches precomputed assignments from the edge CDN. The core operation is: configurationFromString(wire) -> provider.setConfiguration(configuration) -> evaluate(...) ``` -Scope for this task: **precomputed (static context)** configuration only. The API is -designed to leave room for a future **rules-based (UFC / dynamic)** branch without +Scope for this task: **precomputed, static single-context (one user)** configuration only. +The API is designed to leave room for a future **rules-based (UFC / dynamic)** branch without reshaping it. +## Non-goals (out of scope for now) + +The goal here is only `setConfiguration()` for a **static, single-context (one user)** +precomputed configuration. The following are explicitly out of scope for this work and can be +later: + +- **`fetchPrecomputedConfiguration(...)`** — a convenience helper that fetches precomputed + assignments over HTTP (from the Datadog/Fastly edge CDN, or from the customer's own + service/proxy) and returns a `FlagsConfiguration`. Customers in this work fetch the + configuration themselves; a JS-level fetch helper (auth, endpoint, ETag/`304`) is a separate + convenience feature for customers who would rather not handle the HTTP fetch, and is later + work. +- **`precomputeConfiguration(...)`** — server-side conversion of rules into a client precomputed + configuration (Node.js-only in the RFC). Not relevant to the RN client. +- **Rules-based / dynamic (UFC) on-device evaluation** — the wire `server` branch. The type + stays extensible for it, but it is not implemented here. + ## Background: how flags work today 1. `DdFlags.enable(config)` → native `enable`. @@ -133,8 +150,17 @@ Key facts that de-risk the JS approach: Customers may call `setConfiguration` and `setEvaluationContext` in either order. The `FlagsClient` holds the **loaded configuration** (carrying its embedded `context`) and the **active evaluation context** independently. The servable `flagsCache` is only populated -when the two **match**; the match is re-validated on **both** calls. On mismatch, values are -not served. +when the two **match**; the match is re-validated on **both** calls. + +On mismatch the provider does not serve the precomputed values: it emits a `PROVIDER_ERROR` +event and enters the error provider state, and flag evaluations return the default with an +`INVALID_CONTEXT` error code (the active context does not match the loaded precomputed +configuration). `PROVIDER_ERROR` is an OpenFeature provider event/status, not an evaluation +error code. `INVALID_CONTEXT` is an existing OpenFeature `ErrorCode` — no new code is needed — +but RN's local `FlagErrorCode` union +(`PROVIDER_NOT_READY | FLAG_NOT_FOUND | PARSE_ERROR | TYPE_MISMATCH`) has to be extended to +include it. The provider's `toFlagResolution` already maps any `ErrorCode`, so it needs no +change. This is a port of the reference `configMatchesContext` (deep-equality on `targetingKey` + attributes), including its nuance: **a config with no embedded `context` is context-agnostic @@ -188,7 +214,8 @@ overwriting the config-populated cache. This is a JS-only change and lives in FF - Check with the flags team whether the `PrecomputedFlag`-style shape seen in the sample (`example.json`) is stable across environments/versions, vs the OpenFeature-aligned proposal page (FFL-2687). -- Precomputed context-mismatch behavior: default value vs `PROVIDER_NOT_READY` vs error - (RFC open question). +- Precomputed context mismatch surfaces as a `PROVIDER_ERROR` event/state plus an + `INVALID_CONTEXT` evaluation error code (extending RN's `FlagErrorCode` union with this + existing OpenFeature code) — current direction for RFC open Q2. - `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise keeps parity with `setEvaluationContext` and forward-compat for rules). From 13f60de41b8c76f87ece6fb936b93420bd9fb71d Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 09:18:44 -0400 Subject: [PATCH 10/22] docs(plan): add fetchPolicy to opt out of fetch on setEvaluationContext setEvaluationContext always fetches from the CDN today with no toggle. Add a fetchPolicy (ALWAYS default / NEVER / ON_MISMATCH) set at enable() with a per-getClient() override; build ALWAYS + NEVER now and defer ON_MISMATCH. Tracked as FFL-2718. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 44 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index be6483aac..ba98d1220 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -59,6 +59,9 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. - **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. - The customer still calls `setEvaluationContext` themselves; `setConfiguration` must **check that the precomputed config matches the active context** (see below). +- Add a **`fetchPolicy`** (`ALWAYS` default / `NEVER` / `ON_MISMATCH`) set at `enable()` with a + per-`getClient()` override, so customers can turn off the fetch-on-`setEvaluationContext`. + Offline init needs `NEVER`; see [Fetch policy](#fetch-policy). - **Port, don't depend.** `openfeature-js-client` is not a dependency here (only upstream `@openfeature/core` + `@openfeature/web-sdk` are). Port its small pure helpers — `wire.ts` (`configurationFromString`/`configurationToString`) and `configMatchesContext` — @@ -194,9 +197,37 @@ this.flagsCache = result; // overwrites l ``` In the offline flow the customer still calls `setEvaluationContext` (needed for context matching -and to hand a context to `trackEvaluation`), so this path has to branch when an offline -configuration is present: record the context without invoking the native fetch and without -overwriting the config-populated cache. This is a JS-only change and lives in FFL-2688. +and to hand a context to `trackEvaluation`), so under `fetchPolicy: NEVER` this path records the +context without invoking the native fetch and without overwriting the config-populated cache. +This is a JS-only change. See [Fetch policy](#fetch-policy) below. + +## Fetch policy + +Because `setEvaluationContext` fetches from the CDN today (see above), offline customers need an +explicit way to turn that off — and a hard "no network" guarantee, not just "usually won't." We +add a `fetchPolicy` set at `enable()` (global default) with an optional per-`getClient()` +override: + +```ts +enum FetchPolicy { + ALWAYS, // fetch on setEvaluationContext (today's behavior; default) + NEVER, // never fetch; serve only configurations supplied via setConfiguration + ON_MISMATCH, // use a matching supplied config; fetch only when it doesn't match the context +} +``` + +- **`ALWAYS`** — default; preserves current behavior. A `setConfiguration` bootstrap is + overwritten by the fetch, so this mode is mostly for online apps. +- **`NEVER`** — MVP target for offline. `setEvaluationContext` records the context but does not + call the native fetch or overwrite the cache; the client serves only what `setConfiguration` + loaded. A context set with no matching config → provider not-ready / `PROVIDER_ERROR`. +- **`ON_MISMATCH`** — *fast-follow, not in this MVP.* Bootstrap-then-refresh: serve a supplied + config when it matches the active context, otherwise fetch. Adds async fetch orchestration, + `Reconciling`/`Stale`/`Ready` sequencing, and a fetch-failure fallback, so it is deferred. + +Only `ALWAYS` (default) and `NEVER` are built now. `ON_MISMATCH` is declared for forward-compat +and implemented later. A mutable runtime setter is intentionally left out of v1 to avoid races +with in-flight fetches and already-loaded config. ## Work breakdown @@ -204,7 +235,8 @@ overwriting the config-populated cache. This is a JS-only change and lives in FF | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | -| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`); branch `setEvaluationContext` so it does not trigger the native CDN fetch / overwrite the loaded cache when an offline config is present | +| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | +| [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | | [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Tests (parse/round-trip, decode, context match/mismatch, events) | @@ -219,3 +251,7 @@ overwriting the config-populated cache. This is a JS-only change and lives in FF existing OpenFeature code) — current direction for RFC open Q2. - `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise keeps parity with `setEvaluationContext` and forward-compat for rules). +- For the future `ON_MISMATCH` policy: fetch-failure fallback (keep serving the previous usable + config and report `Stale`, but never serve a config that doesn't match the context) and a + staleness axis (`fetchedAt`/`etag`/`expiresAt`-driven refresh) are separate from context + matching and out of this MVP. From f8b1b7fe4df63e6dfc42a4a37b0c9ac0b3e11ffa Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 09:51:42 -0400 Subject: [PATCH 11/22] docs(plan): add 4-PR delivery plan and repurpose FFL-2691 to RUM FIT e2e Group the seven sub-tasks into four stacked, self-contained PRs (unit tests co-located, no trailing test-only PR) and document the ordering rationale. FFL-2691 is repurposed to RUM FIT integration/e2e coverage (offline-loaded flag -> RUM parity). Jira encodes the order via Blocks links. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index ba98d1220..c374e1e5c 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -239,7 +239,29 @@ with in-flight fetches and already-loaded config. | [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | -| [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Tests (parse/round-trip, decode, context match/mismatch, events) | +| [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Integration / e2e tests (RUM FIT) — offline-loaded flag → RUM parity; unit tests ship inside each PR above | + +## Delivery plan (PRs) + +To keep each changeset easy to review, the seven sub-tasks ship as **four stacked PRs**, each +self-contained with its own unit tests (no trailing test-only PR). RUM FIT integration/e2e +coverage (FFL-2691) lands after the feature is complete. + +``` +PR1 ─▶ PR2 ─▶ PR3 ─▶ PR4 (each branch based on the previous) +``` + +| PR | Sub-tasks | Scope | +| :- | :-------- | :---- | +| PR1 | FFL-2686 + FFL-2687 | Pure JS: wire parse + precomputed → `FlagCacheEntry`, with fixtures. Kept internal (un-exported) until PR4. | +| PR2 | FFL-2688 | `FlagsClient.setConfiguration` + context matching; mismatch → `PROVIDER_ERROR` / `INVALID_CONTEXT`. | +| PR3 | FFL-2718 | `fetchPolicy` `ALWAYS`/`NEVER`. Code-independent of PR1/PR2 — can be authored in parallel and rebased into the stack. | +| PR4 | FFL-2689 + FFL-2690 | OpenFeature provider `setConfiguration` + events, public exports, docs, example. | +| after | FFL-2691 | RUM FIT integration/e2e: an offline-loaded flag evaluation reports to RUM with parity to the fetch path. | + +Ordering rationale: leaf-first (pure, tested transforms), then client behavior, then fetch +control, then the public surface — one reviewable idea per PR, tests co-located. Jira encodes +this order with `Blocks` links: 2686 → 2687 → 2688 → 2718 → 2689 → 2690 → 2691. ## Open items (non-blocking) From 7de98be5792dd12c1477ab28a6149c8ef23171b1 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 10:35:53 -0400 Subject: [PATCH 12/22] docs(plan): avoid FlagsConfiguration name collision (H1) The parsed-wire config type must not reuse FlagsConfiguration, which already names the enable() options type. Use ParsedFlagsConfiguration for the configurationFromString/setConfiguration surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index c374e1e5c..232dad67e 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -26,7 +26,8 @@ later: - **`fetchPrecomputedConfiguration(...)`** — a convenience helper that fetches precomputed assignments over HTTP (from the Datadog/Fastly edge CDN, or from the customer's own service/proxy) and returns a `FlagsConfiguration`. Customers in this work fetch the - configuration themselves; a JS-level fetch helper (auth, endpoint, ETag/`304`) is a separate + configuration themselves; a JS-level fetch helper (auth, endpoint, ETag/`304`) returns a + `ParsedFlagsConfiguration` and is a separate convenience feature for customers who would rather not handle the HTTP fetch, and is later work. - **`precomputeConfiguration(...)`** — server-side conversion of rules into a client precomputed @@ -57,6 +58,10 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. - **No Swift/Kotlin changes** — evaluation and per-flag exposure tracking already run off JS-provided data. - **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. +- **Avoid the `FlagsConfiguration` name collision.** That identifier is already the `enable()` + options type (`packages/core/src/flags/types.ts`). Name the parsed-wire config type distinctly + — this plan uses **`ParsedFlagsConfiguration`** — so `configurationFromString` / + `setConfiguration` don't overload the existing type. - The customer still calls `setEvaluationContext` themselves; `setConfiguration` must **check that the precomputed config matches the active context** (see below). - Add a **`fetchPolicy`** (`ALWAYS` default / `NEVER` / `ON_MISMATCH`) set at `enable()` with a @@ -233,7 +238,7 @@ with in-flight fetches and already-loaded config. | Subtask | Summary | | :------ | :------ | -| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `FlagsConfiguration` type (parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | +| [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `ParsedFlagsConfiguration` type (distinct from the existing `enable()` `FlagsConfiguration`; parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | | [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | From fb178854bf1267fee7deaf1926ad37f33bfd9fac Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 10:36:37 -0400 Subject: [PATCH 13/22] docs(plan): separate config-kind axis from fetchPolicy (H2, H3) Add a configuration-kind/evaluation-mode concept chosen by which wire branch is populated (precomputed vs future server/rules), keep fetchPolicy as the network axis only, and gate the context match/mismatch-error on the precomputed kind so a future rules config (context-agnostic) is not rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 232dad67e..0ba66881a 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -153,12 +153,30 @@ Key facts that de-risk the JS approach: fields present directly. The one transform is the typed `variationValue` → RN's typed `value` plus a stringified `variationValue`. +## Configuration kind (evaluation mode) + +Keep two axes separate so the future rules mode is additive, not a reshape: + +- **Configuration kind / evaluation mode** — *how* a loaded config is evaluated, chosen by which + wire branch is populated (the way the reference providers do it): `precomputed` → look up an + assignment that **must match** the active context; `server` (future rules/UFC) → evaluate the + active context **locally** and serve *any* context. Only `precomputed` is implemented now. +- **`fetchPolicy`** — *whether* the SDK may hit the network on `setEvaluationContext` + ([Fetch policy](#fetch-policy)). Network posture only; it does not select the evaluation mode. + +The context match/mismatch rules below apply to the **precomputed** kind; a rules config is +context-agnostic (below). + ## Context matching (order-independent) Customers may call `setConfiguration` and `setEvaluationContext` in either order. The `FlagsClient` holds the **loaded configuration** (carrying its embedded `context`) and the -**active evaluation context** independently. The servable `flagsCache` is only populated -when the two **match**; the match is re-validated on **both** calls. +**active evaluation context** independently. + +**For a precomputed configuration** the servable `flagsCache` is only populated when the two +**match**; the match is re-validated on **both** calls. (A future rules/`server` configuration +is context-agnostic — it evaluates any active context locally and is never rejected for a +context mismatch; the match check is gated on config kind = precomputed.) On mismatch the provider does not serve the precomputed values: it emits a `PROVIDER_ERROR` event and enters the error provider state, and flag evaluations return the default with an @@ -172,7 +190,8 @@ change. This is a port of the reference `configMatchesContext` (deep-equality on `targetingKey` + attributes), including its nuance: **a config with no embedded `context` is context-agnostic -and matches any evaluation context; a stored context must match exactly.** +and matches any evaluation context; a stored context must match exactly.** The RN port must +also treat a **rules-kind** config (not just a missing `context`) as context-agnostic. ## Native tracking path and `setEvaluationContext` @@ -234,13 +253,16 @@ Only `ALWAYS` (default) and `NEVER` are built now. `ON_MISMATCH` is declared for and implemented later. A mutable runtime setter is intentionally left out of v1 to avoid races with in-flight fetches and already-loaded config. +`fetchPolicy` is purely the **network axis**; *how* a config is evaluated is set by its +[configuration kind](#configuration-kind-evaluation-mode), not by `fetchPolicy`. + ## Work breakdown | Subtask | Summary | | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `ParsedFlagsConfiguration` type (distinct from the existing `enable()` `FlagsConfiguration`; parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | -| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | +| [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`, gated on config kind = precomputed); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | | [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | From f10e73a3cac7612715462dcc20b128ccf5cde3be Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 10:37:08 -0400 Subject: [PATCH 14/22] docs(plan): add obfuscation guard to the decoder (H6) The decoder must read attributes.obfuscated and fail predictably (unsupported -> PROVIDER_ERROR) instead of mis-mapping hashed keys as flag names if the CDN ever enables obfuscation. This is the seam for a future obfuscated format. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 0ba66881a..d7c9bf2fa 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -139,7 +139,10 @@ Key facts that de-risk the JS approach: - **Obfuscation is not supported** in the DD precomputed format (the sample response carries `obfuscated: false`) — parsing is plain JSON → object mapping (no key hashing, no - base64/salt decoding). + base64/salt decoding). The decoder must still **read `attributes.obfuscated` and fail + predictably** (unsupported → `PROVIDER_ERROR`) if it is ever `true`, rather than silently + mis-mapping hashed keys as flag names when the CDN flips it on. This is the seam for a future + obfuscated precomputed/rules format. - `context` and the active context are both plain (`targetingKey` + attributes) — matching is a normalized deep-equality. - **`configurationFromString` is lenient** — it returns an empty config (`{}`) on a parse @@ -261,7 +264,7 @@ with in-flight fetches and already-loaded config. | Subtask | Summary | | :------ | :------ | | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `ParsedFlagsConfiguration` type (distinct from the existing `enable()` `FlagsConfiguration`; parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | -| [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — ~1:1, plain JSON | +| [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — plain JSON; inject `key`; derive typed `value` + string `variationValue`; fail predictably if `attributes.obfuscated` | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`, gated on config kind = precomputed); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | | [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | From 1030cc4d967f6aaf3404da3f8cc51b5c0d32d2c5 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 10:37:46 -0400 Subject: [PATCH 15/22] docs(plan): fetchPolicy options object + depend on shared rules evaluator (M6, M5) Pass fetchPolicy inside an options object so it can grow (ttl, staleWhileRevalidate) without an API break, and record that the future rules (UFC) evaluator should be depended on from a shared core rather than re-ported, to avoid silent assignment drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index d7c9bf2fa..1bd2a4c58 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -71,7 +71,11 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. `@openfeature/core` + `@openfeature/web-sdk` are). Port its small pure helpers — `wire.ts` (`configurationFromString`/`configurationToString`) and `configMatchesContext` — into RN core rather than depending on the package, whose provider/exposure-logging would - bypass RN's native RUM/exposure path. + bypass RN's native RUM/exposure path. **But for the future rules (UFC) evaluator and any + obfuscation logic, prefer *depending* on a shared platform-agnostic core (e.g. + `@datadog/flagging-core`) rather than re-porting** — hand-porting an evolving evaluator risks + silent assignment drift. Keep the ported helpers behind a thin internal module boundary so a + later port → dependency swap stays contained. ## Wire format @@ -256,6 +260,10 @@ Only `ALWAYS` (default) and `NEVER` are built now. `ON_MISMATCH` is declared for and implemented later. A mutable runtime setter is intentionally left out of v1 to avoid races with in-flight fetches and already-loaded config. +`fetchPolicy` is passed inside an **options object** at `enable()` / `getClient()` (not a bare +positional enum) so it can grow fields later — e.g. `ttl`, `staleWhileRevalidate` for the future +staleness axis — without an API break. + `fetchPolicy` is purely the **network axis**; *how* a config is evaluated is set by its [configuration kind](#configuration-kind-evaluation-mode), not by `fetchPolicy`. @@ -266,7 +274,7 @@ with in-flight fetches and already-loaded config. | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `ParsedFlagsConfiguration` type (distinct from the existing `enable()` `FlagsConfiguration`; parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — plain JSON; inject `key`; derive typed `value` + string `variationValue`; fail predictably if `attributes.obfuscated` | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`, gated on config kind = precomputed); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | -| [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring: `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite). `ON_MISMATCH` declared, implemented later | +| [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring via an **options object** at `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite and sets context synchronously). `ON_MISMATCH` declared, implemented later | | [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | | [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Integration / e2e tests (RUM FIT) — offline-loaded flag → RUM parity; unit tests ship inside each PR above | From 250d6936e7ca30639e7cc870f32363db07da1267 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 10:38:23 -0400 Subject: [PATCH 16/22] docs(plan): add review-driven acceptance criteria (implementation must-fixes) Capture the plan-review silent-failure risks as a per-subtask acceptance checklist: variationValue stringification, wire-context normalization before matching, NEVER sets context synchronously, provider event ordering, and the iOS int/double parity note. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 1bd2a4c58..c007dc295 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -301,6 +301,35 @@ Ordering rationale: leaf-first (pure, tested transforms), then client behavior, control, then the public surface — one reviewable idea per PR, tests co-located. Jira encodes this order with `Blocks` links: 2686 → 2687 → 2688 → 2718 → 2689 → 2690 → 2691. +## Review-driven acceptance criteria (must-fix at implementation) + +From a two-agent plan review. These are silent-failure risks; mirrored as acceptance checklists +on each sub-task. + +- **FFL-2686 (parse):** accept a *known set* of `version`s (not a hard `1`); `setConfiguration` + must distinguish parse-failure from valid-but-no-`precomputed` (don't let a lenient `{}` + masquerade as success); align the type with the ported source (no phantom `etag`; `fetchedAt` + type). +- **FFL-2687 (decode):** inject `key` from the flag map key (`PrecomputedFlag` has none, + `FlagCacheEntry.key` is required); set `value` = typed `variationValue` **and** a string + `variationValue` (`JSON.stringify` for objects, lowercase `"true"/"false"` for booleans) so + Android exposure tracking round-trips; `serialId?: number | null`; validate `variationType` + ∈ the four values and reject unknowns; fail predictably if `obfuscated`. +- **FFL-2688 (setConfiguration + matching):** normalize the wire `context` through the *same* + `processEvaluationContext` before comparing — the raw wire context is flat + `{ targetingKey, …attrs }` while the active one is `{ targetingKey, attributes: {…} }`, so a + naive deep-equal always mismatches; gate the match on config kind = precomputed; emit + `PROVIDER_ERROR` via the provider event emitter (do **not** reject `initialize` / + `onContextChange`); distinguish empty/absent precomputed (`PROVIDER_ERROR`) from `FLAG_NOT_FOUND`. +- **FFL-2718 (fetchPolicy):** the `NEVER` branch must set `evaluationContext` **synchronously** + (no `await`, no throw) and re-run matching, or the provider is stuck `PROVIDER_NOT_READY`. +- **FFL-2689 (provider):** one owner of `{ loadedConfig, activeContext, cache }`; define ordering + of `setConfiguration` vs the `contextChangePromise` chain and reset it to resolved after + failures so later context changes aren't poisoned; keep any future rules evaluator in a + tree-shakeable entry point. +- **FFL-2691 (RUM FIT):** iOS derives numeric type from `value` at runtime (whole number → + integer, fractional → double) — watch int/double parity vs Android in the RUM assertions. + ## Open items (non-blocking) - Check with the flags team whether the `PrecomputedFlag`-style shape seen in the sample From 34452382afbea56886fb619aafc197149fc0ba05 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 11:37:04 -0400 Subject: [PATCH 17/22] docs(plan): add per-step meta-plan for future context-free agents Add an "Executing a step" section: what to read (this doc, the RFCs, the Jira ticket + acceptance checklist), where to ground in code/formats, the required shape of the per-step implementation plan (explicit files, signatures, return types, tests), the guardrails, and a reusable prompt. Lets a fresh-context agent execute any PR step consistently. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index c007dc295..f191d697a 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -301,6 +301,69 @@ Ordering rationale: leaf-first (pure, tested transforms), then client behavior, control, then the public surface — one reviewable idea per PR, tests co-located. Jira encodes this order with `Blocks` links: 2686 → 2687 → 2688 → 2718 → 2689 → 2690 → 2691. +## Executing a step (meta-plan for future agents) + +Each PR in the [Delivery plan](#delivery-plan-prs) is a "step." A future agent may start a step +with **no prior context**. To execute a step (one of FFL-2686 / 2687 / 2688 / 2718 / 2689 / 2690 / +2691), follow this procedure. + +**1. Orient — read, in this order:** +- This file (`provider_set_configuration.md`) end to end — it is the source of truth for scope, + design decisions, wire/format shapes, the config-kind vs `fetchPolicy` split, and the + [acceptance criteria](#review-driven-acceptance-criteria-must-fix-at-implementation). +- `Offline-Initialization-for-Feature-Flagging.md` (read fully — small) and + `Portable-Flag-Configuration-RFC.md` (repo root) for intent. ⚠️ The Portable RFC is ~200 KB + with an embedded base64 image — **do not read it whole; grep it for specific terms.** +- The step's Jira ticket(s) via the Atlassian MCP (`getJiraIssue`, cloudId + `datadoghq.atlassian.net`); parent is FFL-2666. **Read the "Acceptance checklist" in the ticket + description — every item is a requirement for the step.** The same items are mirrored in + [Review-driven acceptance criteria](#review-driven-acceptance-criteria-must-fix-at-implementation) + below in case the MCP is unavailable (if it is, ask the user to authenticate). + +**2. Ground in the code & formats — only what the step touches:** +- Current flags code: `packages/core/src/flags/{FlagsClient.ts,DdFlags.ts,types.ts,internal.ts}`, + `packages/core/src/specs/NativeDdFlags.ts`, `packages/react-native-openfeature/src/provider.ts`. +- Reference to **port from** (do NOT add it as a dependency): + `~/dd/openfeature-js-client/packages/core/src/configuration/{wire.ts,configuration.ts}`; for + provider behavior, `~/dd/openfeature-js-client/packages/{browser,node-server}/src/…provider*.ts`. +- Sample precomputed CDN payload for fixtures: `example.json` (repo root, untracked). +- Native tracking path (for parity — **not** to change): iOS + `example-new-architecture/ios/Pods/DatadogFlags/DatadogFlags/Sources/…`; Android in the + `dd-sdk-android-flags` `.aar` in the gradle cache. +- Format specs (Confluence, via Atlassian MCP `getConfluencePage`): ConfigurationWire + `5141725646`, PrecomputedConfiguration `5141791092`, RUM FIT `6896386323`. + +**3. Produce a short implementation plan _before_ writing code.** It must be explicit: +- **Files** to add/change (exact paths). +- **Function / type signatures** with parameter and **return types**, e.g. + `configurationFromString(wire: string): ParsedFlagsConfiguration`. +- **Behavior** for each acceptance-checklist item, and how each is tested (test file paths + cases). +- Anything touching **native** — expected to be **none** (see + [Native tracking path](#native-tracking-path-and-setevaluationcontext)); if a step seems to + need a native change, stop and flag it to the user. + +**4. Guardrails:** +- **JS-only, no Swift/Kotlin changes.** Evaluation and per-flag tracking already run off + JS-supplied data. +- Use the agreed names: `ParsedFlagsConfiguration` (never reuse `FlagsConfiguration`, the + `enable()` options type); config-kind chosen by which wire branch is populated; `fetchPolicy` + is network-only, passed inside an options object; mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT`. +- Keep new parse/decode helpers **internal (un-exported)** until the exports step (FFL-2690). +- Ship unit tests **in the same PR** (no trailing test-only PR). +- Delivery is **stacked PRs**: base each step on the previous step's branch (base of the stack is + `develop`), in the order the Jira `Blocks` links encode. +- Writing style for plans/PRs: state facts and assumptions plainly; don't overstate certainty + about things that are still assumptions. + +**5. Reusable prompt** — instantiate `{TICKET}` and `{STEP}`: + +> Read `Offline-Initialization-for-Feature-Flagging.md` and `Portable-Flag-Configuration-RFC.md` +> (grep the Portable RFC — do not read it whole). Read `provider_set_configuration.md` and the +> Jira ticket `{TICKET}` (Atlassian MCP, cloudId `datadoghq.atlassian.net`); note its acceptance +> checklist. Then produce a short plan to achieve `{STEP}` of `provider_set_configuration.md`, +> with **explicit changes** — filenames, function signatures, return types, and the test cases +> covering each acceptance item — before writing any code. + ## Review-driven acceptance criteria (must-fix at implementation) From a two-agent plan review. These are silent-failure risks; mirrored as acceptance checklists From dd81cb1acda94b79b9f9f3c80fd0c48e6ae6575c Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 12:18:14 -0400 Subject: [PATCH 18/22] docs(plan): add per-PR branch naming convention Name each step branch blake.thomas/FFL-2666-PR{N}, based on the previous step's branch. PR1 branches off this planning branch so every step inherits the plan. Add a Branch column to the delivery table. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index f191d697a..7b40b4fe5 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -289,18 +289,22 @@ coverage (FFL-2691) lands after the feature is complete. PR1 ─▶ PR2 ─▶ PR3 ─▶ PR4 (each branch based on the previous) ``` -| PR | Sub-tasks | Scope | -| :- | :-------- | :---- | -| PR1 | FFL-2686 + FFL-2687 | Pure JS: wire parse + precomputed → `FlagCacheEntry`, with fixtures. Kept internal (un-exported) until PR4. | -| PR2 | FFL-2688 | `FlagsClient.setConfiguration` + context matching; mismatch → `PROVIDER_ERROR` / `INVALID_CONTEXT`. | -| PR3 | FFL-2718 | `fetchPolicy` `ALWAYS`/`NEVER`. Code-independent of PR1/PR2 — can be authored in parallel and rebased into the stack. | -| PR4 | FFL-2689 + FFL-2690 | OpenFeature provider `setConfiguration` + events, public exports, docs, example. | -| after | FFL-2691 | RUM FIT integration/e2e: an offline-loaded flag evaluation reports to RUM with parity to the fetch path. | +| PR | Branch | Sub-tasks | Scope | +| :- | :----- | :-------- | :---- | +| PR1 | `blake.thomas/FFL-2666-PR1` | FFL-2686 + FFL-2687 | Pure JS: wire parse + precomputed → `FlagCacheEntry`, with fixtures. Kept internal (un-exported) until PR4. | +| PR2 | `blake.thomas/FFL-2666-PR2` | FFL-2688 | `FlagsClient.setConfiguration` + context matching; mismatch → `PROVIDER_ERROR` / `INVALID_CONTEXT`. | +| PR3 | `blake.thomas/FFL-2666-PR3` | FFL-2718 | `fetchPolicy` `ALWAYS`/`NEVER`. Code-independent of PR1/PR2 — can be authored in parallel and rebased into the stack. | +| PR4 | `blake.thomas/FFL-2666-PR4` | FFL-2689 + FFL-2690 | OpenFeature provider `setConfiguration` + events, public exports, docs, example. | +| after | (RUM FIT repos) | FFL-2691 | RUM FIT integration/e2e in the test-framework repos: an offline-loaded flag evaluation reports to RUM with parity to the fetch path. | Ordering rationale: leaf-first (pure, tested transforms), then client behavior, then fetch control, then the public surface — one reviewable idea per PR, tests co-located. Jira encodes this order with `Blocks` links: 2686 → 2687 → 2688 → 2718 → 2689 → 2690 → 2691. +**Branch naming & base:** each step branch is `blake.thomas/FFL-2666-PR{N}`, based on the +previous step's branch. PR1 is based on this planning branch (`blake.thomas/FFL-2666`) so every +step inherits this plan. (FFL-2691 lands in the RUM FIT repos, not this one.) + ## Executing a step (meta-plan for future agents) Each PR in the [Delivery plan](#delivery-plan-prs) is a "step." A future agent may start a step @@ -350,8 +354,9 @@ with **no prior context**. To execute a step (one of FFL-2686 / 2687 / 2688 / 27 is network-only, passed inside an options object; mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT`. - Keep new parse/decode helpers **internal (un-exported)** until the exports step (FFL-2690). - Ship unit tests **in the same PR** (no trailing test-only PR). -- Delivery is **stacked PRs**: base each step on the previous step's branch (base of the stack is - `develop`), in the order the Jira `Blocks` links encode. +- Delivery is **stacked PRs** named `blake.thomas/FFL-2666-PR{N}`: base each step on the previous + step's branch, in the order the Jira `Blocks` links encode. The base of the stack (PR1) is this + planning branch `blake.thomas/FFL-2666`, so every step inherits this plan. - Writing style for plans/PRs: state facts and assumptions plainly; don't overstate certainty about things that are still assumptions. From 70406da12f6b97aade7c9f4619d5431aafe66aaa Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 12:31:13 -0400 Subject: [PATCH 19/22] Update provider_set_configuration.md Co-authored-by: Oleksii Shmalko --- provider_set_configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 7b40b4fe5..3f062c840 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -115,7 +115,7 @@ with RN's existing `FlagCacheEntry`: format: 'PRECOMPUTED' environment: { name: string } flags: Record Date: Tue, 7 Jul 2026 13:01:01 -0400 Subject: [PATCH 20/22] docs(plan): add open question on persisting loaded config to disk A config loaded via setConfiguration is in-memory only today. Capture as an open question whether to offer a persist option for offline last-known-values across cold starts (wire vs parsed, JS-side store vs native flagsDataStore, auto-load + fetchPolicy interaction, staleness). Likely a fast-follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 3f062c840..ece14f0c7 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -412,3 +412,13 @@ on each sub-task. config and report `Stale`, but never serve a config that doesn't match the context) and a staleness axis (`fetchedAt`/`etag`/`expiresAt`-driven refresh) are separate from context matching and out of this MVP. +- **Persist the loaded configuration to disk?** Today a config loaded via `setConfiguration` is + in-memory only — on the next cold start the customer must call `setConfiguration` again. An + option (e.g. `setConfiguration(config, { persist: true })`) could cache it for offline + last-known-values across restarts. Sub-questions: (a) persist the raw wire string or the parsed + config; (b) where — a JS-side store (AsyncStorage / filesystem / MMKV) or the native + `flagsDataStore` (native work, breaks the JS-only guarantee); (c) auto-load on next launch and + how that interacts with `fetchPolicy` (`NEVER` + persisted = offline startup with no network); + (d) staleness / eviction of the persisted copy. Note the native SDK already persists its own + fetched flags to disk, but RN's JS read path does not consume that store. Likely a fast-follow, + not MVP. From 240fa366c8c87b8305c3f493ccf2736ef19d2c9e Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 13:06:06 -0400 Subject: [PATCH 21/22] docs(plan): reconcile variationType accepted set with integer/float The wire variationType union now includes integer and float, so widen the FFL-2687 validation to boolean|string|number|integer|float|object and note that integer/float map to a JS number for value while the original variationType string is preserved for Android's exposure round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index ece14f0c7..502a8745f 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -382,7 +382,9 @@ on each sub-task. `FlagCacheEntry.key` is required); set `value` = typed `variationValue` **and** a string `variationValue` (`JSON.stringify` for objects, lowercase `"true"/"false"` for booleans) so Android exposure tracking round-trips; `serialId?: number | null`; validate `variationType` - ∈ the four values and reject unknowns; fail predictably if `obfuscated`. + ∈ `boolean | string | number | integer | float | object` and reject unknowns; map both + `integer` and `float` to a JS `number` for `value` while preserving the original + `variationType` string for Android's round-trip; fail predictably if `obfuscated`. - **FFL-2688 (setConfiguration + matching):** normalize the wire `context` through the *same* `processEvaluationContext` before comparing — the raw wire context is flat `{ targetingKey, …attrs }` while the active one is `{ targetingKey, attributes: {…} }`, so a From 340691b54a08a00ca75e52552510dc3f6408b0c1 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 18:15:22 -0400 Subject: [PATCH 22/22] docs(plan): replace fetchPolicy with an OfflineProvider plan Deliver never-fetch as a dedicated offline OpenFeature provider rather than a fetchPolicy flag. Its initialize/onContextChange use FlagsClient.setEvaluationContextWithoutFetching and never hit the CDN. Simpler core, no online/offline mixing, all-JS (no native changes), and the same provider extends to future offline dynamic rules. Collapse the delivery to three stacked PRs (PR2 = core offline API incl. the no-fetch method, PR3 = OfflineProvider + exports) and update the work breakdown and acceptance. Co-Authored-By: Claude Opus 4.8 (1M context) --- provider_set_configuration.md | 133 +++++++++++++++++----------------- 1 file changed, 66 insertions(+), 67 deletions(-) diff --git a/provider_set_configuration.md b/provider_set_configuration.md index 502a8745f..d5947e80c 100644 --- a/provider_set_configuration.md +++ b/provider_set_configuration.md @@ -1,7 +1,7 @@ # `provider.setConfiguration` — Offline Init for React Native Feature Flags **Jira:** [FFL-2666](https://datadoghq.atlassian.net/browse/FFL-2666) -**Status:** Planning (no implementation yet) +**Status:** In progress — see [Delivery plan](#delivery-plan-prs) ## Goal @@ -57,16 +57,18 @@ into the same `FlagCacheEntry` map and populate `flagsCache`. - **Input** is a `ConfigurationWire` string, consumed via `configurationFromString(wire)`. - **No Swift/Kotlin changes** — evaluation and per-flag exposure tracking already run off JS-provided data. -- **Expose the API on both** the core `FlagsClient` and the `DatadogOpenFeatureProvider`. +- **Expose `setConfiguration`** on the core `FlagsClient` and on a new **`OfflineProvider`** + (see [Offline provider](#offline-provider)). - **Avoid the `FlagsConfiguration` name collision.** That identifier is already the `enable()` options type (`packages/core/src/flags/types.ts`). Name the parsed-wire config type distinctly — this plan uses **`ParsedFlagsConfiguration`** — so `configurationFromString` / `setConfiguration` don't overload the existing type. - The customer still calls `setEvaluationContext` themselves; `setConfiguration` must **check that the precomputed config matches the active context** (see below). -- Add a **`fetchPolicy`** (`ALWAYS` default / `NEVER` / `ON_MISMATCH`) set at `enable()` with a - per-`getClient()` override, so customers can turn off the fetch-on-`setEvaluationContext`. - Offline init needs `NEVER`; see [Fetch policy](#fetch-policy). +- **Never-fetch via a dedicated `OfflineProvider`, not a `fetchPolicy` flag.** The offline + behavior is delivered by a second OpenFeature provider whose `initialize`/`onContextChange` + use `FlagsClient.setEvaluationContextWithoutFetching` and never hit the CDN. See + [Offline provider](#offline-provider). - **Port, don't depend.** `openfeature-js-client` is not a dependency here (only upstream `@openfeature/core` + `@openfeature/web-sdk` are). Port its small pure helpers — `wire.ts` (`configurationFromString`/`configurationToString`) and `configMatchesContext` — @@ -168,8 +170,10 @@ Keep two axes separate so the future rules mode is additive, not a reshape: wire branch is populated (the way the reference providers do it): `precomputed` → look up an assignment that **must match** the active context; `server` (future rules/UFC) → evaluate the active context **locally** and serve *any* context. Only `precomputed` is implemented now. -- **`fetchPolicy`** — *whether* the SDK may hit the network on `setEvaluationContext` - ([Fetch policy](#fetch-policy)). Network posture only; it does not select the evaluation mode. +- **Provider choice** — *whether* the SDK may hit the network on `setEvaluationContext` depends + on which provider you use: `DatadogOpenFeatureProvider` (online, fetches) vs + `OfflineProvider` (never fetches; see [Offline provider](#offline-provider)). Network posture + only; it does not select the evaluation mode. The context match/mismatch rules below apply to the **precomputed** kind; a rules config is context-agnostic (below). @@ -227,45 +231,40 @@ this.evaluationContext = processedContext; this.flagsCache = result; // overwrites loaded config ``` -In the offline flow the customer still calls `setEvaluationContext` (needed for context matching -and to hand a context to `trackEvaluation`), so under `fetchPolicy: NEVER` this path records the -context without invoking the native fetch and without overwriting the config-populated cache. -This is a JS-only change. See [Fetch policy](#fetch-policy) below. +For the offline flow the client exposes **`setEvaluationContextWithoutFetching(context)`**: it +records the context and reconciles the loaded config (context matching for precomputed) without +invoking the native fetch. The `OfflineProvider` uses this in `initialize`/`onContextChange`. +This is a JS-only change. See [Offline provider](#offline-provider) below. -## Fetch policy +## Offline provider -Because `setEvaluationContext` fetches from the CDN today (see above), offline customers need an -explicit way to turn that off — and a hard "no network" guarantee, not just "usually won't." We -add a `fetchPolicy` set at `enable()` (global default) with an optional per-`getClient()` -override: +Rather than a `fetchPolicy` flag on the core, the never-fetch behavior is a **provider choice**. +Alongside the existing `DatadogOpenFeatureProvider` (online — fetches on context changes), add an +**`OfflineProvider`** (working name; likely `DatadogOfflineOpenFeatureProvider`) in +`react-native-openfeature`. It is identical to `DatadogOpenFeatureProvider` **except**: -```ts -enum FetchPolicy { - ALWAYS, // fetch on setEvaluationContext (today's behavior; default) - NEVER, // never fetch; serve only configurations supplied via setConfiguration - ON_MISMATCH, // use a matching supplied config; fetch only when it doesn't match the context -} -``` +- `initialize` / `onContextChange` call `FlagsClient.setEvaluationContextWithoutFetching` instead + of the fetching `setEvaluationContext`, so it **never hits the CDN**. +- It exposes `setConfiguration` (delegating to the `FlagsClient`) and re-exports + `configurationFromString`. -- **`ALWAYS`** — default; preserves current behavior. A `setConfiguration` bootstrap is - overwritten by the fetch, so this mode is mostly for online apps. -- **`NEVER`** — MVP target for offline. `setEvaluationContext` records the context but does not - call the native fetch or overwrite the cache; the client serves only what `setConfiguration` - loaded. A context set with no matching config → provider not-ready / `PROVIDER_ERROR`. -- **`ON_MISMATCH`** — *fast-follow, not in this MVP.* Bootstrap-then-refresh: serve a supplied - config when it matches the active context, otherwise fetch. Adds async fetch orchestration, - `Reconciling`/`Stale`/`Ready` sequencing, and a fetch-failure fallback, so it is deferred. +Everything else — `resolveBoolean/String/Number/Object`, `toDdContext`, `toFlagResolution`, the +event emitter — is shared with `DatadogOpenFeatureProvider` (via a common base/helpers). -Only `ALWAYS` (default) and `NEVER` are built now. `ON_MISMATCH` is declared for forward-compat -and implemented later. A mutable runtime setter is intentionally left out of v1 to avoid races -with in-flight fetches and already-loaded config. +Why a provider instead of a flag: -`fetchPolicy` is passed inside an **options object** at `enable()` / `getClient()` (not a bare -positional enum) so it can grow fields later — e.g. `ttl`, `staleWhileRevalidate` for the future -staleness axis — without an API break. +- **Simpler core** — no `fetchPolicy` enum or options object on `enable()`/`getClient()`. +- **No online/offline mixing** — a given client is driven by one provider, so "always fetch" and + "never fetch" never contend on the same `FlagsClient` (this dissolves the precedence/overlay + edge cases from the PR2 review). +- **Extensible** — the same `OfflineProvider` serves future offline **dynamic rules**: + `onContextChange` re-evaluates the ruleset locally (still no fetch); only the core evaluator + gains a rules branch later. +- **No native changes** — the offline path uses existing native `enable` + `trackEvaluation` and + skips native `setEvaluationContext` entirely; evaluation is all JS. -`fetchPolicy` is purely the **network axis**; *how* a config is evaluated is set by its -[configuration kind](#configuration-kind-evaluation-mode), not by `fetchPolicy`. +`OfflineProvider` emits the OpenFeature events: first configuration → `PROVIDER_READY`, +subsequent → `PROVIDER_CONFIGURATION_CHANGED`, invalid/mismatch → `PROVIDER_ERROR`. ## Work breakdown @@ -274,32 +273,30 @@ staleness axis — without an API break. | [FFL-2686](https://datadoghq.atlassian.net/browse/FFL-2686) | `configurationFromString` + `ParsedFlagsConfiguration` type (distinct from the existing `enable()` `FlagsConfiguration`; parse wire v1; lenient — empty config on invalid/unknown version; extensible for `server`/rules) | | [FFL-2687](https://datadoghq.atlassian.net/browse/FFL-2687) | Decode precomputed `flags` (assumed `PrecomputedFlag` shape) → `FlagCacheEntry` map — plain JSON; inject `key`; derive typed `value` + string `variationValue`; fail predictably if `attributes.obfuscated` | | [FFL-2688](https://datadoghq.atlassian.net/browse/FFL-2688) | `FlagsClient.setConfiguration` + order-independent context matching (port `configMatchesContext`, gated on config kind = precomputed); mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT` | -| [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `fetchPolicy` enum + wiring via an **options object** at `enable()` default + `getClient()` override; implement `ALWAYS` (default) and `NEVER` (under `NEVER`, `setEvaluationContext` skips the native fetch / cache overwrite and sets context synchronously). `ON_MISMATCH` declared, implemented later | -| [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | OpenFeature provider `setConfiguration` + lifecycle events | +| [FFL-2718](https://datadoghq.atlassian.net/browse/FFL-2718) | `FlagsClient.setEvaluationContextWithoutFetching` — set context + reconcile the loaded config with no native fetch (the primitive the `OfflineProvider` uses). Replaces the earlier `fetchPolicy` flag | +| [FFL-2689](https://datadoghq.atlassian.net/browse/FFL-2689) | `OfflineProvider` (never-fetch OpenFeature provider) + `setConfiguration` surface + lifecycle events | | [FFL-2690](https://datadoghq.atlassian.net/browse/FFL-2690) | Public exports, types & docs (core + openfeature + example) | | [FFL-2691](https://datadoghq.atlassian.net/browse/FFL-2691) | Integration / e2e tests (RUM FIT) — offline-loaded flag → RUM parity; unit tests ship inside each PR above | ## Delivery plan (PRs) -To keep each changeset easy to review, the seven sub-tasks ship as **four stacked PRs**, each +To keep each changeset easy to review, the work ships as **three stacked PRs**, each self-contained with its own unit tests (no trailing test-only PR). RUM FIT integration/e2e -coverage (FFL-2691) lands after the feature is complete. +coverage (FFL-2691) lands after the feature is complete, in the test-framework repos. ``` -PR1 ─▶ PR2 ─▶ PR3 ─▶ PR4 (each branch based on the previous) +PR1 ─▶ PR2 ─▶ PR3 (each branch based on the previous) ``` | PR | Branch | Sub-tasks | Scope | | :- | :----- | :-------- | :---- | -| PR1 | `blake.thomas/FFL-2666-PR1` | FFL-2686 + FFL-2687 | Pure JS: wire parse + precomputed → `FlagCacheEntry`, with fixtures. Kept internal (un-exported) until PR4. | -| PR2 | `blake.thomas/FFL-2666-PR2` | FFL-2688 | `FlagsClient.setConfiguration` + context matching; mismatch → `PROVIDER_ERROR` / `INVALID_CONTEXT`. | -| PR3 | `blake.thomas/FFL-2666-PR3` | FFL-2718 | `fetchPolicy` `ALWAYS`/`NEVER`. Code-independent of PR1/PR2 — can be authored in parallel and rebased into the stack. | -| PR4 | `blake.thomas/FFL-2666-PR4` | FFL-2689 + FFL-2690 | OpenFeature provider `setConfiguration` + events, public exports, docs, example. | -| after | (RUM FIT repos) | FFL-2691 | RUM FIT integration/e2e in the test-framework repos: an offline-loaded flag evaluation reports to RUM with parity to the fetch path. | +| PR1 | `blake.thomas/FFL-2666-PR1` | FFL-2686 + FFL-2687 | Pure JS: wire parse + precomputed → `FlagCacheEntry`, with fixtures. Internal (un-exported). | +| PR2 | `blake.thomas/FFL-2666-PR2` | FFL-2688 + FFL-2718 | Core offline API on `FlagsClient`: `setConfiguration` + context matching + `setEvaluationContextWithoutFetching`. | +| PR3 | `blake.thomas/FFL-2666-PR3` | FFL-2689 + FFL-2690 | `OfflineProvider` (never-fetch) + `setConfiguration` surface + events + public exports/docs/example. | +| after | (RUM FIT repos) | FFL-2691 | RUM FIT integration/e2e: an offline-loaded flag evaluation reports to RUM with parity to the fetch path. | -Ordering rationale: leaf-first (pure, tested transforms), then client behavior, then fetch -control, then the public surface — one reviewable idea per PR, tests co-located. Jira encodes -this order with `Blocks` links: 2686 → 2687 → 2688 → 2718 → 2689 → 2690 → 2691. +Ordering rationale: leaf-first (pure, tested transforms) → core client API → the provider + +public surface. One reviewable idea per PR, tests co-located. **Branch naming & base:** each step branch is `blake.thomas/FFL-2666-PR{N}`, based on the previous step's branch. PR1 is based on this planning branch (`blake.thomas/FFL-2666`) so every @@ -313,7 +310,7 @@ with **no prior context**. To execute a step (one of FFL-2686 / 2687 / 2688 / 27 **1. Orient — read, in this order:** - This file (`provider_set_configuration.md`) end to end — it is the source of truth for scope, - design decisions, wire/format shapes, the config-kind vs `fetchPolicy` split, and the + design decisions, wire/format shapes, the config-kind vs provider-choice split, and the [acceptance criteria](#review-driven-acceptance-criteria-must-fix-at-implementation). - `Offline-Initialization-for-Feature-Flagging.md` (read fully — small) and `Portable-Flag-Configuration-RFC.md` (repo root) for intent. ⚠️ The Portable RFC is ~200 KB @@ -350,8 +347,8 @@ with **no prior context**. To execute a step (one of FFL-2686 / 2687 / 2688 / 27 - **JS-only, no Swift/Kotlin changes.** Evaluation and per-flag tracking already run off JS-supplied data. - Use the agreed names: `ParsedFlagsConfiguration` (never reuse `FlagsConfiguration`, the - `enable()` options type); config-kind chosen by which wire branch is populated; `fetchPolicy` - is network-only, passed inside an options object; mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT`. + `enable()` options type); config-kind chosen by which wire branch is populated; never-fetch is + a provider choice (`OfflineProvider`), not a flag; mismatch → `PROVIDER_ERROR` + `INVALID_CONTEXT`. - Keep new parse/decode helpers **internal (un-exported)** until the exports step (FFL-2690). - Ship unit tests **in the same PR** (no trailing test-only PR). - Delivery is **stacked PRs** named `blake.thomas/FFL-2666-PR{N}`: base each step on the previous @@ -391,11 +388,13 @@ on each sub-task. naive deep-equal always mismatches; gate the match on config kind = precomputed; emit `PROVIDER_ERROR` via the provider event emitter (do **not** reject `initialize` / `onContextChange`); distinguish empty/absent precomputed (`PROVIDER_ERROR`) from `FLAG_NOT_FOUND`. -- **FFL-2718 (fetchPolicy):** the `NEVER` branch must set `evaluationContext` **synchronously** - (no `await`, no throw) and re-run matching, or the provider is stuck `PROVIDER_NOT_READY`. -- **FFL-2689 (provider):** one owner of `{ loadedConfig, activeContext, cache }`; define ordering - of `setConfiguration` vs the `contextChangePromise` chain and reset it to resolved after - failures so later context changes aren't poisoned; keep any future rules evaluator in a +- **FFL-2718 (`setEvaluationContextWithoutFetching`):** sets the context and re-runs matching + with **no native call**; a context change re-validates the loaded config (match serves, + mismatch → `INVALID_CONTEXT`). Replaces the earlier `fetchPolicy` flag. +- **FFL-2689 (`OfflineProvider`):** `initialize`/`onContextChange` use + `setEvaluationContextWithoutFetching` (never fetch); shares code with + `DatadogOpenFeatureProvider`; one owner of `{ loadedConfig, activeContext, cache }`; a rejected + promise must not poison later `onContextChange`; keep any future rules evaluator in a tree-shakeable entry point. - **FFL-2691 (RUM FIT):** iOS derives numeric type from `value` at runtime (whole number → integer, fractional → double) — watch int/double parity vs Android in the RUM assertions. @@ -408,19 +407,19 @@ on each sub-task. - Precomputed context mismatch surfaces as a `PROVIDER_ERROR` event/state plus an `INVALID_CONTEXT` evaluation error code (extending RN's `FlagErrorCode` union with this existing OpenFeature code) — current direction for RFC open Q2. -- `setConfiguration` sync vs `Promise`-returning (JS-only work is synchronous, but a Promise - keeps parity with `setEvaluationContext` and forward-compat for rules). -- For the future `ON_MISMATCH` policy: fetch-failure fallback (keep serving the previous usable - config and report `Stale`, but never serve a config that doesn't match the context) and a - staleness axis (`fetchedAt`/`etag`/`expiresAt`-driven refresh) are separate from context - matching and out of this MVP. +- Resolved: `setConfiguration` and `setEvaluationContextWithoutFetching` are synchronous + (`void`) — JS-only work, no I/O. +- Future online refresh / bootstrap-then-refresh (an online provider that starts from a supplied + config, then refreshes via the CDN): fetch-failure fallback (keep serving the previous usable + config, report `Stale`, never serve a mismatched config) and a staleness axis + (`fetchedAt`/`etag`/`expiresAt`) are separate from context matching and out of this MVP. - **Persist the loaded configuration to disk?** Today a config loaded via `setConfiguration` is in-memory only — on the next cold start the customer must call `setConfiguration` again. An option (e.g. `setConfiguration(config, { persist: true })`) could cache it for offline last-known-values across restarts. Sub-questions: (a) persist the raw wire string or the parsed config; (b) where — a JS-side store (AsyncStorage / filesystem / MMKV) or the native `flagsDataStore` (native work, breaks the JS-only guarantee); (c) auto-load on next launch and - how that interacts with `fetchPolicy` (`NEVER` + persisted = offline startup with no network); + how it interacts with the `OfflineProvider` (persisted + offline = startup with no network); (d) staleness / eviction of the persisted copy. Note the native SDK already persists its own fetched flags to disk, but RN's JS read path does not consume that store. Likely a fast-follow, not MVP.