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.
@@ -92,23 +92,27 @@ module ConvertSdk
92
92
  # so a Context can supply its own resolution without re-reading config.
93
93
  # @param project_resolver [#call, nil] returns the project id for the visitor
94
94
  # store key; defaults to {#project_id}.
95
+ # @param config_cache_disabled [Boolean] when true, {#cache_config} is a
96
+ # no-op — the config-cache WRITE is suppressed. Ruby-SDK-only hardening
97
+ # (qs-03, NOT a JS-parity concern): while a +debug_token+ is configured,
98
+ # a shared store (e.g. Redis) must never be poisoned with a
99
+ # debug-widened config that a production reader could later pick up.
100
+ # Sourced by {ConvertSdk.build_data_manager} from +!config.debug_token.nil?+.
101
+ # Defaults to +false+ (cache writes enabled) for standalone construction.
95
102
  def initialize(log_manager:, data_store_manager: nil, config_key: nil, ttl: nil,
96
103
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, refetch: nil,
97
104
  bucketing_manager: nil, rule_manager: nil,
98
- account_resolver: nil, project_resolver: nil)
105
+ account_resolver: nil, project_resolver: nil, config_cache_disabled: false)
99
106
  @log_manager = log_manager
100
107
  @data_store_manager = data_store_manager
101
108
  @config_key = config_key
102
109
  @ttl = ttl
110
+ @config_cache_disabled = config_cache_disabled
103
111
  # Timer-off (Lambda/CLI) mode is exactly "no refresh interval configured".
104
112
  @timer_off = ttl.nil?
105
113
  @clock = clock
106
114
  @refetch = refetch
107
- # Decision-flow collaborators (Story 2.11). Config-read-only when absent.
108
- @bucketing_manager = bucketing_manager
109
- @rule_manager = rule_manager
110
- @account_resolver = account_resolver || -> { account_id }
111
- @project_resolver = project_resolver || -> { project_id }
115
+ assign_collaborators(bucketing_manager, rule_manager, account_resolver, project_resolver)
112
116
  # The deep-frozen config envelope, or nil before the first install. Read
113
117
  # lock-free by every reader; replaced atomically under @config_mutex.
114
118
  @config = nil #: Hash[String, untyped]?
@@ -291,6 +295,17 @@ module ConvertSdk
291
295
  find_by_key(experiences, key)
292
296
  end
293
297
 
298
+ # @param id [String] the experience +id+ to find (+to_s+ compared — ids may
299
+ # arrive as Integer or String across the wire, e.g. a caller-supplied
300
+ # +experience_id+ vs. the config's JSON-parsed +id+). Used by the qs-03
301
+ # preview resolution ({Context#set_preview}) to look the previewed
302
+ # experience up against the CURRENT installed config before falling back
303
+ # to the +?exp=+ live fetch.
304
+ # @return [Hash, nil] the frozen experience with that id, or nil.
305
+ def experience_by_id(experience_id)
306
+ find_by_id(experiences, experience_id)
307
+ end
308
+
294
309
  # @param key [String] the feature +key+ to find.
295
310
  # @return [Hash, nil] the frozen feature with that key, or nil.
296
311
  def feature_by_key(key)
@@ -338,6 +353,32 @@ module ConvertSdk
338
353
  retrieve_bucketing(visitor_id, experience, attributes)
339
354
  end
340
355
 
356
+ # ============================ PREVIEW (qs-03) ===========================
357
+ # Force-decide a specific variation on a caller-supplied experience,
358
+ # bypassing the ENTIRE decision flow above: audiences, segments, locations,
359
+ # the environment check, experience status, variation status/traffic
360
+ # filters, stored decisions, and the bucketing hash. Mirrors JS
361
+ # data-manager.ts +getPreviewDecision+ (qs-03 / SDK-4).
362
+ #
363
+ # PURE by construction: reads ONLY +experience["variations"]+ (via
364
+ # {#retrieve_variation}) -- never the installed config -- because a preview
365
+ # experience fetched via +?exp=+ may not be registered there. Has ZERO side
366
+ # effects: never calls {#persist_bucketing} / +merge_visitor_data+ and never
367
+ # enqueues anything (this method has no store or transport collaborator to
368
+ # call in the first place).
369
+ #
370
+ # @param experience [Hash] the experience config entity to preview (may be
371
+ # absent from the installed config).
372
+ # @param variation_id [String] the variation id to force (+to_s+ compared).
373
+ # @return [BucketedVariation, nil] the forced decision, or nil when
374
+ # +variation_id+ does not match any variation on +experience+.
375
+ def get_preview_decision(experience, variation_id)
376
+ variation = retrieve_variation(experience, variation_id)
377
+ return nil if variation.nil?
378
+
379
+ build_bucketed_variation(experience, variation, nil)
380
+ end
381
+
341
382
  # ========================== CONVERSION TRACKING =========================
