convert_sdk 1.0.1 → 2.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.
@@ -46,6 +46,29 @@ module ConvertSdk
46
46
  # (+nil+ for lookups, +self+ for the chainable mutator). A raising collaborator
47
47
  # degrades the call; it never crashes the host request.
48
48
  class Context
49
+ # The reserved keys the public per-call hash accepts (CAP-3): a +:merged_map+ row
50
+ # lands in the engine envelope at +destination+, a +:raw_per_call+ row in that reader.
51
+ RESERVED_KEYS = {
52
+ "location_properties" => { source: :merged_map, destination: :location_properties,
53
+ scope: "every decision entry point" },
54
+ "environment" => { source: :merged_map, destination: :environment,
55
+ scope: "every decision entry point" },
56
+ "enable_tracking" => { source: :raw_per_call, destination: :tracking_enabled_for_call,
57
+ scope: "honoured on run_experience(s), accepted inert on run_feature(s)" },
58
+ "experience_keys" => { source: :raw_per_call, destination: :experiences,
59
+ scope: "run_feature(s) only; narrows the experiences decided (CAP-1)" },
60
+ "type_casting" => { source: :raw_per_call, destination: :type_casting,
61
+ scope: "run_feature(s) only; false returns the config-stored variables (CAP-2)" },
62
+ "ruleData" => { source: :raw_per_call, destination: :visitor_properties,
63
+ scope: "run_custom_segments only; camelCase on a snake_case surface (SD-4)" }
64
+ }.freeze
65
+
66
+ # Engine-readable keys the seam never lifts, each with its reason (CAP-3's negative list).
67
+ NOT_LIFTED = {
68
+ "enable_storage" => "preview owns the persistence gate (D-4)",
69
+ "update_visitor_properties" => "documented Ruby divergence (D-5)"
70
+ }.freeze
71
+
49
72
  # @param visitor_id [String] the resolved visitor id (validated non-blank by
50
73
  # {Client#create_context} before construction).
51
74
  # @param attributes [Hash, nil] the per-visitor attributes; deep-stringified
@@ -86,6 +109,11 @@ module ConvertSdk
86
109
  # Deep-stringify the caller's attributes ONCE at the boundary; internals
87
110
  # only ever see string keys. nil → empty. The caller's hash is never mutated.
88
111
  @attributes = deep_stringify(attributes || {})
112
+ # qs-03 (RB-5) preview state — nil until {#set_preview} succeeds. A plain
113
+ # per-instance ivar (never a class/shared variable): two Contexts NEVER
114
+ # share this (AC7 isolation). Shape: +{experience_id:, variation_id:,
115
+ # experience:, experience_key:}+.
116
+ @preview = nil #: Hash[Symbol, untyped]?
89
117
  end
90
118
 
91
119
  # @return [String] the visitor id this context is bound to.
@@ -105,12 +133,25 @@ module ConvertSdk
105
133
  # +{segments: props}+ — +context.ts:482+). The merge is atomic per visitor:
106
134
  # the read-modify-write runs inside the store manager's merge mutex.
107
135
  #
136
+ # == Zero-trace under preview (qs-03 AC6, RB-6)
137
+ #
138
+ # On a preview-active context ONLY the store write below is skipped — the
139
+ # in-memory +@attributes+ merge always applies (JS parity: JS's public
140
+ # +updateVisitorProperties+ has no preview guard of its own, but the
141
+ # PRIVATE helper it calls to persist DOES skip entirely under preview —
142
+ # +context.ts:626-629+, +if (this._preview) return;+ — leaving the
143
+ # in-memory side unaffected there too). "Per-context scratch" per the
144
+ # qs-03 spec: a later decision on THIS context still sees the merge; no
145
+ # trace of it ever reaches the store.
146
+ #
108
147
  # @param properties [Hash] the properties to merge (symbol or string keys).
109
148
  # @return [self]
110
149
  def update_visitor_properties(properties)
111
150
  normalised = deep_stringify(properties || {})
112
- @data_store_manager.merge_visitor_data(account_key, project_key, @visitor_id) do |_current|
113
- { "segments" => normalised }
151
+ if @preview.nil?
152
+ @data_store_manager.merge_visitor_data(account_key, project_key, @visitor_id) do |_current|
153
+ { "segments" => normalised }
154
+ end
114
155
  end
