convert_sdk 1.0.1 → 2.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aab5bea865be444282ecd0fdcea5b30b5928204e4fecee1bb8b455c3ad54c550
4
- data.tar.gz: 2753c4042b733710dde55260479205dcc81bcc637c3d5c473b4c3a2671f6f05e
3
+ metadata.gz: 4971b19e554964f05a8c86555b919d102492a09190eac1747567177f375dc29e
4
+ data.tar.gz: b4a91c4ab52075f0268aa1f138989f406d4f9166f7cd06f859ea879a4f7e2975
5
5
  SHA512:
6
- metadata.gz: 8be2456929af9052ff3522b4004320056d526e6f5ea768ba1cb2f9c47116f80cffe79b8123a141db54d7afb2d70553393ee03ad6fa1d7abb0b9e11baaae39268
7
- data.tar.gz: a1273013c76b64bb5598767cf9a3d87273f7fda3a4a8a65f2803473bdd123222b8b0dbe67437547dc8d6d0a28b850387e9be01a887b0d51b0e27a0489147c5d3
6
+ metadata.gz: 288df65beff1f18b3dbe25334e31e852222705c0786113cb56fbd3a253ffc5edcfba335a00258c062ea7faf34728afd5e26ecd234f7df305cb74e043275e962b
7
+ data.tar.gz: 2ee04c45261064677c2cd2da5bd4a89e06747f39cf172a9d02a87a976ef167d48b7ddafaa5df3b1c1ad9961e5e7fced4d636313f9645dd5ac2b0bf670f9c748e
data/.rubocop.yml CHANGED
@@ -82,6 +82,15 @@ Metrics/ClassLength:
82
82
  # length reflects the ported reference surface, not unmanaged growth (same
83
83
  # rationale as the DataManager/RuleManager feature surfaces above).
84
84
  - "lib/convert_sdk/feature_manager.rb"
85
+ # ApiManager (RB-3 / qs-03 AC4/AC8) gains #get_config_by_experience — the
86
+ # exp-scoped preview-config fetch + its process-wide memo. The task spec
87
+ # explicitly directs this onto ApiManager rather than a new file ("do not
88
+ # reach into Client or duplicate the qs-02 low-cache pattern wholesale —
89
+ # this is a distinct exp-scoped endpoint" living beside the existing
90
+ # HTTP-port delivery surface). Same rationale as the other cohesive-
91
+ # feature-surface exclusions above: the growth is the mandated surface,
92
+ # not unmanaged sprawl.
93
+ - "lib/convert_sdk/api_manager.rb"
85
94
 
86
95
  # The Client (Story 2.5) is wired by explicit constructor injection of its six
87
96
  # collaborator managers (config, log, http, data-store, event, data) — the
data/CONTRIBUTING.md CHANGED
@@ -73,7 +73,7 @@ runs on CRuby only — the JRuby leg runs the suite without the gate.
73
73
  |-----------|------------------|
74
74
  | `spec/unit/` | Per-class unit specs (mock collaborators, isolated logic). |
75
75
  | `spec/integration/` | End-to-end specs: `full_chain_spec.rb` (the release-blocking create→decide→track→flush loop), `runtime_recipes_spec.rb` (the runtime-lifecycle recipes the quickstarts are transcribed from), `fork_safety_spec.rb`, `factory_wiring_spec.rb`. |
76
- | `spec/cross_sdk/` | The cross-SDK MurmurHash3 parity vectors (the byte-identical bucketing proof). |
76
+ | `spec/cross_sdk/` | The cross-SDK parity proofs: MurmurHash3 hash vectors and the anchored bucketing layout vectors (`anchored_bucketing_vectors_spec.rb`) — the byte-identical bucketing proof. |
77
77
  | `spec/docs/` | Docs-snippet smoke specs — run the README/quickstart code samples against the real gem so documentation never drifts. |
78
78
  | `spec/staging/` | The live-platform suite (runs on schedule/dispatch only, never in PR CI). |
79
79
  | `spec/support/` | Shared helpers (e.g. `runtime_recipe_helpers.rb`, which co-locates the recipe wiring snippets the quickstarts ship). |
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require "uri"
6
+
5
7
  module ConvertSdk
6
8
  # The outbound delivery manager — it owns the {VisitorsQueue}, the tracking
7
9
  # endpoint, queue release, and THE wire-payload builder.
@@ -59,6 +61,97 @@ module ConvertSdk
59
61
  # dataStore is configured, and Ruby always provides one (api-manager.ts:94).
60
62
  ENRICH_DATA = false
61
63
 