342
383
  # Track a conversion for +visitor_id+ on +goal_key+ with optional revenue /
343
384
  # transaction +goal_data+, applying two-level goal dedup (Story 4.3).
@@ -393,6 +434,17 @@ module ConvertSdk
393
434
 
394
435
  private
395
436
 
437
+ # Assign the decision-flow collaborators (Story 2.11) and their store-key
438
+ # resolvers — extracted from #initialize to keep it small. Collaborators
439
+ # are config-read-only when absent; resolvers default to this manager's
440
+ # own {#account_id} / {#project_id} readers when not injected.
441
+ def assign_collaborators(bucketing_manager, rule_manager, account_resolver, project_resolver)
442
+ @bucketing_manager = bucketing_manager
443
+ @rule_manager = rule_manager
444
+ @account_resolver = account_resolver || -> { account_id }
445
+ @project_resolver = project_resolver || -> { project_id }
446
+ end
447
+
396
448
  # The atomic check-then-mark (Story 4.3 / qs-01). Runs the dedup decision AND
397
449
  # the mark inside ONE store-merge block (the merge mutex). Returns true when
398
450
  # the caller should enqueue, false when the conversion is deduplicated.
@@ -476,6 +528,35 @@ module ConvertSdk
476
528
  bucketing.is_a?(Hash) ? bucketing : {}
477
529
  end
478
530
 
531
+ # qs-04 mutual-exclusion resolver builder: a per-call pure
532
+ # +->(target_experience_key) { true | false | nil }+ closure over
533
+ # +visitor_id+ ONLY (no global state, no cross-visitor memoization — a
534
+ # fresh lambda per {#match_rules_by_field} call) matching
535
+ # {RuleManager#is_rule_matched}'s +resolver:+ contract exactly.
536
+ #
537
+ # Resolution (the qs-04 normative algorithm):
538
+ # target = config experience whose key == target_experience_key
539
+ # bucketed_raw = target exists AND the visitor's stored bucketing map
540
+ # contains an entry for target["id"].to_s
541
+ #
542
+ # Both reads are pure: {#experience_by_key} reads the frozen installed
543
+ # config; {#stored_bucketing_map} reads the visitor's StoreData via the
544
+ # store seam. Neither ever triggers bucketing of the target
545
+ # (BucketingManager is never called here), never writes (no
546
+ # +merge_visitor_data+), and never tracks — the AC5 read-only invariant.
547
+ #
548
+ # @return [Proc] +nil+ from the built lambda means "target key
549
+ # unresolvable" — RuleManager itself logs the AC8 warning naming the key
550
+ # and applies negation around a +bucketed_raw = false+.
551
+ def mutual_exclusion_resolver(visitor_id)
552
+ lambda do |target_experience_key|
553
+ target = experience_by_key(target_experience_key)
554
+ next nil if target.nil?
555
+
556
+ stored_bucketing_map(visitor_id).key?(target["id"].to_s)
557
+ end
558
+ end
559
+
479
560
  # Steps 1-7: resolve the experience and run every eligibility gate up to (but
480
561
  # not including) traffic allocation. Returns the matched experience Hash, a
481
562
  # {Sentinel} (a propagated {RuleError} from a rule walk), or +nil+ (a plain
@@ -495,7 +576,11 @@ module ConvertSdk
495
576
  return reason_miss(experience, "location not match") unless location_outcome
496
577
 
497
578
  # Step 6 — audiences (permanent skipped when bucketed; transient always).
498
- audiences_outcome = match_audiences(experience, attributes[:visitor_properties], is_bucketed)
579
+ # qs-04: build the mutual-exclusion resolver here (visitor_id is in scope)
580
+ # and thread it down through match_audiences -> matched_audiences ->
581
+ # RuleManager#is_rule_matched — the ONLY site that builds it.
582
+ resolver = mutual_exclusion_resolver(visitor_id)
583
+ audiences_outcome = match_audiences(experience, attributes[:visitor_properties], is_bucketed, resolver)
499
584
  return audiences_outcome if audiences_outcome.is_a?(Sentinel)
500
585
 
501
586
  # Step 7 — custom segments. Both must pass to reach variation selection.
@@ -566,8 +651,7 @@ module ConvertSdk
566
651
  # No covering bucket OR a drifted-out selected id -> VARIATION_NOT_DECIDED.