115
156
  @attributes = @attributes.merge(normalised)
116
157
  self
@@ -170,6 +211,79 @@ module ConvertSdk
170
211
  nil
171
212
  end
172
213
 
214
+ # Force a specific variation of an experience for THIS context — bypassing
215
+ # audiences, segments, locations, the environment check, experience status,
216
+ # variation status/traffic filters, stored decisions, and the bucketing
217
+ # hash for that experience only (qs-03 AC4/AC5). Mirrors JS
218
+ # +Context#setPreview+ (+context.ts:143-205+); this Ruby surface is
219
+ # synchronous — the +?exp=+ fallback fetch runs through
220
+ # {ApiManager#get_config_by_experience}, itself process-wide memoized for
221
+ # 60s (qs-03 AC8), so no async/await equivalent is needed here.
222
+ #
223
+ # == Resolution
224
+ #
225
+ # The CURRENT installed config is tried first, by id
226
+ # ({DataManager#experience_by_id}); when absent, a live
227
+ # +?exp={experience_id}+ fetch resolves it instead (never touches the
228
+ # installed config or the store — a previewed experience may be draft/
229
+ # paused and must never be cached alongside production config). The
230
+ # resolved experience is held BY REFERENCE, never duped or rebuilt:
231
+ # DataManager's installed config entities are already deep-frozen (Story
232
+ # 2.7), so simply holding the reference carries none of the JS SDK-7
233
+ # in-place-mutation risk (mutating a frozen Ruby Hash raises
234
+ # +FrozenError+ rather than silently corrupting shared state); a fetched
235
+ # experience is a brand-new object that is never installed anywhere, so it
236
+ # is never shared to begin with.
237
+ #
238
+ # == Inert on bad input (AC7)
239
+ #
240
+ # A blank +experience_id+/+variation_id+, an unresolvable experience
241
+ # (absent from both the installed config and the +?exp=+ fetch response),
242
+ # or an unknown +variation_id+ on the resolved experience all leave preview
243
+ # state UNSET (a +warn+ log, never a raise) — a subsequent
244
+ # {#run_experience} on this context decides exactly as if this method had
245
+ # never been called.
246
+ #
247
+ # == Isolation (AC7)
248
+ #
249
+ # Preview state lives on THIS +Context+ instance only (a plain ivar) — two
250
+ # +Context+s, even for the same client/config, never share it.
251
+ #
252
+ # Never raises into the host: an internal failure degrades to an +error+
253
+ # log + leaves preview state unset (NFR9).
254
+ #
255
+ # @param experience_id [String] the previewed experience's +id+.
256
+ # @param variation_id [String] the variation +id+ to force.
257
+ # @return [self]
258
+ def set_preview(experience_id:, variation_id:)
259
+ return warn_preview_inert("experience_id/variation_id required") if blank?(experience_id) || blank?(variation_id)
260
+
261
+ # Coerce ONCE at this public entry (review round 2) — ids may arrive as
262
+ # different types (an Integer id read straight off a link param, a
263
+ # String elsewhere); every downstream reference (resolution, the
264
+ # decision lookup, the stored @preview hash) threads these coerced
265
+ # locals instead of the original keyword args, mirroring
266
+ # {#fetch_preview_experience}'s existing "ids may arrive as different
267
+ # types" +.to_s+ convention.
268
+ exp_id = experience_id.to_s
269
+ var_id = variation_id.to_s
270
+
271
+ experience = resolve_preview_experience(exp_id)
272
+ return warn_preview_inert("no experience found for id=#{exp_id}", clear: true) if experience.nil?
273
+
274
+ decision = @data_manager.get_preview_decision(experience, var_id)
275
+ return warn_preview_inert("no variation found for id=#{var_id}", clear: true) if decision.nil?
276
+
277
+ @preview = {
278
+ experience_id: exp_id, variation_id: var_id,
279
+ experience: experience, experience_key: experience["key"]
280
+ }
281
+ self
282
+ rescue StandardError => e
283
+ @log_manager.error("Context#set_preview: #{e.class}: #{e.message}")
284
+ self
285
+ end
286
+
173
287
  # Decide a single experience for this visitor and return its variation.