64
+ # qs-03 AC8 — the {#get_config_by_experience} memo TTL in seconds. JS
65
+ # oracle: +CONFIG_BY_EXPERIENCE_TTL+ (api-manager.ts, ms there, seconds
66
+ # here — Ruby-side math is wall-clock seconds via +Time.now.to_f+).
67
+ CONFIG_BY_EXPERIENCE_TTL = 60
68
+
69
+ # qs-03 AC8 — the PROCESS-WIDE {#get_config_by_experience} memo: a class-
70
+ # level Hash (NOT a class variable — see architecture's "no globals or
71
+ # singletons" constructor-injection mandate; this is the one deliberate,
72
+ # narrowly-scoped exception, mirroring the JS oracle's module-level
73
+ # +configByExperienceCache+ Map so every {ApiManager} instance sharing an
74
+ # +sdk_key+ shares one fetch) keyed +"{sdk_key}:{experience_id}"+, entries
75
+ # shaped +{"config" => Hash, "expires_at" => Float}+. Mutated only under
76
+ # the +@config_by_experience_mutex+ below (fork-safety + thread-safety) —
77
+ # mirrors {ForkGuard}'s own module-level-state + mutex discipline
78
+ # (fork_guard.rb).
79
+ @config_by_experience_cache = {}
80
+ # qs-03 AC8 — guards {@config_by_experience_cache}. Class-level (not
81
+ # per-instance) because the cache it protects is process-wide.
82
+ @config_by_experience_mutex = Thread::Mutex.new
83
+
84
+ class << self
85
+ # Read a still-fresh (within {CONFIG_BY_EXPERIENCE_TTL}) memoized config
86
+ # for +cache_key+, or +nil+ when absent/expired (qs-03 AC8).
87
+ # @api private
88
+ # @param cache_key [String]
89
+ # @return [Hash{String=>Object}, nil]
90
+ def cached_config_by_experience(cache_key)
91
+ @config_by_experience_mutex.synchronize do
92
+ entry = @config_by_experience_cache[cache_key]
93
+ entry["config"] if entry && Time.now.to_f < entry["expires_at"]
94
+ end
95
+ end
96
+
97
+ # Memoize +config+ under +cache_key+ for {CONFIG_BY_EXPERIENCE_TTL}
98
+ # seconds (qs-03 AC8). A failed fetch must NEVER reach this method (the
99
+ # caller only memoizes on success).
100
+ #
101
+ # FIX-1 (code review round 1) — sweeps every OTHER already-expired entry
102
+ # from the process-wide cache before inserting, mirroring the JS oracle
103
+ # (+api-manager.ts+ ~367-370, which does the same on every
104
+ # +getConfigByExperience+ write). +experience_id+ is
105
+ # operator/attacker-influenced (the +convert_preview={expId}.{varId}+
106
+ # link param), so without this sweep a stream of distinct ids would grow
107
+ # this Hash without bound in a long-lived process (a Puma/Rails worker) —
108
+ # it would otherwise only shrink on fork or process exit.
109
+ # @api private
110
+ # @param cache_key [String]
111
+ # @param config [Hash{String=>Object}]
112
+ # @return [void]
113
+ def memoize_config_by_experience(cache_key, config)
114
+ @config_by_experience_mutex.synchronize do
115
+ now = Time.now.to_f
116
+ @config_by_experience_cache.delete_if { |_key, entry| entry["expires_at"] <= now }
117
+ @config_by_experience_cache[cache_key] = {
118
+ "config" => config,
119
+ "expires_at" => now + CONFIG_BY_EXPERIENCE_TTL
120
+ }
121
+ end
122
+ end
123
+
124
+ # Test-only reset (mirrors {ForkGuard.reset_for_tests!}) so specs stay
125
+ # order-independent against this process-wide memo. Also the FORK-side
126
+ # clear (registered per-instance in {#initialize} via
127
+ # +ForkGuard.register_child_callback+): even though a stale memo isn't
128
+ # unsafe to serve, clearing it on fork matches this file's existing
129
+ # queue-ownership-clear discipline ({#clear_queue_ownership}) and avoids
130
+ # a forked child transparently serving a parent process's stale
131
+ # preview-experience config across a fork boundary.
132
+ # @api private
133
+ # @return [void]
134
+ def reset_config_by_experience_cache_for_tests!
135
+ @config_by_experience_mutex.synchronize { @config_by_experience_cache = {} }
136
+ end
137
+
138
+ # Test-only inspection: the current size of {@config_by_experience_cache}
139
+ # (FIX-1, code review round 1, qs-03 AC8 hardening). Lets specs assert
140
+ # that {.memoize_config_by_experience}'s expired-entry sweep actually
141
+ # shrinks the process-wide Hash — not merely that an expired LOOKUP
142
+ # returns nil. `experience_id` is operator/attacker-influenced (the
143
+ # +convert_preview={expId}.{varId}+ link param), so without a sweep a
144
+ # stream of distinct ids would accumulate unbounded entries in a
145
+ # long-lived process (Puma/Rails worker). Mirrors
146
+ # {.reset_config_by_experience_cache_for_tests!}'s test-only scope and
147
+ # mutex discipline.
148
+ # @api private
149
+ # @return [Integer]
150
+ def config_by_experience_cache_size_for_tests
151
+ @config_by_experience_mutex.synchronize { @config_by_experience_cache.size }
152
+ end
153
+ end
154
+
62
155
  # @param config [Config] the validated configuration (track endpoint, sdk_key,
