From 2ae6ebe9a20e181a8cd07fc85ac88c831d58638e Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 6 Mar 2026 19:38:57 +0000 Subject: [PATCH 01/39] Add ADR for offline local storage cache Co-authored-by: Jonathan Norris Signed-off-by: Norris Signed-off-by: Jonathan Norris --- ...8-localStorageForStaticContextProviders.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 service/adrs/0008-localStorageForStaticContextProviders.md diff --git a/service/adrs/0008-localStorageForStaticContextProviders.md b/service/adrs/0008-localStorageForStaticContextProviders.md new file mode 100644 index 0000000..6335cc0 --- /dev/null +++ b/service/adrs/0008-localStorageForStaticContextProviders.md @@ -0,0 +1,98 @@ +# 8. Persist static-context evaluations in local storage by default for web and mobile providers + +Date: 2026-03-06 + +## Status + +Proposed + +## Context + +OFREP static-context providers evaluate all flags in one request and then serve evaluations from a local cache. +This model works well when the provider can reach the OFREP service during initialization and while polling for updates. + +Web and mobile applications often operate with intermittent connectivity. +They are also frequently restarted, which means an in-memory cache is lost between sessions. +Today, a client may have a previously successful bulk evaluation, but if it starts while offline it cannot reuse that evaluation unless the provider implementation persists it locally. + +This creates a poor experience for static-context providers in the environments they are primarily meant to support. +An offline user may see feature state regress to errors or code defaults even though the application already had a usable last-known evaluation. + +OFREP already supports local cached evaluation, bulk evaluation, polling, and ETag-based revalidation. +Persisting the last successful static-context evaluation extends the existing cache model to survive application restarts and temporary loss of connectivity. + +## Decision + +Web and mobile OFREP providers that implement the static-context paradigm should persist their last successful bulk evaluation in local persistent storage by default. + +The persisted entry should include: + +- the bulk evaluation payload +- the associated `ETag`, if one was returned +- enough metadata to determine whether the entry applies to the current provider instance, such as the OFREP endpoint and the static evaluation context or a stable derived key for it +- the time the entry was written + +The provider should continue to use its in-memory cache for normal flag evaluation. +Persistent local storage acts as the source used to bootstrap or recover that in-memory cache. + +During initialization, a provider should: + +1. Attempt to load a matching persisted bulk evaluation from local storage. +2. Attempt the normal `/ofrep/v1/evaluate/flags` request. +3. If the request succeeds, populate the in-memory cache and update the persisted entry. +4. If the request cannot complete because the client is offline or the network is temporarily unavailable, and a matching persisted entry exists, populate the in-memory cache from that persisted entry and continue operating from it. +5. If no matching persisted entry exists, preserve the existing initialization failure behavior. + +Providers should only reuse a persisted evaluation when it matches the current static-context inputs. +At minimum, this includes the target OFREP service and the evaluation context. +Implementations may include additional inputs in the cache key when they affect the returned evaluation. + +Fallback to persisted data is intended for offline or transient network failures. +Providers should not silently fall back to persisted data for authorization failures, invalid requests, or other server responses that indicate a configuration or protocol problem. + +When connectivity returns, the provider should resume its normal refresh behavior. +If an `ETag` was stored with the persisted entry, the provider should use it with `If-None-Match` when revalidating the bulk evaluation. + +Providers should allow applications to disable the default persistence behavior or replace the storage backend when platform requirements or policy constraints require it. + +## Consequences + +### Positive + +- Static-context providers become resilient to offline application startup when a last-known evaluation exists +- Web and mobile applications preserve feature state across restarts instead of losing it with the in-memory cache +- The decision aligns with the existing OFREP model where static-context providers evaluate remotely once and then read locally +- Reusing the stored `ETag` allows efficient revalidation when connectivity returns +- Provider implementations get a consistent default expectation for offline behavior across ecosystems + +### Negative + +- Providers become more complex because they must manage persistence, cache-key matching, and recovery flows +- Persisted evaluations may become stale, so applications can continue using outdated flag values while offline +- Local persistent storage can be unavailable, limited in size, or restricted by platform policy +- Persisting evaluation data introduces security and privacy considerations, especially if flag metadata or context-derived identifiers are sensitive +- Mobile platforms do not share a single storage API, so providers may need platform-specific defaults behind a common abstraction + +## Alternatives Considered + +### Keep static-context caches in memory only + +This keeps provider implementations simpler, but it means offline startup cannot use a previously successful evaluation. +That undermines a core advantage of static-context evaluation for web and mobile environments. + +### Make persistence opt-in instead of the default + +This reduces default behavior changes, but it produces inconsistent offline behavior across provider implementations and requires every application to rediscover and enable the same capability. +For web and mobile static-context providers, persistence is expected behavior rather than an exceptional optimization. + +### Add protocol-level support for offline snapshots + +This could standardize snapshot delivery more explicitly, but it would require protocol changes. +Persisting the existing bulk evaluation response is sufficient for the current need and can be implemented entirely within providers. + +## Implementation Notes + +- "Local storage" means a local persistent key-value store appropriate for the runtime, such as browser `localStorage` on the web or an equivalent mobile storage mechanism +- Providers should version their persisted format so future schema changes can be handled safely +- Providers should clear or replace persisted entries when the static context changes, such as on logout or user switch +- SDK documentation should describe that offline fallback uses the last successful bulk evaluation and may therefore serve stale values until connectivity returns From 758492976c467f7b7d59e45b6c737bdf3835d0d2 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 6 Mar 2026 19:41:20 +0000 Subject: [PATCH 02/39] Renumber local storage ADR to 0009 Co-authored-by: Jonathan Norris Signed-off-by: Norris Signed-off-by: Jonathan Norris --- ...oviders.md => 0009-localStorageForStaticContextProviders.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename service/adrs/{0008-localStorageForStaticContextProviders.md => 0009-localStorageForStaticContextProviders.md} (98%) diff --git a/service/adrs/0008-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md similarity index 98% rename from service/adrs/0008-localStorageForStaticContextProviders.md rename to service/adrs/0009-localStorageForStaticContextProviders.md index 6335cc0..062a689 100644 --- a/service/adrs/0008-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -1,4 +1,4 @@ -# 8. Persist static-context evaluations in local storage by default for web and mobile providers +# 9. Persist static-context evaluations in local storage by default for web and mobile providers Date: 2026-03-06 From db1f6824f6b67bb2feba9a4ada6a397183d73d7a Mon Sep 17 00:00:00 2001 From: Norris Date: Sat, 7 Mar 2026 13:20:20 +0100 Subject: [PATCH 03/39] docs(adr): refine static-context local persistence proposal Clarify ADR 0009 with provider behavior, persistence examples, and implementation guidance for local cached bulk evaluations. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- ...9-localStorageForStaticContextProviders.md | 103 +++++++++++++----- 1 file changed, 78 insertions(+), 25 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 062a689..5d52337 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -1,4 +1,4 @@ -# 9. Persist static-context evaluations in local storage by default for web and mobile providers +# 9. Persist static-context evaluations in local storage by default Date: 2026-03-06 @@ -9,29 +9,48 @@ Proposed ## Context OFREP static-context providers evaluate all flags in one request and then serve evaluations from a local cache. -This model works well when the provider can reach the OFREP service during initialization and while polling for updates. +Current implementations in `js-sdk-contrib`, `kotlin-sdk-contrib`, and `ofrep-swift-client-provider` keep that cache in memory only. -Web and mobile applications often operate with intermittent connectivity. -They are also frequently restarted, which means an in-memory cache is lost between sessions. -Today, a client may have a previously successful bulk evaluation, but if it starts while offline it cannot reuse that evaluation unless the provider implementation persists it locally. +Static-context providers are primarily web and mobile providers, where applications are often restarted or temporarily offline. +In those cases, the last successful bulk evaluation is lost and applications fall back to errors or code defaults instead of continuing with a usable last-known state. +This is also out of step with most vendor-provided web and mobile SDKs for the same class of provider, which persist flag state to local storage or on-device disk by default. -This creates a poor experience for static-context providers in the environments they are primarily meant to support. -An offline user may see feature state regress to errors or code defaults even though the application already had a usable last-known evaluation. - -OFREP already supports local cached evaluation, bulk evaluation, polling, and ETag-based revalidation. -Persisting the last successful static-context evaluation extends the existing cache model to survive application restarts and temporary loss of connectivity. +Persisting the last successful static-context evaluation would extend the existing cache model across restarts and temporary connectivity loss without requiring protocol changes. ## Decision -Web and mobile OFREP providers that implement the static-context paradigm should persist their last successful bulk evaluation in local persistent storage by default. +Static-context providers should persist their last successful bulk evaluation in local persistent storage by default. The persisted entry should include: - the bulk evaluation payload - the associated `ETag`, if one was returned -- enough metadata to determine whether the entry applies to the current provider instance, such as the OFREP endpoint and the static evaluation context or a stable derived key for it +- a stable derived cache key for determining whether the entry applies to the current provider instance, such as a hash derived from the `targetingKey`, auth token, and other inputs that affect the returned evaluation - the time the entry was written +Providers may store this as a single fixed local record, for example under a runtime-appropriate key such as `ofrepLocalCache`, and replace that record on each successful refresh. +In that model, the stored value should contain the persisted bulk evaluation together with the derived cache-key hash, rather than storing raw `targetingKey` and auth token values on disk. + +Example persisted value: + +```json +{ + "cacheKeyHash": "sha256:3e0f5c7d...", + "etag": "\"abc123\"", + "writtenAt": "2026-03-07T18:20:00Z", + "data": { + "flags": [ + { + "key": "discount-banner", + "value": true, + "reason": "TARGETING_MATCH", + "variant": "enabled" + } + ] + } +} +``` + The provider should continue to use its in-memory cache for normal flag evaluation. Persistent local storage acts as the source used to bootstrap or recover that in-memory cache. @@ -43,8 +62,42 @@ During initialization, a provider should: 4. If the request cannot complete because the client is offline or the network is temporarily unavailable, and a matching persisted entry exists, populate the in-memory cache from that persisted entry and continue operating from it. 5. If no matching persisted entry exists, preserve the existing initialization failure behavior. +```mermaid +sequenceDiagram + participant App as Application + participant Provider as OFREP Provider + participant Storage as Local Storage + participant Server as OFREP Service + + App->>Provider: initialize(targetingKey, auth token) + Provider->>Storage: load persisted evaluation + Storage-->>Provider: matching entry or none + Provider->>Server: POST /ofrep/v1/evaluate/flags + alt Request succeeds + Server-->>Provider: 200 OK (flags + ETag) + Provider->>Provider: Populate in-memory cache + Provider->>Storage: Persist flags + ETag + else Network unavailable + alt Matching persisted entry exists + Provider->>Provider: Populate in-memory cache from persisted entry + else No matching persisted entry + Provider-->>App: Initialization failure + end + end + + Note over App,Server: Later, when connectivity returns + Provider->>Server: POST /ofrep/v1/evaluate/flags with If-None-Match + alt Flags changed + Server-->>Provider: 200 OK (new flags + ETag) + Provider->>Provider: Update in-memory cache + Provider->>Storage: Replace persisted entry + else Flags unchanged + Server-->>Provider: 304 Not Modified + end +``` + Providers should only reuse a persisted evaluation when it matches the current static-context inputs. -At minimum, this includes the target OFREP service and the evaluation context. +At minimum, this includes a matching derived cache key based on the current `targetingKey` and auth token. Implementations may include additional inputs in the cache key when they affect the returned evaluation. Fallback to persisted data is intended for offline or transient network failures. @@ -75,24 +128,24 @@ Providers should allow applications to disable the default persistence behavior ## Alternatives Considered -### Keep static-context caches in memory only - -This keeps provider implementations simpler, but it means offline startup cannot use a previously successful evaluation. -That undermines a core advantage of static-context evaluation for web and mobile environments. - ### Make persistence opt-in instead of the default This reduces default behavior changes, but it produces inconsistent offline behavior across provider implementations and requires every application to rediscover and enable the same capability. -For web and mobile static-context providers, persistence is expected behavior rather than an exceptional optimization. - -### Add protocol-level support for offline snapshots - -This could standardize snapshot delivery more explicitly, but it would require protocol changes. -Persisting the existing bulk evaluation response is sufficient for the current need and can be implemented entirely within providers. +For static-context providers, especially web and mobile providers, persistence is expected behavior rather than an exceptional optimization. ## Implementation Notes - "Local storage" means a local persistent key-value store appropriate for the runtime, such as browser `localStorage` on the web or an equivalent mobile storage mechanism - Providers should version their persisted format so future schema changes can be handled safely -- Providers should clear or replace persisted entries when the static context changes, such as on logout or user switch +- Providers may use a single fixed storage key or filename and store the matching information inside the record as a derived cache-key hash +- Providers should avoid persisting raw `targetingKey` and auth token values when a derived cache key is sufficient for matching +- Providers should clear or replace persisted entries when the `targetingKey` or auth token changes, such as on logout or user switch - SDK documentation should describe that offline fallback uses the last successful bulk evaluation and may therefore serve stale values until connectivity returns + +## Open Question + +Should providers fall back to persisted data only when the client is offline or the network is temporarily unavailable, or should they also fall back for: + +- authorization failures +- invalid requests +- other server responses that indicate a configuration or protocol problem From 6ece5e52ba44f94beb5c9610be999e1691e6aeb7 Mon Sep 17 00:00:00 2001 From: Norris Date: Sat, 7 Mar 2026 13:33:11 +0100 Subject: [PATCH 04/39] docs(adr): clarify fallback semantics in ADR 0009 Clarify initialization flow, explain the persisted timestamp, and define temporary server failures as eligible for persisted fallback. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- ...9-localStorageForStaticContextProviders.md | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 5d52337..8de61a2 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -26,7 +26,7 @@ The persisted entry should include: - the bulk evaluation payload - the associated `ETag`, if one was returned - a stable derived cache key for determining whether the entry applies to the current provider instance, such as a hash derived from the `targetingKey`, auth token, and other inputs that affect the returned evaluation -- the time the entry was written +- the time the entry was written, which can be used for diagnostics and optional implementation-specific staleness policies Providers may store this as a single fixed local record, for example under a runtime-appropriate key such as `ofrepLocalCache`, and replace that record on each successful refresh. In that model, the stored value should contain the persisted bulk evaluation together with the derived cache-key hash, rather than storing raw `targetingKey` and auth token values on disk. @@ -58,9 +58,11 @@ During initialization, a provider should: 1. Attempt to load a matching persisted bulk evaluation from local storage. 2. Attempt the normal `/ofrep/v1/evaluate/flags` request. -3. If the request succeeds, populate the in-memory cache and update the persisted entry. -4. If the request cannot complete because the client is offline or the network is temporarily unavailable, and a matching persisted entry exists, populate the in-memory cache from that persisted entry and continue operating from it. -5. If no matching persisted entry exists, preserve the existing initialization failure behavior. +3. If the request succeeds, populate the in-memory cache from the response and update the persisted entry. +4. If the request cannot complete because the client is offline, the network is temporarily unavailable, or the server is temporarily unavailable, such as a `5xx` response: + - If a matching persisted entry exists, populate the in-memory cache from that persisted entry and continue operating from it. + - If no matching persisted entry exists, preserve the existing initialization failure behavior. +5. If the request fails for authorization, invalid requests, or other responses that indicate a configuration or protocol problem, preserve the existing initialization failure behavior. ```mermaid sequenceDiagram @@ -100,8 +102,8 @@ Providers should only reuse a persisted evaluation when it matches the current s At minimum, this includes a matching derived cache key based on the current `targetingKey` and auth token. Implementations may include additional inputs in the cache key when they affect the returned evaluation. -Fallback to persisted data is intended for offline or transient network failures. -Providers should not silently fall back to persisted data for authorization failures, invalid requests, or other server responses that indicate a configuration or protocol problem. +Fallback to persisted data is intended for offline, transient network failures, or temporary server unavailability such as `5xx` responses. +Providers should not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. When connectivity returns, the provider should resume its normal refresh behavior. If an `ETag` was stored with the persisted entry, the provider should use it with `If-None-Match` when revalidating the bulk evaluation. @@ -141,11 +143,3 @@ For static-context providers, especially web and mobile providers, persistence i - Providers should avoid persisting raw `targetingKey` and auth token values when a derived cache key is sufficient for matching - Providers should clear or replace persisted entries when the `targetingKey` or auth token changes, such as on logout or user switch - SDK documentation should describe that offline fallback uses the last successful bulk evaluation and may therefore serve stale values until connectivity returns - -## Open Question - -Should providers fall back to persisted data only when the client is offline or the network is temporarily unavailable, or should they also fall back for: - -- authorization failures -- invalid requests -- other server responses that indicate a configuration or protocol problem From ba6c4c781d312ac7e469029b993c733cbc17104d Mon Sep 17 00:00:00 2001 From: Norris Date: Sat, 7 Mar 2026 13:47:57 +0100 Subject: [PATCH 05/39] docs(adr): clarify cache key guidance in ADR 0009 Specify the cacheKeyHash formula and restore explicit open questions for reviewer feedback. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- ...9-localStorageForStaticContextProviders.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 8de61a2..41e7561 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -25,17 +25,17 @@ The persisted entry should include: - the bulk evaluation payload - the associated `ETag`, if one was returned -- a stable derived cache key for determining whether the entry applies to the current provider instance, such as a hash derived from the `targetingKey`, auth token, and other inputs that affect the returned evaluation +- a `cacheKeyHash` equal to `sha256(authToken + targetingKey)` - the time the entry was written, which can be used for diagnostics and optional implementation-specific staleness policies Providers may store this as a single fixed local record, for example under a runtime-appropriate key such as `ofrepLocalCache`, and replace that record on each successful refresh. -In that model, the stored value should contain the persisted bulk evaluation together with the derived cache-key hash, rather than storing raw `targetingKey` and auth token values on disk. +In that model, the stored value should contain the persisted bulk evaluation together with `cacheKeyHash = sha256(authToken + targetingKey)`, rather than storing raw `targetingKey` and auth token values on disk. Example persisted value: ```json { - "cacheKeyHash": "sha256:3e0f5c7d...", + "cacheKeyHash": "sha256(authToken + targetingKey)", "etag": "\"abc123\"", "writtenAt": "2026-03-07T18:20:00Z", "data": { @@ -99,8 +99,7 @@ sequenceDiagram ``` Providers should only reuse a persisted evaluation when it matches the current static-context inputs. -At minimum, this includes a matching derived cache key based on the current `targetingKey` and auth token. -Implementations may include additional inputs in the cache key when they affect the returned evaluation. +This includes a matching `cacheKeyHash` equal to `sha256(authToken + targetingKey)`. Fallback to persisted data is intended for offline, transient network failures, or temporary server unavailability such as `5xx` responses. Providers should not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. @@ -139,7 +138,13 @@ For static-context providers, especially web and mobile providers, persistence i - "Local storage" means a local persistent key-value store appropriate for the runtime, such as browser `localStorage` on the web or an equivalent mobile storage mechanism - Providers should version their persisted format so future schema changes can be handled safely -- Providers may use a single fixed storage key or filename and store the matching information inside the record as a derived cache-key hash -- Providers should avoid persisting raw `targetingKey` and auth token values when a derived cache key is sufficient for matching +- Providers may use a single fixed storage key or filename and store the matching information inside the record as `cacheKeyHash` +- `cacheKeyHash` should be `sha256(authToken + targetingKey)` +- Providers should avoid persisting raw `targetingKey` and auth token values when `cacheKeyHash` is sufficient for matching - Providers should clear or replace persisted entries when the `targetingKey` or auth token changes, such as on logout or user switch - SDK documentation should describe that offline fallback uses the last successful bulk evaluation and may therefore serve stale values until connectivity returns + +## Open Questions + +1. Should providers fall back to persisted data only when the client is offline or the network is temporarily unavailable, or should they also fall back for authorization failures, invalid requests, or other server responses that indicate a configuration or protocol problem? +2. Should providers also persist the full evaluation context used for the cached bulk evaluation, so that when falling back to persisted values they can override the current context with the cached context that produced those values? From 9672519e090dab5457298c2bd40485d69b2e76bd Mon Sep 17 00:00:00 2001 From: Norris Date: Sat, 7 Mar 2026 13:52:08 +0100 Subject: [PATCH 06/39] docs(adr): add disableLocalCache option to ADR 0009 Document an explicit provider option for turning off persisted local storage. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 41e7561..eedd9ad 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -107,7 +107,7 @@ Providers should not silently fall back to persisted data for authorization fail When connectivity returns, the provider should resume its normal refresh behavior. If an `ETag` was stored with the persisted entry, the provider should use it with `If-None-Match` when revalidating the bulk evaluation. -Providers should allow applications to disable the default persistence behavior or replace the storage backend when platform requirements or policy constraints require it. +Providers should allow applications to disable the default persistence behavior, for example with a `disableLocalCache` option, or replace the storage backend when platform requirements or policy constraints require it. ## Consequences @@ -141,6 +141,7 @@ For static-context providers, especially web and mobile providers, persistence i - Providers may use a single fixed storage key or filename and store the matching information inside the record as `cacheKeyHash` - `cacheKeyHash` should be `sha256(authToken + targetingKey)` - Providers should avoid persisting raw `targetingKey` and auth token values when `cacheKeyHash` is sufficient for matching +- Providers should expose a `disableLocalCache` option to turn off persisted local storage - Providers should clear or replace persisted entries when the `targetingKey` or auth token changes, such as on logout or user switch - SDK documentation should describe that offline fallback uses the last successful bulk evaluation and may therefore serve stale values until connectivity returns From 6ef2020074712ad48dd8260397497e7b326c2b65 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:09:18 -0400 Subject: [PATCH 07/39] docs(adr): simplify cache key to hash(targetingKey) in ADR 0009 Drop authToken from cache key derivation and replace sha256 with generic hash(), per reviewer feedback. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- .../0009-localStorageForStaticContextProviders.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index eedd9ad..6049b4f 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -25,17 +25,17 @@ The persisted entry should include: - the bulk evaluation payload - the associated `ETag`, if one was returned -- a `cacheKeyHash` equal to `sha256(authToken + targetingKey)` +- a `cacheKeyHash` equal to `hash(targetingKey)` - the time the entry was written, which can be used for diagnostics and optional implementation-specific staleness policies Providers may store this as a single fixed local record, for example under a runtime-appropriate key such as `ofrepLocalCache`, and replace that record on each successful refresh. -In that model, the stored value should contain the persisted bulk evaluation together with `cacheKeyHash = sha256(authToken + targetingKey)`, rather than storing raw `targetingKey` and auth token values on disk. +In that model, the stored value should contain the persisted bulk evaluation together with `cacheKeyHash = hash(targetingKey)`, rather than storing raw `targetingKey` values on disk. Example persisted value: ```json { - "cacheKeyHash": "sha256(authToken + targetingKey)", + "cacheKeyHash": "hash(targetingKey)", "etag": "\"abc123\"", "writtenAt": "2026-03-07T18:20:00Z", "data": { @@ -99,7 +99,7 @@ sequenceDiagram ``` Providers should only reuse a persisted evaluation when it matches the current static-context inputs. -This includes a matching `cacheKeyHash` equal to `sha256(authToken + targetingKey)`. +This includes a matching `cacheKeyHash` equal to `hash(targetingKey)`. Fallback to persisted data is intended for offline, transient network failures, or temporary server unavailability such as `5xx` responses. Providers should not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. @@ -139,10 +139,10 @@ For static-context providers, especially web and mobile providers, persistence i - "Local storage" means a local persistent key-value store appropriate for the runtime, such as browser `localStorage` on the web or an equivalent mobile storage mechanism - Providers should version their persisted format so future schema changes can be handled safely - Providers may use a single fixed storage key or filename and store the matching information inside the record as `cacheKeyHash` -- `cacheKeyHash` should be `sha256(authToken + targetingKey)` -- Providers should avoid persisting raw `targetingKey` and auth token values when `cacheKeyHash` is sufficient for matching +- `cacheKeyHash` should be `hash(targetingKey)` +- Providers should avoid persisting raw `targetingKey` values when `cacheKeyHash` is sufficient for matching - Providers should expose a `disableLocalCache` option to turn off persisted local storage -- Providers should clear or replace persisted entries when the `targetingKey` or auth token changes, such as on logout or user switch +- Providers should clear or replace persisted entries when the `targetingKey` changes, such as on logout or user switch - SDK documentation should describe that offline fallback uses the last successful bulk evaluation and may therefore serve stale values until connectivity returns ## Open Questions From 2e8c2bd3c525fa734d321504fe7581b18c90236b Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:21:43 -0400 Subject: [PATCH 08/39] docs(adr): add CACHED evaluation reason and remove resolved open question Specify CACHED as the evaluation reason when serving from persisted storage. Remove fallback scope open question since the decision section already addresses it. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 6049b4f..d17551b 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -60,7 +60,7 @@ During initialization, a provider should: 2. Attempt the normal `/ofrep/v1/evaluate/flags` request. 3. If the request succeeds, populate the in-memory cache from the response and update the persisted entry. 4. If the request cannot complete because the client is offline, the network is temporarily unavailable, or the server is temporarily unavailable, such as a `5xx` response: - - If a matching persisted entry exists, populate the in-memory cache from that persisted entry and continue operating from it. + - If a matching persisted entry exists, populate the in-memory cache from that persisted entry and continue operating from it. Evaluations served from the persisted entry should use `CACHED` as the evaluation reason. - If no matching persisted entry exists, preserve the existing initialization failure behavior. 5. If the request fails for authorization, invalid requests, or other responses that indicate a configuration or protocol problem, preserve the existing initialization failure behavior. @@ -147,5 +147,4 @@ For static-context providers, especially web and mobile providers, persistence i ## Open Questions -1. Should providers fall back to persisted data only when the client is offline or the network is temporarily unavailable, or should they also fall back for authorization failures, invalid requests, or other server responses that indicate a configuration or protocol problem? -2. Should providers also persist the full evaluation context used for the cached bulk evaluation, so that when falling back to persisted values they can override the current context with the cached context that produced those values? +1. Should providers also persist the full evaluation context used for the cached bulk evaluation, so that when falling back to persisted values they can override the current context with the cached context that produced those values? From 98b2c8a720b010b053bf15bf8d82cbe0f9bdd176 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:23:54 -0400 Subject: [PATCH 09/39] docs(adr): strengthen fallback language to must not in ADR 0009 Use must not for auth/config error fallback to prevent masking real problems. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index d17551b..e142ae7 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -102,7 +102,7 @@ Providers should only reuse a persisted evaluation when it matches the current s This includes a matching `cacheKeyHash` equal to `hash(targetingKey)`. Fallback to persisted data is intended for offline, transient network failures, or temporary server unavailability such as `5xx` responses. -Providers should not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. +Providers must not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. When connectivity returns, the provider should resume its normal refresh behavior. If an `ETag` was stored with the persisted entry, the provider should use it with `If-None-Match` when revalidating the bulk evaluation. From 82c3d52c8e3ef651b992d502830d987280b100c9 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:24:54 -0400 Subject: [PATCH 10/39] docs(adr): remove platform constraint from negative consequences Local storage availability is a platform constraint, not a consequence of the proposal. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 1 - 1 file changed, 1 deletion(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index e142ae7..39cb468 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -123,7 +123,6 @@ Providers should allow applications to disable the default persistence behavior, - Providers become more complex because they must manage persistence, cache-key matching, and recovery flows - Persisted evaluations may become stale, so applications can continue using outdated flag values while offline -- Local persistent storage can be unavailable, limited in size, or restricted by platform policy - Persisting evaluation data introduces security and privacy considerations, especially if flag metadata or context-derived identifiers are sensitive - Mobile platforms do not share a single storage API, so providers may need platform-specific defaults behind a common abstraction From 8d3d0d1f421b5d389428bb778cc758fd10f68273 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:26:32 -0400 Subject: [PATCH 11/39] docs(adr): make security/privacy consequence more concrete Specify that flag values are stored in plaintext and accessible to same-origin code or compromised devices. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 39cb468..d9bcf91 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -123,7 +123,7 @@ Providers should allow applications to disable the default persistence behavior, - Providers become more complex because they must manage persistence, cache-key matching, and recovery flows - Persisted evaluations may become stale, so applications can continue using outdated flag values while offline -- Persisting evaluation data introduces security and privacy considerations, especially if flag metadata or context-derived identifiers are sensitive +- Persisting evaluation data on-device means flag values are stored in plaintext in platform-local storage, which may be accessible to other code running in the same origin (web) or on compromised devices (mobile) - Mobile platforms do not share a single storage API, so providers may need platform-specific defaults behind a common abstraction ## Alternatives Considered From 1129060066e584bd2848901b4173f20b4c2f4a12 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:27:33 -0400 Subject: [PATCH 12/39] docs(adr): remove storage model implementation details from ADR 0009 The specific storage key and record model are implementation details. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index d9bcf91..46afd72 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -28,9 +28,6 @@ The persisted entry should include: - a `cacheKeyHash` equal to `hash(targetingKey)` - the time the entry was written, which can be used for diagnostics and optional implementation-specific staleness policies -Providers may store this as a single fixed local record, for example under a runtime-appropriate key such as `ofrepLocalCache`, and replace that record on each successful refresh. -In that model, the stored value should contain the persisted bulk evaluation together with `cacheKeyHash = hash(targetingKey)`, rather than storing raw `targetingKey` values on disk. - Example persisted value: ```json From a7ad620f57d9c800ba5ba1091d9d0ff51ce0a797 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:31:00 -0400 Subject: [PATCH 13/39] docs(adr): clean up implementation notes and mermaid diagram Remove redundant implementation notes that overlap with the decision section. Simplify mermaid diagram initialize call to use context. Signed-off-by: Norris Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 46afd72..00ebfe2 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -68,7 +68,7 @@ sequenceDiagram participant Storage as Local Storage participant Server as OFREP Service - App->>Provider: initialize(targetingKey, auth token) + App->>Provider: initialize(context) Provider->>Storage: load persisted evaluation Storage-->>Provider: matching entry or none Provider->>Server: POST /ofrep/v1/evaluate/flags @@ -134,8 +134,6 @@ For static-context providers, especially web and mobile providers, persistence i - "Local storage" means a local persistent key-value store appropriate for the runtime, such as browser `localStorage` on the web or an equivalent mobile storage mechanism - Providers should version their persisted format so future schema changes can be handled safely -- Providers may use a single fixed storage key or filename and store the matching information inside the record as `cacheKeyHash` -- `cacheKeyHash` should be `hash(targetingKey)` - Providers should avoid persisting raw `targetingKey` values when `cacheKeyHash` is sufficient for matching - Providers should expose a `disableLocalCache` option to turn off persisted local storage - Providers should clear or replace persisted entries when the `targetingKey` changes, such as on logout or user switch From f69127f4073017d3c98cb53145d9d7de5929e9a8 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 19 Mar 2026 10:47:12 -0400 Subject: [PATCH 14/39] docs(adr): rewrite ADR 0009 for cache-first initialization Replace fallback-on-failure with cache-first initialization pattern aligned with vendor SDKs (LaunchDarkly, Statsig, DevCycle, Eppo). Provider loads from persisted cache immediately on startup, refreshes from network in background, and emits PROVIDER_CONFIGURATION_CHANGED when fresh values arrive. Signed-off-by: Jonathan Norris --- ...9-localStorageForStaticContextProviders.md | 105 +++++++++++++----- 1 file changed, 75 insertions(+), 30 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 00ebfe2..81cbe87 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -15,11 +15,14 @@ Static-context providers are primarily web and mobile providers, where applicati In those cases, the last successful bulk evaluation is lost and applications fall back to errors or code defaults instead of continuing with a usable last-known state. This is also out of step with most vendor-provided web and mobile SDKs for the same class of provider, which persist flag state to local storage or on-device disk by default. -Persisting the last successful static-context evaluation would extend the existing cache model across restarts and temporary connectivity loss without requiring protocol changes. +Vendor SDKs from LaunchDarkly, Statsig, DevCycle, and Eppo all use a cache-first initialization pattern: load persisted evaluations immediately on startup so initial synchronous flag evaluations never return defaults, refresh from the network in parallel, and emit change events when fresh values arrive. +See [vendor mobile SDK caching research](https://gist.github.com/jonathannorris/4f2f63142b70719e3c6bfe8b226a0585) for a detailed comparison. + +Persisting the last successful static-context evaluation and loading it on startup would extend the existing cache model across restarts and temporary connectivity loss without requiring protocol changes, while eliminating the flash-of-defaults problem that occurs when applications wait for a network response before evaluations return meaningful values. ## Decision -Static-context providers should persist their last successful bulk evaluation in local persistent storage by default. +Static-context providers should persist their last successful bulk evaluation in local persistent storage by default, and use cache-first initialization to serve persisted evaluations immediately on startup. The persisted entry should include: @@ -49,17 +52,25 @@ Example persisted value: ``` The provider should continue to use its in-memory cache for normal flag evaluation. -Persistent local storage acts as the source used to bootstrap or recover that in-memory cache. - -During initialization, a provider should: - -1. Attempt to load a matching persisted bulk evaluation from local storage. -2. Attempt the normal `/ofrep/v1/evaluate/flags` request. -3. If the request succeeds, populate the in-memory cache from the response and update the persisted entry. -4. If the request cannot complete because the client is offline, the network is temporarily unavailable, or the server is temporarily unavailable, such as a `5xx` response: - - If a matching persisted entry exists, populate the in-memory cache from that persisted entry and continue operating from it. Evaluations served from the persisted entry should use `CACHED` as the evaluation reason. - - If no matching persisted entry exists, preserve the existing initialization failure behavior. -5. If the request fails for authorization, invalid requests, or other responses that indicate a configuration or protocol problem, preserve the existing initialization failure behavior. +Persistent local storage acts as the source used to bootstrap that in-memory cache on startup and update it on each successful refresh. + +### Initialization + +During initialization, a provider should follow a cache-first approach: + +1. Attempt to load a matching persisted bulk evaluation from local storage (matching `cacheKeyHash`). +2. **If a matching persisted entry exists (cache hit):** + - Populate the in-memory cache from the persisted entry immediately. + - Return from `initialize()` so the SDK can emit `PROVIDER_READY`. Evaluations served from the persisted entry should use `CACHED` as the evaluation reason. + - Attempt the `/ofrep/v1/evaluate/flags` request in the background. + - If the background request succeeds, update the in-memory cache from the response, update the persisted entry, and emit `PROVIDER_CONFIGURATION_CHANGED`. Evaluations should switch to the server-provided reasons. + - If the background request fails with a transient or server error (network unavailable, `5xx`), continue serving cached values and retry on the normal polling schedule. + - If the background request fails with an authorization or configuration error (`401`, `403`, `400`), surface the error via logging or provider error events but continue serving cached values for this session. +3. **If no matching persisted entry exists (cache miss):** + - Attempt the `/ofrep/v1/evaluate/flags` request and await the response. + - If the request succeeds, populate the in-memory cache from the response, persist the entry, and return from `initialize()` (SDK emits `PROVIDER_READY`). + - If the request fails with a transient or server error, preserve the existing initialization failure behavior (SDK emits `PROVIDER_ERROR`). + - If the request fails with an authorization or configuration error, preserve the existing initialization failure behavior with a fatal error code (SDK emits `PROVIDER_FATAL`). ```mermaid sequenceDiagram @@ -70,49 +81,74 @@ sequenceDiagram App->>Provider: initialize(context) Provider->>Storage: load persisted evaluation - Storage-->>Provider: matching entry or none - Provider->>Server: POST /ofrep/v1/evaluate/flags - alt Request succeeds - Server-->>Provider: 200 OK (flags + ETag) + alt Cache hit (matching entry exists) + Storage-->>Provider: persisted entry Provider->>Provider: Populate in-memory cache - Provider->>Storage: Persist flags + ETag - else Network unavailable - alt Matching persisted entry exists - Provider->>Provider: Populate in-memory cache from persisted entry - else No matching persisted entry - Provider-->>App: Initialization failure + Provider-->>App: PROVIDER_READY (from cache, reason: CACHED) + Provider->>Server: POST /ofrep/v1/evaluate/flags (background) + alt Request succeeds + Server-->>Provider: 200 OK (flags + ETag) + Provider->>Provider: Update in-memory cache + Provider->>Storage: Persist updated entry + Provider-->>App: PROVIDER_CONFIGURATION_CHANGED + else Transient error + Note over Provider: Continue serving cached values + else Auth/config error + Note over Provider: Surface error, continue serving cached values + end + else Cache miss (no matching entry) + Storage-->>Provider: none + Provider->>Server: POST /ofrep/v1/evaluate/flags + alt Request succeeds + Server-->>Provider: 200 OK (flags + ETag) + Provider->>Provider: Populate in-memory cache + Provider->>Storage: Persist entry + Provider-->>App: PROVIDER_READY + else Transient error + Provider-->>App: PROVIDER_ERROR + else Auth/config error + Provider-->>App: PROVIDER_FATAL end end - Note over App,Server: Later, when connectivity returns + Note over App,Server: Normal polling cycle Provider->>Server: POST /ofrep/v1/evaluate/flags with If-None-Match alt Flags changed Server-->>Provider: 200 OK (new flags + ETag) Provider->>Provider: Update in-memory cache Provider->>Storage: Replace persisted entry + Provider-->>App: PROVIDER_CONFIGURATION_CHANGED else Flags unchanged Server-->>Provider: 304 Not Modified end ``` +### Cache matching and fallback + Providers should only reuse a persisted evaluation when it matches the current static-context inputs. This includes a matching `cacheKeyHash` equal to `hash(targetingKey)`. -Fallback to persisted data is intended for offline, transient network failures, or temporary server unavailability such as `5xx` responses. -Providers must not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. +When the provider has not initialized from cache (cache miss path), providers must not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. -When connectivity returns, the provider should resume its normal refresh behavior. +When the provider has already initialized from cache (cache hit path), authorization or configuration errors from the background refresh should be surfaced via logging or provider error events, but the provider should continue serving cached values for the current session rather than revoking a working state. + +### Refresh and revalidation + +When connectivity returns or during normal polling, the provider should resume its normal refresh behavior. If an `ETag` was stored with the persisted entry, the provider should use it with `If-None-Match` when revalidating the bulk evaluation. +### Configuration + Providers should allow applications to disable the default persistence behavior, for example with a `disableLocalCache` option, or replace the storage backend when platform requirements or policy constraints require it. ## Consequences ### Positive +- Cache-first initialization eliminates the flash-of-defaults problem, where applications briefly show default values before evaluated values arrive - Static-context providers become resilient to offline application startup when a last-known evaluation exists - Web and mobile applications preserve feature state across restarts instead of losing it with the in-memory cache -- The decision aligns with the existing OFREP model where static-context providers evaluate remotely once and then read locally +- The decision aligns with the established pattern used by vendor SDKs (LaunchDarkly, Statsig, DevCycle, Eppo) and with the existing OFREP model where static-context providers evaluate remotely once and then read locally - Reusing the stored `ETag` allows efficient revalidation when connectivity returns - Provider implementations get a consistent default expectation for offline behavior across ecosystems @@ -120,6 +156,7 @@ Providers should allow applications to disable the default persistence behavior, - Providers become more complex because they must manage persistence, cache-key matching, and recovery flows - Persisted evaluations may become stale, so applications can continue using outdated flag values while offline +- Applications may briefly see stale cached values before fresh values arrive, and should handle `PROVIDER_CONFIGURATION_CHANGED` events if they need to react to updates - Persisting evaluation data on-device means flag values are stored in plaintext in platform-local storage, which may be accessible to other code running in the same origin (web) or on compromised devices (mobile) - Mobile platforms do not share a single storage API, so providers may need platform-specific defaults behind a common abstraction @@ -130,6 +167,12 @@ Providers should allow applications to disable the default persistence behavior, This reduces default behavior changes, but it produces inconsistent offline behavior across provider implementations and requires every application to rediscover and enable the same capability. For static-context providers, especially web and mobile providers, persistence is expected behavior rather than an exceptional optimization. +### Fall back to cache only on network failure + +In this approach, the provider always attempts the network request first and only falls back to cached evaluations when the request fails. +This is simpler to implement but introduces the flash-of-defaults problem on every normal startup: applications must wait for the network response before flag evaluations return meaningful values. +Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first initialization instead because it produces better UX for end users. + ## Implementation Notes - "Local storage" means a local persistent key-value store appropriate for the runtime, such as browser `localStorage` on the web or an equivalent mobile storage mechanism @@ -137,8 +180,10 @@ For static-context providers, especially web and mobile providers, persistence i - Providers should avoid persisting raw `targetingKey` values when `cacheKeyHash` is sufficient for matching - Providers should expose a `disableLocalCache` option to turn off persisted local storage - Providers should clear or replace persisted entries when the `targetingKey` changes, such as on logout or user switch -- SDK documentation should describe that offline fallback uses the last successful bulk evaluation and may therefore serve stale values until connectivity returns +- 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 +- SDK documentation should note that initial evaluations may return cached values (with `CACHED` reason) that are subsequently updated when fresh values arrive ## Open Questions -1. Should providers also persist the full evaluation context used for the cached bulk evaluation, so that when falling back to persisted values they can override the current context with the cached context that produced those values? +1. Should providers support caching evaluations for multiple targeting keys (like LaunchDarkly's `maxCachedContexts`), or only retain the most recent? Multi-context caching enables instant user switching on shared devices but increases storage usage. From a2e709072a56d0bd5c3252d7c566d5a086d6a80f Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 19 Mar 2026 16:55:33 -0400 Subject: [PATCH 15/39] docs(adr): add cache TTL as open question in ADR 0009 Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 1 + 1 file changed, 1 insertion(+) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 81cbe87..3fbf436 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -187,3 +187,4 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first ## Open Questions 1. Should providers support caching evaluations for multiple targeting keys (like LaunchDarkly's `maxCachedContexts`), or only retain the most recent? Multi-context caching enables instant user switching on shared devices but increases storage usage. +2. Should providers enforce a TTL on persisted entries (e.g. 30 days, similar to DevCycle's `configCacheTTL`)? A TTL would ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). If so, should the TTL be configurable? From f5603ffd058b082219361ef0c44a61fd7733263c Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 19 Mar 2026 17:21:28 -0400 Subject: [PATCH 16/39] docs(adr): improve precision of provider lifecycle semantics in ADR 0009 Fix PROVIDER_FATAL to PROVIDER_ERROR with fatal error code per spec. Add rationale for READY vs STALE on cache-hit startup. Clarify cache key tradeoff (targetingKey vs full context). Note existing provider implementations will need lifecycle refactors. Signed-off-by: Jonathan Norris --- ...9-localStorageForStaticContextProviders.md | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 3fbf436..3f0454d 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -70,7 +70,7 @@ During initialization, a provider should follow a cache-first approach: - Attempt the `/ofrep/v1/evaluate/flags` request and await the response. - If the request succeeds, populate the in-memory cache from the response, persist the entry, and return from `initialize()` (SDK emits `PROVIDER_READY`). - If the request fails with a transient or server error, preserve the existing initialization failure behavior (SDK emits `PROVIDER_ERROR`). - - If the request fails with an authorization or configuration error, preserve the existing initialization failure behavior with a fatal error code (SDK emits `PROVIDER_FATAL`). + - If the request fails with an authorization or configuration error, preserve the existing initialization failure behavior (SDK emits `PROVIDER_ERROR` with error code `PROVIDER_FATAL`). ```mermaid sequenceDiagram @@ -107,7 +107,7 @@ sequenceDiagram else Transient error Provider-->>App: PROVIDER_ERROR else Auth/config error - Provider-->>App: PROVIDER_FATAL + Provider-->>App: PROVIDER_ERROR (fatal) end end @@ -123,11 +123,27 @@ sequenceDiagram end ``` +### Why PROVIDER_READY and not PROVIDER_STALE on cache hit + +The spec defines `READY` as "the provider has been initialized, and is able to reliably resolve flag values" and `STALE` as "the provider's cached state is no longer valid and may not be up-to-date with the source of truth." + +On cache-hit startup, the provider emits `PROVIDER_READY` rather than `PROVIDER_STALE` for two reasons. +First, at the moment of loading from cache, the provider does not yet know whether the cached values differ from the server. The values were correct as of the last successful evaluation and may still be current. The background refresh will determine whether they have changed. +Second, `PROVIDER_STALE` would break the initialization contract. Applications and SDKs listen for `PROVIDER_READY` to begin flag evaluation. If the provider emitted `PROVIDER_STALE` instead, the SDK would not transition out of `NOT_READY`, and flag evaluations would short-circuit to defaults, which defeats the purpose of cache-first initialization. + +If the background refresh fails and the provider cannot confirm that cached values are current, the provider may emit `PROVIDER_STALE` at that point to signal that values may be out of date. + ### Cache matching and fallback Providers should only reuse a persisted evaluation when it matches the current static-context inputs. This includes a matching `cacheKeyHash` equal to `hash(targetingKey)`. +The cache key is intentionally derived from `targetingKey` alone rather than the full evaluation context. +Static-context evaluations on the server can depend on context properties beyond `targetingKey`, so cached values may not reflect the current full context. +However, hashing the full context is impractical for cache-first startup because many implementations set volatile context properties on initialization (e.g. `lastSessionTime`, `lastSeen`, `sessionId`) that would change the hash on every app restart, defeating the purpose of persistence. +The accepted tradeoff is that the cache is keyed by stable user identity: a change in `targetingKey` (user switch, logout) invalidates the cache, but changes to other context properties do not. +Those properties only affect evaluation when the server is reachable, at which point the provider refreshes anyway. + When the provider has not initialized from cache (cache miss path), providers must not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. When the provider has already initialized from cache (cache hit path), authorization or configuration errors from the background refresh should be surfaced via logging or provider error events, but the provider should continue serving cached values for the current session rather than revoking a working state. @@ -159,6 +175,7 @@ Providers should allow applications to disable the default persistence behavior, - Applications may briefly see stale cached values before fresh values arrive, and should handle `PROVIDER_CONFIGURATION_CHANGED` events if they need to react to updates - Persisting evaluation data on-device means flag values are stored in plaintext in platform-local storage, which may be accessible to other code running in the same origin (web) or on compromised devices (mobile) - Mobile platforms do not share a single storage API, so providers may need platform-specific defaults behind a common abstraction +- Existing OFREP static-context providers (`js-sdk-contrib`, `kotlin-sdk-contrib`, `ofrep-swift-client-provider`) all block `initialize()` on a network request today. Adopting cache-first initialization requires lifecycle and event model changes in each implementation, particularly the Kotlin provider which currently emits `PROVIDER_READY` on poll updates instead of `PROVIDER_CONFIGURATION_CHANGED` ## Alternatives Considered From 261b5121e13b5dd017db6c7be78bbda3d380061e Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 20 Mar 2026 10:01:34 -0400 Subject: [PATCH 17/39] docs(adr): clear persisted cache on auth/config errors in ADR 0009 On the cache-hit path, if the background refresh fails with 401/403/400, the provider continues serving cached values for the current session but clears the persisted entry. This ensures the next cold start uses the cache-miss path, making auth errors immediately visible. Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 3f0454d..2a99cac 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -65,7 +65,7 @@ During initialization, a provider should follow a cache-first approach: - Attempt the `/ofrep/v1/evaluate/flags` request in the background. - If the background request succeeds, update the in-memory cache from the response, update the persisted entry, and emit `PROVIDER_CONFIGURATION_CHANGED`. Evaluations should switch to the server-provided reasons. - If the background request fails with a transient or server error (network unavailable, `5xx`), continue serving cached values and retry on the normal polling schedule. - - If the background request fails with an authorization or configuration error (`401`, `403`, `400`), surface the error via logging or provider error events but continue serving cached values for this session. + - If the background request fails with an authorization or configuration error (`401`, `403`, `400`), surface the error via logging or provider error events, continue serving cached values for the current session, and clear the persisted entry from local storage. This ensures the next cold start uses the cache-miss path, making the auth or configuration error immediately visible rather than silently booting from increasingly stale data. 3. **If no matching persisted entry exists (cache miss):** - Attempt the `/ofrep/v1/evaluate/flags` request and await the response. - If the request succeeds, populate the in-memory cache from the response, persist the entry, and return from `initialize()` (SDK emits `PROVIDER_READY`). @@ -94,6 +94,7 @@ sequenceDiagram else Transient error Note over Provider: Continue serving cached values else Auth/config error + Provider->>Storage: Clear persisted entry Note over Provider: Surface error, continue serving cached values end else Cache miss (no matching entry) @@ -146,7 +147,7 @@ Those properties only affect evaluation when the server is reachable, at which p When the provider has not initialized from cache (cache miss path), providers must not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. -When the provider has already initialized from cache (cache hit path), authorization or configuration errors from the background refresh should be surfaced via logging or provider error events, but the provider should continue serving cached values for the current session rather than revoking a working state. +When the provider has already initialized from cache (cache hit path), authorization or configuration errors from the background refresh should be surfaced via logging or provider error events. The provider should continue serving cached values for the current session rather than revoking a working state, but should clear the persisted entry from local storage so the next cold start follows the cache-miss path and the error is immediately visible. ### Refresh and revalidation From 3069d466a74d17ba492f5ef8af66cc43ce9a12a4 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 30 Mar 2026 17:02:06 -0400 Subject: [PATCH 18/39] docs(adr): clarify background refresh cancellation and first cold start behavior Providers should cancel in-flight background refresh when onContextChanged() is called. Document that cache-first only applies after the first successful evaluation is persisted. Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 2a99cac..fe0789a 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -200,6 +200,8 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first - Providers should clear or replace persisted entries when the `targetingKey` changes, such as on logout or user switch - 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 +- On the first cold start (no persisted entry), `initialize()` blocks on the network request as normal. Cache-first initialization only applies once a successful evaluation has been persisted - SDK documentation should note that initial evaluations may return cached values (with `CACHED` reason) that are subsequently updated when fresh values arrive ## Open Questions From e135a79531dc5fb74c8b4eb53afbe34c95218513 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 31 Mar 2026 15:49:35 -0400 Subject: [PATCH 19/39] docs(adr): add cache key namespace as open question in ADR 0009 Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 1 + 1 file changed, 1 insertion(+) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index fe0789a..03cecc4 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -208,3 +208,4 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first 1. Should providers support caching evaluations for multiple targeting keys (like LaunchDarkly's `maxCachedContexts`), or only retain the most recent? Multi-context caching enables instant user switching on shared devices but increases storage usage. 2. Should providers enforce a TTL on persisted entries (e.g. 30 days, similar to DevCycle's `configCacheTTL`)? A TTL would ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). If so, should the TTL be configurable? +3. Should the cache key include a namespace derived from the provider's base URL or an environment identifier, to prevent collisions when multiple OFREP providers share the same local storage origin? In practice most applications use a single provider pointing at a single backend, so real-world collisions are unlikely, but multi-tenant or multi-environment setups could be affected. From 392126dc861944d68decc2b91c88a3e7a70c7256 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 31 Mar 2026 15:51:40 -0400 Subject: [PATCH 20/39] docs(adr): refine cache namespace open question to use auth token hash Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 03cecc4..8b99843 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -208,4 +208,4 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first 1. Should providers support caching evaluations for multiple targeting keys (like LaunchDarkly's `maxCachedContexts`), or only retain the most recent? Multi-context caching enables instant user switching on shared devices but increases storage usage. 2. Should providers enforce a TTL on persisted entries (e.g. 30 days, similar to DevCycle's `configCacheTTL`)? A TTL would ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). If so, should the TTL be configurable? -3. Should the cache key include a namespace derived from the provider's base URL or an environment identifier, to prevent collisions when multiple OFREP providers share the same local storage origin? In practice most applications use a single provider pointing at a single backend, so real-world collisions are unlikely, but multi-tenant or multi-environment setups could be affected. +3. Should the storage key include a namespace to prevent collisions when multiple OFREP providers share the same local storage origin (e.g. different backends on the same web origin)? In practice most applications use a single provider, so real-world collisions are unlikely. One option is to namespace using a hash of the auth token, since it is already environment- and project-specific and effectively distinguishes one provider configuration from another. From ed13ec0167fe86bd6bcbc9e3398300203f54ee3c Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 10 Apr 2026 15:59:42 +0200 Subject: [PATCH 21/39] Revert "refactor: make endpoint.origin optional, default to OFREP base URL" This reverts commit b69eafdb94ef06edcb6f882d66b0703d7bf44a56. Signed-off-by: Jonathan Norris --- service/adrs/0008-sse-for-bulk-evaluation-changes.md | 10 ++++------ service/openapi.yaml | 6 ++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/service/adrs/0008-sse-for-bulk-evaluation-changes.md b/service/adrs/0008-sse-for-bulk-evaluation-changes.md index 91b6393..f52d8b5 100644 --- a/service/adrs/0008-sse-for-bulk-evaluation-changes.md +++ b/service/adrs/0008-sse-for-bulk-evaluation-changes.md @@ -62,10 +62,10 @@ Add an optional `eventStreams` field to `bulkEvaluationSuccess`: Each event stream object has: - `type` (string, required): The connection type. Currently `"sse"` is the only defined value. Providers must ignore entries with unknown types for forward compatibility, allowing new push mechanisms to be added without breaking existing clients. - `url` (string, optional): The endpoint URL. This is the default representation and is opaque to the provider. It may include authentication tokens, channel identifiers, or other vendor-specific query parameters. Implementations must treat this URL as sensitive -- it may contain auth tokens or channel credentials -- and must not log or persist the full URL including query string. -- `endpoint` (object, optional): Structured endpoint components for deployments that need to override the origin cleanly (for example, via a proxy) while preserving the request target. It has a required `requestUri` field and an optional `origin` field. If `origin` is absent, providers should use their configured OFREP base URL origin. +- `endpoint` (object, optional): Structured endpoint components for deployments that need to override the origin cleanly (for example, via a proxy) while preserving the request target. If present, it has `origin` and `requestUri` fields. - `inactivityDelaySec` (integer, optional): Seconds of client inactivity (e.g., browser tab hidden, mobile app backgrounded) after which the connection should be closed. The client must reconnect and perform a full unconditional re-fetch when activity resumes. Minimum value is `1`. When determining the effective inactivity timeout, providers should use a client-side override if configured; otherwise use this value when present; otherwise default to `120` seconds. -Exactly one of `url` or `endpoint` must be provided. Providers should use `url` as-is when present. When `endpoint` is present, providers should construct the connection URL as `origin + requestUri`, where `origin` defaults to the provider's configured OFREP base URL if not specified. +Exactly one of `url` or `endpoint` must be provided. Providers should use `url` as-is when present. When `endpoint` is present, providers should construct the connection URL as `origin + requestUri`. The `eventStreams` field is an array to support vendors whose infrastructure may require connections to multiple channels or endpoints (e.g., a global channel for environment-wide changes and a user-specific channel for targeted updates). Many SSE providers support multiple channels on a single URL, so the array will typically contain a single entry. @@ -242,20 +242,18 @@ eventStream: endpoint: type: object required: + - origin - requestUri description: | Structured endpoint components for deployments that need to override the origin cleanly while preserving the request target. When present, - providers construct the connection URL as `origin + requestUri`. If - `origin` is absent, providers should use their configured OFREP base - URL origin. + providers construct the connection URL as `origin + requestUri`. properties: origin: type: string format: uri description: | The scheme + host + optional port portion of the endpoint URL. - If absent, providers should use their configured OFREP base URL origin. example: "https://sse.example.com" requestUri: type: string diff --git a/service/openapi.yaml b/service/openapi.yaml index 1707928..e2a650f 100644 --- a/service/openapi.yaml +++ b/service/openapi.yaml @@ -418,20 +418,18 @@ components: eventStreamEndpoint: type: object required: + - origin - requestUri description: | Structured endpoint components for deployments that need to override the origin cleanly while preserving the request target. When present, - providers construct the connection URL as `origin + requestUri`. If - `origin` is absent, providers should use their configured OFREP base - URL origin. + providers construct the connection URL as `origin + requestUri`. properties: origin: type: string format: uri description: | The scheme + host + optional port portion of the endpoint URL. - If absent, providers should use their configured OFREP base URL origin. example: "https://sse.example.com" requestUri: type: string From 5d79478fa1f6cb8ccfd0ae66ceb0a29550e1b0da Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Mon, 13 Apr 2026 11:20:05 -0400 Subject: [PATCH 22/39] chore: add a requirement to have an optional init option in providers for prefixing the cache key (#74) Signed-off-by: Jason Salaber Signed-off-by: Jonathan Norris --- .../adrs/0009-localStorageForStaticContextProviders.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 8b99843..461b125 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -31,6 +31,8 @@ The persisted entry should include: - a `cacheKeyHash` equal to `hash(targetingKey)` - the time the entry was written, which can be used for diagnostics and optional implementation-specific staleness policies +The key used to read and write this entry in the platform’s local key-value store should incorporate the `cacheKeyHash` (and any implementation-defined suffix for versioning or multiple entries). Implementations should also support an optional **persisted-cache key prefix** (configuration option) that namespaces that storage key when an application runs **multiple provider instances** that share the same storage partition (for example, two OFREP providers on the same web origin). Without a prefix, those instances could collide on the same storage slot; with distinct prefixes, each instance keeps an isolated persisted evaluation. + Example persisted value: ```json @@ -58,7 +60,7 @@ Persistent local storage acts as the source used to bootstrap that in-memory cac During initialization, a provider should follow a cache-first approach: -1. Attempt to load a matching persisted bulk evaluation from local storage (matching `cacheKeyHash`). +1. Attempt to load a matching persisted bulk evaluation from local storage (matching `cacheKeyHash`, and the same persisted-cache key prefix the instance was configured with, if any). 2. **If a matching persisted entry exists (cache hit):** - Populate the in-memory cache from the persisted entry immediately. - Return from `initialize()` so the SDK can emit `PROVIDER_READY`. Evaluations served from the persisted entry should use `CACHED` as the evaluation reason. @@ -137,7 +139,7 @@ If the background refresh fails and the provider cannot confirm that cached valu ### Cache matching and fallback Providers should only reuse a persisted evaluation when it matches the current static-context inputs. -This includes a matching `cacheKeyHash` equal to `hash(targetingKey)`. +This includes a matching cacheKeyHash equal to hash(targetingKey). The lookup must also use the persisted-cache key prefix provided in the initialization options. The cache key is intentionally derived from `targetingKey` alone rather than the full evaluation context. Static-context evaluations on the server can depend on context properties beyond `targetingKey`, so cached values may not reflect the current full context. @@ -158,6 +160,8 @@ If an `ETag` was stored with the persisted entry, the provider should use it wit Providers should allow applications to disable the default persistence behavior, for example with a `disableLocalCache` option, or replace the storage backend when platform requirements or policy constraints require it. +When applications configure **more than one** static-context provider against the same underlying storage (same browser origin, shared app container, and so on), providers should expose an optional **persisted-cache key prefix** (name may vary by SDK, for example `persistedCacheKeyPrefix` or `localCacheKeyPrefix`). Applications set a distinct prefix per provider instance so persisted entries are namespaced and instances do not load or overwrite each other’s bulk evaluations. + ## Consequences ### Positive @@ -197,6 +201,7 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first - Providers should version their persisted format so future schema changes can be handled safely - Providers should avoid persisting raw `targetingKey` values when `cacheKeyHash` is sufficient for matching - Providers should expose a `disableLocalCache` option to turn off persisted local storage +- Providers should expose an optional persisted-cache key prefix (or equivalent) so multiple provider instances sharing one storage partition do not collide on the same storage key - Providers should clear or replace persisted entries when the `targetingKey` changes, such as on logout or user switch - 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 @@ -208,4 +213,3 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first 1. Should providers support caching evaluations for multiple targeting keys (like LaunchDarkly's `maxCachedContexts`), or only retain the most recent? Multi-context caching enables instant user switching on shared devices but increases storage usage. 2. Should providers enforce a TTL on persisted entries (e.g. 30 days, similar to DevCycle's `configCacheTTL`)? A TTL would ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). If so, should the TTL be configurable? -3. Should the storage key include a namespace to prevent collisions when multiple OFREP providers share the same local storage origin (e.g. different backends on the same web origin)? In practice most applications use a single provider, so real-world collisions are unlikely. One option is to namespace using a hash of the auth token, since it is already environment- and project-specific and effectively distinguishes one provider configuration from another. From c8cfb28eebfd3d29143402c3973742b1f9d0a10f Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 13 Apr 2026 15:32:13 -0400 Subject: [PATCH 23/39] docs(adr): keep persisted cache on auth errors, rely on TTL for expiry Don't clear the persisted cache on 401/403/400 errors. The cache TTL is responsible for eventual expiry. This avoids degrading subsequent cold starts to defaults while auth errors are investigated. Aligns with vendor SDK behavior (DevCycle keeps cache through auth errors with a 30-day TTL). Moved TTL from open question to implementation recommendation. Signed-off-by: Jonathan Norris --- .../adrs/0009-localStorageForStaticContextProviders.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index 461b125..cf94066 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -67,7 +67,7 @@ During initialization, a provider should follow a cache-first approach: - Attempt the `/ofrep/v1/evaluate/flags` request in the background. - If the background request succeeds, update the in-memory cache from the response, update the persisted entry, and emit `PROVIDER_CONFIGURATION_CHANGED`. Evaluations should switch to the server-provided reasons. - If the background request fails with a transient or server error (network unavailable, `5xx`), continue serving cached values and retry on the normal polling schedule. - - If the background request fails with an authorization or configuration error (`401`, `403`, `400`), surface the error via logging or provider error events, continue serving cached values for the current session, and clear the persisted entry from local storage. This ensures the next cold start uses the cache-miss path, making the auth or configuration error immediately visible rather than silently booting from increasingly stale data. + - If the background request fails with an authorization or configuration error (`401`, `403`, `400`), surface the error via logging or provider error events and continue serving cached values for the current session. The persisted entry should not be cleared; the cache TTL is responsible for eventual expiry. This ensures that subsequent cold starts can still bootstrap from cached values while the error is investigated, rather than immediately degrading to defaults. 3. **If no matching persisted entry exists (cache miss):** - Attempt the `/ofrep/v1/evaluate/flags` request and await the response. - If the request succeeds, populate the in-memory cache from the response, persist the entry, and return from `initialize()` (SDK emits `PROVIDER_READY`). @@ -96,7 +96,6 @@ sequenceDiagram else Transient error Note over Provider: Continue serving cached values else Auth/config error - Provider->>Storage: Clear persisted entry Note over Provider: Surface error, continue serving cached values end else Cache miss (no matching entry) @@ -149,7 +148,7 @@ Those properties only affect evaluation when the server is reachable, at which p When the provider has not initialized from cache (cache miss path), providers must not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. -When the provider has already initialized from cache (cache hit path), authorization or configuration errors from the background refresh should be surfaced via logging or provider error events. The provider should continue serving cached values for the current session rather than revoking a working state, but should clear the persisted entry from local storage so the next cold start follows the cache-miss path and the error is immediately visible. +When the provider has already initialized from cache (cache hit path), authorization or configuration errors from the background refresh should be surfaced via logging or provider error events. The provider should continue serving cached values for the current session rather than revoking a working state. The persisted entry should not be cleared on auth or config errors; the cache TTL is responsible for eventual expiry. This avoids degrading subsequent cold starts to defaults while the error is investigated. ### Refresh and revalidation @@ -208,8 +207,9 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first - 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 - On the first cold start (no persisted entry), `initialize()` blocks on the network request as normal. Cache-first initialization only applies once a successful evaluation has been persisted - SDK documentation should note that initial evaluations may return cached values (with `CACHED` reason) that are subsequently updated when fresh values arrive +- Providers should enforce a configurable TTL on persisted entries to ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). Since auth and config errors do not clear the persisted cache, the TTL is the mechanism that prevents indefinitely stale data. DevCycle uses a 30-day default (`configCacheTTL`) as a reference. ## Open Questions 1. Should providers support caching evaluations for multiple targeting keys (like LaunchDarkly's `maxCachedContexts`), or only retain the most recent? Multi-context caching enables instant user switching on shared devices but increases storage usage. -2. Should providers enforce a TTL on persisted entries (e.g. 30 days, similar to DevCycle's `configCacheTTL`)? A TTL would ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). If so, should the TTL be configurable? +2. Should the storage key include a namespace to prevent collisions when multiple OFREP providers share the same local storage origin (e.g. different backends on the same web origin)? In practice most applications use a single provider, so real-world collisions are unlikely. One option is to namespace using a hash of the auth token, since it is already environment- and project-specific and effectively distinguishes one provider configuration from another. From 3a523516c32bbfda29594ffee42d40a620de9999 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 13 Apr 2026 15:44:36 -0400 Subject: [PATCH 24/39] docs(adr): recommend cacheKeyPrefix for multi-provider namespacing Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index cf94066..f380388 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -212,4 +212,4 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first ## Open Questions 1. Should providers support caching evaluations for multiple targeting keys (like LaunchDarkly's `maxCachedContexts`), or only retain the most recent? Multi-context caching enables instant user switching on shared devices but increases storage usage. -2. Should the storage key include a namespace to prevent collisions when multiple OFREP providers share the same local storage origin (e.g. different backends on the same web origin)? In practice most applications use a single provider, so real-world collisions are unlikely. One option is to namespace using a hash of the auth token, since it is already environment- and project-specific and effectively distinguishes one provider configuration from another. +2. Should providers support an optional `cacheKeyPrefix` configuration to prevent collisions when multiple OFREP providers share the same local storage origin? When provided, the cache key would become `hash(cacheKeyPrefix + targetingKey)` instead of `hash(targetingKey)`. This gives flexibility for different use cases: the prefix could be the OFREP base URL (multiple servers), a project or auth token (multi-tenant on the same server), or any other string the app author chooses. The default (no prefix) keeps the single-provider case simple. From e5bed69a3ca202b409f82178523e8356cd698c1e Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 13 Apr 2026 15:50:01 -0400 Subject: [PATCH 25/39] docs(adr): implement cacheKeyPrefix for multi-provider namespacing Move cacheKeyPrefix from open question to decision. Providers support an optional cacheKeyPrefix config option; when set, the cache key becomes hash(cacheKeyPrefix + targetingKey). Standardize terminology across the ADR. Signed-off-by: Jonathan Norris --- .../0009-localStorageForStaticContextProviders.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index f380388..b697969 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -28,10 +28,10 @@ The persisted entry should include: - the bulk evaluation payload - the associated `ETag`, if one was returned -- a `cacheKeyHash` equal to `hash(targetingKey)` +- a `cacheKeyHash` equal to `hash(targetingKey)`, or `hash(cacheKeyPrefix + targetingKey)` when a `cacheKeyPrefix` is configured - the time the entry was written, which can be used for diagnostics and optional implementation-specific staleness policies -The key used to read and write this entry in the platform’s local key-value store should incorporate the `cacheKeyHash` (and any implementation-defined suffix for versioning or multiple entries). Implementations should also support an optional **persisted-cache key prefix** (configuration option) that namespaces that storage key when an application runs **multiple provider instances** that share the same storage partition (for example, two OFREP providers on the same web origin). Without a prefix, those instances could collide on the same storage slot; with distinct prefixes, each instance keeps an isolated persisted evaluation. +Providers should support an optional `cacheKeyPrefix` configuration option. When provided, the prefix is included in the cache key hash: `hash(cacheKeyPrefix + targetingKey)`. This prevents collisions when multiple OFREP provider instances share the same local storage partition (e.g., two providers on the same web origin pointing at different OFREP servers). The prefix value is left to the application author; it could be the OFREP base URL, a project or auth token, or any other distinguishing string. When no prefix is configured, the cache key defaults to `hash(targetingKey)`. Example persisted value: @@ -60,7 +60,7 @@ Persistent local storage acts as the source used to bootstrap that in-memory cac During initialization, a provider should follow a cache-first approach: -1. Attempt to load a matching persisted bulk evaluation from local storage (matching `cacheKeyHash`, and the same persisted-cache key prefix the instance was configured with, if any). +1. Attempt to load a matching persisted bulk evaluation from local storage (matching `cacheKeyHash`). 2. **If a matching persisted entry exists (cache hit):** - Populate the in-memory cache from the persisted entry immediately. - Return from `initialize()` so the SDK can emit `PROVIDER_READY`. Evaluations served from the persisted entry should use `CACHED` as the evaluation reason. @@ -138,7 +138,7 @@ If the background refresh fails and the provider cannot confirm that cached valu ### Cache matching and fallback Providers should only reuse a persisted evaluation when it matches the current static-context inputs. -This includes a matching cacheKeyHash equal to hash(targetingKey). The lookup must also use the persisted-cache key prefix provided in the initialization options. +This includes a matching `cacheKeyHash` equal to `hash(targetingKey)`, or `hash(cacheKeyPrefix + targetingKey)` when a `cacheKeyPrefix` is configured. The cache key is intentionally derived from `targetingKey` alone rather than the full evaluation context. Static-context evaluations on the server can depend on context properties beyond `targetingKey`, so cached values may not reflect the current full context. @@ -159,7 +159,7 @@ If an `ETag` was stored with the persisted entry, the provider should use it wit Providers should allow applications to disable the default persistence behavior, for example with a `disableLocalCache` option, or replace the storage backend when platform requirements or policy constraints require it. -When applications configure **more than one** static-context provider against the same underlying storage (same browser origin, shared app container, and so on), providers should expose an optional **persisted-cache key prefix** (name may vary by SDK, for example `persistedCacheKeyPrefix` or `localCacheKeyPrefix`). Applications set a distinct prefix per provider instance so persisted entries are namespaced and instances do not load or overwrite each other’s bulk evaluations. +When applications configure more than one static-context provider against the same underlying storage (same browser origin, shared app container, etc.), each provider instance should be configured with a distinct `cacheKeyPrefix` so persisted entries are namespaced and instances do not load or overwrite each other's bulk evaluations. ## Consequences @@ -200,7 +200,7 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first - Providers should version their persisted format so future schema changes can be handled safely - Providers should avoid persisting raw `targetingKey` values when `cacheKeyHash` is sufficient for matching - Providers should expose a `disableLocalCache` option to turn off persisted local storage -- Providers should expose an optional persisted-cache key prefix (or equivalent) so multiple provider instances sharing one storage partition do not collide on the same storage key +- Providers should expose an optional `cacheKeyPrefix` configuration option so multiple provider instances sharing one storage partition do not collide on the same storage key - Providers should clear or replace persisted entries when the `targetingKey` changes, such as on logout or user switch - 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 @@ -212,4 +212,3 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first ## Open Questions 1. Should providers support caching evaluations for multiple targeting keys (like LaunchDarkly's `maxCachedContexts`), or only retain the most recent? Multi-context caching enables instant user switching on shared devices but increases storage usage. -2. Should providers support an optional `cacheKeyPrefix` configuration to prevent collisions when multiple OFREP providers share the same local storage origin? When provided, the cache key would become `hash(cacheKeyPrefix + targetingKey)` instead of `hash(targetingKey)`. This gives flexibility for different use cases: the prefix could be the OFREP base URL (multiple servers), a project or auth token (multi-tenant on the same server), or any other string the app author chooses. The default (no prefix) keeps the single-provider case simple. From 1a3519521ed15b54bc7303bf8e11819bd4e9bb72 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 13 Apr 2026 15:50:58 -0400 Subject: [PATCH 26/39] docs(adr): restore cacheKeyPrefix open question with answer Signed-off-by: Jonathan Norris --- service/adrs/0009-localStorageForStaticContextProviders.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-localStorageForStaticContextProviders.md index b697969..2556956 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-localStorageForStaticContextProviders.md @@ -212,3 +212,5 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first ## Open Questions 1. Should providers support caching evaluations for multiple targeting keys (like LaunchDarkly's `maxCachedContexts`), or only retain the most recent? Multi-context caching enables instant user switching on shared devices but increases storage usage. +2. Should the storage key include a namespace to prevent collisions when multiple OFREP providers share the same local storage origin? + - **Answer:** Yes. Providers should support an optional `cacheKeyPrefix` configuration option. When provided, the cache key becomes `hash(cacheKeyPrefix + targetingKey)` instead of `hash(targetingKey)`. The prefix value is left to the application author (e.g., the OFREP base URL, a project or auth token, or any other distinguishing string). The default (no prefix) keeps the single-provider case simple. See the `cacheKeyPrefix` section in the Decision above. From 85b5aafe6db8cfed9307487fc9f976d7e694039a Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 13 Apr 2026 15:53:58 -0400 Subject: [PATCH 27/39] docs(adr): add version field to example and rename file to kebab-case Add version field to persisted entry example JSON for schema versioning. Rename ADR file from camelCase to kebab-case to match convention. Signed-off-by: Jonathan Norris --- ...ers.md => 0009-local-storage-for-static-context-providers.md} | 1 + 1 file changed, 1 insertion(+) rename service/adrs/{0009-localStorageForStaticContextProviders.md => 0009-local-storage-for-static-context-providers.md} (99%) diff --git a/service/adrs/0009-localStorageForStaticContextProviders.md b/service/adrs/0009-local-storage-for-static-context-providers.md similarity index 99% rename from service/adrs/0009-localStorageForStaticContextProviders.md rename to service/adrs/0009-local-storage-for-static-context-providers.md index 2556956..6f0e3a1 100644 --- a/service/adrs/0009-localStorageForStaticContextProviders.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -37,6 +37,7 @@ Example persisted value: ```json { + "version": 1, "cacheKeyHash": "hash(targetingKey)", "etag": "\"abc123\"", "writtenAt": "2026-03-07T18:20:00Z", From adac228687f451bcc51148fd6f0597dc96863035 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 13 Apr 2026 16:01:55 -0400 Subject: [PATCH 28/39] chore: reset non-ADR-0009 files to main Signed-off-by: Jonathan Norris --- service/adrs/0008-sse-for-bulk-evaluation-changes.md | 10 ++++++---- service/openapi.yaml | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/service/adrs/0008-sse-for-bulk-evaluation-changes.md b/service/adrs/0008-sse-for-bulk-evaluation-changes.md index f52d8b5..91b6393 100644 --- a/service/adrs/0008-sse-for-bulk-evaluation-changes.md +++ b/service/adrs/0008-sse-for-bulk-evaluation-changes.md @@ -62,10 +62,10 @@ Add an optional `eventStreams` field to `bulkEvaluationSuccess`: Each event stream object has: - `type` (string, required): The connection type. Currently `"sse"` is the only defined value. Providers must ignore entries with unknown types for forward compatibility, allowing new push mechanisms to be added without breaking existing clients. - `url` (string, optional): The endpoint URL. This is the default representation and is opaque to the provider. It may include authentication tokens, channel identifiers, or other vendor-specific query parameters. Implementations must treat this URL as sensitive -- it may contain auth tokens or channel credentials -- and must not log or persist the full URL including query string. -- `endpoint` (object, optional): Structured endpoint components for deployments that need to override the origin cleanly (for example, via a proxy) while preserving the request target. If present, it has `origin` and `requestUri` fields. +- `endpoint` (object, optional): Structured endpoint components for deployments that need to override the origin cleanly (for example, via a proxy) while preserving the request target. It has a required `requestUri` field and an optional `origin` field. If `origin` is absent, providers should use their configured OFREP base URL origin. - `inactivityDelaySec` (integer, optional): Seconds of client inactivity (e.g., browser tab hidden, mobile app backgrounded) after which the connection should be closed. The client must reconnect and perform a full unconditional re-fetch when activity resumes. Minimum value is `1`. When determining the effective inactivity timeout, providers should use a client-side override if configured; otherwise use this value when present; otherwise default to `120` seconds. -Exactly one of `url` or `endpoint` must be provided. Providers should use `url` as-is when present. When `endpoint` is present, providers should construct the connection URL as `origin + requestUri`. +Exactly one of `url` or `endpoint` must be provided. Providers should use `url` as-is when present. When `endpoint` is present, providers should construct the connection URL as `origin + requestUri`, where `origin` defaults to the provider's configured OFREP base URL if not specified. The `eventStreams` field is an array to support vendors whose infrastructure may require connections to multiple channels or endpoints (e.g., a global channel for environment-wide changes and a user-specific channel for targeted updates). Many SSE providers support multiple channels on a single URL, so the array will typically contain a single entry. @@ -242,18 +242,20 @@ eventStream: endpoint: type: object required: - - origin - requestUri description: | Structured endpoint components for deployments that need to override the origin cleanly while preserving the request target. When present, - providers construct the connection URL as `origin + requestUri`. + providers construct the connection URL as `origin + requestUri`. If + `origin` is absent, providers should use their configured OFREP base + URL origin. properties: origin: type: string format: uri description: | The scheme + host + optional port portion of the endpoint URL. + If absent, providers should use their configured OFREP base URL origin. example: "https://sse.example.com" requestUri: type: string diff --git a/service/openapi.yaml b/service/openapi.yaml index e2a650f..1707928 100644 --- a/service/openapi.yaml +++ b/service/openapi.yaml @@ -418,18 +418,20 @@ components: eventStreamEndpoint: type: object required: - - origin - requestUri description: | Structured endpoint components for deployments that need to override the origin cleanly while preserving the request target. When present, - providers construct the connection URL as `origin + requestUri`. + providers construct the connection URL as `origin + requestUri`. If + `origin` is absent, providers should use their configured OFREP base + URL origin. properties: origin: type: string format: uri description: | The scheme + host + optional port portion of the endpoint URL. + If absent, providers should use their configured OFREP base URL origin. example: "https://sse.example.com" requestUri: type: string From dff68819968f5def2ded69cffc9b3683126b65b0 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 15 Apr 2026 15:46:56 -0400 Subject: [PATCH 29/39] docs(adr): use delimiter in cacheKeyPrefix hash to prevent collisions Use hash(cacheKeyPrefix + ":" + targetingKey) to avoid ambiguous concatenation where different prefix/key pairs produce the same hash. Signed-off-by: Jonathan Norris --- .../0009-local-storage-for-static-context-providers.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 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 6f0e3a1..68dcf55 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -28,10 +28,10 @@ The persisted entry should include: - the bulk evaluation payload - the associated `ETag`, if one was returned -- a `cacheKeyHash` equal to `hash(targetingKey)`, or `hash(cacheKeyPrefix + targetingKey)` when a `cacheKeyPrefix` is configured +- a `cacheKeyHash` equal to `hash(targetingKey)`, or `hash(cacheKeyPrefix + ":" + targetingKey)` when a `cacheKeyPrefix` is configured - the time the entry was written, which can be used for diagnostics and optional implementation-specific staleness policies -Providers should support an optional `cacheKeyPrefix` configuration option. When provided, the prefix is included in the cache key hash: `hash(cacheKeyPrefix + targetingKey)`. This prevents collisions when multiple OFREP provider instances share the same local storage partition (e.g., two providers on the same web origin pointing at different OFREP servers). The prefix value is left to the application author; it could be the OFREP base URL, a project or auth token, or any other distinguishing string. When no prefix is configured, the cache key defaults to `hash(targetingKey)`. +Providers should support an optional `cacheKeyPrefix` configuration option. When provided, the prefix is included in the cache key hash: `hash(cacheKeyPrefix + ":" + targetingKey)`. This prevents collisions when multiple OFREP provider instances share the same local storage partition (e.g., two providers on the same web origin pointing at different OFREP servers). The prefix value is left to the application author; it could be the OFREP base URL, a project or auth token, or any other distinguishing string. When no prefix is configured, the cache key defaults to `hash(targetingKey)`. Example persisted value: @@ -139,7 +139,7 @@ If the background refresh fails and the provider cannot confirm that cached valu ### Cache matching and fallback Providers should only reuse a persisted evaluation when it matches the current static-context inputs. -This includes a matching `cacheKeyHash` equal to `hash(targetingKey)`, or `hash(cacheKeyPrefix + targetingKey)` when a `cacheKeyPrefix` is configured. +This includes a matching `cacheKeyHash` equal to `hash(targetingKey)`, or `hash(cacheKeyPrefix + ":" + targetingKey)` when a `cacheKeyPrefix` is configured. The cache key is intentionally derived from `targetingKey` alone rather than the full evaluation context. Static-context evaluations on the server can depend on context properties beyond `targetingKey`, so cached values may not reflect the current full context. @@ -214,4 +214,4 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first 1. Should providers support caching evaluations for multiple targeting keys (like LaunchDarkly's `maxCachedContexts`), or only retain the most recent? Multi-context caching enables instant user switching on shared devices but increases storage usage. 2. Should the storage key include a namespace to prevent collisions when multiple OFREP providers share the same local storage origin? - - **Answer:** Yes. Providers should support an optional `cacheKeyPrefix` configuration option. When provided, the cache key becomes `hash(cacheKeyPrefix + targetingKey)` instead of `hash(targetingKey)`. The prefix value is left to the application author (e.g., the OFREP base URL, a project or auth token, or any other distinguishing string). The default (no prefix) keeps the single-provider case simple. See the `cacheKeyPrefix` section in the Decision above. + - **Answer:** Yes. Providers should support an optional `cacheKeyPrefix` configuration option. When provided, the cache key becomes `hash(cacheKeyPrefix + ":" + targetingKey)` instead of `hash(targetingKey)`. The prefix value is left to the application author (e.g., the OFREP base URL, a project or auth token, or any other distinguishing string). The default (no prefix) keeps the single-provider case simple. See the `cacheKeyPrefix` section in the Decision above. From 752bf76a1ecfa5ef483aa9c745c396127367f981 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 15 Apr 2026 15:50:26 -0400 Subject: [PATCH 30/39] docs(adr): add implementation note for storage write failures If persisting fails, continue with fresh in-memory values. The old persisted entry remains for the next cold start. Signed-off-by: Jonathan Norris --- service/adrs/0009-local-storage-for-static-context-providers.md | 1 + 1 file changed, 1 insertion(+) 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 68dcf55..6811e00 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -209,6 +209,7 @@ Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first - On the first cold start (no persisted entry), `initialize()` blocks on the network request as normal. Cache-first initialization only applies once a successful evaluation has been persisted - SDK documentation should note that initial evaluations may return cached values (with `CACHED` reason) that are subsequently updated when fresh values arrive - Providers should enforce a configurable TTL on persisted entries to ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). Since auth and config errors do not clear the persisted cache, the TTL is the mechanism that prevents indefinitely stale data. DevCycle uses a 30-day default (`configCacheTTL`) as a reference. +- If a storage write fails (e.g. quota exceeded, permission denied), the provider should log the error and continue operating with the fresh values in memory. The previously persisted entry, if any, remains on disk for the next cold start. ## Open Questions From a16acdb9dc2912787a4b7255388a37b56301abe5 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 22 Apr 2026 11:07:07 -0400 Subject: [PATCH 31/39] docs(adr): introduce cacheMode option with network-first mode for SPAs Replace the disableLocalCache boolean with a three-value cacheMode enum: cache-first (default), network-first, and disabled. network-first awaits the initial network request and falls back to cached values only on network errors or 5xx responses, which fits SPA use cases that block rendering on flag evaluation. Document timeoutMs tuning guidance for network-first, add alternatives considered, and update initialization flow and mermaid diagram notes. Signed-off-by: Jonathan Norris --- ...al-storage-for-static-context-providers.md | 66 +++++++++++++++++-- 1 file changed, 59 insertions(+), 7 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 6811e00..69053bb 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -24,6 +24,12 @@ Persisting the last successful static-context evaluation and loading it on start Static-context providers should persist their last successful bulk evaluation in local persistent storage by default, and use cache-first initialization to serve persisted evaluations immediately on startup. +Providers should expose a `cacheMode` option that controls this behavior, with three supported values: + +- `cache-first` (default): load from the persisted cache immediately on startup so `initialize()` can return right away, then refresh from the network in the background. +- `network-first`: `initialize()` awaits the initial `/ofrep/v1/evaluate/flags` response (subject to the provider's existing request timeout). If the request succeeds, populate the in-memory cache from the response and persist it. If the request fails with a network error or `5xx`, fall back to the persisted entry when one exists. If the request fails with an authorization or configuration error (`401`, `403`, `400`), emit a fatal error as normal and do not fall back to cached values. This mode still writes successful evaluations to disk so cached values are available for fallback on future startups. +- `disabled`: no persistence at all. The in-memory cache is used during the session but nothing is written to or read from local storage. `initialize()` blocks on the network request (same as a cache miss). + The persisted entry should include: - the bulk evaluation payload @@ -59,7 +65,9 @@ Persistent local storage acts as the source used to bootstrap that in-memory cac ### Initialization -During initialization, a provider should follow a cache-first approach: +The initialization flow depends on the configured `cacheMode`. The default `cache-first` behavior is described in detail below and reflected in the sequence diagram; `network-first` and `disabled` follow variants of the same flow described in their own subsections. + +#### `cache-first` initialization (default) 1. Attempt to load a matching persisted bulk evaluation from local storage (matching `cacheKeyHash`). 2. **If a matching persisted entry exists (cache hit):** @@ -75,6 +83,24 @@ During initialization, a provider should follow a cache-first approach: - If the request fails with a transient or server error, preserve the existing initialization failure behavior (SDK emits `PROVIDER_ERROR`). - If the request fails with an authorization or configuration error, preserve the existing initialization failure behavior (SDK emits `PROVIDER_ERROR` with error code `PROVIDER_FATAL`). +#### `network-first` initialization + +In this mode, `initialize()` awaits the initial `/ofrep/v1/evaluate/flags` request (subject to the provider's request timeout) rather than returning immediately from cache. A persisted entry is only used as a fallback when the network is unavailable. + +1. Attempt the `/ofrep/v1/evaluate/flags` request and await the response. +2. **If the request succeeds**: populate the in-memory cache from the response, persist the entry, and return from `initialize()` (SDK emits `PROVIDER_READY`). Evaluations use the server-provided reasons. The application sees fresh values from the first evaluation with no cached flash. +3. **If the request fails with a transient or server error, or times out**: + - Attempt to load a matching persisted entry from local storage. + - If one exists, populate the in-memory cache from it and return from `initialize()` (SDK emits `PROVIDER_READY`). Evaluations use `CACHED` as the reason. Continue retrying the network request in the background. + - If no persisted entry exists, preserve the existing initialization failure behavior (SDK emits `PROVIDER_ERROR`). +4. **If the request fails with an authorization or configuration error (`401`, `403`, `400`)**: preserve the existing initialization failure behavior (SDK emits `PROVIDER_ERROR` with error code `PROVIDER_FATAL`). Do not fall back to cached values, even if a persisted entry exists. The application has explicitly chosen to block on a fresh evaluation; an auth or configuration error is a real problem that should be surfaced rather than masked by cache. + +Applications choosing `network-first` should consider lowering the provider's request timeout from its default so that initialization falls back to cache quickly when the network is unavailable, rather than leaving users staring at a loading state. + +#### `disabled` cache initialization + +When `cacheMode` is `disabled`, the provider does not read from or write to local storage. `initialize()` blocks on the `/ofrep/v1/evaluate/flags` request and behaves the same as the cache-miss path in `cache-first` mode. Persistence-related options (`cacheKeyPrefix`, TTL) have no effect. + ```mermaid sequenceDiagram participant App as Application @@ -126,6 +152,8 @@ sequenceDiagram end ``` +The sequence diagram above shows the default `cache-first` flow. In `network-first` mode, `initialize()` instead awaits the network request first and only loads from cache on network failure (see the "`network-first` initialization" subsection above). In `disabled` mode, no storage reads or writes occur and `initialize()` blocks on the network request the same way the cache-miss path does today. + ### Why PROVIDER_READY and not PROVIDER_STALE on cache hit The spec defines `READY` as "the provider has been initialized, and is able to reliably resolve flag values" and `STALE` as "the provider's cached state is no longer valid and may not be up-to-date with the source of truth." @@ -147,9 +175,11 @@ However, hashing the full context is impractical for cache-first startup because The accepted tradeoff is that the cache is keyed by stable user identity: a change in `targetingKey` (user switch, logout) invalidates the cache, but changes to other context properties do not. Those properties only affect evaluation when the server is reachable, at which point the provider refreshes anyway. -When the provider has not initialized from cache (cache miss path), providers must not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. +When the provider has not initialized from cache (cache miss path, or `network-first` mode), providers must not silently fall back to persisted data for authorization failures, invalid requests, or other responses that indicate a configuration or protocol problem. In `network-first` mode this applies even when a matching persisted entry exists: the application has explicitly chosen to block on a fresh evaluation, and an auth or configuration error should be surfaced rather than masked by the cache. -When the provider has already initialized from cache (cache hit path), authorization or configuration errors from the background refresh should be surfaced via logging or provider error events. The provider should continue serving cached values for the current session rather than revoking a working state. The persisted entry should not be cleared on auth or config errors; the cache TTL is responsible for eventual expiry. This avoids degrading subsequent cold starts to defaults while the error is investigated. +In `network-first` mode, fallback to a persisted entry is limited to network errors, `5xx` responses, and request timeouts. If a persisted entry exists in those cases, the provider loads it, emits `PROVIDER_READY` with `CACHED` as the evaluation reason, and continues retrying in the background. + +When the provider has already initialized from cache (cache hit path in `cache-first` mode), authorization or configuration errors from the background refresh should be surfaced via logging or provider error events. The provider should continue serving cached values for the current session rather than revoking a working state. The persisted entry should not be cleared on auth or config errors; the cache TTL is responsible for eventual expiry. This avoids degrading subsequent cold starts to defaults while the error is investigated. ### Refresh and revalidation @@ -158,10 +188,18 @@ If an `ETag` was stored with the persisted entry, the provider should use it wit ### Configuration -Providers should allow applications to disable the default persistence behavior, for example with a `disableLocalCache` option, or replace the storage backend when platform requirements or policy constraints require it. +Providers should expose a `cacheMode` option with values `cache-first` (default), `network-first`, or `disabled`. Applications choose the mode based on their UX and consistency requirements: + +- `cache-first` is appropriate for most mobile and web applications where the flash-of-defaults problem on cold start is the primary UX concern. +- `network-first` is appropriate for single-page applications and other use cases that already block rendering on initialization and want fresh values on every cold start, with cached values used only as a fallback when the network is unreachable. +- `disabled` is appropriate when platform requirements or policy constraints forbid persisting evaluation data. + +Applications using `network-first` should consider lowering the provider's request timeout (`timeoutMs` or equivalent) from the default (typically `10000` ms) to a shorter value appropriate for blocking initialization, so that users do not sit on a loading state for the full timeout when the network is unavailable. When applications configure more than one static-context provider against the same underlying storage (same browser origin, shared app container, etc.), each provider instance should be configured with a distinct `cacheKeyPrefix` so persisted entries are namespaced and instances do not load or overwrite each other's bulk evaluations. +Providers may additionally allow replacing the storage backend when platform requirements or policy constraints require a specific storage mechanism. + ## Consequences ### Positive @@ -169,6 +207,7 @@ When applications configure more than one static-context provider against the sa - Cache-first initialization eliminates the flash-of-defaults problem, where applications briefly show default values before evaluated values arrive - Static-context providers become resilient to offline application startup when a last-known evaluation exists - Web and mobile applications preserve feature state across restarts instead of losing it with the in-memory cache +- Applications with strict consistency requirements (e.g., SPAs that already block rendering on flag evaluation and prefer fresh values on every cold start over potential flicker from cached values) can opt into `network-first` mode while still retaining persistence for offline fallback - The decision aligns with the established pattern used by vendor SDKs (LaunchDarkly, Statsig, DevCycle, Eppo) and with the existing OFREP model where static-context providers evaluate remotely once and then read locally - Reusing the stored `ETag` allows efficient revalidation when connectivity returns - Provider implementations get a consistent default expectation for offline behavior across ecosystems @@ -195,18 +234,31 @@ In this approach, the provider always attempts the network request first and onl This is simpler to implement but introduces the flash-of-defaults problem on every normal startup: applications must wait for the network response before flag evaluations return meaningful values. Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first initialization instead because it produces better UX for end users. +This approach is still available to applications as a non-default mode via `cacheMode: "network-first"`, which is appropriate for SPAs and similar use cases that already block rendering on initialization. + +### Two separate booleans (`disableLocalCache` + `cacheFirstInit`) + +An earlier version of this ADR used a single `disableLocalCache` boolean. Adding a second boolean for initialization strategy would have given three meaningful combinations plus one nonsensical one (`disableLocalCache: true` combined with `cacheFirstInit: true` has no cache to load from). +A single `cacheMode` enum with three explicit values is clearer and avoids the nonsensical combination. + +### Platform-specific defaults (cache-first on mobile, network-first on web) + +The right initialization behavior depends on application type rather than platform. Many web applications (especially PWAs and apps with persistent sessions) benefit from cache-first, while some mobile apps might prefer network-first for specific consistency requirements. +A single default (cache-first) with an explicit per-application opt-out is clearer than per-platform defaults that authors would need to learn and override. + ## Implementation Notes - "Local storage" means a local persistent key-value store appropriate for the runtime, such as browser `localStorage` on the web or an equivalent mobile storage mechanism - Providers should version their persisted format so future schema changes can be handled safely - Providers should avoid persisting raw `targetingKey` values when `cacheKeyHash` is sufficient for matching -- Providers should expose a `disableLocalCache` option to turn off persisted local storage +- Providers should expose a `cacheMode` option with values `cache-first` (default), `network-first`, and `disabled`. `network-first` and `disabled` block `initialize()` on the network request; `cache-first` returns from `initialize()` immediately when a persisted entry exists - Providers should expose an optional `cacheKeyPrefix` configuration option so multiple provider instances sharing one storage partition do not collide on the same storage key - Providers should clear or replace persisted entries when the `targetingKey` changes, such as on logout or user switch -- The `initialize()` function should return immediately when a matching cached entry exists, allowing the SDK to emit `PROVIDER_READY` from cache +- In `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 -- On the first cold start (no persisted entry), `initialize()` blocks on the network request as normal. Cache-first initialization only applies once a successful evaluation has been persisted +- On the first cold start in `cache-first` mode (no persisted entry), `initialize()` blocks on the network request as normal. Cache-first initialization only returns immediately once a successful evaluation has been persisted +- In `network-first` mode, applications should consider lowering the provider's request timeout (e.g., `timeoutMs`) from the default so that initialization falls back to cache or fails quickly when the network is unavailable, rather than leaving users on a loading state for the full timeout - SDK documentation should note that initial evaluations may return cached values (with `CACHED` reason) that are subsequently updated when fresh values arrive - Providers should enforce a configurable TTL on persisted entries to ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). Since auth and config errors do not clear the persisted cache, the TTL is the mechanism that prevents indefinitely stale data. DevCycle uses a 30-day default (`configCacheTTL`) as a reference. - If a storage write fails (e.g. quota exceeded, permission denied), the provider should log the error and continue operating with the fresh values in memory. The previously persisted entry, if any, remains on disk for the next cold start. From fe624b2bc51ef4b9c436aa39b771f8ddaa92057e Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 22 Apr 2026 11:55:31 -0400 Subject: [PATCH 32/39] docs(adr): rename cache-first to local-cache-first for clarity Signed-off-by: Jonathan Norris --- ...al-storage-for-static-context-providers.md | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 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 69053bb..4b96c0a 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -15,18 +15,18 @@ Static-context providers are primarily web and mobile providers, where applicati In those cases, the last successful bulk evaluation is lost and applications fall back to errors or code defaults instead of continuing with a usable last-known state. This is also out of step with most vendor-provided web and mobile SDKs for the same class of provider, which persist flag state to local storage or on-device disk by default. -Vendor SDKs from LaunchDarkly, Statsig, DevCycle, and Eppo all use a cache-first initialization pattern: load persisted evaluations immediately on startup so initial synchronous flag evaluations never return defaults, refresh from the network in parallel, and emit change events when fresh values arrive. +Vendor SDKs from LaunchDarkly, Statsig, DevCycle, and Eppo all use a local-cache-first initialization pattern: load persisted evaluations immediately on startup so initial synchronous flag evaluations never return defaults, refresh from the network in parallel, and emit change events when fresh values arrive. See [vendor mobile SDK caching research](https://gist.github.com/jonathannorris/4f2f63142b70719e3c6bfe8b226a0585) for a detailed comparison. Persisting the last successful static-context evaluation and loading it on startup would extend the existing cache model across restarts and temporary connectivity loss without requiring protocol changes, while eliminating the flash-of-defaults problem that occurs when applications wait for a network response before evaluations return meaningful values. ## Decision -Static-context providers should persist their last successful bulk evaluation in local persistent storage by default, and use cache-first initialization to serve persisted evaluations immediately on startup. +Static-context providers should persist their last successful bulk evaluation in local persistent storage by default, and use local-cache-first initialization to serve persisted evaluations immediately on startup. Providers should expose a `cacheMode` option that controls this behavior, with three supported values: -- `cache-first` (default): load from the persisted cache immediately on startup so `initialize()` can return right away, then refresh from the network in the background. +- `local-cache-first` (default): load from the persisted cache immediately on startup so `initialize()` can return right away, then refresh from the network in the background. - `network-first`: `initialize()` awaits the initial `/ofrep/v1/evaluate/flags` response (subject to the provider's existing request timeout). If the request succeeds, populate the in-memory cache from the response and persist it. If the request fails with a network error or `5xx`, fall back to the persisted entry when one exists. If the request fails with an authorization or configuration error (`401`, `403`, `400`), emit a fatal error as normal and do not fall back to cached values. This mode still writes successful evaluations to disk so cached values are available for fallback on future startups. - `disabled`: no persistence at all. The in-memory cache is used during the session but nothing is written to or read from local storage. `initialize()` blocks on the network request (same as a cache miss). @@ -65,9 +65,9 @@ Persistent local storage acts as the source used to bootstrap that in-memory cac ### Initialization -The initialization flow depends on the configured `cacheMode`. The default `cache-first` behavior is described in detail below and reflected in the sequence diagram; `network-first` and `disabled` follow variants of the same flow described in their own subsections. +The initialization flow depends on the configured `cacheMode`. The default `local-cache-first` behavior is described in detail below and reflected in the sequence diagram; `network-first` and `disabled` follow variants of the same flow described in their own subsections. -#### `cache-first` initialization (default) +#### `local-cache-first` initialization (default) 1. Attempt to load a matching persisted bulk evaluation from local storage (matching `cacheKeyHash`). 2. **If a matching persisted entry exists (cache hit):** @@ -99,7 +99,7 @@ Applications choosing `network-first` should consider lowering the provider's re #### `disabled` cache initialization -When `cacheMode` is `disabled`, the provider does not read from or write to local storage. `initialize()` blocks on the `/ofrep/v1/evaluate/flags` request and behaves the same as the cache-miss path in `cache-first` mode. Persistence-related options (`cacheKeyPrefix`, TTL) have no effect. +When `cacheMode` is `disabled`, the provider does not read from or write to local storage. `initialize()` blocks on the `/ofrep/v1/evaluate/flags` request and behaves the same as the cache-miss path in `local-cache-first` mode. Persistence-related options (`cacheKeyPrefix`, TTL) have no effect. ```mermaid sequenceDiagram @@ -152,7 +152,7 @@ sequenceDiagram end ``` -The sequence diagram above shows the default `cache-first` flow. In `network-first` mode, `initialize()` instead awaits the network request first and only loads from cache on network failure (see the "`network-first` initialization" subsection above). In `disabled` mode, no storage reads or writes occur and `initialize()` blocks on the network request the same way the cache-miss path does today. +The sequence diagram above shows the default `local-cache-first` flow. In `network-first` mode, `initialize()` instead awaits the network request first and only loads from cache on network failure (see the "`network-first` initialization" subsection above). In `disabled` mode, no storage reads or writes occur and `initialize()` blocks on the network request the same way the cache-miss path does today. ### Why PROVIDER_READY and not PROVIDER_STALE on cache hit @@ -160,7 +160,7 @@ The spec defines `READY` as "the provider has been initialized, and is able to r On cache-hit startup, the provider emits `PROVIDER_READY` rather than `PROVIDER_STALE` for two reasons. First, at the moment of loading from cache, the provider does not yet know whether the cached values differ from the server. The values were correct as of the last successful evaluation and may still be current. The background refresh will determine whether they have changed. -Second, `PROVIDER_STALE` would break the initialization contract. Applications and SDKs listen for `PROVIDER_READY` to begin flag evaluation. If the provider emitted `PROVIDER_STALE` instead, the SDK would not transition out of `NOT_READY`, and flag evaluations would short-circuit to defaults, which defeats the purpose of cache-first initialization. +Second, `PROVIDER_STALE` would break the initialization contract. Applications and SDKs listen for `PROVIDER_READY` to begin flag evaluation. If the provider emitted `PROVIDER_STALE` instead, the SDK would not transition out of `NOT_READY`, and flag evaluations would short-circuit to defaults, which defeats the purpose of local-cache-first initialization. If the background refresh fails and the provider cannot confirm that cached values are current, the provider may emit `PROVIDER_STALE` at that point to signal that values may be out of date. @@ -171,7 +171,7 @@ This includes a matching `cacheKeyHash` equal to `hash(targetingKey)`, or `hash( The cache key is intentionally derived from `targetingKey` alone rather than the full evaluation context. Static-context evaluations on the server can depend on context properties beyond `targetingKey`, so cached values may not reflect the current full context. -However, hashing the full context is impractical for cache-first startup because many implementations set volatile context properties on initialization (e.g. `lastSessionTime`, `lastSeen`, `sessionId`) that would change the hash on every app restart, defeating the purpose of persistence. +However, hashing the full context is impractical for local-cache-first startup because many implementations set volatile context properties on initialization (e.g. `lastSessionTime`, `lastSeen`, `sessionId`) that would change the hash on every app restart, defeating the purpose of persistence. The accepted tradeoff is that the cache is keyed by stable user identity: a change in `targetingKey` (user switch, logout) invalidates the cache, but changes to other context properties do not. Those properties only affect evaluation when the server is reachable, at which point the provider refreshes anyway. @@ -179,7 +179,7 @@ When the provider has not initialized from cache (cache miss path, or `network-f In `network-first` mode, fallback to a persisted entry is limited to network errors, `5xx` responses, and request timeouts. If a persisted entry exists in those cases, the provider loads it, emits `PROVIDER_READY` with `CACHED` as the evaluation reason, and continues retrying in the background. -When the provider has already initialized from cache (cache hit path in `cache-first` mode), authorization or configuration errors from the background refresh should be surfaced via logging or provider error events. The provider should continue serving cached values for the current session rather than revoking a working state. The persisted entry should not be cleared on auth or config errors; the cache TTL is responsible for eventual expiry. This avoids degrading subsequent cold starts to defaults while the error is investigated. +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 surfaced via logging or provider error events. The provider should continue serving cached values for the current session rather than revoking a working state. The persisted entry should not be cleared on auth or config errors; the cache TTL is responsible for eventual expiry. This avoids degrading subsequent cold starts to defaults while the error is investigated. ### Refresh and revalidation @@ -188,9 +188,9 @@ If an `ETag` was stored with the persisted entry, the provider should use it wit ### Configuration -Providers should expose a `cacheMode` option with values `cache-first` (default), `network-first`, or `disabled`. Applications choose the mode based on their UX and consistency requirements: +Providers should expose a `cacheMode` option with values `local-cache-first` (default), `network-first`, or `disabled`. Applications choose the mode based on their UX and consistency requirements: -- `cache-first` is appropriate for most mobile and web applications where the flash-of-defaults problem on cold start is the primary UX concern. +- `local-cache-first` is appropriate for most mobile and web applications where the flash-of-defaults problem on cold start is the primary UX concern. - `network-first` is appropriate for single-page applications and other use cases that already block rendering on initialization and want fresh values on every cold start, with cached values used only as a fallback when the network is unreachable. - `disabled` is appropriate when platform requirements or policy constraints forbid persisting evaluation data. @@ -204,7 +204,7 @@ Providers may additionally allow replacing the storage backend when platform req ### Positive -- Cache-first initialization eliminates the flash-of-defaults problem, where applications briefly show default values before evaluated values arrive +- Local-cache-first initialization eliminates the flash-of-defaults problem, where applications briefly show default values before evaluated values arrive - Static-context providers become resilient to offline application startup when a last-known evaluation exists - Web and mobile applications preserve feature state across restarts instead of losing it with the in-memory cache - Applications with strict consistency requirements (e.g., SPAs that already block rendering on flag evaluation and prefer fresh values on every cold start over potential flicker from cached values) can opt into `network-first` mode while still retaining persistence for offline fallback @@ -219,7 +219,7 @@ Providers may additionally allow replacing the storage backend when platform req - Applications may briefly see stale cached values before fresh values arrive, and should handle `PROVIDER_CONFIGURATION_CHANGED` events if they need to react to updates - Persisting evaluation data on-device means flag values are stored in plaintext in platform-local storage, which may be accessible to other code running in the same origin (web) or on compromised devices (mobile) - Mobile platforms do not share a single storage API, so providers may need platform-specific defaults behind a common abstraction -- Existing OFREP static-context providers (`js-sdk-contrib`, `kotlin-sdk-contrib`, `ofrep-swift-client-provider`) all block `initialize()` on a network request today. Adopting cache-first initialization requires lifecycle and event model changes in each implementation, particularly the Kotlin provider which currently emits `PROVIDER_READY` on poll updates instead of `PROVIDER_CONFIGURATION_CHANGED` +- Existing OFREP static-context providers (`js-sdk-contrib`, `kotlin-sdk-contrib`, `ofrep-swift-client-provider`) all block `initialize()` on a network request today. Adopting local-cache-first initialization requires lifecycle and event model changes in each implementation, particularly the Kotlin provider which currently emits `PROVIDER_READY` on poll updates instead of `PROVIDER_CONFIGURATION_CHANGED` ## Alternatives Considered @@ -232,7 +232,7 @@ For static-context providers, especially web and mobile providers, persistence i In this approach, the provider always attempts the network request first and only falls back to cached evaluations when the request fails. This is simpler to implement but introduces the flash-of-defaults problem on every normal startup: applications must wait for the network response before flag evaluations return meaningful values. -Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses cache-first initialization instead because it produces better UX for end users. +Every major vendor SDK (LaunchDarkly, Statsig, DevCycle, Eppo) uses local-cache-first initialization instead because it produces better UX for end users. This approach is still available to applications as a non-default mode via `cacheMode: "network-first"`, which is appropriate for SPAs and similar use cases that already block rendering on initialization. @@ -241,23 +241,23 @@ This approach is still available to applications as a non-default mode via `cach An earlier version of this ADR used a single `disableLocalCache` boolean. Adding a second boolean for initialization strategy would have given three meaningful combinations plus one nonsensical one (`disableLocalCache: true` combined with `cacheFirstInit: true` has no cache to load from). A single `cacheMode` enum with three explicit values is clearer and avoids the nonsensical combination. -### Platform-specific defaults (cache-first on mobile, network-first on web) +### Platform-specific defaults (local-cache-first on mobile, network-first on web) -The right initialization behavior depends on application type rather than platform. Many web applications (especially PWAs and apps with persistent sessions) benefit from cache-first, while some mobile apps might prefer network-first for specific consistency requirements. -A single default (cache-first) with an explicit per-application opt-out is clearer than per-platform defaults that authors would need to learn and override. +The right initialization behavior depends on application type rather than platform. Many web applications (especially PWAs and apps with persistent sessions) benefit from local-cache-first, while some mobile apps might prefer network-first for specific consistency requirements. +A single default (local-cache-first) with an explicit per-application opt-out is clearer than per-platform defaults that authors would need to learn and override. ## Implementation Notes - "Local storage" means a local persistent key-value store appropriate for the runtime, such as browser `localStorage` on the web or an equivalent mobile storage mechanism - Providers should version their persisted format so future schema changes can be handled safely - Providers should avoid persisting raw `targetingKey` values when `cacheKeyHash` is sufficient for matching -- Providers should expose a `cacheMode` option with values `cache-first` (default), `network-first`, and `disabled`. `network-first` and `disabled` block `initialize()` on the network request; `cache-first` returns from `initialize()` immediately when a persisted entry exists +- 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 `cacheKeyPrefix` configuration option so multiple provider instances sharing one storage partition do not collide on the same storage key - Providers should clear or replace persisted entries when the `targetingKey` changes, such as on logout or user switch -- In `cache-first` mode, the `initialize()` function should return immediately when a matching cached entry exists, allowing the SDK to emit `PROVIDER_READY` from cache +- 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 -- On the first cold start in `cache-first` mode (no persisted entry), `initialize()` blocks on the network request as normal. Cache-first initialization only returns immediately once a successful evaluation has been persisted +- On the first cold start in `local-cache-first` mode (no persisted entry), `initialize()` blocks on the network request as normal. Local-cache-first initialization only returns immediately once a successful evaluation has been persisted - In `network-first` mode, applications should consider lowering the provider's request timeout (e.g., `timeoutMs`) from the default so that initialization falls back to cache or fails quickly when the network is unavailable, rather than leaving users on a loading state for the full timeout - SDK documentation should note that initial evaluations may return cached values (with `CACHED` reason) that are subsequently updated when fresh values arrive - Providers should enforce a configurable TTL on persisted entries to ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). Since auth and config errors do not clear the persisted cache, the TTL is the mechanism that prevents indefinitely stale data. DevCycle uses a 30-day default (`configCacheTTL`) as a reference. From 4b778657487da2bdf42a4e4e5423b1710b9a0a6d Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 22 Apr 2026 12:02:38 -0400 Subject: [PATCH 33/39] docs(adr): simplify network-first description to reference existing behavior Signed-off-by: Jonathan Norris --- service/adrs/0009-local-storage-for-static-context-providers.md | 2 +- 1 file changed, 1 insertion(+), 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 4b96c0a..e8835c4 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -27,7 +27,7 @@ Static-context providers should persist their last successful bulk evaluation in Providers should expose a `cacheMode` option that controls this behavior, with three supported values: - `local-cache-first` (default): load from the persisted cache immediately on startup so `initialize()` can return right away, then refresh from the network in the background. -- `network-first`: `initialize()` awaits the initial `/ofrep/v1/evaluate/flags` response (subject to the provider's existing request timeout). If the request succeeds, populate the in-memory cache from the response and persist it. If the request fails with a network error or `5xx`, fall back to the persisted entry when one exists. If the request fails with an authorization or configuration error (`401`, `403`, `400`), emit a fatal error as normal and do not fall back to cached values. This mode still writes successful evaluations to disk so cached values are available for fallback on future startups. +- `network-first`: matches the existing provider behavior (block `initialize()` on the network request) with a local-cache backup for offline and transient-error scenarios. Successful evaluations are still persisted to disk so future startups have a fallback when the network is unavailable, but cached values are never used on the happy path or on auth/configuration errors. - `disabled`: no persistence at all. The in-memory cache is used during the session but nothing is written to or read from local storage. `initialize()` blocks on the network request (same as a cache miss). The persisted entry should include: From e84df065d819663c94bb7034dc403abc37be4558 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 22 Apr 2026 12:03:59 -0400 Subject: [PATCH 34/39] docs(adr): explain network-first as existing behavior plus offline fallback Signed-off-by: Jonathan Norris --- service/adrs/0009-local-storage-for-static-context-providers.md | 2 ++ 1 file changed, 2 insertions(+) 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 e8835c4..c0f2931 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -85,6 +85,8 @@ The initialization flow depends on the configured `cacheMode`. The default `loca #### `network-first` initialization +This mode matches the existing behavior of today's OFREP static-context providers (`js-sdk-contrib`, `kotlin-sdk-contrib`, `ofrep-swift-client-provider`), which all block `initialize()` on a network request, with one addition: successful evaluations are persisted to disk and used as a fallback when the network is unavailable. An application migrating from today's provider to `network-first` sees no UX change on the happy path, and gains graceful recovery when the network is unreachable or the server returns a transient error. + In this mode, `initialize()` awaits the initial `/ofrep/v1/evaluate/flags` request (subject to the provider's request timeout) rather than returning immediately from cache. A persisted entry is only used as a fallback when the network is unavailable. 1. Attempt the `/ofrep/v1/evaluate/flags` request and await the response. From 2806037623fde71b3801a31918334097ec4f82a1 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 22 Apr 2026 12:05:36 -0400 Subject: [PATCH 35/39] docs(adr): add section title and description before initialization diagram Signed-off-by: Jonathan Norris --- .../adrs/0009-local-storage-for-static-context-providers.md | 4 ++++ 1 file changed, 4 insertions(+) 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 c0f2931..0785dfa 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -103,6 +103,10 @@ Applications choosing `network-first` should consider lowering the provider's re When `cacheMode` is `disabled`, the provider does not read from or write to local storage. `initialize()` blocks on the `/ofrep/v1/evaluate/flags` request and behaves the same as the cache-miss path in `local-cache-first` mode. Persistence-related options (`cacheKeyPrefix`, TTL) have no effect. +#### `local-cache-first` initialization sequence + +The diagram below illustrates the `local-cache-first` initialization flow in detail, covering both cache hit and cache miss paths along with the subsequent background refresh. `network-first` and `disabled` modes follow the flows described in their respective subsections above. + ```mermaid sequenceDiagram participant App as Application From cdfab0ed0cc8f551550190fadc0807954a684baf Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 22 Apr 2026 12:18:16 -0400 Subject: [PATCH 36/39] docs(adr): clarify TTL-expired entries must not be served Signed-off-by: Jonathan Norris --- service/adrs/0009-local-storage-for-static-context-providers.md | 2 +- 1 file changed, 1 insertion(+), 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 0785dfa..ca55dab 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -266,7 +266,7 @@ A single default (local-cache-first) with an explicit per-application opt-out is - On the first cold start in `local-cache-first` mode (no persisted entry), `initialize()` blocks on the network request as normal. Local-cache-first initialization only returns immediately once a successful evaluation has been persisted - In `network-first` mode, applications should consider lowering the provider's request timeout (e.g., `timeoutMs`) from the default so that initialization falls back to cache or fails quickly when the network is unavailable, rather than leaving users on a loading state for the full timeout - SDK documentation should note that initial evaluations may return cached values (with `CACHED` reason) that are subsequently updated when fresh values arrive -- Providers should enforce a configurable TTL on persisted entries to ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). Since auth and config errors do not clear the persisted cache, the TTL is the mechanism that prevents indefinitely stale data. DevCycle uses a 30-day default (`configCacheTTL`) as a reference. +- Providers should enforce a configurable TTL on persisted entries to ensure stale caches are eventually purged, particularly in cases where the provider can no longer refresh from the server (e.g. persistent auth errors). Since auth and config errors do not clear the persisted cache, the TTL is the mechanism that prevents indefinitely stale data. A persisted entry past its TTL must not be served to the application: the provider should treat it as a cache miss and fall through to the cache-miss path. DevCycle uses a 30-day default (`configCacheTTL`) as a reference. - If a storage write fails (e.g. quota exceeded, permission denied), the provider should log the error and continue operating with the fresh values in memory. The previously persisted entry, if any, remains on disk for the next cold start. ## Open Questions From 8c4d8e0ea015fdaa37c6e8ce28feff05e8459193 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 22 Apr 2026 12:20:06 -0400 Subject: [PATCH 37/39] docs(adr): split initialization sequence diagram into three smaller diagrams Replace the single nested-alt diagram with three focused ones: cache hit with background refresh, cache miss, and the normal polling cycle. Addresses review feedback that nested alts were hard to read. Signed-off-by: Jonathan Norris --- ...al-storage-for-static-context-providers.md | 82 ++++++++++++------- 1 file changed, 52 insertions(+), 30 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 ca55dab..daedcdd 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -105,7 +105,37 @@ When `cacheMode` is `disabled`, the provider does not read from or write to loca #### `local-cache-first` initialization sequence -The diagram below illustrates the `local-cache-first` initialization flow in detail, covering both cache hit and cache miss paths along with the subsequent background refresh. `network-first` and `disabled` modes follow the flows described in their respective subsections above. +The diagrams below illustrate the `local-cache-first` flow in three parts: cache-hit initialization (with background refresh), cache-miss initialization, and the normal polling cycle. `network-first` and `disabled` modes follow the flows described in their respective subsections above. + +**Cache hit — initialization from persisted entry, then background refresh:** + +```mermaid +sequenceDiagram + participant App as Application + participant Provider as OFREP Provider + participant Storage as Local Storage + participant Server as OFREP Service + + App->>Provider: initialize(context) + Provider->>Storage: load persisted evaluation + Storage-->>Provider: persisted entry + Provider->>Provider: Populate in-memory cache + Provider-->>App: PROVIDER_READY (from cache, reason: CACHED) + + Provider->>Server: POST /ofrep/v1/evaluate/flags (background) + alt Request succeeds + Server-->>Provider: 200 OK (flags + ETag) + Provider->>Provider: Update in-memory cache + Provider->>Storage: Persist updated entry + Provider-->>App: PROVIDER_CONFIGURATION_CHANGED + else Transient error + Note over Provider: Continue serving cached values + else Auth/config error + Note over Provider: Surface error, continue serving cached values + end +``` + +**Cache miss — no persisted entry, block on network:** ```mermaid sequenceDiagram @@ -116,37 +146,29 @@ sequenceDiagram App->>Provider: initialize(context) Provider->>Storage: load persisted evaluation - alt Cache hit (matching entry exists) - Storage-->>Provider: persisted entry + Storage-->>Provider: none + Provider->>Server: POST /ofrep/v1/evaluate/flags + alt Request succeeds + Server-->>Provider: 200 OK (flags + ETag) Provider->>Provider: Populate in-memory cache - Provider-->>App: PROVIDER_READY (from cache, reason: CACHED) - Provider->>Server: POST /ofrep/v1/evaluate/flags (background) - alt Request succeeds - Server-->>Provider: 200 OK (flags + ETag) - Provider->>Provider: Update in-memory cache - Provider->>Storage: Persist updated entry - Provider-->>App: PROVIDER_CONFIGURATION_CHANGED - else Transient error - Note over Provider: Continue serving cached values - else Auth/config error - Note over Provider: Surface error, continue serving cached values - end - else Cache miss (no matching entry) - Storage-->>Provider: none - Provider->>Server: POST /ofrep/v1/evaluate/flags - alt Request succeeds - Server-->>Provider: 200 OK (flags + ETag) - Provider->>Provider: Populate in-memory cache - Provider->>Storage: Persist entry - Provider-->>App: PROVIDER_READY - else Transient error - Provider-->>App: PROVIDER_ERROR - else Auth/config error - Provider-->>App: PROVIDER_ERROR (fatal) - end + Provider->>Storage: Persist entry + Provider-->>App: PROVIDER_READY + else Transient error + Provider-->>App: PROVIDER_ERROR + else Auth/config error + Provider-->>App: PROVIDER_ERROR (fatal) end +``` + +**Normal polling cycle — ETag revalidation:** + +```mermaid +sequenceDiagram + participant App as Application + participant Provider as OFREP Provider + participant Storage as Local Storage + participant Server as OFREP Service - Note over App,Server: Normal polling cycle Provider->>Server: POST /ofrep/v1/evaluate/flags with If-None-Match alt Flags changed Server-->>Provider: 200 OK (new flags + ETag) @@ -158,7 +180,7 @@ sequenceDiagram end ``` -The sequence diagram above shows the default `local-cache-first` flow. In `network-first` mode, `initialize()` instead awaits the network request first and only loads from cache on network failure (see the "`network-first` initialization" subsection above). In `disabled` mode, no storage reads or writes occur and `initialize()` blocks on the network request the same way the cache-miss path does today. +The sequence diagrams above show the default `local-cache-first` flow. In `network-first` mode, `initialize()` instead awaits the network request first and only loads from cache on network failure (see the "`network-first` initialization" subsection above). In `disabled` mode, no storage reads or writes occur and `initialize()` blocks on the network request the same way the cache-miss path does today. ### Why PROVIDER_READY and not PROVIDER_STALE on cache hit From 5b5ce6439d20d57843c2e1d0e8ec0557444ffd42 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 22 Apr 2026 12:23:28 -0400 Subject: [PATCH 38/39] docs(adr): reframe cache clearing as implementation detail, focus on TTL invariant Signed-off-by: Jonathan Norris --- .../adrs/0009-local-storage-for-static-context-providers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 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 daedcdd..1858645 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -76,7 +76,7 @@ The initialization flow depends on the configured `cacheMode`. The default `loca - Attempt the `/ofrep/v1/evaluate/flags` request in the background. - If the background request succeeds, update the in-memory cache from the response, update the persisted entry, and emit `PROVIDER_CONFIGURATION_CHANGED`. Evaluations should switch to the server-provided reasons. - If the background request fails with a transient or server error (network unavailable, `5xx`), continue serving cached values and retry on the normal polling schedule. - - If the background request fails with an authorization or configuration error (`401`, `403`, `400`), surface the error via logging or provider error events and continue serving cached values for the current session. The persisted entry should not be cleared; the cache TTL is responsible for eventual expiry. This ensures that subsequent cold starts can still bootstrap from cached values while the error is investigated, rather than immediately degrading to defaults. + - If the background request fails with an authorization or configuration error (`401`, `403`, `400`), surface the error via logging or provider error events and continue serving cached values for the current session. Auth or config errors alone should not invalidate the persisted entry; the cache TTL is what governs when it stops being served. This ensures that subsequent cold starts can still bootstrap from cached values while the error is investigated, rather than immediately degrading to defaults. 3. **If no matching persisted entry exists (cache miss):** - Attempt the `/ofrep/v1/evaluate/flags` request and await the response. - If the request succeeds, populate the in-memory cache from the response, persist the entry, and return from `initialize()` (SDK emits `PROVIDER_READY`). @@ -207,7 +207,7 @@ When the provider has not initialized from cache (cache miss path, or `network-f In `network-first` mode, fallback to a persisted entry is limited to network errors, `5xx` responses, and request timeouts. If a persisted entry exists in those cases, the provider loads it, emits `PROVIDER_READY` with `CACHED` as the evaluation reason, and continues retrying in the background. -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 surfaced via logging or provider error events. The provider should continue serving cached values for the current session rather than revoking a working state. The persisted entry should not be cleared on auth or config errors; the cache TTL is responsible for eventual expiry. This avoids degrading subsequent cold starts to defaults while the error is investigated. +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 surfaced via logging or 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. ### Refresh and revalidation From 05364b517f7162bf01fedf8468a813994fbbc12b Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 22 Apr 2026 12:25:53 -0400 Subject: [PATCH 39/39] docs(adr): require both logging and PROVIDER_ERROR emit on auth/config errors Per review feedback, the cache-hit path should both log the error and emit a PROVIDER_ERROR event when a background refresh fails with auth or config errors. Updates the decision text, cache matching section, and the sequence diagram. Signed-off-by: Jonathan Norris --- .../0009-local-storage-for-static-context-providers.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 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 1858645..4e42e48 100644 --- a/service/adrs/0009-local-storage-for-static-context-providers.md +++ b/service/adrs/0009-local-storage-for-static-context-providers.md @@ -76,7 +76,7 @@ The initialization flow depends on the configured `cacheMode`. The default `loca - Attempt the `/ofrep/v1/evaluate/flags` request in the background. - If the background request succeeds, update the in-memory cache from the response, update the persisted entry, and emit `PROVIDER_CONFIGURATION_CHANGED`. Evaluations should switch to the server-provided reasons. - If the background request fails with a transient or server error (network unavailable, `5xx`), continue serving cached values and retry on the normal polling schedule. - - If the background request fails with an authorization or configuration error (`401`, `403`, `400`), surface the error via logging or provider error events and continue serving cached values for the current session. Auth or config errors alone should not invalidate the persisted entry; the cache TTL is what governs when it stops being served. This ensures that subsequent cold starts can still bootstrap from cached values while the error is investigated, rather than immediately degrading to defaults. + - If the background request fails with an authorization or configuration error (`401`, `403`, `400`), log the error and emit a `PROVIDER_ERROR` event, then continue serving cached values for the current session. Auth or config errors alone should not invalidate the persisted entry; the cache TTL is what governs when it stops being served. This ensures that subsequent cold starts can still bootstrap from cached values while the error is investigated, rather than immediately degrading to defaults. 3. **If no matching persisted entry exists (cache miss):** - Attempt the `/ofrep/v1/evaluate/flags` request and await the response. - If the request succeeds, populate the in-memory cache from the response, persist the entry, and return from `initialize()` (SDK emits `PROVIDER_READY`). @@ -131,7 +131,8 @@ sequenceDiagram else Transient error Note over Provider: Continue serving cached values else Auth/config error - Note over Provider: Surface error, continue serving cached values + Provider-->>App: PROVIDER_ERROR (logged, non-fatal) + Note over Provider: Continue serving cached values end ``` @@ -207,7 +208,7 @@ When the provider has not initialized from cache (cache miss path, or `network-f In `network-first` mode, fallback to a persisted entry is limited to network errors, `5xx` responses, and request timeouts. If a persisted entry exists in those cases, the provider loads it, emits `PROVIDER_READY` with `CACHED` as the evaluation reason, and continues retrying in the background. -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 surfaced via logging or 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. +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. ### Refresh and revalidation