174
288
  #
175
289
  # The optional per-call +attributes+ are deep-stringified and merged OVER the
@@ -197,6 +311,29 @@ module ConvertSdk
197
311
  # NO bucketing event is enqueued (a +debug+ line records the suppression). The
198
312
  # global Config +tracking: false+ switch ALWAYS wins over a per-call +true+.
199
313
  #
314
+ # == Preview forcing (qs-03 AC4/AC5)
315
+ #
316
+ # When {#set_preview} has forced a variation for THIS +key+ on this context,
317
+ # that forced decision is returned DIRECTLY — bypassing decisioning entirely
318
+ # (no audience/location/environment/status/traffic/stored-decision/bucketing
319
+ # walk) and firing NO {SystemEvents::BUCKETING} event (mirrors JS
320
+ # +context.ts:228-235+). Because this check runs FIRST, it naturally takes
321
+ # precedence over a stored decision or fresh bucketing for that experience;
322
+ # every OTHER experience on this same context still decides normally.
323
+ #
324
+ # == Zero-trace on OTHER experiences under preview (qs-03 AC6, RB-6)
325
+ #
326
+ # When THIS context has a preview active but +key+ is NOT the previewed
327
+ # experience, decisioning proceeds NORMALLY — only tracking/persistence are
328
+ # suppressed: {#decision_attributes} threads +enable_storage: false+ so
329
+ # {DataManager#persist_bucketing} never writes sticky StoreData, and the
330
+ # per-call tracking verdict is forced +false+ so {#fire_bucketing}'s
331
+ # {#suppress_bucketing_enqueue?} skips the outbound enqueue.
332
+ # {#fire_bucketing} ALSO suppresses the in-process {SystemEvents::BUCKETING}
333
+ # lifecycle-event fire while a preview is active on this context (JS parity
334
+ # — +context.ts:260+: +if (!this._preview) { fire BUCKETING }+). No event
335
+ # fires for any experience on a preview-active context.
336
+ #
200
337
  # @param key [String] the experience +key+.
201
338
  # @param attributes [Hash, nil] optional per-call visitor properties merged
202
339
  # over the context attributes (deep-stringified). May carry +:enable_tracking+.
@@ -205,9 +342,13 @@ module ConvertSdk
205
342
  manager = @experience_manager
206
343
  return RuleError::NO_DATA_FOUND if manager.nil?
207
344
 
345
+ preview = @preview
346
+ return forced_preview_variation(preview) if preview && key == preview[:experience_key]
347
+
208
348
  @data_manager.ensure_fresh_config!
209
349
  variation = manager.select_variation(@visitor_id, key, decision_attributes(attributes))
210
- fire_bucketing(key, variation, track: tracking_enabled_for_call?(attributes)) unless variation.is_a?(Sentinel)
350
+ track = preview.nil? && tracking_enabled_for_call?(attributes)
351
+ fire_bucketing(key, variation, track: track) unless variation.is_a?(Sentinel)
211
352
  variation
212
353
  rescue StandardError => e
213
354
  @log_manager.error("Context#run_experience: #{e.class}: #{e.message}")
@@ -230,6 +371,22 @@ module ConvertSdk
230
371
  # enqueue for THIS call (decisioning + sticky writes unaffected); the global
231
372
  # Config +tracking: false+ switch always wins (Story 4.5).
232
373
  #
374
+ # On a preview-active context (qs-03 AC6, RB-6) the previewed experience is
375
+ # FORCED to its preview variation here too — run-all forcing is API-agnostic,
376
+ # matching {#run_experience} and the PHP/Android/Python/iOS SDKs. Its
377
+ # normally-decided entry is dropped and the forced variation appended (or
378
+ # appended outright when the previewed experience is absent from the run-all
379
+ # set, e.g. a draft resolved only via +?exp=+); the forced entry fires no
380
+ # {SystemEvents::BUCKETING} event, exactly as {#run_experience}'s forced
381
+ # branch returns before {#fire_bucketing}. Every OTHER decided variation is
382
+ # zero-trace like {#run_experience}'s other-experience branch:
383
+ # {#decision_attributes} suppresses the sticky persist, the tracking verdict
384
+ # is forced +false+ so the outbound enqueue is skipped, and
385
+ # {#fire_bucketing}'s +@preview.nil?+ guard suppresses the
386
+ # {SystemEvents::BUCKETING} event as well — NO event fires for ANY
387
+ # experience on a preview-active context (JS parity — +context.ts:260+:
388
+ # +if (!this._preview) { fire BUCKETING }+).
389
+ #
233
390
  # @param attributes [Hash, nil] optional per-call visitor properties merged
