diff --git a/provider_set_configuration.md b/provider_set_configuration.md new file mode 100644 index 000000000..d5947e80c --- /dev/null +++ b/provider_set_configuration.md @@ -0,0 +1,425 @@ +# `provider.setConfiguration` — Offline Init for React Native Feature Flags + +**Jira:** [FFL-2666](https://datadoghq.atlassian.net/browse/FFL-2666) +**Status:** In progress — see [Delivery plan](#delivery-plan-prs) + +## 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 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`) 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 + 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`. +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 + +- **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 `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). +- **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` — + into RN core rather than depending on the package, whose provider/exposure-logging would + 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 + +**ConfigurationWire v1** ([ConfigurationWire](https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/5141725646/ConfigurationWire)): + +```ts +type ConfigurationWire = string // JSON-serialized +type Configuration = { + version: 1 + precomputed?: { + response: string // JSON-encoded PrecomputedConfiguration + context?: EvaluationContext // the context the assignments were computed for + fetchedAt?: number + etag?: string + } + server?: { // rules-based (UFC) config — out of scope for MVP + response: string // JSON-encoded server configuration + fetchedAt?: number + etag?: string + } +} +``` + +**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: { + 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 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 (the sample response carries + `obfuscated: false`) — parsing is plain JSON → object mapping (no key hashing, no + 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 + 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. +- 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 — 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`. + +## 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. +- **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). + +## 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. + +**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 +`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 +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` + +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 +``` + +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. + +## Offline provider + +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**: + +- `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`. + +Everything else — `resolveBoolean/String/Number/Object`, `toDdContext`, `toFlagResolution`, the +event emitter — is shared with `DatadogOpenFeatureProvider` (via a common base/helpers). + +Why a provider instead of a flag: + +- **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. + +`OfflineProvider` emits the OpenFeature events: first configuration → `PROVIDER_READY`, +subsequent → `PROVIDER_CONFIGURATION_CHANGED`, invalid/mismatch → `PROVIDER_ERROR`. + +## 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 — 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) | `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 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, in the test-framework repos. + +``` +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. 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) → 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 +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 +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 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 + 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; 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 + 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. + +**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 +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` + ∈ `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 + 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 (`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. + +## Open items (non-blocking) + +- 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 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. +- 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 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.