63
156
  # sdk_key_secret).
64
157
  # @param data_manager [DataManager] supplies +account_id+ / +project_id+ for
@@ -95,6 +188,12 @@ module ConvertSdk
95
188
  # knows nothing about the queue; ApiManager owns its own clear (architecture
96
189
  # Decision 6 callback-registry design).
97
190
  ForkGuard.register_child_callback(-> { clear_queue_ownership })
191
+ # qs-03 AC8 — clear the process-wide config-by-experience memo in a
192
+ # forked child (see {ApiManager.reset_config_by_experience_cache_for_tests!}
193
+ # doc for the rationale). Registered ALONGSIDE (not replacing) the
194
+ # queue-ownership-clear callback above — ForkGuard fires every
195
+ # registered callback in registration order.
196
+ ForkGuard.register_child_callback(-> { ApiManager.reset_config_by_experience_cache_for_tests! })
98
197
  end
99
198
 
100
199
  # @return [VisitorsQueue] the underlying per-visitor event queue.
@@ -167,6 +266,42 @@ module ConvertSdk
167
266
  @log_manager.error("ApiManager#release_queue: #{e.class}: #{e.message}")
168
267
  end
169
268
 
269
+ # Fetch (or reuse a memoized) config scoped to a single experience — qs-03
270
+ # AC4 fetch resolution / AC8 memoization, the preview lookup path. Always
271
+ # requests +_conv_low_cache=1+ (bypassing the CDN cache) and scopes the
272
+ # response with +exp=<experience_id>+; includes +environment+ /
273
+ # +debug_token+ when configured. JS oracle: +getConfigByExperience+
274
+ # (api-manager.ts ~344).
275
+ #
276
+ # Memoized PROCESS-WIDE (see {CONFIG_BY_EXPERIENCE_TTL}), keyed
277
+ # +"{sdk_key}:{experience_id}"+ — repeated lookups within the TTL window,
278
+ # including from a DIFFERENT {ApiManager} instance sharing the same
279
+ # +sdk_key+, reuse the same fetch. Never touches the store (qs-03: "TTL
280
+ # 60s (in-memory only; never the store)") — the memo lives entirely in the
281
+ # class-level cache.
282
+ #
283
+ # On a failed fetch (transport failure, non-2xx, or a non-Hash body):
284
+ # returns +nil+ WITHOUT memoizing, so the next lookup retries (graceful
285
+ # degradation, NFR9 — never raises).
286
+ #
287
+ # @param experience_id [String]
288
+ # @return [Hash{String=>Object}, nil] the fetched/memoized config, or nil
289
+ # on a failed fetch.
290
+ def get_config_by_experience(experience_id)
291
+ # Data-only mode guard (review round 2) — a direct-data Config has no
292
+ # sdk_key, and therefore no config-fetch endpoint to hit; without this
293
+ # guard a preview resolution reachable in that mode would build a
294
+ # +.../config/?exp=…+ URL with a nil key and fire a guaranteed-failing
295
+ # HTTP request.
296
+ return nil if @config.sdk_key.nil?
297
+
298
+ cache_key = "#{@config.sdk_key}:#{experience_id}"
299
+ cached = self.class.cached_config_by_experience(cache_key)
300
+ return cached unless cached.nil?
301
+
302
+ fetch_config_by_experience(cache_key, experience_id)
303
+ end
304
+
170
305
  private
171
306
 
172
307
  # POST the drained per-visitor entries and branch on the result. On SUCCESS:
@@ -284,5 +419,37 @@ module ConvertSdk
284
419
 
285
420
  { "Authorization" => "Bearer #{secret}" }
286
421
  end