234
391
  # over the context attributes (deep-stringified). May carry +:enable_tracking+.
235
392
  # @return [Array<BucketedVariation>] the frozen variations (misses excluded).
@@ -239,6 +396,9 @@ module ConvertSdk
239
396
 
240
397
  @data_manager.ensure_fresh_config!
241
398
  variations = manager.select_variations(@visitor_id, decision_attributes(attributes))
399
+ preview = @preview
400
+ return force_preview_in_run_all(variations, preview) if preview
401
+
242
402
  track = tracking_enabled_for_call?(attributes)
243
403
  variations.each { |variation| fire_bucketing(variation.experience_key, variation, track: track) }
244
404
  variations
@@ -268,23 +428,30 @@ module ConvertSdk
268
428
  # render_legacy_checkout
269
429
  # end
270
430
  #
271
- # NOTE (accepted parity break): JS +runFeature+ accepts an optional
272
- # +experienceKeys+ filter argument; this Ruby surface intentionally OMITS it
273
- # (deferred feature). Resolution always spans all configured experiences.
431
+ # A per-call +experience_keys+ Array narrows which experiences are decided,
432
+ # and so which sticky assignments the read commits (CAP-1); absent, nil or
433
+ # empty decides every configured experience (D-6).
434
+ # A per-call +type_casting+ of +false+ returns variables as config stores them (CAP-2).
435
+ #
436
+ # NOTE (accepted parity break): +type_casting: nil+ leaves casting ON here,
437
+ # where the JS presence-based rule would disable it (D-8).
274
438
  #
275
439
  # Never raises into the host: an internal failure degrades to a DISABLED
276
440
  # {BucketedFeature} (carrying the requested key) + an +error+ log (NFR9).
277
441
  #
278
442
  # @param key [String] the feature +key+ to evaluate.
279
- # @param attributes [Hash, nil] optional per-call visitor properties merged
280
- # over the context attributes (deep-stringified).
443
+ # @param attributes [Hash, nil] optional per-call visitor properties merged over
444
+ # the context attributes (deep-stringified). May carry +:experience_keys+
445
+ # (CAP-1) and +:type_casting+ (CAP-2); only a boolean +false+ disables casting (D-8).
281
446
  # @return [BucketedFeature, Array<BucketedFeature>] the resolved feature(s).
282
447
  def run_feature(key, attributes = nil)
283
448
  manager = @feature_manager
284
449
  return disabled_feature(key) if manager.nil?
285
450
 
286
451
  @data_manager.ensure_fresh_config!
287
- manager.run_feature(@visitor_id, key, decision_attributes(attributes))
452
+ manager.run_feature(@visitor_id, key, decision_attributes(attributes),
453
+ experiences: experience_keys_for_call(attributes),
454
+ type_casting: type_casting_for_call?(attributes))
288
455
  rescue StandardError => e
289
456
  @log_manager.error("Context#run_feature: #{e.class}: #{e.message}")
290
457
  disabled_feature(key)
@@ -303,15 +470,18 @@ module ConvertSdk
303
470
  # Never raises into the host: an internal failure degrades to +[]+ + an
304
471
  # +error+ log (NFR9).
305
472
  #
306
- # @param attributes [Hash, nil] optional per-call visitor properties merged
307
- # over the context attributes (deep-stringified).
473
+ # @param attributes [Hash, nil] optional per-call visitor properties merged over
474
+ # the context attributes (deep-stringified). May carry +:experience_keys+
475
+ # (CAP-1) and +:type_casting+ (CAP-2); only a boolean +false+ disables casting (D-8).
308
476
  # @return [Array<BucketedFeature>] the resolved features (enabled + disabled).
