From b75d36a497171d7b64a28fbd17f52deb7121fff4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 30 Sep 2026 23:01:44 -0400 Subject: [PATCH 1/4] feat(browser): support obfuscated precomputed flag keys --- packages/browser/README.md | 18 ++ .../browser/src/openfeature/core-provider.ts | 43 +++- .../src/transport/fetchConfiguration.ts | 16 +- .../test/openfeature/obfuscation.spec.ts | 209 ++++++++++++++++++ .../openfeature/provider-persistence.spec.ts | 30 +++ .../browser/test/openfeature/provider.spec.ts | 3 + .../test/transport/fetchConfiguration.spec.ts | 3 + .../core/src/configuration/configuration.ts | 5 + .../src/configuration/flag-key-obfuscation.ts | 72 ++++++ packages/core/src/configuration/index.ts | 1 + .../core/src/configuration/wire-validation.ts | 8 +- .../src/evaluation/precomputed-evaluation.ts | 22 +- .../evaluation/flag-key-obfuscation.spec.ts | 195 ++++++++++++++++ test-app/obfuscation.html | 11 + test-app/src/obfuscation.ts | 42 ++++ test-app/tests/smoke.spec.ts | 109 +++++++++ test-app/vite.config.ts | 1 + 17 files changed, 778 insertions(+), 10 deletions(-) create mode 100644 packages/browser/test/openfeature/obfuscation.spec.ts create mode 100644 packages/core/src/configuration/flag-key-obfuscation.ts create mode 100644 packages/core/test/evaluation/flag-key-obfuscation.spec.ts create mode 100644 test-app/obfuscation.html create mode 100644 test-app/src/obfuscation.ts diff --git a/packages/browser/README.md b/packages/browser/README.md index 1609a7b9..c5d712e2 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -147,6 +147,24 @@ If the RUM user changes after provider initialization, call preserving explicitly configured OpenFeature properties. Nested RUM user properties are not included in the evaluation context. +## Flag-key obfuscation + +The Precompute fetcher automatically advertises support for the +flag-key-sha256-v1 assignment encoding. Datadog controls its server rollout. +The provider accepts both plaintext and obfuscated responses without an +application configuration change. + +Continue evaluating the original flag key. The shared core hashes it with the +response's public salt before lookup. Values, evaluation details, exposure +events, and RUM annotations keep their existing behavior. Portable +configuration and IndexedDB storage retain the descriptor with the assignments. +Unsupported or malformed encodings are rejected, not interpreted as plaintext. + +Obfuscation removes readable flag-map keys. It is not encryption, authorization, +or response signing. Values, variation names, allocation names, telemetry, +and application code can still reveal a feature's purpose. Do not put sensitive +information in client-facing variant values, including JSON objects. + ## Portable configuration parsing The default entry point supports precomputed configurations without including diff --git a/packages/browser/src/openfeature/core-provider.ts b/packages/browser/src/openfeature/core-provider.ts index 9dc3e6b2..af138e4a 100644 --- a/packages/browser/src/openfeature/core-provider.ts +++ b/packages/browser/src/openfeature/core-provider.ts @@ -1,5 +1,11 @@ import type { FlagsConfiguration, FlagTypeToValue } from '@datadog/flagging-core' -import { evaluate, type FlagsConfigurationError, getFlagsConfigurationError, getMD5Hash } from '@datadog/flagging-core' +import { + configMatchesContext, + evaluate, + type FlagsConfigurationError, + getFlagsConfigurationError, + getMD5Hash, +} from '@datadog/flagging-core' import { configurationToString } from '@datadog/flagging-core/rules-based' import type { EvaluationContext, @@ -72,10 +78,37 @@ export class DatadogCoreProvider extends DatadogProviderBase { context: EvaluationContext, logger: Logger ): ResolutionDetails> { - return withCoreConfigurationId( - evaluate(this.flagsConfiguration, type, flagKey, defaultValue, context, logger), - this.flagsConfigurationId - ) + const details = evaluate(this.flagsConfiguration, type, flagKey, defaultValue, context, logger) + return withCoreConfigurationId(details, this.getExposureConfigurationId(flagKey, details, context)) + } + + private getExposureConfigurationId( + flagKey: string, + details: ResolutionDetails>, + context: EvaluationContext + ): string | undefined { + const precomputed = this.flagsConfiguration?.precomputed + if ( + !precomputed || + !configMatchesContext(this.flagsConfiguration, context) || + details.flagMetadata?.doLog !== true + ) { + return this.flagsConfigurationId + } + + try { + // A new salt changes the wire keys, not the assignment. Use the original + // key and resolved assignment to keep exposure deduplication unchanged. + const { + flags: _flags, + obfuscated: _obfuscated, + obfuscation: _obfuscation, + ...attributes + } = precomputed.response.data.attributes + return getMD5Hash(JSON.stringify({ attributes, context: precomputed.context, flagKey, details })) + } catch { + return this.flagsConfigurationId + } } private canEvaluateCurrentContext(): boolean { diff --git a/packages/browser/src/transport/fetchConfiguration.ts b/packages/browser/src/transport/fetchConfiguration.ts index 4a15c26c..18e84619 100644 --- a/packages/browser/src/transport/fetchConfiguration.ts +++ b/packages/browser/src/transport/fetchConfiguration.ts @@ -1,6 +1,11 @@ -import { type FlagsConfiguration, parsePrecomputedConfigurationResponse } from '@datadog/flagging-core' +import { + type FlagsConfiguration, + parsePrecomputedConfigurationResponse, + SUPPORTED_ASSIGNMENT_ENCODINGS, +} from '@datadog/flagging-core' import { timeStampNow } from '@datadog/js-core/time' import type { EvaluationContext } from '@openfeature/web-sdk' +import { ParseError } from '@openfeature/web-sdk' import type { FlaggingInitConfiguration } from '../domain/configuration' import { buildEndpointHost } from './endpoint' @@ -132,6 +137,9 @@ export async function fetchPrecomputedConfiguration( attributes: { env: envPayload, source: sourcePayload, + supported_capabilities: { + assignment_encodings: SUPPORTED_ASSIGNMENT_ENCODINGS, + }, subject: { targeting_key: options.context.targetingKey || '', targeting_attributes: stringifiedContext, @@ -160,12 +168,16 @@ export function createFlagsConfigurationFetcher(initConfiguration: FlaggingInitC // Validate the endpoint while building the provider, preserving the existing constructor behavior. buildConfigurationUrl(initConfiguration, 'precomputed') return async (context: EvaluationContext, { signal }: { signal?: AbortSignal } = {}): Promise => { - return fetchPrecomputedConfiguration({ + const configuration = await fetchPrecomputedConfiguration({ ...initConfiguration, env: initConfiguration.env || '', context, fetch: initConfiguration.flagConfigurationFetch, signal, }) + // Use the provider's existing context-matched cache fallback. Do not replace + // a valid snapshot with an unsupported or malformed response encoding. + if (configuration.precomputedError) throw new ParseError(configuration.precomputedError) + return configuration } } diff --git a/packages/browser/test/openfeature/obfuscation.spec.ts b/packages/browser/test/openfeature/obfuscation.spec.ts new file mode 100644 index 00000000..dd65e4ab --- /dev/null +++ b/packages/browser/test/openfeature/obfuscation.spec.ts @@ -0,0 +1,209 @@ +import { createHash } from 'node:crypto' +import { getGlobalObject } from '@datadog/browser-core' +import { OpenFeature, ProviderStatus } from '@openfeature/web-sdk' +import { DatadogProvider } from '../../src/openfeature/provider' +import type { DDRum } from '../../src/openfeature/rumIntegration' +import { + configurationFromString, + configurationToString, + createDatadogExposureLoggingHook, + DatadogCoreProvider, +} from '../../src/rules-based' +import { fetchPrecomputedConfiguration } from '../../src/transport/fetchConfiguration' + +const context = { targetingKey: 'athlete-123' } +const key = 'new-route-planner' +const salt = '000102030405060708090a0b0c0d0e0f' +const options = { + clientToken: 'test-token', + env: 'test', + enableExposureLogging: false, + enableFlagEvaluationTracking: false, + enableRumFeatureFlagTracking: false, +} +const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } + +function response(publicSalt: string | undefined = salt, value = true) { + const lookupKey = publicSalt + ? createHash('sha256') + .update('datadog.feature-flags.flag-key.v1\0') + .update(Buffer.from(publicSalt, 'hex')) + .update(key) + .digest('hex') + : key + return { + data: { + attributes: { + createdAt: '2026-09-30T00:00:00Z', + obfuscated: Boolean(publicSalt), + obfuscation: publicSalt ? { scheme: 'flag-key-sha256-v1', salt: publicSalt } : undefined, + flags: { + [lookupKey]: { + variationType: 'boolean', + variationValue: value, + variationKey: 'variant-1', + allocationKey: 'allocation-1', + reason: 'TARGETING_MATCH', + doLog: true, + serialId: 123, + }, + }, + }, + }, + } +} + +function fetchResponse(payload: unknown) { + return { ok: true, headers: new Headers(), json: async () => payload } +} + +describe('browser flag-key obfuscation', () => { + const originalFetch = global.fetch + let fetchMock: jest.Mock + + beforeEach(async () => { + await OpenFeature.clearProviders() + await OpenFeature.clearContext() + OpenFeature.clearHooks() + OpenFeature.clearHandlers() + localStorage.clear() + fetchMock = jest.fn().mockResolvedValue(fetchResponse(response())) + global.fetch = fetchMock + }) + + afterEach(async () => { + await OpenFeature.clearProviders() + delete getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM + global.fetch = originalFetch + jest.useRealTimers() + }) + + it('advertises support and resolves original keys through the online provider', async () => { + await OpenFeature.setProviderAndWait(new DatadogProvider(options), context) + const details = OpenFeature.getClient().getBooleanDetails(key, false) + expect(details).toMatchObject({ + flagKey: key, + value: true, + variant: 'variant-1', + reason: 'TARGETING_MATCH', + flagMetadata: { allocationKey: 'allocation-1', doLog: true, __dd_split_serial_id: 123 }, + }) + expect(JSON.parse(fetchMock.mock.calls[0][1].body).data.attributes).toMatchObject({ + source: { sdk_name: 'browser', sdk_version: '1.0.0-test' }, + supported_capabilities: { assignment_encodings: ['flag-key-sha256-v1'] }, + }) + }) + + it('supports the portable fetch, serialization, and core-provider path', async () => { + const configuration = await fetchPrecomputedConfiguration({ ...options, context }) + const provider = new DatadogCoreProvider() + provider.setConfiguration(configurationFromString(configurationToString(configuration))) + await OpenFeature.setProviderAndWait(provider, context) + expect(OpenFeature.getClient().getBooleanDetails(key, false)).toMatchObject({ flagKey: key, value: true }) + }) + + it('switches between salts and plaintext without changing application calls', async () => { + const provider = new DatadogProvider(options) + await provider.initialize(context) + for (const publicSalt of ['f'.repeat(32), '', salt]) { + fetchMock.mockResolvedValue(fetchResponse(response(publicSalt))) + await provider.onContextChange(context, context) + expect(provider.resolveBooleanEvaluation(key, false, context, logger).value).toBe(true) + } + await provider.onClose() + }) + + it('keeps a matching previous snapshot when an encoding is unsupported', async () => { + const provider = new DatadogProvider(options) + await provider.initialize(context) + const invalid = response() + invalid.data.attributes.obfuscation!.scheme = 'future-encoding' + fetchMock.mockResolvedValue(fetchResponse(invalid)) + await provider.onContextChange(context, context) + expect(provider.status).toBe(ProviderStatus.STALE) + expect(provider.resolveBooleanEvaluation(key, false, context, logger).value).toBe(true) + await expect(provider.onContextChange(context, { targetingKey: 'different-athlete' })).rejects.toThrow( + 'Unsupported precomputed flag-key obfuscation scheme' + ) + expect(provider.status).toBe(ProviderStatus.ERROR) + await provider.onClose() + }) + + it('rejects unsupported encoding on a cold start', async () => { + const invalid = response() + invalid.data.attributes.obfuscation!.scheme = 'future-encoding' + fetchMock.mockResolvedValue(fetchResponse(invalid)) + const provider = new DatadogProvider(options) + await expect(provider.initialize(context)).rejects.toMatchObject({ code: 'PARSE_ERROR' }) + await provider.onClose() + }) + + it('keeps original flag names in exposures, evaluation events, and RUM', async () => { + jest.useFakeTimers() + const rum = jest.fn() + getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM = { addFeatureFlagEvaluation: rum } + fetchMock.mockImplementation(async (url: string) => + url.includes('precompute-assignments') ? fetchResponse(response()) : { ok: true, status: 200 } + ) + await OpenFeature.setProviderAndWait( + new DatadogProvider({ + ...options, + enableExposureLogging: true, + enableFlagEvaluationTracking: true, + enableRumFeatureFlagTracking: true, + flagEvaluationTrackingInterval: 1000, + }), + context + ) + OpenFeature.getClient().getBooleanValue(key, false) + OpenFeature.getClient().getBooleanValue(key, false) + jest.advanceTimersByTime(31_000) + + expect(rum).toHaveBeenCalledWith(key, 'variant-1') + for (const channel of ['exposures', 'flagevaluation']) { + const requests = fetchMock.mock.calls.filter(([url]) => String(url).includes(channel)) + const events = requests.flatMap(([, request]) => + (request.body as string) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + ) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + flag: { key }, + allocation: { key: 'allocation-1' }, + variant: { key: 'variant-1' }, + }) + } + }) + + it('does not repeat portable-provider exposures just because the salt changes', async () => { + jest.useFakeTimers() + const tracking = createDatadogExposureLoggingHook(options) + await tracking.initialize() + const provider = new DatadogCoreProvider() + provider.setConfiguration(await fetchPrecomputedConfiguration({ ...options, context })) + await OpenFeature.setProviderAndWait(provider, context) + const client = OpenFeature.getClient() + client.addHooks(...tracking.hooks) + const first = client.getBooleanDetails(key, false) + jest.advanceTimersByTime(31_000) + + for (const publicSalt of ['f'.repeat(32), '', salt]) { + fetchMock.mockResolvedValue(fetchResponse(response(publicSalt))) + provider.setConfiguration(await fetchPrecomputedConfiguration({ ...options, context })) + expect(client.getBooleanDetails(key, false)).toEqual(first) + jest.advanceTimersByTime(31_000) + } + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('exposures'))).toHaveLength(1) + + // A real assignment change still starts a new exposure identity. + fetchMock.mockResolvedValue(fetchResponse(response(salt, false))) + provider.setConfiguration(await fetchPrecomputedConfiguration({ ...options, context })) + expect(client.getBooleanDetails(key, false).value).toBe(false) + jest.advanceTimersByTime(31_000) + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('exposures'))).toHaveLength(2) + await tracking.shutdown() + client.clearHooks() + }) +}) diff --git a/packages/browser/test/openfeature/provider-persistence.spec.ts b/packages/browser/test/openfeature/provider-persistence.spec.ts index bce95994..b8b1144c 100644 --- a/packages/browser/test/openfeature/provider-persistence.spec.ts +++ b/packages/browser/test/openfeature/provider-persistence.spec.ts @@ -56,6 +56,36 @@ describe('DatadogProvider IndexedDB persistence', () => { }) describe('persists flags on successful fetch', () => { + it('restores the obfuscation descriptor and map together after a restart', async () => { + const context = { targetingKey: 'obfuscated-user' } + const digest = 'a60479237ef2f69175bbe0bd581966d1583766941815dc1d414c883767795190' + const payload = { + data: { + attributes: { + createdAt: '2026-09-30T00:00:00Z', + obfuscated: true, + obfuscation: { scheme: 'flag-key-sha256-v1', salt: '000102030405060708090a0b0c0d0e0f' }, + flags: { [digest]: precomputedResponse.data.attributes.flags['boolean-flag'] }, + }, + }, + } + fetchMock.mockResolvedValue({ ok: true, json: async () => payload }) + const provider = new DatadogProvider(options) + await provider.initialize(context) + await flushAsync() + await provider.onClose() + + const stored = await new IndexedDBFlagsCache(options.clientToken).get(context) + expect(stored?.precomputed?.response).toEqual(payload) + global.fetch = failingFetchMock() + const restarted = new DatadogProvider(options) + await restarted.initialize(context) + expect(restarted.status).toBe(ProviderStatus.STALE) + const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } + expect(restarted.resolveBooleanEvaluation('new-route-planner', false, context, logger).value).toBe(true) + await restarted.onClose() + }) + it('should persist flags to IndexedDB after initialize', async () => { const provider = new DatadogProvider(options) const context = { targetingKey: 'user-1' } diff --git a/packages/browser/test/openfeature/provider.spec.ts b/packages/browser/test/openfeature/provider.spec.ts index 8c517478..732163ce 100644 --- a/packages/browser/test/openfeature/provider.spec.ts +++ b/packages/browser/test/openfeature/provider.spec.ts @@ -298,6 +298,9 @@ describe('DatadogProvider', () => { sdk_name: 'browser', sdk_version: '1.0.0-test', }, + supported_capabilities: { + assignment_encodings: ['flag-key-sha256-v1'], + }, subject: { targeting_key: 'test-user', targeting_attributes: { diff --git a/packages/browser/test/transport/fetchConfiguration.spec.ts b/packages/browser/test/transport/fetchConfiguration.spec.ts index 32493628..fe3e93ff 100644 --- a/packages/browser/test/transport/fetchConfiguration.spec.ts +++ b/packages/browser/test/transport/fetchConfiguration.spec.ts @@ -313,6 +313,9 @@ describe('createFlagsConfigurationFetcher', () => { sdk_name: 'browser', sdk_version: '1.0.0-test', }, + supported_capabilities: { + assignment_encodings: ['flag-key-sha256-v1'], + }, subject: { targeting_key: 'user-123', targeting_attributes: { diff --git a/packages/core/src/configuration/configuration.ts b/packages/core/src/configuration/configuration.ts index 4b740f32..048aed05 100644 --- a/packages/core/src/configuration/configuration.ts +++ b/packages/core/src/configuration/configuration.ts @@ -1,5 +1,6 @@ import type { EvaluationContext, FlagValueType, JsonValue, ResolutionReason } from '@openfeature/core' import type { TimeStamp } from '../time' +import type { FlagKeyObfuscation } from './flag-key-obfuscation' import type { PreparedRulesResponse } from './prepared-rules-response' /** @@ -50,6 +51,10 @@ export type PrecomputedConfigurationResponse = { attributes: { /** When configuration was generated. */ createdAt: string + /** Absent or false for legacy plaintext assignments. */ + obfuscated?: boolean + /** Required when obfuscated is true; retained with the map in caches. */ + obfuscation?: FlagKeyObfuscation flags: Record } } diff --git a/packages/core/src/configuration/flag-key-obfuscation.ts b/packages/core/src/configuration/flag-key-obfuscation.ts new file mode 100644 index 00000000..6536fe65 --- /dev/null +++ b/packages/core/src/configuration/flag-key-obfuscation.ts @@ -0,0 +1,72 @@ +import { sha256Hex } from '../evaluation/sha256' +import { encodeUtf8 } from '../utf8' + +const SCHEME = 'flag-key-sha256-v1' +const DOMAIN = encodeUtf8('datadog.feature-flags.flag-key.v1\0') + +/** Assignment encodings supported by the precomputed parser and evaluator. @internal */ +export const SUPPORTED_ASSIGNMENT_ENCODINGS = [SCHEME] as const + +/** Public metadata stored with the encoded assignment map. @internal */ +export type FlagKeyObfuscation = { + scheme: typeof SCHEME + salt: string +} + +/** Validate the response's encoding before parsing or looking up assignments. */ +export function readFlagKeyObfuscation( + obfuscated: unknown, + obfuscation: unknown +): { encoding?: FlagKeyObfuscation } | { error: string } { + if ((obfuscated === undefined || obfuscated === false) && obfuscation === undefined) { + return {} + } + if (obfuscated !== true || typeof obfuscation !== 'object' || obfuscation === null || Array.isArray(obfuscation)) { + return { error: 'Invalid precomputed flag-key obfuscation metadata' } + } + const descriptor = obfuscation as Record + if (descriptor.scheme !== SCHEME) { + return { error: 'Unsupported precomputed flag-key obfuscation scheme' } + } + if (!isLowercaseHex(descriptor.salt, 16)) { + return { error: 'Precomputed flag-key salt must contain 32 lowercase hexadecimal characters' } + } + return { encoding: { scheme: SCHEME, salt: descriptor.salt } } +} + +/** Hash only the lookup key. Values and telemetry retain their original meaning. */ +export function encodePrecomputedFlagKey( + key: string, + encoding: FlagKeyObfuscation +): { key: string } | { error: string } { + // TextEncoder replaces invalid surrogates. Reject them instead of aliasing + // a different flag whose name contains the Unicode replacement character. + if (!isWellFormedUnicode(key)) return { error: 'Flag key must contain valid Unicode' } + + const keyBytes = encodeUtf8(key) + const input = new Uint8Array(DOMAIN.length + 16 + keyBytes.length) + input.set(DOMAIN) + for (let index = 0; index < 16; index++) { + input[DOMAIN.length + index] = Number.parseInt(encoding.salt.slice(index * 2, index * 2 + 2), 16) + } + input.set(keyBytes, DOMAIN.length + 16) + return { key: sha256Hex(input) } +} + +/** Check the exact byte length and alphabet of a hex-encoded wire value. */ +export function isLowercaseHex(value: unknown, bytes: number): value is string { + return typeof value === 'string' && value.length === bytes * 2 && /^[0-9a-f]+$/.test(value) +} + +function isWellFormedUnicode(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index) + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(++index) + if (!(next >= 0xdc00 && next <= 0xdfff)) return false + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return false + } + } + return true +} diff --git a/packages/core/src/configuration/index.ts b/packages/core/src/configuration/index.ts index 1fa86452..05536d8b 100644 --- a/packages/core/src/configuration/index.ts +++ b/packages/core/src/configuration/index.ts @@ -1,6 +1,7 @@ export * from './configuration' export * from './exposureEvent' export * from './exposureEvent.types' +export { SUPPORTED_ASSIGNMENT_ENCODINGS } from './flag-key-obfuscation' export * from './flagEvaluationAggregator' export * from './flagEvaluationEvent' export * from './flagEvaluationEvent.types' diff --git a/packages/core/src/configuration/wire-validation.ts b/packages/core/src/configuration/wire-validation.ts index 6ee0b379..c0efcf24 100644 --- a/packages/core/src/configuration/wire-validation.ts +++ b/packages/core/src/configuration/wire-validation.ts @@ -1,5 +1,6 @@ import type { EvaluationContext } from '@openfeature/core' import type { PrecomputedConfigurationResponse, PrecomputedFlag } from './configuration' +import { isLowercaseHex, readFlagKeyObfuscation } from './flag-key-obfuscation' type WireEntry = { response: string @@ -33,7 +34,9 @@ export function parsePrecomputedConfigurationResponse( if (!isRecord(value.data.attributes)) { return { error: 'Precomputed configuration response is missing attributes' } } - const { createdAt, flags } = value.data.attributes + const { createdAt, flags, obfuscated, obfuscation } = value.data.attributes + const encoding = readFlagKeyObfuscation(obfuscated, obfuscation) + if ('error' in encoding) return encoding if (typeof createdAt !== 'string' && (typeof createdAt !== 'number' || !Number.isFinite(createdAt))) { return { error: 'Precomputed configuration createdAt is invalid' } } @@ -41,6 +44,9 @@ export function parsePrecomputedConfigurationResponse( const flagErrors: Array<[string, string]> = [] for (const [key, flag] of Object.entries(flags)) { + if (encoding.encoding && !isLowercaseHex(key, 32)) { + return { error: 'Obfuscated flag keys must contain 64 lowercase hexadecimal characters' } + } if (!isPrecomputedFlag(flag)) { flagErrors.push([key, 'Invalid precomputed flag configuration']) } diff --git a/packages/core/src/evaluation/precomputed-evaluation.ts b/packages/core/src/evaluation/precomputed-evaluation.ts index 8f820587..1995df8e 100644 --- a/packages/core/src/evaluation/precomputed-evaluation.ts +++ b/packages/core/src/evaluation/precomputed-evaluation.ts @@ -6,6 +6,7 @@ import { type PrecomputedConfiguration, type PrecomputedFlagMetadata, } from '../configuration' +import { encodePrecomputedFlagKey, readFlagKeyObfuscation } from '../configuration/flag-key-obfuscation' import { getOwnProperty } from './getOwnProperty' export function evaluatePrecomputedConfiguration( @@ -80,7 +81,24 @@ function evaluatePrecomputedFlag( flagKey: string, defaultValue: FlagTypeToValue ): ResolutionDetails> { - const flagError = precomputed.flagErrors ? getOwnProperty(precomputed.flagErrors, flagKey) : undefined + const attributes = precomputed.response.data.attributes + // Initial configurations and persistent caches can bypass the wire parser. + const encoding = readFlagKeyObfuscation(attributes.obfuscated, attributes.obfuscation) + const lookup = + 'error' in encoding + ? encoding + : encoding.encoding + ? encodePrecomputedFlagKey(flagKey, encoding.encoding) + : { key: flagKey } + if ('error' in lookup) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'PARSE_ERROR' as ErrorCode, + errorMessage: lookup.error, + } + } + const flagError = precomputed.flagErrors ? getOwnProperty(precomputed.flagErrors, lookup.key) : undefined if (flagError) { return { value: defaultValue, @@ -90,7 +108,7 @@ function evaluatePrecomputedFlag( } } - const flag = getOwnProperty(precomputed.response.data.attributes.flags, flagKey) + const flag = getOwnProperty(attributes.flags, lookup.key) if (!flag) { return { value: defaultValue, diff --git a/packages/core/test/evaluation/flag-key-obfuscation.spec.ts b/packages/core/test/evaluation/flag-key-obfuscation.spec.ts new file mode 100644 index 00000000..34a8235e --- /dev/null +++ b/packages/core/test/evaluation/flag-key-obfuscation.spec.ts @@ -0,0 +1,195 @@ +import { createHash } from 'node:crypto' +import type { FlagValueType } from '@openfeature/core' +import { + configurationFromString, + configurationToString, + type FlagsConfiguration, + parsePrecomputedConfigurationResponse, +} from '../../src/configuration' +import { evaluatePrecomputedConfiguration } from '../../src/evaluation/precomputed-evaluation' + +const salt = '000102030405060708090a0b0c0d0e0f' +const context = { targetingKey: 'athlete-123' } +const assignment = { + allocationKey: 'allocation-123', + variationKey: 'variation-456', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + serialId: 123, +} +const descriptor = { scheme: 'flag-key-sha256-v1', salt } + +// These vectors also run in the Rust edge encoder. Expected digests are not +// generated by the SDK implementation under test. +const vectors = [ + ['new-route-planner', 'a60479237ef2f69175bbe0bd581966d1583766941815dc1d414c883767795190'], + ['Flag', 'adca75d2141c51b0c0f084c1058edfb8e6e763f91aaf54ca06326d9847bd9586'], + ['flag', '9817872c144b018abd77e3915bd77e2c27f4f534dccff8ffaca27361e3a5e1ee'], + [' flag ', 'b1a5f851cc82a3fdf03a461d72a2241584dcf455df1d384e02a937901b3bc80b'], + ['café', '3bfa8c3c17c1b61035b98ecf14007cdf5cba5ce61a48e8cfb65f8106541892d3'], + ['cafe\u0301', '1e8b7ec5e8028a1ec96b38dd37f6ccea041c0a90358904af40ac5810c23c8765'], + ['🚲/旗', '94b611e0d3b26b52f6ad66013d1c72f8a92ea109390049759e75d3c8d3aa4dab'], + ['a\0b', 'bac134d201be5e7f28fc7019248f0809c2a013b137e866446ee71add2bc344c7'], + ['', '072d985b427f536ad0a11b2d4c5e0f7e6f0ff6d23e779e082f75599ce3fe3eba'], +] + +function response(flags: Record, encoding: Record = {}) { + return { data: { attributes: { createdAt: '2026-09-30T00:00:00Z', format: 'PRECOMPUTED', ...encoding, flags } } } +} + +function decode(value: unknown): FlagsConfiguration { + return configurationFromString( + JSON.stringify({ version: 1, precomputed: { response: JSON.stringify(value), context } }) + ) +} + +function hash(key: string, publicSalt = salt): string { + return createHash('sha256') + .update('datadog.feature-flags.flag-key.v1\0') + .update(Buffer.from(publicSalt, 'hex')) + .update(key) + .digest('hex') +} + +describe('precomputed flag-key obfuscation', () => { + it.each(vectors)('matches the edge digest for %j', (key, digest) => { + const plain = decode(response({ [key]: assignment })) + const encoded = decode(response({ [digest]: assignment }, { obfuscated: true, obfuscation: descriptor })) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', key, false, context)).toEqual( + evaluatePrecomputedConfiguration(plain, 'boolean', key, false, context) + ) + }) + + it.each([ + ['boolean', false, true], + ['string', 'default', 'visible-value'], + ['number', -1, 12.5], + ['object', {}, { visible: ['nested', 42] }], + ['BOOLEAN', false, true], + ['STRING', 'default', 'visible-value'], + ['INTEGER', -1, 42], + ['NUMERIC', -1, 12.5], + ['JSON', {}, { visible: ['nested', 42] }], + ])('preserves the complete %s evaluation result', (variationType, defaultValue, variationValue) => { + const key = 'flag' + const flag = { ...assignment, variationType, variationValue } + const type = typeof defaultValue as FlagValueType + const plain = decode(response({ [key]: flag })) + const encoded = decode(response({ [hash(key)]: flag }, { obfuscated: true, obfuscation: descriptor })) + expect(evaluatePrecomputedConfiguration(encoded, type, key, defaultValue, context)).toEqual( + evaluatePrecomputedConfiguration(plain, type, key, defaultValue, context) + ) + }) + + it('preserves missing-flag and type-mismatch defaults', () => { + const plain = decode(response({ flag: assignment })) + const encoded = decode(response({ [hash('flag')]: assignment }, { obfuscated: true, obfuscation: descriptor })) + for (const key of ['flag', 'missing']) { + expect(evaluatePrecomputedConfiguration(encoded, 'string', key, 'default', context)).toEqual( + evaluatePrecomputedConfiguration(plain, 'string', key, 'default', context) + ) + } + }) + + it('uses the hashed key for per-flag parse errors', () => { + const encoded = decode( + response( + { [hash('invalid')]: { ...assignment, variationValue: 'not-a-boolean' }, [hash('valid')]: assignment }, + { obfuscated: true, obfuscation: descriptor } + ) + ) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', 'invalid', false, context)).toMatchObject({ + value: false, + errorCode: 'PARSE_ERROR', + }) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', 'valid', false, context).value).toBe(true) + }) + + it('preserves the encoding through portable configuration round trips', () => { + const original = decode(response({ [hash('flag')]: assignment }, { obfuscated: true, obfuscation: descriptor })) + const restored = configurationFromString(configurationToString(original)) + expect(restored).toEqual(original) + expect(evaluatePrecomputedConfiguration(restored, 'boolean', 'flag', false, context).value).toBe(true) + expect(Object.keys(restored.precomputed!.response.data.attributes.flags)).toEqual([hash('flag')]) + }) + + it('keeps each response salt scoped to its own assignments', () => { + for (const publicSalt of [salt, 'f'.repeat(32), salt]) { + const encoded = decode( + response( + { [hash('flag', publicSalt)]: assignment }, + { obfuscated: true, obfuscation: { ...descriptor, salt: publicSalt } } + ) + ) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', 'flag', false, context).value).toBe(true) + expect( + evaluatePrecomputedConfiguration(encoded, 'boolean', 'flag', false, { targetingKey: 'other' }) + ).toMatchObject({ + errorCode: 'INVALID_CONTEXT', + }) + } + }) + + it.each([{}, { obfuscated: false }])('keeps legacy payloads unchanged: %j', (encoding) => { + const plain = decode(response({ flag: assignment }, encoding)) + expect(evaluatePrecomputedConfiguration(plain, 'boolean', 'flag', false, context).value).toBe(true) + }) + + it.each([ + { obfuscated: true }, + { obfuscated: 'true', obfuscation: descriptor }, + { obfuscated: null }, + { obfuscated: false, obfuscation: descriptor }, + { obfuscation: descriptor }, + { obfuscated: true, obfuscation: null }, + { obfuscated: true, obfuscation: [] }, + { obfuscated: true, obfuscation: { ...descriptor, scheme: 'flag-key-sha256-v2' } }, + ...['', '0'.repeat(30), '0'.repeat(34), 'G'.repeat(32), salt.toUpperCase(), salt + '\n', 42].map((salt) => ({ + obfuscated: true, + obfuscation: { ...descriptor, salt }, + })), + ])('rejects malformed or unsupported encoding metadata: %j', (encoding) => { + const payload = response({ flag: assignment }, encoding) + expect(parsePrecomputedConfigurationResponse(payload)).toHaveProperty('error') + expect(evaluatePrecomputedConfiguration(decode(payload), 'boolean', 'flag', false, context)).toMatchObject({ + value: false, + errorCode: 'PARSE_ERROR', + }) + // Initial configurations and IndexedDB values can bypass the wire parser. + const direct = { precomputed: { response: payload, context } } as FlagsConfiguration + expect(evaluatePrecomputedConfiguration(direct, 'boolean', 'flag', false, context)).toMatchObject({ + value: false, + errorCode: 'PARSE_ERROR', + }) + }) + + it.each(['plaintext-key', 'a'.repeat(63), 'a'.repeat(65), 'A'.repeat(64), 'a'.repeat(64) + '\n'])( + 'rejects a malformed encoded map key: %j', + (key) => { + expect( + parsePrecomputedConfigurationResponse( + response({ [key]: assignment }, { obfuscated: true, obfuscation: descriptor }) + ) + ).toHaveProperty('error') + } + ) + + it('never retries a missed hashed lookup as plaintext', () => { + const digest = hash('flag') + const encoded = decode(response({ [digest]: assignment }, { obfuscated: true, obfuscation: descriptor })) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', digest, false, context)).toMatchObject({ + value: false, + errorCode: 'FLAG_NOT_FOUND', + }) + }) + + it.each(['\ud800', '\udc00', 'a\ud800b'])('does not alias invalid Unicode to a replacement character: %j', (key) => { + const encoded = decode(response({ [hash(key)]: assignment }, { obfuscated: true, obfuscation: descriptor })) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', key, false, context)).toMatchObject({ + value: false, + reason: 'ERROR', + }) + }) +}) diff --git a/test-app/obfuscation.html b/test-app/obfuscation.html new file mode 100644 index 00000000..185afc6b --- /dev/null +++ b/test-app/obfuscation.html @@ -0,0 +1,11 @@ + + + + + Datadog flag-key obfuscation browser test + + +