422
+
423
+ # GET {config_by_experience_url(experience_id)} through the HTTP port; on
424
+ # success with a Hash body, memoize and return it, else return nil WITHOUT
425
+ # memoizing (qs-03 AC4/AC8). +@config.sdk_key+ is used directly (no
426
+ # account/project fallback — the config-fetch convention this endpoint
427
+ # mirrors, {Client#config_url}, uses +@config.sdk_key+ unconditionally; the
428
+ # fallback in {#sdk_key} is a track-URL-only concern).
429
+ def fetch_config_by_experience(cache_key, experience_id)
430
+ response = @http_client.request(method: :get, url: config_by_experience_url(experience_id), headers: auth_headers)
431
+ return nil unless response.success? && response.body.is_a?(Hash)
432
+
433
+ self.class.memoize_config_by_experience(cache_key, response.body)
434
+ response.body
435
+ end
436
+
437
+ # Build +{config_endpoint}/config/{sdk_key}+ with query params in the
438
+ # FIXED order the qs-03 contract mandates: +environment+ (when set), then
439
+ # +exp=<experience_id>+ (always), then +_conv_low_cache=1+ (always), then
440
+ # +debug_token+ (when set). Values are URI-encoded (mirrors
441
+ # {Client#config_url_params}'s encoding convention).
442
+ def config_by_experience_url(experience_id)
443
+ env = @config.environment
444
+ debug_token = @config.debug_token
445
+
446
+ params = [] #: Array[String]
447
+ params << "environment=#{URI.encode_www_form_component(env)}" unless env.nil?
448
+ params << "exp=#{URI.encode_www_form_component(experience_id)}"
449
+ params << "_conv_low_cache=1"
450
+ params << "debug_token=#{URI.encode_www_form_component(debug_token)}" unless debug_token.nil?
451
+
452
+ "#{@config.config_endpoint}/config/#{@config.sdk_key}?#{params.join("&")}"
453
+ end
287
454
  end
288
455
  end
@@ -39,6 +39,16 @@ module ConvertSdk
39
39
  #
40
40
  # @api private
41
41
  class BucketingManager
42
+ # Fixed anchor-scale constant for the ANCHORED bucketing layout (contract
43
+ # v12, qs-01). Unlike +max_traffic+ (config-supplied, used only to scale the
44
+ # visitor's HASH value in {#value_visitor_based}), the anchor/width scale for
45
+ # the anchored range walk is a FIXED module constant in the JS oracle
46
+ # (+bucketing-manager.ts:25+ +DEFAULT_MAX_TRAFFIC = 10000+) — never read from
47
+ # config. Named here (rather than an inline literal in {#select_bucket_anchored})
48
+ # so the constant stays traceable to its JS origin without conflating it with
49
+ # the per-config +@max_traffic+ used elsewhere in this class.
50
+ ANCHORED_MAX_TRAFFIC = 10_000
51
+
42
52
  # Build a bucketing engine bound to a {Config}'s frozen bucketing constants.
43
53
  #
44
54
  # @param config [Config] supplies +max_traffic+, +hash_seed+, +max_hash+.
@@ -130,5 +140,156 @@ module ConvertSdk
130
140
 
131
141
  { variation_id: selected, bucketing_allocation: value }
132
142
  end