309
477
  def run_features(attributes = nil)
310
478
  manager = @feature_manager
311
479
  return [] if manager.nil?
312
480
 
313
481
  @data_manager.ensure_fresh_config!
314
- manager.run_features(@visitor_id, decision_attributes(attributes))
482
+ manager.run_features(@visitor_id, decision_attributes(attributes),
483
+ experiences: experience_keys_for_call(attributes),
484
+ type_casting: type_casting_for_call?(attributes))
315
485
  rescue StandardError => e
316
486
  @log_manager.error("Context#run_features: #{e.class}: #{e.message}")
317
487
  []
@@ -328,6 +498,12 @@ module ConvertSdk
328
498
  # NO lifecycle event fires on segment attachment (JS parity — neither
329
499
  # +setDefaultSegments+ nor +runCustomSegments+ fire +SystemEvents.SEGMENTS+).
330
500
  #
501
+ # == Zero-trace under preview (qs-03 AC6, RB-6)
502
+ #
503
+ # +enable_storage: @preview.nil?+ threads through to
504
+ # {SegmentsManager#put_segments}, suppressing ONLY the persistence write
505
+ # (mirrors JS SDK-6, +context.ts:571+ — +!this._preview+ passed the same way).
506
+ #
331
507
  # Never raises into the host: a failure degrades to an +error+ log and returns
332
508
  # +self+ (NFR9).
333
509
  #
@@ -337,7 +513,7 @@ module ConvertSdk
337
513
  manager = @segments_manager
338
514
  return self if manager.nil?
339
515
 
340
- manager.put_segments(@visitor_id, deep_stringify(segments || {}))
516
+ manager.put_segments(@visitor_id, deep_stringify(segments || {}), enable_storage: @preview.nil?)
341
517
  self
342
518
  rescue StandardError => e
343
519
  @log_manager.error("Context#set_default_segments: #{e.class}: #{e.message}")
@@ -355,6 +531,13 @@ module ConvertSdk
355
531
  #
356
532
  # NO lifecycle event fires on attachment (JS parity, F-014).
357
533
  #
534
+ # == Zero-trace under preview (qs-03 AC6, RB-6)
535
+ #
536
+ # +enable_storage: @preview.nil?+ threads through to
537
+ # {SegmentsManager#select_custom_segments}, suppressing ONLY the persistence
538
+ # write — rule MATCHING still runs exactly as normal (mirrors JS SDK-6,
539
+ # +context.ts:610+ — +!this._preview+ passed the same way).
540
+ #
358
541
  # Never raises into the host: a failure degrades to an +error+ log + +nil+ (NFR9).
359
542
  #
360
543
  # @param segment_keys [Array<String>] the segment keys to evaluate.
@@ -366,7 +549,9 @@ module ConvertSdk
366
549
  manager = @segments_manager
367
550
  return nil if manager.nil?
368
551
 
369
- result = manager.select_custom_segments(@visitor_id, segment_keys, visitor_properties(attributes))
552
+ result = manager.select_custom_segments(
553
+ @visitor_id, segment_keys, visitor_properties(attributes), enable_storage: @preview.nil?
554
+ )
370
555
  result.is_a?(Sentinel) ? result : nil
371
556
  rescue StandardError => e
372
557
  @log_manager.error("Context#run_custom_segments: #{e.class}: #{e.message}")
@@ -408,6 +593,14 @@ module ConvertSdk
408
593
  # @param force_multiple_transactions [Boolean] bypass the per-goal dedup check.
409
594
  # @return [self]
410
595
  def track_conversion(goal_key, goal_data: nil, force_multiple_transactions: false)
596
+ # qs-03 (RB-6) — zero-trace: a preview-active context is a FULL no-op here,
597
+ # checked BEFORE the global tracking gate below (and BEFORE
598
+ # DataManager#convert) so NOTHING happens — no enqueue, no CONVERSION
599
+ # event, and no dedup mark (the mark lives inside #convert's atomic
600
+ # dedup-and-mark, never reached). Mirrors JS +context.ts:512-519+
601
+ # (+if (this._preview) return;+ at the top of +trackConversion+).
602
+ return self if @preview
603
+
411
604
  # Story 4.5 — the global tracking gate sits BEFORE DataManager#convert so a
