Skip to content
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e8b08b6
Add ADR for offline local storage cache
jonathannorris Mar 6, 2026
2755f54
Renumber local storage ADR to 0009
jonathannorris Mar 6, 2026
51b8c3f
docs(adr): refine static-context local persistence proposal
jonathannorris Mar 7, 2026
860580c
docs(adr): clarify fallback semantics in ADR 0009
jonathannorris Mar 7, 2026
4765321
docs(adr): clarify cache key guidance in ADR 0009
jonathannorris Mar 7, 2026
0771833
docs(adr): add disableLocalCache option to ADR 0009
jonathannorris Mar 7, 2026
3d0a087
docs(adr): simplify cache key to hash(targetingKey) in ADR 0009
jonathannorris Mar 13, 2026
efb15f9
docs(adr): add CACHED evaluation reason and remove resolved open ques…
jonathannorris Mar 13, 2026
3d17441
docs(adr): strengthen fallback language to must not in ADR 0009
jonathannorris Mar 13, 2026
2c97a1a
docs(adr): remove platform constraint from negative consequences
jonathannorris Mar 13, 2026
8436722
docs(adr): make security/privacy consequence more concrete
jonathannorris Mar 13, 2026
2609556
docs(adr): remove storage model implementation details from ADR 0009
jonathannorris Mar 13, 2026
d97187f
docs(adr): clean up implementation notes and mermaid diagram
jonathannorris Mar 13, 2026
3071fb3
docs(adr): rewrite ADR 0009 for cache-first initialization
jonathannorris Mar 19, 2026
5dbb057
docs(adr): add cache TTL as open question in ADR 0009
jonathannorris Mar 19, 2026
34f2534
docs(adr): improve precision of provider lifecycle semantics in ADR 0009
jonathannorris Mar 19, 2026
9f68c52
docs(adr): clear persisted cache on auth/config errors in ADR 0009
jonathannorris Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions service/adrs/0009-localStorageForStaticContextProviders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# 9. Persist static-context evaluations in local storage by default

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.
Current implementations in `js-sdk-contrib`, `kotlin-sdk-contrib`, and `ofrep-swift-client-provider` keep that cache in memory only.

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.

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, and use cache-first initialization to serve persisted evaluations immediately on startup.

The persisted entry should include:

- the bulk evaluation payload
- the associated `ETag`, if one was returned
- a `cacheKeyHash` equal to `hash(targetingKey)`
- the time the entry was written, which can be used for diagnostics and optional implementation-specific staleness policies

Example persisted value:

```json
{
"cacheKeyHash": "hash(targetingKey)",
"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 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, 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`).
- If the request fails with a transient or server error, preserve the existing initialization failure behavior (SDK emits `PROVIDER_ERROR`).
- If the request fails with an authorization or configuration error, preserve the existing initialization failure behavior (SDK emits `PROVIDER_ERROR` with error code `PROVIDER_FATAL`).

```mermaid
sequenceDiagram
participant App as Application
participant Provider as OFREP Provider
participant Storage as Local Storage
participant Server as OFREP Service

App->>Provider: initialize(context)
Provider->>Storage: load persisted evaluation
alt Cache hit (matching entry exists)
Storage-->>Provider: persisted entry
Provider->>Provider: Populate in-memory cache
Provider-->>App: PROVIDER_READY (from cache, reason: CACHED)
Provider->>Server: POST /ofrep/v1/evaluate/flags (background)
alt Request succeeds
Server-->>Provider: 200 OK (flags + ETag)
Provider->>Provider: Update in-memory cache
Provider->>Storage: Persist updated entry
Provider-->>App: PROVIDER_CONFIGURATION_CHANGED
else Transient error
Note over Provider: Continue serving cached values
else Auth/config error
Provider->>Storage: Clear persisted entry
Note over Provider: Surface error, continue serving cached values
end
else Cache miss (no matching entry)
Storage-->>Provider: none
Provider->>Server: POST /ofrep/v1/evaluate/flags
alt Request succeeds
Server-->>Provider: 200 OK (flags + ETag)
Provider->>Provider: Populate in-memory cache
Provider->>Storage: Persist entry
Provider-->>App: PROVIDER_READY
else Transient error
Provider-->>App: PROVIDER_ERROR
else Auth/config error
Provider-->>App: PROVIDER_ERROR (fatal)
end
end

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
```

### 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. 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

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 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

### 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
- 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

### 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 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
- 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 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
- 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 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?
Loading