143
+
144
+ # Select the variation whose ANCHORED range contains +value+ (contract v12,
145
+ # qs-01/BUCK-2). Given the FULL ordered variation config list (active AND
146
+ # inactive/stopped arms — never pre-filtered), this folds the JS reference's
147
+ # +getBucketRanges+ + +selectBucketAnchored+ (bm.ts:145-190) into one pass so
148
+ # the method stays self-contained and independently unit-testable:
149
+ #
150
+ # allocation = anchored_allocation(ta) — Float(ta, exception: false) coercion; a
151
+ # nil coercion result (non-numeric/absent ta) defaults to 100.0, mirroring the
152
+ # JS oracle's `isNaN(ta) ? 100.0 : Number(ta)`
153
+ # active = anchored_active?(status, allocation) — ((status.nil? || status == "")
154
+ # ? true : status == "running") && allocation.positive?
155
+ # total_weight = sum(allocation) over ALL entries (active AND inactive)
156
+ # return nil if total_weight <= 0
157
+ # cum = 0.0
158
+ # each entry: anchor = (cum / total_weight) * ANCHORED_MAX_TRAFFIC
159
+ # width = active ? allocation * 100 : 0
160
+ # hit iff anchor <= value < anchor + width (first match wins)
161
+ # cum += allocation
162
+ #
163
+ # Inactive arms (stopped, or an explicit +traffic_allocation: 0+) keep their
164
+ # weight (anchor stability for the OTHER arms) but get zero width, so they
165
+ # can never themselves be selected. This is a DIFFERENT method from the
166
+ # packed {#select_bucket} — that packed walk is untouched for version<=11.
167
+ #
168
+ # @param variations [Array<Hash>] the FULL ordered variation config list
169
+ # (+"id"+, +"traffic_allocation"+, +"status"+) — inactive arms included.
170
+ # @param value [Integer] a bucket value in +[0, ANCHORED_MAX_TRAFFIC)+.
171
+ # @return [String, nil] the selected variation id, or +nil+ (including when
172
+ # +total_weight <= 0+ or the list is empty).
173
+ def select_bucket_anchored(variations, value)
174
+ entries = anchored_allocations(variations)
175
+ # NAIVE left-to-right fold — NOT +Array#sum+. Ruby's +Array#sum+ uses a
176
+ # Kahan-Babuska (Neumaier) compensated algorithm for Float arrays, which
177
+ # can differ from a plain fold at the float64 ULP level. The JS oracle
178
+ # computes +totalWeight+ via a naive +Array.prototype.reduce+
179
+ # (+bucketing-manager.ts:153-156+: +allocations.reduce((sum, {allocation})
180
+ # => sum + allocation, 0)+) — every other SDK (PHP/Python/Android/iOS)
181
+ # matches that same naive fold. A compensated sum here would diverge from
182
+ # every other SDK's +total_weight+ by 1+ ULP on some inputs, which can
183
+ # flip which arm a boundary +value+ resolves to (verified: a 3-way
184
+ # ~33.34/33.33/33.33-style split picks a different arm at its exact
185
+ # anchor boundary depending on the fold algorithm) — a cross-SDK parity
186
+ # break, not just a cosmetic rounding difference.
187
+ total_weight = entries.reduce(0.0) { |acc, entry| acc + entry[:allocation] }
188
+ variation = total_weight.positive? ? anchored_walk(entries, total_weight, value) : nil
189
+
190
+ @log_manager&.debug(
191
+ "BucketingManager#select_bucket_anchored: " \
192
+ "value=#{value} total_weight=#{total_weight} variation=#{variation.inspect}"
193
+ )
194
+ variation
195
+ end
196
+
197
+ # Resolve a visitor to a variation under the ANCHORED layout (contract v12),
198
+ # returning the SAME shape as {#bucket_for_visitor} (AC9 — no return-shape
199
+ # drift): +{variation_id:, bucketing_allocation:}+ or +nil+. Reuses the
200
+ # existing visitor-hash value unchanged (JS +getBucketForVisitorAnchored+,
201
+ # bm.ts:195-215) then resolves it through {#select_bucket_anchored}.
202
+ #
203
+ # @param variations [Array<Hash>] the FULL ordered variation config list
204
+ # (active AND inactive arms) — see {#select_bucket_anchored}.
205
+ # @param visitor_id [#to_s] the visitor identifier.
206
+ # @param experience_id [String] the experience identifier (default +""+).
207
+ # @param seed [Integer] MurmurHash3 seed (default Config hash seed).
208
+ # @return [Hash{Symbol=>Object}, nil] +{variation_id:, bucketing_allocation:}+
209
+ # or +nil+ (caller treats +nil+ as VARIATION_NOT_DECIDED).
210
+ def bucket_for_visitor_anchored(variations, visitor_id, experience_id: "", seed: @hash_seed)
211
+ value = value_visitor_based(visitor_id, experience_id: experience_id, seed: seed)
212
+ selected = select_bucket_anchored(variations, value)
213
+
214
+ @log_manager&.debug(
215
+ "BucketingManager#bucket_for_visitor_anchored: " \
216
+ "experience_id=#{experience_id.inspect} visitor_id=#{visitor_id.inspect} " \
217
+ "bucket_value=#{value} selected_variation_id=#{selected.inspect}"
218
+ )
219
+
220
+ return nil if selected.nil?
221
+
222
+ { variation_id: selected, bucketing_allocation: value }
223
+ end
224
+
225
+ private
226
+
227
+ # Build +{id:, allocation:, active:}+ entries from the raw ordered variation
228
+ # config list, mirroring the JS +_buildVariationAllocations+ mapping
229
+ # (data-manager.ts:591-610). Entries without an +"id"+ are skipped entirely
230
+ # (never counted in +total_weight+, never selectable) — defensive against a
231
+ # sparse/malformed config row, same tolerance the rest of the SDK applies.
232
+ def anchored_allocations(variations)
233
+ variations.each_with_object([]) do |variation, entries|
234
+ next unless variation.is_a?(Hash) && variation["id"]
235
+
236
+ allocation = anchored_allocation(variation["traffic_allocation"])
237
+ active = anchored_active?(variation["status"], allocation)
238
+ entries << { id: variation["id"], allocation: allocation, active: active }
239
+ end
240
+ end
241
+
242
+ # Mirrors the JS +isNaN(ta) ? 100.0 : Number(ta)+ mapping
243
+ # (data-manager.ts:594-601) via +Float(x, exception: false)+ coercion: a
244
+ # numeric-looking String (e.g. +"50"+) coerces to its numeric weight
245
+ # exactly like the JS oracle's +Number()+, not just a genuine
246
+ # Integer/Float. A genuinely non-numeric value, +nil+, or an absent field
247
+ # coerces to +nil+ and defaults to +100.0+ (full-allocation weight, AC5) —
248
+ # RBS core types +Float(untyped, exception: false)+ as +Float?+, so the
249
+ # nil-check both implements the JS default and narrows the return to the
250
+ # declared +-> Float+.
251
+ #
252
+ # Hard boundary (documented, not fixable in Ruby): +JSON.parse+ collapses
253
+ # an explicit JSON +null+ +traffic_allocation+ and an ABSENT field to the
254
+ # same Ruby +nil+, so this SDK cannot reproduce JS's split (JS: absent ->
255
+ # +undefined+ -> +isNaN+ -> 100.0/active; explicit +null+ ->
256
+ # +Number(null)+ is +0+ -> 0/inactive). We match JS for the served/tested
257
+ # case (absent -> 100.0/active); explicit-null +traffic_allocation+ is
258
+ # never served (0 occurrences in the 59-vector golden fixture). The same
259
+ # coercion-class divergence applies to an empty-string/whitespace-only
260
+ # +traffic_allocation+: +Float("", exception: false)+ (and whitespace-only
261
+ # strings) coerce to +nil+ -> defaults to 100.0/active here, whereas JS's
262
+ # +Number("")+ is +0+ -> 0/inactive; behaviorally neutral for the actual
263
+ # contract since +traffic_allocation+ is a backend-served numeric field
264
+ # and this value is never served (0 occurrences of empty-string/
265
+ # whitespace-only +ta+ in the 59-vector golden cross-SDK fixture).
266
+ def anchored_allocation(traffic_allocation)
267
+ coerced = Float(traffic_allocation, exception: false)
268
+ coerced.nil? ? 100.0 : coerced
269
+ end
270
+
271
+ # +true+ when the variation is eligible for a non-zero anchored width: a
272
+ # +nil+/+""+ status defaults to running (JS +status ? status === RUNNING :
273
+ # true+, treating an empty string as falsy), AND the allocation is positive
274
+ # (an explicit +traffic_allocation: 0+ is inactive, never defaulted to 100).
275
+ def anchored_active?(status, allocation)
276
+ status_active = status.nil? || status == "" ? true : status == "running"
277
+ status_active && allocation.positive?
278
+ end
279
+
280
+ # Walk the anchored +entries+ in config order, returning the first entry
281
+ # whose half-open +[anchor, anchor + width)+ band contains +value+, or +nil+
282
+ # when no band covers it. +total_weight+ is guaranteed positive by the caller.
283
+ def anchored_walk(entries, total_weight, value)
284
+ cum = 0.0
285
+ entries.each do |entry|
286
+ anchor = (cum / total_weight) * ANCHORED_MAX_TRAFFIC
287
+ width = entry[:active] ? entry[:allocation] * 100 : 0
288
+ return entry[:id] if value >= anchor && value < anchor + width
289
+
290
+ cum += entry[:allocation]
291
+ end
292
+ nil
293
+ end
133
294
  end
