From f75af5e065815c67dcaaae8ae7d78e7b82d4e208 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 29 Sep 2026 11:20:19 -0400 Subject: [PATCH 1/4] docs: amend ADR 0009 to define cache behavior with no targeting key Signed-off-by: Jonathan Norris --- ...al-storage-for-static-context-providers.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/service/adrs/0009-local-storage-for-static-context-providers.md b/service/adrs/0009-local-storage-for-static-context-providers.md index 3b45619..1990bfc 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -8,6 +8,8 @@ Accepted Proposed amendment (2026-06-19): tie the cache key to the OFREP resource the evaluation was fetched from by including the provider's bound `domain`, the OFREP base URL, and the auth credential, in addition to the `targetingKey`, and expose a cache-key generator function so applications can customize the key. See [open-feature/spec#393](https://github.com/open-feature/spec/pull/393). +Proposed amendment (2026-09-29): define cache behavior when the evaluation context has no `targetingKey`. The `targetingKey` is optional in the OpenFeature evaluation context, so the cache key must be well defined in its absence: an absent key is encoded as a distinct absent value rather than coalesced to an empty string, and the rule for clearing a persisted entry is expressed in terms of the derived cache key rather than the `targetingKey` alone. + ## Context OFREP static-context providers evaluate all flags in one request and then serve evaluations from a local cache. @@ -65,6 +67,7 @@ type CacheKeyGenerator = (input: { The default generator combines the OFREP base URL, auth credential, bound `domain`, and `targetingKey` into unambiguous key material. How those fields are combined and hashed is an implementation detail, but the combination must be injective so distinct inputs cannot collide. +Injectivity applies to absent values too: an absent `targetingKey` must not produce the same key material as an empty-string `targetingKey`, and the same holds for `auth` and `domain` (see "Missing targeting key" below). Example persisted value: @@ -237,6 +240,20 @@ In `network-first` mode, fallback to a persisted entry is limited to network err When the provider has already initialized from cache (cache hit path in `local-cache-first` mode), authorization or configuration errors from the background refresh should be logged and emitted as `PROVIDER_ERROR` events. The provider should continue serving cached values for the current session rather than revoking a working state. Auth or config errors alone should not invalidate the persisted entry; the cache TTL is what governs when it stops being served. This avoids degrading subsequent cold starts to defaults while the error is investigated. +### Missing targeting key + +The `targetingKey` is optional in the OpenFeature evaluation context, so a static-context provider may be asked to persist an evaluation for a context that has none. This is a supported case and must not be treated as an error: providers must not refuse to persist, refuse to load, or raise `TARGETING_KEY_MISSING` solely because the context has no `targetingKey`. + +An absent `targetingKey` contributes a component that encodes absence, distinct from every string value including `""`. The cache key then reduces to the OFREP resource inputs (base URL, auth credential, and bound `domain`), exactly as an absent `domain` or absent auth credential does. + +Two consequences follow, and providers should document both. + +First, **all contexts without a `targetingKey` share one persisted entry** for a given OFREP resource. The key carries no identity component, so nothing invalidates the entry when the subject changes. This is the intended behavior: an anonymous context asserts no identity, so there is no identity to separate entries by. It does mean that on a shared device, two anonymous sessions read the same persisted evaluation. Applications where that is not acceptable should set a `targetingKey`, supply a cache-key generator that includes a distinguishing context property, or set `cacheMode` to `disabled`. + +Second, **an absent `targetingKey` and an empty-string `targetingKey` must produce different key material.** Coalescing a missing key to `""` makes the two indistinguishable, and SDKs already diverge on whether a missing, null, or empty targeting key are equivalent (see [open-feature/flagd#1948](https://github.com/open-feature/flagd/issues/1948)). The cache should not silently merge them: an application that sets `targetingKey: ""` has supplied a value, even an unhelpful one, and should not share a cache entry with an application that supplied nothing. The injectivity requirement on the key material combination already implies this; it is stated here because coalescing with a default (`targetingKey ?? ""`) is the natural implementation and violates it. + +Clearing a persisted entry is governed by the derived cache key, not the `targetingKey` in isolation. Providers should compare the key material produced for the old and new contexts and clear the old entry when it changes. Comparing `targetingKey` values alone is incorrect in two directions once the key is customizable: a configured cache-key generator that incorporates other context properties can change the key while the `targetingKey` is untouched, leaving an orphaned entry; and treating absent and empty-string as different `targetingKey` values while the generator maps them to the same key material would clear an entry that the new context is about to reuse, forcing an unnecessary fetch. + ### Refresh and revalidation When connectivity returns or during normal polling, the provider should resume its normal refresh behavior. @@ -310,7 +327,7 @@ A single default (local-cache-first) with an explicit per-application opt-out is - Providers should avoid persisting raw `targetingKey` values when `cacheKeyHash` is sufficient for matching - Providers should expose a `cacheMode` option with values `local-cache-first` (default), `network-first`, and `disabled`. `network-first` and `disabled` block `initialize()` on the network request; `local-cache-first` returns from `initialize()` immediately when a persisted entry exists - Providers should expose an optional cache-key generator function so applications and wrapping providers can customize the key material (narrowing or broadening the default); the provider always hashes whatever the generator returns -- Providers should clear or replace persisted entries when the cache key changes, such as on logout or user switch (`targetingKey` change) or when the provider is re-bound to a different `domain` +- Providers should clear or replace persisted entries when the derived cache key changes, such as on logout or user switch (a `targetingKey` change, including to or from absent) or when the provider is re-bound to a different `domain`. The comparison should be made on the key material the cache-key generator produces, not on the `targetingKey` alone - In `local-cache-first` mode, the `initialize()` function should return immediately when a matching cached entry exists, allowing the SDK to emit `PROVIDER_READY` from cache - Providers should emit `PROVIDER_CONFIGURATION_CHANGED` when fresh values replace cached values after a background refresh - If `onContextChanged()` is called while a background refresh is still in-flight, the provider should cancel or discard the in-flight request. The context-change evaluation supersedes it and should be the authoritative write to the persisted entry From 1c1d01359debd36e3f23a20bb9d6d8e28f3164b0 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 29 Sep 2026 11:24:00 -0400 Subject: [PATCH 2/4] docs: treat absent and empty-string targeting keys as equivalent for cache keying Signed-off-by: Jonathan Norris --- .../0009-local-storage-for-static-context-providers.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/service/adrs/0009-local-storage-for-static-context-providers.md b/service/adrs/0009-local-storage-for-static-context-providers.md index 1990bfc..3d045f0 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -8,7 +8,7 @@ Accepted Proposed amendment (2026-06-19): tie the cache key to the OFREP resource the evaluation was fetched from by including the provider's bound `domain`, the OFREP base URL, and the auth credential, in addition to the `targetingKey`, and expose a cache-key generator function so applications can customize the key. See [open-feature/spec#393](https://github.com/open-feature/spec/pull/393). -Proposed amendment (2026-09-29): define cache behavior when the evaluation context has no `targetingKey`. The `targetingKey` is optional in the OpenFeature evaluation context, so the cache key must be well defined in its absence: an absent key is encoded as a distinct absent value rather than coalesced to an empty string, and the rule for clearing a persisted entry is expressed in terms of the derived cache key rather than the `targetingKey` alone. +Proposed amendment (2026-09-29): define cache behavior when the evaluation context has no `targetingKey`. The `targetingKey` is optional in the OpenFeature evaluation context, so the cache key must be well defined in its absence: an absent key normalizes to an empty string and keys the same entry as an empty-string key, and the rule for clearing a persisted entry is expressed in terms of the derived cache key rather than the `targetingKey` alone. ## Context @@ -67,7 +67,7 @@ type CacheKeyGenerator = (input: { The default generator combines the OFREP base URL, auth credential, bound `domain`, and `targetingKey` into unambiguous key material. How those fields are combined and hashed is an implementation detail, but the combination must be injective so distinct inputs cannot collide. -Injectivity applies to absent values too: an absent `targetingKey` must not produce the same key material as an empty-string `targetingKey`, and the same holds for `auth` and `domain` (see "Missing targeting key" below). +Absent values are normalized before the components are combined: an absent `targetingKey`, `auth`, or `domain` is treated as an empty string. Injectivity applies over the normalized components (see "Missing targeting key" below). Example persisted value: @@ -244,15 +244,15 @@ When the provider has already initialized from cache (cache hit path in `local-c The `targetingKey` is optional in the OpenFeature evaluation context, so a static-context provider may be asked to persist an evaluation for a context that has none. This is a supported case and must not be treated as an error: providers must not refuse to persist, refuse to load, or raise `TARGETING_KEY_MISSING` solely because the context has no `targetingKey`. -An absent `targetingKey` contributes a component that encodes absence, distinct from every string value including `""`. The cache key then reduces to the OFREP resource inputs (base URL, auth credential, and bound `domain`), exactly as an absent `domain` or absent auth credential does. +An absent `targetingKey` normalizes to an empty string, so the cache key reduces to the OFREP resource inputs (base URL, auth credential, and bound `domain`), exactly as an absent `domain` or absent auth credential does. Two consequences follow, and providers should document both. First, **all contexts without a `targetingKey` share one persisted entry** for a given OFREP resource. The key carries no identity component, so nothing invalidates the entry when the subject changes. This is the intended behavior: an anonymous context asserts no identity, so there is no identity to separate entries by. It does mean that on a shared device, two anonymous sessions read the same persisted evaluation. Applications where that is not acceptable should set a `targetingKey`, supply a cache-key generator that includes a distinguishing context property, or set `cacheMode` to `disabled`. -Second, **an absent `targetingKey` and an empty-string `targetingKey` must produce different key material.** Coalescing a missing key to `""` makes the two indistinguishable, and SDKs already diverge on whether a missing, null, or empty targeting key are equivalent (see [open-feature/flagd#1948](https://github.com/open-feature/flagd/issues/1948)). The cache should not silently merge them: an application that sets `targetingKey: ""` has supplied a value, even an unhelpful one, and should not share a cache entry with an application that supplied nothing. The injectivity requirement on the key material combination already implies this; it is stated here because coalescing with a default (`targetingKey ?? ""`) is the natural implementation and violates it. +Second, **an absent `targetingKey` and an empty-string `targetingKey` key the same entry.** Both normalize to an empty string. This follows the direction the ecosystem settled on: [open-feature/flagd#1948](https://github.com/open-feature/flagd/issues/1948) found that SDKs diverged on whether a missing, null, or empty targeting key were equivalent, and resolved that all three should behave identically. The cache should not introduce a distinction the rest of the ecosystem does not make, and neither the OpenFeature specification nor any SDK assigns an empty targeting key a meaning separate from an absent one. -Clearing a persisted entry is governed by the derived cache key, not the `targetingKey` in isolation. Providers should compare the key material produced for the old and new contexts and clear the old entry when it changes. Comparing `targetingKey` values alone is incorrect in two directions once the key is customizable: a configured cache-key generator that incorporates other context properties can change the key while the `targetingKey` is untouched, leaving an orphaned entry; and treating absent and empty-string as different `targetingKey` values while the generator maps them to the same key material would clear an entry that the new context is about to reuse, forcing an unnecessary fetch. +Clearing a persisted entry is governed by the derived cache key, not the `targetingKey` in isolation. Providers should compare the key material produced for the old and new contexts and clear the old entry when it changes. Comparing `targetingKey` values alone is incorrect in two directions once the key is customizable: a configured cache-key generator that incorporates other context properties can change the key while the `targetingKey` is untouched, leaving an orphaned entry; and treating absent and empty-string as different `targetingKey` values, when both normalize to the same key material, would clear an entry that the new context is about to reuse, forcing an unnecessary fetch. ### Refresh and revalidation From 4563415ba86685aa73f1dcc2abd152468ecf67e5 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 29 Sep 2026 11:26:40 -0400 Subject: [PATCH 3/4] docs: simplify and shorten the missing targeting key section Signed-off-by: Jonathan Norris --- ...cal-storage-for-static-context-providers.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/service/adrs/0009-local-storage-for-static-context-providers.md b/service/adrs/0009-local-storage-for-static-context-providers.md index 3d045f0..6c97832 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -8,7 +8,7 @@ Accepted Proposed amendment (2026-06-19): tie the cache key to the OFREP resource the evaluation was fetched from by including the provider's bound `domain`, the OFREP base URL, and the auth credential, in addition to the `targetingKey`, and expose a cache-key generator function so applications can customize the key. See [open-feature/spec#393](https://github.com/open-feature/spec/pull/393). -Proposed amendment (2026-09-29): define cache behavior when the evaluation context has no `targetingKey`. The `targetingKey` is optional in the OpenFeature evaluation context, so the cache key must be well defined in its absence: an absent key normalizes to an empty string and keys the same entry as an empty-string key, and the rule for clearing a persisted entry is expressed in terms of the derived cache key rather than the `targetingKey` alone. +Proposed amendment (2026-09-29): define the cache key when the evaluation context has no `targetingKey`. An absent `targetingKey` normalizes to an empty string, and entries are cleared based on the derived cache key rather than the `targetingKey` alone. ## Context @@ -67,7 +67,7 @@ type CacheKeyGenerator = (input: { The default generator combines the OFREP base URL, auth credential, bound `domain`, and `targetingKey` into unambiguous key material. How those fields are combined and hashed is an implementation detail, but the combination must be injective so distinct inputs cannot collide. -Absent values are normalized before the components are combined: an absent `targetingKey`, `auth`, or `domain` is treated as an empty string. Injectivity applies over the normalized components (see "Missing targeting key" below). +Absent values normalize to an empty string before the components are combined, and injectivity applies over the normalized components. Example persisted value: @@ -242,17 +242,13 @@ When the provider has already initialized from cache (cache hit path in `local-c ### Missing targeting key -The `targetingKey` is optional in the OpenFeature evaluation context, so a static-context provider may be asked to persist an evaluation for a context that has none. This is a supported case and must not be treated as an error: providers must not refuse to persist, refuse to load, or raise `TARGETING_KEY_MISSING` solely because the context has no `targetingKey`. +The `targetingKey` is optional, so a provider may be asked to persist an evaluation for a context without one. This is a supported case: providers must not refuse to persist or load, and must not raise `TARGETING_KEY_MISSING`, because the context has no `targetingKey`. -An absent `targetingKey` normalizes to an empty string, so the cache key reduces to the OFREP resource inputs (base URL, auth credential, and bound `domain`), exactly as an absent `domain` or absent auth credential does. +An absent `targetingKey` normalizes to an empty string, so the key reduces to the OFREP resource inputs (base URL, auth credential, and bound `domain`). An absent and an empty-string `targetingKey` therefore key the same entry, matching [open-feature/flagd#1948](https://github.com/open-feature/flagd/issues/1948), which resolved that a missing, null, and empty targeting key behave identically. -Two consequences follow, and providers should document both. +Providers should document one consequence: all contexts without a `targetingKey` share a single persisted entry per OFREP resource. The key holds no identity, so nothing invalidates the entry when the subject changes, and on a shared device two anonymous sessions read the same evaluation. Applications that need them separated should set a `targetingKey`, add a distinguishing property through the cache-key generator, or set `cacheMode` to `disabled`. -First, **all contexts without a `targetingKey` share one persisted entry** for a given OFREP resource. The key carries no identity component, so nothing invalidates the entry when the subject changes. This is the intended behavior: an anonymous context asserts no identity, so there is no identity to separate entries by. It does mean that on a shared device, two anonymous sessions read the same persisted evaluation. Applications where that is not acceptable should set a `targetingKey`, supply a cache-key generator that includes a distinguishing context property, or set `cacheMode` to `disabled`. - -Second, **an absent `targetingKey` and an empty-string `targetingKey` key the same entry.** Both normalize to an empty string. This follows the direction the ecosystem settled on: [open-feature/flagd#1948](https://github.com/open-feature/flagd/issues/1948) found that SDKs diverged on whether a missing, null, or empty targeting key were equivalent, and resolved that all three should behave identically. The cache should not introduce a distinction the rest of the ecosystem does not make, and neither the OpenFeature specification nor any SDK assigns an empty targeting key a meaning separate from an absent one. - -Clearing a persisted entry is governed by the derived cache key, not the `targetingKey` in isolation. Providers should compare the key material produced for the old and new contexts and clear the old entry when it changes. Comparing `targetingKey` values alone is incorrect in two directions once the key is customizable: a configured cache-key generator that incorporates other context properties can change the key while the `targetingKey` is untouched, leaving an orphaned entry; and treating absent and empty-string as different `targetingKey` values, when both normalize to the same key material, would clear an entry that the new context is about to reuse, forcing an unnecessary fetch. +Entries are cleared based on the derived cache key, not the `targetingKey` alone. A cache-key generator that includes other context properties can change the key while the `targetingKey` is unchanged, leaving a stale entry behind. ### Refresh and revalidation @@ -327,7 +323,7 @@ A single default (local-cache-first) with an explicit per-application opt-out is - Providers should avoid persisting raw `targetingKey` values when `cacheKeyHash` is sufficient for matching - Providers should expose a `cacheMode` option with values `local-cache-first` (default), `network-first`, and `disabled`. `network-first` and `disabled` block `initialize()` on the network request; `local-cache-first` returns from `initialize()` immediately when a persisted entry exists - Providers should expose an optional cache-key generator function so applications and wrapping providers can customize the key material (narrowing or broadening the default); the provider always hashes whatever the generator returns -- Providers should clear or replace persisted entries when the derived cache key changes, such as on logout or user switch (a `targetingKey` change, including to or from absent) or when the provider is re-bound to a different `domain`. The comparison should be made on the key material the cache-key generator produces, not on the `targetingKey` alone +- Providers should clear or replace persisted entries when the derived cache key changes, such as on logout or user switch (a `targetingKey` change, including to or from absent) or when the provider is re-bound to a different `domain`. Compare the key material the generator produces, not the `targetingKey` alone - In `local-cache-first` mode, the `initialize()` function should return immediately when a matching cached entry exists, allowing the SDK to emit `PROVIDER_READY` from cache - Providers should emit `PROVIDER_CONFIGURATION_CHANGED` when fresh values replace cached values after a background refresh - If `onContextChanged()` is called while a background refresh is still in-flight, the provider should cancel or discard the in-flight request. The context-change evaluation supersedes it and should be the authoritative write to the persisted entry From dc25e72896958ec13a13e0322c57a44fe1c6e9e9 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 29 Sep 2026 11:32:20 -0400 Subject: [PATCH 4/4] docs: do not persist evaluations without a targeting key Signed-off-by: Jonathan Norris --- ...9-local-storage-for-static-context-providers.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/service/adrs/0009-local-storage-for-static-context-providers.md b/service/adrs/0009-local-storage-for-static-context-providers.md index 6c97832..f3cb9a6 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -8,7 +8,7 @@ Accepted Proposed amendment (2026-06-19): tie the cache key to the OFREP resource the evaluation was fetched from by including the provider's bound `domain`, the OFREP base URL, and the auth credential, in addition to the `targetingKey`, and expose a cache-key generator function so applications can customize the key. See [open-feature/spec#393](https://github.com/open-feature/spec/pull/393). -Proposed amendment (2026-09-29): define the cache key when the evaluation context has no `targetingKey`. An absent `targetingKey` normalizes to an empty string, and entries are cleared based on the derived cache key rather than the `targetingKey` alone. +Proposed amendment (2026-09-29): do not persist evaluations for contexts with no `targetingKey`, since the cache key would then carry no identity and the entry could be served to a different subject. Applications that want persistence for anonymous contexts opt in with a cache-key generator. ## Context @@ -67,7 +67,6 @@ type CacheKeyGenerator = (input: { The default generator combines the OFREP base URL, auth credential, bound `domain`, and `targetingKey` into unambiguous key material. How those fields are combined and hashed is an implementation detail, but the combination must be injective so distinct inputs cannot collide. -Absent values normalize to an empty string before the components are combined, and injectivity applies over the normalized components. Example persisted value: @@ -242,13 +241,15 @@ When the provider has already initialized from cache (cache hit path in `local-c ### Missing targeting key -The `targetingKey` is optional, so a provider may be asked to persist an evaluation for a context without one. This is a supported case: providers must not refuse to persist or load, and must not raise `TARGETING_KEY_MISSING`, because the context has no `targetingKey`. +The `targetingKey` is optional, so a provider may be asked to evaluate a context without one. Providers must still evaluate normally and must not raise `TARGETING_KEY_MISSING`, but they should not persist the result. -An absent `targetingKey` normalizes to an empty string, so the key reduces to the OFREP resource inputs (base URL, auth credential, and bound `domain`). An absent and an empty-string `targetingKey` therefore key the same entry, matching [open-feature/flagd#1948](https://github.com/open-feature/flagd/issues/1948), which resolved that a missing, null, and empty targeting key behave identically. +Without a `targetingKey` the cache key carries no identity, so it reduces to the OFREP resource inputs (base URL, auth credential, and bound `domain`) and is identical for every context against that resource. Nothing invalidates the entry when the subject or the rest of the context changes. An application that signs a user out and continues anonymously, or that changes anonymous properties such as `country` or `plan`, would be served the previous evaluation even though the server would return different values. -Providers should document one consequence: all contexts without a `targetingKey` share a single persisted entry per OFREP resource. The key holds no identity, so nothing invalidates the entry when the subject changes, and on a shared device two anonymous sessions read the same evaluation. Applications that need them separated should set a `targetingKey`, add a distinguishing property through the cache-key generator, or set `cacheMode` to `disabled`. +Providers should therefore treat a context with no `targetingKey` as non-persistable, neither reading nor writing an entry, so `local-cache-first` behaves like `disabled` for that context. A provider holding an entry from an earlier context that had a `targetingKey` should clear it when the `targetingKey` is removed. -Entries are cleared based on the derived cache key, not the `targetingKey` alone. A cache-key generator that includes other context properties can change the key while the `targetingKey` is unchanged, leaving a stale entry behind. +Applications that want persistence for anonymous contexts should supply a cache-key generator. Configuring one asserts that the returned key material identifies the subject, for example a stable device or anonymous ID, and the provider persists using that key. + +Entries are cleared based on the derived cache key, not the `targetingKey` alone. A generator that includes other context properties can change the key while the `targetingKey` is unchanged, leaving a stale entry behind. ### Refresh and revalidation @@ -323,6 +324,7 @@ A single default (local-cache-first) with an explicit per-application opt-out is - Providers should avoid persisting raw `targetingKey` values when `cacheKeyHash` is sufficient for matching - Providers should expose a `cacheMode` option with values `local-cache-first` (default), `network-first`, and `disabled`. `network-first` and `disabled` block `initialize()` on the network request; `local-cache-first` returns from `initialize()` immediately when a persisted entry exists - Providers should expose an optional cache-key generator function so applications and wrapping providers can customize the key material (narrowing or broadening the default); the provider always hashes whatever the generator returns +- Providers should not read or write a persisted entry for a context with no `targetingKey` unless a cache-key generator is configured; `local-cache-first` behaves like `disabled` for such contexts - Providers should clear or replace persisted entries when the derived cache key changes, such as on logout or user switch (a `targetingKey` change, including to or from absent) or when the provider is re-bound to a different `domain`. Compare the key material the generator produces, not the `targetingKey` alone - In `local-cache-first` mode, the `initialize()` function should return immediately when a matching cached entry exists, allowing the SDK to emit `PROVIDER_READY` from cache - Providers should emit `PROVIDER_CONFIGURATION_CHANGED` when fresh values replace cached values after a background refresh