confeature 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require "base64"
6
+ require "digest"
7
+
8
+ module Confeature
9
+ module Internal
10
+ # SDK-key format helpers + cache-key derivation.
11
+ #
12
+ # SECURITY (Constitution security clause + spec 037 FR-008): SDK
13
+ # keys are secrets. They MUST NEVER appear verbatim in any log
14
+ # line. Every log site routes through `redact` first.
15
+ #
16
+ # The cache-key algorithm mirrors `packages/sdk-shared/src/sdk-key.ts`
17
+ # (SHA-256 → base64url → first 32 chars). Cross-language fixture
18
+ # verification lives in `spec/cache_key_fixtures_spec.rb`.
19
+ module SdkKey
20
+ # Wire formats of a valid Confeature SDK key — both are accepted
21
+ # (#802):
22
+ #
23
+ # - `configcat-sdk-1/<22 url-safe chars>/<22 url-safe chars>` —
24
+ # minted today; ConfigCat's own shape, so the stock `configcat`
25
+ # gem accepts the key too.
26
+ # - `confeature-sdk-1-<32 url-safe chars>-<32 url-safe chars>` —
27
+ # minted before #802; still served until the key is rotated.
28
+ #
29
+ # Mirrors `CONFEATURE_SDK_KEY_PATTERN` in
30
+ # `packages/sdk-shared/src/sdk-key.ts`. The shared accept/reject
31
+ # cases live in
32
+ # `packages/test-fixtures-sdk-parity/fixtures/sdk-key-shapes.json`.
33
+ CONFEATURE_SDK_KEY_PATTERN = %r{
34
+ \A
35
+ (?:
36
+ configcat-sdk-1/[A-Za-z0-9_-]{22}/[A-Za-z0-9_-]{22}
37
+ |
38
+ confeature-sdk-1-[A-Za-z0-9_-]{32}-[A-Za-z0-9_-]{32}
39
+ )
40
+ \z
41
+ }x
42
+
43
+ # Envelope prefix for derived cache keys. Identical across every
44
+ # Confeature SDK; mirrors the TS implementation's constant.
45
+ CACHE_KEY_PREFIX = "confeature:cache:v1:"
46
+
47
+ # Return true iff `sdk_key` matches either Confeature SDK-key format
48
+ # (the ConfigCat-shaped `configcat-sdk-1/...` key or the legacy
49
+ # `confeature-sdk-1-...` key).
50
+ #
51
+ # A `false` result does NOT cause the SDK to raise; it triggers an
52
+ # `INVALID_SDK_KEY` log line (which, per the security clause,
53
+ # includes only the redacted form).
54
+ def self.valid_confeature_key_format?(sdk_key)
55
+ return false unless sdk_key.is_a?(String)
56
+
57
+ # Matched on the raw bytes: a key in a broken or non-ASCII-
58
+ # compatible encoding is simply not well-shaped, never an
59
+ # `ArgumentError` / `Encoding::CompatibilityError` (#803).
60
+ CONFEATURE_SDK_KEY_PATTERN.match?(sdk_key.b)
61
+ end
62
+
63
+ # Return a privacy-preserving rendering of `sdk_key` for logs.
64
+ #
65
+ # Strategy: keep ~10% of the key visible at each end (minimum 4
66
+ # chars), mask the middle. For short / unusual keys (< 16 chars)
67
+ # and nil, fall back to a full mask. NEVER returns the raw key.
68
+ #
69
+ # The `/` separators of a ConfigCat-shaped key are plain characters
70
+ # here: for a 61-char `configcat-sdk-1/<22>/<22>` key the head is
71
+ # `config` (no secret) and the tail is the last 6 secret chars —
72
+ # fewer than the 8 shown for a legacy 83-char key.
73
+ #
74
+ # Mirrors `redactSdkKey` from packages/sdk-shared/src/sdk-key.ts.
75
+ def self.redact(sdk_key)
76
+ return "[redacted]" if sdk_key.nil? || !sdk_key.is_a?(String) || sdk_key.empty?
77
+
78
+ key = printable(sdk_key)
79
+ return "[redacted]" if key.nil? || key.length < 16
80
+
81
+ visible = [4, (key.length / 10.0).floor].max
82
+ head = key[0, visible]
83
+ tail = key[-visible, visible]
84
+ "#{head}…[redacted]…#{tail}"
85
+ end
86
+
87
+ # `sdk_key` as valid UTF-8 (bad bytes replaced), so interpolating
88
+ # it next to the UTF-8 "…" never raises an encoding error (#803).
89
+ # A valid UTF-8 key is returned unchanged. nil when no conversion
90
+ # exists.
91
+ def self.printable(sdk_key)
92
+ return sdk_key.scrub if sdk_key.encoding == Encoding::UTF_8
93
+
94
+ sdk_key.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
95
+ rescue EncodingError
96
+ nil
97
+ end
98
+ private_class_method :printable
99
+
100
+ # Derive a stable, opaque cache key from an SDK key.
101
+ #
102
+ # Format: `confeature:cache:v1:<base64url(sha256(sdk_key))[0, 32]>`.
103
+ #
104
+ # Output is byte-identical to the TS workspace's async
105
+ # `deriveCacheKey` (Web Crypto's `subtle.digest("SHA-256")`).
106
+ # The cross-language fixture at
107
+ # `packages/test-fixtures-sdk-parity/fixtures/cache-key-fixtures.json`
108
+ # pins ≥ 5 (sdkKey, expectedCacheKey) pairs and the Ruby unit test
109
+ # asserts string equality for every entry.
110
+ #
111
+ # SECURITY: the SDK key is HASHED, never stored raw. Even an
112
+ # attacker with filesystem access to the cache directory cannot
113
+ # read the SDK key from a written cache file's name.
114
+ def self.derive_cache_key(sdk_key)
115
+ digest = Digest::SHA256.digest(sdk_key.to_s.b)
116
+ b64 = Base64.urlsafe_encode64(digest, padding: false)
117
+ "#{CACHE_KEY_PREFIX}#{b64[0, 32]}"
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require_relative "comparators"
6
+
7
+ module Confeature
8
+ module Internal
9
+ # Segment-condition resolution (v6 + legacy v5). Ported from the spec-117 TS
10
+ # reference (`packages/sdk-shared/src/segments.ts`) and the spec-118 Python
11
+ # port, itself from common-js `RolloutEvaluator.evaluateSegmentCondition`.
12
+ #
13
+ # A segment condition (`{ "s" => index, "c" => comparator }`) references a
14
+ # segment by index into the config's top-level `s[]` (`{ "n" => name,
15
+ # "r" => [...] }`) and a comparator (`IsIn` / `IsNotIn`). The segment's `r[]`
16
+ # entries are **bare user conditions**, AND-grouped: the user IS-IN the
17
+ # segment iff every condition matches. The decision is then optionally
18
+ # negated by `IsNotIn`.
19
+ #
20
+ # The **sensitive-comparator context salt is the segment NAME** (`n`), NOT
21
+ # the setting key — the crucial divergence from a user condition; getting it
22
+ # wrong silently breaks every hashed segment row.
23
+ #
24
+ # Three-valued outcome: `true` (in segment per the comparator), `false` (not
25
+ # in segment), or a `Comparators::ValidationError`. An error is returned
26
+ # **un-negated** — `IsNotIn` never flips an error into a match.
27
+ module Segments
28
+ SEGMENT_IS_IN = 0
29
+ SEGMENT_IS_NOT_IN = 1
30
+
31
+ # A malformed segment reference / name — a fatal config error (fail-closed,
32
+ # caught by the evaluator's rescue-all).
33
+ class SegmentReferenceError < StandardError; end
34
+
35
+ module_function
36
+
37
+ # Resolve one segment condition. `evaluate_user_condition` is injected (the
38
+ # evaluator owns it) so this module has no back-reference to the evaluator;
39
+ # it evaluates one segment `r[]` user condition with the segment NAME as
40
+ # the context salt.
41
+ def evaluate_segment_condition(condition, segments, has_user, surface_missing_user, evaluate_user_condition)
42
+ # No user → the whole segment condition is the missing-user error
43
+ # (surfaced once per context), exactly like a user condition.
44
+ return Comparators.error(surface_missing_user.call) unless has_user
45
+
46
+ index = condition["s"]
47
+ unless index.is_a?(Integer) && !boolean?(index) && index >= 0 && index < segments.length
48
+ raise SegmentReferenceError, "Segment reference is invalid."
49
+ end
50
+
51
+ segment = segments[index]
52
+ segment_name = segment["n"]
53
+ unless segment_name.is_a?(String) && !segment_name.empty?
54
+ raise SegmentReferenceError,
55
+ "Segment name is missing."
56
+ end
57
+
58
+ # The segment's `r[]` is an AND-group of BARE user conditions; the
59
+ # context salt is the segment NAME. Short-circuit on first non-match.
60
+ in_segment = true
61
+ (segment["r"] || []).each do |uc|
62
+ in_segment = evaluate_user_condition.call(uc, segment_name)
63
+ break if in_segment != true
64
+ end
65
+
66
+ # An error is returned un-negated — IsNotIn never converts an error into
67
+ # a match (matches common-js: the comparator switch only runs when the
68
+ # segment result is a clean bool).
69
+ return in_segment unless [true, false].include?(in_segment)
70
+
71
+ condition["c"] == SEGMENT_IS_NOT_IN ? !in_segment : in_segment
72
+ end
73
+
74
+ def boolean?(value)
75
+ [true, false].include?(value)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ module Confeature
6
+ module Internal
7
+ # ConfigCat-semantics semver parse + compare. Ported field-for-field from
8
+ # the spec-117 TS reference (`packages/sdk-shared/src/semver.ts`) and the
9
+ # spec-118 Python port (`apps/sdk-python/.../semver.py`), which themselves
10
+ # vendor ConfigCat `common-js` `Semver.ts`. A Ruby semver gem is NOT used —
11
+ # ConfigCat's strict-parse + precedence edge cases diverge from those gems
12
+ # and would fail the vendored `testmatrix_semantic*` precedence rows.
13
+ #
14
+ # Key behaviours:
15
+ # - **Strict (non-loose) parse** of the FULL grammar
16
+ # `v?MAJOR.MINOR.PATCH(-prerelease)?(+build)?`, each core component a
17
+ # `0|[1-9]\d*` numeric identifier; an optional leading `v` is allowed.
18
+ # - **Parse on trim.** Callers parse `value.strip`; {.parse} also trims.
19
+ # - **Unparseable → nil**, never an exception. The caller treats nil as a
20
+ # validation error (the rule errors and does not match — FR-010), NOT a
21
+ # silent false.
22
+ # - **Precedence**: numeric major.minor.patch first; then pre-release. A
23
+ # version WITHOUT a pre-release ranks HIGHER than one WITH a pre-release.
24
+ # Pre-release identifiers compared field-by-field — numeric vs numeric
25
+ # numerically; a numeric identifier < an alphanumeric identifier;
26
+ # alphanumeric compared ASCII-lexically; when all preceding fields are
27
+ # equal, MORE fields rank higher than fewer.
28
+ # - **Build metadata is ignored** for precedence (SemVer §10).
29
+ module Semver
30
+ NUMERIC_IDENTIFIER = '0|[1-9]\d*'
31
+ NON_NUMERIC_IDENTIFIER = '\d*[a-zA-Z-][a-zA-Z0-9-]*'
32
+ MAIN_VERSION = "(#{NUMERIC_IDENTIFIER})\\.(#{NUMERIC_IDENTIFIER})\\.(#{NUMERIC_IDENTIFIER})".freeze
33
+ PRERELEASE_IDENTIFIER = "(?:#{NUMERIC_IDENTIFIER}|#{NON_NUMERIC_IDENTIFIER})".freeze
34
+ PRERELEASE = "(?:-(#{PRERELEASE_IDENTIFIER}(?:\\.#{PRERELEASE_IDENTIFIER})*))".freeze
35
+ BUILD_IDENTIFIER = "[0-9A-Za-z-]+"
36
+ BUILD = "(?:\\+(#{BUILD_IDENTIFIER}(?:\\.#{BUILD_IDENTIFIER})*))".freeze
37
+ FULL = /\Av?#{MAIN_VERSION}#{PRERELEASE}?#{BUILD}?\z/
38
+
39
+ MAX_LENGTH = 256
40
+ # Mirrors JS Number.MAX_SAFE_INTEGER (the TS reference's bound).
41
+ MAX_SAFE_INTEGER = 9_007_199_254_740_991
42
+
43
+ NUMERIC_RE = /\A[0-9]+\z/
44
+
45
+ # A parsed semver with a ConfigCat-precedence #compare.
46
+ #
47
+ # `prerelease` is an Array of Integer (numeric ids) | String;
48
+ # `build` is an Array of String (ignored for precedence).
49
+ Version = Struct.new(:version, :major, :minor, :patch, :prerelease, :build, keyword_init: true) do
50
+ # Compare against `other`: <0 lower, 0 equal, >0 higher. Build metadata
51
+ # is ignored. Mirrors common-js `SemVer.compare` → `compareMain ||
52
+ # comparePre`.
53
+ def compare(other)
54
+ return 0 if other.version == version
55
+
56
+ main = compare_main(other)
57
+ return main unless main.zero?
58
+
59
+ compare_pre(other)
60
+ end
61
+
62
+ private
63
+
64
+ def compare_main(other)
65
+ c = Semver.compare_identifiers(major, other.major)
66
+ return c unless c.zero?
67
+
68
+ c = Semver.compare_identifiers(minor, other.minor)
69
+ return c unless c.zero?
70
+
71
+ Semver.compare_identifiers(patch, other.patch)
72
+ end
73
+
74
+ def compare_pre(other)
75
+ # A version WITHOUT a pre-release is higher-precedence than one WITH.
76
+ return -1 if !prerelease.empty? && other.prerelease.empty?
77
+ return 1 if prerelease.empty? && !other.prerelease.empty?
78
+ return 0 if prerelease.empty? && other.prerelease.empty?
79
+
80
+ i = 0
81
+ loop do
82
+ a = i < prerelease.length ? prerelease[i] : nil
83
+ b = i < other.prerelease.length ? other.prerelease[i] : nil
84
+ return 0 if a.nil? && b.nil?
85
+ return 1 if b.nil?
86
+ return -1 if a.nil?
87
+
88
+ if a == b
89
+ i += 1
90
+ next
91
+ end
92
+ return Semver.compare_identifiers(a, b)
93
+ end
94
+ end
95
+ end
96
+
97
+ module_function
98
+
99
+ # Compare two pre-release / core identifiers (common-js
100
+ # `compareIdentifiers`): both numeric → numeric compare; a numeric
101
+ # identifier is lower-precedence than an alphanumeric one; otherwise
102
+ # ASCII-lexical.
103
+ def compare_identifiers(a, b)
104
+ anum = !NUMERIC_RE.match(a.to_s).nil?
105
+ bnum = !NUMERIC_RE.match(b.to_s).nil?
106
+ av = a
107
+ bv = b
108
+ if anum && bnum
109
+ av = a.to_i
110
+ bv = b.to_i
111
+ end
112
+ return 0 if av == bv
113
+ return -1 if anum && !bnum
114
+ return 1 if bnum && !anum
115
+
116
+ av < bv ? -1 : 1
117
+ end
118
+
119
+ # Parse a version string under ConfigCat's strict grammar. Returns nil on
120
+ # any failure (length, grammar, out-of-range core component) — never
121
+ # raises. Trims the input first (parse on trim). The caller surfaces nil
122
+ # as a validation error (FR-010), never a silent match.
123
+ def parse(version)
124
+ trimmed = version.strip
125
+ return nil if trimmed.length > MAX_LENGTH
126
+
127
+ m = FULL.match(trimmed)
128
+ return nil if m.nil?
129
+
130
+ major = m[1].to_i
131
+ minor = m[2].to_i
132
+ patch = m[3].to_i
133
+ return nil if major.negative? || major > MAX_SAFE_INTEGER
134
+ return nil if minor.negative? || minor > MAX_SAFE_INTEGER
135
+ return nil if patch.negative? || patch > MAX_SAFE_INTEGER
136
+
137
+ prerelease = parse_prerelease(m[4])
138
+ build = m[5] ? m[5].split(".") : []
139
+
140
+ canonical = "#{major}.#{minor}.#{patch}"
141
+ canonical += "-#{prerelease.join(".")}" unless prerelease.empty?
142
+ Version.new(
143
+ version: canonical,
144
+ major: major, minor: minor, patch: patch,
145
+ prerelease: prerelease, build: build
146
+ )
147
+ end
148
+
149
+ # Numberify numeric pre-release ids (matching common-js).
150
+ def parse_prerelease(raw)
151
+ return [] if raw.nil?
152
+
153
+ raw.split(".").map do |ident|
154
+ if NUMERIC_RE.match(ident)
155
+ num = ident.to_i
156
+ next num if num >= 0 && num < MAX_SAFE_INTEGER
157
+ end
158
+ ident
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require "logger"
6
+
7
+ module Confeature
8
+ # Confeature log levels. Int values align with stdlib `Logger`
9
+ # severity constants so customers can swap between Confeature levels
10
+ # and stdlib levels transparently.
11
+ module LogLevel
12
+ DEBUG = ::Logger::DEBUG # 0
13
+ INFO = ::Logger::INFO # 1
14
+ WARNING = ::Logger::WARN # 2
15
+ ERROR = ::Logger::ERROR # 3
16
+ OFF = ::Logger::UNKNOWN + 1 # 6 — above every stdlib level
17
+
18
+ ALL = [DEBUG, INFO, WARNING, ERROR, OFF].freeze
19
+ end
20
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require "logger"
6
+
7
+ require_relative "log_level"
8
+
9
+ module Confeature
10
+ # Pluggable logger surface.
11
+ #
12
+ # Customers integrate by either:
13
+ # - keeping the default stderr-backed logger (wraps stdlib
14
+ # `::Logger.new($stderr)`), or
15
+ # - subclassing `Confeature::Logger` and passing the instance via
16
+ # the client's `logger:` constructor kwarg.
17
+ #
18
+ # The default level is WARNING (mirrors `configcat`'s default).
19
+ class Logger
20
+ attr_reader :level
21
+
22
+ def initialize(level: LogLevel::WARNING, io: $stderr)
23
+ @level = level
24
+ @logger = ::Logger.new(io)
25
+ @logger.progname = "confeature"
26
+ @logger.level = level_to_stdlib(level)
27
+ end
28
+
29
+ def error(message)
30
+ @logger.error(message) if @level <= LogLevel::ERROR
31
+ end
32
+
33
+ def warning(message)
34
+ @logger.warn(message) if @level <= LogLevel::WARNING
35
+ end
36
+
37
+ def info(message)
38
+ @logger.info(message) if @level <= LogLevel::INFO
39
+ end
40
+
41
+ def debug(message)
42
+ @logger.debug(message) if @level <= LogLevel::DEBUG
43
+ end
44
+
45
+ private
46
+
47
+ def level_to_stdlib(level)
48
+ # LogLevel::OFF maps above ::Logger::UNKNOWN so nothing fires.
49
+ return ::Logger::UNKNOWN + 1 if level == LogLevel::OFF
50
+
51
+ level
52
+ end
53
+ end
54
+
55
+ # Build the SDK's default logger — stderr-backed, WARNING level.
56
+ def self.default_logger
57
+ Logger.new
58
+ end
59
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ module Confeature
6
+ # Polling-mode factory + value types.
7
+ #
8
+ # Mirrors the upstream `configcat` gem's `PollingMode` module: three
9
+ # factory methods produce frozen value objects that the client wires
10
+ # into the matching poll service (AutoPoll → daemon thread, LazyLoad
11
+ # → on-demand staleness check, ManualPoll → caller-driven).
12
+ module PollingMode
13
+ AutoPoll = Struct.new(:poll_interval_seconds, :max_init_wait_time_seconds, keyword_init: true) do
14
+ def to_s
15
+ "AutoPoll(interval=#{poll_interval_seconds}s, " \
16
+ "max_init_wait=#{max_init_wait_time_seconds}s)"
17
+ end
18
+ end
19
+
20
+ LazyLoad = Struct.new(:cache_refresh_interval_seconds, keyword_init: true) do
21
+ def to_s
22
+ "LazyLoad(refresh=#{cache_refresh_interval_seconds}s)"
23
+ end
24
+ end
25
+
26
+ ManualPoll = Class.new do
27
+ def to_s
28
+ "ManualPoll"
29
+ end
30
+
31
+ def ==(other)
32
+ other.is_a?(ManualPoll)
33
+ end
34
+ alias_method :eql?, :==
35
+
36
+ def hash
37
+ ManualPoll.hash
38
+ end
39
+ end
40
+
41
+ # Build an AutoPoll value with the standard ConfigCat defaults.
42
+ def self.auto_poll(poll_interval: 60, max_init_wait_time: 5)
43
+ AutoPoll.new(
44
+ poll_interval_seconds: poll_interval,
45
+ max_init_wait_time_seconds: max_init_wait_time
46
+ ).freeze
47
+ end
48
+
49
+ # Build a LazyLoad value with the standard ConfigCat default.
50
+ def self.lazy_load(cache_refresh_interval: 60)
51
+ LazyLoad.new(cache_refresh_interval_seconds: cache_refresh_interval).freeze
52
+ end
53
+
54
+ # Singleton ManualPoll value — identity-stable for cheap `equal?`
55
+ # short-circuits when the client checks the active mode.
56
+ MANUAL_POLL = ManualPoll.new.freeze
57
+
58
+ def self.manual_poll
59
+ MANUAL_POLL
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ require_relative "evaluation_details"
6
+ require_relative "internal/evaluator"
7
+ require_relative "internal/override_eval"
8
+
9
+ module Confeature
10
+ # Immutable view of the last-fetched config + a reference to overrides.
11
+ #
12
+ # All methods are synchronous. `get_value` consults overrides FIRST
13
+ # via `Internal::OverrideEval.evaluate_sync`, then falls through to
14
+ # the cached config + boolean evaluator. This is the spec-036
15
+ # correctness path that the Ruby SDK inherits from day one.
16
+ #
17
+ # `fetch_time` is captured at snapshot construction time so
18
+ # `EvaluationDetails.fetch_time` is immutable across the snapshot's
19
+ # life (spec 086 / #266).
20
+ class Snapshot
21
+ def initialize(config:, flag_overrides:, default_user:, fetch_time: nil)
22
+ @config = config
23
+ @flag_overrides = flag_overrides
24
+ @default_user = default_user
25
+ @fetch_time = fetch_time
26
+ freeze
27
+ end
28
+
29
+ def get_value(key, default, user = nil)
30
+ # Override path FIRST — spec 036 correctness. Route through the
31
+ # full-config-aware router (spec 135 / #431) so a rule-bearing
32
+ # override source resolves targeting rules; the flat-map source is
33
+ # unchanged.
34
+ effective_user = user || @default_user
35
+ found, value = Internal::OverrideEval.evaluate(@flag_overrides, key, default, effective_user)
36
+ return value if found
37
+
38
+ result = Internal::Evaluator.evaluate(
39
+ @config, key, default, effective_user
40
+ )
41
+ result.value
42
+ end
43
+
44
+ def get_all_keys
45
+ keys = []
46
+ if @config.is_a?(Hash)
47
+ settings = @config["f"]
48
+ keys.concat(settings.keys) if settings.is_a?(Hash)
49
+ end
50
+ if @flag_overrides
51
+ begin
52
+ override_map = @flag_overrides.data_source.get_overrides_sync
53
+ rescue StandardError
54
+ override_map = nil
55
+ end
56
+ keys.concat(override_map.keys) if override_map.is_a?(Hash)
57
+ end
58
+ keys.uniq.sort
59
+ end
60
+
61
+ # Spec 086 / #266 — typed-envelope mirror of `get_value`.
62
+ #
63
+ # For LocalOnly consumers, the override map is the only source of
64
+ # truth: a key present in the map yields the typed value with
65
+ # `error: nil`; a missing key yields the default with
66
+ # `error: "KEY_NOT_FOUND"`. The override-eval helper returns the
67
+ # default on both KEY_NOT_FOUND and TYPE_MISMATCH branches,
68
+ # indistinguishable from a legitimate override whose value equals
69
+ # the default — Snapshot re-queries the map to disambiguate.
70
+ #
71
+ # Non-LocalOnly clients route through the evaluator; the
72
+ # `EvaluationResult.error` string propagates verbatim into the
73
+ # envelope's `error` field.
74
+ def get_value_details(key, default, user = nil)
75
+ effective_user = user || @default_user
76
+ return local_only_details(key, default, effective_user) if local_only?
77
+
78
+ result = Internal::Evaluator.evaluate(@config, key, default, effective_user)
79
+ matched_rule = result.matched_targeting_rule && TargetingRule.new(result.matched_targeting_rule)
80
+ matched_option = result.matched_percentage_option && PercentageOption.new(result.matched_percentage_option)
81
+ EvaluationDetails.new(
82
+ key: key,
83
+ value: result.value,
84
+ variation_id: result.variation_id,
85
+ user: effective_user,
86
+ is_default_value: !result.error.nil?,
87
+ error: result.error,
88
+ fetch_time: @fetch_time,
89
+ matched_targeting_rule: matched_rule,
90
+ matched_percentage_option: matched_option
91
+ )
92
+ end
93
+
94
+ private
95
+
96
+ def local_only?
97
+ return false if @flag_overrides.nil?
98
+
99
+ @flag_overrides.behaviour == OverrideBehaviour::LOCAL_ONLY
100
+ end
101
+
102
+ def local_only_details(key, default, effective_user)
103
+ # Full-config override source → resolve through the real evaluator
104
+ # so targeting rules apply AND the served `variation_id` survives
105
+ # to the PUBLIC details envelope (spec 152 / #475, US2; closes the
106
+ # #395 gap class). Dispatch on the source TYPE, never the resolved
107
+ # value, mirroring `get_value`'s `OverrideEval.evaluate` router.
108
+ found_full, result = Internal::OverrideEval.evaluate_full_config_override_details_sync(
109
+ @flag_overrides, key, default, effective_user
110
+ )
111
+ if found_full
112
+ matched_rule = result.matched_targeting_rule && TargetingRule.new(result.matched_targeting_rule)
113
+ matched_option = result.matched_percentage_option && PercentageOption.new(result.matched_percentage_option)
114
+ return EvaluationDetails.new(
115
+ key: key,
116
+ value: result.value,
117
+ variation_id: result.variation_id,
118
+ user: effective_user,
119
+ is_default_value: !result.error.nil?,
120
+ error: result.error,
121
+ fetch_time: @fetch_time,
122
+ matched_targeting_rule: matched_rule,
123
+ matched_percentage_option: matched_option
124
+ )
125
+ end
126
+
127
+ found, value = Internal::OverrideEval.evaluate_sync(@flag_overrides, key, default)
128
+ data_source = @flag_overrides.data_source
129
+ overrides_map =
130
+ if data_source.respond_to?(:get_overrides_sync)
131
+ begin
132
+ data_source.get_overrides_sync
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+ present = overrides_map.is_a?(Hash) && overrides_map.key?(key)
138
+
139
+ if found && present
140
+ EvaluationDetails.new(
141
+ key: key,
142
+ value: value,
143
+ user: effective_user,
144
+ fetch_time: @fetch_time
145
+ )
146
+ else
147
+ EvaluationDetails.new(
148
+ key: key,
149
+ value: default,
150
+ is_default_value: true,
151
+ error: "KEY_NOT_FOUND",
152
+ user: effective_user,
153
+ fetch_time: @fetch_time
154
+ )
155
+ end
156
+ end
157
+ end
158
+ end