134
295
  end
@@ -347,12 +347,15 @@ module ConvertSdk
347
347
  # Fetch config through the HTTP port and install it on success. A failed
348
348
  # response degrades gracefully: it MAY fall back to a non-stale cached entry
349
349
  # from the store (meaningful across processes with a shared store like
350
- # Redis), otherwise a +warn+ line, no config, no raise.
350
+ # Redis), otherwise a +warn+ line, no config, no raise. The store fallback
351
+ # is skipped entirely when +debug_token+ is configured (qs-03 AC2 —
352
+ # always-live: a stale copy is misleading during QA, so a failed live
353
+ # fetch degrades straight to the no-config warn path).
351
354
  def fetch_and_install_config
352
355
  response = @http_client.request(method: :get, url: config_url, headers: fetch_headers)
353
356
  if response.success? && response.body.is_a?(Hash)
354
357
  install(response.body, "Client#initialize: installed fetched config")
355
- elsif @data_manager.install_from_cache_if_fresh
358
+ elsif @config.debug_token.nil? && @data_manager.install_from_cache_if_fresh
356
359
  @event_manager.fire(SystemEvents::READY, deferred: true)
357
360
  else
358
361
  @log_manager.warn(
@@ -378,14 +381,46 @@ module ConvertSdk
378
381
  end
379
382
  end
380
383
 
381
- # Build the config-fetch URL: +{config_endpoint}/config/{sdkKey}+ with an
382
- # +environment+ query parameter appended only when one is configured.
383
- def config_url
384
+ # Build the config-fetch URL: +{config_endpoint}/config/{sdkKey}+ with
385
+ # query params appended in a fixed order — +environment+ (when configured),
386
+ # then +_conv_low_cache=1+ (when effective low-cache is active), then
387
+ # +debug_token=<value>+ (when configured) — joined with +&+ and prefixed
388
+ # with a single +?+ only when at least one param is present. +environment+
389
+ # stays first so the no-cache_level/no-override shape (the pre-RB-2 AC2
390
+ # regression lock) stays byte-identical: bare URL when no environment,
391
+ # +?environment=...+ with no trailing +&+ otherwise.
392
+ #
393
+ # +force_low_cache:+ is a private, internal-only seam (qs-03's per-fetch
394
+ # experiment-preview override) — not part of the public API. Effective
395
+ # low-cache is an OR of the override, the configured +cache_level+, and a
396
+ # configured +debug_token+ (qs-03 AC1 — a debug token always forces
397
+ # low-cache, regardless of +cache_level+):
398
+ # +force_low_cache || @config.cache_level == "low" || !@config.debug_token.nil?+.
399
+ # +debug_token+ requires no call-site argument — every #config_url call
400
+ # (construction fetch, refresh tick, timer-off refetch) picks it up
401
+ # directly from +@config.debug_token+.
402
+ def config_url(force_low_cache: false)
384
403
  url = "#{@config.config_endpoint}/config/#{@config.sdk_key}"
404
+ params = config_url_params(force_low_cache)
405
+
406
+ return url if params.empty?
407
+
408
+ "#{url}?#{params.join("&")}"
409
+ end
410
+
411
+ # The ordered query-param list for {#config_url} — extracted so the URL
412
+ # builder itself stays small: +environment+, then +_conv_low_cache=1+
413
+ # (effective low-cache), then +debug_token+, each only when applicable.
414
+ def config_url_params(force_low_cache)
385
415
  env = @config.environment
386
- return url if env.nil?
416
+ debug_token = @config.debug_token
417
+ low_cache = force_low_cache || @config.cache_level == "low" || !debug_token.nil?
387
418
 
388
- "#{url}?environment=#{URI.encode_www_form_component(env)}"
419
+ params = [] #: Array[String]
420
+ params << "environment=#{URI.encode_www_form_component(env)}" unless env.nil?
421
+ params << "_conv_low_cache=1" if low_cache
422
+ params << "debug_token=#{URI.encode_www_form_component(debug_token)}" unless debug_token.nil?
423
+ params
389
424
  end
390
425
 
391
426
  # The fetch headers: an +Authorization: Bearer {secret}+ value when a secret
@@ -82,7 +82,16 @@ module ConvertSdk
82
82
  tracking: true,
83
83
  # HTTP client timeouts in seconds (consumed by HttpClient, Story 1.5).
84
84
  open_timeout: 5,
85
- read_timeout: 10
85
+ read_timeout: 10,
86
+ # Network cache-level signal (qs-02); nil is a no-op, "low" appends the
87
+ # platform's low-cache query param to config-fetch URLs.
88
+ cache_level: nil,
89
+ # QA config-transport token (qs-03); nil is a no-op. When set, every
90
+ # config-fetch URL carries debug_token=<value> AND is forced to
91
+ # low-cache, config caching (read fallback + write-through) is disabled,
92
+ # and the value is redacted like sdk_key_secret (never logged in clear,
93
+ # never included in the wire payload built by #to_internal).
94
+ debug_token: nil
86
95
  }.freeze
