From 989c7d01463c8de529ccfd216f2d5cbb853c0313 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 6 Mar 2026 19:38:57 +0000 Subject: [PATCH 01/29] 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 9e28818ab5dfe38e5e6d1ecaab2d28bc85629f0c Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 6 Mar 2026 19:41:20 +0000 Subject: [PATCH 02/29] 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 898e22afb604ec4bd7e608c8aa30918f6b664776 Mon Sep 17 00:00:00 2001 From: Norris Date: Sat, 7 Mar 2026 13:20:20 +0100 Subject: [PATCH 03/29] 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 5f93da4dae7c217c4a8310c4bb0e025a74b56a7b Mon Sep 17 00:00:00 2001 From: Norris Date: Sat, 7 Mar 2026 13:33:11 +0100 Subject: [PATCH 04/29] 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 fe064949f7ca3a13836de8ef5d7c1c8c716c0be7 Mon Sep 17 00:00:00 2001 From: Norris Date: Sat, 7 Mar 2026 13:47:57 +0100 Subject: [PATCH 05/29] 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 bff62e47b6d50a9c5e36bd8d94bbd62ea1ffd9ba Mon Sep 17 00:00:00 2001 From: Norris Date: Sat, 7 Mar 2026 13:52:08 +0100 Subject: [PATCH 06/29] 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 a2919d863cfa3461414600c667c3ec60ef2d1576 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:09:18 -0400 Subject: [PATCH 07/29] 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 9bbbaf2c18314131742f5e51f73d420ce9ea9276 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:21:43 -0400 Subject: [PATCH 08/29] 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 244ebcd5138769946003836abd196a6ea842c6c4 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:23:54 -0400 Subject: [PATCH 09/29] 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 6819b06328ab674046f5d7a28d2ee61b7fdef9a0 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:24:54 -0400 Subject: [PATCH 10/29] 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 dec0899b430f685ab56fcca9299d3fa74f6f434f Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:26:32 -0400 Subject: [PATCH 11/29] 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 df1e9cd991878731d88a54c235b6f6d59005150d Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:27:33 -0400 Subject: [PATCH 12/29] 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 3049aa534ed58fd672096dd1b96a8d334238d5b2 Mon Sep 17 00:00:00 2001 From: Norris Date: Thu, 12 Mar 2026 22:31:00 -0400 Subject: [PATCH 13/29] 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 6629173b3ddf01b191cb843280a201d0c5e597f4 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 19 Mar 2026 10:47:12 -0400 Subject: [PATCH 14/29] 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 814bc34cfca7285acedf2aef608bf0c414da56ff Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 19 Mar 2026 16:55:33 -0400 Subject: [PATCH 15/29] 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 da01d88d0039a0cbeccb67bee41a32efee62352d Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 19 Mar 2026 17:21:28 -0400 Subject: [PATCH 16/29] 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 d31b44e7283ec3d6f8f882ec12d7acf8336108b6 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 20 Mar 2026 10:01:34 -0400 Subject: [PATCH 17/29] 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 75ffe5ba85e9a0a5bbda7d7a2654f29e43463e43 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 16 Mar 2026 16:24:04 -0400 Subject: [PATCH 18/29] feat: implement ADR-0008 SSE event stream support in OpenAPI spec - Bump OpenAPI version from 3.1.0 to 3.2.0 for text/event-stream support - Bump OFREP version from 0.2.0 to 0.3.0 - Add flagConfigEtag and flagConfigLastModified query parameters to both eval endpoints - Add eventStreams field to bulkEvaluationSuccess and serverEvaluationSuccess responses - Add eventStream schema with mutually exclusive url/endpoint fields - Add eventStreamEndpoint schema for structured origin + requestUri - Add sseEvent and sseEventData schemas for event stream payloads - Add webhook documenting text/event-stream content type (OAS 3.2.0) - Disable oas3-schema spectral rule until 3.2.0 support lands (stoplightio/spectral#2910) Signed-off-by: Jonathan Norris --- .spectral.yaml | 3 + service/openapi.yaml | 267 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 268 insertions(+), 2 deletions(-) diff --git a/.spectral.yaml b/.spectral.yaml index 77292bf..a476373 100644 --- a/.spectral.yaml +++ b/.spectral.yaml @@ -2,3 +2,6 @@ extends: - spectral:oas rules: oas3-valid-media-example: off + # Disabled until spectral supports OpenAPI 3.2.0 + # See: https://github.com/stoplightio/spectral/issues/2910 + oas3-schema: off diff --git a/service/openapi.yaml b/service/openapi.yaml index f04804e..a603d90 100644 --- a/service/openapi.yaml +++ b/service/openapi.yaml @@ -1,8 +1,8 @@ -openapi: 3.1.0 +openapi: 3.2.0 servers: - url: / info: - version: 0.2.0 + version: 0.3.0 title: OpenFeature Remote Evaluation Protocol (OFREP) description: | --- @@ -54,6 +54,40 @@ paths: schema: type: string example: discount-banner + - in: query + name: flagConfigEtag + description: | + Optional ETag metadata provided by an event stream for change-triggered + re-fetches (see ADR-0008). This is not a standard HTTP conditional request + header; it is metadata for server-side cache validation and freshness + checks. It should only be included when the request is directly triggered + by a received change notification event. + schema: + type: string + required: false + example: '"550e8400-e29b-41d4-a716-446655440000"' + - in: query + name: flagConfigLastModified + description: | + Optional last-modified metadata provided by an event stream for + change-triggered re-fetches (see ADR-0008). Supports Unix timestamp in + seconds (recommended) or a date string (ISO 8601 / HTTP-date), and is + transported as query metadata rather than `If-Modified-Since`. It should + only be included when the request is directly triggered by a received + change notification event. + schema: + oneOf: + - type: integer + minimum: 0 + - type: string + required: false + examples: + epochSeconds: + value: 1771622898 + isoDate: + value: "2026-02-20T21:28:18Z" + httpDate: + value: "Thu, 20 Feb 2026 21:28:18 GMT" requestBody: required: true description: Evaluation request containing the context for flag evaluation @@ -79,6 +113,10 @@ paths: value: true reason: TARGETING_MATCH variant: enabled + eventStreams: + - type: sse + url: https://sse.example.com/event-stream?channels=env_abc123_v1 + inactivityDelaySec: 120 "400": description: Bad evaluation request. The request is malformed or contains invalid context. content: @@ -147,6 +185,40 @@ paths: type: string required: false example: '"abc123xyz"' + - in: query + name: flagConfigEtag + description: | + Optional ETag metadata provided by an event stream for change-triggered + re-fetches (see ADR-0008). This is not a standard HTTP conditional request + header; it is metadata for server-side cache validation and freshness + checks. It should only be included when the request is directly triggered + by a received change notification event. + schema: + type: string + required: false + example: '"550e8400-e29b-41d4-a716-446655440000"' + - in: query + name: flagConfigLastModified + description: | + Optional last-modified metadata provided by an event stream for + change-triggered re-fetches (see ADR-0008). Supports Unix timestamp in + seconds (recommended) or a date string (ISO 8601 / HTTP-date), and is + transported as query metadata rather than `If-Modified-Since`. It should + only be included when the request is directly triggered by a received + change notification event. + schema: + oneOf: + - type: integer + minimum: 0 + - type: string + required: false + examples: + epochSeconds: + value: 1771622898 + isoDate: + value: "2026-02-20T21:28:18Z" + httpDate: + value: "Thu, 20 Feb 2026 21:28:18 GMT" requestBody: required: true content: @@ -188,6 +260,10 @@ paths: - key: non-existent-flag errorCode: FLAG_NOT_FOUND errorDetails: "Flag 'non-existent-flag' was not found" + eventStreams: + - type: sse + url: https://sse.example.com/event-stream?channels=env_abc123_v1 + inactivityDelaySec: 120 metadata: version: v12 "304": @@ -228,6 +304,29 @@ paths: $ref: "#/components/schemas/generalErrorResponse" example: errorDetails: "An internal server error occurred while processing the request" +webhooks: + flagConfigChanged: + post: + summary: SSE Flag Configuration Change Notification + description: | + Describes the Server-Sent Events (SSE) stream that providers receive from + the URLs specified in `eventStreams`. Events signal that the underlying flag + configuration has changed and the provider should re-fetch evaluations. + + This webhook documents the event format only; the actual SSE endpoint URLs + are opaque and vendor-provided via the `eventStreams` field in evaluation responses. + tags: [OFREP Core] + operationId: flagConfigChangedEvent + requestBody: + description: SSE event stream carrying flag configuration change notifications + content: + text/event-stream: + itemSchema: + $ref: "#/components/schemas/sseEvent" + responses: + "200": + description: SSE connection established successfully + components: securitySchemes: BearerAuth: @@ -276,6 +375,16 @@ components: $ref: "#/components/schemas/metadata" description: | Arbitrary metadata for the flag set, useful for telemetry and documentary purposes. + eventStreams: + type: array + description: | + Optional array of real-time change notification connections. When present, + the provider should connect to any entries with a known type and re-fetch + flag evaluations when notified of changes. If not present, the provider + should continue using polling for change detection. Entries with unknown + types must be ignored for forward compatibility. + items: + $ref: "#/components/schemas/eventStream" bulkEvaluationFailure: description: | Failure response for bulk evaluation. Returned when the entire bulk evaluation request @@ -308,6 +417,160 @@ components: serverEvaluationSuccess: allOf: - $ref: "#/components/schemas/evaluationSuccess" + - type: object + properties: + eventStreams: + type: array + description: | + Optional array of real-time change notification connections. When present, + the provider should connect to any entries with a known type and re-fetch + flag evaluations when notified of changes. If not present, the provider + should continue using polling for change detection. Entries with unknown + types must be ignored for forward compatibility. + items: + $ref: "#/components/schemas/eventStream" + eventStream: + description: | + A real-time change notification connection endpoint. The `type` field + identifies the push mechanism; currently only `sse` is defined. Providers + must ignore entries with unknown types for forward compatibility. + Exactly one of `url` or `endpoint` must be provided. + type: object + required: + - type + oneOf: + - required: + - url + not: + required: + - endpoint + - required: + - endpoint + not: + required: + - url + properties: + type: + type: string + description: | + The connection type identifying the push mechanism to use. + Currently only `sse` is defined. Providers must ignore entries + with unknown types for forward compatibility. + example: "sse" + url: + type: string + format: uri + description: | + The endpoint URL the client should connect to for real-time + flag change notifications. This is the default representation and + is opaque to the provider. The URL may include authentication tokens, + channel identifiers, or other query parameters as needed by the + vendor's infrastructure. Implementations should treat this value as + sensitive and should not log or persist the full URL including its + query string. + example: "https://sse.example.com/event-stream?channels=env_abc123_v1" + endpoint: + $ref: "#/components/schemas/eventStreamEndpoint" + inactivityDelaySec: + type: integer + minimum: 1 + description: | + Number of seconds of client inactivity (e.g., browser tab hidden, + mobile app backgrounded) after which the connection should be closed + to conserve resources. The client must reconnect and perform a full + unconditional re-fetch when activity resumes. 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. + example: 120 + 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`. + properties: + origin: + type: string + format: uri + description: | + The scheme + host + optional port portion of the endpoint URL. + example: "https://sse.example.com" + requestUri: + type: string + description: | + The path + query portion of the endpoint URL. + example: "/event-stream?channels=env_abc123_v1" + sseEvent: + description: | + Schema for a single Server-Sent Event in the flag configuration change + notification stream. The `data` field contains a JSON-encoded payload + that providers must parse to determine the event type and any metadata. + type: object + required: + - data + properties: + data: + type: string + description: | + JSON-encoded event payload. Providers must parse this string as JSON + and inspect the `type` field to determine behavior. + contentMediaType: application/json + contentSchema: + $ref: "#/components/schemas/sseEventData" + event: + type: string + description: | + The SSE event type. Always `message` for OFREP events. Providers + must inspect `data.type` rather than this field for event routing. + example: "message" + id: + type: string + description: | + Event identifier used by SSE clients for resume semantics via + `Last-Event-ID`. + example: "evt-1234" + retry: + type: integer + minimum: 0 + description: | + Reconnection time in milliseconds suggested by the server. + sseEventData: + description: | + JSON payload inside the event `data` field. The `type` field determines + the event semantics. Providers must handle `refetchEvaluation` and + ignore unknown values for forward compatibility. + type: object + required: + - type + properties: + type: + type: string + description: | + The OFREP event type. Currently only `refetchEvaluation` is defined. + Providers must ignore unknown values for forward compatibility. + example: "refetchEvaluation" + etag: + type: string + description: | + Latest flag configuration cache validation token. If present, + providers should include it as the `flagConfigEtag` query parameter + on the re-fetch request. + example: '"abc123"' + lastModified: + description: | + Latest flag configuration timestamp. Supports Unix timestamp in + seconds (recommended) or a date string (ISO 8601 or HTTP-date). + If present, providers should include it as the `flagConfigLastModified` + query parameter on the re-fetch request. + oneOf: + - type: integer + minimum: 0 + - type: string + example: 1771622898 evaluationSuccess: description: | Successful feature flag evaluation response. The value property is present From 2deabd63364fdf5d341d59311e4af6cc7ccc7596 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 16 Mar 2026 16:27:31 -0400 Subject: [PATCH 19/29] refactor: move query params to components/parameters Signed-off-by: Jonathan Norris --- service/openapi.yaml | 109 ++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 68 deletions(-) diff --git a/service/openapi.yaml b/service/openapi.yaml index a603d90..e06ead0 100644 --- a/service/openapi.yaml +++ b/service/openapi.yaml @@ -54,40 +54,8 @@ paths: schema: type: string example: discount-banner - - in: query - name: flagConfigEtag - description: | - Optional ETag metadata provided by an event stream for change-triggered - re-fetches (see ADR-0008). This is not a standard HTTP conditional request - header; it is metadata for server-side cache validation and freshness - checks. It should only be included when the request is directly triggered - by a received change notification event. - schema: - type: string - required: false - example: '"550e8400-e29b-41d4-a716-446655440000"' - - in: query - name: flagConfigLastModified - description: | - Optional last-modified metadata provided by an event stream for - change-triggered re-fetches (see ADR-0008). Supports Unix timestamp in - seconds (recommended) or a date string (ISO 8601 / HTTP-date), and is - transported as query metadata rather than `If-Modified-Since`. It should - only be included when the request is directly triggered by a received - change notification event. - schema: - oneOf: - - type: integer - minimum: 0 - - type: string - required: false - examples: - epochSeconds: - value: 1771622898 - isoDate: - value: "2026-02-20T21:28:18Z" - httpDate: - value: "Thu, 20 Feb 2026 21:28:18 GMT" + - $ref: "#/components/parameters/flagConfigEtag" + - $ref: "#/components/parameters/flagConfigLastModified" requestBody: required: true description: Evaluation request containing the context for flag evaluation @@ -185,40 +153,8 @@ paths: type: string required: false example: '"abc123xyz"' - - in: query - name: flagConfigEtag - description: | - Optional ETag metadata provided by an event stream for change-triggered - re-fetches (see ADR-0008). This is not a standard HTTP conditional request - header; it is metadata for server-side cache validation and freshness - checks. It should only be included when the request is directly triggered - by a received change notification event. - schema: - type: string - required: false - example: '"550e8400-e29b-41d4-a716-446655440000"' - - in: query - name: flagConfigLastModified - description: | - Optional last-modified metadata provided by an event stream for - change-triggered re-fetches (see ADR-0008). Supports Unix timestamp in - seconds (recommended) or a date string (ISO 8601 / HTTP-date), and is - transported as query metadata rather than `If-Modified-Since`. It should - only be included when the request is directly triggered by a received - change notification event. - schema: - oneOf: - - type: integer - minimum: 0 - - type: string - required: false - examples: - epochSeconds: - value: 1771622898 - isoDate: - value: "2026-02-20T21:28:18Z" - httpDate: - value: "Thu, 20 Feb 2026 21:28:18 GMT" + - $ref: "#/components/parameters/flagConfigEtag" + - $ref: "#/components/parameters/flagConfigLastModified" requestBody: required: true content: @@ -344,6 +280,43 @@ components: type: apiKey in: header name: X-API-Key + parameters: + flagConfigEtag: + in: query + name: flagConfigEtag + description: | + Optional ETag metadata provided by an event stream for change-triggered + re-fetches (see ADR-0008). This is not a standard HTTP conditional request + header; it is metadata for server-side cache validation and freshness + checks. It should only be included when the request is directly triggered + by a received change notification event. + schema: + type: string + required: false + example: '"550e8400-e29b-41d4-a716-446655440000"' + flagConfigLastModified: + in: query + name: flagConfigLastModified + description: | + Optional last-modified metadata provided by an event stream for + change-triggered re-fetches (see ADR-0008). Supports Unix timestamp in + seconds (recommended) or a date string (ISO 8601 / HTTP-date), and is + transported as query metadata rather than `If-Modified-Since`. It should + only be included when the request is directly triggered by a received + change notification event. + schema: + oneOf: + - type: integer + minimum: 0 + - type: string + required: false + examples: + epochSeconds: + value: 1771622898 + isoDate: + value: "2026-02-20T21:28:18Z" + httpDate: + value: "Thu, 20 Feb 2026 21:28:18 GMT" schemas: bulkEvaluationRequest: description: | From 4679d61dd99256c34d98d5b1a8b090b9044c139a Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 16 Mar 2026 16:30:33 -0400 Subject: [PATCH 20/29] refactor: move webhooks to end of file, add Event Streams tag Signed-off-by: Jonathan Norris --- service/openapi.yaml | 47 ++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/service/openapi.yaml b/service/openapi.yaml index e06ead0..f85a7cd 100644 --- a/service/openapi.yaml +++ b/service/openapi.yaml @@ -28,6 +28,9 @@ tags: description: | **Required**: Core APIs to implement to support OFREP. *This is the minimum set of APIs required for a flag management system to be OFREP compatible.* + - name: Event Streams + description: | + **Optional**: Real-time change notification mechanisms for flag configuration updates (see ADR-0008). paths: /ofrep/v1/evaluate/flags/{key}: @@ -240,29 +243,6 @@ paths: $ref: "#/components/schemas/generalErrorResponse" example: errorDetails: "An internal server error occurred while processing the request" -webhooks: - flagConfigChanged: - post: - summary: SSE Flag Configuration Change Notification - description: | - Describes the Server-Sent Events (SSE) stream that providers receive from - the URLs specified in `eventStreams`. Events signal that the underlying flag - configuration has changed and the provider should re-fetch evaluations. - - This webhook documents the event format only; the actual SSE endpoint URLs - are opaque and vendor-provided via the `eventStreams` field in evaluation responses. - tags: [OFREP Core] - operationId: flagConfigChangedEvent - requestBody: - description: SSE event stream carrying flag configuration change notifications - content: - text/event-stream: - itemSchema: - $ref: "#/components/schemas/sseEvent" - responses: - "200": - description: SSE connection established successfully - components: securitySchemes: BearerAuth: @@ -734,3 +714,24 @@ components: flagMetadataDescription: description: | Arbitrary metadata for the flag, useful for telemetry and documentary purposes. +webhooks: + flagConfigChanged: + post: + summary: SSE Flag Configuration Change Notification + description: | + Describes the Server-Sent Events (SSE) stream that providers receive from + the URLs specified in `eventStreams`. Events signal that the underlying flag + configuration has changed and the provider should re-fetch evaluations. + + This webhook documents the event format only; the actual SSE endpoint URLs + are opaque and vendor-provided via the `eventStreams` field in evaluation responses. + tags: [Event Streams] + requestBody: + description: SSE event stream carrying flag configuration change notifications + content: + text/event-stream: + itemSchema: + $ref: "#/components/schemas/sseEvent" + responses: + "200": + description: SSE connection established successfully From ba36d39af17d476ebdb5b1a96b1f80cbd3c0cc04 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 18 Mar 2026 21:52:05 -0400 Subject: [PATCH 21/29] fix: use normative 'must not' for URL sensitivity per ADR-0008 Signed-off-by: Jonathan Norris --- service/openapi.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/openapi.yaml b/service/openapi.yaml index f85a7cd..c5f720a 100644 --- a/service/openapi.yaml +++ b/service/openapi.yaml @@ -418,8 +418,8 @@ components: flag change notifications. This is the default representation and is opaque to the provider. The URL may include authentication tokens, channel identifiers, or other query parameters as needed by the - vendor's infrastructure. Implementations should treat this value as - sensitive and should not log or persist the full URL including its + vendor's infrastructure. Implementations must treat this value as + sensitive and must not log or persist the full URL including its query string. example: "https://sse.example.com/event-stream?channels=env_abc123_v1" endpoint: From 0289c85dd6ffb5f9fc2912aa3e644b8c81bf655e Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 18 Mar 2026 22:00:53 -0400 Subject: [PATCH 22/29] fix: require leading slash on requestUri Signed-off-by: Jonathan Norris --- service/openapi.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/service/openapi.yaml b/service/openapi.yaml index c5f720a..bf74a2a 100644 --- a/service/openapi.yaml +++ b/service/openapi.yaml @@ -454,8 +454,9 @@ components: example: "https://sse.example.com" requestUri: type: string + pattern: "^/" description: | - The path + query portion of the endpoint URL. + The path + query portion of the endpoint URL. Must start with `/`. example: "/event-stream?channels=env_abc123_v1" sseEvent: description: | From e3f7d665a6e01adcebe32f3d67a6f0ea5f4d62de Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 30 Mar 2026 17:02:06 -0400 Subject: [PATCH 23/29] 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 015bbc01a31982adc4a61db9d48c85a0cedd12cb Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 31 Mar 2026 15:49:35 -0400 Subject: [PATCH 24/29] 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 d6f0f4540f33e89e6596f2c43bd2efd9fb43ad69 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 31 Mar 2026 15:51:40 -0400 Subject: [PATCH 25/29] 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 30ffe7cabebf1ea606470414088b264ebc8c6358 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 10 Apr 2026 15:59:25 +0200 Subject: [PATCH 26/29] refactor: make endpoint.origin optional, default to OFREP base URL 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 112d62a..1234c84 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` and `serverEvalu 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. @@ -243,18 +243,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 bf74a2a..67a0100 100644 --- a/service/openapi.yaml +++ b/service/openapi.yaml @@ -439,18 +439,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 901410e25d42418f927d4be5c99e179fc50fb3bb Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 10 Apr 2026 15:59:42 +0200 Subject: [PATCH 27/29] 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 1234c84..112d62a 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` and `serverEvalu 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. @@ -243,20 +243,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 67a0100..bf74a2a 100644 --- a/service/openapi.yaml +++ b/service/openapi.yaml @@ -439,20 +439,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 ad025d974bb4a76f2812d23ae7c2cf261c400026 Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Mon, 13 Apr 2026 11:20:05 -0400 Subject: [PATCH 28/29] 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 834e261dc92487cce8ce3a8a7b5a356d42ddde77 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 13 Apr 2026 14:12:49 -0400 Subject: [PATCH 29/29] chore: remove OpenAPI spec changes from ADR 0009 PR Revert .spectral.yaml and service/openapi.yaml to main. These ADR-0008 changes belong in PR #67. Signed-off-by: Jonathan Norris --- .spectral.yaml | 3 - service/openapi.yaml | 242 +------------------------------------------ 2 files changed, 2 insertions(+), 243 deletions(-) diff --git a/.spectral.yaml b/.spectral.yaml index a476373..77292bf 100644 --- a/.spectral.yaml +++ b/.spectral.yaml @@ -2,6 +2,3 @@ extends: - spectral:oas rules: oas3-valid-media-example: off - # Disabled until spectral supports OpenAPI 3.2.0 - # See: https://github.com/stoplightio/spectral/issues/2910 - oas3-schema: off diff --git a/service/openapi.yaml b/service/openapi.yaml index bf74a2a..f04804e 100644 --- a/service/openapi.yaml +++ b/service/openapi.yaml @@ -1,8 +1,8 @@ -openapi: 3.2.0 +openapi: 3.1.0 servers: - url: / info: - version: 0.3.0 + version: 0.2.0 title: OpenFeature Remote Evaluation Protocol (OFREP) description: | --- @@ -28,9 +28,6 @@ tags: description: | **Required**: Core APIs to implement to support OFREP. *This is the minimum set of APIs required for a flag management system to be OFREP compatible.* - - name: Event Streams - description: | - **Optional**: Real-time change notification mechanisms for flag configuration updates (see ADR-0008). paths: /ofrep/v1/evaluate/flags/{key}: @@ -57,8 +54,6 @@ paths: schema: type: string example: discount-banner - - $ref: "#/components/parameters/flagConfigEtag" - - $ref: "#/components/parameters/flagConfigLastModified" requestBody: required: true description: Evaluation request containing the context for flag evaluation @@ -84,10 +79,6 @@ paths: value: true reason: TARGETING_MATCH variant: enabled - eventStreams: - - type: sse - url: https://sse.example.com/event-stream?channels=env_abc123_v1 - inactivityDelaySec: 120 "400": description: Bad evaluation request. The request is malformed or contains invalid context. content: @@ -156,8 +147,6 @@ paths: type: string required: false example: '"abc123xyz"' - - $ref: "#/components/parameters/flagConfigEtag" - - $ref: "#/components/parameters/flagConfigLastModified" requestBody: required: true content: @@ -199,10 +188,6 @@ paths: - key: non-existent-flag errorCode: FLAG_NOT_FOUND errorDetails: "Flag 'non-existent-flag' was not found" - eventStreams: - - type: sse - url: https://sse.example.com/event-stream?channels=env_abc123_v1 - inactivityDelaySec: 120 metadata: version: v12 "304": @@ -260,43 +245,6 @@ components: type: apiKey in: header name: X-API-Key - parameters: - flagConfigEtag: - in: query - name: flagConfigEtag - description: | - Optional ETag metadata provided by an event stream for change-triggered - re-fetches (see ADR-0008). This is not a standard HTTP conditional request - header; it is metadata for server-side cache validation and freshness - checks. It should only be included when the request is directly triggered - by a received change notification event. - schema: - type: string - required: false - example: '"550e8400-e29b-41d4-a716-446655440000"' - flagConfigLastModified: - in: query - name: flagConfigLastModified - description: | - Optional last-modified metadata provided by an event stream for - change-triggered re-fetches (see ADR-0008). Supports Unix timestamp in - seconds (recommended) or a date string (ISO 8601 / HTTP-date), and is - transported as query metadata rather than `If-Modified-Since`. It should - only be included when the request is directly triggered by a received - change notification event. - schema: - oneOf: - - type: integer - minimum: 0 - - type: string - required: false - examples: - epochSeconds: - value: 1771622898 - isoDate: - value: "2026-02-20T21:28:18Z" - httpDate: - value: "Thu, 20 Feb 2026 21:28:18 GMT" schemas: bulkEvaluationRequest: description: | @@ -328,16 +276,6 @@ components: $ref: "#/components/schemas/metadata" description: | Arbitrary metadata for the flag set, useful for telemetry and documentary purposes. - eventStreams: - type: array - description: | - Optional array of real-time change notification connections. When present, - the provider should connect to any entries with a known type and re-fetch - flag evaluations when notified of changes. If not present, the provider - should continue using polling for change detection. Entries with unknown - types must be ignored for forward compatibility. - items: - $ref: "#/components/schemas/eventStream" bulkEvaluationFailure: description: | Failure response for bulk evaluation. Returned when the entire bulk evaluation request @@ -370,161 +308,6 @@ components: serverEvaluationSuccess: allOf: - $ref: "#/components/schemas/evaluationSuccess" - - type: object - properties: - eventStreams: - type: array - description: | - Optional array of real-time change notification connections. When present, - the provider should connect to any entries with a known type and re-fetch - flag evaluations when notified of changes. If not present, the provider - should continue using polling for change detection. Entries with unknown - types must be ignored for forward compatibility. - items: - $ref: "#/components/schemas/eventStream" - eventStream: - description: | - A real-time change notification connection endpoint. The `type` field - identifies the push mechanism; currently only `sse` is defined. Providers - must ignore entries with unknown types for forward compatibility. - Exactly one of `url` or `endpoint` must be provided. - type: object - required: - - type - oneOf: - - required: - - url - not: - required: - - endpoint - - required: - - endpoint - not: - required: - - url - properties: - type: - type: string - description: | - The connection type identifying the push mechanism to use. - Currently only `sse` is defined. Providers must ignore entries - with unknown types for forward compatibility. - example: "sse" - url: - type: string - format: uri - description: | - The endpoint URL the client should connect to for real-time - flag change notifications. This is the default representation and - is opaque to the provider. The URL may include authentication tokens, - channel identifiers, or other query parameters as needed by the - vendor's infrastructure. Implementations must treat this value as - sensitive and must not log or persist the full URL including its - query string. - example: "https://sse.example.com/event-stream?channels=env_abc123_v1" - endpoint: - $ref: "#/components/schemas/eventStreamEndpoint" - inactivityDelaySec: - type: integer - minimum: 1 - description: | - Number of seconds of client inactivity (e.g., browser tab hidden, - mobile app backgrounded) after which the connection should be closed - to conserve resources. The client must reconnect and perform a full - unconditional re-fetch when activity resumes. 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. - example: 120 - 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`. - properties: - origin: - type: string - format: uri - description: | - The scheme + host + optional port portion of the endpoint URL. - example: "https://sse.example.com" - requestUri: - type: string - pattern: "^/" - description: | - The path + query portion of the endpoint URL. Must start with `/`. - example: "/event-stream?channels=env_abc123_v1" - sseEvent: - description: | - Schema for a single Server-Sent Event in the flag configuration change - notification stream. The `data` field contains a JSON-encoded payload - that providers must parse to determine the event type and any metadata. - type: object - required: - - data - properties: - data: - type: string - description: | - JSON-encoded event payload. Providers must parse this string as JSON - and inspect the `type` field to determine behavior. - contentMediaType: application/json - contentSchema: - $ref: "#/components/schemas/sseEventData" - event: - type: string - description: | - The SSE event type. Always `message` for OFREP events. Providers - must inspect `data.type` rather than this field for event routing. - example: "message" - id: - type: string - description: | - Event identifier used by SSE clients for resume semantics via - `Last-Event-ID`. - example: "evt-1234" - retry: - type: integer - minimum: 0 - description: | - Reconnection time in milliseconds suggested by the server. - sseEventData: - description: | - JSON payload inside the event `data` field. The `type` field determines - the event semantics. Providers must handle `refetchEvaluation` and - ignore unknown values for forward compatibility. - type: object - required: - - type - properties: - type: - type: string - description: | - The OFREP event type. Currently only `refetchEvaluation` is defined. - Providers must ignore unknown values for forward compatibility. - example: "refetchEvaluation" - etag: - type: string - description: | - Latest flag configuration cache validation token. If present, - providers should include it as the `flagConfigEtag` query parameter - on the re-fetch request. - example: '"abc123"' - lastModified: - description: | - Latest flag configuration timestamp. Supports Unix timestamp in - seconds (recommended) or a date string (ISO 8601 or HTTP-date). - If present, providers should include it as the `flagConfigLastModified` - query parameter on the re-fetch request. - oneOf: - - type: integer - minimum: 0 - - type: string - example: 1771622898 evaluationSuccess: description: | Successful feature flag evaluation response. The value property is present @@ -715,24 +498,3 @@ components: flagMetadataDescription: description: | Arbitrary metadata for the flag, useful for telemetry and documentary purposes. -webhooks: - flagConfigChanged: - post: - summary: SSE Flag Configuration Change Notification - description: | - Describes the Server-Sent Events (SSE) stream that providers receive from - the URLs specified in `eventStreams`. Events signal that the underlying flag - configuration has changed and the provider should re-fetch evaluations. - - This webhook documents the event format only; the actual SSE endpoint URLs - are opaque and vendor-provided via the `eventStreams` field in evaluation responses. - tags: [Event Streams] - requestBody: - description: SSE event stream carrying flag configuration change notifications - content: - text/event-stream: - itemSchema: - $ref: "#/components/schemas/sseEvent" - responses: - "200": - description: SSE connection established successfully