diff --git a/lib/core/decision_service/index.spec.ts b/lib/core/decision_service/index.spec.ts index 90aa5016e..d3b405284 100644 --- a/lib/core/decision_service/index.spec.ts +++ b/lib/core/decision_service/index.spec.ts @@ -2882,9 +2882,12 @@ describe('DecisionService', () => { describe('local holdouts', () => { // Helper: build a datafile that has a local holdout targeting a specific experiment or delivery rule. + // Per FSSDK-12760, local holdouts live in the top-level `localHoldouts` section + // (separate from `holdouts`, which now only carries global holdouts). const makeLocalHoldoutDatafile = (targetRuleId: string, ruleIds: string[] = [targetRuleId]) => { const datafile = getDecisionTestDatafile(); - (datafile as any).holdouts = [ + (datafile as any).holdouts = []; + (datafile as any).localHoldouts = [ { id: 'local_holdout_id', key: 'local_holdout', diff --git a/lib/project_config/project_config.spec.ts b/lib/project_config/project_config.spec.ts index 7eee6a096..8dc8ed73c 100644 --- a/lib/project_config/project_config.spec.ts +++ b/lib/project_config/project_config.spec.ts @@ -416,12 +416,12 @@ describe('createProjectConfig - holdouts', () => { }); }); -// Level 1 tests for local holdouts (FSSDK-12369): isGlobal, getGlobalHoldouts, getHoldoutsForRule -describe('createProjectConfig - local holdouts (FSSDK-12369)', () => { - const makeLocalHoldoutsDatafile = () => { +// Level 1 tests for local holdouts (FSSDK-12369, FSSDK-12760). +// Section membership (`holdouts` vs `localHoldouts`) is the sole signal for scope. +describe('createProjectConfig - local holdouts (FSSDK-12369, FSSDK-12760)', () => { + const makeHoldoutsDatafile = () => { const datafile = testDatafile.getTestDecideProjectConfig(); - // Holdout with no includedRules → global - // Holdout with includedRules array → local + // Entries in `holdouts` are global by section membership. (datafile as any).holdouts = [ { id: 'global_holdout_id', @@ -433,8 +433,10 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => { audienceConditions: [], variations: [{ id: 'global_var_id', key: 'global_var', variables: [] }], trafficAllocation: [{ entityId: 'global_var_id', endOfRange: 5000 }], - // no includedRules → isGlobal should be true }, + ]; + // Entries in `localHoldouts` are local; scoped via includedRules. + (datafile as any).localHoldouts = [ { id: 'local_holdout_rule_a_id', key: 'local_holdout_rule_a', @@ -447,70 +449,32 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => { variations: [{ id: 'local_var_id', key: 'local_var', variables: [] }], trafficAllocation: [{ entityId: 'local_var_id', endOfRange: 5000 }], }, - { - id: 'empty_local_holdout_id', - key: 'empty_local_holdout', - status: 'Running', - includedFlags: [], - excludedFlags: [], - audienceIds: [], - audienceConditions: [], - includedRules: [], // empty array → local holdout (NOT global) - variations: [{ id: 'empty_var_id', key: 'empty_var', variables: [] }], - trafficAllocation: [{ entityId: 'empty_var_id', endOfRange: 5000 }], - }, - { - id: 'null_included_rules_id', - key: 'null_included_rules', - status: 'Running', - includedFlags: [], - excludedFlags: [], - audienceIds: [], - audienceConditions: [], - includedRules: null, // explicit null → global holdout - variations: [{ id: 'null_var_id', key: 'null_var', variables: [] }], - trafficAllocation: [{ entityId: 'null_var_id', endOfRange: 5000 }], - }, ]; return datafile; }; - it('should set isGlobal=true for holdout with no includedRules field (backward compat with old datafiles)', () => { - const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any); + it('should set isGlobal=true for entries in the holdouts section (backward compat with old datafiles)', () => { + const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any); const holdout = config.holdoutIdMap!['global_holdout_id']; expect(holdout.isGlobal).toBe(true); }); - it('should set isGlobal=false for holdout with includedRules array', () => { - const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any); + it('should set isGlobal=false for entries in the localHoldouts section', () => { + const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any); const holdout = config.holdoutIdMap!['local_holdout_rule_a_id']; expect(holdout.isGlobal).toBe(false); }); - it('should set isGlobal=false for holdout with empty includedRules array (empty array is still local)', () => { - const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any); - const holdout = config.holdoutIdMap!['empty_local_holdout_id']; - expect(holdout.isGlobal).toBe(false); - }); - - it('should set isGlobal=true for holdout with explicit null includedRules', () => { - const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any); - const holdout = config.holdoutIdMap!['null_included_rules_id']; - expect(holdout.isGlobal).toBe(true); - }); - - it('getGlobalHoldouts should return only holdouts with isGlobal=true', () => { - const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any); + it('getGlobalHoldouts should return only entries from the holdouts section', () => { + const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any); const globals = getGlobalHoldouts(config); const globalIds = globals.map(h => h.id); expect(globalIds).toContain('global_holdout_id'); - expect(globalIds).toContain('null_included_rules_id'); expect(globalIds).not.toContain('local_holdout_rule_a_id'); - expect(globalIds).not.toContain('empty_local_holdout_id'); }); it('getHoldoutsForRule should return local holdouts targeting the given rule ID', () => { - const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any); + const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any); const forRuleA = getHoldoutsForRule(config, 'rule_a'); expect(forRuleA).toHaveLength(1); expect(forRuleA[0].id).toBe('local_holdout_rule_a_id'); @@ -521,14 +485,15 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => { }); it('getHoldoutsForRule should return empty array for an unknown rule ID', () => { - const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any); + const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any); const forUnknown = getHoldoutsForRule(config, 'nonexistent_rule'); expect(forUnknown).toHaveLength(0); }); - it('getGlobalHoldouts should return empty array when all holdouts are local', () => { - const datafile = cloneDeep(makeLocalHoldoutsDatafile()); - (datafile as any).holdouts = [ + it('getGlobalHoldouts should return empty array when only the localHoldouts section is populated', () => { + const datafile = cloneDeep(makeHoldoutsDatafile()); + (datafile as any).holdouts = []; + (datafile as any).localHoldouts = [ { id: 'only_local_id', key: 'only_local', @@ -554,7 +519,7 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => { }); it('a single local holdout targeting multiple rules should appear for each targeted rule', () => { - const config = projectConfig.createProjectConfig(cloneDeep(makeLocalHoldoutsDatafile()) as any); + const config = projectConfig.createProjectConfig(cloneDeep(makeHoldoutsDatafile()) as any); const forRuleA = getHoldoutsForRule(config, 'rule_a'); const forRuleB = getHoldoutsForRule(config, 'rule_b'); // Both rule_a and rule_b point to the same holdout @@ -563,6 +528,163 @@ describe('createProjectConfig - local holdouts (FSSDK-12369)', () => { }); }); +// Level 1 tests for the FSSDK-12760 backward-compatible localHoldouts section design. +// Older SDKs (Gen 1/Gen 2) ignore the unknown `localHoldouts` top-level key entirely. +// Gen 3 SDKs (this one) treat section membership as the sole signal for scope. +describe('createProjectConfig - localHoldouts section (FSSDK-12760)', () => { + const makeBaseDatafile = () => testDatafile.getTestDecideProjectConfig(); + + const makeLocal = (id: string, key: string, includedRules: any) => ({ + id, + key, + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + includedRules, + variations: [{ id: `${id}_var`, key: 'holdout', variables: [] }], + trafficAllocation: [{ entityId: `${id}_var`, endOfRange: 10000 }], + }); + + const makeGlobal = (id: string, key: string, extra: Record = {}) => ({ + id, + key, + status: 'Running', + includedFlags: [], + excludedFlags: [], + audienceIds: [], + audienceConditions: [], + variations: [{ id: `${id}_var`, key: 'holdout', variables: [] }], + trafficAllocation: [{ entityId: `${id}_var`, endOfRange: 10000 }], + ...extra, + }); + + it('exposes localHoldouts as a top-level array on the project config', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = []; + datafile.localHoldouts = [makeLocal('l1', 'local_h', ['rule_x'])]; + const config = projectConfig.createProjectConfig(datafile); + expect(Array.isArray(config.localHoldouts)).toBe(true); + expect(config.localHoldouts).toHaveLength(1); + expect(config.localHoldouts[0].id).toBe('l1'); + }); + + it('defaults localHoldouts to an empty array when the section is absent (backward compat)', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = [makeGlobal('g1', 'global_h')]; + // No localHoldouts key at all + const config = projectConfig.createProjectConfig(datafile); + expect(config.localHoldouts).toEqual([]); + expect(getGlobalHoldouts(config).map(h => h.id)).toContain('g1'); + expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]); + }); + + it('ignores includedRules on entries in the global holdouts section', () => { + // An includedRules field on a `holdouts` entry must NOT narrow its scope — + // section membership is the sole signal for scope. + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = [ + makeGlobal('stray', 'stray_global', { includedRules: ['rule_should_be_ignored'] }), + ]; + datafile.localHoldouts = []; + const config = projectConfig.createProjectConfig(datafile); + + const stray = config.holdoutIdMap!['stray']; + // includedRules must be stripped at parse time + expect(stray.includedRules).toBeUndefined(); + // Entity is global + expect(stray.isGlobal).toBe(true); + // It appears in global list and NOT in the rule map + expect(getGlobalHoldouts(config).map(h => h.id)).toContain('stray'); + expect(getHoldoutsForRule(config, 'rule_should_be_ignored')).toEqual([]); + }); + + it('does not mutate the caller-provided datafile when stripping includedRules', () => { + // Regression guard: parsing a datafile must not bleed mutations into the + // caller's reference (some hosts re-use the same datafile object). + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = [ + makeGlobal('stray', 'stray_global', { includedRules: ['rule_x'] }), + ]; + projectConfig.createProjectConfig(datafile); + // Original datafile still has includedRules on the entry + expect(datafile.holdouts[0].includedRules).toEqual(['rule_x']); + }); + + it('partitions both sections correctly when both are present', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = [makeGlobal('g1', 'g'), makeGlobal('g2', 'g2')]; + datafile.localHoldouts = [ + makeLocal('l1', 'l1', ['rule_a']), + makeLocal('l2', 'l2', ['rule_b']), + ]; + const config = projectConfig.createProjectConfig(datafile); + + expect(getGlobalHoldouts(config).map(h => h.id).sort()).toEqual(['g1', 'g2']); + expect(getHoldoutsForRule(config, 'rule_a').map(h => h.id)).toEqual(['l1']); + expect(getHoldoutsForRule(config, 'rule_b').map(h => h.id)).toEqual(['l2']); + // ID map covers both sections + expect(config.holdoutIdMap!['g1']).toBeDefined(); + expect(config.holdoutIdMap!['l1']).toBeDefined(); + }); + + it('logs an error and excludes localHoldouts entries with no includedRules', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = []; + // Invalid: no includedRules at all + const invalid: any = makeLocal('bad', 'invalid_local', undefined); + delete invalid.includedRules; + datafile.localHoldouts = [invalid]; + + const config = projectConfig.createProjectConfig(datafile, null, logger); + + expect(getGlobalHoldouts(config)).toEqual([]); + expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]); + expect(config.holdoutIdMap!['bad']).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringMatching(/invalid_local.*includedRules/i) + ); + }); + + it('logs an error and excludes localHoldouts entries with null includedRules', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = []; + datafile.localHoldouts = [makeLocal('bad_null', 'null_local', null)]; + + const config = projectConfig.createProjectConfig(datafile, null, logger); + + expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]); + expect(config.holdoutIdMap!['bad_null']).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringMatching(/null_local.*includedRules/i) + ); + }); + + it('logs an error and excludes localHoldouts entries with empty includedRules', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = []; + datafile.localHoldouts = [makeLocal('bad_empty', 'empty_local', [])]; + + const config = projectConfig.createProjectConfig(datafile, null, logger); + + expect(getHoldoutsForRule(config, 'any_rule')).toEqual([]); + expect(config.holdoutIdMap!['bad_empty']).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringMatching(/empty_local.*includedRules/i) + ); + }); + + it('keeps holdout variations in the variationIdMap from both sections', () => { + const datafile = cloneDeep(makeBaseDatafile()) as any; + datafile.holdouts = [makeGlobal('g1', 'g')]; + datafile.localHoldouts = [makeLocal('l1', 'l', ['rule_x'])]; + const config = projectConfig.createProjectConfig(datafile); + expect(config.variationIdMap['g1_var']).toBeDefined(); + expect(config.variationIdMap['l1_var']).toBeDefined(); + }); +}); + describe('getExperimentId', () => { let testData: Record; let configObj: ProjectConfig; diff --git a/lib/project_config/project_config.ts b/lib/project_config/project_config.ts index 9401e8a66..76aebc880 100644 --- a/lib/project_config/project_config.ts +++ b/lib/project_config/project_config.ts @@ -112,6 +112,11 @@ export interface ProjectConfig { integrationKeyMap?: { [key: string]: Integration }; odpIntegrationConfig: OdpIntegrationConfig; holdouts: Holdout[]; + /** + * Local (rule-scoped) holdouts parsed from the top-level `localHoldouts` + * datafile section. Absent in older datafiles — defaults to an empty array. + */ + localHoldouts: Holdout[]; holdoutIdMap?: { [id: string]: Holdout }; holdoutConfig?: HoldoutConfig; } @@ -119,9 +124,13 @@ export interface ProjectConfig { /** * Holds pre-computed holdout lookup structures built during config parsing. * Stored as plain data (no methods) to be serializable and equality-safe. + * + * Scope is determined by datafile section membership: entries from the top-level + * `holdouts` section are global; entries from the top-level `localHoldouts` + * section are local (rule-scoped via `includedRules`). */ export interface HoldoutConfig { - /** All holdouts whose includedRules is null or undefined (global holdouts). */ + /** Holdouts parsed from the top-level `holdouts` datafile section (global). */ global: Holdout[]; /** Maps a rule ID to the local holdouts that target it. */ ruleHoldoutsMap: { [ruleId: string]: Holdout[] }; @@ -158,6 +167,16 @@ function createMutationSafeDatafileCopy(datafile: any): ProjectConfig { return rolloutCopy; }); + // Shallow-copy each holdout entry so per-entry mutations (e.g. stripping + // `includedRules` from global-section entries) don't bleed into the caller's + // datafile object. + datafileCopy.holdouts = (datafile.holdouts || []).map((holdout: Holdout) => { + return { ...holdout }; + }); + datafileCopy.localHoldouts = (datafile.localHoldouts || []).map((holdout: Holdout) => { + return { ...holdout }; + }); + datafileCopy.environmentKey = datafile.environmentKey ?? ''; datafileCopy.sdkKey = datafile.sdkKey ?? ''; @@ -168,9 +187,15 @@ function createMutationSafeDatafileCopy(datafile: any): ProjectConfig { * Creates projectConfig object to be used for quick project property lookup * @param {Object} datafileObj JSON datafile representing the project * @param {string|null} datafileStr JSON string representation of the datafile + * @param {LoggerFacade} logger Optional logger for parse-time diagnostics + * (e.g. invalid local-holdout entries). * @return {ProjectConfig} Object representing project configuration */ -export const createProjectConfig = function(datafileObj?: JSON, datafileStr: string | null = null): ProjectConfig { +export const createProjectConfig = function( + datafileObj?: JSON, + datafileStr: string | null = null, + logger?: LoggerFacade +): ProjectConfig { const projectConfig = createMutationSafeDatafileCopy(datafileObj); if (!projectConfig.region) { @@ -368,7 +393,7 @@ export const createProjectConfig = function(datafileObj?: JSON, datafileStr: str projectConfig.flagVariationsMap[flagKey] = variations; }); - parseHoldoutsConfig(projectConfig); + parseHoldoutsConfig(projectConfig, logger); return projectConfig; }; @@ -395,15 +420,31 @@ const getEveryoneElseVariation = function( return everyoneElseRule.variations[0]; }; -const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => { +/** + * Parse holdouts from the two top-level datafile sections. + * + * Two top-level sections drive holdout scoping (Gen 3+): + * - `holdouts` → ALL entries are global holdouts (applied to every flag). + * Any `includedRules` field on these entries is IGNORED; + * section membership alone determines scope. + * - `localHoldouts` → ALL entries are local holdouts (rule-scoped via + * `includedRules`). Entries missing/with empty `includedRules` + * are invalid and skipped with an error log. + * + * Backward compatibility: older datafiles that only emit the `holdouts` section + * continue to work — every entry is treated as global, matching pre-localHoldouts + * behavior. The `localHoldouts` key is simply absent and parsed as an empty list. + */ +const parseHoldoutsConfig = (projectConfig: ProjectConfig, logger?: LoggerFacade): void => { projectConfig.holdouts = projectConfig.holdouts || []; - projectConfig.holdoutIdMap = keyBy(projectConfig.holdouts, 'id'); + projectConfig.localHoldouts = projectConfig.localHoldouts || []; const global: Holdout[] = []; const ruleHoldoutsMap: { [ruleId: string]: Holdout[] } = {}; + const holdoutIdMap: { [id: string]: Holdout } = {}; - projectConfig.holdouts.forEach((holdout) => { - + // Helper to seed common per-holdout fields (matches legacy behavior). + const initHoldout = (holdout: Holdout): void => { // Original design of holdouts made use of the includeFlags and excludeFlags fields to identify local holdouts. // But this was never released. In the current design, these fields are no longer used. These fields are kept // and assigned empty array to keep the published type `Holdout` unchanged. @@ -411,24 +452,44 @@ const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => { holdout.excludedFlags = []; holdout.variationKeyMap = keyBy(holdout.variations, 'key'); assignBy(holdout.variations, 'id', projectConfig.variationIdMap); + }; - // Compute isGlobal: null/undefined includedRules means global holdout. - // An empty array ([]) means local holdout targeting no rules — still NOT global. - holdout.isGlobal = holdout.includedRules === null || holdout.includedRules === undefined; - - if (holdout.isGlobal) { - global.push(holdout); - } else { - // Local holdout: register under each rule ID it targets. - for (const ruleId of holdout.includedRules!) { - if (!ruleHoldoutsMap[ruleId]) { - ruleHoldoutsMap[ruleId] = []; - } - ruleHoldoutsMap[ruleId].push(holdout); + // Process global holdouts: section membership is the sole signal for scope. + // Drop any `includedRules` field on entries here so the entity is unambiguously + // global (isGlobal === true), even if the datafile incorrectly includes one. + projectConfig.holdouts.forEach((holdout) => { + initHoldout(holdout); + delete holdout.includedRules; + holdout.isGlobal = true; + holdoutIdMap[holdout.id] = holdout; + global.push(holdout); + }); + + // Process local holdouts: every entry must carry a non-empty `includedRules` list. + // Entries missing it (or with [] / null) are invalid per spec — log an error and + // exclude from evaluation. Do NOT fall back to global application — the partition + // between sections is hard. + projectConfig.localHoldouts.forEach((holdout) => { + const includedRules = holdout.includedRules; + if (!Array.isArray(includedRules) || includedRules.length === 0) { + logger?.error( + `Local holdout "${holdout.key || holdout.id}" is missing or has empty "includedRules"; skipping.` + ); + return; + } + + initHoldout(holdout); + holdout.isGlobal = false; + holdoutIdMap[holdout.id] = holdout; + for (const ruleId of includedRules) { + if (!ruleHoldoutsMap[ruleId]) { + ruleHoldoutsMap[ruleId] = []; } + ruleHoldoutsMap[ruleId].push(holdout); } }); + projectConfig.holdoutIdMap = holdoutIdMap; projectConfig.holdoutConfig = { global, ruleHoldoutsMap, @@ -436,8 +497,9 @@ const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => { } /** - * Returns all global holdouts from the holdout config. - * Global holdouts have includedRules === null or undefined. + * Returns all global holdouts (parsed from the top-level `holdouts` section). + * Section membership in `holdouts` is the sole signal for global scope — + * any `includedRules` field on these entries is ignored. */ export const getGlobalHoldouts = (projectConfig: ProjectConfig): Holdout[] => { return projectConfig.holdoutConfig?.global ?? []; @@ -445,6 +507,9 @@ export const getGlobalHoldouts = (projectConfig: ProjectConfig): Holdout[] => { /** * Returns local holdouts targeting the given rule ID, or an empty array. + * + * Local holdouts come from the top-level `localHoldouts` datafile section and + * are scoped per-rule via their `includedRules` field. */ export const getHoldoutsForRule = (projectConfig: ProjectConfig, ruleId: string): Holdout[] => { return projectConfig.holdoutConfig?.ruleHoldoutsMap[ruleId] ?? []; @@ -968,13 +1033,10 @@ export const tryCreatingProjectConfig = function( config.logger?.info(SKIPPING_JSON_VALIDATION); } - const createProjectConfigArgs = [newDatafileObj]; - if (typeof config.datafile === 'string') { - // Since config.datafile was validated above, we know that it is a valid JSON string - createProjectConfigArgs.push(config.datafile); - } - - const newConfigObj = createProjectConfig(...createProjectConfigArgs); + // Pass the datafile string (when available) and the logger so parse-time + // diagnostics (e.g. invalid local-holdout entries) are surfaced. + const datafileStr = typeof config.datafile === 'string' ? config.datafile : null; + const newConfigObj = createProjectConfig(newDatafileObj, datafileStr, config.logger); return newConfigObj; }; diff --git a/lib/shared_types.ts b/lib/shared_types.ts index f82aa0bce..6640622ab 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -177,16 +177,17 @@ export interface Holdout extends ExperimentCore { includedFlags: string[]; excludedFlags: string[]; /** - * When null or undefined, this is a global holdout (applies to all rules across all flags). - * When an array of rule ID strings, this is a local holdout (applies only to those rules). - * An empty array means a local holdout with no matching rules (still local, not global). - * This field may be absent in old datafiles — treated as null (global). + * Per-rule targeting for local holdouts. Required on entries from the + * `localHoldouts` datafile section; stripped from entries in the `holdouts` + * section at parse time. Scope is determined by datafile section membership, + * not this field. */ includedRules?: string[] | null; /** - * True if this is a global holdout (includedRules is null or undefined). - * False if this is a local holdout targeting specific rules. - * Computed and set during config parsing in parseHoldoutsConfig. + * True if this holdout came from the `holdouts` (global) datafile section. + * Computed during config parsing in parseHoldoutsConfig — `includedRules` is + * stripped from global-section entries, so this stays consistent with section + * membership. */ isGlobal: boolean; }