87
96
 
88
97
  # @!attribute [r] sdk_key
@@ -121,11 +130,19 @@ module ConvertSdk
121
130
  # @return [Numeric] HTTP connect timeout seconds (HttpClient, NFR3).
122
131
  # @!attribute [r] read_timeout
123
132
  # @return [Numeric] HTTP read timeout seconds (HttpClient, NFR3).
133
+ # @!attribute [r] cache_level
134
+ # @return [String, nil] network cache-level signal (JS +network.cacheLevel+);
135
+ # nil is a no-op, "low" appends a low-cache signal to config fetches (qs-02).
136
+ # @!attribute [r] debug_token
137
+ # @return [String, nil] QA config-transport token (qs-03); nil is a no-op.
138
+ # When set, forces a live, low-cache config fetch carrying
139
+ # +debug_token=<value>+ and disables config caching; redacted like
140
+ # +sdk_key_secret+ and never present in {#to_internal}.
124
141
  attr_reader :sdk_key, :sdk_key_secret, :data, :environment,
125
142
  :config_endpoint, :track_endpoint, :max_traffic, :hash_seed,
126
143
  :data_refresh_interval, :event_batch_size, :flush_interval,
127
144
  :keys_case_sensitive, :negation, :log_level, :tracking,
128
- :open_timeout, :read_timeout
145
+ :open_timeout, :read_timeout, :cache_level, :debug_token
129
146
 