412
605
  # suppressed conversion neither enqueues NOR marks dedup (the goals[goalId]
413
606
  # mark lives inside #convert's atomic dedup-and-mark). A subsequent same-goal
@@ -433,6 +626,91 @@ module ConvertSdk
433
626
 
434
627
  private
435
628
 
629
+ # {#set_preview}'s inert-path helper: warn-log +detail+ under the
630
+ # +Context#set_preview+ prefix (the single log-message shape every inert
631
+ # branch shares — AC7) and return +self+.
632
+ #
633
+ # +clear:+ mirrors JS +Context#setPreview+ (+context.ts:143-205+) exactly:
634
+ # the BLANK-input guard (this method's caller with +clear: false+, the
635
+ # default) leaves a prior successful +@preview+ untouched — JS's own
636
+ # +if (!experienceId || !variationId)+ branch returns without touching
637
+ # +this._preview+ (+context.ts:146-152+). Every OTHER failure path — an
638
+ # unresolvable experience (+context.ts:172,182+) or an unknown variation id
639
+ # on the resolved experience (+context.ts:195+) — explicitly nulls the
640
+ # preview (JS: +this._preview = null;+ before each of those returns), so a
641
+ # FAILED re-preview after a prior success falls back to normal decisioning
642
+ # rather than stranding the stale forced pick. Callers pass +clear: true+
643
+ # for those two paths only.
644
+ def warn_preview_inert(detail, clear: false)
645
+ @preview = nil if clear
646
+ @log_manager.warn("Context#set_preview: #{detail}")
647
+ self
648
+ end
649
+
650
+ # {#run_experience}'s preview-forcing branch: force-decide THIS visitor's
651
+ # previewed variation via {DataManager#get_preview_decision}. +#set_preview+
652
+ # only ever stores a preview whose (experience, variation_id) pair already
653
+ # resolved a decision, and the resolved experience is a frozen Hash that can
654
+ # never drift afterward, so a nil result here is a defensive fallback, not
655
+ # an expected path.
656
+ def forced_preview_variation(preview)
657
+ @data_manager.get_preview_decision(preview[:experience], preview[:variation_id]) || RuleError::NO_DATA_FOUND
658
+ end
659
+
660
+ # {#run_experiences}'s preview branch, extracted to keep #run_experiences
661
+ # within RuboCop's ABC/complexity budget: drop the previewed experience's
662
+ # normally-decided entry (the forced decision replaces it), route every
663
+ # OTHER experience through {#fire_bucketing} with +track: false+, then
664
+ # append the forced variation. On a preview-active context that
665
+ # {#fire_bucketing} call is inert BY DESIGN — its +@preview.nil?+ guard
666
+ # suppresses the {SystemEvents::BUCKETING} event and +track: false+
667
+ # suppresses the enqueue, so all it emits is the +debug+ suppression line.
668
+ # The seam is kept rather than skipped so the zero-trace verdict stays
669
+ # enforced at the SINGLE bucketing site instead of being duplicated here.
670
+ # A defensive Sentinel from {#forced_preview_variation} (set_preview
671
+ # pre-validates, so not expected) appends nothing.
672
+ def force_preview_in_run_all(variations, preview)
673
+ others = variations.reject { |variation| variation.experience_key == preview[:experience_key] }
674
+ others.each { |variation| fire_bucketing(variation.experience_key, variation, track: false) }
675
+ forced = forced_preview_variation(preview)
676
+ forced.is_a?(Sentinel) ? others : others + [forced]
677
+ end
678
+
679
+ # Resolve the previewed experience for {#set_preview}: the installed
680
+ # config's by-id reader first ({DataManager#experience_by_id}); when
681
+ # absent, the +?exp=+ live-fetch fallback ({#fetch_preview_experience}).
682
+ def resolve_preview_experience(experience_id)
683
+ @data_manager.experience_by_id(experience_id) || fetch_preview_experience(experience_id)
684
+ end
685
+
686
+ # The +?exp={experience_id}+ fallback fetch ({ApiManager#get_config_by_experience},
687
+ # process-wide memoized for 60s — qs-03 AC8) for an experience absent from
688
+ # the installed config (e.g. a draft/paused preview target). A nil
689
+ # {ApiManager} (no Client-wired collaborator) or a failed fetch (the port
690
+ # never raises — it degrades to nil) both resolve to a miss here; the
691
+ # response's own +experiences+ collection is scanned by id (+to_s+
692
+ # compared, ids may arrive as different types).
693
+ def fetch_preview_experience(experience_id)
694
+ manager = @api_manager
695
+ return nil if manager.nil?
696
+
697
+ config = manager.get_config_by_experience(experience_id)
698
+ return nil unless config.is_a?(Hash)
699
+
700
+ experiences = config["experiences"]
701
+ return nil unless experiences.is_a?(Array)
702
+
703
+ target = experience_id.to_s
704
+ experiences.find { |candidate| candidate.is_a?(Hash) && candidate["id"].to_s == target }
705
+ end
706
+
707
+ # True for +nil+ or a (post-+#strip+) empty String — the blank-input guard
708
+ # for {#set_preview}'s +experience_id+/+variation_id+ (mirrors
709
+ # {Client#create_context}'s blank +visitor_id+ guard).
710
+ def blank?(value)
711
+ value.nil? || (value.respond_to?(:strip) && value.strip.empty?)
712
+ end
713
+
436
714
  # The single conversion seam (mirrors {#fire_bucketing}): enqueue the