567
652
  def bucket_fresh(visitor_id, experience, attributes)
568
653
  experience_id = experience["id"].to_s
569
- buckets = build_buckets(experience)
570
- decision = @bucketing_manager&.bucket_for_visitor(buckets, visitor_id, experience_id: experience_id)
654
+ decision = fresh_bucketing_decision(visitor_id, experience, experience_id)
571
655
  variation_id = decision&.fetch(:variation_id, nil)
572
656
  variation = variation_id && retrieve_variation(experience, variation_id)
573
657
  if variation.nil?
@@ -580,6 +664,37 @@ module ConvertSdk
580
664
  build_bucketed_variation(experience, variation, decision&.fetch(:bucketing_allocation, nil))
581
665
  end
582
666
 
667
+ # qs-01 (bucketing contract v12) anchored-vs-packed GATE. Mirrors the JS
668
+ # reference's +Number(experience.version) > 11+ (data-manager.ts:695) via
669
+ # +Float(x, exception: false)+ coercion: a numeric-looking String (e.g.
670
+ # +"12"+) counts as anchored exactly like the JS oracle, not just a
671
+ # genuine +Numeric+. Missing / nil / genuinely non-numeric (e.g.
672
+ # +"twelve"+) / <= 11 all coerce to +nil+ or a value <= 11 and take the
673
+ # EXISTING packed walk, byte-for-byte unchanged (+build_buckets+ +
674
+ # +bucket_for_visitor+, both untouched by this story). The anchored branch
675
+ # is fed the FULL ordered {#variation_list} (inactive arms retained for
676
+ # anchor stability) — never {#build_buckets}, which drops inactive arms
677
+ # for the packed walk.
678
+ #
679
+ # Hard boundary (documented, not fixable in Ruby): +JSON.parse+ collapses
680
+ # an explicit JSON +null+ version and an ABSENT version to the same Ruby
681
+ # +nil+, so this SDK cannot reproduce a JS split between the two (JS:
682
+ # absent -> +undefined+ -> +Number(undefined)+ is +NaN+ -> packed;
683
+ # explicit +null+ -> +Number(null)+ is +0+ -> also packed here, so the two
684
+ # actually agree for this field). Never served in practice regardless.
685
+ def fresh_bucketing_decision(visitor_id, experience, experience_id)
686
+ version = Float(experience["version"], exception: false)
687
+ anchored = !version.nil? && version > 11
688
+ if anchored
689
+ @bucketing_manager&.bucket_for_visitor_anchored(
690
+ variation_list(experience), visitor_id, experience_id: experience_id
691
+ )
692
+ else
693
+ buckets = build_buckets(experience)
694
+ @bucketing_manager&.bucket_for_visitor(buckets, visitor_id, experience_id: experience_id)
695
+ end
696
+ end
697
+
583
698
  # True when +experience.id+ is in the archived-experiences list (to_s match).
584
699
  def archived?(experience)
585
700
  id = experience["id"].to_s
@@ -653,7 +768,7 @@ module ConvertSdk
653
768
  # re-evaluated. +matching_options.audiences == "all"+ requires every checked
654
769
  # audience to match; otherwise any match suffices. Returns true/false or a
655
770
  # propagated {RuleError} sentinel. Mirrors JS data-manager.ts:350-416.
656
- def match_audiences(experience, visitor_properties, is_bucketed)
771
+ def match_audiences(experience, visitor_properties, is_bucketed, resolver)
657
772
  # JS parity (data-manager.ts:356-416): +audiencesMatched+ defaults to FALSE
658
773
  # and is only ever set true INSIDE +if (visitorProperties)+. A nil/absent
659
774
  # visitor-properties bag therefore GATES the experience (false), even when
@@ -665,7 +780,7 @@ module ConvertSdk
665
780
  to_check = audiences_to_check(experience, is_bucketed)
666
781
  return true if to_check.empty? # unrestricted (no audiences, or all permanent+bucketed)
667
782
 
668
- matched = matched_audiences(to_check, visitor_properties)
783
+ matched = matched_audiences(to_check, visitor_properties, resolver)
669
784
  return matched if matched.is_a?(Sentinel)
670
785
 
671
786
  audiences_verdict?(experience, matched, to_check)
@@ -690,12 +805,17 @@ module ConvertSdk
690
805
 
691
806
  # Walk each checked audience's rules; collect the matches. A propagated
692
807
  # {RuleError} sentinel from any walk short-circuits and is returned as-is.