+    
+  
+
diff --git a/test-app/src/obfuscation.ts b/test-app/src/obfuscation.ts
new file mode 100644
index 00000000..92f426ec
--- /dev/null
+++ b/test-app/src/obfuscation.ts
@@ -0,0 +1,42 @@
+import { DatadogProvider } from '@datadog/openfeature-browser'
+import { OpenFeature } from '@openfeature/web-sdk'
+import { reportSuccess } from './smoke'
+
+async function run() {
+  const provider = new DatadogProvider({
+    clientToken: 'obfuscation-smoke-token',
+    env: 'test',
+    flaggingProxy: new URL('/assignments', location.href).toString(),
+    enableExposureLogging: false,
+    enableFlagEvaluationTracking: false,
+    enableRumFeatureFlagTracking: false,
+    ...(new URLSearchParams(location.search).has('offline')
+      ? {
+          flagConfigurationFetch: async () => {
+            throw new Error('Offline test')
+          },
+        }
+      : {}),
+  })
+  await OpenFeature.setProviderAndWait(provider, { targetingKey: 'browser-smoke-subject' })
+  const client = OpenFeature.getClient()
+  const details = client.getBooleanDetails('new-route-planner', false)
+  reportSuccess({
+    values: [
+      details.value,
+      client.getStringValue('café', 'default'),
+      client.getNumberValue('number-flag', -1),
+      client.getObjectValue('object-flag', {}),
+    ],
+    flagKey: details.flagKey,
+    variant: details.variant,
+    reason: details.reason,
+    missing: client.getBooleanDetails('missing-flag', false).errorCode,
+    mismatch: client.getStringDetails('new-route-planner', 'default').errorCode,
+    status: provider.status,
+  })
+}
+
+run().catch((error: unknown) => {
+  Object.assign(globalThis, { __OPENFEATURE_SMOKE_ERROR__: String(error) })
+})
diff --git a/test-app/tests/smoke.spec.ts b/test-app/tests/smoke.spec.ts
index ac96c404..e738e6be 100644
--- a/test-app/tests/smoke.spec.ts
+++ b/test-app/tests/smoke.spec.ts
@@ -1,4 +1,5 @@
 import { execFileSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
 import { createRequire } from 'node:module'
 import { expect, type Page, test } from '@playwright/test'
 import type { SmokeResult as FetchSmokeResult } from '../src/smokeResult'
@@ -107,3 +108,111 @@ test('executes the packed precomputed entrypoint in Chromium', async ({ page })
     rulesExcluded: true,
   })
 })