437
715
  # wire-shaped event THEN fire the lifecycle event with +deferred: true+ (late
438
716
  # subscribers replay — JS context.ts:416-424). Fired on SUCCESS only (the
@@ -469,7 +747,8 @@ module ConvertSdk
469
747
  # per-call +ruleData+ (and context attributes) win over stored segments. All
470
748
  # deep-stringified to string keys (the rule engine reads string keys).
471
749
  def visitor_properties(attributes)
472
- rule_data = attributes.is_a?(Hash) ? (attributes[:ruleData] || attributes["ruleData"]) : nil
750
+ key = reserved_key_name(:visitor_properties)&.to_s
751
+ rule_data = key && attributes.is_a?(Hash) ? attributes[key.to_sym] || attributes[key] : nil
473
752
  empty = {} #: Hash[String, untyped]
474
753
  merged = @attributes.merge(deep_stringify(rule_data || empty))
475
754
  stored = get_visitor_data["segments"]
@@ -491,13 +770,53 @@ module ConvertSdk
491
770
  # it never defaults location matching to the visitor properties) — supplied
492
771
  # only when the caller passes +location_properties+/+"location_properties"+.
493
772
  # +environment+ is lifted out so the flow's environment-match step sees it.
773
+ #
774
+ # +enable_storage: @preview.nil?+ (qs-03 / RB-6 zero-trace) rides along on
775
+ # EVERY decision built through this ONE seam — {#run_experience},
776
+ # {#run_experiences}, {#run_feature}, and {#run_features} all call it — so a
777
+ # preview-active context's ENTIRE decisioning surface (not just experiences)
778
+ # never persists sticky StoreData ({DataManager#persist_bucketing}'s gate).
779
+ # Absent preview (the overwhelming default), this is always +true+ —
780
+ # byte-identical to the pre-qs-03 behavior.
494
781
  def decision_attributes(per_call)
495
782
  merged = @attributes.merge(deep_stringify(per_call || {}))