693
- def matched_audiences(to_check, visitor_properties)
808
+ # qs-04: +resolver+ is threaded unchanged into {RuleManager#is_rule_matched}
809
+ # — consulted ONLY by a +bucketed_into_experience_key+ leaf; every other
810
+ # leaf (the 3 generic rule types, AC7) ignores it, byte-identical to before.
811
+ def matched_audiences(to_check, visitor_properties, resolver)
694
812
  matched = [] #: Array[Hash[String, untyped]]
695
813
  to_check.each do |audience|
696
814
  next unless audience["rules"]
697
815
 
698
- result = @rule_manager&.is_rule_matched(visitor_properties, audience["rules"], "audience ##{audience["id"]}")
816
+ result = @rule_manager&.is_rule_matched(
817
+ visitor_properties, audience["rules"], "audience ##{audience["id"]}", resolver: resolver
818
+ )
699
819
  return result if result.is_a?(Sentinel)
700
820
 
701
821
  matched << audience if result == true
@@ -781,7 +901,17 @@ module ConvertSdk
781
901
  # (atomic merge via DataStoreManager). Optionally also stores visitor
782
902
  # properties as segments (JS updateVisitorProperties path). In-memory store
783
903
  # ops only (NFR1; user-supplied Redis trades the no-disk contract).
904
+ #
905
+ # +attributes[:enable_storage]+ (qs-03 / RB-6 zero-trace) gates this write:
906
+ # a preview {Context} threads +enable_storage: false+ through
907
+ # {Context#decision_attributes} for the ENTIRE call (target AND other
908
+ # experiences), so a fresh decision made while previewing is never
909
+ # persisted. Absent (or any non-false value) defaults to +true+ — every
910
+ # existing non-preview caller (including direct {#get_bucketing} unit
911
+ # calls with a bare +{}+ attributes hash) is unaffected.
784
912
  def persist_bucketing(visitor_id, experience_id, variation_id, attributes)
913
+ return unless attributes.fetch(:enable_storage, true)
914
+
785
915
  manager = @data_store_manager
786
916
  return if manager.nil?
787
917
 
@@ -840,9 +970,14 @@ module ConvertSdk
840
970
 
841
971
  # Write the freshly-installed config through to the store, wrapped with a
842
972
  # WALL-CLOCK +fetched_at+ for cross-process staleness. A no-op when no store
843
- # or key is wired (standalone unit construction). The DataStoreManager
844
- # contains any store failure (logged), so this never crashes an install.
973
+ # or key is wired (standalone unit construction), or when
974
+ # +config_cache_disabled+ was set at construction (qs-03 an active
975
+ # +debug_token+ suppresses the write so a shared store is never poisoned
976
+ # with a debug-widened config). The DataStoreManager contains any store
977
+ # failure (logged), so this never crashes an install.
845
978
  def cache_config(frozen)
979
+ return if @config_cache_disabled
980
+
846
981
  store = @data_store_manager
847
982
  key = @config_key
848
983
  return if store.nil? || key.nil?
@@ -887,6 +1022,14 @@ module ConvertSdk
887
1022
  list.find { |entity| entity.is_a?(Hash) && entity["key"] == key }
888
1023
  end
889
1024
 
1025
+ # Linear scan for the entity whose +"id"+ +to_s+-matches +id+. Mirrors
1026
+ # {#find_by_key}; +to_s+ comparison because ids may arrive as different
1027
+ # types across the wire (JSON Integer vs. a caller-supplied String).
1028
+ def find_by_id(list, entity_id)
1029
+ target = entity_id.to_s
1030
+ list.find { |entity| entity.is_a?(Hash) && entity["id"].to_s == target }
1031
+ end
1032
+
890
1033
  # Build a recursively-frozen copy of +node+. Hashes and arrays are rebuilt
891
1034
  # with frozen children then frozen; strings are duped-and-frozen; immutable
892
1035
  # scalars (Integer/Float/Symbol/true/false/nil) pass through unchanged. The
@@ -31,9 +31,11 @@ module ConvertSdk
31
31
  # The single-character ellipsis appended after the unmasked prefix (or used
32
32
  # as the whole replacement for short secrets).
33
33
  MASK_GLYPH = "…"