+
+// Use Node's independent SHA-256 implementation to produce the edge wire format.
+function assignmentPayload(salt?: string) {
+  const flags = [
+    ['new-route-planner', 'boolean', true],
+    ['café', 'string', 'visible-value'],
+    ['number-flag', 'number', 12.5],
+    ['object-flag', 'object', { nested: true }],
+  ] as const
+  return {
+    data: {
+      attributes: {
+        createdAt: '2026-09-30T00:00:00Z',
+        obfuscated: salt !== undefined,
+        ...(salt ? { obfuscation: { scheme: 'flag-key-sha256-v1', salt } } : {}),
+        flags: Object.fromEntries(
+          flags.map(([key, variationType, variationValue]) => [
+            salt
+              ? createHash('sha256')
+                  .update('datadog.feature-flags.flag-key.v1\0')
+                  .update(Buffer.from(salt, 'hex'))
+                  .update(key)
+                  .digest('hex')
+              : key,
+            {
+              variationType,
+              variationValue,
+              allocationKey: 'allocation',
+              variationKey: 'on',
+              reason: 'TARGETING_MATCH',
+              doLog: true,
+            },
+          ])
+        ),
+      },
+    },
+  }
+}
+
+const expectedObfuscationResult = {
+  values: [true, 'visible-value', 12.5, { nested: true }],
+  flagKey: 'new-route-planner',
+  variant: 'on',
+  reason: 'TARGETING_MATCH',
+  missing: 'FLAG_NOT_FOUND',
+  mismatch: 'TYPE_MISMATCH',
+  status: 'READY',
+}
+
+test('negotiates obfuscation, rotates salts, and restores hashed assignments from IndexedDB', async ({ page }) => {
+  let salt = '000102030405060708090a0b0c0d0e0f'
+  const requests: Record[] = []
+  await page.route('**/assignments?*', async (route) => {
+    requests.push(route.request().postDataJSON())
+    await route.fulfill({ json: assignmentPayload(salt) })
+  })
+  expect(await runSmoke(page, '/obfuscation.html')).toEqual(expectedObfuscationResult)
+  salt = 'f'.repeat(32)
+  expect(await runSmoke(page, '/obfuscation.html')).toEqual(expectedObfuscationResult)
+  expect(requests).toHaveLength(2)
+  for (const request of requests) {
+    expect(request).toMatchObject({
+      data: {
+        attributes: {
+          source: { sdk_name: 'browser', sdk_version: expectedSdkVersion },
+          supported_capabilities: { assignment_encodings: ['flag-key-sha256-v1'] },
+        },
+      },
+    })
+  }
+  await page.waitForFunction(async (expectedSalt) => {
+    return new Promise((resolve) => {
+      const request = indexedDB.open('dd-flagging')
+      request.onsuccess = () => {
+        const db = request.result
+        const read = db.transaction('configurations').objectStore('configurations').getAll()
+        read.onsuccess = () => {
+          const found = read.result.some(
+            (entry) => entry.precomputed?.response.data.attributes.obfuscation?.salt === expectedSalt
+          )
+          db.close()
+          resolve(found)
+        }
+      }
+    })
+  }, salt)
+  expect(await runSmoke(page, '/obfuscation.html?offline=1')).toEqual({
+    ...expectedObfuscationResult,
+    status: 'STALE',
+  })
+  expect(requests).toHaveLength(2)
+})
+
+test('keeps plaintext responses compatible when rollout is disabled', async ({ page }) => {
+  await page.route('**/assignments?*', (route) => route.fulfill({ json: assignmentPayload() }))
+  expect(await runSmoke(page, '/obfuscation.html')).toEqual(expectedObfuscationResult)
+})
+
+test('evaluates obfuscated keys without native text encoders or Web Crypto', async ({ page }) => {
+  await page.addInitScript(() => {
+    Object.assign(globalThis, { TextEncoder: undefined, TextDecoder: undefined })
+    Object.defineProperty(globalThis.crypto, 'subtle', { value: undefined })
+  })
+  await page.route('**/assignments?*', (route) =>
+    route.fulfill({ json: assignmentPayload('000102030405060708090a0b0c0d0e0f') })
+  )
+  expect(await runSmoke(page, '/obfuscation.html')).toEqual(expectedObfuscationResult)
+})
diff --git a/test-app/vite.config.ts b/test-app/vite.config.ts
index fec23190..9bba70e0 100644
--- a/test-app/vite.config.ts
+++ b/test-app/vite.config.ts
@@ -9,6 +9,7 @@ export default defineConfig({
         main: fileURLToPath(new URL('./index.html', import.meta.url)),
         protobuf: fileURLToPath(new URL('./protobuf.html', import.meta.url)),
         precomputed: fileURLToPath(new URL('./precomputed.html', import.meta.url)),
+        obfuscation: fileURLToPath(new URL('./obfuscation.html', import.meta.url)),
         trackingBaseline: fileURLToPath(new URL('./tracking-baseline.html', import.meta.url)),
         trackingExposure: fileURLToPath(new URL('./tracking-exposure.html', import.meta.url)),
         trackingEvaluation: fileURLToPath(new URL('./tracking-evaluation.html', import.meta.url)),

From b6d18842f60ca580af5cb3c84393984aa0f68d01 Mon Sep 17 00:00:00 2001
From: Leo Romanovsky 
Date: Tue, 6 Oct 2026 15:58:56 -0400
Subject: [PATCH 2/4] fix(browser): address obfuscation review and cache
 compatibility

---
 packages/browser/README.md                    |  23 ++-
 .../src/cache/indexeddb-flags-cache.ts        |  29 ++-
 .../browser/src/openfeature/core-provider.ts  |  38 +---
 packages/browser/src/openfeature/provider.ts  |  27 +--
 .../src/transport/fetchConfiguration.ts       |   9 +-
 .../test/cache/indexeddb-flags-cache.spec.ts  |  79 +++++++-
 .../test/openfeature/core-tracking.spec.ts    |   4 +-
 .../test/openfeature/exposures.spec.ts        |   9 +-
 .../test/openfeature/obfuscation.spec.ts      | 180 ++++++++++++++++--
 .../browser/test/openfeature/provider.spec.ts |   5 +-
 .../test/transport/fetchConfiguration.spec.ts |   9 +-
 .../src/configuration/flag-key-obfuscation.ts |  24 ++-
 packages/core/src/configuration/index.ts      |   2 +-
 .../src/configuration/precomputed-wire.ts     |   2 +
 packages/core/src/configuration/wire-types.ts |   9 +-
 packages/core/src/configuration/wire.test.ts  |  39 +++-
 .../src/evaluation/precomputed-evaluation.ts  |  14 +-
 .../evaluation/flag-key-obfuscation.spec.ts   |  75 +++++++-
 test-app/tests/smoke.spec.ts                  |  70 ++++---
 19 files changed, 498 insertions(+), 149 deletions(-)

diff --git a/packages/browser/README.md b/packages/browser/README.md
index c5d712e2..96c3efc4 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -149,17 +149,28 @@ evaluation context.
 
 ## Flag-key obfuscation
 
-The Precompute fetcher automatically advertises support for the
-flag-key-sha256-v1 assignment encoding. Datadog controls its server rollout.
+The Precompute fetcher automatically sends
+`X-DD-FEATURE-FLAGS-CAPABILITIES: assignment-encoding-flag-key-256-v1`.
+This declares support for the `flag-key-sha256-v1` response encoding.
+Datadog controls its server rollout.
 The provider accepts both plaintext and obfuscated responses without an
 application configuration change.
 
 Continue evaluating the original flag key. The shared core hashes it with the
-response's public salt before lookup. Values, evaluation details, exposure
-events, and RUM annotations keep their existing behavior. Portable
+response's public salt before lookup. Flag values do not change. Evaluation
+details, exposure events, and RUM annotations retain the original flag key. Portable
 configuration and IndexedDB storage retain the descriptor with the assignments.
 Unsupported or malformed encodings are rejected, not interpreted as plaintext.
 
+Encoded portable snapshots use wire version 2, which older readers reject.
+Plaintext and rules-only snapshots keep version 1. New readers accept both versions.
+This version applies to SDK serialization, not the Precompute API response.
+
+New IndexedDB writes use a separate cache key namespace for both response formats.
+Older SDKs cannot read these encoded entries. New SDKs can read legacy plaintext
+entries when the new namespace has no entry. A plaintext rollout rollback replaces
+the encoded entry in the new namespace. It does not update an older SDK's cache.
+
 Obfuscation removes readable flag-map keys. It is not encryption, authorization,
 or response signing. Values, variation names, allocation names, telemetry,
 and application code can still reveal a feature's purpose. Do not put sensitive
@@ -285,9 +296,9 @@ await tracking.shutdown()
 
 The application owns manually registered hooks: clearing client hooks or removing `DatadogCoreProvider` does not shut down their resources. Unregister them and call `tracking.shutdown()` when they are no longer needed. The regular `DatadogProvider` shuts down its own tracking resources through OpenFeature's provider lifecycle.
 
-Exposure deduplication is tied to the active `DatadogCoreProvider` configuration, so replacing the provider configuration allows exposures for the new configuration to be emitted without clearing application-managed hook state.
+For precomputed assignments, both providers keep exposure deduplication across configuration refreshes. The existing exposure cache identifies the subject and its attributes, original flag key, allocation, variant, and assignment serial when present. A change to these fields can emit a new exposure. A changed response timestamp, salt, or unrelated flag does not. A changed value under the same variant and serial does not create a new exposure identity.
 
-Refetching identical content does not invalidate deduplication when only retrieval metadata (`fetchedAt` or `etag`) changes. For rules-based configurations, the server's `createdAt` build timestamp is also excluded from the identity, matching the backend's semantic fingerprint behavior. These fields remain available on the configuration and in its portable wire representation.
+For rules-based configurations, `DatadogCoreProvider` also includes the rules configuration identity. Changed rules allow new exposures without clearing application-managed hook state. Retrieval metadata (`fetchedAt` and `etag`) and the server's `createdAt` build timestamp do not change that identity. These fields remain available on the configuration and in its portable wire representation.
 
 Both the standalone exposure hook and `DatadogProvider` scope persistent exposure caches by telemetry site, client token, proxy URL, environment, application, service, and source. Scope values are hashed into the storage namespace; raw tokens are not stored in cache keys. Recreating a hook with the same scope retains deduplication, while another destination can emit its own exposures. Older unscoped cache entries are not reused, so upgrading can produce a one-time repeat exposure. Function-valued telemetry proxies use memory-only deduplication because their destination cannot be inferred reliably from the callback's identity.
 
diff --git a/packages/browser/src/cache/indexeddb-flags-cache.ts b/packages/browser/src/cache/indexeddb-flags-cache.ts
index 09f633ff..a7c1ec06 100644
--- a/packages/browser/src/cache/indexeddb-flags-cache.ts
+++ b/packages/browser/src/cache/indexeddb-flags-cache.ts
@@ -38,14 +38,35 @@ export class IndexedDBFlagsCache {
   /** Read cached config for the given context. Returns undefined on miss or any error. */
   async get(context: EvaluationContext): Promise {
     try {
-      const configKey = buildConfigKey(this.clientToken, context)
+      const legacyKey = buildConfigKey(this.clientToken, context)
+      const configKey = `v2-${legacyKey}`
       const db = await openDB()
       try {
         const config = await new Promise((resolve, reject) => {
           const tx = db.transaction(STORE_NAME, 'readonly')
           const store = tx.objectStore(STORE_NAME)
           const request = store.get(configKey)
-          request.onsuccess = () => resolve(request.result as FlagsConfiguration | undefined)
+          request.onsuccess = () => {
+            if (request.result !== undefined) {
+              resolve(request.result as FlagsConfiguration)
+              return
+            }
+            // Read older plaintext entries on upgrade. Never expose encoded
+            // snapshots written by a prerelease SDK through the legacy key.
+            const legacy = store.get(legacyKey)
+            legacy.onerror = () => reject(legacy.error)
+            legacy.onsuccess = () => {
+              const config = legacy.result as FlagsConfiguration | undefined
+              const attributes = config?.precomputed?.response?.data?.attributes
+              resolve(
+                attributes &&
+                  (attributes.obfuscated === undefined || attributes.obfuscated === false) &&
+                  attributes.obfuscation === undefined
+                  ? config
+                  : undefined
+              )
+            }
+          }
           request.onerror = () => reject(request.error)
         })
         if (!config || typeof config !== 'object') {
@@ -62,7 +83,9 @@ export class IndexedDBFlagsCache {
 
   /** Fire-and-forget persist. Never throws. */
   set(config: FlagsConfiguration, context: EvaluationContext): void {
-    const configKey = buildConfigKey(this.clientToken, context)
+    // One namespace for new writes keeps plaintext rollback and encoded
+    // refreshes in the same slot without changing what older SDKs can read.
+    const configKey = `v2-${buildConfigKey(this.clientToken, context)}`
     openDB()
       .then((db) => {
         const tx = db.transaction(STORE_NAME, 'readwrite')
diff --git a/packages/browser/src/openfeature/core-provider.ts b/packages/browser/src/openfeature/core-provider.ts
index af138e4a..7333b79b 100644
--- a/packages/browser/src/openfeature/core-provider.ts
+++ b/packages/browser/src/openfeature/core-provider.ts
@@ -79,36 +79,14 @@ export class DatadogCoreProvider extends DatadogProviderBase {
     logger: Logger
   ): ResolutionDetails> {
     const details = evaluate(this.flagsConfiguration, type, flagKey, defaultValue, context, logger)
-    return withCoreConfigurationId(details, this.getExposureConfigurationId(flagKey, details, context))
-  }
-
-  private getExposureConfigurationId(
-    flagKey: string,
-    details: ResolutionDetails>,
-    context: EvaluationContext
-  ): string | undefined {
-    const precomputed = this.flagsConfiguration?.precomputed
-    if (
-      !precomputed ||
-      !configMatchesContext(this.flagsConfiguration, context) ||
-      details.flagMetadata?.doLog !== true
-    ) {
-      return this.flagsConfigurationId
-    }
-
-    try {
-      // A new salt changes the wire keys, not the assignment. Use the original
-      // key and resolved assignment to keep exposure deduplication unchanged.
-      const {
-        flags: _flags,
-        obfuscated: _obfuscated,
-        obfuscation: _obfuscation,
-        ...attributes
-      } = precomputed.response.data.attributes
-      return getMD5Hash(JSON.stringify({ attributes, context: precomputed.context, flagKey, details }))
-    } catch {
-      return this.flagsConfigurationId
-    }
+    // Precomputed exposures already identify the subject, flag, allocation,
+    // variant, and serial. A delivery timestamp or salt is not a new assignment.
+    // Keep a stable marker to distinguish these entries from older cache formats.
+    const configurationId =
+      this.flagsConfiguration?.precomputed && configMatchesContext(this.flagsConfiguration, context)
+        ? 'precomputed'
+        : this.flagsConfigurationId
+    return withCoreConfigurationId(details, configurationId)
   }
 
   private canEvaluateCurrentContext(): boolean {
diff --git a/packages/browser/src/openfeature/provider.ts b/packages/browser/src/openfeature/provider.ts
index 25a7b241..65950c00 100644
--- a/packages/browser/src/openfeature/provider.ts
+++ b/packages/browser/src/openfeature/provider.ts
@@ -1,5 +1,4 @@
 import {
-  type AssignmentCache,
   configMatchesContext,
   evaluatePrecomputedConfiguration,
   type FlagsConfiguration,
@@ -77,7 +76,6 @@ export class DatadogProvider extends DatadogProviderBase {
   private flagsConfiguration: FlagsConfiguration | undefined
   private flagsCache: IndexedDBFlagsCache | undefined
 
-  private exposureCache: AssignmentCache | undefined
   private exposureCacheReady: Promise | undefined
   private readonly tracking: ProviderTracking
 
@@ -111,7 +109,6 @@ export class DatadogProvider extends DatadogProviderBase {
       getTrackingContext: () => this.evaluationContext,
     })
     this.hooks = this.tracking.hooks
-    this.exposureCache = this.tracking.exposureCache
 
     if (hasIndexedDB()) {
       this.flagsCache = new IndexedDBFlagsCache(options.clientToken)
@@ -161,14 +158,9 @@ export class DatadogProvider extends DatadogProviderBase {
     // `signal`, so we don't block OF SDK unnecessarily.
     this.latestContextUpdate = this.retrieveFlagsConfiguration(evaluationContext, { signal })
       .then((result) =>
-        // New configuration might require clearing exposure
-        // cache. One example of this is updating experiment
-        // boundaries: if we previously emitted exposure events for an
-        // experiment and the new configuration bumped experiment
-        // start time, we need to emit at least one new event within
-        // the new experiment timeframe. We do that by clearing our
-        // exposure
-        this.maybeClearExposureCache(result.config, { signal }).then(
+        // Load persisted exposure identities before publishing the configuration.
+        // Refetches do not clear them; assignment changes produce different entries.
+        waitWithAbort(signal, this.exposureCacheReady).then(
           () => result,
           // Ignore exposure cache errors. They should not prevent us from using the latest configuration.
           () => result
@@ -255,19 +247,6 @@ export class DatadogProvider extends DatadogProviderBase {
     }
   }
 
-  private async maybeClearExposureCache(
-    newFlagsConfiguration: FlagsConfiguration,
-    { signal }: { signal: AbortSignal }
-  ): Promise {
-    await waitWithAbort(signal, this.exposureCacheReady)
-
-    const prevCreatedAt = this.flagsConfiguration?.precomputed?.response.data.attributes.createdAt
-    const newCreatedAt = newFlagsConfiguration.precomputed?.response.data.attributes.createdAt
-    if (prevCreatedAt !== undefined && prevCreatedAt !== newCreatedAt) {
-      await waitWithAbort(signal, this.exposureCache?.clear())
-    }
-  }
-
   protected resolve(
     type: T,
     flagKey: string,
diff --git a/packages/browser/src/transport/fetchConfiguration.ts b/packages/browser/src/transport/fetchConfiguration.ts
index 18e84619..c4a57111 100644
--- a/packages/browser/src/transport/fetchConfiguration.ts
+++ b/packages/browser/src/transport/fetchConfiguration.ts
@@ -1,7 +1,7 @@
 import {
   type FlagsConfiguration,
   parsePrecomputedConfigurationResponse,
-  SUPPORTED_ASSIGNMENT_ENCODINGS,
+  SUPPORTED_FLAGS_CAPABILITIES,
 } from '@datadog/flagging-core'
 import { timeStampNow } from '@datadog/js-core/time'
 import type { EvaluationContext } from '@openfeature/web-sdk'
@@ -13,6 +13,7 @@ const sourcePayload = {
   sdk_name: 'browser',
   sdk_version: __BUILD_ENV__SDK_VERSION__,
 }
+const capabilitiesHeader = [...SUPPORTED_FLAGS_CAPABILITIES].sort().join(',')
 
 type JSONAPIError = {
   errors: {
@@ -112,6 +113,7 @@ export async function fetchPrecomputedConfiguration(
     options,
     {
       'Content-Type': 'application/vnd.api+json',
+      'X-DD-FEATURE-FLAGS-CAPABILITIES': capabilitiesHeader,
     },
     'precomputed'
   )
@@ -137,9 +139,6 @@ export async function fetchPrecomputedConfiguration(
         attributes: {
           env: envPayload,
           source: sourcePayload,
-          supported_capabilities: {
-            assignment_encodings: SUPPORTED_ASSIGNMENT_ENCODINGS,
-          },
           subject: {
             targeting_key: options.context.targetingKey || '',
             targeting_attributes: stringifiedContext,
@@ -176,7 +175,7 @@ export function createFlagsConfigurationFetcher(initConfiguration: FlaggingInitC
       signal,
     })
     // Use the provider's existing context-matched cache fallback. Do not replace
-    // a valid snapshot with an unsupported or malformed response encoding.
+    // a valid snapshot with a malformed response or unsupported encoding.
     if (configuration.precomputedError) throw new ParseError(configuration.precomputedError)
     return configuration
   }
diff --git a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts
index ed814976..0c641e8c 100644
--- a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts
+++ b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts
@@ -4,7 +4,7 @@ if (typeof globalThis.structuredClone === 'undefined') {
 }
 
 import 'fake-indexeddb/auto'
-import type { FlagsConfiguration } from '@datadog/flagging-core'
+import { buildStorageKeySuffix, type FlagsConfiguration, getMD5Hash } from '@datadog/flagging-core'
 import type { TimeStamp } from '@datadog/js-core/time'
 import { IDBFactory } from 'fake-indexeddb'
 import { IndexedDBFlagsCache } from '../../src/cache/indexeddb-flags-cache'
@@ -34,6 +34,23 @@ const testConfig: FlagsConfiguration = {
 }
 
 const context = { targetingKey: 'user-123' }
+const legacyKey = `flags-config-${buildStorageKeySuffix('test-client-token')}-${getMD5Hash(JSON.stringify(context))}`
+const currentKey = `v2-${legacyKey}`
+const encodedConfig: FlagsConfiguration = {
+  precomputed: {
+    ...testConfig.precomputed!,
+    response: {
+      data: {
+        attributes: {
+          ...testConfig.precomputed!.response.data.attributes,
+          obfuscated: true,
+          obfuscation: { scheme: 'flag-key-sha256-v1', salt: '0'.repeat(32) },
+          flags: { ['a'.repeat(64)]: testConfig.precomputed!.response.data.attributes.flags['test-flag'] },
+        },
+      },
+    },
+  },
+}
 
 describe('IndexedDBFlagsCache', () => {
   let cache: IndexedDBFlagsCache
@@ -66,7 +83,7 @@ describe('IndexedDBFlagsCache', () => {
       // Write a string directly — not a FlagsConfiguration object
       const db = await openTestDB()
       const tx = db.transaction('configurations', 'readwrite')
-      tx.objectStore('configurations').put('not an object', 'flags-config')
+      tx.objectStore('configurations').put('not an object', currentKey)
       await transactionComplete(tx)
       db.close()
 
@@ -78,7 +95,7 @@ describe('IndexedDBFlagsCache', () => {
       // Write an object that has no precomputed field
       const db = await openTestDB()
       const tx = db.transaction('configurations', 'readwrite')
-      tx.objectStore('configurations').put({ version: 1 }, 'flags-config')
+      tx.objectStore('configurations').put({ version: 1 }, currentKey)
       await transactionComplete(tx)
       db.close()
 
@@ -148,6 +165,41 @@ describe('IndexedDBFlagsCache', () => {
     })
   })
 
+  describe('older SDK compatibility', () => {
+    it('reads legacy plaintext on upgrade without overwriting it with encoded keys', async () => {
+      await writeEntry(legacyKey, testConfig)
+      expect(await cache.get(context)).toEqual(testConfig)
+
+      cache.set(encodedConfig, context)
+      await flushAsync()
+      expect(await cache.get(context)).toEqual(encodedConfig)
+      expect(await readEntry(currentKey)).toEqual(encodedConfig)
+      expect(await readEntry(legacyKey)).toEqual(testConfig)
+    })
+
+    it('never writes encoded snapshots where an older SDK can read them', async () => {
+      cache.set(encodedConfig, context)
+      await flushAsync()
+      expect(await readEntry(currentKey)).toEqual(encodedConfig)
+      expect(await readEntry(legacyKey)).toBeUndefined()
+    })
+
+    it('replaces encoded snapshots with plaintext on rollout rollback', async () => {
+      cache.set(encodedConfig, context)
+      await flushAsync()
+      cache.set(testConfig, context)
+      await flushAsync()
+      expect(await cache.get(context)).toEqual(testConfig)
+      expect(await readEntry(currentKey)).toEqual(testConfig)
+      expect(await readEntry(legacyKey)).toBeUndefined()
+    })
+
+    it('does not restore encoded snapshots from the legacy namespace', async () => {
+      await writeEntry(legacyKey, encodedConfig)
+      expect(await cache.get(context)).toBeUndefined()
+    })
+  })
+
   describe('client token isolation', () => {
     it('should not share data between caches with different client tokens', async () => {
       const cacheA = new IndexedDBFlagsCache('token-aaa')
@@ -234,6 +286,27 @@ function flushAsync(): Promise {
   return new Promise((resolve) => setTimeout(resolve, 50))
 }
 
+async function writeEntry(key: string, value: FlagsConfiguration): Promise {
+  const db = await openTestDB()
+  const tx = db.transaction('configurations', 'readwrite')
+  tx.objectStore('configurations').put(value, key)
+  await transactionComplete(tx)
+  db.close()
+}
+
+async function readEntry(key: string): Promise {
+  const db = await openTestDB()
+  try {
+    return await new Promise((resolve, reject) => {
+      const request = db.transaction('configurations', 'readonly').objectStore('configurations').get(key)
+      request.onsuccess = () => resolve(request.result)
+      request.onerror = () => reject(request.error)
+    })
+  } finally {
+    db.close()
+  }
+}
+
 // Helpers to directly open the test DB for setup/verification
 function openTestDB(): Promise {
   return new Promise((resolve, reject) => {
diff --git a/packages/browser/test/openfeature/core-tracking.spec.ts b/packages/browser/test/openfeature/core-tracking.spec.ts
index df07a663..02aab022 100644
--- a/packages/browser/test/openfeature/core-tracking.spec.ts
+++ b/packages/browser/test/openfeature/core-tracking.spec.ts
@@ -208,7 +208,7 @@ describe('DatadogCoreProvider tracking', () => {
     expect(fetchMock.mock.calls.some(([url]) => url.toString().includes('exposures'))).toBe(false)
   })
 
-  it('emits exposures again when the core provider configuration identity changes', async () => {
+  it('keeps exposures deduplicated when the precomputed response timestamp changes', async () => {
     const { trackingHooks } = createExposureOnlyTracking()
     await trackingHooks.initialize()
 
@@ -225,7 +225,7 @@ describe('DatadogCoreProvider tracking', () => {
     client.getStringValue('static-flag', 'default')
     jest.advanceTimersByTime(31_000)
 
-    expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(2)
+    expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(1)
   })
 
   it('keeps exposure deduplication when the core provider configuration identity is unchanged', async () => {
diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts
index ecaea69d..b283c55e 100644
--- a/packages/browser/test/openfeature/exposures.spec.ts
+++ b/packages/browser/test/openfeature/exposures.spec.ts
@@ -726,7 +726,7 @@ describe('Exposures End-to-End', () => {
       expect(secondExposureEvents[0].variant.key).toBe('variation-b')
     })
 
-    it('should clear exposure cache when configuration createdAt changes', async () => {
+    it('should keep exposure cache when only configuration createdAt changes', async () => {
       // Create two responses with different createdAt timestamps
       const firstResponse = {
         ...precomputedServerResponse,
@@ -785,15 +785,14 @@ describe('Exposures End-to-End', () => {
       // Verify first exposure was logged
       expect(getExposuresCalls()).toHaveLength(1)
 
-      // Fetch new configuration with different createdAt (cache should be cleared)
+      // Fetch the same assignment with a new delivery timestamp.
       await provider.onContextChange({}, { targetingKey: 'test-user-123', customAttribute: 'test-value' })
 
-      // Evaluate same flag - should log again because cache was cleared
+      // The assignment has not changed, so do not log another exposure.
       client.getStringValue('string-flag', 'default')
       triggerBatch()
 
-      // Should have 2 exposure calls (cache was cleared)
-      expect(getExposuresCalls()).toHaveLength(2)
+      expect(getExposuresCalls()).toHaveLength(1)
     })
 
     it('should not clear exposure cache on initial page load', async () => {
diff --git a/packages/browser/test/openfeature/obfuscation.spec.ts b/packages/browser/test/openfeature/obfuscation.spec.ts
index dd65e4ab..638b0e62 100644
--- a/packages/browser/test/openfeature/obfuscation.spec.ts
+++ b/packages/browser/test/openfeature/obfuscation.spec.ts
@@ -23,7 +23,7 @@ const options = {
 }
 const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }
 
-function response(publicSalt: string | undefined = salt, value = true) {
+function response(publicSalt: string | undefined = salt, value = true, createdAt = '2026-09-30T00:00:00Z') {
   const lookupKey = publicSalt
     ? createHash('sha256')
         .update('datadog.feature-flags.flag-key.v1\0')
@@ -34,7 +34,7 @@ function response(publicSalt: string | undefined = salt, value = true) {
   return {
     data: {
       attributes: {
-        createdAt: '2026-09-30T00:00:00Z',
+        createdAt,
         obfuscated: Boolean(publicSalt),
         obfuscation: publicSalt ? { scheme: 'flag-key-sha256-v1', salt: publicSalt } : undefined,
         flags: {
@@ -88,9 +88,11 @@ describe('browser flag-key obfuscation', () => {
       reason: 'TARGETING_MATCH',
       flagMetadata: { allocationKey: 'allocation-1', doLog: true, __dd_split_serial_id: 123 },
     })
-    expect(JSON.parse(fetchMock.mock.calls[0][1].body).data.attributes).toMatchObject({
+    const request = fetchMock.mock.calls[0][1]
+    expect(request.headers['X-DD-FEATURE-FLAGS-CAPABILITIES']).toBe('assignment-encoding-flag-key-256-v1')
+    expect(JSON.parse(request.body).data.attributes).not.toHaveProperty('supported_capabilities')
+    expect(JSON.parse(request.body).data.attributes).toMatchObject({
       source: { sdk_name: 'browser', sdk_version: '1.0.0-test' },
-      supported_capabilities: { assignment_encodings: ['flag-key-sha256-v1'] },
     })
   })
 
@@ -113,6 +115,37 @@ describe('browser flag-key obfuscation', () => {
     await provider.onClose()
   })
 
+  it.each(['', salt])('accepts new flags and future fields on refresh (salt: %s)', async (publicSalt) => {
+    const provider = new DatadogProvider(options)
+    await provider.initialize(context)
+    const payload = response(publicSalt)
+    const attributes = payload.data.attributes
+    const assignment = Object.values(attributes.flags)[0]
+    const lookupKey = (name: string) =>
+      publicSalt
+        ? createHash('sha256')
+            .update('datadog.feature-flags.flag-key.v1\0')
+            .update(Buffer.from(publicSalt, 'hex'))
+            .update(name)
+            .digest('hex')
+        : name
+    attributes.flags[lookupKey('new-flag')] = { ...assignment, variationValue: false }
+    attributes.flags[lookupKey('future-type')] = { ...assignment, variationType: 'future-type' }
+    Object.assign(attributes, { futureField: { enabled: true } })
+    Object.assign(assignment, { futureAssignmentField: 123 })
+    fetchMock.mockResolvedValue(fetchResponse(payload))
+    await provider.onContextChange(context, context)
+
+    expect(provider.status).toBe(ProviderStatus.READY)
+    expect(provider.resolveBooleanEvaluation(key, false, context, logger).value).toBe(true)
+    expect(provider.resolveBooleanEvaluation('new-flag', true, context, logger).value).toBe(false)
+    expect(provider.resolveBooleanEvaluation('future-type', false, context, logger)).toMatchObject({
+      value: false,
+      errorCode: 'PARSE_ERROR',
+    })
+    await provider.onClose()
+  })
+
   it('keeps a matching previous snapshot when an encoding is unsupported', async () => {
     const provider = new DatadogProvider(options)
     await provider.initialize(context)
@@ -138,6 +171,29 @@ describe('browser flag-key obfuscation', () => {
     await provider.onClose()
   })
 
+  it.each(['createdAt', 'flags'])('keeps a matching snapshot when plaintext %s is malformed', async (field) => {
+    const provider = new DatadogProvider(options)
+    fetchMock.mockResolvedValue(fetchResponse(response('')))
+    await provider.initialize(context)
+    const invalid = response('')
+    Object.assign(invalid.data.attributes, { [field]: null })
+    fetchMock.mockResolvedValue(fetchResponse(invalid))
+    await provider.onContextChange(context, context)
+    expect(provider.status).toBe(ProviderStatus.STALE)
+    expect(provider.resolveBooleanEvaluation(key, false, context, logger).value).toBe(true)
+    await provider.onClose()
+  })
+
+  it.each(['createdAt', 'flags'])('rejects malformed plaintext %s without a usable cache', async (field) => {
+    const invalid = response('')
+    Object.assign(invalid.data.attributes, { [field]: null })
+    fetchMock.mockResolvedValue(fetchResponse(invalid))
+    const provider = new DatadogProvider(options)
+    await expect(provider.initialize(context)).rejects.toMatchObject({ code: 'PARSE_ERROR' })
+    expect(provider.status).toBe(ProviderStatus.ERROR)
+    await provider.onClose()
+  })
+
   it('keeps original flag names in exposures, evaluation events, and RUM', async () => {
     jest.useFakeTimers()
     const rum = jest.fn()
@@ -177,33 +233,115 @@ describe('browser flag-key obfuscation', () => {
     }
   })
 
-  it('does not repeat portable-provider exposures just because the salt changes', async () => {
+  it.each(['online', 'portable'])('keeps %s exposures deduplicated across refreshes and restart', async (kind) => {
     jest.useFakeTimers()
-    const tracking = createDatadogExposureLoggingHook(options)
-    await tracking.initialize()
-    const provider = new DatadogCoreProvider()
-    provider.setConfiguration(await fetchPrecomputedConfiguration({ ...options, context }))
-    await OpenFeature.setProviderAndWait(provider, context)
+    let payload = response()
+    fetchMock.mockImplementation(async (url: string) =>
+      url.includes('precompute-assignments') ? fetchResponse(payload) : { ok: true, status: 200 }
+    )
+    let tracking: ReturnType | undefined
+    let provider: DatadogProvider | DatadogCoreProvider
     const client = OpenFeature.getClient()
-    client.addHooks(...tracking.hooks)
+    const install = async () => {
+      if (kind === 'online') {
+        provider = new DatadogProvider({ ...options, enableExposureLogging: true })
+      } else {
+        tracking = createDatadogExposureLoggingHook(options)
+        await tracking.initialize()
+        const core = new DatadogCoreProvider()
+        core.setConfiguration(await fetchPrecomputedConfiguration({ ...options, context }))
+        provider = core
+      }
+      await OpenFeature.setProviderAndWait(provider, context)
+      if (tracking) client.addHooks(...tracking.hooks)
+    }
+    const refresh = async () => {
+      if (provider instanceof DatadogCoreProvider) {
+        const configuration = await fetchPrecomputedConfiguration({ ...options, context })
+        provider.setConfiguration(configurationFromString(configurationToString(configuration)))
+      } else {
+        await provider.onContextChange(context, context)
+      }
+    }
+    const exposureEvents = () =>
+      fetchMock.mock.calls
+        .filter(([url]) => String(url).includes('exposures'))
+        .flatMap(([, request]) =>
+          (request.body as string)
+            .trim()
+            .split('\n')
+            .map((line) => JSON.parse(line))
+        )
+
+    await install()
     const first = client.getBooleanDetails(key, false)
     jest.advanceTimersByTime(31_000)
 
-    for (const publicSalt of ['f'.repeat(32), '', salt]) {
-      fetchMock.mockResolvedValue(fetchResponse(response(publicSalt)))
-      provider.setConfiguration(await fetchPrecomputedConfiguration({ ...options, context }))
-      expect(client.getBooleanDetails(key, false)).toEqual(first)
+    // Real edge responses change both the salt and createdAt on each fetch.
+    for (const [index, publicSalt] of ['f'.repeat(32), '', salt].entries()) {
+      payload = response(publicSalt, true, `2026-09-30T00:0${index + 1}:00Z`)
+      await refresh()
+      expect(client.getBooleanDetails(key, false).value).toBe(first.value)
       jest.advanceTimersByTime(31_000)
     }
-    expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('exposures'))).toHaveLength(1)
+    expect(exposureEvents()).toHaveLength(1)
 
-    // A real assignment change still starts a new exposure identity.
-    fetchMock.mockResolvedValue(fetchResponse(response(salt, false)))
-    provider.setConfiguration(await fetchPrecomputedConfiguration({ ...options, context }))
+    // The exposure describes the assigned variant, not its delivered value.
+    payload = response(salt, false)
+    const assignment = Object.values(payload.data.attributes.flags)[0]
+    payload.data.attributes.flags['b'.repeat(64)] = { ...assignment, variationValue: true }
+    await refresh()
     expect(client.getBooleanDetails(key, false).value).toBe(false)
     jest.advanceTimersByTime(31_000)
-    expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('exposures'))).toHaveLength(2)
-    await tracking.shutdown()
+    expect(exposureEvents()).toHaveLength(1)
+
+    for (const [index, change] of [
+      { variationKey: 'variant-2' },
+      { allocationKey: 'allocation-2' },
+      { serialId: 124 },
+    ].entries()) {
+      Object.assign(assignment, change)
+      await refresh()
+      client.getBooleanValue(key, true)
+      jest.advanceTimersByTime(31_000)
+      expect(exposureEvents()).toHaveLength(index + 2)
+    }
+    expect(exposureEvents()[3]).toMatchObject({
+      flag: { key },
+      allocation: { key: 'allocation-2' },
+      variant: { key: 'variant-2' },
+      serial_id: 124,
+    })
+
+    // Recreate the provider and hooks without clearing persisted deduplication.
+    client.clearHooks()
+    await tracking?.shutdown()
+    await OpenFeature.clearProviders()
+    await install()
+    client.getBooleanValue(key, true)
+    jest.advanceTimersByTime(31_000)
+    expect(exposureEvents()).toHaveLength(4)
     client.clearHooks()
+    await tracking?.shutdown()
+  })
+
+  it('does not serialize assignment values on repeated portable-provider evaluations', async () => {
+    const configuration = await fetchPrecomputedConfiguration({ ...options, context })
+    const flag = Object.values(configuration.precomputed!.response.data.attributes.flags)[0]
+    flag.variationType = 'object'
+    flag.variationValue = { large: 'x'.repeat(6600) }
+    const provider = new DatadogCoreProvider()
+    provider.setConfiguration(configuration)
+    await provider.initialize(context)
+    const stringify = jest.spyOn(JSON, 'stringify')
+    try {
+      for (let index = 0; index < 10; index++) {
+        const details = provider.resolveObjectEvaluation(key, {}, context, logger)
+        expect(details.value).toBe(flag.variationValue)
+      }
+      expect(stringify).not.toHaveBeenCalled()
+    } finally {
+      stringify.mockRestore()
+    }
   })
 })
diff --git a/packages/browser/test/openfeature/provider.spec.ts b/packages/browser/test/openfeature/provider.spec.ts
index 732163ce..c6eb55cd 100644
--- a/packages/browser/test/openfeature/provider.spec.ts
+++ b/packages/browser/test/openfeature/provider.spec.ts
@@ -281,6 +281,7 @@ describe('DatadogProvider', () => {
       // Verify headers were set correctly
       expect(requestOptions.headers).toEqual({
         'Content-Type': 'application/vnd.api+json',
+        'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
         'dd-client-token': options.clientToken,
         'dd-application-id': options.applicationId,
       })
@@ -298,9 +299,6 @@ describe('DatadogProvider', () => {
               sdk_name: 'browser',
               sdk_version: '1.0.0-test',
             },
-            supported_capabilities: {
-              assignment_encodings: ['flag-key-sha256-v1'],
-            },
             subject: {
               targeting_key: 'test-user',
               targeting_attributes: {
@@ -608,6 +606,7 @@ describe('DatadogProvider', () => {
           method: 'POST',
           headers: {
             'Content-Type': 'application/vnd.api+json',
+            'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
             'dd-client-token': options.clientToken,
             'dd-application-id': options.applicationId,
           },
diff --git a/packages/browser/test/transport/fetchConfiguration.spec.ts b/packages/browser/test/transport/fetchConfiguration.spec.ts
index fe3e93ff..91703d19 100644
--- a/packages/browser/test/transport/fetchConfiguration.spec.ts
+++ b/packages/browser/test/transport/fetchConfiguration.spec.ts
@@ -205,6 +205,7 @@ describe('createFlagsConfigurationFetcher', () => {
         expect.objectContaining({
           headers: {
             'Content-Type': 'application/vnd.api+json',
+            'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
             'dd-client-token': 'test-token',
             'dd-application-id': 'test-app-id',
           },
@@ -212,7 +213,7 @@ describe('createFlagsConfigurationFetcher', () => {
       )
     })
 
-    it('should exclude dd headers when overwriteRequestHeaders is true', async () => {
+    it('should exclude authentication headers when overwriteRequestHeaders is true', async () => {
       const config = {
         ...baseConfig,
         flaggingProxy: 'https://proxy.example.com',
@@ -227,6 +228,7 @@ describe('createFlagsConfigurationFetcher', () => {
         expect.objectContaining({
           headers: {
             'Content-Type': 'application/vnd.api+json',
+            'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
           },
         })
       )
@@ -250,6 +252,7 @@ describe('createFlagsConfigurationFetcher', () => {
         expect.objectContaining({
           headers: {
             'Content-Type': 'application/vnd.api+json',
+            'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
             'dd-client-token': 'test-token',
             'dd-application-id': 'test-app-id',
             'X-Custom-Header': 'custom-value',
@@ -274,6 +277,7 @@ describe('createFlagsConfigurationFetcher', () => {
         expect.objectContaining({
           headers: {
             'Content-Type': 'application/vnd.api+json',
+            'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
             'dd-client-token': 'test-token',
           },
         })
@@ -313,9 +317,6 @@ describe('createFlagsConfigurationFetcher', () => {
               sdk_name: 'browser',
               sdk_version: '1.0.0-test',
             },
-            supported_capabilities: {
-              assignment_encodings: ['flag-key-sha256-v1'],
-            },
             subject: {
               targeting_key: 'user-123',
               targeting_attributes: {
diff --git a/packages/core/src/configuration/flag-key-obfuscation.ts b/packages/core/src/configuration/flag-key-obfuscation.ts
index 6536fe65..717be2a4 100644
--- a/packages/core/src/configuration/flag-key-obfuscation.ts
+++ b/packages/core/src/configuration/flag-key-obfuscation.ts
@@ -2,10 +2,12 @@ import { sha256Hex } from '../evaluation/sha256'
 import { encodeUtf8 } from '../utf8'
 
 const SCHEME = 'flag-key-sha256-v1'
-const DOMAIN = encodeUtf8('datadog.feature-flags.flag-key.v1\0')
+const DOMAIN = /* @__PURE__ */ encodeUtf8('datadog.feature-flags.flag-key.v1\0')
+const MAX_CACHED_KEYS = 1024
+const lookupCaches = new WeakMap }>()
 
-/** Assignment encodings supported by the precomputed parser and evaluator. @internal */
-export const SUPPORTED_ASSIGNMENT_ENCODINGS = [SCHEME] as const
+/** Capabilities supported by the precomputed parser and evaluator. @internal */
+export const SUPPORTED_FLAGS_CAPABILITIES = ['assignment-encoding-flag-key-256-v1'] as const
 
 /** Public metadata stored with the encoded assignment map. @internal */
 export type FlagKeyObfuscation = {
@@ -31,7 +33,8 @@ export function readFlagKeyObfuscation(
   if (!isLowercaseHex(descriptor.salt, 16)) {
     return { error: 'Precomputed flag-key salt must contain 32 lowercase hexadecimal characters' }
   }
-  return { encoding: { scheme: SCHEME, salt: descriptor.salt } }
+  // Keep descriptor identity stable so repeated lookups share a bounded cache.
+  return { encoding: descriptor as FlagKeyObfuscation }
 }
 
 /** Hash only the lookup key. Values and telemetry retain their original meaning. */
@@ -39,6 +42,14 @@ export function encodePrecomputedFlagKey(
   key: string,
   encoding: FlagKeyObfuscation
 ): { key: string } | { error: string } {
+  let cache = lookupCaches.get(encoding)
+  if (!cache || cache.salt !== encoding.salt) {
+    cache = { salt: encoding.salt, keys: new Map() }
+    lookupCaches.set(encoding, cache)
+  }
+  const cached = cache.keys.get(key)
+  if (cached !== undefined) return { key: cached }
+
   // TextEncoder replaces invalid surrogates. Reject them instead of aliasing
   // a different flag whose name contains the Unicode replacement character.
   if (!isWellFormedUnicode(key)) return { error: 'Flag key must contain valid Unicode' }
@@ -50,7 +61,10 @@ export function encodePrecomputedFlagKey(
     input[DOMAIN.length + index] = Number.parseInt(encoding.salt.slice(index * 2, index * 2 + 2), 16)
   }
   input.set(keyBytes, DOMAIN.length + 16)
-  return { key: sha256Hex(input) }
+  const digest = sha256Hex(input)
+  if (cache.keys.size >= MAX_CACHED_KEYS) cache.keys.clear()
+  cache.keys.set(key, digest)
+  return { key: digest }
 }
 
 /** Check the exact byte length and alphabet of a hex-encoded wire value. */
diff --git a/packages/core/src/configuration/index.ts b/packages/core/src/configuration/index.ts
index 05536d8b..fb5c1331 100644
--- a/packages/core/src/configuration/index.ts
+++ b/packages/core/src/configuration/index.ts
@@ -1,7 +1,7 @@
 export * from './configuration'
 export * from './exposureEvent'
 export * from './exposureEvent.types'
-export { SUPPORTED_ASSIGNMENT_ENCODINGS } from './flag-key-obfuscation'
+export { SUPPORTED_FLAGS_CAPABILITIES } from './flag-key-obfuscation'
 export * from './flagEvaluationAggregator'
 export * from './flagEvaluationEvent'
 export * from './flagEvaluationEvent.types'
diff --git a/packages/core/src/configuration/precomputed-wire.ts b/packages/core/src/configuration/precomputed-wire.ts
index bfdf3264..d60a1135 100644
--- a/packages/core/src/configuration/precomputed-wire.ts
+++ b/packages/core/src/configuration/precomputed-wire.ts
@@ -49,6 +49,8 @@ export function precomputedConfigurationToWire(configuration: FlagsConfiguration
   if (!configuration.precomputed) return wire
 
   const { context, response, fetchedAt, etag } = configuration.precomputed
+  // Older readers reject version 2 instead of treating hashed keys as plaintext.
+  if (response.data.attributes.obfuscated === true) wire.version = 2
   wire.precomputed = {
     context,
     response: JSON.stringify(response),
diff --git a/packages/core/src/configuration/wire-types.ts b/packages/core/src/configuration/wire-types.ts
index 5b6f928e..763fef49 100644
--- a/packages/core/src/configuration/wire-types.ts
+++ b/packages/core/src/configuration/wire-types.ts
@@ -6,7 +6,7 @@ export type FlagsConfigurationWire = string
 export const INVALID_CONFIGURATION_WIRE_ERROR = 'Invalid flags configuration wire format'
 
 export type ConfigurationWireContents = {
-  version: 1
+  version: 1 | 2
   precomputed?: {
     context?: EvaluationContext
     response: string
@@ -27,7 +27,12 @@ export function parseConfigurationWire(wire: FlagsConfigurationWire): Configurat
   } catch {
     return undefined
   }
-  if (typeof serialized !== 'object' || serialized === null || !('version' in serialized) || serialized.version !== 1) {
+  if (
+    typeof serialized !== 'object' ||
+    serialized === null ||
+    !('version' in serialized) ||
+    (serialized.version !== 1 && serialized.version !== 2)
+  ) {
     return undefined
   }
   return serialized as ConfigurationWireContents
diff --git a/packages/core/src/configuration/wire.test.ts b/packages/core/src/configuration/wire.test.ts
index 038596e7..b1252121 100644
--- a/packages/core/src/configuration/wire.test.ts
+++ b/packages/core/src/configuration/wire.test.ts
@@ -74,6 +74,43 @@ describe('configuration wire', () => {
     expect(restored).toEqual(configuration)
   })
 
+  it.each([false, true])('versions encoded portable snapshots (with rules: %s)', (includeRules) => {
+    const precomputed = configuration.precomputed!
+    const encoded: FlagsConfiguration = {
+      precomputed: {
+        ...precomputed,
+        response: {
+          data: {
+            attributes: {
+              ...precomputed.response.data.attributes,
+              obfuscated: true,
+              obfuscation: { scheme: 'flag-key-sha256-v1', salt: '0'.repeat(32) },
+              flags: { ['a'.repeat(64)]: precomputed.response.data.attributes.flags['my-flag'] },
+            },
+          },
+        },
+      },
+      ...(includeRules
+        ? configurationFromString(JSON.stringify({ version: 1, rules: { response: rulesResponse } }))
+        : {}),
+    }
+    const wire = configurationToString(encoded)
+    expect(JSON.parse(wire).version).toBe(2)
+    expect(configurationFromString(wire)).toEqual(encoded)
+    expect(configurationFromRulesString(wire).rules).toEqual(encoded.rules)
+
+    const precomputedWire = configurationToPrecomputedString(encoded)
+    expect(JSON.parse(precomputedWire).version).toBe(2)
+    expect(configurationFromPrecomputedString(precomputedWire)).toEqual({ precomputed: encoded.precomputed })
+  })
+
+  it('keeps plaintext and rules-only portable snapshots on version 1', () => {
+    expect(JSON.parse(configurationToString(configuration)).version).toBe(1)
+    expect(JSON.parse(configurationToPrecomputedString(configuration)).version).toBe(1)
+    const rules = configurationFromString(JSON.stringify({ version: 1, rules: { response: rulesResponse } }))
+    expect(JSON.parse(configurationToString(rules)).version).toBe(1)
+  })
+
   it('keeps flags readable after a round-trip', () => {
     const restored = configurationFromString(configurationToString(configuration))
 
@@ -117,7 +154,7 @@ describe('configuration wire', () => {
   })
 
   it('retains a configuration error for an unknown version', () => {
-    expect(configurationFromString(JSON.stringify({ version: 2 }))).toEqual({
+    expect(configurationFromString(JSON.stringify({ version: 3 }))).toEqual({
       configurationError: 'Invalid flags configuration wire format',
     })
   })
diff --git a/packages/core/src/evaluation/precomputed-evaluation.ts b/packages/core/src/evaluation/precomputed-evaluation.ts
index 1995df8e..5439fd1c 100644
--- a/packages/core/src/evaluation/precomputed-evaluation.ts
+++ b/packages/core/src/evaluation/precomputed-evaluation.ts
@@ -84,20 +84,18 @@ function evaluatePrecomputedFlag(
   const attributes = precomputed.response.data.attributes
   // Initial configurations and persistent caches can bypass the wire parser.
   const encoding = readFlagKeyObfuscation(attributes.obfuscated, attributes.obfuscation)
-  const lookup =
-    'error' in encoding
-      ? encoding
-      : encoding.encoding
-        ? encodePrecomputedFlagKey(flagKey, encoding.encoding)
-        : { key: flagKey }
-  if ('error' in lookup) {
+  if ('error' in encoding) {
     return {
       value: defaultValue,
       reason: 'ERROR',
       errorCode: 'PARSE_ERROR' as ErrorCode,
-      errorMessage: lookup.error,
+      errorMessage: encoding.error,
     }
   }
+  const lookup = encoding.encoding ? encodePrecomputedFlagKey(flagKey, encoding.encoding) : { key: flagKey }
+  if ('error' in lookup) {
+    return { value: defaultValue, reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' as ErrorCode }
+  }
   const flagError = precomputed.flagErrors ? getOwnProperty(precomputed.flagErrors, lookup.key) : undefined
   if (flagError) {
     return {
diff --git a/packages/core/test/evaluation/flag-key-obfuscation.spec.ts b/packages/core/test/evaluation/flag-key-obfuscation.spec.ts
index 34a8235e..be80f972 100644
--- a/packages/core/test/evaluation/flag-key-obfuscation.spec.ts
+++ b/packages/core/test/evaluation/flag-key-obfuscation.spec.ts
@@ -6,7 +6,9 @@ import {
   type FlagsConfiguration,
   parsePrecomputedConfigurationResponse,
 } from '../../src/configuration'
+import { encodePrecomputedFlagKey, type FlagKeyObfuscation } from '../../src/configuration/flag-key-obfuscation'
 import { evaluatePrecomputedConfiguration } from '../../src/evaluation/precomputed-evaluation'
+import * as sha256 from '../../src/evaluation/sha256'
 
 const salt = '000102030405060708090a0b0c0d0e0f'
 const context = { targetingKey: 'athlete-123' }
@@ -54,6 +56,71 @@ function hash(key: string, publicSalt = salt): string {
 }
 
 describe('precomputed flag-key obfuscation', () => {
+  afterEach(() => jest.restoreAllMocks())
+
+  it('caches repeated lookups separately for concurrent configurations', () => {
+    const configurations = [salt, 'f'.repeat(32)].map((publicSalt) =>
+      decode(
+        response(
+          { [hash('flag', publicSalt)]: assignment },
+          { obfuscated: true, obfuscation: { ...descriptor, salt: publicSalt } }
+        )
+      )
+    )
+    const digest = jest.spyOn(sha256, 'sha256Hex')
+    for (let iteration = 0; iteration < 3; iteration++) {
+      for (const configuration of configurations) {
+        expect(evaluatePrecomputedConfiguration(configuration, 'boolean', 'flag', false, context).value).toBe(true)
+      }
+    }
+    expect(digest).toHaveBeenCalledTimes(2)
+  })
+
+  it('does not reuse a lookup hash after a descriptor salt changes', () => {
+    const encoding: FlagKeyObfuscation = { scheme: 'flag-key-sha256-v1', salt }
+    for (const publicSalt of [salt, 'f'.repeat(32), salt]) {
+      encoding.salt = publicSalt
+      expect(encodePrecomputedFlagKey('flag', encoding)).toEqual({ key: hash('flag', publicSalt) })
+    }
+  })
+
+  it('bounds the lookup cache without changing evaluation results', () => {
+    const encoding: FlagKeyObfuscation = { scheme: 'flag-key-sha256-v1', salt }
+    const digest = jest.spyOn(sha256, 'sha256Hex')
+    for (let index = 0; index <= 1024; index++) {
+      expect(encodePrecomputedFlagKey(`flag-${index}`, encoding)).toEqual({ key: hash(`flag-${index}`) })
+    }
+    expect(encodePrecomputedFlagKey('flag-1024', encoding)).toEqual({ key: hash('flag-1024') })
+    expect(digest).toHaveBeenCalledTimes(1025)
+    expect(encodePrecomputedFlagKey('flag-0', encoding)).toEqual({ key: hash('flag-0') })
+    expect(digest).toHaveBeenCalledTimes(1026)
+  })
+
+  it.each([false, true])('accepts new flag keys and unknown fields (obfuscated: %s)', (obfuscated) => {
+    const lookupKey = (key: string) => (obfuscated ? hash(key) : key)
+    const payload = response(
+      {
+        [lookupKey('existing-flag')]: assignment,
+        [lookupKey('new-flag')]: { ...assignment, futureAssignmentField: { enabled: true } },
+        [lookupKey('unsupported-flag')]: { ...assignment, variationType: 'future-type' },
+      },
+      {
+        futureResponseField: ['new metadata'],
+        ...(obfuscated ? { obfuscated: true, obfuscation: { ...descriptor, futureEncodingField: true } } : {}),
+      }
+    )
+    const configuration = decode({ ...payload, futureEnvelopeField: true })
+    expect(configuration.precomputedError).toBeUndefined()
+    for (const key of ['existing-flag', 'new-flag']) {
+      expect(evaluatePrecomputedConfiguration(configuration, 'boolean', key, false, context).value).toBe(true)
+    }
+    expect(
+      evaluatePrecomputedConfiguration(configuration, 'boolean', 'unsupported-flag', false, context)
+    ).toMatchObject({
+      errorCode: 'PARSE_ERROR',
+    })
+  })
+
   it.each(vectors)('matches the edge digest for %j', (key, digest) => {
     const plain = decode(response({ [key]: assignment }))
     const encoded = decode(response({ [digest]: assignment }, { obfuscated: true, obfuscation: descriptor }))
@@ -187,9 +254,9 @@ describe('precomputed flag-key obfuscation', () => {
 
   it.each(['\ud800', '\udc00', 'a\ud800b'])('does not alias invalid Unicode to a replacement character: %j', (key) => {
     const encoded = decode(response({ [hash(key)]: assignment }, { obfuscated: true, obfuscation: descriptor }))
-    expect(evaluatePrecomputedConfiguration(encoded, 'boolean', key, false, context)).toMatchObject({
-      value: false,
-      reason: 'ERROR',
-    })
+    const plain = decode(response({ flag: assignment }))
+    expect(evaluatePrecomputedConfiguration(encoded, 'boolean', key, false, context)).toEqual(
+      evaluatePrecomputedConfiguration(plain, 'boolean', key, false, context)
+    )
   })
 })
diff --git a/test-app/tests/smoke.spec.ts b/test-app/tests/smoke.spec.ts
index b2e8c559..07b1b993 100644
--- a/test-app/tests/smoke.spec.ts
+++ b/test-app/tests/smoke.spec.ts
@@ -110,9 +110,9 @@ test('executes the packed precomputed entrypoint in Chromium', async ({ page })
 })
 
 // Use Node's independent SHA-256 implementation to produce the edge wire format.
-function assignmentPayload(salt?: string) {
+function assignmentPayload(salt?: string, booleanValue = true) {
   const flags = [
-    ['new-route-planner', 'boolean', true],
+    ['new-route-planner', 'boolean', booleanValue],
     ['café', 'string', 'visible-value'],
     ['number-flag', 'number', 12.5],
     ['object-flag', 'object', { nested: true }],
@@ -159,43 +159,69 @@ const expectedObfuscationResult = {
 
 test('negotiates obfuscation, rotates salts, and restores hashed assignments from IndexedDB', async ({ page }) => {
   let salt = '000102030405060708090a0b0c0d0e0f'
+  let booleanValue = true
+  const expectStoredSalt = async () => {
+    await expect
+      .poll(() =>
+        page.evaluate(
+          () =>
+            new Promise((resolve, reject) => {
+              const request = indexedDB.open('dd-flagging')
+              request.onerror = () => reject(request.error)
+              request.onsuccess = () => {
+                const db = request.result
+                if (!db.objectStoreNames.contains('configurations')) {
+                  db.close()
+                  resolve([])
+                  return
+                }
+                const read = db.transaction('configurations').objectStore('configurations').getAll()
+                read.onerror = () => {
+                  db.close()
+                  reject(read.error)
+                }
+                read.onsuccess = () => {
+                  const salts = read.result.map(
+                    (entry) => entry.precomputed?.response.data.attributes.obfuscation?.salt
+                  )
+                  db.close()
+                  resolve(salts)
+                }
+              }
+            })
+        )
+      )
+      .toEqual([salt])
+  }
   const requests: Record[] = []
   await page.route('**/assignments?*', async (route) => {
+    expect(route.request().headers()['x-dd-feature-flags-capabilities']).toBe('assignment-encoding-flag-key-256-v1')
     requests.push(route.request().postDataJSON())
-    await route.fulfill({ json: assignmentPayload(salt) })
+    await route.fulfill({ json: assignmentPayload(salt, booleanValue) })
   })
   expect(await runSmoke(page, '/obfuscation.html')).toEqual(expectedObfuscationResult)
+  await expectStoredSalt()
   salt = 'f'.repeat(32)
-  expect(await runSmoke(page, '/obfuscation.html')).toEqual(expectedObfuscationResult)
+  booleanValue = false
+  const refreshedResult = {
+    ...expectedObfuscationResult,
+    values: [false, ...expectedObfuscationResult.values.slice(1)],
+  }
+  expect(await runSmoke(page, '/obfuscation.html')).toEqual(refreshedResult)
   expect(requests).toHaveLength(2)
   for (const request of requests) {
+    expect(request).not.toHaveProperty('data.attributes.supported_capabilities')
     expect(request).toMatchObject({
       data: {
         attributes: {
           source: { sdk_name: 'browser', sdk_version: expectedSdkVersion },
-          supported_capabilities: { assignment_encodings: ['flag-key-sha256-v1'] },
         },
       },
     })
   }
-  await page.waitForFunction(async (expectedSalt) => {
-    return new Promise((resolve) => {
-      const request = indexedDB.open('dd-flagging')
-      request.onsuccess = () => {
-        const db = request.result
-        const read = db.transaction('configurations').objectStore('configurations').getAll()
-        read.onsuccess = () => {
-          const found = read.result.some(
-            (entry) => entry.precomputed?.response.data.attributes.obfuscation?.salt === expectedSalt
-          )
-          db.close()
-          resolve(found)
-        }
-      }
-    })
-  }, salt)
+  await expectStoredSalt()
   expect(await runSmoke(page, '/obfuscation.html?offline=1')).toEqual({
-    ...expectedObfuscationResult,
+    ...refreshedResult,
     status: 'STALE',
   })
   expect(requests).toHaveLength(2)

From 74de79b6bd184b73cd10da02b7531414de3b60b8 Mon Sep 17 00:00:00 2001
From: Leo Romanovsky 
Date: Tue, 6 Oct 2026 22:03:37 -0400
Subject: [PATCH 3/4] fix(browser): bound exposure caches and preserve proxy
 compatibility

---
 packages/browser/README.md                    |  14 ++-
 .../cache/chrome-storage-assignment-cache.ts  |   5 +-
 .../src/cache/chrome-storage-async-map.ts     |  54 ++++++++-
 packages/browser/src/cache/constants.ts       |   2 +
 .../cache/local-storage-assignment-shim.ts    |  22 +++-
 .../src/cache/simple-assignment-cache.ts      |  44 ++------
 .../src/openfeature/provider-tracking.ts      |   8 +-
 .../src/transport/fetchConfiguration.ts       |   6 +-
 .../cache/assignment-cache-factory.spec.ts    |   3 +-
 .../cache/exposure-cache-capacity.spec.ts     | 101 +++++++++++++++++
 .../browser/test/cache/exposure-cache.spec.ts |   1 +
 .../test/openfeature/core-tracking.spec.ts    | 105 +++++++++++-------
 .../test/openfeature/obfuscation.spec.ts      |  46 ++++++++
 .../test/transport/fetchConfiguration.spec.ts |  28 ++++-
 test-app/src/obfuscation.ts                   |   1 +
 15 files changed, 345 insertions(+), 95 deletions(-)
 create mode 100644 packages/browser/src/cache/constants.ts
 create mode 100644 packages/browser/test/cache/exposure-cache-capacity.spec.ts

diff --git a/packages/browser/README.md b/packages/browser/README.md
index 96c3efc4..c7b6a71f 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -149,10 +149,13 @@ evaluation context.
 
 ## Flag-key obfuscation
 
-The Precompute fetcher automatically sends
+Requests to the Datadog Precompute endpoint automatically send
 `X-DD-FEATURE-FLAGS-CAPABILITIES: assignment-encoding-flag-key-256-v1`.
 This declares support for the `flag-key-sha256-v1` response encoding.
 Datadog controls its server rollout.
+Requests through `flaggingProxy` do not send this header by default. Proxy owners
+can opt in through `customHeaders`. The proxy must forward the header and, for
+cross-origin requests, allow it in its CORS response before enabling it.
 The provider accepts both plaintext and obfuscated responses without an
 application configuration change.
 
@@ -167,9 +170,12 @@ Plaintext and rules-only snapshots keep version 1. New readers accept both versi
 This version applies to SDK serialization, not the Precompute API response.
 
 New IndexedDB writes use a separate cache key namespace for both response formats.
-Older SDKs cannot read these encoded entries. New SDKs can read legacy plaintext
+Older SDKs cannot read any entries in the new namespace, including plaintext.
+New SDKs can read legacy plaintext
 entries when the new namespace has no entry. A plaintext rollout rollback replaces
 the encoded entry in the new namespace. It does not update an older SDK's cache.
+A snapshot containing both encoded assignments and rules uses version 2.
+Older readers reject that entire snapshot, including its rules.
 
 Obfuscation removes readable flag-map keys. It is not encryption, authorization,
 or response signing. Values, variation names, allocation names, telemetry,
@@ -298,6 +304,10 @@ The application owns manually registered hooks: clearing client hooks or removin
 
 For precomputed assignments, both providers keep exposure deduplication across configuration refreshes. The existing exposure cache identifies the subject and its attributes, original flag key, allocation, variant, and assignment serial when present. A change to these fields can emit a new exposure. A changed response timestamp, salt, or unrelated flag does not. A changed value under the same variant and serial does not create a new exposure identity.
 
+Exposure caches retain up to 50,000 entries per scope, matching the Node provider's limit.
+The memory cache removes the least recently used entry. Persistent caches remove the oldest written entries.
+An evicted entry can produce another exposure. Storage failures do not prevent flag evaluation.
+
 For rules-based configurations, `DatadogCoreProvider` also includes the rules configuration identity. Changed rules allow new exposures without clearing application-managed hook state. Retrieval metadata (`fetchedAt` and `etag`) and the server's `createdAt` build timestamp do not change that identity. These fields remain available on the configuration and in its portable wire representation.
 
 Both the standalone exposure hook and `DatadogProvider` scope persistent exposure caches by telemetry site, client token, proxy URL, environment, application, service, and source. Scope values are hashed into the storage namespace; raw tokens are not stored in cache keys. Recreating a hook with the same scope retains deduplication, while another destination can emit its own exposures. Older unscoped cache entries are not reused, so upgrading can produce a one-time repeat exposure. Function-valued telemetry proxies use memory-only deduplication because their destination cannot be inferred reliably from the callback's identity.
diff --git a/packages/browser/src/cache/chrome-storage-assignment-cache.ts b/packages/browser/src/cache/chrome-storage-assignment-cache.ts
index c318fc84..aea7f88c 100644
--- a/packages/browser/src/cache/chrome-storage-assignment-cache.ts
+++ b/packages/browser/src/cache/chrome-storage-assignment-cache.ts
@@ -1,3 +1,4 @@
+import { addTelemetryDebug } from '@datadog/browser-core'
 import {
   type AssignmentCacheEntry,
   assignmentCacheKeyToString,
@@ -21,7 +22,9 @@ export default class ChromeStorageAssignmentCache implements BulkReadAssignmentC
   set(entry: AssignmentCacheEntry): void {
     // "fire-and-forget" - we intentionally don't wait for the promise to resolve
     // noinspection JSIgnoredPromiseFromCall
-    this.storage.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry))
+    this.storage.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry)).catch(() => {
+      addTelemetryDebug('Failed to persist an exposure cache entry')
+    })
   }
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
diff --git a/packages/browser/src/cache/chrome-storage-async-map.ts b/packages/browser/src/cache/chrome-storage-async-map.ts
index e3ba8d98..4c952e12 100644
--- a/packages/browser/src/cache/chrome-storage-async-map.ts
+++ b/packages/browser/src/cache/chrome-storage-async-map.ts
@@ -1,8 +1,11 @@
 import type { AsyncMap } from '@datadog/flagging-core'
+import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants'
 
 /** Chrome storage-backed {@link AsyncMap}. */
 export default class ChromeStorageAsyncMap implements AsyncMap {
   private readonly prefix: string
+  private keys: Set | undefined
+  private pendingOperation = Promise.resolve()
 
   constructor(
     private readonly storage: chrome.storage.StorageArea,
@@ -23,6 +26,18 @@ export default class ChromeStorageAsyncMap implements AsyncMap {
   }
 
   async entries(): Promise<{ [p: string]: T }> {
+    return this.run(async () => {
+      const entries = await this.readEntries()
+      this.keys = new Set(Object.keys(entries))
+      await this.trim()
+      for (const key of Object.keys(entries)) {
+        if (!this.keys.has(key)) delete entries[key]
+      }
+      return entries
+    })
+  }
+
+  private async readEntries(): Promise> {
     const entries = await this.storage.get>(null)
     const scopedEntries: Record = Object.create(null)
     for (const [key, value] of Object.entries(entries)) {
@@ -32,11 +47,44 @@ export default class ChromeStorageAsyncMap implements AsyncMap {
   }
 
   async set(key: string, value: T) {
-    await this.storage.set({ [this.prefix + key]: value })
+    return this.run(async () => {
+      this.keys ??= new Set(Object.keys(await this.readEntries()))
+      this.keys.delete(key)
+      this.keys.add(key)
+      // Evict before writing so a full store has space for the new entry.
+      await this.trim()
+      await this.storage.set({ [this.prefix + key]: value })
+    })
   }
 
   async clear() {
-    const keys = Object.keys(await this.entries()).map((key) => this.prefix + key)
-    if (keys.length) await this.storage.remove(keys)
+    return this.run(async () => {
+      const keys = Object.keys(await this.readEntries()).map((key) => this.prefix + key)
+      if (keys.length) await this.storage.remove(keys)
+      this.keys = new Set()
+    })
+  }
+
+  private async trim(): Promise {
+    const keys = this.keys!
+    const removed: string[] = []
+    for (const key of keys) {
+      if (keys.size - removed.length <= MAX_EXPOSURE_CACHE_ENTRIES) break
+      removed.push(key)
+    }
+    if (removed.length) {
+      await this.storage.remove(removed.map((key) => this.prefix + key))
+      for (const key of removed) keys.delete(key)
+    }
+  }
+
+  private run(operation: () => Promise): Promise {
+    // Serialize writes and eviction. A failed operation must not stop later writes.
+    const result = this.pendingOperation.then(operation)
+    this.pendingOperation = result.then(
+      () => undefined,
+      () => undefined
+    )
+    return result
   }
 }
diff --git a/packages/browser/src/cache/constants.ts b/packages/browser/src/cache/constants.ts
new file mode 100644
index 00000000..22f9f42f
--- /dev/null
+++ b/packages/browser/src/cache/constants.ts
@@ -0,0 +1,2 @@
+// Match the Node provider's exposure cache limit.
+export const MAX_EXPOSURE_CACHE_ENTRIES = 50_000
diff --git a/packages/browser/src/cache/local-storage-assignment-shim.ts b/packages/browser/src/cache/local-storage-assignment-shim.ts
index 280367ff..70c9e8e6 100644
--- a/packages/browser/src/cache/local-storage-assignment-shim.ts
+++ b/packages/browser/src/cache/local-storage-assignment-shim.ts
@@ -1,4 +1,6 @@
 // noinspection JSUnusedGlobalSymbols (methods are used by common repository)
+
+import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants'
 import { hasWindowLocalStorage } from './helpers'
 
 export class LocalStorageAssignmentShim {
@@ -49,12 +51,28 @@ export class LocalStorageAssignmentShim {
   }
 
   public set(key: string, value: string): this {
-    return this.setCache(this.getCache().set(key, value))
+    const cache = this.getCache()
+    cache.delete(key)
+    cache.set(key, value)
+    this.trim(cache)
+    return this.setCache(cache)
   }
 
   private getCache(): Map {
     const cache = window.localStorage.getItem(this.localStorageKey)
-    return cache ? new Map(JSON.parse(cache)) : new Map()
+    const entries: Map = cache ? new Map(JSON.parse(cache)) : new Map()
+    if (entries.size > MAX_EXPOSURE_CACHE_ENTRIES) {
+      this.trim(entries)
+      this.setCache(entries)
+    }
+    return entries
+  }
+
+  private trim(cache: Map): void {
+    for (const key of cache.keys()) {
+      if (cache.size <= MAX_EXPOSURE_CACHE_ENTRIES) break
+      cache.delete(key)
+    }
   }
 
   private setCache(cache: Map): this {
diff --git a/packages/browser/src/cache/simple-assignment-cache.ts b/packages/browser/src/cache/simple-assignment-cache.ts
index fb02f38d..bfcb0f9d 100644
--- a/packages/browser/src/cache/simple-assignment-cache.ts
+++ b/packages/browser/src/cache/simple-assignment-cache.ts
@@ -1,47 +1,25 @@
-import {
-  type AbstractAssignmentCache,
-  type AssignmentCacheEntry,
-  NonExpiringInMemoryAssignmentCache,
-} from '@datadog/flagging-core'
+import { LRUInMemoryAssignmentCache } from '@datadog/flagging-core'
 
+import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants'
 import type { BulkReadAssignmentCache, BulkWriteAssignmentCache } from './hybrid-assignment-cache'
 
-/** An {@link BulkWriteAssignmentCache} assignment cache backed by an in-memory {@link Map} */
-export default class SimpleAssignmentCache implements BulkWriteAssignmentCache, BulkReadAssignmentCache {
-  private readonly store: Map
-  private readonly cache: AbstractAssignmentCache>
-
+/** A bounded in-memory exposure cache, also used to serve persisted entries. */
+export default class SimpleAssignmentCache
+  extends LRUInMemoryAssignmentCache
+  implements BulkWriteAssignmentCache, BulkReadAssignmentCache
+{
   constructor() {
-    this.store = new Map()
-    this.cache = new NonExpiringInMemoryAssignmentCache(this.store)
-  }
-
-  init(): Promise {
-    return Promise.resolve()
-  }
-
-  set(key: AssignmentCacheEntry): void {
-    this.cache.set(key)
-  }
-
-  has(key: AssignmentCacheEntry): boolean {
-    return this.cache.has(key)
+    super(MAX_EXPOSURE_CACHE_ENTRIES)
   }
 
   setEntries(entries: [string, string][]): void {
-    const { store } = this
-    // it's important to call store.set() directly here because we want to set the raw entries into the cache, bypassing
-    // the AbstractAssignmentCache logic, which takes an AssignmentCacheKey instead.
+    // Load serialized entries through the same capacity limit as new exposures.
     entries.forEach(([key, value]) => {
-      store.set(key, value)
+      this.delegate.set(key, value)
     })
   }
 
   getEntries(): Promise<[string, string][]> {
-    return Promise.resolve(Array.from(this.cache.entries()))
-  }
-
-  clear(): void {
-    this.cache.clear()
+    return Promise.resolve(Array.from(this.entries()))
   }
 }
diff --git a/packages/browser/src/openfeature/provider-tracking.ts b/packages/browser/src/openfeature/provider-tracking.ts
index 956fafc5..b1b73c30 100644
--- a/packages/browser/src/openfeature/provider-tracking.ts
+++ b/packages/browser/src/openfeature/provider-tracking.ts
@@ -1,4 +1,3 @@
-import type { AssignmentCache } from '@datadog/flagging-core'
 import type { EvaluationContext, Hook, HookContext } from '@openfeature/web-sdk'
 import { createExposureCache } from '../cache/exposure-cache'
 import type { FlaggingTrackingConfiguration, FlaggingTrackingInitConfiguration } from '../domain/configuration'
@@ -8,9 +7,7 @@ import { createRumTrackingHook } from './rumIntegration'
 import type { DatadogTrackingHook, DatadogTrackingHooks } from './tracking'
 import { composeDatadogTrackingHooks, createTrackingHookController, runTrackingLifecycleOperation } from './tracking'
 
-export interface ProviderTracking extends DatadogTrackingHooks {
-  exposureCache?: AssignmentCache
-}
+export type ProviderTracking = DatadogTrackingHooks
 
 export function createProviderTracking({
   options,
@@ -33,10 +30,8 @@ export function createProviderTracking({
     trackingHooks.push(createTrackingHookController(() => createFlagEvalEVPHook(configuration)))
   }
 
-  let exposureCache: AssignmentCache | undefined
   if ((options.enableExposureLogging ?? enabledByDefault) && configuration) {
     const cache = createExposureCache(options, configuration)
-    exposureCache = cache
     trackingHooks.push(
       createTrackingHookController(async () => {
         await runTrackingLifecycleOperation(() => cache.init())
@@ -51,7 +46,6 @@ export function createProviderTracking({
     hooks: getTrackingContext
       ? tracking.hooks.map((hook) => withTrackingContext(hook, getTrackingContext))
       : tracking.hooks,
-    exposureCache,
   }
 }
 
diff --git a/packages/browser/src/transport/fetchConfiguration.ts b/packages/browser/src/transport/fetchConfiguration.ts
index c4a57111..00b35234 100644
--- a/packages/browser/src/transport/fetchConfiguration.ts
+++ b/packages/browser/src/transport/fetchConfiguration.ts
@@ -107,13 +107,15 @@ export function buildConfigurationHeaders(
 export async function fetchPrecomputedConfiguration(
   options: PrecomputedConfigurationFetchOptions
 ): Promise {
-  const url = buildConfigurationUrl(options, 'precomputed')
+  const requestOptions: ConfigurationRequestOptions = options
+  const url = buildConfigurationUrl(requestOptions, 'precomputed')
   const fetchedAt = timeStampNow()
   const defaultHeaders = buildConfigurationHeaders(
     options,
     {
       'Content-Type': 'application/vnd.api+json',
-      'X-DD-FEATURE-FLAGS-CAPABILITIES': capabilitiesHeader,
+      // Customer proxies can opt in through customHeaders after allowing it in CORS.
+      ...(!requestOptions.flaggingProxy && { 'X-DD-FEATURE-FLAGS-CAPABILITIES': capabilitiesHeader }),
     },
     'precomputed'
   )
diff --git a/packages/browser/test/cache/assignment-cache-factory.spec.ts b/packages/browser/test/cache/assignment-cache-factory.spec.ts
index 193adb6a..8e5e54c0 100644
--- a/packages/browser/test/cache/assignment-cache-factory.spec.ts
+++ b/packages/browser/test/cache/assignment-cache-factory.spec.ts
@@ -38,7 +38,7 @@ describe('AssignmentCacheFactory', () => {
     })
   })
 
-  it('should create a hybrid cache if chrome storage is available', () => {
+  it('should create a hybrid cache if chrome storage is available', async () => {
     const cache = assignmentCacheFactory({
       chromeStorage: mockChromeStorage,
       storageKeySuffix: 'foo',
@@ -52,6 +52,7 @@ describe('AssignmentCacheFactory', () => {
       variant: { key: 'qux' },
     }
     cache.set(exposureEvent)
+    await cache.init()
     expect(Object.keys(fakeStore)).toHaveLength(1)
   })
 
diff --git a/packages/browser/test/cache/exposure-cache-capacity.spec.ts b/packages/browser/test/cache/exposure-cache-capacity.spec.ts
new file mode 100644
index 00000000..756e77c0
--- /dev/null
+++ b/packages/browser/test/cache/exposure-cache-capacity.spec.ts
@@ -0,0 +1,101 @@
+import { assignmentCacheKeyToString, assignmentCacheValueToString, type ExposureEvent } from '@datadog/flagging-core'
+import ChromeStorageAsyncMap from '../../src/cache/chrome-storage-async-map'
+import { MAX_EXPOSURE_CACHE_ENTRIES } from '../../src/cache/constants'
+import { LocalStorageAssignmentShim } from '../../src/cache/local-storage-assignment-shim'
+import SimpleAssignmentCache from '../../src/cache/simple-assignment-cache'
+
+const exposure = (id: string): ExposureEvent => ({
+  subject: { id, attributes: {} },
+  flag: { key: 'flag' },
+  allocation: { key: 'allocation' },
+  variant: { key: 'control' },
+})
+const entries = (count = MAX_EXPOSURE_CACHE_ENTRIES): [string, string][] =>
+  Array.from({ length: count }, (_, index) => [`key-${index}`, 'value'])
+
+describe('exposure cache capacity', () => {
+  beforeEach(() => localStorage.clear())
+
+  it('uses the Node provider limit and evicts the least recently used memory entry', async () => {
+    expect(MAX_EXPOSURE_CACHE_ENTRIES).toBe(50_000)
+    const cache = new SimpleAssignmentCache()
+    const hot = exposure('hot')
+    cache.set(hot)
+    cache.setEntries(entries(MAX_EXPOSURE_CACHE_ENTRIES - 1))
+    expect(cache.has(hot)).toBe(true)
+    cache.set(exposure('new'))
+    const stored = new Map(await cache.getEntries())
+    expect(stored.size).toBe(MAX_EXPOSURE_CACHE_ENTRIES)
+    expect(stored.has('key-0')).toBe(false)
+    expect(cache.has(hot)).toBe(true)
+    expect(cache.has(exposure('new'))).toBe(true)
+  })
+
+  it('bounds imported memory entries and permits a new exposure after eviction', () => {
+    const cache = new SimpleAssignmentCache()
+    const old = exposure('old')
+    cache.setEntries([[assignmentCacheKeyToString(old), assignmentCacheValueToString(old)], ...entries()])
+    expect(cache.has(old)).toBe(false)
+    cache.set(old)
+    expect(cache.has(old)).toBe(true)
+  })
+
+  it('bounds localStorage on load and on write without changing another namespace', () => {
+    localStorage.setItem('datadog-assignment-bounded', JSON.stringify(entries(MAX_EXPOSURE_CACHE_ENTRIES + 1)))
+    localStorage.setItem('datadog-assignment-other', 'preserve')
+    const cache = new LocalStorageAssignmentShim('bounded')
+    expect(cache.has('key-0')).toBe(false)
+    cache.set('key-1', 'updated')
+    cache.set('new', 'value')
+    const stored = new Map(JSON.parse(localStorage.getItem('datadog-assignment-bounded')!))
+    expect(stored.size).toBe(MAX_EXPOSURE_CACHE_ENTRIES)
+    expect(stored.has('key-2')).toBe(false)
+    expect(stored.get('key-1')).toBe('updated')
+    expect(new LocalStorageAssignmentShim('bounded').has('new')).toBe(true)
+    expect(localStorage.getItem('datadog-assignment-other')).toBe('preserve')
+  })
+
+  function chromeCache(count: number) {
+    const stored: Record = Object.fromEntries(
+      entries(count).map(([key, value]) => [`bounded:${key}`, value])
+    )
+    stored['other:preserve'] = 'value'
+    const storage = {
+      get: jest.fn(async () => ({ ...stored })),
+      set: jest.fn(async (items: Record) => {
+        Object.assign(stored, items)
+      }),
+      remove: jest.fn(async (keys: string[]) => {
+        for (const key of keys) delete stored[key]
+      }),
+    }
+    const cache = new ChromeStorageAsyncMap(storage as unknown as chrome.storage.StorageArea, 'bounded')
+    return { stored, storage, cache }
+  }
+
+  it('bounds Chrome storage on load and serializes concurrent writes and eviction', async () => {
+    const { stored, cache } = chromeCache(MAX_EXPOSURE_CACHE_ENTRIES + 1)
+    expect(Object.keys(await cache.entries())).toHaveLength(MAX_EXPOSURE_CACHE_ENTRIES)
+    expect(stored['bounded:key-0']).toBeUndefined()
+    await Promise.all([cache.set('key-1', 'updated'), cache.set('new-1', 'value'), cache.set('new-2', 'value')])
+    expect(Object.keys(stored)).toHaveLength(MAX_EXPOSURE_CACHE_ENTRIES + 1)
+    expect(stored['bounded:key-1']).toBe('updated')
+    expect(stored['bounded:key-2']).toBeUndefined()
+    expect(stored['bounded:key-3']).toBeUndefined()
+    expect(stored['bounded:new-1']).toBe('value')
+    expect(stored['bounded:new-2']).toBe('value')
+    expect(stored['other:preserve']).toBe('value')
+  })
+
+  it('continues after a failed Chrome write and clears after queued writes', async () => {
+    const { stored, storage, cache } = chromeCache(0)
+    storage.set.mockRejectedValueOnce(new Error('quota'))
+    await expect(cache.set('failed', 'value')).rejects.toThrow('quota')
+    await cache.set('next', 'value')
+    expect(stored['bounded:next']).toBe('value')
+    const pendingWrite = cache.set('pending', 'value')
+    await cache.clear()
+    await pendingWrite
+    expect(stored).toEqual({ 'other:preserve': 'value' })
+  })
+})
diff --git a/packages/browser/test/cache/exposure-cache.spec.ts b/packages/browser/test/cache/exposure-cache.spec.ts
index 583c3ded..bf528ef9 100644
--- a/packages/browser/test/cache/exposure-cache.spec.ts
+++ b/packages/browser/test/cache/exposure-cache.spec.ts
@@ -59,6 +59,7 @@ describe('exposure cache storage boundaries', () => {
       const second = createCache('scope-b')
       first.set(exposure)
       second.set(exposure)
+      await Promise.all([first.init(), second.init()])
 
       const storageKeys = Object.keys(kind === 'chrome' ? stored : localStorage).filter((key) => key !== 'unrelated')
       const entrySuffix = kind === 'chrome' ? `:${assignmentCacheKeyToString(exposure)}` : ''
diff --git a/packages/browser/test/openfeature/core-tracking.spec.ts b/packages/browser/test/openfeature/core-tracking.spec.ts
index 02aab022..7444afb8 100644
--- a/packages/browser/test/openfeature/core-tracking.spec.ts
+++ b/packages/browser/test/openfeature/core-tracking.spec.ts
@@ -1,10 +1,5 @@
 import { getGlobalObject, INTAKE_SITE_STAGING } from '@datadog/browser-core'
-import {
-  assignmentCacheKeyToString,
-  assignmentCacheValueToString,
-  type ExposureEvent,
-  type FlagsConfiguration,
-} from '@datadog/flagging-core'
+import type { FlagsConfiguration } from '@datadog/flagging-core'
 import { timeStampNow } from '@datadog/js-core/time'
 import { OpenFeature } from '@openfeature/web-sdk'
 import type { DDRum } from '../../src/openfeature/rumIntegration'
@@ -344,53 +339,83 @@ describe('DatadogCoreProvider tracking', () => {
     expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(2)
   })
 
-  it('does not let legacy exposure cache entries suppress core provider exposures', async () => {
-    const staleExposure: ExposureEvent = {
-      allocation: { key: 'static-allocation' },
-      flag: { key: 'static-flag' },
-      variant: { key: 'static-variation' },
-      subject: { id: 'static-user', attributes: { plan: 'free' } },
-    }
-    const staleEntries = {
-      [assignmentCacheKeyToString(staleExposure)]: assignmentCacheValueToString(staleExposure),
-    }
-    let notifyReadStarted!: () => void
-    const readStarted = new Promise((resolve) => {
-      notifyReadStarted = resolve
-    })
-    let resolveInitialRead!: (entries: Record) => void
-    const storage = {
-      get: jest.fn(
-        () =>
-          new Promise>((resolve) => {
-            resolveInitialRead = resolve
-            notifyReadStarted()
-          })
-      ),
-      set: jest.fn().mockResolvedValue(undefined),
-      clear: jest.fn().mockResolvedValue(undefined),
-    } as unknown as chrome.storage.StorageArea
+  it('does not let persisted marker-less exposure entries suppress core provider exposures', async () => {
+    const persisted: Record = {}
     Object.defineProperty(globalThis, 'chrome', {
       configurable: true,
-      value: { storage: { local: storage } },
+      value: {
+        storage: {
+          local: {
+            get: jest.fn(async () => ({ ...persisted })),
+            set: jest.fn(async (items: Record) => {
+              Object.assign(persisted, items)
+            }),
+            remove: jest.fn(async (keys: string[]) => {
+              for (const storageKey of keys) delete persisted[storageKey]
+            }),
+          },
+        },
+      },
     })
 
-    const { trackingHooks } = createExposureOnlyTracking()
-    const trackingInitialization = trackingHooks.initialize()
-    await readStarted
-    resolveInitialRead(staleEntries)
-    await trackingInitialization
+    // Persist an entry in the format written without a core configuration marker.
+    const legacy = createExposureOnlyTracking().trackingHooks
+    await legacy.initialize()
+    legacy.hooks[0].after!(
+      { flagKey: 'static-flag', context: { targetingKey: 'static-user', plan: 'free' } } as never,
+      {
+        flagKey: 'static-flag',
+        value: 'static-value',
+        variant: 'static-variation',
+        flagMetadata: { allocationKey: 'static-allocation', doLog: true },
+      } as never
+    )
+    jest.advanceTimersByTime(31_000)
+    await legacy.shutdown()
+    expect(Object.keys(persisted)).toHaveLength(1)
 
+    const { trackingHooks } = createExposureOnlyTracking()
+    await trackingHooks.initialize()
     const provider = new DatadogCoreProvider()
     provider.setConfiguration(precomputedConfiguration)
     await OpenFeature.setProviderAndWait(DOMAIN, provider, { targetingKey: 'static-user', plan: 'free' })
-
     const client = OpenFeature.getClient(DOMAIN)
     client.addHooks(...trackingHooks.hooks)
     client.getStringValue('static-flag', 'default')
     jest.advanceTimersByTime(31_000)
 
-    expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(1)
+    expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(2)
+  })
+
+  it('keeps rules identities for contexts outside a mixed precomputed snapshot', async () => {
+    const { trackingHooks } = createExposureOnlyTracking()
+    await trackingHooks.initialize()
+    const rules = rulesConfiguration.rules!
+    const provider = new DatadogCoreProvider()
+    provider.setConfiguration({ ...precomputedConfiguration, rules })
+    await OpenFeature.setProviderAndWait(DOMAIN, provider, { targetingKey: 'rules-user', country: 'US' })
+    const client = OpenFeature.getClient(DOMAIN)
+    client.addHooks(...trackingHooks.hooks)
+
+    const first = client.getBooleanDetails('test-flag', false)
+    expect(first.reason).not.toBe('ERROR')
+    jest.advanceTimersByTime(31_000)
+
+    provider.setConfiguration({
+      ...precomputedConfiguration,
+      rules: {
+        ...rules,
+        response: {
+          ...rules.response,
+          flags: { ...rules.response.flags, 'another-flag': rules.response.flags['test-flag']! },
+        },
+      },
+    })
+    const changed = client.getBooleanDetails('test-flag', false)
+    expect(changed.variant).toBe(first.variant)
+    expect(changed.flagMetadata.__dd_core_configuration_id).not.toBe(first.flagMetadata.__dd_core_configuration_id)
+    jest.advanceTimersByTime(31_000)
+    expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(2)
   })
 
   function precomputedConfigurationWithCreatedAt(createdAt: string): FlagsConfiguration {
diff --git a/packages/browser/test/openfeature/obfuscation.spec.ts b/packages/browser/test/openfeature/obfuscation.spec.ts
index 638b0e62..1733faaf 100644
--- a/packages/browser/test/openfeature/obfuscation.spec.ts
+++ b/packages/browser/test/openfeature/obfuscation.spec.ts
@@ -344,4 +344,50 @@ describe('browser flag-key obfuscation', () => {
       stringify.mockRestore()
     }
   })
+
+  it('loads persisted exposure identities before reporting ready', async () => {
+    jest.useFakeTimers()
+    let resolveRead: ((entries: Record) => void) | undefined
+    const readStarted = new Promise((started) => {
+      Object.defineProperty(globalThis, 'chrome', {
+        configurable: true,
+        value: {
+          storage: {
+            local: {
+              get: jest.fn(
+                () =>
+                  new Promise>((resolve) => {
+                    resolveRead = resolve
+                    started()
+                  })
+              ),
+              set: jest.fn().mockResolvedValue(undefined),
+              remove: jest.fn().mockResolvedValue(undefined),
+            },
+          },
+        },
+      })
+    })
+    try {
+      const provider = new DatadogProvider({ ...options, enableExposureLogging: true })
+      let ready = false
+      const initialized = OpenFeature.setProviderAndWait(provider, context).then(() => {
+        ready = true
+      })
+      await readStarted
+      await jest.advanceTimersByTimeAsync(100)
+      expect(fetchMock.mock.calls.some(([url]) => String(url).includes('precompute-assignments'))).toBe(true)
+      expect(ready).toBe(false)
+
+      resolveRead!({})
+      await initialized
+      OpenFeature.getClient().getBooleanValue(key, false)
+      jest.advanceTimersByTime(31_000)
+      expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('exposures'))).toHaveLength(1)
+    } finally {
+      // Let provider shutdown finish if an assertion failed before the read resolved.
+      resolveRead?.({})
+      Reflect.deleteProperty(globalThis, 'chrome')
+    }
+  })
 })
diff --git a/packages/browser/test/transport/fetchConfiguration.spec.ts b/packages/browser/test/transport/fetchConfiguration.spec.ts
index 91703d19..7052ca62 100644
--- a/packages/browser/test/transport/fetchConfiguration.spec.ts
+++ b/packages/browser/test/transport/fetchConfiguration.spec.ts
@@ -194,6 +194,30 @@ describe('createFlagsConfigurationFetcher', () => {
   })
 
   describe('request headers', () => {
+    it('should declare capabilities to the Datadog edge', async () => {
+      const fetcher = createFlagsConfigurationFetcher({ ...baseConfig, site: 'datadoghq.com' })
+
+      await fetcher(mockContext)
+
+      expect(mockFetch.mock.calls[0][1].headers).toMatchObject({
+        'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
+      })
+    })
+
+    it('should not add a preflighted capabilities header to proxy requests unless configured', async () => {
+      const proxy = { ...baseConfig, flaggingProxy: 'https://proxy.example.com' }
+      await createFlagsConfigurationFetcher(proxy)(mockContext)
+      await createFlagsConfigurationFetcher({
+        ...proxy,
+        customHeaders: { 'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1' },
+      })(mockContext)
+
+      expect(mockFetch.mock.calls[0][1].headers).not.toHaveProperty('X-DD-FEATURE-FLAGS-CAPABILITIES')
+      expect(mockFetch.mock.calls[1][1].headers).toMatchObject({
+        'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
+      })
+    })
+
     it('should include default headers when not overwriting', async () => {
       const config = { ...baseConfig, flaggingProxy: 'https://proxy.example.com' }
       const fetcher = createFlagsConfigurationFetcher(config)
@@ -205,7 +229,6 @@ describe('createFlagsConfigurationFetcher', () => {
         expect.objectContaining({
           headers: {
             'Content-Type': 'application/vnd.api+json',
-            'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
             'dd-client-token': 'test-token',
             'dd-application-id': 'test-app-id',
           },
@@ -228,7 +251,6 @@ describe('createFlagsConfigurationFetcher', () => {
         expect.objectContaining({
           headers: {
             'Content-Type': 'application/vnd.api+json',
-            'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
           },
         })
       )
@@ -252,7 +274,6 @@ describe('createFlagsConfigurationFetcher', () => {
         expect.objectContaining({
           headers: {
             'Content-Type': 'application/vnd.api+json',
-            'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
             'dd-client-token': 'test-token',
             'dd-application-id': 'test-app-id',
             'X-Custom-Header': 'custom-value',
@@ -277,7 +298,6 @@ describe('createFlagsConfigurationFetcher', () => {
         expect.objectContaining({
           headers: {
             'Content-Type': 'application/vnd.api+json',
-            'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1',
             'dd-client-token': 'test-token',
           },
         })
diff --git a/test-app/src/obfuscation.ts b/test-app/src/obfuscation.ts
index 92f426ec..36ef52a6 100644
--- a/test-app/src/obfuscation.ts
+++ b/test-app/src/obfuscation.ts
@@ -7,6 +7,7 @@ async function run() {
     clientToken: 'obfuscation-smoke-token',
     env: 'test',
     flaggingProxy: new URL('/assignments', location.href).toString(),
+    customHeaders: { 'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1' },
     enableExposureLogging: false,
     enableFlagEvaluationTracking: false,
     enableRumFeatureFlagTracking: false,

From afa7d0057f2c557b2170f4f22eb73da30785e468 Mon Sep 17 00:00:00 2001
From: Leo Romanovsky 
Date: Tue, 6 Oct 2026 22:28:57 -0400
Subject: [PATCH 4/4] fix(browser): restore exposure resets on configuration
 changes

---
 packages/browser/README.md                    |   8 +-
 .../browser/src/openfeature/core-provider.ts  |  21 +-
 .../src/openfeature/provider-tracking.ts      |   8 +-
 packages/browser/src/openfeature/provider.ts  |  22 ++-
 .../test/openfeature/core-tracking.spec.ts    |   5 +-
 .../test/openfeature/exposures.spec.ts        |  10 +-
 .../test/openfeature/obfuscation.spec.ts      | 185 +++++++++++++++---
 7 files changed, 209 insertions(+), 50 deletions(-)

diff --git a/packages/browser/README.md b/packages/browser/README.md
index c7b6a71f..4aa285c3 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -302,7 +302,13 @@ await tracking.shutdown()
 
 The application owns manually registered hooks: clearing client hooks or removing `DatadogCoreProvider` does not shut down their resources. Unregister them and call `tracking.shutdown()` when they are no longer needed. The regular `DatadogProvider` shuts down its own tracking resources through OpenFeature's provider lifecycle.
 
-For precomputed assignments, both providers keep exposure deduplication across configuration refreshes. The existing exposure cache identifies the subject and its attributes, original flag key, allocation, variant, and assignment serial when present. A change to these fields can emit a new exposure. A changed response timestamp, salt, or unrelated flag does not. A changed value under the same variant and serial does not create a new exposure identity.
+Precomputed assignments retain the existing exposure-reset behavior for plaintext and obfuscated responses.
+On a refresh, `DatadogProvider` clears exposure deduplication when a previously loaded `createdAt` changes.
+It does not clear on the first fetch without an initial configuration.
+`DatadogCoreProvider` includes the configuration identity in exposure deduplication. Replacing the configuration,
+including a changed `createdAt` or obfuscation salt, permits another exposure.
+Reapplying the same configuration preserves deduplication. Neither provider emits an exposure until the application evaluates a flag.
+`createdAt` is a configuration timestamp, not an experiment revision. This behavior does not depend on it changing on every request.
 
 Exposure caches retain up to 50,000 entries per scope, matching the Node provider's limit.
 The memory cache removes the least recently used entry. Persistent caches remove the oldest written entries.
diff --git a/packages/browser/src/openfeature/core-provider.ts b/packages/browser/src/openfeature/core-provider.ts
index 7333b79b..9dc3e6b2 100644
--- a/packages/browser/src/openfeature/core-provider.ts
+++ b/packages/browser/src/openfeature/core-provider.ts
@@ -1,11 +1,5 @@
 import type { FlagsConfiguration, FlagTypeToValue } from '@datadog/flagging-core'
-import {
-  configMatchesContext,
-  evaluate,
-  type FlagsConfigurationError,
-  getFlagsConfigurationError,
-  getMD5Hash,
-} from '@datadog/flagging-core'
+import { evaluate, type FlagsConfigurationError, getFlagsConfigurationError, getMD5Hash } from '@datadog/flagging-core'
 import { configurationToString } from '@datadog/flagging-core/rules-based'
 import type {
   EvaluationContext,
@@ -78,15 +72,10 @@ export class DatadogCoreProvider extends DatadogProviderBase {
     context: EvaluationContext,
     logger: Logger
   ): ResolutionDetails> {
-    const details = evaluate(this.flagsConfiguration, type, flagKey, defaultValue, context, logger)
-    // Precomputed exposures already identify the subject, flag, allocation,
-    // variant, and serial. A delivery timestamp or salt is not a new assignment.
-    // Keep a stable marker to distinguish these entries from older cache formats.
-    const configurationId =
-      this.flagsConfiguration?.precomputed && configMatchesContext(this.flagsConfiguration, context)
-        ? 'precomputed'
-        : this.flagsConfigurationId
-    return withCoreConfigurationId(details, configurationId)
+    return withCoreConfigurationId(
+      evaluate(this.flagsConfiguration, type, flagKey, defaultValue, context, logger),
+      this.flagsConfigurationId
+    )
   }
 
   private canEvaluateCurrentContext(): boolean {
diff --git a/packages/browser/src/openfeature/provider-tracking.ts b/packages/browser/src/openfeature/provider-tracking.ts
index b1b73c30..b6c1b9c4 100644
--- a/packages/browser/src/openfeature/provider-tracking.ts
+++ b/packages/browser/src/openfeature/provider-tracking.ts
@@ -1,3 +1,4 @@
+import type { AssignmentCache } from '@datadog/flagging-core'
 import type { EvaluationContext, Hook, HookContext } from '@openfeature/web-sdk'
 import { createExposureCache } from '../cache/exposure-cache'
 import type { FlaggingTrackingConfiguration, FlaggingTrackingInitConfiguration } from '../domain/configuration'
@@ -7,7 +8,9 @@ import { createRumTrackingHook } from './rumIntegration'
 import type { DatadogTrackingHook, DatadogTrackingHooks } from './tracking'
 import { composeDatadogTrackingHooks, createTrackingHookController, runTrackingLifecycleOperation } from './tracking'
 
-export type ProviderTracking = DatadogTrackingHooks
+export interface ProviderTracking extends DatadogTrackingHooks {
+  exposureCache?: AssignmentCache
+}
 
 export function createProviderTracking({
   options,
@@ -30,8 +33,10 @@ export function createProviderTracking({
     trackingHooks.push(createTrackingHookController(() => createFlagEvalEVPHook(configuration)))
   }
 
+  let exposureCache: AssignmentCache | undefined
   if ((options.enableExposureLogging ?? enabledByDefault) && configuration) {
     const cache = createExposureCache(options, configuration)
+    exposureCache = cache
     trackingHooks.push(
       createTrackingHookController(async () => {
         await runTrackingLifecycleOperation(() => cache.init())
@@ -43,6 +48,7 @@ export function createProviderTracking({
   const tracking = composeDatadogTrackingHooks(...trackingHooks)
   return {
     ...tracking,
+    exposureCache,
     hooks: getTrackingContext
       ? tracking.hooks.map((hook) => withTrackingContext(hook, getTrackingContext))
       : tracking.hooks,
diff --git a/packages/browser/src/openfeature/provider.ts b/packages/browser/src/openfeature/provider.ts
index 65950c00..6b67d9a5 100644
--- a/packages/browser/src/openfeature/provider.ts
+++ b/packages/browser/src/openfeature/provider.ts
@@ -1,4 +1,5 @@
 import {
+  type AssignmentCache,
   configMatchesContext,
   evaluatePrecomputedConfiguration,
   type FlagsConfiguration,
@@ -76,6 +77,7 @@ export class DatadogProvider extends DatadogProviderBase {
   private flagsConfiguration: FlagsConfiguration | undefined
   private flagsCache: IndexedDBFlagsCache | undefined
 
+  private exposureCache: AssignmentCache | undefined
   private exposureCacheReady: Promise | undefined
   private readonly tracking: ProviderTracking
 
@@ -109,6 +111,7 @@ export class DatadogProvider extends DatadogProviderBase {
       getTrackingContext: () => this.evaluationContext,
     })
     this.hooks = this.tracking.hooks
+    this.exposureCache = this.tracking.exposureCache
 
     if (hasIndexedDB()) {
       this.flagsCache = new IndexedDBFlagsCache(options.clientToken)
@@ -158,9 +161,9 @@ export class DatadogProvider extends DatadogProviderBase {
     // `signal`, so we don't block OF SDK unnecessarily.
     this.latestContextUpdate = this.retrieveFlagsConfiguration(evaluationContext, { signal })
       .then((result) =>
-        // Load persisted exposure identities before publishing the configuration.
-        // Refetches do not clear them; assignment changes produce different entries.
-        waitWithAbort(signal, this.exposureCacheReady).then(
+        // Preserve the existing timestamp-based reset for experiment reporting.
+        // createdAt is a configuration timestamp, not an experiment revision.
+        this.maybeClearExposureCache(result.config, { signal }).then(
           () => result,
           // Ignore exposure cache errors. They should not prevent us from using the latest configuration.
           () => result
@@ -247,6 +250,19 @@ export class DatadogProvider extends DatadogProviderBase {
     }
   }
 
+  private async maybeClearExposureCache(
+    newFlagsConfiguration: FlagsConfiguration,
+    { signal }: { signal: AbortSignal }
+  ): Promise {
+    await waitWithAbort(signal, this.exposureCacheReady)
+
+    const prevCreatedAt = this.flagsConfiguration?.precomputed?.response.data.attributes.createdAt
+    const newCreatedAt = newFlagsConfiguration.precomputed?.response.data.attributes.createdAt
+    if (prevCreatedAt !== undefined && prevCreatedAt !== newCreatedAt) {
+      await waitWithAbort(signal, this.exposureCache?.clear())
+    }
+  }
+
   protected resolve(
     type: T,
     flagKey: string,
diff --git a/packages/browser/test/openfeature/core-tracking.spec.ts b/packages/browser/test/openfeature/core-tracking.spec.ts
index 7444afb8..900ad719 100644
--- a/packages/browser/test/openfeature/core-tracking.spec.ts
+++ b/packages/browser/test/openfeature/core-tracking.spec.ts
@@ -203,7 +203,7 @@ describe('DatadogCoreProvider tracking', () => {
     expect(fetchMock.mock.calls.some(([url]) => url.toString().includes('exposures'))).toBe(false)
   })
 
-  it('keeps exposures deduplicated when the precomputed response timestamp changes', async () => {
+  it('emits another exposure when the precomputed configuration timestamp changes', async () => {
     const { trackingHooks } = createExposureOnlyTracking()
     await trackingHooks.initialize()
 
@@ -217,10 +217,11 @@ describe('DatadogCoreProvider tracking', () => {
     jest.advanceTimersByTime(31_000)
 
     provider.setConfiguration(precomputedConfigurationWithCreatedAt('2026-07-07T00:00:00.000Z'))
+    expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(1)
     client.getStringValue('static-flag', 'default')
     jest.advanceTimersByTime(31_000)
 
-    expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(1)
+    expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(2)
   })
 
   it('keeps exposure deduplication when the core provider configuration identity is unchanged', async () => {
diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts
index b283c55e..0415a8d6 100644
--- a/packages/browser/test/openfeature/exposures.spec.ts
+++ b/packages/browser/test/openfeature/exposures.spec.ts
@@ -726,7 +726,7 @@ describe('Exposures End-to-End', () => {
       expect(secondExposureEvents[0].variant.key).toBe('variation-b')
     })
 
-    it('should keep exposure cache when only configuration createdAt changes', async () => {
+    it('should clear exposure cache when configuration createdAt changes', async () => {
       // Create two responses with different createdAt timestamps
       const firstResponse = {
         ...precomputedServerResponse,
@@ -785,14 +785,16 @@ describe('Exposures End-to-End', () => {
       // Verify first exposure was logged
       expect(getExposuresCalls()).toHaveLength(1)
 
-      // Fetch the same assignment with a new delivery timestamp.
+      // Fetch the same assignment with a changed configuration timestamp.
       await provider.onContextChange({}, { targetingKey: 'test-user-123', customAttribute: 'test-value' })
 
-      // The assignment has not changed, so do not log another exposure.
+      // Receiving configuration does not itself emit an exposure.
+      expect(getExposuresCalls()).toHaveLength(1)
+      // The next evaluation can emit another exposure despite identical assignment IDs.
       client.getStringValue('string-flag', 'default')
       triggerBatch()
 
-      expect(getExposuresCalls()).toHaveLength(1)
+      expect(getExposuresCalls()).toHaveLength(2)
     })
 
     it('should not clear exposure cache on initial page load', async () => {
diff --git a/packages/browser/test/openfeature/obfuscation.spec.ts b/packages/browser/test/openfeature/obfuscation.spec.ts
index 1733faaf..7e257ed1 100644
--- a/packages/browser/test/openfeature/obfuscation.spec.ts
+++ b/packages/browser/test/openfeature/obfuscation.spec.ts
@@ -1,6 +1,7 @@
 import { createHash } from 'node:crypto'
 import { getGlobalObject } from '@datadog/browser-core'
 import { OpenFeature, ProviderStatus } from '@openfeature/web-sdk'
+import HybridAssignmentCache from '../../src/cache/hybrid-assignment-cache'
 import { DatadogProvider } from '../../src/openfeature/provider'
 import type { DDRum } from '../../src/openfeature/rumIntegration'
 import {
@@ -233,9 +234,14 @@ describe('browser flag-key obfuscation', () => {
     }
   })
 
-  it.each(['online', 'portable'])('keeps %s exposures deduplicated across refreshes and restart', async (kind) => {
+  it.each([
+    ['online', ''],
+    ['online', salt],
+    ['portable', ''],
+    ['portable', salt],
+  ])('preserves %s exposure resets with initial salt "%s"', async (kind, initialSalt) => {
     jest.useFakeTimers()
-    let payload = response()
+    let payload = response(initialSalt)
     fetchMock.mockImplementation(async (url: string) =>
       url.includes('precompute-assignments') ? fetchResponse(payload) : { ok: true, status: 200 }
     )
@@ -274,27 +280,42 @@ describe('browser flag-key obfuscation', () => {
         )
 
     await install()
-    const first = client.getBooleanDetails(key, false)
-    jest.advanceTimersByTime(31_000)
-
-    // Real edge responses change both the salt and createdAt on each fetch.
-    for (const [index, publicSalt] of ['f'.repeat(32), '', salt].entries()) {
-      payload = response(publicSalt, true, `2026-09-30T00:0${index + 1}:00Z`)
-      await refresh()
-      expect(client.getBooleanDetails(key, false).value).toBe(first.value)
+    const evaluate = () => {
+      expect(client.getBooleanValue(key, false)).toBe(true)
       jest.advanceTimersByTime(31_000)
     }
+    evaluate()
+    evaluate()
     expect(exposureEvents()).toHaveLength(1)
 
-    // The exposure describes the assigned variant, not its delivered value.
-    payload = response(salt, false)
-    const assignment = Object.values(payload.data.attributes.flags)[0]
-    payload.data.attributes.flags['b'.repeat(64)] = { ...assignment, variationValue: true }
+    // createdAt may stay unchanged across requests. Reusing the same snapshot
+    // must preserve deduplication, including a portable serialization round trip.
     await refresh()
-    expect(client.getBooleanDetails(key, false).value).toBe(false)
-    jest.advanceTimersByTime(31_000)
+    evaluate()
     expect(exposureEvents()).toHaveLength(1)
 
+    // Timestamp changes permit another exposure with identical assignment IDs.
+    // This also applies to an older timestamp; it is not a monotonic revision.
+    for (const [index, createdAt] of ['2026-09-30T00:01:00Z', '2026-09-29T00:00:00Z'].entries()) {
+      payload = response(initialSalt, true, createdAt)
+      await refresh()
+      jest.advanceTimersByTime(31_000)
+      expect(exposureEvents()).toHaveLength(index + 1)
+      evaluate()
+      evaluate()
+      expect(exposureEvents()).toHaveLength(index + 2)
+    }
+
+    // Cover new salts and both rollout directions without changing assignments.
+    for (const [index, publicSalt] of ['f'.repeat(32), '', salt].entries()) {
+      payload = response(publicSalt, true, `2026-09-30T00:0${index + 2}:00Z`)
+      await refresh()
+      evaluate()
+      expect(exposureEvents()).toHaveLength(index + 4)
+    }
+
+    // Assignment identity changes still produce exposures with a stable timestamp.
+    const assignment = Object.values(payload.data.attributes.flags)[0]
     for (const [index, change] of [
       { variationKey: 'variant-2' },
       { allocationKey: 'allocation-2' },
@@ -302,11 +323,10 @@ describe('browser flag-key obfuscation', () => {
     ].entries()) {
       Object.assign(assignment, change)
       await refresh()
-      client.getBooleanValue(key, true)
-      jest.advanceTimersByTime(31_000)
-      expect(exposureEvents()).toHaveLength(index + 2)
+      evaluate()
+      expect(exposureEvents()).toHaveLength(index + 7)
     }
-    expect(exposureEvents()[3]).toMatchObject({
+    expect(exposureEvents()[8]).toMatchObject({
       flag: { key },
       allocation: { key: 'allocation-2' },
       variant: { key: 'variant-2' },
@@ -318,13 +338,132 @@ describe('browser flag-key obfuscation', () => {
     await tracking?.shutdown()
     await OpenFeature.clearProviders()
     await install()
-    client.getBooleanValue(key, true)
-    jest.advanceTimersByTime(31_000)
-    expect(exposureEvents()).toHaveLength(4)
+    evaluate()
+    expect(exposureEvents()).toHaveLength(9)
     client.clearHooks()
     await tracking?.shutdown()
   })
 
+  it.each(['', salt])('preserves first-load behavior with initial salt "%s"', async (publicSalt) => {
+    jest.useFakeTimers()
+    let payload = response(publicSalt)
+    fetchMock.mockImplementation(async (url: string) =>
+      url.includes('precompute-assignments') ? fetchResponse(payload) : { ok: true, status: 200 }
+    )
+    const providerOptions = { ...options, enableExposureLogging: true }
+    const exposures = () => fetchMock.mock.calls.filter(([url]) => String(url).includes('exposures'))
+    await OpenFeature.setProviderAndWait(new DatadogProvider(providerOptions), context)
+    OpenFeature.getClient().getBooleanValue(key, false)
+    jest.advanceTimersByTime(31_000)
+    expect(exposures()).toHaveLength(1)
+    await OpenFeature.clearProviders()
+
+    // Without an initial configuration, first fetch must retain persisted deduplication,
+    // even when the server timestamp has changed since the previous page load.
+    payload = response(publicSalt, true, '2026-09-30T00:01:00Z')
+    await OpenFeature.setProviderAndWait(new DatadogProvider(providerOptions), context)
+    OpenFeature.getClient().getBooleanValue(key, false)
+    jest.advanceTimersByTime(31_000)
+    expect(exposures()).toHaveLength(1)
+    await OpenFeature.clearProviders()
+
+    // An explicitly supplied initial configuration restores the normal comparison.
+    const initialFlagsConfiguration = await fetchPrecomputedConfiguration({ ...options, context })
+    payload = response(publicSalt, true, '2026-09-30T00:02:00Z')
+    await OpenFeature.setProviderAndWait(
+      new DatadogProvider({ ...providerOptions, initialFlagsConfiguration }),
+      context
+    )
+    jest.advanceTimersByTime(31_000)
+    expect(exposures()).toHaveLength(1)
+    OpenFeature.getClient().getBooleanValue(key, false)
+    jest.advanceTimersByTime(31_000)
+    expect(exposures()).toHaveLength(2)
+  })
+
+  it.each(['', salt])('keeps configuration usable when clearing exposures fails, salt "%s"', async (publicSalt) => {
+    let payload = response(publicSalt)
+    fetchMock.mockImplementation(async (url: string) =>
+      url.includes('precompute-assignments') ? fetchResponse(payload) : { ok: true, status: 200 }
+    )
+    const provider = new DatadogProvider({ ...options, enableExposureLogging: true })
+    const clear = jest
+      .spyOn(HybridAssignmentCache.prototype, 'clear')
+      .mockRejectedValue(new Error('storage unavailable'))
+    try {
+      await provider.initialize(context)
+      expect(clear).not.toHaveBeenCalled()
+      payload = response(publicSalt, false, '2026-09-30T00:01:00Z')
+      await provider.onContextChange(context, context)
+      expect(clear).toHaveBeenCalledTimes(1)
+      expect(provider.status).toBe(ProviderStatus.READY)
+      expect(provider.resolveBooleanEvaluation(key, true, context, logger).value).toBe(false)
+    } finally {
+      clear.mockRestore()
+      await provider.onClose()
+    }
+  })
+
+  it.each(['', salt])('waits for exposure clearing before publishing configuration, salt "%s"', async (publicSalt) => {
+    let payload = response(publicSalt)
+    fetchMock.mockImplementation(async (url: string) =>
+      url.includes('precompute-assignments') ? fetchResponse(payload) : { ok: true, status: 200 }
+    )
+    const provider = new DatadogProvider({ ...options, enableExposureLogging: true })
+    await provider.initialize(context)
+    let finishClear!: () => void
+    let startClear!: () => void
+    const clearingStarted = new Promise((resolve) => {
+      startClear = resolve
+    })
+    const clearingFinished = new Promise((resolve) => {
+      finishClear = resolve
+    })
+    const clear = jest.spyOn(HybridAssignmentCache.prototype, 'clear').mockImplementation(() => {
+      startClear()
+      return clearingFinished
+    })
+    let refreshed = false
+    payload = response(publicSalt, false, '2026-09-30T00:01:00Z')
+    const refresh = provider.onContextChange(context, context).then(() => {
+      refreshed = true
+    })
+    try {
+      await clearingStarted
+      // Drain the update chain so an unawaited clear cannot pass this assertion.
+      await new Promise((resolve) => setTimeout(resolve, 0))
+      expect(refreshed).toBe(false)
+      expect(provider.resolveBooleanEvaluation(key, false, context, logger).value).toBe(true)
+      finishClear()
+      await refresh
+      expect(provider.status).toBe(ProviderStatus.READY)
+      expect(provider.resolveBooleanEvaluation(key, true, context, logger).value).toBe(false)
+    } finally {
+      finishClear()
+      await refresh
+      clear.mockRestore()
+      await provider.onClose()
+    }
+  })
+
+  it.each(['', salt])('refreshes without an exposure cache when logging is disabled, salt "%s"', async (publicSalt) => {
+    fetchMock.mockResolvedValue(fetchResponse(response(publicSalt)))
+    const provider = new DatadogProvider(options)
+    const clear = jest.spyOn(HybridAssignmentCache.prototype, 'clear')
+    try {
+      await provider.initialize(context)
+      fetchMock.mockResolvedValue(fetchResponse(response(publicSalt, false, '2026-09-30T00:01:00Z')))
+      await provider.onContextChange(context, context)
+      expect(provider.status).toBe(ProviderStatus.READY)
+      expect(provider.resolveBooleanEvaluation(key, true, context, logger).value).toBe(false)
+      expect(clear).not.toHaveBeenCalled()
+      expect(localStorage.length).toBe(0)
+    } finally {
+      clear.mockRestore()
+      await provider.onClose()
+    }
+  })
+
   it('does not serialize assignment values on repeated portable-provider evaluations', async () => {
     const configuration = await fetchPrecomputedConfiguration({ ...options, context })
     const flag = Object.values(configuration.precomputed!.response.data.attributes.flags)[0]