130
147
  # Build a validated configuration from snake_case keyword options merged over
131
148
  # {DEFAULTS}. Raises +ArgumentError+ (the SDK's only raising surface) on any
@@ -209,6 +226,7 @@ module ConvertSdk
209
226
 
210
227
  log_manager.register_secret(@sdk_key) unless @sdk_key.nil?
211
228
  log_manager.register_secret(@sdk_key_secret) unless @sdk_key_secret.nil?
229
+ log_manager.register_secret(@debug_token) unless @debug_token.nil?
212
230
  end
213
231
  end
214
232
  end
@@ -20,7 +20,9 @@ module ConvertSdk
20
20
  # * intervals — +data_refresh_interval+ / +flush_interval+ are Numeric or nil
21
21
  # (nil = timer-off); +open_timeout+ / +read_timeout+ are Numeric;
22
22
  # * booleans — +keys_case_sensitive+ / +tracking+ (strict true/false);
23
- # * log level — must be one of the {LogLevel} values.
23
+ # * log level — must be one of the {LogLevel} values;
24
+ # * cache level (qs-02) — must be +nil+ or the String +"low"+;
25
+ # * debug token (qs-03) — must be +nil+ or a String (no fixed allow-list).
24
26
  class ConfigValidator
25
27
  # The accepted {LogLevel} integer values (TRACE..SILENT).
26
28
  LOG_LEVEL_VALUES = [
@@ -28,6 +30,9 @@ module ConvertSdk
28
30
  LogLevel::WARN, LogLevel::ERROR, LogLevel::SILENT
29
31
  ].freeze
30
32
 
33
+ # The accepted +cache_level+ values (qs-02): nil (no-op) or "low".
34
+ CACHE_LEVEL_VALUES = [nil, "low"].freeze
35
+
31
36
  # @param values [Hash{Symbol=>Object}] the merged option values, keyed by the
32
37
  # public snake_case option names (the {Config::DEFAULTS} keys).
33
38
  def initialize(values)
@@ -46,6 +51,7 @@ module ConvertSdk
46
51
  validate_intervals!
47
52
  validate_booleans!
48
53
  validate_log_level!
54
+ validate_cache_level!
49
55
  end
50
56
 
51
57
  private
@@ -59,7 +65,7 @@ module ConvertSdk
59
65
 
60
66
  # String-or-nil options, plus the Hash-or-nil data option.
61
67
  def validate_strings!
62
- %i[sdk_key sdk_key_secret environment config_endpoint track_endpoint negation].each do |name|
68
+ %i[sdk_key sdk_key_secret environment config_endpoint track_endpoint negation debug_token].each do |name|
63
69
  require_string(name, @values[name])
64
70
  end
65
71
  require_type(:data, @values[:data], Hash) unless @values[:data].nil?
@@ -96,6 +102,16 @@ module ConvertSdk
96
102
  "log_level must be a LogLevel value (#{LOG_LEVEL_VALUES.join(", ")}), got #{level.inspect}"
97
103
  end
98
104
 
105
+ # cache_level (qs-02) must be nil or "low" — the platform's low-cache signal.
106
+ def validate_cache_level!
107
+ value = @values[:cache_level]
108
+ return if CACHE_LEVEL_VALUES.include?(value)
109
+
110
+ raise ArgumentError,
111
+ "cache_level must be nil or \"low\" (#{CACHE_LEVEL_VALUES.map(&:inspect).join(", ")}), " \
112
+ "got #{value.inspect}"
113
+ end
114
+
99
115
  # Require String-or-nil (nil acceptable for optional strings).
100
116
  def require_string(name, value)
101
117
  return if value.nil? || value.is_a?(String)