34
- # Matches an +http(s)+ URL's query string: a +?+ and everything up to the
35
- # next whitespace. The query is stripped; the path is kept.
36
- URL_QUERY_PATTERN = %r{(https?://\S*?)\?\S*}
34
+ # Matches an +http(s)+ URL up to the next whitespace. +strip_url_queries+
35
+ # then drops the +?query+ portion in code, keeping the path. A single
36
+ # trailing quantifier (no quantifier competing after +https?://+) keeps the
37
+ # match linear — no polynomial backtracking on repeated +http://+ prefixes.
38
+ URL_QUERY_PATTERN = %r{https?://\S*}
37
39
 
38
40
  # @param secrets [Array<String, nil>] secret values to mask. nil/blank
39
41
  # entries are ignored.
@@ -87,7 +89,7 @@ module ConvertSdk
87
89
 
88
90
  # Drop the query string from any URL in the message, keeping the path.
89
91
  def strip_url_queries(message)
90
- message.gsub(URL_QUERY_PATTERN, '\1')
92
+ message.gsub(URL_QUERY_PATTERN) { |url| url.split("?", 2).first }
91
93
  end
92
94
  end
93
95
  end
@@ -65,9 +65,17 @@ module ConvertSdk
65
65
  # @param data [Hash{String=>Object}] the key-value data to match.
66
66
  # @param rule_set [Hash] the OR/AND/OR_WHEN rule structure.
67
67
  # @param log_entry [String, nil] an optional label for the entity being matched.
68
+ # @param resolver [Proc, nil] qs-04 mutual-exclusion resolver:
69
+ # +->(target_experience_key) { true | false | nil }+. Threaded unchanged
70
+ # through the whole walk and consulted ONLY by a
71
+ # +bucketed_into_experience_key+ leaf ({#process_rule_item}); every other
72
+ # leaf ignores it. +nil+ (no resolver injected at all) makes such a leaf
73
+ # fall closed to +false+ with negation unapplied; a resolver that itself
74
+ # returns +nil+ for an unresolvable target key is treated as
75
+ # +bucketed_raw = false+ plus a warning naming the key.
68
76
  # @return [Boolean, Sentinel] true/false, or a {RuleError} sentinel propagated
69
77
  # from a leaf.
70
- def is_rule_matched(data, rule_set, log_entry = nil)
78
+ def is_rule_matched(data, rule_set, log_entry = nil, resolver: nil)
71
79
  or_groups = nonempty_block(rule_set, "OR")
72
80
  unless or_groups
73
81
  warn_rule_not_valid("RuleManager#is_rule_matched", log_entry)
@@ -76,7 +84,7 @@ module ConvertSdk
76
84
 
77
85
  match = false
78
86
  or_groups.each do |and_group|
79
- match = process_and(data, and_group)
87
+ match = process_and(data, and_group, resolver)
80
88
  return true if match == true
81
89
 
82
90
  log_outcome("RuleManager#is_rule_matched", match, log_entry)
@@ -90,7 +98,7 @@ module ConvertSdk
90
98
 
91
99
  # AND block: every OR_WHEN leaf-list must return true. Returns the first
92
100
  # non-true result (false or a sentinel short-circuits). rule-manager.ts:191-220.
93
- def process_and(data, and_group)
101
+ def process_and(data, and_group, resolver)
94
102
  leaves = nonempty_block(and_group, "AND")
95
103
  unless leaves
96
104
  warn_rule_not_valid("RuleManager#process_and")
@@ -98,7 +106,7 @@ module ConvertSdk
98
106
  end
99
107
 
100
108
  leaves.each do |or_when|
101
- match = process_or_when(data, or_when)
109
+ match = process_or_when(data, or_when, resolver)
102
110
  return match if match != true
103
111
  end
104
112
  @log_manager&.debug("RuleManager#process_and: AND block matched")
@@ -107,7 +115,7 @@ module ConvertSdk
107
115
 
108
116
  # OR_WHEN block: the first matching leaf wins. After the loop, returns the
109
117
  # last result unless false (so a sentinel propagates). rule-manager.ts:229-255.
110
- def process_or_when(data, or_when)
118
+ def process_or_when(data, or_when, resolver)
111
119
  leaves = nonempty_block(or_when, "OR_WHEN")
112
120
  unless leaves
113
121
  warn_rule_not_valid("RuleManager#process_or_when")
@@ -116,7 +124,7 @@ module ConvertSdk
116
124
 
117
125
  match = false
118
126
  leaves.each do |rule|
119
- match = process_rule_item(data, rule)
127
+ match = process_rule_item(data, rule, resolver)
120
128
  return true if match == true
121
129
  end
122
130
  return match if match != false
@@ -124,11 +132,20 @@ module ConvertSdk
124
132
  false
125
133
  end
126
134
 
127
- # A single leaf rule. Validates shape, resolves the operator from the
128
- # comparison processor's dispatch map, and evaluates it against the matching
129
- # data value or against {Comparisons::UNDEFINED} for an absent key under an
130
- # existence operator. rule-manager.ts:264-365.
131
- def process_rule_item(data, rule)
135
+ # A single leaf rule. A +bucketed_into_experience_key+ leaf (qs-04 mutual
136
+ # exclusion) is dispatched to {#process_mutual_exclusion_rule} BEFORE the
137
+ # generic shape validation / comparison-operator dispatch below it never
138
+ # consults +match_type+ or +@comparisons+ (vestigial for this rule_type).
139
+ # Every other leaf takes the unchanged generic path: validates shape,
140
+ # resolves the operator from the comparison processor's dispatch map, and
141
+ # evaluates it against the matching data value — or against
142
+ # {Comparisons::UNDEFINED} for an absent key under an existence operator.
143
+ # rule-manager.ts:264-365.
144
+ def process_rule_item(data, rule, resolver)
145
+ if rule.is_a?(Hash) && rule["rule_type"] == "bucketed_into_experience_key"
146
+ return process_mutual_exclusion_rule(rule, resolver)
147
+ end
148
+
132
149
  unless valid_rule?(rule)
133
150
  warn_rule_not_valid("RuleManager#process_rule_item")
134
151
  return false
@@ -147,6 +164,48 @@ module ConvertSdk
147
164
  evaluate_leaf(data, rule, match_type, method, negation)
148
165
  end
149
166
 
167
+ # The qs-04 mutual-exclusion leaf (+bucketed_into_experience_key+): resolves
168
+ # against the injected +resolver+ — a pure +->(target_experience_key) { true
169
+ # | false | nil }+ function of the target key — never against
170
+ # +@comparisons+; +match_type+ is vestigial and is never consulted for this
171
+ # rule_type.
172
+ #
173
+ # * No resolver threaded through the call at all (+resolver+ is +nil+):
174
+ # fail closed to +false+, negation UNAPPLIED (the capability is entirely
175
+ # absent, distinct from "resolver present but target unknown" below).
176
+ # * Resolver present, target key unresolvable (+resolver.call+ returns
177
+ # +nil+): warn naming the unresolved key, treat +bucketed_raw+ as +false+,
178
+ # THEN apply negation like any other known outcome.
179
+ # * Resolver present, target key resolved to +true+/+false+: that is
180
+ # +bucketed_raw+; negation applies on top of it as usual.
181
+ # * +rule["matching"]+ is malformed — a non-Hash, non-+nil+ value (e.g. a
182
+ # stray +match_type+ string) OR the +matching+ key missing entirely: this
183
+ # leaf skips +valid_rule?+ (unlike the generic path), so the malformed
184
+ # shape is guarded here directly. Fails closed to +false+, negation
185
+ # UNAPPLIED, and the resolver is NEVER invoked — mirrors the "no resolver
186
+ # injected" case's fail-closed spirit for a leaf that cannot be trusted.
187
+ def process_mutual_exclusion_rule(rule, resolver)
188
+ return false unless resolver
189
+
190
+ matching = rule["matching"]
191
+ return false unless matching.is_a?(Hash)
192
+
193
+ negated = matching["negated"] || false
194
+ target_key = rule["value"]
195
+ raw = resolver.call(target_key)
196
+
197
+ if raw.nil?
198
+ @log_manager&.warn(
199
+ "RuleManager#process_mutual_exclusion_rule: unresolved mutual-exclusion target #{target_key.inspect}"
200
+ )
201
+ bucketed_raw = false
202
+ else
203
+ bucketed_raw = raw == true
204
+ end
205
+
206
+ negated ? !bucketed_raw : bucketed_raw
207
+ end
208
+
150
209
  # Resolve the data value for the leaf's key and invoke the operator. Iterates
151
210
  # the data keys honoring +keys_case_sensitive+; on a key match invokes the
152
211
  # operator with the data value. On no key match, an existence operator is
@@ -44,6 +44,16 @@ module ConvertSdk
44
44
  # (Story 2.1) into +StoreData["segments"]+. Stored data is string-keyed
45
45
  # wire-world by design (so Epic 4's payload builder needs zero translation).
46
46
  #
47
+ # == Zero-trace preview gate (qs-03 / RB-6)
48
+ #
49
+ # {#put_segments} and {#select_custom_segments} accept an +enable_storage:+
50
+ # keyword (default +true+) that suppresses ONLY the persistence write above —
51
+ # filtering / rule MATCHING is never affected. {Context#set_default_segments}
52
+ # and {Context#run_custom_segments} thread +!@preview+ through it so a
53
+ # preview-active context leaves zero trace in the store while still
54
+ # evaluating segment rules normally (mirrors JS SDK-6,
55
+ # +segments-manager.ts:74-174+).
56
+ #
47
57
  # @api private
48
58
  class SegmentsManager
49
59
  # The +customSegments+ wire key — byte-identical to JS
@@ -89,12 +99,18 @@ module ConvertSdk
89
99
  #
90
100
  # @param visitor_id [String]
91
101
  # @param segments [Hash] the candidate report-segments (string-keyed wire shape).
102
+ # @param enable_storage [Boolean] when +false+, suppress ONLY the persistence
103
+ # write below — the filtering above still runs identically. Threaded by
104
+ # {Context#set_default_segments} as +!@preview+ (qs-03 / RB-6 zero-trace);
105
+ # mirrors JS +SegmentsManager#putSegments+'s +enableStorage+ param
106
+ # (JS SDK-6, +segments-manager.ts:74-89+). Defaults to +true+ (every
107
+ # pre-qs-03 caller is unaffected).
92
108
  # @return [void]
93
- def put_segments(visitor_id, segments)
109
+ def put_segments(visitor_id, segments, enable_storage: true)
94
110
  report_segments = filter_report_segments(segments)
95
111
  return if report_segments.empty?
96
112
 
97
- merge_segments(visitor_id, report_segments)
113
+ merge_segments(visitor_id, report_segments) if enable_storage
98
114
  nil
99
115
  end
100
116
 
@@ -113,11 +129,17 @@ module ConvertSdk
113
129
  # @param segment_rule [Hash, nil] the visitor data the segment rules match
114
130
  # against; +nil+ attaches every resolved segment unconditionally (JS:
115
131
  # +if (!segmentRule || segmentsMatched)+).
132
+ # @param enable_storage [Boolean] when +false+, suppress ONLY the eventual
133
+ # {#put_segments} persistence write — rule MATCHING (and a propagated
134
+ # {RuleError}) is unaffected. Threaded by {Context#run_custom_segments} as
135
+ # +!@preview+ (qs-03 / RB-6 zero-trace); mirrors JS
136
+ # +SegmentsManager#selectCustomSegments+'s +enableStorage+ param
137
+ # (JS SDK-6, +segments-manager.ts:156-174+). Defaults to +true+.
116
138
  # @return [Hash, Sentinel, nil] the updated segments hash, a propagated
117
139
  # {RuleError}, or +nil+ when nothing matched.
118
- def select_custom_segments(visitor_id, segment_keys, segment_rule = nil)
140
+ def select_custom_segments(visitor_id, segment_keys, segment_rule = nil, enable_storage: true)
119
141
  segments = lookup_segments(segment_keys)
120
- set_custom_segments(visitor_id, segments, segment_rule)
142
+ set_custom_segments(visitor_id, segments, segment_rule, enable_storage: enable_storage)
121
143
  end
122
144
 
123
145
  private
@@ -163,8 +185,9 @@ module ConvertSdk
163
185
  # For each resolved segment: when a rule is supplied and nothing has matched
164
186
  # yet, walk the segment's rules — a {RuleError} sentinel short-circuits and
165
187
  # propagates out. On a match (or when no rule was supplied) append the segment
166
- # id unless already stored. A non-empty append is persisted via {#put_segments}.
167
- def set_custom_segments(visitor_id, segments, segment_rule)
188
+ # id unless already stored. A non-empty append is persisted via {#put_segments}
189
+ # (gated on +enable_storage+ — see {#select_custom_segments}).
190
+ def set_custom_segments(visitor_id, segments, segment_rule, enable_storage: true)
168
191
  existing = stored_custom_segments(visitor_id)
169
192
  segment_ids = [] #: Array[String]
170
193
  matched = false #: (bool | Sentinel)
@@ -180,7 +203,7 @@ module ConvertSdk
180
203
  append_segment_id(segment, existing, segment_ids)
181
204
  end
182
205
 
183
- persist_custom_segments(visitor_id, existing, segment_ids)
206
+ persist_custom_segments(visitor_id, existing, segment_ids, enable_storage: enable_storage)
184
207
  end
185
208
 
186
209
  # Append +segment["id"]+ (stringified) to the pending list unless it is already
@@ -199,15 +222,16 @@ module ConvertSdk
199
222
 
200
223
  # Persist the newly-matched ids (appended to the existing list) into
201
224
  # +StoreData["segments"]["customSegments"]+, or no-op + debug when nothing
202
- # matched (JS +SEGMENTS_NOT_FOUND+). Returns the persisted segments hash or nil.
203
- def persist_custom_segments(visitor_id, existing, segment_ids)
225
+ # matched (JS +SEGMENTS_NOT_FOUND+). Returns the persisted segments hash or
226
+ # nil. +enable_storage+ passes straight through to {#put_segments}.
227
+ def persist_custom_segments(visitor_id, existing, segment_ids, enable_storage: true)
204
228
  if segment_ids.empty?
205
229
  @log_manager&.debug("SegmentsManager#set_custom_segments: no segments matched")
206
230
  return nil
207
231
  end
208
232
 
209
233
  segments_data = { CUSTOM_SEGMENTS => existing + segment_ids }
210
- put_segments(visitor_id, segments_data)
234
+ put_segments(visitor_id, segments_data, enable_storage: enable_storage)
211
235
  segments_data
212
236
  end
213
237
 
@@ -10,5 +10,5 @@ module ConvertSdk
10
10
  # derives its version from this run's git tag, not from this file (FR66).
11
11
  # Mirrors the Android SDK's `0.0.0` placeholder in gradle/libs.versions.toml.
12
12
  # @return [String]
13
- VERSION = "1.0.1"
13
+ VERSION = "2.0.0"
14
14
  end
data/lib/convert_sdk.rb CHANGED
@@ -78,6 +78,42 @@ module ConvertSdk
78
78
  @at_exit_registration_enabled = value
79
79
  end
80
80
 
81
+ # Numeric-only segment pattern used to validate both the experience id and
82
+ # variation id halves of a preview param value (qs-03 AC9).
83
+ # @api private
84
+ PREVIEW_PARAM_NUMERIC_ONLY = /\A\d+\z/
85
+
86
+ # Parse a preview link param value of the form
87
+ # +"{experienceId}.{variationId}"+ (mirrors the web tracking script's force
88
+ # param +_conv_eforce={experienceId}.{variationId}+ and the JS SDK's
89
+ # +parsePreviewParam+ — javascript-sdk packages/js-sdk/src/parse-preview-param.ts).
90
+ #
91
+ # Splits on the FIRST dot only; both segments must be non-empty, numeric-only
92
+ # strings. Any other shape (missing dot, extra dot, empty segment,
93
+ # non-numeric segment, non-String input) returns +nil+.
94
+ #
95
+ # PURE: never raises, no side effects, no logging. Ruby-specific return
96
+ # contract (qs-03): a 2-element String array on success, not the JS oracle's
97
+ # +{experienceId, variationId}+ object shape.
98
+ #
99
+ # @param value [Object] expected to be a +String+; anything else returns +nil+.
100
+ # @return [Array(String, String), nil]
101
+ def self.parse_preview_param(value)
102
+ return nil unless value.is_a?(String)
103
+
104
+ dot_index = value.index(".")
105
+ return nil if dot_index.nil?
106
+ return nil if value.index(".", dot_index + 1)
107
+
108
+ experience_id = value[0...dot_index] #: String
109
+ variation_id = value[(dot_index + 1)..] #: String
110
+
111
+ return nil unless PREVIEW_PARAM_NUMERIC_ONLY.match?(experience_id)
112
+ return nil unless PREVIEW_PARAM_NUMERIC_ONLY.match?(variation_id)
113
+
114
+ [experience_id, variation_id]
115
+ end
116
+
81
117
  # Build an SDK client from an SDK key (live config fetch) or a pre-fetched
82
118
  # +data:+ object (direct data mode). THE public entry point.
83
119
  #
@@ -199,6 +235,11 @@ module ConvertSdk
199
235
  # DataManager is config-read-only). The account/project resolvers are left to
200
236
  # the DataManager's own readers (its constructor defaults to +#account_id+ /
201
237
  # +#project_id+) — the live config IS the source of those store-key halves here.
238
+ # +config_cache_disabled+ is derived from +!config.debug_token.nil?+ (qs-03,
239
+ # Ruby-SDK-only hardening — NOT a JS-parity concern): while a debug token is
240
+ # configured, the config-cache WRITE is suppressed so a shared store cannot
241
+ # be poisoned with a debug-widened config that a production reader might
242
+ # later pick up.
202
243
  # @api private
203
244
  def self.build_data_manager(config, log_manager, data_store_manager, clock,
204
245
  bucketing_manager, rule_manager)
@@ -211,6 +252,7 @@ module ConvertSdk
211
252
  ttl: config.data_refresh_interval,
212
253
  bucketing_manager: bucketing_manager,
213
254
  rule_manager: rule_manager,
255
+ config_cache_disabled: !config.debug_token.nil?,
214
256
  **clock_option
215
257
  )
216
258
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: convert_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Convert Insights, Inc.