496
- {
497
- visitor_properties: merged,
498
- location_properties: merged["location_properties"],
499
- environment: merged["environment"]
500
- }
783
+ envelope = { visitor_properties: merged } #: Hash[Symbol, untyped]
784
+ reserved_rows(:merged_map).each { |name, fields| envelope[fields[:destination]] = merged[name.to_s] }
785
+ envelope[:enable_storage] = @preview.nil?
786
+ envelope
787
+ end
788
+
789
+ def reserved_rows(source)
790
+ RESERVED_KEYS.reject { |name, fields| NOT_LIFTED.key?(name.to_s) || fields[:source] != source }
791
+ end
792
+
793
+ # The per-call key name the enumeration routes to +destination+, nil when none does.
794
+ def reserved_key_name(destination)
795
+ row = reserved_rows(:raw_per_call).find { |_, fields| fields[:destination] == destination }
796
+ row&.first
797
+ end
798
+
799
+ # The per-call experience-key filter (CAP-1). A non-Array value degrades to
800
+ # no filter with a +warn+ (SD-2); nil is absence and never warns.
801
+ def experience_keys_for_call(attributes)
802
+ key = reserved_key_name(:experiences)&.to_s
803
+ return nil unless key && attributes.is_a?(Hash)
804
+
805
+ value = attributes.fetch(key.to_sym) { attributes.fetch(key, nil) }
806
+ return value if value.nil? || value.is_a?(Array)
807
+
808
+ @log_manager.warn("Context#run_feature: #{key} must be an Array, got #{value.class} — ignoring it")
809
+ nil
810
+ end
811
+
812
+ # The per-call casting switch (CAP-2): only an explicit +false+ turns it off (D-8).
813
+ def type_casting_for_call?(attributes)
814
+ return true unless attributes.is_a?(Hash)
815
+
816
+ key = reserved_key_name(:type_casting)&.to_s
817
+ return true if key.nil?
818
+
819
+ attributes.fetch(key.to_sym) { attributes.fetch(key, true) } != false
501
820
  end
502
821
 
503
822
  # The single named seam fired once per fresh/decided variation. It does TWO
@@ -514,20 +833,26 @@ module ConvertSdk
514
833
  # wire entry omits the +segments+ key entirely.
515
834
  #
516
835
  # The SOLE bucketing enqueue site. The {SystemEvents::BUCKETING} LIFECYCLE event
517
- # ALWAYS fires (it is decisioning observability, not tracking — a host listener
518
- # may need to react to the decision even under consent denial); only the
519
- # outbound ENQUEUE is gated by the tracking switch (Story 4.5). +track+ is the
836
+ # fires for every fresh/decided variation on a NON-preview context (decisioning
837
+ # observability, not tracking — a host listener may need to react to the
838
+ # decision even under consent denial); a preview-active context (qs-03,
839
+ # +@preview+ set) fires NO {SystemEvents::BUCKETING} event for ANY
840
+ # experience on this context, matching JS +context.ts:260+
841
+ # (+if (!this._preview) { fire BUCKETING }+). Independently, the outbound
842
+ # ENQUEUE is gated by the tracking switch (Story 4.5). +track+ is the
520
843
  # composed verdict ({#tracking_enabled_for_call?} — global AND per-call); when +false+
521
844
  # the wire enqueue is suppressed with a +debug+ line and stickiness/decisioning
522
845
  # are untouched. Contained — a raising listener never crosses back (EventManager
523
846
  # swallows it); the enqueue is pure in-memory and inert when no ApiManager is wired.
524
847
  def fire_bucketing(experience_key, variation, track: true)
525
- @event_manager.fire(
526
- SystemEvents::BUCKETING,
527
- { visitor_id: @visitor_id, experience_key: experience_key, variation_key: variation.key },
528
- nil,
529
- deferred: true
530
- )
848
+ if @preview.nil?
849
+ @event_manager.fire(
850
+ SystemEvents::BUCKETING,
851
+ { visitor_id: @visitor_id, experience_key: experience_key, variation_key: variation.key },
852
+ nil,
853
+ deferred: true
854
+ )
855
+ end
531
856
  return if suppress_bucketing_enqueue?(track)
532
857
 
533
858
  enqueue_bucketing_event(variation)
@@ -557,7 +882,10 @@ module ConvertSdk
557
882
  def tracking_enabled_for_call?(attributes)
558
883
  return true unless attributes.is_a?(Hash)
559
884
 
560
- value = attributes.fetch(:enable_tracking) { attributes.fetch("enable_tracking", true) }
885
+ key = reserved_key_name(:tracking_enabled_for_call)&.to_s
886
+ return true if key.nil?
887
+
888
+ value = attributes.fetch(key.to_sym) { attributes.fetch(key, true) }
561
889
  value != false
562
890
  end
563
891